# 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. --- ## Architecture Overview MIZAN has three tiers of developer-facing API: ``` @client → a function I call from React @client(context='global') → read-only data, fetched once, everywhere @client(context='') → read-only data, fetched when provider mounts @client(affects='') → a mutation that triggers context refresh @client(affects=specific_function) → a mutation that triggers a specific function's refresh ReactContext('') with send → read-only data, class form ReactContext('') with send+receive → read/write data, explicit mutation logic ``` Decorators are the infantry — single-purpose, lightweight, composable. Classes are the battleship — multi-surface, explicit, for tight read/write coupling. The developer picks the tier that matches their complexity. Most apps never need the class form. `@client` + `affects` covers 95% of cases. --- ## 1. Named Contexts (replacing context='local' and @compose) ### How it works Any string passed to `context=` becomes a named context. Functions and classes sharing the same context string are automatically grouped into one provider, one fetch request, and one set of hooks. ```python @client(context='user') def user_profile(request, user_id: int) -> UserProfileShape: return UserProfileShape.query(lambda qs: qs.filter(user__pk=user_id))[0] @client(context='user') def user_orders(request, user_id: int) -> list[OrderShape]: return OrderShape.query(lambda qs: qs.filter(user__pk=user_id)) @client(context='user') def user_friends(request, user_id: int) -> list[FlatUserShape]: return FlatUserShape.query(lambda qs: qs.filter(friends__pk=user_id)) ``` Three functions, one context name, one generated provider. ### Context type resolution - `context='global'` → special case. Collected into `` (the root provider). Fetched once at app root. SSR-hydrated. No params. - `context=''` → generates `` provider. Fetched when mounted. Accepts params as props. - No `context` (default) → not a context. Just a callable function. ### What codegen produces for `context='user'` - `` — provider component - `useUserProfile()` — typed hook, returns `UserProfileShape` - `useUserOrders()` — typed hook, returns `OrderShape[]` - `useUserFriends()` — typed hook, returns `FlatUserShape[]` ### `context='global'` is just a named context There is no separate mechanism for global contexts. `'global'` is a reserved context name whose provider is automatically included in the root ``. All other named contexts generate standalone providers the developer mounts themselves. ### Registration-time validation - Duplicate function/class names within the same context → error - Mixed WebSocket transport within a context (some `websocket=True`, some not) → error --- ## 2. Param Elevation Functions sharing a context name have their parameters analyzed at codegen time. Shared params elevate to required provider props. Non-shared params elevate to optional provider props. Per-function overrides are available via `specify`. ### Example ```python # All three take user_id # Only orders and friends take page_size, page_index def user_profile(request, user_id: int) -> UserProfileShape: ... def user_orders(request, user_id: int, page_size: int, page_index: int) -> ...: ... def user_friends(request, user_id: int, page_size: int, page_index: int) -> ...: ... ``` ### Generated provider interface ```tsx interface UserContextProps { children: ReactNode user_id: number // required — all functions need it page_size?: number // optional — orders + friends only page_index?: number // optional — orders + friends only specify?: { // per-function overrides user_orders?: { page_size?: number; page_index?: number } user_friends?: { page_size?: number; page_index?: number } } } ``` ### Resolution order (frontend runtime) For each function in the context: 1. Start with elevated props from the provider component 2. Override with values from `specify[function_name]` if present 3. Runtime error if any required param for that function is still missing ### Usage ```tsx // Simple — shared params cover everything // Override — different pagination per function ``` --- ## 3. Server Bundling & Transport ### Context fetch All functions in a named context are fetched in a single GET request. ``` GET /api/mizan/ctx/user/?user_id=123&page_size=20&page_index=0 ``` Response: ```json { "error": false, "data": { "user_profile": { ... }, "user_orders": [ ... ], "user_friends": [ ... ] } } ``` The server executes each function, bundles results, returns one response. The frontend splits the response into individual hook states. Context functions use GET because they are reads. This makes them CDN-cacheable, edge-cacheable, and compatible with standard HTTP caching headers. ### Global context fetch ``` GET /api/mizan/ctx/global/ ``` No params. Fetched once. SSR-hydrated. ### Mutation calls Non-context `@client` functions (including those with `affects`) use the existing POST endpoint: ``` POST /api/mizan/call/ { "fn": "profile_edit", "args": { "name": "new name" } } ``` ### Cache key The cache identity for a context is: context name + shared elevated params. `user_id=123` is one cache entry. Per-function overrides via `specify` are part of the request but do not change the cache identity. ### Wire format convention All parameter names on the wire (HTTP headers, JSON keys, query params, manifest fields) use `snake_case`. TypeScript adapters convert to `camelCase` at the boundary for local use but emit `snake_case` in protocol-level artifacts (invalidation headers, manifest params). This is a protocol rule, not a language convention. --- ## 4. Mutation Invalidation with `affects` This is the key feature. Mutations declare which contexts they invalidate. The generated client code handles the refetch automatically. ### Declaration (Python) ```python @client(context='user') def user_profile(request, user_id: int) -> UserProfileShape: return UserProfileShape.query(lambda qs: qs.filter(user__pk=user_id))[0] # Mutation that invalidates the entire user context @client(affects='user') def profile_edit(request, name: str, email: str) -> dict: User.objects.filter(pk=request.user.pk).update(name=name, email=email) return {"ok": True} # Mutation that invalidates only user_profile within the user context @client(affects=user_profile) def update_avatar(request, avatar_url: str) -> dict: User.objects.filter(pk=request.user.pk).update(avatar=avatar_url) return {"ok": True} # Mutation that invalidates specific functions @client(affects=[user_profile, user_orders]) def change_subscription(request, plan: str) -> dict: update_plan(request.user, plan) return {"ok": True} ``` ### `affects` options - `affects='context_name'` — refetch the entire named context (all functions in it) - `affects=function_ref` — refetch only that specific function within its context - `affects=[fn1, fn2]` — refetch specific functions (can span multiple contexts) ### What codegen produces for a mutation with `affects` The generated hook bakes the invalidation relationship into the client code. The developer never writes cache invalidation logic. ```tsx // Generated export function useProfileEdit() { const mizan = useMizan() return async (input: { name: string; email: string }) => { const result = await mizan.call('profile_edit', input) // Auto-invalidate: refetch entire 'user' context await mizan.invalidateContext('user') return result } } ``` ### Invalidation behavior - Mutation fires via POST - On success, the framework automatically refetches the affected context(s) - If the affected context is not currently mounted, nothing happens (no wasted requests for data nobody is looking at) - On mutation failure, no invalidation occurs ### Parallel to React Query This is the same model as TanStack Query (React Query), but the query keys and invalidation relationships are declared server-side in Python and generated into the client. The developer never manages cache keys, never calls `invalidateQueries`, never wires up `onSuccess` callbacks. --- ## 5. ReactContext Classes (the read/write surface) For cases where mutation logic is tightly coupled to the read shape — where the frontend sends modified state back and the server diffs it — `ReactContext` classes provide explicit `send` and `receive` methods. This is the heavy weapon. Most apps don't need it. `@client` + `affects` covers the common case. Use `ReactContext` when: - The frontend edits data in place and commits changes (form-like behavior) - The mutation needs to diff against current state (Shape diffing) - The read and write logic are tightly coupled and belong in one place ### Definition ```python class UserProfile(ReactContext('user')): def send(self, request, user_id: int) -> UserProfileShape: return UserProfileShape.query(lambda qs: qs.filter(user__pk=user_id))[0] def receive(self, request, data: UserProfileShape): if not request.user.is_staff and request.user.pk != data.id: raise PermissionError("Cannot edit another user's profile") diff = data.diff() if not diff.changed: return {"status": "no_changes"} with transaction.atomic(): User.objects.filter(pk=data.id).update(**diff.changed) if 'email' in diff.changed: transaction.on_commit(lambda: send_verification_email(data.email)) return {"status": "updated", "changed": list(diff.changed.keys())} ``` `send` — what goes out. The read surface. Same as a `@client(context=...)` function. `receive` — what comes back. The write surface. The developer owns all logic: diffing, validation, auth, transactions, side effects. ### Mixing with decorated functions ReactContext classes and @client functions can share a context name: ```python @client(context='user') def user_friends(request, user_id: int) -> list[FlatUserShape]: return FlatUserShape.query(lambda qs: qs.filter(friends__pk=user_id)) class UserProfile(ReactContext('user')): def send(self, request, user_id: int) -> UserProfileShape: ... def receive(self, request, data: UserProfileShape): ... ``` Both live in the `'user'` context. The generated `` includes both. Only `UserProfile` has a commit surface because only it defines `receive`. ### What codegen produces ```tsx // Read hook (from send) const profile = useUserProfile() // Commit function (from receive) const commitProfile = useCommitUserProfile() // Usage const handleSave = async () => { const result = await commitProfile(modifiedProfile) } ``` ### Commit endpoint ``` POST /api/mizan/ctx/user/commit/ { "user_profile": { ...modified shape data... } } ``` The server routes `user_profile` data to `UserProfile.receive()`. Multiple writable members can be committed in one request. ### Invalidation after commit After a successful commit, the context automatically refetches all `send` methods (and all @client context functions in the same named context). The frontend state is guaranteed to reflect current DB state. If the developer wants to avoid the extra round trip, they can return a Shape instance from `receive` matching the `send` return type. The framework detects this and uses it as the new state instead of refetching: ```python def receive(self, request, data: UserProfileShape): User.objects.filter(pk=data.id).update(**data.dict(exclude={'id'})) # Return fresh state — framework skips refetch for this function return UserProfileShape.query(lambda qs: qs.filter(pk=data.id))[0] ``` ### What the developer owns in receive() Everything. The framework provides the Shape data as a typed Pydantic object. The developer decides: - Whether to diff (data.diff() or Shape.diff_many()) - How deep to diff - What auth checks to perform - What validation to run - Whether to wrap in a transaction - What side effects to trigger - What to return Mutation is business logic, not automation. --- ## 6. Discovery and Registration ### @client functions Discovered via `clients.py` convention (DjangoAppVisitor), same as current. ### ReactContext classes Same discovery. Classes inheriting from `ReactContext` found in `clients.py` are registered automatically. The context name string and presence/absence of `receive` are detected at registration time. ### Registration-time validation - 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) --- ## 7. What to Remove / Deprecate - `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` --- ## 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 Write functions. Name your contexts. Declare what affects what. The framework generates the client, handles the caching, and runs the invalidation. ```python # I read data @client(context='user') def user_profile(request, user_id: int) -> UserProfileShape: ... # I mutate data and declare what it affects @client(affects='user') def edit_profile(request, name: str) -> dict: ... # I need tight read/write coupling (rare, powerful) class UserProfile(ReactContext('user')): def send(self, request, user_id: int) -> UserProfileShape: ... def receive(self, request, data: UserProfileShape): ... ``` ```tsx // I use the data const profile = useUserProfile() // I call the mutation — invalidation is automatic const editProfile = useEditProfile() await editProfile({ name: 'new name' }) // useUserProfile() updates automatically. I wrote zero invalidation code. ``` No REST. No CRUD. No cache keys. No manual invalidation. The decorator is the declaration. The framework is the execution.