8-expert review identified 3 bugs in shipped code (Vary header hallucination, fn/function wire key mismatch, max-age=0 defeating PSR) — all fixed with tests updated across Python and TypeScript. Added: manifest version field, affects validation, wire format convention, origin-side cache module (HMAC key derivation, MemoryCache + RedisCache backends, reverse index for scoped invalidation, executor integration). 16 known issues documented in cache/KNOWN_ISSUES.md from expert review — critical items (user_id not passed, purge race condition, no Redis error handling) to be fixed in follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
16 KiB
MIZAN — Named Contexts & Mutation Architecture
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. Treat this as the spec.
The framework formerly called mizan is now called MIZAN. Package names, imports, and references should be updated accordingly. The internal codegen engine is called Maison — it lives inside Mizan and does not need its own public surface.
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='<name>') → read-only data, fetched when provider mounts
@client(affects='<name>') → a mutation that triggers context refresh
@client(affects=specific_function) → a mutation that triggers a specific function's refresh
ReactContext('<name>') with send → read-only data, class form
ReactContext('<name>') 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.
@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<MizanContext/>(the root provider). Fetched once at app root. SSR-hydrated. No params.context='<any other string>'→ generates<XxxContext/>provider. Fetched when mounted. Accepts params as props.- No
context(default) → not a context. Just a callable function.
What codegen produces for context='user'
<UserContext user_id={...}>— provider componentuseUserProfile()— typed hook, returnsUserProfileShapeuseUserOrders()— typed hook, returnsOrderShape[]useUserFriends()— typed hook, returnsFlatUserShape[]
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 <MizanContext/>. 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
# 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
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:
- Start with elevated props from the provider component
- Override with values from
specify[function_name]if present - Runtime error if any required param for that function is still missing
Usage
// Simple — shared params cover everything
<UserContext user_id={user.id} page_size={20} page_index={0}>
<UserPage />
</UserContext>
// Override — different pagination per function
<UserContext user_id={user.id} specify={{
user_orders: { page_size: 20, page_index: ordersPage },
user_friends: { page_size: 10, page_index: friendsPage }
}}>
<UserPage />
</UserContext>
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:
{
"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)
@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 contextaffects=[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.
// 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
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:
@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 <UserContext/> includes both.
Only UserProfile has a commit surface because only it defines receive.
What codegen produces
// 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:
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
receivedefined withoutsend→ erroraffectsreferencing a non-existent context name or function → error (or warning)
7. What to Remove / Deprecate
context='local'→ replaced by any non-'global' context string@composedecorator → replaced by shared context namesComposedContextclass → remove from public APIon_serverflag → default behavior (contexts always bundled)shareprop pattern → replaced by param elevation +specify
8. Implementation Order
Phase 1: Named contexts (core feature)
- Accept any string for
context=(not just 'global'/'local') - Group functions by context name in the registry
- Add context bundling endpoint:
GET /api/mizan/ctx/<name>/ - Update codegen to produce named providers with param elevation
- Update codegen to produce
specifyprop handling - Make
context='global'use the same mechanism, just auto-mounted
Phase 2: affects invalidation
- Add
affectsparameter to@clientdecorator - Accept string (context name), function reference, or list
- Store affects metadata in the function's
_metadict - Export affects relationships in the schema
- Update codegen: mutation hooks auto-invalidate after success
- Frontend: invalidation checks if affected context is mounted before refetching
Phase 3: ReactContext classes
- Implement
ReactContextbase class with metaclass magic for the string arg sendmethod registered as a context function (same as @client with context)receivemethod registered as a commit handler- Commit endpoint:
POST /api/mizan/ctx/<name>/commit/ - Update codegen: produce commit hooks for classes with
receive - Auto-refetch after commit, with optional fresh-data-from-receive optimization
Phase 4: Cleanup
- Remove
@composefrom public API and docs - Remove
context='local'(accept for backwards compat with deprecation warning) - 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.
# 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): ...
// 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.