AFI parity: close all 35 gaps — every adapter wires every AFI-common capability

The conformance board (tests/afi/test_capability_parity.py) is now fully green:
90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The
gaps the prose table used to launder as "Django-only" / "out of scope" are
wired, against the pinned-spec model (single-authored spec, byte-identical
conformance across languages) — never per-language reimplementation.

FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest),
WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic
SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes
(SQLAlchemy projection, same declaration surface as django-readers), Forms
(Pydantic schema/validate/submit).

Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth=
enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC
subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT
mint+verify and cache-key derivation byte-pinned to the Python reference
(cache_keys_pin, token_pin, invalidate_header_pin).

TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS
backend can feed the codegen — the largest gap), multipart upload, session-init,
WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms.

Verified in the merged tree: core 25, fastapi 74, django 353/21-skip,
mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 13:44:35 -04:00
parent 58d2cb2848
commit 6c5f6f1fba
81 changed files with 9893 additions and 463 deletions

View File

@@ -0,0 +1,161 @@
"""
Edge-manifest derivation — the AFI-common source of truth.
The Edge manifest is a static JSON mapping contexts to URL patterns, params, and
cache/render policy. Mizan Edge reads it at deploy time to drive CDN cache
purging: when it receives `X-Mizan-Invalidate: user;user_id=5` it looks up
`user` in the manifest, resolves the page routes with the params, and purges
both the resolved URLs and the context endpoint.
The manifest is *derived from the registry* — the same `@client` metadata every
adapter populates — so its derivation is AFI-common, not framework-bound. It
lives here in the core; each adapter exposes it (a callable, a CLI entry) over
its own surface. Django's `export_edge_manifest` command and the FastAPI
console entry both call `generate_edge_manifest`; there is one derivation.
`render_strategy` is computed here too: a context whose params overlap
`USER_SCOPED_PARAMS` is `dynamic_cached` (per-user at the edge); one whose
params don't is `psr` (one shared pre-rendered artifact, re-rendered on
mutation). That single rule is what the `psr` capability checks for.
"""
from __future__ import annotations
import json
from typing import Any
from mizan_core.registry import get_context_groups, get_function, get_all_functions
__all__ = [
"USER_SCOPED_PARAMS",
"generate_edge_manifest",
"generate_edge_manifest_json",
]
# A context is per-user (and so must be `dynamic_cached` at the edge) when any of
# its params identifies a user. A context with no such param renders one shared
# artifact — `psr`. This set is the entire `render_strategy` decision.
USER_SCOPED_PARAMS: frozenset[str] = frozenset({"user_id", "user", "owner_id", "account_id"})
def _input_param_names(fn_cls: Any) -> set[str]:
"""The declared input field names of a registered function (empty if none)."""
input_cls = getattr(fn_cls, "Input", None)
if input_cls is not None and hasattr(input_cls, "model_fields"):
return set(input_cls.model_fields.keys())
return set()
def generate_edge_manifest(
base_url: str = "/api/mizan",
view_urls: dict[str, list[str]] | None = None,
) -> dict[str, Any]:
"""Derive the Edge manifest from the function registry.
Args:
base_url: The Mizan API mount point (default ``/api/mizan``).
view_urls: Optional extra page routes per context for Edge to purge,
beyond the routes declared on view-path functions.
Returns:
A JSON-serializable manifest: ``{"version", "contexts", "mutations"}``.
"""
groups = get_context_groups()
all_functions = get_all_functions()
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
for ctx_name, fn_names in sorted(groups.items()):
param_names: set[str] = set()
functions_meta: list[dict[str, Any]] = []
page_routes: list[str] = []
for fn_name in fn_names:
fn_cls = all_functions.get(fn_name)
if fn_cls is None:
continue
param_names |= _input_param_names(fn_cls)
meta = getattr(fn_cls, "_meta", {})
route = meta.get("route")
view_path = meta.get("view_path")
fn_entry: dict[str, Any] = {
"name": fn_name,
"path": "view" if view_path else "rpc",
}
if route:
fn_entry["route"] = route
fn_entry["methods"] = meta.get("methods", ["GET"])
page_routes.append(route)
if meta.get("rev"):
fn_entry["rev"] = meta["rev"]
if meta.get("cache") is not None and meta.get("cache") is not True:
fn_entry["cache"] = meta["cache"]
functions_meta.append(fn_entry)
user_scoped = any(p in USER_SCOPED_PARAMS for p in param_names)
ctx_entry: dict[str, Any] = {
"functions": functions_meta,
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
"params": sorted(param_names),
"user_scoped": user_scoped,
"render_strategy": "dynamic_cached" if user_scoped else "psr",
}
if page_routes:
ctx_entry["page_routes"] = page_routes
if view_urls and ctx_name in view_urls:
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
manifest["contexts"][ctx_name] = ctx_entry
for fn_name, fn_cls in sorted(all_functions.items()):
meta = getattr(fn_cls, "_meta", {})
if not meta.get("affects"):
continue
affected_contexts = list({a["name"] for a in meta["affects"]})
mutation: dict[str, Any] = {"affects": affected_contexts}
# Auto-scoped params — function params that match a param of an affected
# context. These are the keys Edge can resolve to scope the purge.
fn_params = _input_param_names(fn_cls)
if fn_params:
auto_scoped: list[str] = []
for ctx_name in affected_contexts:
ctx_param_names: set[str] = set()
for ctx_fn_name in groups.get(ctx_name, []):
ctx_fn_cls = all_functions.get(ctx_fn_name)
if ctx_fn_cls is not None:
ctx_param_names |= _input_param_names(ctx_fn_cls)
for p in fn_params:
if p in ctx_param_names and p not in auto_scoped:
auto_scoped.append(p)
if auto_scoped:
mutation["auto_scoped_params"] = sorted(auto_scoped)
if meta.get("private"):
mutation["private"] = True
if meta.get("route"):
mutation["route"] = meta["route"]
mutation["methods"] = meta.get("methods", ["POST"])
manifest["mutations"][fn_name] = mutation
return manifest
def generate_edge_manifest_json(
base_url: str = "/api/mizan",
view_urls: dict[str, list[str]] | None = None,
indent: int | None = 2,
) -> str:
"""JSON-serialize the Edge manifest (keys sorted for deterministic output)."""
return json.dumps(
generate_edge_manifest(base_url, view_urls), indent=indent, sort_keys=True
)

View File

@@ -5,12 +5,11 @@ sub-registry (channels/WebSocket, forms, shapes) to plug into.
This is the framework-agnostic registry. The extension points
(channels, forms, websockets, shapes) are AFI-common: every adapter owes
a binding for each, and registers it here so the unified schema export
sees it. Django binds all of them today; the other adapters' unbound
extensions are gaps tracked by the capability-parity suite in
`tests/afi/`, not framework-specific features. The binding is per-stack
(Django Channels vs. native WebSocket, Django Forms vs. Pydantic); the
capability is common.
a binding for each, on its own stack — Django Channels or a native
WebSocket route; Django Forms or Pydantic; django-readers or the project's
ORM. The capability is common; the binding is per-stack. Each adapter wires
its binding so the unified schema export sees it; an unwired one is a gap on
the capability-parity board (`tests/afi/`), not a framework-specific feature.
"""
from __future__ import annotations

View File

@@ -0,0 +1,12 @@
"""
mizan_core.ssr — framework-agnostic server-side rendering.
`SSRBridge` manages a persistent Bun subprocess that renders React components to
HTML over JSON-RPC. It is the single source for the SSR subprocess lifecycle;
adapters wrap it over their own surface (Django's `MizanTemplates`, FastAPI's
`SSRRenderer`).
"""
from mizan_core.ssr.bridge import RenderResult, SSRBridge
__all__ = ["SSRBridge", "RenderResult"]

View File

@@ -0,0 +1,186 @@
"""
SSR Bridge — manages a persistent Bun subprocess for React rendering.
Framework-agnostic (no web-framework imports): the bridge spawns the Bun worker,
speaks the JSON-RPC protocol, and returns rendered HTML. Each adapter wraps it
over its own surface — Django's `MizanTemplates` template backend, FastAPI's SSR
render path — so the subprocess lifecycle and wire protocol are authored once.
Protocol: newline-delimited JSON-RPC over stdin/stdout.
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
Response: {"id": 1, "html": "<div>...</div>"}
The subprocess stays alive across requests. It is started on first use
and restarted automatically if it crashes.
"""
from __future__ import annotations
import atexit
import json
import logging
import subprocess
import threading
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger("mizan.ssr")
@dataclass
class RenderResult:
"""Result of an SSR render call."""
html: str
class SSRBridge:
"""
Manages a persistent Bun subprocess for server-side rendering.
Thread-safe. Multiple worker threads can call render() concurrently.
Request-response matching via message IDs.
"""
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
self._worker_path = worker_path
self._timeout = timeout
self._proc: subprocess.Popen | None = None
self._lock = threading.Lock()
self._write_lock = threading.Lock() # Serializes stdin writes
self._counter = 0
self._pending: dict[int, threading.Event] = {}
self._results: dict[int, dict] = {}
self._reader_thread: threading.Thread | None = None
self._ready = threading.Event()
# Ensure cleanup on process exit
atexit.register(self.shutdown)
def _ensure_running(self) -> None:
"""Start the Bun subprocess if it's not running."""
if self._proc is not None and self._proc.poll() is None:
return
if self._proc is not None:
logger.warning("Bun SSR worker died (exit code %s), restarting", self._proc.returncode)
self._ready.clear()
self._proc = subprocess.Popen(
["bun", "run", self._worker_path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self._reader_thread = threading.Thread(
target=self._read_responses, daemon=True, name="mizan-ssr-reader",
)
self._reader_thread.start()
# Wait for the "ready" signal from the worker
if not self._ready.wait(timeout=self._timeout):
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
self.shutdown()
raise TimeoutError("SSR worker failed to start")
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
def _read_responses(self) -> None:
"""Background thread that reads JSON responses from stdout."""
try:
for line in self._proc.stdout:
if isinstance(line, bytes):
line = line.decode("utf-8")
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
logger.warning("Malformed JSON from SSR worker: %s", line[:200])
continue
msg_id = msg.get("id")
# Ready signal (id=0)
if msg_id == 0 and msg.get("ready"):
self._ready.set()
continue
if msg_id is not None and msg_id in self._pending:
self._results[msg_id] = msg
self._pending[msg_id].set()
except Exception:
logger.warning("SSR reader thread exited", exc_info=True)
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
"""
Render a React component to HTML.
Args:
file: Absolute path to the .tsx/.jsx file to render.
props: Props to pass to the component.
Returns:
RenderResult with the HTML string.
Raises:
TimeoutError: If the render takes longer than the configured timeout.
RuntimeError: If the render fails.
"""
with self._lock:
self._ensure_running()
self._counter += 1
msg_id = self._counter
event = threading.Event()
self._pending[msg_id] = event
request = json.dumps({
"id": msg_id,
"method": "render",
"params": {"file": file, "props": props or {}},
}) + "\n"
# Serialize stdin writes to prevent interleaving from concurrent threads
with self._write_lock:
try:
self._proc.stdin.write(request.encode("utf-8"))
self._proc.stdin.flush()
except (BrokenPipeError, OSError) as e:
self._pending.pop(msg_id, None)
raise RuntimeError(f"SSR worker pipe broken: {e}")
if not event.wait(self._timeout):
self._pending.pop(msg_id, None)
raise TimeoutError(
f"SSR render of '{file}' timed out after {self._timeout}s"
)
self._pending.pop(msg_id, None)
result = self._results.pop(msg_id)
if "error" in result:
raise RuntimeError(f"SSR render failed: {result['error']}")
return RenderResult(html=result["html"])
def shutdown(self) -> None:
"""Stop the Bun subprocess."""
if self._proc is not None:
try:
self._proc.stdin.close()
except Exception:
pass
try:
self._proc.terminate()
self._proc.wait(timeout=3)
except Exception:
try:
self._proc.kill()
except Exception:
pass
self._proc = None
logger.info("Bun SSR worker stopped")