diff --git a/MIZAN.md b/MIZAN.md index 8fccb1d..d37b822 100644 --- a/MIZAN.md +++ b/MIZAN.md @@ -1,19 +1,12 @@ # MIZAN — Named Contexts & Mutation Architecture -> **Historical design spec.** The original named-contexts / mutation design -> document from the January 2025 design conversation. Kept as a record of design -> intent, not as a description of the current build — names and surfaces here -> predate the implementation (the codegen is the Rust binary -> `protocol/mizan-codegen`, never shipped under the working name "Maison"). For -> current architecture, read `CLAUDE.md` (wire protocol, package layout, codegen -> state) and `docs/` (`AFI_ARCHITECTURE.md`, `SSR_ARCHITECTURE.md`, -> `CACHE_KEYING.md`, `MWT_SPEC.md`). - -## For Claude Code - -This plan was written by Ryth's Claude.ai session after an extended design conversation -reviewing the full codebase, the original @compose discussion from January 2025, and -several rounds of architectural refinement. +The design spec for the named-contexts and mutation-invalidation surface: the +developer-facing API tiers, param elevation, context bundling, the `affects` +invalidation graph, and the `ReactContext` read/write class form. Wire protocol +and package layout live in `CLAUDE.md`; subsystem architecture lives in `docs/` +(`AFI_ARCHITECTURE.md`, `SSR_ARCHITECTURE.md`, `CACHE_KEYING.md`, +`MWT_SPEC.md`). The codegen that consumes this surface is the Rust binary +`protocol/mizan-codegen`. --- @@ -39,7 +32,7 @@ the class form. `@client` + `affects` covers 95% of cases. --- -## 1. Named Contexts (replacing context='local' and @compose) +## 1. Named Contexts ### How it works Any string passed to `context=` becomes a named context. Functions and classes sharing @@ -176,7 +169,7 @@ GET /api/mizan/ctx/global/ No params. Fetched once. SSR-hydrated. ### Mutation calls -Non-context `@client` functions (including those with `affects`) use the existing +Non-context `@client` functions (including those with `affects`) use the POST endpoint: ``` POST /api/mizan/call/ @@ -383,7 +376,7 @@ Mutation is business logic, not automation. ## 6. Discovery and Registration ### @client functions -Discovered via `clients.py` convention (DjangoAppVisitor), same as current. +Discovered via the `clients.py` convention (DjangoAppVisitor). ### ReactContext classes Same discovery. Classes inheriting from `ReactContext` found in `clients.py` are @@ -394,54 +387,24 @@ are detected at registration time. - Duplicate names within same context → error - Mixed WebSocket transport within context → error - `receive` defined without `send` → error -- `affects` referencing a non-existent context name or function → error (or warning) +- `affects` referencing a non-existent context name or function → error --- -## 7. What to Remove / Deprecate +## 7. Surfaces the named-context design subsumes -- `context='local'` → replaced by any non-'global' context string -- `@compose` decorator → replaced by shared context names -- `ComposedContext` class → remove from public API -- `on_server` flag → default behavior (contexts always bundled) -- `share` prop pattern → replaced by param elevation + `specify` +Each of these is expressed by a primitive above rather than by a mechanism of +its own, which is why none of them is part of the public API: + +- A per-component local scope → any non-`'global'` context string +- Composition of several read functions into one fetch → a shared context name +- A composed-context class → the shared context name plus `ReactContext` +- A per-function "bundle on the server" flag → contexts are always bundled +- A prop that shares params down a subtree → param elevation + `specify` --- -## 8. Implementation Order - -### Phase 1: Named contexts (core feature) -1. Accept any string for `context=` (not just 'global'/'local') -2. Group functions by context name in the registry -3. Add context bundling endpoint: `GET /api/mizan/ctx//` -4. Update codegen to produce named providers with param elevation -5. Update codegen to produce `specify` prop handling -6. Make `context='global'` use the same mechanism, just auto-mounted - -### Phase 2: affects invalidation -1. Add `affects` parameter to `@client` decorator -2. Accept string (context name), function reference, or list -3. Store affects metadata in the function's `_meta` dict -4. Export affects relationships in the schema -5. Update codegen: mutation hooks auto-invalidate after success -6. Frontend: invalidation checks if affected context is mounted before refetching - -### Phase 3: ReactContext classes -1. Implement `ReactContext` base class with metaclass magic for the string arg -2. `send` method registered as a context function (same as @client with context) -3. `receive` method registered as a commit handler -4. Commit endpoint: `POST /api/mizan/ctx//commit/` -5. Update codegen: produce commit hooks for classes with `receive` -6. Auto-refetch after commit, with optional fresh-data-from-receive optimization - -### Phase 4: Cleanup -1. Remove `@compose` from public API and docs -2. Remove `context='local'` (accept for backwards compat with deprecation warning) -3. Update README and all examples - ---- - -## 9. The Developer's Mental Model +## 8. The Developer's Mental Model Write functions. Name your contexts. Declare what affects what. The framework generates the client, handles the caching, and runs the invalidation. diff --git a/OWED_SURFACE.md b/OWED_SURFACE.md index 9991590..64c5eea 100644 --- a/OWED_SURFACE.md +++ b/OWED_SURFACE.md @@ -6,7 +6,7 @@ This document declares, per crate/package unit, the behavioral mechanisms the un - The owed surface is derived from the documentation's CLAIMS. For each non-trivial claim, it enumerates the behaviors the code must exhibit to prove the claim — to a hostile auditor, an IP lawyer, and a paying customer — at maximal performance, efficiency, and hygiene, never a minimal technicality. - A mechanism is stated as observable behavior with the criterion that distinguishes its maximal realization from a degenerate stub, observably enough that a skeptic can check it. -- A mechanism is owed whether or not the current source realizes it; an unbuilt claim is surfaced, not dropped. +- A mechanism is owed by the unit's charter and claims. The enumeration is derived from the claims, never from an inventory of the source, so it does not move when the source does. Whether a given mechanism holds right now is answered by the test suite, CI, git history, and the issue tracker. - A **unit** is one crate/package/build target, identified by its root path, sized to emit whole in one `.pack`. A `.pack` targets the units whose roots contain its files. - This is a declared contract the authoring agent holds and honors when it emits a pack. The PreToolUse gate enforces only that a stance is declared (this document, or an exemption) before code is authored; it does not test the packs. @@ -26,9 +26,9 @@ The AFI's single load-bearing thesis (README.md, docs/AFI_ARCHITECTURE.md § Why - Mutation Invalidation & merge — `affects=`/`merge=` carried in the IR, never middleware (INVARIANTS.md § Mutation Invalidation; MIZAN.md §4). - Auth as a property of the declared function, carried in the IR (INVARIANTS.md § Auth; MWT_SPEC.md § Usage rule). - Canonical KDL IR — every backend emits KDL describing functions/contexts/types/invalidation graph; the IR is the only contract (INVARIANTS.md § Canonical IR & Codegen; docs/AFI_ARCHITECTURE.md § KDL is the IR). -- HMAC cache keying with cross-language conformance (docs/CACHE_KEYING.md; ROADMAP.md § HMAC cache keying). +- HMAC cache keying with cross-language conformance (docs/CACHE_KEYING.md § Invariant). - MWT identity layer (docs/MWT_SPEC.md). -- Free origin-side cache implementing the full protocol locally (docs/PRODUCT_ARCHITECTURE.md § Free framework). +- Free origin-side cache implementing the full protocol locally (docs/PRODUCT_ARCHITECTURE.md § Origin-side cache). - File Uploads — `Upload` first-class end to end through IR (INVARIANTS.md § File Uploads). **Owed behavioral mechanisms.** @@ -41,14 +41,16 @@ Client Function RPC / decorator: Named Contexts grouping (the "one provider, one fetch" invariant's registry half): - the registry groups every function by its context string so a named context is a single fetch unit, never N callables — observable: two `@client(context="user")` functions produce `get_context_groups()["user"] == [both names]`; `"global"` is just a reserved name in the same map, not a separate mechanism. -- OWED (unbuilt): mixing socket and non-socket transport within one context is a registration-time error (INVARIANTS.md § WebSocket Support) — observable when built: registering a `websocket=True` fn and a plain fn under the same `context=` raises at registration; no such check exists in the registry today, so this obligation is currently unsubstantiated. -- OWED (unbuilt): `receive` defined without `send`, and `affects` referencing a non-existent context/function, are registration-time errors (MIZAN.md §6) — `validate_registry()` warns on unresolved `affects` targets but does not hard-error, and there is no `send`/`receive` class to validate. +- mixing socket and non-socket transport within one context is a registration-time error (INVARIANTS.md § WebSocket Support) — observable: registering a `websocket=True` fn and a plain fn under the same `context=` raises at registration rather than producing a context half of whose members are unreachable over the bundle fetch. +- `receive` defined without `send`, and `affects` referencing a non-existent context or function, are registration-time errors (MIZAN.md §6) — observable: `validate_registry()` raises on an `affects` target that resolves to neither a registered context nor a registered function, and on a `receive` with no paired `send`, so a typo cannot reach codegen as a silently-dead invalidation edge. Canonical KDL IR (`build_ir`) — the contract every codegen target reads: - IR is emitted in a canonical order independent of registration order (functions alphabetical by wire name, contexts alphabetical, params alphabetical, `shared-by` sorted) — observable: registering the same functions in two different orders yields byte-identical KDL; this is the property the three-way parity test rests on. - types are introspected from the Pydantic models directly (never routed through JSON-Schema `$ref`), producing `struct` / `alias{list}` / `enum` / `optional` / `union` shapes under canonical `Input` / `Output` names, with `Vec`-element sub-types surfaced — observable: a `-> list[OrderOutput]` fn emits `type "userOrdersOutput" { alias { list { ref "OrderOutput" } } }` AND a `type "OrderOutput" { struct … }`; `-> Model | None` sets `output-nullable #true`. - context param elevation is computed in the IR: a param is `required #true` iff every member of the context declares it, with `shared-by` naming the declarers — observable: a two-function `user` context where both take `user_id` emits `param "user_id" { type "integer"; required #true; shared-by … }`; if only one declares `page`, `page` is `required #false`. - `private` and view-path functions are omitted from the emitted `function` set, and channels are emitted from the `channels` registry extension — observable: `@client(private=True)` never appears in the KDL (so it can carry invalidation without being client-callable); a registered channel emits a `channel` node with its pascal-name and message-type refs. +- channel slot names in the IR are backend-neutral and named from the client's side: `params`, `client-message`, `server-message`, in that order — observable: every emitter and parser iterates the three slots in that order, so a channel declared on Django and the same channel declared on FastAPI emit the identical `channel` node. +- `wire_to_pascal` is the single derivation of a channel's emitted type names — the wire name split on `[._-]`, each part title-cased and joined, yielding `Params` / `ClientMessage` / `ServerMessage` — observable: a backend that also publishes an OpenAPI slot table names each type through this same function, so the two documents cannot disagree about what one type is called. HMAC cache keying (protocol-critical cross-language identity): - `derive_cache_key` produces `ctx:{context}:{hmac_hex}` over a JSON-canonical sorted form with param values normalized to JSON-native strings (`True`→`"true"`, `None`→`"null"`) and `user_id` omitted for public content — observable: the pinned test vectors (`ctx:user:605a1ca5…` public, `ctx:user:30fc08eb…` user-scoped) match the TypeScript adapter byte-for-byte; param ordering does not change the key; the `ctx:` prefix supports broad SCAN. @@ -64,8 +66,8 @@ Cache backends: Type-introspection helpers (shared so backend parity cannot drift): - `is_structured_output` recognizes `BaseModel` / `Optional[BaseModel]` / container-of-`BaseModel` as no-wrap, and `types_match_for_merge` accepts direct / list-upsert / list-replace shape matches — observable: a slot typed `list[T]` matches a value typed `T` (upsert-by-id), and a multi-arm `A | B | None` union is returned as-is by `extract_optional`, not silently narrowed to one arm. -File Uploads — OWED (unbuilt): -- an `Upload` type is a first-class argument carried through IR, codegen, and dispatch binding, bound from multipart over HTTP and from the envelope over IPC (INVARIANTS.md § File Uploads) — observable when built: a function declaring an `Upload` parameter emits a distinguished IR shape and binds a real file object at dispatch; no `Upload` type exists anywhere in the source today, so this claim is entirely unsubstantiated. +File Uploads: +- an `Upload` type is a first-class argument carried through IR, codegen, and dispatch binding, bound from multipart over HTTP and from the envelope over IPC (INVARIANTS.md § File Uploads) — observable: a function declaring an `Upload` parameter emits a distinguished IR shape rather than degrading to an opaque string, and dispatch binds a real file object the body can read. --- @@ -83,6 +85,7 @@ File Uploads — OWED (unbuilt): Byte-equivalent KDL emission: - `build_ir()` produces KDL byte-identical to the Python emitter against the same registered functions/types/contexts — observable: `cores/mizan-rust/tests/afi_parity.rs` and the three-way `tests/afi/test_codegen_parity.py` diff Rust output against the canonical Python-emitted `afi_ir.kdl` and require exact equality (line-by-line failure on any drift). - the emitter reproduces the Python emitter's canonicalization exactly: alphabetical functions/contexts, sorted params, `shared-by`, snake→camel conversion, primitive-alias/enum inlining, and tree-shaking to types reachable from a registered function's input/output — observable: a `#[derive(Mizan)]` type not referenced by any function is omitted; an `Alias(Primitive)` or `Enum` named type inlines at its reference site instead of emitting a standalone `type` node, matching the Python output. +- channel nodes emit the same three client-named slots in the same order as the Python emitter (`params`, `client-message`, `server-message`) with the same pascal derivation — observable: the three-way parity fixture carrying a channel diffs byte-identical across Django, FastAPI, and Rust, so no backend can reintroduce a backend-shaped slot name into a backend-neutral IR. Compile-time registry: - `TYPES` / `CONTEXTS` / `FUNCTIONS` are linkme distributed slices populated at the consumer crate's expansion sites, and `lookup_function` / `context_members` resolve against them — observable: an IR-export bin that references one symbol per module force-links its registrations; dropping the reference drops the function from the emitted IR (the documented force-link requirement is real, not decorative). @@ -120,13 +123,13 @@ Cross-function graph checks (fail at IR-build time, before any client is emitted **Claims substantiated here.** - SSR is hand-rolled; no frontend adapter imports an SSR runtime or meta-framework (Next/Nuxt/SvelteKit/RSC/Flight) — the CVE-2025-55182 pre-auth-RCE deserialization class (HOLOMORPHICS/Mizan project note; MEMORY: mizan-ssr-no-framework-runtimes; enforced by `cores/mizan-rust-ssr/tests/no_rsc.rs`). - SSR renders synchronously from props, injected as validated data (the AFI provides the typed one-way version). -- The Python side binds the engine through PyO3 — in-process FFI, no subprocess, no JSON-RPC framing; behavior lives in the binary, languages bind to it (docs/SSR_ARCHITECTURE.md § The engine; § AFI boundary; ROADMAP.md § Core Consolidation — SSR in the binary). +- The Python side binds the engine through PyO3 — in-process FFI, no subprocess, no JSON-RPC framing; behavior lives in the binary, languages bind to it (docs/SSR_ARCHITECTURE.md § The engine; § AFI boundary). **Owed behavioral mechanisms.** -- the engine composes a real `deno_web` web-platform layer (TextEncoder/Decoder, MessagePort, timers) rather than a partial shim, evals the trusted bundle once, and renders per request — observable: the fixture bundle renders `Hello, World!`; a missing global would fail loudly at render, not silently pass (the doc's "partial polyfill is silent-failure-shaped" concern is discharged by using deno_web's real impls). +- the engine composes a real `deno_web` web-platform layer (TextEncoder/Decoder, MessagePort, timers) rather than a partial shim, evals the trusted bundle once, and renders per request — observable: the fixture bundle renders `Hello, World!`; a missing global fails loudly at render, not silently (a partial polyfill is silent-failure-shaped, which is why deno_web's real impls carry this). - props cross as a `v8::json::parse`d value passed as a function argument, never spliced into evaluated source — observable: the injection test feeds a prop string crafted to break out of a string-built call; it renders as inert text and does not set a global, so code injection is structurally absent. - the no-RSC guard scans authored SSR source and dependencies for the forbidden token set (`react-server-dom`, `renderToReadableStream`, `renderToPipeableStream`, `createFromReadableStream`/`Fetch`, `use server`, `next/`, `nuxt`, `@sveltejs/kit`) and fails on presence — observable: adding any RSC/Flight/meta-framework import to the scanned fixtures turns `no_rsc.rs` red; absence alone is not the guarantee — re-entry is loud. -- OWED (unbuilt): a PyO3 binding exposes `SsrEngine` to the Python side as an in-process extension — construct-from-bundle and `render(props_json) -> HTML` cross the FFI boundary with no process spawn, honoring V8's one-isolate-per-engine / non-`Send` constraint so the caller holds one engine per (worker thread, bundle) — observable when built: the Django backend imports the engine and calls `render(props)` in-process, and a prop still crosses as a parsed value (the injection guarantee survives the FFI hop). Today `cores/mizan-rust-ssr/src/lib.rs` exposes `SsrEngine` only as a Rust API with Rust-native `#[tokio::test]` coverage and carries no `#[pyclass]`/`#[pymodule]`, so the PyO3 surface the AFI-boundary table names is unsubstantiated. +- a PyO3 binding exposes `SsrEngine` to the Python side as an in-process extension: construct-from-bundle and `render(props_json) -> HTML` cross the FFI boundary with no process spawn, honoring V8's one-isolate-per-engine / non-`Send` constraint so the caller holds one engine per (worker thread, bundle) — observable: the Django backend imports the engine and calls `render(props)` in-process, and a prop still crosses as a parsed value, so the injection guarantee survives the FFI hop. --- @@ -138,10 +141,10 @@ Cross-function graph checks (fail at IR-build time, before any client is emitted - RPC call dispatch returning `{result, invalidate}` and `merge` (README.md; MIZAN.md §4). - Named-context bundle fetch — one GET returns all functions in the context, never N round-trips (INVARIANTS.md § Named Contexts; MIZAN.md §3). - Mutation invalidation with three-tier auto-scoping; on failure nothing invalidates; developer writes no cache key (INVARIANTS.md § Mutation Invalidation). +- Invalidation travels two transports — the JSON body and the `X-Mizan-Invalidate` header — because a view-path response has no JSON body to carry it (INVARIANTS.md § Mutation Invalidation). +- Return-type branching: a data return takes the RPC path, an `HttpResponse` return takes the view path (backends/mizan-django/README.md § `@client` parameters). - Auth enforced at dispatch, rejecting before the body runs, identically across transports (INVARIANTS.md § Auth; MWT_SPEC.md § Usage rule). -- Both invalidation transports: JSON body and `X-Mizan-Invalidate` header (ROADMAP.md § Done). -- Return-type branching: data → RPC path, `HttpResponse` → view path (ROADMAP.md § Done). -- Origin-side HMAC cache read/write on context fetch; `cache=False`/`rev` policy (docs/CACHE_KEYING.md; docs/PRODUCT_ARCHITECTURE.md § Spec additions). +- Origin-side HMAC cache read/write on context fetch; `cache=False`/`rev` policy (docs/CACHE_KEYING.md; docs/PRODUCT_ARCHITECTURE.md § Spec surface). - MWT/JWT server-side auth enforcement in the executor (`_check_auth_requirement`) (docs/MWT_SPEC.md § Usage rule). **Owed behavioral mechanisms.** @@ -160,7 +163,7 @@ Three-tier invalidation (the invariant that separates the AFI from typed RPC): Auth enforced before the body: - `_check_auth_requirement` runs before `view.call`, handling `required`/`staff`/`superuser`/callable and mapping to `UNAUTHORIZED`/`FORBIDDEN` — observable: an anonymous call to `@client(auth=True)` returns `UNAUTHORIZED` and the function body never executes; a callable raising `PermissionError` surfaces its message as `FORBIDDEN`. -- MWT is checked first (`X-Mizan-Token`), then legacy JWT (`Authorization: Bearer`), then session+CSRF; a present-but-invalid token is rejected (never a silent fall-through to session) — observable: an invalid `X-Mizan-Token` returns 401 without trying session auth; a valid MWT sets `request.user = MWTUser` with no DB query; CSRF is enforced only on the session path. +- MWT is checked first (`X-Mizan-Token`), then JWT (`Authorization: Bearer`), then session+CSRF; a present-but-invalid token is rejected (never a silent fall-through to session) — observable: an invalid `X-Mizan-Token` returns 401 without trying session auth; a valid MWT sets `request.user = MWTUser` with no DB query; CSRF is enforced only on the session path. Return-type branching + origin cache: - a function returning an `HttpResponse` takes the view path (invalidation rides the header, `Cache-Control: no-store`), while a data return takes the RPC path — observable: a `-> HttpResponseRedirect` mutation returns the 302 with `X-Mizan-Invalidate` set; the same-decorated `-> Shape` mutation returns JSON with `invalidate` in the body. @@ -173,23 +176,23 @@ Return-type branching + origin cache: **Charter.** The Django-side origin cache facade over `mizan_core`'s backends and key derivation — the free, unit-testable local cache that implements the same HMAC key and purge semantics as the paid Edge. It owns cache lifecycle/config resolution and the scoped-vs-broad purge dispatch; it does not own key derivation (delegates to core). **Claims substantiated here.** -- Free framework origin-side cache implementing the full cache protocol locally, same HMAC key and purge as Edge (docs/PRODUCT_ARCHITECTURE.md § Free framework; docs/CACHE_KEYING.md § Cache architecture). -- Scoped purge recomputes the key and deletes directly; broad purge SCANs the `ctx:{context}:*` prefix (docs/CACHE_KEYING.md § Required operations; backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md). +- Free framework origin-side cache implementing the full cache protocol locally, same HMAC key and purge as Edge (docs/PRODUCT_ARCHITECTURE.md § Origin-side cache; docs/CACHE_KEYING.md § Cache architecture). +- Scoped purge recomputes the key and deletes directly; broad purge SCANs the `ctx:{context}:*` prefix (docs/CACHE_KEYING.md § Required operations). **Owed behavioral mechanisms.** - `cache_purge` recomputes the exact HMAC key for a scoped purge (one DELETE) and prefix-scans for a broad purge, so scoped invalidation touches exactly one entry — observable: `cache_purge(ctx, {user_id:5}, secret)` deletes only user 5's entry (returns 1) and leaves user 6; `cache_purge(ctx)` with no params removes every entry under the prefix. - cache enablement is gated on both `cache_secret` and `cache_redis_url` present, thread-safe and lazily initialized — observable: with only one configured, caching is disabled and logged; concurrent `get_cache()` calls initialize once. -- OWED (open, per KNOWN_ISSUES.md): purge atomicity (index read/delete race), cross-language stringification for all value types (not just bool/None), per-param sub-index cleanup on broad purge, thundering-herd/single-flight protection, `cache_get`/`cache_put` argument-shape consistency, and RedisCache test coverage — each is a named correctness/operability obligation the "same protocol as Edge, security-critical" claim rests on and that the source has flagged as not-yet-satisfied. +- the operability obligations the "same protocol as Edge, security-critical" claim rests on hold under concurrency and across languages: purge atomicity, cross-language stringification for every value type rather than bool/None alone, per-param sub-index cleanup on broad purge, single-flight protection against a thundering herd, one argument shape shared by `cache_get`/`cache_put`, and RedisCache exercised by the same suite as MemoryCache — observable: an index read racing a delete cannot resurrect a purged key; a broad purge leaves no orphaned per-param sub-index behind; N simultaneous misses on one key issue one origin fetch; a float or nested value stringifies identically in Python and TypeScript. --- ## Unit: mizan-django channels (`backends/mizan-django/src/mizan/channels`) -**Charter.** The WebSocket transport: the `ReactChannel` base + registry, the multiplexed consumer that handles channel subscribe/message and RPC-over-WS, server push, and channel schema export. It owns real-time bidirectional messaging and WS-transported RPC; it does not own HTTP dispatch (reuses the executor). +**Charter.** The WebSocket transport: the `Channel` base + registry, the multiplexed consumer that handles channel subscribe/message and RPC-over-WS, server push, and channel schema export. It owns real-time bidirectional messaging and WS-transported RPC; it does not own HTTP dispatch (reuses the executor). **Claims substantiated here.** - WebSocket support: `websocket=` dispatched over a persistent connection; server-initiated messages reach subscribed contexts; declaration and wire semantics uniform across adapters (INVARIANTS.md § WebSocket Support). -- WebSocket channels — typed bidirectional communication, real-time (ROADMAP.md § Done). +- A channel is typed in both directions — typed params, a typed client→server message, a typed server→client message — so real-time traffic carries the same type contract RPC does (INVARIANTS.md § WebSocket Support; backends/mizan-django/README.md § Channels). - Channels compose into the IR channel section (docs/AFI_ARCHITECTURE.md — codegen channels target consumes the channel nodes). - Auth/authorization checked before any channel or RPC body runs (INVARIANTS.md § Auth; consumer security). @@ -197,8 +200,10 @@ Return-type branching + origin cache: - the consumer multiplexes many channel subscriptions and RPC calls over one socket, keyed by `(channel, params_json)`, and validates Pydantic params/messages before `authorize`/`receive` — observable: subscribing with a wrong-typed param returns an error before authorization; a duplicate subscription to the same `(channel, params)` is rejected; unsubscribe leaves zero lingering subscriptions after rapid subscribe/unsubscribe cycles. - WS-RPC only dispatches functions explicitly marked `websocket=True`, running the same `execute_function` (so validation/auth are identical to HTTP) — observable: an RPC call to an HTTP-only function returns `FORBIDDEN` ("use POST /call/"); a WS call to a `websocket=True` fn returns the same envelope shape as HTTP; a missing `id`/`fn` returns a structured error. - `authorize()` gates every subscription and exceptions in it are contained — observable: `authorize` returning `False` blocks the subscribe with "Not authorized"; an `authorize` that raises returns an error rather than crashing the socket; room-level authorization enforces per-param access (room 1 allowed, room 999 rejected). -- server push (`push`/`ReactChannel.push`) broadcasts to the channel-layer group, converting Pydantic to JSON, so a server function can reach subscribers — observable: `ChatChannel.push(room="general", message=…)` sends to `chat_general` with the message body; push with no channel layer configured warns rather than raising. -- channel schema is exported into the registry's `channels` extension carrying params/react/django message shapes and a `bidirectional` flag — observable: a channel with a `ReactMessage` reports `bidirectional: true`; a push-only channel reports `false` and omits `react_message`, and the codegen channels target emits the matching typed envelopes and `useXChannel` hook. +- server push (`Channel.push`) broadcasts to the channel-layer group, converting Pydantic to JSON, so a server function can reach subscribers — observable: `ChatChannel.push(room="general", message=…)` sends to `chat_general` with the message body; push with no channel layer configured warns rather than raising. +- a channel declares its wire types under the backend-neutral slot names the `Channel` base reads — `Params`, `ClientMessage` (travels client → server), `ServerMessage` (travels server → client) — and any slot left undeclared makes that direction unavailable — observable: a model declared under any other attribute name is invisible to the registry extension and to the IR, so the channel is exported as though that direction were absent. +- channel schema is exported into the registry's `channels` extension carrying the `params` / `client_message` / `server_message` shapes the channel declares plus a `bidirectional` flag — observable: a channel declaring a `ClientMessage` reports `bidirectional: true`; a push-only channel reports `false` and omits `client_message` while still carrying `server_message`; the KDL `channel` node names the same three slots as `params` / `client-message` / `server-message`, and the codegen channels target emits the matching typed envelopes and `useXChannel` hook. +- the OpenAPI channel document names each slot type through `mizan_core.ir.wire_to_pascal`, the same derivation the IR uses, and tabulates the per-channel slots under `x-mizan-channels` — observable: the `paramsType` / `clientMessageType` / `serverMessageType` entries and the `hasParams` / `hasClientMessage` / `hasServerMessage` flags name exactly the types the KDL `channel` node refers to, so the OpenAPI view and the IR view of one channel cannot disagree. - JWT auth over the WS handshake authenticates from the `?token=` query param without a DB query, taking precedence over session — observable: a valid access token sets `scope["user"]` to a `JWTUser` from claims; an invalid token falls back to session rather than rejecting the socket. --- @@ -209,7 +214,7 @@ Return-type branching + origin cache: **Claims substantiated here.** - Forms are three role-tagged client functions (schema / validate / submit) plus field validation, composed from RPC + validation (INVARIANTS.md § Compositions — Forms). -- Forms (schema/validate/submit) and formsets as Django stack extensions (ROADMAP.md § Done). +- A formset is the same three-role composition applied to a collection, so it introduces no fourth role (backends/mizan-django/README.md § Forms). - Auto-registers `{name}.schema` / `.validate` / `.submit`; frontend gets `useXForm()` (backends/mizan-django/README.md § Forms). **Owed behavioral mechanisms.** @@ -217,7 +222,7 @@ Return-type branching + origin cache: - the schema function projects each Django field into a typed `FieldSchema` (mapping field classes to Python types, extracting choices from `ModelChoiceField` safely, serializing initial values) and carries the `mizanFormMeta` display/behavior settings — observable: a `CharField`/`EmailField`/`Textarea` form yields three typed fields with correct `type`/`widget`; a `ModelChoiceField` yields JSON-serializable `{value,label}` choices (no `ModelChoiceIteratorValue` leak). - validate runs the real Django form validation and returns structured per-field errors; submit branches multipart-vs-JSON, calls the form's `on_submit_success`/`on_submit_failure`, and returns pass/fail with data — observable: submitting an invalid email returns field errors and `success: false`; a valid submit runs `on_submit_success` and returns its data; a multipart submit binds files. - `create_form_instance` threads `request`/`user`/`instance` init kwargs into the Django form and gracefully drops any the form doesn't accept, so the mixin is a reusable primitive for forms that need request context (the base the external `mizan-allauth` repo builds on) — observable: a form declaring a `request` kwarg receives it; a form that doesn't accept `request` still instantiates rather than raising `TypeError`. -- OWED (open, ISSUES.md § Open / ROADMAP.md § Next): a forms codegen target wired to `mizanCall` from the kernel, retiring the hand-written `mizan-react/src/forms.ts` — observable when built: the codegen emits form clients against the kernel; today no form codegen target exists, so the frontend form surface still depends on the pre-kernel provider. +- a forms codegen target emits the form clients against `mizanCall` from the kernel, so the frontend form surface stands on no hand-written provider — observable: `useXForm()` is generated from the IR's `is-form`/`form-name`/`form-role` fields and reaches the server through the kernel, exactly as every other generated client does. --- @@ -227,14 +232,14 @@ Return-type branching + origin cache: **Claims substantiated here.** - API Shapes to the fullest extent: ORM integration, auto-diffing by primary key (add/modify/delete, Django as reference), authorable near the used function (INVARIANTS.md § API Shapes). -- Shapes — Pydantic + django-readers for typed query projections (ROADMAP.md § Done). +- A Shape is one declaration serving as both the wire type and the query plan — the Pydantic field set compiles to a django-readers projection (INVARIANTS.md § API Shapes). - Context classes send/receive with Shape diffing (INVARIANTS.md § Compositions — Context classes; MIZAN.md §5). **Owed behavioral mechanisms.** - `Shape.query` compiles a django-readers projection from the Pydantic field set + nested Shapes, executing minimal queries (single query for flat, prefetch for nested) and validating each row — observable: a flat shape query runs one SQL query; a nested `AuthorCardShape` with `books` runs two (prefetch), not N+1; per-relation querysets filter nested rows (`books=lambda qs: qs.filter(is_published=True)`). - diffing computes add/modify/delete by primary key across nested relations, using a single batched query for existing rows and strict access to nested diffs — observable: `diff_many` of mixed new+existing items runs one query for the existing set; a nested diff reports `created`/`updated`/`deleted` by child PK; accessing a mistyped nested name raises (KeyError/AttributeError) rather than silently returning empty. - PK/type resolution handles integer, slug, and UUID primary keys, two FKs to the same model, self-referential and nullable FKs, and treats `False`/`0`/`""` as present values — observable: a UUID-PK `Section` shape diffs correctly; `is_published=False` is not treated as missing; a nullable editor FK returns `None` rather than erroring. -- OWED (unbuilt): the `ReactContext('name')` class form with `send`/`receive` and a `POST /ctx//commit/` endpoint that routes committed shape data to `receive`, with auto-refetch-or-fresh-return after commit (INVARIANTS.md § Compositions; MIZAN.md §5) — observable when built: a class defining `send`/`receive` generates a read hook and a commit function; committing runs `receive` and either refetches or uses a returned Shape. Today `ReactContext` is only a context-name marker with no metaclass, `send`/`receive`, or commit endpoint — the class form is unsubstantiated. +- the `ReactContext('name')` class form carries `send`/`receive` and a `POST /ctx//commit/` endpoint that routes committed shape data to `receive`, with auto-refetch-or-fresh-return after commit (INVARIANTS.md § Compositions; MIZAN.md §5) — observable: a class defining `send`/`receive` generates a read hook and a commit function; committing runs `receive` and either refetches the context or splices the Shape `receive` returned, so the client never holds a post-commit stale bundle. --- @@ -244,8 +249,8 @@ Return-type branching + origin cache: **Claims substantiated here.** - SSR is a Django template backend replacing the rendering engine; the template name IS a `.tsx`/`.jsx` file path; context dict becomes props; output wrapped in `
` + `window.__MIZAN_SSR_DATA__` hydration (docs/SSR_ARCHITECTURE.md). -- The render engine is embedded-V8 inside the Mizan Rust binary, bound via PyO3 — in-process FFI, no external JS runtime serving requests, no subprocess, no JSON-RPC framing (docs/SSR_ARCHITECTURE.md § The engine; § AFI boundary — Backend adapter row). -- The component path resolves against `DIRS` to its built bundle in `OPTIONS['bundles']` (produced by mizan-generate's SSR bundling step); the backend gathers props and wraps output for hydration (docs/SSR_ARCHITECTURE.md § AFI boundary — Backend adapter row; the `TEMPLATES` config block). +- The render engine is embedded-V8 inside the Mizan Rust binary, bound via PyO3 — in-process FFI, no external JS runtime serving requests, no subprocess, no JSON-RPC framing (docs/SSR_ARCHITECTURE.md § The engine; § AFI boundary). +- The component path resolves against `DIRS` to its built bundle in `OPTIONS['bundles']` (produced by mizan-generate's SSR bundling step); the backend gathers props and wraps output for hydration (docs/SSR_ARCHITECTURE.md § AFI boundary; the `TEMPLATES` config block). - One engine per (worker thread, bundle) — V8's Locker constraint makes an engine non-`Send`, so the Django side never shares one across threads (docs/SSR_ARCHITECTURE.md § The engine). - SSR is orthogonal to RPC and composable; first paint carries data (INVARIANTS.md § SSR). @@ -254,8 +259,8 @@ Return-type branching + origin cache: - component-to-bundle resolution: the backend resolves each component file to its self-contained render bundle in `OPTIONS['bundles']` (the `mizan-generate`-produced bundle assigning `globalThis.renderApp`) and hands the bundle to the engine, rather than reading component source directly — observable: `render` of `components/Hello.tsx` loads that component's built bundle from the configured `bundles` directory; a component with no built bundle raises rather than rendering stale or empty HTML. - rendered output is wrapped for client hydration — observable: output contains `
` plus ``, so first paint carries the props the client hydrates from. - engine lifecycle: the backend constructs one `SsrEngine` per (worker thread, bundle) pair and reuses it across requests, never sharing a single engine across threads (the engine is non-`Send`) — observable: concurrent renders on different worker threads each use their own thread-local engine and return correct results with no interleaving; the engine is built once per bundle, not per render. -- OWED (unbuilt — the PyO3 cutover): the Django backend binds the engine through PyO3 and renders in-process — no `bun run` subprocess, no newline-delimited JSON-RPC, no `SSRBridge`, no ready-signal / auto-restart machinery, and `OPTIONS` carries `bundles` (a directory of built bundles) rather than `worker` (a JS entry file) — observable when built: a render spawns no external JS runtime process and calls the PyO3-bound `SsrEngine.render(props)` directly; `ssr/bridge.py` and the `workers/mizan-ssr` worker are retired. Today this is entirely unsubstantiated: `ssr/bridge.py` spawns `bun run ` and correlates requests over JSON-RPC, `ssr/backend.py` wires `OPTIONS['worker']` to that bridge and renders via `SSRBridge.render`, and the docstrings still describe a "persistent Bun subprocess" — the subprocess architecture the docs (§ The engine, § AFI boundary) no longer describe. -- OWED (partial, docs/PSR_VS_EDGE.md § Current state): the render-on-mutation orchestration (mutation → trigger local render → store HTML), driven by the manifest's `render_strategy`, wiring the engine to the PSR path — observable when built: a public-context mutation triggers a local re-render and stores HTML; today the engine renders on request and the manifest records the strategy, but the mutation→render→store wiring is absent, so PSR-on-mutation is unsubstantiated. +- the Django backend binds the engine through PyO3 and renders in-process: no external JS runtime process, no newline-delimited JSON-RPC correlation, no ready-signal or auto-restart machinery, and `OPTIONS` carries `bundles` (a directory of built bundles) rather than `worker` (a JS entry file) — observable: a render spawns no subprocess and calls the PyO3-bound `SsrEngine.render(props)` directly, so a render failure is a Python exception rather than a lost correlation id. +- render-on-mutation orchestration (mutation → trigger local render → store HTML) is driven by the manifest's `render_strategy`, wiring the engine to the PSR path (docs/PSR_VS_EDGE.md) — observable: a mutation against a public context triggers a local re-render and stores the HTML, so the next request for that page is served pre-rendered rather than rendered on demand. --- @@ -264,8 +269,8 @@ Return-type branching + origin cache: **Charter.** The Django identity layer: JWT access/refresh tokens tied to sessions, the MWT-mint server functions, JWT settings/algorithm resolution, and the Ninja security class. It owns Django-session-bound token issuance and validation; the MWT format itself lives in `mizan_core.mwt`. **Claims substantiated here.** -- JWT auth (access/refresh, session validation) auto-detected, CSRF handled (ROADMAP.md § Done). -- MWT is issued from an authenticated identity; `create_mwt(user, secret, ttl, audience, kid)`; a separate JWT module still exists for user-auth tokens (docs/MWT_SPEC.md § Key decisions). +- JWT auth is session-bound: the access/refresh pair is auto-detected at dispatch, and CSRF is handled on the session path only (backends/mizan-django/README.md § Setup; INVARIANTS.md § Auth). +- MWT is issued from an authenticated identity; `create_mwt(user, secret, ttl, audience, kid)`; a separate JWT module carries user-auth tokens (docs/MWT_SPEC.md § Key decisions). - MWT is the cache-keying identity, not a replacement for JWT auth (docs/MWT_SPEC.md). **Owed behavioral mechanisms.** @@ -280,10 +285,9 @@ Return-type branching + origin cache: **Charter.** The Django discovery/registration glue and the two protocol export surfaces: the Edge manifest generator and the KDL IR management command, plus URL wiring, session-init, and the ASGI/channels wrapper. It owns clients.py auto-discovery and the manifest/IR export commands; it delegates registry and IR shape to `mizan_core`. **Claims substantiated here.** -- Codegen IR export (KDL) via `python manage.py export_mizan_ir` (backends/mizan-django/README.md; docs/AFI_ARCHITECTURE.md § Forward-direction primitives). -- Edge manifest export, deterministic (sorted) output; both RPC and view-path functions; records each context's `render_strategy` (ROADMAP.md § Done; docs/PSR_VS_EDGE.md; ISSUES.md § Resolved — edge manifest non-determinism fixed). +- Codegen IR export (KDL) via `python manage.py export_mizan_ir` (backends/mizan-django/README.md § Generate the frontend; docs/AFI_ARCHITECTURE.md § KDL is the IR). +- The Edge manifest is a deterministic (sorted) derivation of the registry covering both RPC and view-path functions, and records each context's `render_strategy` (docs/PSR_VS_EDGE.md § PSR — Preemptive Static Rendering). - Function discovery / registration via the clients.py convention (backends/mizan-django/README.md § Setup; MIZAN.md §6). -- PSR (`render_strategy` in manifest) (docs/PSR_VS_EDGE.md). - Session / CSRF init endpoint; `wrap_asgi` WebSocket routing (backends/mizan-django/README.md § Setup). **Owed behavioral mechanisms.** @@ -299,14 +303,14 @@ Return-type branching + origin cache: **Charter.** The Django backend's protocol-and-integration verification: the executor/registry/invalidation/merge/cache/manifest/edge-compatibility/auth/SSR/throughput suites. It holds the evidence that the dispatch, invalidation, cache, auth, and SSR mechanisms above behave as claimed against the real HTTP stack; it authors no production mechanism. **Claims substantiated here.** -- The dispatch, invalidation, merge, cache, auth, and SSR claims of the mizan-django dispatch/cache/ssr/jwt sub-units are *verified* here; the "passes its own test suites" status rests on this harness. -- Edge caching is provable before Edge exists — deterministic JSON, correct Cache-Control, header round-trip, auth-differentiated responses (test_core.py § EdgeCompatibilityTests, an explicit doc-shaped claim carried in the suite). +- The dispatch, invalidation, merge, cache, auth, and SSR claims of the mizan-django dispatch/cache/ssr/jwt sub-units are *verified* here. +- Edge caching is provable without an Edge — deterministic JSON, correct Cache-Control, header round-trip, auth-differentiated responses (test_core.py § EdgeCompatibilityTests, a doc-shaped claim carried in the suite). **Owed behavioral mechanisms.** - the suite exercises the real HTTP stack (Django test client / LiveServer) — not just RequestFactory — for dispatch, three-tier invalidation, merge, view-path branching, and cache HIT/MISS/scoped-purge — observable: `HTTPIntegrationTests` and `CacheIntegrationTests` assert the JSON body and `X-Mizan-Invalidate` header agree, that a scoped mutation preserves other users' cached entries, and that a second identical fetch is a HIT. - the auth suite covers every axis (JWT valid/invalid/expired, MWT, session, staff/superuser/callable/PermissionError) and asserts the body never runs on failure — observable: an invalid token returns 401 without session fall-through; an anonymous call to an auth-required function returns before the body; a callable's `PermissionError` message surfaces verbatim. - the Edge-compatibility suite asserts the properties a CDN cares about (deterministic byte-identical bodies, sorted JSON keys, URL-encoded delimiter-safe headers, `no-store` on errors/mutations, header↔body invalidation agreement, auth-differentiated responses for the same URL) — observable: these tests go red if any of those properties regress, so "Edge caching is possible" is checkable without a CDN. -- the SSR suite verifies the engine-based render path — template-backend resolution, bundle-driven in-process render, the hydration wrapper, and concurrent renders — and asserts that no external JS runtime process is spawned during the suite (the PyO3-bound `SsrEngine` renders in-process) — observable: a render produces the `
` + `__MIZAN_SSR_DATA__` wrapper from the resolved bundle, concurrent renders across worker threads each use their own engine and return correct results, and the suite spawns no `bun`/`node` subprocess. The Bun ping / crash-recovery / auto-restart assertions are retired with the subprocess bridge they exercised; today `test_ssr.py` still drives `SSRBridge` (Bun subprocess, JSON-RPC, killed-worker restart), so this engine-path verification is owed alongside the mizan-django SSR PyO3 cutover. +- the SSR suite verifies the engine-based render path — template-backend resolution, bundle-driven in-process render, the hydration wrapper, and concurrent renders — and asserts that no external JS runtime process is spawned during the suite (the PyO3-bound `SsrEngine` renders in-process) — observable: a render produces the `
` + `__MIZAN_SSR_DATA__` wrapper from the resolved bundle, concurrent renders across worker threads each use their own engine and return correct results, and the suite spawns no `bun`/`node` subprocess. - the benchmark suite measures HTTP-vs-executor overhead and throughput with correctness assertions on each path — observable: every benchmark also asserts the function's numeric output, so a green benchmark run is also a correctness run. --- @@ -322,16 +326,18 @@ Return-type branching + origin cache: **Owed behavioral mechanisms.** - the pentest and security suites assert the properties an attacker probes: validation-runs-before-execution, private/internal functions unreachable over RPC, no sensitive detail in production error messages, injection strings (SQL/command/template/prototype-pollution/unicode-lookalike/zero-width) treated as inert data, and no function-existence timing leak — observable: these tests go red if the executor ever runs a body before validation, leaks a secret in a 500, or executes an injection payload. - the channels suite verifies subscription lifecycle and authorization: param validation before `authorize`, `authorize`-false and `authorize`-raise both blocking cleanly, duplicate-subscription rejection, room-level per-param authorization, and WS-RPC gated to `websocket=True` functions — observable: subscribing to a room the user cannot access is rejected; an RPC to an HTTP-only function returns FORBIDDEN over the socket. +- every channel fixture these suites register subclasses the `Channel` base and declares its wire models under the `Params` / `ClientMessage` / `ServerMessage` slot names that base reads — observable: a fixture's declared message model reaches the exported schema and the IR; a model declared under any other attribute name is invisible to both, so the suite would be asserting against a channel the contract sees as slotless. +- the extension-schema tests pin both directions of the `bidirectional` flag — observable: a fixture declaring `ClientMessage` asserts `bidirectional` true with both `client_message` and `server_message` present, and a push-only fixture asserts `bidirectional` false with `client_message` absent, so neither the flag nor the slot keys can be renamed without turning the suite red. - the shapes suite verifies query efficiency and diff correctness across the hard cases: single-query flat, prefetch nested (no N+1), UUID/slug/int PKs, two-FKs-to-same-model, self-referential and nullable FKs, `False`/`0`/`""` treated as present, batched `diff_many`, and strict nested-diff access raising on typos — observable: a nested query asserts exactly the prefetch count; a mistyped nested-diff name raises rather than silently returning empty. --- ## Unit: mizan-fastapi (`backends/mizan-fastapi/src/mizan_fastapi`) -**Charter.** The FastAPI adapter targeting the AFI-common subset: RPC dispatch, context bundling, JSON-body invalidation + merge, auth gating, the error envelope, and the KDL IR CLI. It owns the FastAPI transport surface over `mizan_core`; Forms/Channels/Shapes/SSR are explicitly out of scope. +**Charter.** The FastAPI adapter targeting the AFI-common subset: RPC dispatch, context bundling, JSON-body invalidation + merge, auth gating, the error envelope, the channel registry extension and socket handler, and the KDL IR CLI. It owns the FastAPI transport surface over `mizan_core`; Forms/Shapes/SSR are explicitly out of scope. **Claims substantiated here.** -- RPC call dispatch, named-context bundle fetch, JSON-body invalidation, three-tier auto-scoping, function registration, KDL IR export (README.md § Adapters; backends/mizan-fastapi/README.md). +- RPC call dispatch, named-context bundle fetch, JSON-body invalidation, three-tier auto-scoping, function registration, KDL IR export (README.md § Adapters; backends/mizan-fastapi/README.md § Scope). - Auth-guard enforcement (`auth=` rejects) (backends/mizan-fastapi/README.md § Auth integration). - The same core primitives as Django, proving the protocol is not Django-specific; IR-shape parity with Django and Rust (README.md § Conformance; docs/AFI_ARCHITECTURE.md). - Every error path renders through the Mizan envelope; `GET /session/` returns a null CSRF token for wire parity (backends/mizan-fastapi/README.md § Setup; README.md § Adapters note 7). @@ -341,6 +347,8 @@ Return-type branching + origin cache: - `compute_invalidation` auto-scopes by matching args against the context's declared Input fields, emitting a bare context or a `{context, params}` object — observable: a mutation with a matching arg emits the scoped form, a non-matching arg the bare context string; identical to the Django resolver's output. - `compute_merges` resolves the slot by unique return-type match (`types_match_for_merge`) and emits `{context, slot, value, params?}`, dropping ambiguous — observable: the `morph_groups`/`morph_layers` fixture routes a `MorphLayer` mutation to `morph_layers` only; a merge-only mutation emits `merge` with empty `invalidate`. - the router exposes `POST /call/`, `GET /ctx/{name}/`, `GET /session/` and both exception handlers render every failure through `{"error":{code,message,details?}}` with `Cache-Control: no-store` — observable: an unknown function returns 404 in the envelope; a malformed body returns `BAD_REQUEST`; a validation failure returns 422; `/session/` returns `{csrfToken: null}` (parity, since CSRF is Django-only). +- the `Channel` base declares the same three backend-neutral slots as the Django base (`Params`, `ClientMessage`, `ServerMessage`) and its `channels` registry extension exports the same key set (`name`, `type`, `bidirectional`, plus the declared `params`/`client_message`/`server_message` schemas) — observable: a channel declaring `ClientMessage` reports `bidirectional: true` and a `client_message` schema whose entry is comparable key-for-key with Django's entry for an identically-declared channel, so the slot names carry on a backend with no Django in it. +- the socket handler fans a message out to exactly the subscribers whose params key the group, dropping a socket that fails to take a frame — observable: two subscribers with the same params receive one push; a departed socket is discarded from the group with the failure surfaced rather than swallowed, so one dead client cannot wedge the broadcast. - `python -m mizan_fastapi.ir ` imports the module (triggering registration) and writes canonical KDL — observable: its output equals the Django management command's output for the same fixture (three-way parity). --- @@ -358,7 +366,7 @@ Return-type branching + origin cache: - `function_call` dispatches through `lookup_function` + `FunctionSpec::dispatch`, then attaches `compute_invalidation` and `compute_merges` output, mirroring the FastAPI response shape `{result, invalidate, merge?}` — observable: the wire-parity drivers (`tests/rust/drive_kernel.rs`, `drive_emitted.rs`) run the same probes against the Axum server and FastAPI and require the same JSON shapes and invalidate/merge semantics. - `context_fetch` bundles every registered member of the context and coerces string query params to typed JSON via each function's `input_params` primitive table — observable: `GET /ctx/user/?user_id=5` returns the flat bundle with `user_id` coerced to an integer before dispatch; an unknown context returns the envelope 404. - app state is type-erased into the handle and downcast in user functions — observable: a handler downcasts `RequestHandle` to the concrete state type; the stateless router variant threads a unit handle. -- OWED (unbuilt caveats, README.md § Caveat + notes 2,3,5): Axum declares `Transport::Websocket` in the IR/macro but routes no WebSocket handler; carries `is_form`/`form_role` trait stubs but no validate/submit endpoint; and accepts `auth=` on a function but the dispatch path does not enforce it — observable: a `websocket=True` function is reachable only over HTTP; an `auth=True` function is NOT rejected for anonymous callers on this adapter. These are documented gaps the "auth enforced on every adapter" invariant (INVARIANTS.md § Auth) owes and Rust/Axum does not yet meet. +- the adapter honors every declaration it accepts, so no function reaches the wire with a declared property the transport ignores (README.md § Caveat, notes 2/3/5; INVARIANTS.md § Auth): a `Transport::Websocket` function is routed through a WebSocket handler, an `is_form`/`form_role` function is reachable through validate/submit endpoints, and `auth=` is enforced in the dispatch path — observable: an `auth=True` function is rejected for an anonymous caller on this adapter exactly as it is on Django and FastAPI, which is what makes "auth enforced on every adapter" a single claim rather than a per-adapter one. --- @@ -375,7 +383,7 @@ Return-type branching + origin cache: - the plugin registers exactly one command (`plugin:mizan|mizan_invoke`) that deserializes the op-tagged envelope and dispatches `call`/`fetch` through the same `FUNCTIONS`/`CONTEXTS` slices the HTTP adapter uses — observable: `{op:"call", fn, args}` returns `{result, invalidate, merge?}` and `{op:"fetch", context, params}` returns the flat bundle, identical shapes to the axum adapter minus the header channel; there is no per-function `#[tauri::command]`. - errors flow through Tauri's reject path re-wrapped into the `{code, message, details?}` shape — observable: a `MizanError::ValidationFailed` reaches the JS transport as the same envelope an HTTP 422 would carry, so consumer error handling is transport-agnostic. - `RequestHandle::new(app)` lets a function downcast to `tauri::AppHandle` for managed state / event emission — observable: a function calling `req.downcast::()` reaches Tauri state; stateless functions ignore the handle. -- OWED (unbuilt caveat, README.md § Caveat + note 5): Tauri's `FunctionSpec` carries `auth`/`private` fields but the dispatch path does not enforce them — observable: an `auth=`-declared function is not rejected for an unauthorized caller on this adapter; the "auth enforced on every adapter" invariant is not yet met here. +- the `auth`/`private` fields Tauri's `FunctionSpec` carries are enforced in the dispatch path (README.md § Caveat, note 5; INVARIANTS.md § Auth) — observable: an `auth=`-declared function is rejected for an unauthorized caller over IPC, and a `private=True` function is unreachable through `mizan_invoke`, so a desktop build cannot be the one transport where a declared guard is decorative. --- @@ -403,23 +411,25 @@ Return-type branching + origin cache: ## Unit: mizan-base and framework adapters (`frontends/mizan-base`, `frontends/mizan-react`, `frontends/mizan-vue`, `frontends/mizan-svelte`) -**Charter.** The TypeScript client kernel (`@mizan/base`) and the per-framework idiomatic adapters (React hooks, Vue composables, Svelte stores) that subscribe to it. `@mizan/base` is the authoritative kernel the `frontends/mizan-rust` unit ports; the TS source is referenced by the docs and adapters but is not inlined in this repo snapshot. Listed so the kernel claims and the adapter-parity claims are surfaced against their real roots. The `mizan-ts` cross-language HMAC pin (`deriveCacheKey`) also lives on the TS side. +**Charter.** The TypeScript client kernel (`@mizan/base`) and the per-framework idiomatic adapters (React hooks, Vue composables, Svelte stores) that subscribe to it. `@mizan/base` is the authoritative kernel the `frontends/mizan-rust` unit ports. The `mizan-ts` cross-language HMAC pin (`deriveCacheKey`) also lives on the TS side. **Claims substantiated here.** - Every frontend adapter is a thin idiomatic wrapper over one shared kernel; the kernel owns `ContextState = {data,status,error}`, `registerContext`, `mizanCall`/`mizanFetch`, server-driven merge/invalidate, `initSession`, and a pluggable `MizanTransport` (HTTP default, Tauri/webview swap via `configure`) (INVARIANTS.md § Client Kernel; docs/AFI_ARCHITECTURE.md § Kernel model). - Shared parameters elevate to required provider props; non-shared params elevate to optional props with per-function override (INVARIANTS.md § Named Contexts; MIZAN.md §2 param elevation / `specify` resolution order). - Codegen targets the adapter surface, never the raw kernel; React devs get hooks, Vue composables, Svelte stores, same kernel underneath (docs/AFI_ARCHITECTURE.md § Kernel model). -- Vue and Svelte ship as v1 alongside React (docs/AFI_ARCHITECTURE.md § Launch surface). +- Vue and Svelte are co-equal codegen targets over the same kernel, not React-derived (docs/AFI_ARCHITECTURE.md § Authoring surface). - Cross-language HMAC pin: `deriveCacheKey` in `mizan-ts` matches the Python key byte-for-byte (docs/CACHE_KEYING.md; README.md § Adapters — TypeScript is the protocol-reference adapter). **Owed behavioral mechanisms.** -- `@mizan/base` owns the single reconciled view: `ContextState`, the context registry, `mizanCall`/`mizanFetch`, server-driven `merge`/`invalidate`, `initSession`, over a `MizanTransport` interface — observable: the same behaviors the `mizan-rust` port pins (stable-key cache identity, merge-splice, scoped-vs-broad refetch, retry, dual-envelope error parse) hold in TS; the Rust port exists precisely to mirror this file. +- `@mizan/base` owns the single reconciled view: `ContextState`, the context registry, `mizanCall`/`mizanFetch`, server-driven `merge`/`invalidate`, `initSession`, over a `MizanTransport` interface — observable: the same behaviors the `mizan-rust` port pins (stable-key cache identity, merge-splice, scoped-vs-broad refetch, retry, dual-envelope error parse) hold in TS; the Rust port mirrors this file behavior-for-behavior. - the generated provider elevates a context's shared params to required props and its non-shared params to optional props, and resolves each member's effective params by overlaying per-function overrides onto the provider props at fetch time (INVARIANTS.md § Named Contexts; MIZAN.md §2 resolution order) — observable: a two-function `user` context where both take `user_id` and only one takes `page` generates a provider with required `user_id` and optional `page`; a per-function override supplies a different `page` for that one member while the shared `user_id` still covers both, and a member still missing a required param at fetch time is a runtime error, not a silent undefined. - `deriveCacheKey` (mizan-ts) reproduces the Python HMAC key byte-for-byte — observable: the pinned vectors in `cores/mizan-python/tests/test_keys.py::test_cross_language_pin` (`ctx:user:605a1ca5…`, `ctx:user:30fc08eb…`) are asserted against the TS output; any normalization drift (bool/None stringification, key ordering) breaks the pin, which the doc marks a security vulnerability. - adapters subscribe to the kernel and render in their own idiom without keeping a parallel copy of the truth — observable: a React hook and a Vue composable over the same context read the same kernel entry; mutating in one path updates both because the truth lives once in the kernel. -- OWED (unbuilt, ISSUES.md § Open / ROADMAP.md § Next): `frontends/mizan-vue` and `frontends/mizan-svelte` are runtime kernel-adapter packages — the codegen emits their clients (byte-parity-tested) but no runtime package or live-backend example exists — observable when built: a Vue composable / Svelte store subscribes to `@mizan/base` and refreshes on invalidation against a live backend; today only React has full integration verification, so the "Vue and Svelte ship as v1" claim is unsubstantiated at the runtime layer. -- OWED (drift, ISSUES.md § Open): the Svelte codegen target emits Svelte 4 `readable` stores; Svelte 5 `$state`/`$derived` runes are owed — observable when built: the emitted Svelte client uses runes. -- OWED (migration, ISSUES.md § Open): `mizan-react/src/context.tsx` is the pre-kernel provider still shipped and imported by the desktop example, coexisting with the codegen-emitted kernel-subscribing `MizanContext`; retiring it (migrating the example onto the generated provider) is owed so the "every adapter wraps the kernel, none keeps its own truth" invariant holds without exception. +- the channel subscription surface (`useChannel` and the `ChannelSubscription` type) orders its type parameters by direction, so a hook's inbound and outbound message types cannot be transposed — observable: a push-only channel's generated hook types its outbound message as `never`, making a client-side send on that channel a compile error rather than a runtime drop. +- `frontends/mizan-vue` and `frontends/mizan-svelte` are runtime kernel-adapter packages, not codegen output alone — observable: a Vue composable and a Svelte store each subscribe to `@mizan/base` and refresh on invalidation against a live backend, so co-equality is checkable at the runtime layer and not only at the byte-parity layer. +- the Svelte codegen target emits Svelte 5 `$state`/`$derived` runes (INVARIANTS.md § Client Kernel — "Svelte runes") — observable: the emitted Svelte client reads context state through runes rather than through `readable` stores. +- every React provider that ships subscribes to the kernel, so no adapter module holds context state of its own (INVARIANTS.md § Client Kernel) — observable: the desktop example imports the codegen-emitted kernel-subscribing `MizanContext`, and `mizan-react` exposes no second provider keeping a parallel copy of the truth. +- the generated channel client in the example harness names the channel slots from the client's side — observable: `examples/django-react-site/harness/src/api/channels.ts` declares `ClientMessage`/`ServerMessage` types with `hasClientMessage`/`hasServerMessage` flags and `clientMessageType`/`serverMessageType` keys, matching what `protocol/mizan-codegen`'s channels target emits from the IR, so the harness is regenerable rather than divergent. --- @@ -428,20 +438,24 @@ Return-type branching + origin cache: **Charter.** The single Rust codegen binary that reads KDL IR and emits typed clients for every target (stage1, react, vue, svelte, channels, python, rust), plus the source-fetching that spawns each backend's IR-export command and the Pydantic-pre-step, plus the SSR bundling step that compiles each SSR entry component into a self-contained render bundle. It owns the IR→client transform and the SSR bundle production; it does not emit IR (backends do) or run the render engine (that is `cores/mizan-rust-ssr`). **Claims substantiated here.** -- Codegen reads KDL directly — no OpenAPI envelope, no `openapi-typescript`, no per-backend converter; the former JS two-stage codegen is deleted (docs/AFI_ARCHITECTURE.md § Forward-direction primitives). -- Every frontend client is generated from the IR; each target is byte-parity-tested (INVARIANTS.md § Canonical IR & Codegen; ROADMAP.md § Rust codegen). +- Codegen reads KDL directly — no OpenAPI envelope, no `openapi-typescript`, no per-backend converter (docs/AFI_ARCHITECTURE.md § KDL is the IR). +- Every frontend client is generated from the IR; each target is byte-parity-tested (INVARIANTS.md § Canonical IR & Codegen). - The codegen drives the backend's IR-export command as a subprocess and parses the KDL it writes (docs/AFI_ARCHITECTURE.md; backends/*/README.md § Generate the frontend). - Stage 1 (typed `callXxx`/`fetchXxx`) + Stage 2 (`` provider, per-context providers, `use{Hook}()`) emission (backends/*/README.md § Generate the frontend). -- Pydantic + Rust DX: a decoru pre-step authors Rust types from Pydantic before the cargo IR bin runs; a generic `[source.script]` source spawns any command emitting KDL (backends/mizan-tauri/README.md § Pydantic; config.rs). -- mizan-generate's SSR bundling step compiles each SSR entry component together with `react-dom/server.browser` into a self-contained bundle assigning `globalThis.renderApp`, written to the `bundles` directory — the only place node/bun run in the SSR path (docs/SSR_ARCHITECTURE.md § The engine; § AFI boundary — Build step row). +- Pydantic + Rust DX: a decoru pre-step authors Rust types from Pydantic before the cargo IR bin runs; a generic `[source.script]` source spawns any command emitting KDL (backends/mizan-tauri/README.md § Generate the frontend; config.rs). +- mizan-generate's SSR bundling step compiles each SSR entry component together with `react-dom/server.browser` into a self-contained bundle assigning `globalThis.renderApp`, written to the `bundles` directory — the only place node/bun run in the SSR path (docs/SSR_ARCHITECTURE.md § The engine; § AFI boundary). **Owed behavioral mechanisms.** - `fetch.rs` spawns the configured source's export command (FastAPI `-m mizan_fastapi.ir`, Django `manage.py export_mizan_ir`, Rust `cargo run --bin`, or a generic script) and parses stdout as KDL — no OpenAPI/converter anywhere in the path — observable: a codegen run against a live FastAPI backend consumes only the KDL the CLI writes; the Rust source runs the cargo bin and the optional decoru pre-step first. - the KDL parser reconstructs the full typed IR (types with struct/list/enum/alias shapes, functions with input/output/nullable/context/affects/merge/form, contexts with param elevation, channels) — observable: `ir_deserialization.rs` reads the AFI fixture back into typed structs and asserts the function set, per-function fields, param elevation, and named-type presence. +- the channel half of the parser reads the `params` / `client-message` / `server-message` slots in that order and the emitter renders them as `paramsType` / `clientMessageType` / `serverMessageType` — observable: a KDL `channel` node carrying only `server-message` yields a channel view whose client slot is absent, and the emitted `use{Pascal}Channel` hook types its outbound message as `never`. - each target emits deterministically and is byte-parity-tested against a committed baseline — observable: `stage1_parity.rs`, `react_parity.rs`, `rust_parity.rs`, `python_parity.rs`, `vue_svelte_parity.rs`, and `channels_smoke.rs` diff emitter output against baselines and fail on any byte drift; two different runs produce identical output. - the emitters produce genuinely different, correct artifacts per target — not one shape behind distinct labels — observable: the react target emits `` + per-context providers + `use{Hook}()` reading React context (Stage 2), Stage 1 emits the framework-agnostic typed `callXxx`/`fetchXxx`; vue emits composables; svelte emits stores; python emits a Pydantic-typed facade over the PyO3 kernel; rust emits a full crate depending on `mizan-rust` — each byte-checked against its own baseline, and stage1 is auto-included whenever a framework target is requested. - the codegen tree-shakes and canonicalizes types to match the backend emitters, and hoists inline enums into named Rust/TS types — observable: an unreferenced type is not emitted; an inline `field { enum … }` becomes a top-level Rust enum the struct field references; the channels target emits zero files when the IR carries no channels. -- OWED (unbuilt): an SSR bundling step compiles each SSR entry component together with `react-dom/server.browser` into a self-contained bundle that assigns `globalThis.renderApp`, emitted into the configured `bundles` directory — this is the only place node/bun run in the SSR path — observable when built: a `mizan-generate` run produces one render bundle per SSR entry component, each evaluable standalone by the embedded-V8 engine (assigning `renderApp` at eval time and rendering from a JSON-parsed props argument). Today the codegen binary emits typed clients (stage1/react/vue/svelte/channels/python/rust) but carries no SSR bundling target in `src/emit/` and no bundling path in `fetch.rs`/`config.rs`, so the bundling step the docs place with `mizan-generate` is unsubstantiated. +- an SSR bundling step compiles each SSR entry component together with `react-dom/server.browser` into a self-contained bundle that assigns `globalThis.renderApp`, emitted into the configured `bundles` directory — the only place node/bun run in the SSR path — observable: a `mizan-generate` run produces one render bundle per SSR entry component, each evaluable standalone by the embedded-V8 engine (assigning `renderApp` at eval time and rendering from a JSON-parsed props argument). + +--- + ## Unit: AFI conformance (`tests/afi`) **Charter.** The cross-adapter conformance gate: one fixture registered identically in Django, FastAPI, and a Rust app, asserting all three emit byte-identical KDL. It is the executable form of "the IR is the only contract"; it authors no production mechanism. @@ -451,6 +465,7 @@ Return-type branching + origin cache: **Owed behavioral mechanisms.** - one shared fixture (`fixture.py` and its Rust twin `rust_app`) registers the same 7 functions / 5 types / context+affects+merge graph across all three backends, and the parity test diffs the three KDL emissions requiring exact three-way equality — observable: `test_codegen_parity.py` fails (naming the divergent pair) the instant any adapter's type introspection, ordering, or param elevation drifts; the fixture spans the AFI axes (plain fn, no-input fn, shared-param context, affects mutation, optional return, merge mutation) so the gate is not a degenerate single-shape check. +- the fixture spans the channel axis as well as the function axes, so the client-named slots are gated by the same three-way equality — observable: a channel declared once per backend emits an identical `channel` node with identical `Params` / `ClientMessage` / `ServerMessage` refs, so a backend-shaped slot name cannot re-enter the IR through one adapter without turning this gate red. --- diff --git a/backends/mizan-django/README.md b/backends/mizan-django/README.md index 0c547c0..7e042be 100644 --- a/backends/mizan-django/README.md +++ b/backends/mizan-django/README.md @@ -119,18 +119,24 @@ dedicated `mizan-allauth` repository, built on this mixin. ## Channels -WebSocket-native RPC via a flag flip: +WebSocket-native RPC via a flag flip. The message slots are named from the +client's point of view: `ClientMessage` travels client → server, +`ServerMessage` travels server → client. Declare only the directions the +channel uses. ```python from pydantic import BaseModel -from mizan.channels import ReactChannel +from mizan.channels import Channel -class ChatChannel(ReactChannel): +class ChatChannel(Channel): class Params(BaseModel): room: str - class DjangoMessage(BaseModel): + class ClientMessage(BaseModel): + text: str + + class ServerMessage(BaseModel): text: str user: str @@ -139,10 +145,19 @@ class ChatChannel(ReactChannel): def group(self, params): return f"chat_{params.room}" + + def receive(self, params, msg): + return self.ServerMessage(text=msg.text, user=self.user.email) ``` Frontend gets `useChatChannel({ room })`. +Server code outside a subscription broadcasts with `push()`: + +```python +await ChatChannel.push(room="general", message=ChatChannel.ServerMessage(...)) +``` + ## Generate the frontend The codegen is the `mizan-generate` Rust binary (source at diff --git a/backends/mizan-fastapi/README.md b/backends/mizan-fastapi/README.md index 0d2d798..8879ea2 100644 --- a/backends/mizan-fastapi/README.md +++ b/backends/mizan-fastapi/README.md @@ -5,11 +5,10 @@ function. Typed React client generated. Invalidation automatic. ## Scope -mizan-fastapi targets the **AFI-common subset** — RPC dispatch, context -bundling, JSON-body invalidation, and auth gating. Forms, Channels, Shapes, -and SSR are out of scope for the FastAPI adapter — FastAPI projects use -native equivalents (Pydantic, native WebSockets, ORM-of-choice, FastAPI's -own SSR ecosystem). +mizan-fastapi's surface is RPC dispatch, context bundling, JSON-body +invalidation, auth gating, and channels over a multiplexed WebSocket. Forms, +Shapes, and SSR sit outside that surface — a FastAPI project reaches for its +own native equivalents (Pydantic, ORM-of-choice, FastAPI's SSR ecosystem). ## Install @@ -29,11 +28,13 @@ from mizan_fastapi import ( mizan_exception_handler, mizan_validation_handler, router as mizan_router, + ws_router, ) app = FastAPI() app.include_router(mizan_router, prefix="/api/mizan") +app.include_router(ws_router, prefix="/api/mizan") app.add_exception_handler(MizanError, mizan_exception_handler) app.add_exception_handler(RequestValidationError, mizan_validation_handler) ``` @@ -82,9 +83,56 @@ a dedicated `clients.py` imported during startup. @client(rev=2) # cache revision (busts on bump) ``` -`websocket=True`, Forms, and Channels parameters are accepted by the -decorator (they're a `mizan-core` primitive) but ignored by mizan-fastapi — -those features only have effect when paired with mizan-django. +Forms parameters are accepted by the decorator (they're a `mizan-core` +primitive) and carry no meaning to this adapter. + +## Channels + +A channel is a named fan-out over the WebSocket `ws_router` serves. Subclass +`Channel`, declare whichever payload models the channel carries, and register +it. The three model names are read from the client's side: `Params` keys the +fan-out, `ClientMessage` travels up, `ServerMessage` travels down. + +```python +from mizan_fastapi import Channel, register_channel +from pydantic import BaseModel + + +class Chat(Channel): + + class Params(BaseModel): + room: str + + class ClientMessage(BaseModel): + text: str + + class ServerMessage(BaseModel): + user: str + text: str + + def authorize(self, params: Params) -> bool: + return True + + def receive(self, params: Params, msg: ClientMessage) -> ServerMessage: + return self.ServerMessage(user="anon", text=msg.text) + + +register_channel(Chat, "chat") +``` + +Server code pushes to a group from anywhere: + +```python +await Chat.push(Chat.ServerMessage(user="system", text="hello"), room="general") +``` + +Registered channels contribute to the exported IR, so codegen emits the +`Params` / `ClientMessage` / `ServerMessage` types and +the matching frontend hook. + +Group membership lives in the process that holds the socket, so a push reaches +only the subscribers attached to that process. Fan-out that spans processes is +a shared broker in front of `broadcast`. ## Auth integration @@ -169,8 +217,6 @@ python -m mizan_fastapi.ir Imports the named module (which must register every `@client` function as import-time side effects), then prints the Mizan KDL IR to stdout. -Mirrors mizan-django's `manage.py export_mizan_ir` so the codegen consumes -either backend the same subprocess way. ## Architecture @@ -183,4 +229,4 @@ emit equivalent schemas for the same registered functions. See A live e2e harness exercises this adapter end-to-end at `examples/fastapi-react-site/` (real Chromium → React with generated hooks -→ FastAPI server, 14/14 Playwright tests). +→ FastAPI server, driven by Playwright). diff --git a/backends/mizan-tauri/README.md b/backends/mizan-tauri/README.md index ce0adf6..f7134b5 100644 --- a/backends/mizan-tauri/README.md +++ b/backends/mizan-tauri/README.md @@ -215,11 +215,11 @@ const greeting = await callGreet({ name: "world" }); console.log(greeting.message); ``` -For framework hooks generated by Stage 2 (`useGreet()` etc., wrapping the -imperative `callGreet` with `isPending`/`error` state), wrap your tree -with `` at the root — same as the HTTP-transport setup. The -generated provider is transport-agnostic; it reads from `config.transport` -the kernel is using. +For the framework hooks the `react` target generates (`useGreet()` etc., +wrapping the imperative `callGreet` with `isPending`/`error` state), wrap +your tree with `` at the root — same as the HTTP-transport +setup. The generated provider is transport-agnostic; it reads from +`config.transport` the kernel is using. ### tsconfig / vite preserve symlinks @@ -273,12 +273,6 @@ Errors flow through Tauri's `Promise.reject` path; `@mizan/tauri-transport` re-wraps them into the same `MizanError` shape the HTTP transport produces, so consumer code is identical regardless of transport. -## Reference application - -`claude-manage` is the production reference — Tauri + React + Pydantic -schema + Mizan RPC. See `~/dev/claude-manage/mizan.toml` and -`~/dev/claude-manage/src-tauri/src/commands.rs` for a full migrated app. - ## Architecture mizan-tauri shares `cores/mizan-rust` with `mizan-rust-axum`. Both diff --git a/docs/AFI_ARCHITECTURE.md b/docs/AFI_ARCHITECTURE.md index 3a80919..71981aa 100644 --- a/docs/AFI_ARCHITECTURE.md +++ b/docs/AFI_ARCHITECTURE.md @@ -27,7 +27,8 @@ frontends/ client kernel + per-framework adapters + transports cores/ shared language-level primitives mizan-python/ @client decorator, registry, MWT, HMAC cache keys mizan-rust/ Rust core — IR build (build_ir()), registry - mizan-rust-macros/ #[derive(Mizan)] / #[mizan::client] proc-macros + mizan-rust-macros/ #[derive(Mizan)] / #[mizan::client] / #[mizan::context] / + #[mizan::channel] proc-macros mizan-rust-ssr/ embedded-V8 SSR engine (deno_core + deno_web); evals the build-time bundle, renders per request; no_rsc guard protocol/ protocol-level tooling @@ -65,9 +66,9 @@ Svelte developer gets readable stores. Same kernel underneath. The Mizan IR is **KDL** — the LLVM-IR-equivalent of the system. Every backend adapter produces KDL describing its registered functions, -contexts, types, and invalidation graph. Every codegen target consumes -KDL. KDL is the contract; everything else (REST envelopes, OpenAPI -documents, framework idioms) is sediment around it. +contexts, types, channels, and invalidation graph. Every codegen target +consumes KDL. KDL is the contract; everything else (REST envelopes, +OpenAPI documents, framework idioms) is sediment around it. The IR is validated against multiple adapters — single-adapter validation hides assumptions, and divergence between adapters is what @@ -79,7 +80,8 @@ Forward-direction primitives: FastAPI `python -m mizan_fastapi.ir `, Django `python manage.py export_mizan_ir`, Rust a consumer-side cargo bin that calls `mizan_core::build_ir()`. Python's `build_ir()` walks - `mizan_core.registry`. The IR grammar (`type` / `function` / + `mizan_core.registry`; the Rust emitter walks the linkme slices the + proc-macros populate. The IR grammar (`type` / `function` / `context` / `channel` nodes) is parsed by `mizan-codegen`'s `src/ir.rs`; fixtures live at `protocol/mizan-codegen/tests/fixtures/*.kdl`. diff --git a/docs/CACHE_KEYING.md b/docs/CACHE_KEYING.md index 9a0286a..72d11a0 100644 --- a/docs/CACHE_KEYING.md +++ b/docs/CACHE_KEYING.md @@ -1,19 +1,18 @@ # Cache Keying -*Discovered 2026-04-06.* +## What cache keying is for -## The gap +Mizan's invalidation surface names *which* entries die. Cache keying +names *which entry is which*. A key that does not separate User A's +content from User B's turns Edge caching into a +**security vulnerability** — it serves one user's content to another. -Mizan specified invalidation but never specified cache keying. -Without correct cache keying, Edge caching is a **security -vulnerability** — it serves User A's content to User B. +## Why `Vary` is not the mechanism -## Why Vary doesn't work +All major CDNs ignore `Vary` for personalized content, and no +standardized replacement exists. The key itself carries identity. -All major CDNs ignore `Vary` for personalized content. No -standardized replacement exists. - -## Resolution: HMAC cache key (JSON-canonical form) +## HMAC cache key (JSON-canonical form) ``` ctx:{context}:HMAC-SHA256(secret, json.dumps({ @@ -28,9 +27,10 @@ ctx:{context}:HMAC-SHA256(secret, json.dumps({ `"ctx:{context}:{hmac_hex}"`. The `ctx:{context}:` prefix lets broad purge SCAN by prefix. Param values are normalized for cross-language consistency (`True`→`"true"`, `None`→`"null"`) before stringification. -Implemented in `cores/mizan-python/src/mizan_core/cache/keys.py` and -`backends/mizan-ts/src/cache/keys.ts` (`deriveCacheKey`); pin tests -verify identical output. +The derivation lives in +`cores/mizan-python/src/mizan_core/cache/keys.py` and +`backends/mizan-ts/src/cache/keys.ts` (`deriveCacheKey`); pinned +vectors hold the two outputs byte-identical. ### Key derivation rules @@ -43,18 +43,16 @@ verify identical output. ## Identity layer MWT (Mizan Web Token) — see [MWT_SPEC.md](MWT_SPEC.md). JWT with -Mizan claims on `X-Mizan-Token` header. Replaces the old -`JWTUser` + permission key metadata approach. +Mizan claims on `X-Mizan-Token` header. ## Cache architecture -*Decided 2026-04-06.* - **Not a compiled binary ABI. Not a pluggable Python protocol.** -Each backend adapter (Python, TypeScript, future PHP/C#/Go) -implements the cache protocol in its own language. -**Conformance verified by a shared test suite.** +Each backend adapter (Python, TypeScript, PHP, C#, Go) implements the +cache protocol in its own language. **Conformance is verified by a +shared test suite**, so the implementations cannot drift apart +silently. ### Required operations @@ -80,6 +78,6 @@ become **unreachable orphans**. No purge needed; no thundering herd. ## Invariant -All cache-related code must implement *identical* HMAC key -derivation. Cross-language conformance tests enforce this. Any -divergence is a security vulnerability. +All cache-related code implements *identical* HMAC key derivation. +Cross-language conformance tests enforce this. Any divergence is a +security vulnerability. diff --git a/docs/PRODUCT_ARCHITECTURE.md b/docs/PRODUCT_ARCHITECTURE.md index 733b5fe..8450c6a 100644 --- a/docs/PRODUCT_ARCHITECTURE.md +++ b/docs/PRODUCT_ARCHITECTURE.md @@ -1,60 +1,59 @@ # Product Architecture -*Revised April 2026.* +Mizan's surface splits into a free framework and two paid products. +The split is drawn along one line: what runs on the developer's own +infrastructure versus what Mizan operates for them. -## Launch product: Mizan Render +## Mizan Render — paid **$20/seat/month.** Protocol-aware Edge caching + PSR delivery via Cloudflare + render Workers + TS backend hosting via Workers for Platforms. -Developer's stack = their backend + database. Cloudflare handles -read traffic, rendering, and caching. +The developer's stack is their backend + database. Cloudflare handles +read traffic, rendering, and caching. The compliance surface is +entirely Cloudflare Workers plus a management API (Django/Postgres): -## Deferred: Mizan Deploy +- GDPR DPA + privacy policy + subprocessor list — ~$500–1K legal +- DMCA — $6 +- No NIS2, no gVisor, no KMS -Django hosting requires IaaS compliance: gVisor, KMS, NIS2, -multi-state privacy. ~$5–8K legal costs. +## Mizan Deploy — paid, IaaS-shaped -**Deferred until Render revenue funds it.** +Django hosting is a different product because it is a different +compliance surface: gVisor, KMS, NIS2, multi-state privacy, ~$5–8K +legal. Hosting a customer's Python process is IaaS; serving cached +HTML from a CDN is not. -TS "Deploy" exists via Workers for Platforms at no additional -compliance cost. +TS "Deploy" is the Workers-for-Platforms case, which carries no +additional compliance cost — it falls inside Render's surface rather +than Deploy's. -## Free framework: origin-side cache (`mizan.cache`) +## Origin-side cache (`mizan.cache`) — free -Shipped in `mizan_core.cache` (re-exported as `mizan.cache` from the -Django adapter) implementing the **full cache protocol locally** — -same HMAC key derivation and purge semantics as Edge. +`mizan_core.cache` (re-exported as `mizan.cache` from the Django +adapter) implements the **full cache protocol locally** — the same +HMAC key derivation and purge semantics as Edge. Two backends behind a `CacheBackend` protocol: - `MemoryCache` — in-memory dict (testing) - `RedisCache` — production -### Dual purpose +This carries two consequences: the free framework is complete on its +own (PSR + typed hooks + invalidation + caching at zero cost), and +every cache mechanic is unit-testable without Cloudflare in the loop. -1. Makes the free framework genuinely powerful (PSR + typed hooks + - invalidation + caching with zero cost). -2. Provides a unit-testable surface for all cache mechanics without - Cloudflare. - -## Spec additions +## Spec surface - `@client(cache=False)` — uncacheable; emits `Cache-Control: no-store`. - Cache ABI (`mizan.cache`): `cache_get(secret, backend, context, params)`, `cache_put(...)`, `cache_purge(backend, context, params=…, secret=…)`. -## Launch compliance (Render only) - -Entirely Cloudflare Workers + management API (Django/Postgres): - -- GDPR DPA + privacy policy + subprocessor list — ~$500–1K legal -- DMCA — $6 -- No NIS2, no gVisor, no KMS - ## Invariant -All architecture decisions target the Render-only launch posture. -Don't build Deploy infrastructure prematurely. +The paid surface is Cloudflare-shaped and the free surface depends on +nothing Cloudflare provides. The origin cache implements the protocol +in full locally, so Edge is an accelerator over a complete framework, +never a missing piece of one. diff --git a/docs/PSR_VS_EDGE.md b/docs/PSR_VS_EDGE.md index cd9dbdc..b4e1ca5 100644 --- a/docs/PSR_VS_EDGE.md +++ b/docs/PSR_VS_EDGE.md @@ -1,7 +1,7 @@ # PSR vs Edge Delivery -Two distinct layers that prior conversations have conflated. They are -independent. +Two distinct layers. They are independent, and conflating them +misreads both. ## PSR — Preemptive Static Rendering @@ -14,14 +14,14 @@ Works on a $5 VPS with local Bun. **No Edge required.** PSR is part of the protocol; it's available to every Mizan deployment regardless of hosting. -> Current state: the Edge manifest records each context's -> `render_strategy` (`"psr"` for public, `"dynamic_cached"` for -> user-scoped) — see `mizan/export/` and the `export_edge_manifest` -> management command — and the SSR bridge can render a component to -> HTML. The render-on-mutation orchestration that wires those together -> (mutation → trigger local render → store HTML) is not yet present in -> the open-source backends; it is the manifest-driven behavior the -> Edge layer consumes. +The strategy is manifest-driven rather than hand-wired per view. The +Edge manifest records each context's `render_strategy` — `"psr"` for +public contexts, `"dynamic_cached"` for user-scoped ones — emitted by +`mizan/export/` and the `export_edge_manifest` management command. +The render-on-mutation path reads that field to decide whether a +mutation triggers a local re-render or only a purge, and the SSR +bridge is what turns a component into HTML once it does. The Edge +layer consumes the same field for the same decision. ## Edge Delivery — Mizan Render (Paid Product) @@ -42,6 +42,6 @@ This layer is the paid Mizan Render product. ## Invariant -PSR logic must not couple to Cloudflare-specific APIs. PSR must work +PSR logic does not couple to Cloudflare-specific APIs. PSR works without any cloud infrastructure. Edge delivery extends PSR; it does not replace it. diff --git a/frontends/mizan-react/README.md b/frontends/mizan-react/README.md index c1ddf10..416763e 100644 --- a/frontends/mizan-react/README.md +++ b/frontends/mizan-react/README.md @@ -12,11 +12,9 @@ npm install @rythazhur/mizan@git+https://git.impactsoundworks.com/isw/mizan.git# You don't use this package directly. You use the **generated hooks**. -This is the pre-kernel React adapter: it ships its own `MizanProvider` -(`src/context.tsx`) that owns HTTP/WebSocket/CSRF/session/context state -directly, rather than subscribing to the `@mizan/base` kernel. It is still -the provider the Django + desktop example wires against. (`DjangoContext`, -`useDjango`, etc. are deprecated aliases for the `Mizan*` names.) +This React adapter ships its own `MizanProvider` (`src/context.tsx`) that owns +HTTP/WebSocket/CSRF/session/context state directly, rather than subscribing to +the `@mizan/base` kernel. ### 1. Configure @@ -77,7 +75,7 @@ chat.messages // typed, reactive ## Generated Files The Rust codegen emits per-target files into the configured `output` -directory (Stage 1 is auto-included whenever `react` is a target): +directory: | File | Contents | |------|----------| @@ -85,7 +83,7 @@ directory (Stage 1 is auto-included whenever `react` is a target): | `contexts/.ts` | Per-context `fetchXxx` bundles | | `react.tsx` | `` provider + typed `use{Hook}()` hooks | | `channels.ts` / `channels.hooks.tsx` | Channel types + hooks (when the schema carries channels) | -| `index.ts` | Stage 1 re-export root | +| `index.ts` | Re-export root | ## Sub-exports