The reverse index approach (Redis sets tracking HMAC keys per context)
was over-engineered. Scoped purge doesn't need an index — recompute
the HMAC key from the invalidation params and DELETE directly. One
Redis command, no TOCTOU race, no atomicity concern, no stale members.
Broad purge uses key-prefix scan (keys are now "ctx:{context}:{hmac}").
This is rare (Tier 3 fallback) and acceptable as a SCAN operation.
Eliminated from both Python and TypeScript:
- All SET/SADD/SMEMBERS/SREM index operations
- CacheBackend.get_index, remove_from_index, delete_index, delete_indexes_by_prefix
- build_index_keys function
- Pipeline transaction complexity
- TOCTOU race condition (was critical, now impossible)
Backend interface is now 5 methods: get, set, delete, delete_by_prefix, clear.
Redis tests updated — prefix isolation test added, connection leak fixed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13 tests hitting Redis on localhost:6399 (docker run redis:alpine):
- get/put/delete, index tracking, remove_from_index, delete_by_prefix
- TTL verification on cache entries AND index sets
- Pipeline atomicity (value + indexes written together)
- Scoped purge (AND semantics) against real Redis
- Broad purge with sub-index cleanup
- Tests skip gracefully if Redis is not available
No mocks, no fakes. Real Redis or skip.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port of Python's origin-side cache to TypeScript:
- cache/keys.ts: deriveCacheKey with stableStringify for JSON-canonical HMAC
- cache/backend.ts: MemoryCache (same API as Python)
- cache/index.ts: cacheGet, cachePut, cachePurge with AND semantics
Integrated into dispatch.ts:
- handleContextFetch: cache lookup before execution, store after
- handleMutationCall: purge on invalidation
Cross-language pin test proves Python and TypeScript produce identical
HMAC-SHA256 output for the same inputs:
Public: 605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6
User-scoped: 30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2
34 TypeScript tests (9 new), 165 Python tests (1 new pin test).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Critical:
- Separate MIZAN_MWT_SECRET from MIZAN_CACHE_SECRET — compromising one
no longer compromises the other (token forgery vs cache poisoning)
- Move kid from JWT payload to JOSE header per RFC 7515 — standard
libraries use header kid for key selection before payload decode
High:
- Full SHA-256 pkey (64 chars) instead of truncated 16 — no reason to
reduce collision resistance
- Add nbf (not-before) claim for clock skew protection
- Log warnings in _try_mwt_auth on missing secret and decode failures
instead of silent swallow
- Rename _csrf_protect_unless_jwt to _csrf_protect_unless_token (accuracy)
- decode_mwt logs at DEBUG level on failures for observability
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
MWT is a standard JWT with Mizan-specific claims on X-Mizan-Token header:
- sub: user_id for HMAC cache key derivation
- pkey: deterministic hash of user's permission state (staff + superuser + perms)
- kid: key ID for future secret rotation
- aud: audience binding for cross-tenant protection
Executor checks X-Mizan-Token first, falls back to Authorization: Bearer
for legacy JWT compat. Invalid tokens return 401 (no session fallback).
New: mizan/mwt.py (create_mwt, decode_mwt, MWTUser, compute_permission_key)
New: mwt_obtain server function for session-to-MWT issuance
New: MIZAN_MWT_TTL setting (default 300s = 5 min permission staleness window)
11 new tests covering creation, decode, pkey determinism, auth integration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
rev=N: bumped by developer when function logic changes. Becomes part of
the HMAC cache key — old cache entries are unreachable without purge.
Effective rev for a context is max(rev) across all functions in it.
cache=int|False|True: TTL escape hatch for unobservable mutations.
cache=60 emits s-maxage=60. cache=False emits no-store. Default (True)
emits s-maxage=31536000 (forever, purge on mutation).
Effective cache for a context is min(TTL) across functions, with False
taking precedence.
Both parameters flow through: decorator → meta → manifest → cache key
and Cache-Control headers. Implemented in both Python and TypeScript
with 13 Python tests and 4 TypeScript tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- RedisCache.put: add pipe.expire() on index sets matching entry TTL,
prevents orphaned index entries when cache values expire
- Broad purge: delete_indexes_by_prefix() cleans per-param sub-indexes
(mizan:idx:ctx:k=v) that previously leaked as dead sets
- Move cache imports to top of executor.py (were inline in view functions)
- Update KNOWN_ISSUES.md — all 16 issues now resolved or documented
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
private=True: server-internal functions (webhooks, cron) that emit
invalidation but are not client-callable. Rejected from POST /call/
with 403. No codegen. Appears in manifest for Edge.
@client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
def stripe_webhook(request) -> HttpResponse: ...
route=: Mizan-owned URL pattern for view-path functions. Registered
during autodiscovery. Populates page_routes in the manifest for
Edge/PSR to resolve during invalidation.
methods=: HTTP methods for the route. Defaults to ['GET'] for context
functions, ['POST'] for mutations.
Extended Edge manifest with:
- mutations section: affects, auto_scoped_params, private, route
- render_strategy: "psr" (no user params) or "dynamic_cached" (user-scoped)
- user_scoped: derived from param names matching common identity params
- page_routes: from route= on view-path functions + external view_urls
323 Django tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
19 tests that prove Edge caching is possible before Edge exists:
- Deterministic JSON (byte-identical responses for same input)
- Sorted JSON keys for consistent cache keys
- Cache-Control: public on context GETs, no-store on mutations/errors
- Vary: Authorization, Cookie differentiates by auth state
- Auth-dependent responses: same URL, different user → different body
- X-Mizan-Invalidate header round-trip: format → parse → verify
- Header matches JSON body invalidation targets
- Special characters in param values: semicolons, spaces, quotes
are URL-encoded to prevent delimiter collisions
- Large invalidation sets (20 contexts) serialize and parse correctly
- Concurrent mutations produce independent, correct headers
- Empty invalidation: no affects → no header, no body key
- Param order irrelevant for response determinism
Design decision: param values in X-Mizan-Invalidate are URL-encoded
(percent-encoded). This prevents semicolon collision when values
contain the delimiter character.
301 Django tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
9 tests that use Django's test Client instead of RequestFactory.
These go through URL routing, middleware (sessions, CSRF, auth),
and real request parsing — proving the protocol works end-to-end:
- Mutation with auto-scoped invalidation (JSON body + header)
- Context fetch with bundled response + CDN headers
- String-to-int query param coercion
- Broad invalidation fallback (no matching args)
- Function-level affects targeting
- 404 for unknown functions and contexts
- Method enforcement (GET-only on /ctx/, POST-only on /call/)
282 Django tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove affects_params lambda. Scoping is now automatic:
Tier 1 - Argument name matching:
If the mutation's args overlap with the context's params by name,
the invalidation is auto-scoped. No developer annotation needed.
@client(context=UserContext)
def user_profile(request, user_id: int) -> UserShape: ...
@client(affects=UserContext)
def update_profile(request, user_id: int, name: str) -> dict: ...
# user_id matches → invalidate: [{context: "user", params: {user_id: 5}}]
Tier 2 - Auth inference (Edge-side, not implemented in framework)
Tier 3 - Broad fallback when no param names match
Also adds function-level affects targeting:
@client(affects='user_profile') # only user_profile, not user_orders
def update_name(request, user_id: int, name: str) -> dict: ...
Function names resolve to their parent context for param lookup.
v1 runtime refetches the whole context regardless, but the protocol
carries the function-level signal for Edge and future optimization.
273 Django tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
affects_params is a callable that extracts which specific params were
affected by a mutation. The server uses it to produce scoped
invalidation in both transports:
@client(affects=UserContext, affects_params=lambda req: {'user_id': req.user.pk})
def update_avatar(request, url: str) -> dict: ...
JSON body: {"result": ..., "invalidate": [{"context": "user", "params": {"user_id": 42}}]}
Header: X-Mizan-Invalidate: user;user_id=42
Edge reads the scoped params to purge only /profile/42/ instead of
all user profiles. The runtime refetches only the UserContext mounted
with user_id=42, not all UserContext instances.
Requires affects= to be set. Falls back to broad invalidation if
the callable fails.
272 Django tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mutation responses now carry invalidation via two transports:
1. JSON body: {"result": ..., "invalidate": ["user"]}
2. HTTP header: X-Mizan-Invalidate: user, notifications
Both are set on every mutation response. The JSON body is consumed
by the client runtime (mizanCall). The header is consumed by Edge
for CDN cache purging and by XHR responses for htmx-style apps.
Header format: comma-separated contexts, semicolon-separated params.
X-Mizan-Invalidate: user;user_id=5, notifications
Also: _resolve_invalidation and _format_invalidate_header extracted
as reusable helpers for when return-type branching adds HttpResponse
support (view-path mutations will only use the header transport).
Updated ROADMAP.md with full v1 plan including both transports,
return-type branching, affects_params, and Edge manifest.
270 Django tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mutation responses now include invalidation directives from the server:
POST /api/mizan/call/
→ {"result": {...}, "invalidate": ["user"]}
The client never hardcodes invalidation targets. The server resolves
affects= metadata and returns what to invalidate. mizan-runtime reads
the invalidate key and triggers refetches automatically.
Context fetch returns raw bundled data (not wrapped):
GET /api/mizan/ctx/user/?user_id=5
→ {"user_profile": {...}, "user_orders": [...]}
Also fixed QueryDict handling (use .dict() not dict() to avoid
list-wrapped values).
267 Django tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
packages/
mizan-runtime/ Framework-agnostic state engine (~150 lines)
Context registry, batched invalidation, fetch primitives
mizan-django/ Django server adapter (was packages/mizan-rpc/adapters/django/)
Codegen moved to mizan-django/generate/
mizan-react/ React adapter (was packages/mizan-csr/adapters/react/)
Removed premature abstractions: mizan-ast, mizan-schema, mizan-rpc,
mizan-csr, mizan-ssr stub packages. The actual architecture is three
concrete packages, not five abstract layers.
mizan-runtime implements the v1 spec: registerContext with params,
scoped invalidation via microtask batching, server-driven invalidation
from mutation responses, mizanFetch for context bundles, mizanCall for
mutations.
264 Django + 33 React tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>