The harness was written against the MIZAN.md oracle (<MizanContext>,
provider-per-context, useMizan, etc.) but the codegen had been narrowed
to just hooks-direct-on-kernel after the kernel split. Restoring the
React-idiomatic layer on top of the kernel.
backends/mizan-django/generate/generator/lib/adapters/react.mjs:
- Emits <MizanContext baseUrl="…"> root provider that calls configure()
once and (if a global context is registered) wraps children in
<GlobalContextProvider>.
- Emits <GlobalContextProvider> + <{Name}Context> per named context —
kernel registration happens once per provider mount, not per hook
call. Consumers read from React Context.
- Base hooks: useGlobalContext() / use{Name}Context() return full
ContextState<T> (data + status + error).
- Convenience hooks per context-function (use{Fn}() returns data | null)
and per regular function/mutation (use{Fn}() returns
{ mutate, isPending, error }).
- useMizan() returns { call, fetch } as an imperative escape hatch
for test harnesses or rare cases where typed hooks don't fit.
- Re-exports MizanError, configure, initSession, ContextState from
@mizan/base.
backends/mizan-django/generate/generator/cli.mjs:
- After Stage 2, appends `export * from './<adapter>'` to index.ts so
`import { useEcho, MizanContext } from './api'` works as a barrel.
Bug fixes surfaced during integration:
- react.mjs was generating `from '../index'` (wrong path); flat layout
needs `./index`.
- harness django.config.mjs had `output: 'src/api/generated.ts'` which
the codegen treated as a directory; corrected to `output: 'src/api'`.
- example testapp/clients.py imported from the deleted
mizan.setup.registry path; routed through mizan.setup aggregator.
harness/package.json: adds @mizan/base dep so the generated react.tsx
can resolve its kernel imports.
harness/src/fixtures.tsx:
- DjangoError → MizanError (kernel error class, backend-agnostic).
- useChatChannel sourced from ./api/channels.hooks directly (not
re-exported from the unified index for now).
- Form fixtures removed — forms codegen deferred per Blazr scope.
Verified: harness `vite build` succeeds, 53 modules transformed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reworked the MVP code along the lines Ryth flagged. Same behavior
(11/11 tests still pass), tighter idiom.
executor.py:
- Replaced FunctionResult / FunctionError dataclasses with a MizanError
exception hierarchy (NotFound, BadRequest, ValidationFailed,
Unauthorized, Forbidden, NotImplementedYet, InternalError). Each
carries its own ErrorCode + HTTP status; the dispatcher path raises
rather than returning sentinel objects.
- Auth check uses match/case for the requirement (True / 'staff' /
'superuser' / callable / other) — single declarative dispatch instead
of an if/elif chain.
- Broke up the single 80-line execute_function into focused helpers:
_resolve_function, _enforce_auth, _validate_input, _serialize,
_invalidation_target. The execute_function body now reads as five
declarative steps.
- Input validation uses Pydantic's model_fields[name].is_required()
directly and a list comprehension for required-field reporting,
instead of round-tripping through model_json_schema().
router.py:
- POST /call/ now declares its body as a Pydantic CallBody model;
FastAPI handles parsing + envelope validation. No more manual
await request.json() + dict[get] dancing.
- Endpoint bodies shrink to 3-5 lines each. Context fetch uses a
dict comprehension over the function group.
- mizan_exception_handler renders MizanError to the protocol's
{error: {code, message, details}} envelope.
- mizan_validation_handler maps FastAPI's RequestValidationError to
the same envelope under BAD_REQUEST so the wire format is uniform
whether the failure is body-shape or business validation.
__init__.py: exposes the full exception hierarchy + both handlers
so consumers can wire them onto their FastAPI app declaratively:
app.add_exception_handler(MizanError, mizan_exception_handler)
app.add_exception_handler(RequestValidationError, mizan_validation_handler)
Verified: mizan-core 15/15, mizan-django 348 pass, mizan-fastapi 11/11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Blazr-critical surface for FastAPI. Forms, Channels, Shapes, SSR,
and MWT are out of scope (Ryth's call: defer until Blazr exercises
them; FastAPI projects use native equivalents anyway).
What ships:
- POST /api/mizan/call/ RPC dispatch with Pydantic input validation
- GET /api/mizan/ctx/{name}/ bundled context fetch (all functions in
the named context, parallel-evaluated, single
JSON response)
- JSON-body invalidation transport (the 'invalidate' field on mutation
responses, with auto-scoping when mutation arg names match context params)
- Auth check infrastructure expecting request.state.user populated by
FastAPI middleware/deps (matches FastAPI idioms)
- Cache-Control: no-store on all responses
Built on existing mizan-core: registry (function lookup, context groups,
invalidation metadata), client.function (the @client decorator + ServerFunction
+ _FunctionWrapper). No code copied or duplicated from mizan-django — the
shared substrate is genuinely shared.
Package layout:
backends/mizan-fastapi/
pyproject.toml distribution=mizan-fastapi, module=mizan_fastapi
src/mizan_fastapi/
executor.py dispatch + auth + invalidation
router.py FastAPI APIRouter with the two endpoints
tests/test_dispatch.py 11 e2e tests against TestClient
Test fixture establishes the registration pattern: explicit
register(fn_class, "name") after each @client. mizan-fastapi doesn't
ship discovery — apps register their functions explicitly. (mizan-django
keeps its DjangoAppVisitor discovery; FastAPI's lack of an app system
makes auto-discovery less natural.)
Makefile: install + test targets now include mizan-fastapi alongside
the other packages. New test-core / test-fastapi targets added for
symmetry.
Verified:
- mizan-core: 15/15
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-fastapi: 11/11
- mizan-ts edge-compat: 34/34
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The @client decorator + ServerFunction base + composition machinery is
mostly framework-agnostic. The only Django couplings were typing
(HttpRequest in __init__ and submit_handler signatures) and runtime
view-path detection (HttpResponseBase isinstance/issubclass checks).
Replaced both with backend-extension hooks:
- HttpRequest type hints → Any. Type Protocol can be tightened later.
- HttpResponseBase view-path detection → set_framework_response_base(cls)
hook in mizan_core.client.function. Backends register their framework's
response base at import time. is_framework_response(obj_or_cls) handles
both instance and subclass checks via the registered base.
mizan-django registers HttpResponseBase via mizan/client/__init__.py
before any @client-decorated code is loaded. FastAPI would similarly
register starlette.responses.Response.
Direct consumers updated:
- mizan/setup/discovery.py: ServerFunction import path
- mizan/forms/__init__.py: ServerFunction + create_form_functions imports
mizan/client/__init__.py keeps its public re-export surface stable so
'from mizan.client import client, ServerFunction, …' continues to work
for downstream Django consumers.
Verified:
- mizan-core: 15/15
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-ts edge-compat: 34/34
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original registry tangled function, channel, composition, and form
registration in a single file with polymorphic register() dispatch.
That predates the household discipline; it was the design that was
supposed to ship but didn't. Re-implementing the original intent.
cores/mizan-python/src/mizan_core/registry.py (new):
- _functions, _compositions dicts
- register() — ServerFunction-only, no polymorphic dispatch
- register_as(), register_compose()
- register_extension(name, extension) — hook interface
- get_function/get_compose/get_all_functions/get_all_compositions
- get_contexts, get_context_groups
- get_registry, get_schema — aggregate extension contributions
- validate_registry, clear_registry — cascade-clear extensions
RegistryExtension Protocol:
- schema() returns the extension's schema subdict (keyed under its name)
- clear() resets extension state (called by clear_registry)
mizan-django/src/mizan/channels/__init__.py:
- _ChannelsExtension wraps the channel _registry, plugs into core via
register_extension('channels', ...). Schema output preserves the
same shape codegen consumed before (snake_case keys, type+bidirectional).
mizan-django/src/mizan/forms/__init__.py:
- register_form() and get_forms() helpers moved here (were in setup/registry.py)
- Both use mizan_core.registry under the hood. Forms don't need a
separate extension because form sub-functions register as regular
ServerFunctions with meta.form set.
mizan-django/src/mizan/setup/registry.py: deleted.
mizan-django/src/mizan/setup/__init__.py: re-exports the registry helpers
from mizan_core.registry / mizan.channels / mizan.forms — the Django
adapter's curated public API surface stays stable for users.
Consumers updated: ~10 files imported `from mizan.setup.registry`;
all switched to direct imports from mizan_core.registry, mizan.channels,
or mizan.forms as appropriate. ChannelTests in test_core.py rewritten
to use mizan.channels.register directly (no more polymorphic
@register_as on ReactChannel subclasses).
Verified:
- mizan-core: 15/15
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-ts edge-compat: 34/34 (cross-language pin holds)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cache/backend.py is pure framework-agnostic key-value abstraction —
CacheBackend Protocol, MemoryCache, RedisCache. No Django imports.
Moves to cores/mizan-python/src/mizan_core/cache/backend.py with no
content changes; mizan-django re-imports.
Verified: mizan-core 15/15, mizan-django 348 pass / 21 skip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull cache/keys.py (HMAC cache key derivation) and mwt.py (Mizan Web Token)
out of backends/mizan-django and into a new cores/mizan-python package.
mizan-django re-imports them via the new mizan_core module.
Naming: directory cores/mizan-python/, distribution mizan-core, importable
module mizan_core. mizan-django keeps its existing 'mizan' distribution slot
on PyPI; the two coexist as distinct packages.
Wiring:
- backends/mizan-django/pyproject.toml gains a 'mizan-core' dep with a
[tool.uv.sources] path entry (editable install from ../../cores/mizan-python).
- Makefile install target prepends 'cd cores/mizan-python && uv pip install -e .'
- 3 import sites in mizan-django updated: cache/__init__.py, jwt/functions.py,
client/executor.py — all now import from mizan_core.
Test split:
- 3 unit-test classes (CacheKeyDerivationTests, MWTCreationTests,
PermissionKeyTests) move to cores/mizan-python/tests/, rewritten against
unittest.TestCase (no Django dep). The cross-language pin test (pinned
HMAC hex digests against mizan-ts) moves with CacheKeyDerivationTests.
- Integration tests stay in mizan-django (CacheBackendTests, CachePurgeTests,
CacheIntegrationTests, RevParameterTests, MWTAuthIntegrationTests) — they
need the Django request flow.
Verified:
- mizan-core: 15/15 pass (incl. cross-language pin)
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-ts: edge-compat 34/34 pass — protocol invariant holds, the moved
Python derive_cache_key still produces the exact hex digests TS pins against.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fe39fcb commit captured the file moves (git mv stages those automatically)
but didn't catch the content edits I made afterward — npm package rename
(@mizan/runtime → @mizan/base), path updates in Makefile/Dockerfile/examples,
and doc updates were all left unstaged at commit time.
This commit lands those:
- npm rename: 3 frontend package.jsons (base/vue/svelte) + mizan-base/src/index.ts + 4 codegen templates
- path updates: Makefile, Dockerfile.test, two Gitea workflows, four example/harness configs
- doc updates: CLAUDE.md, ROADMAP.md, ISSUES.md, docs/AFI_ARCHITECTURE.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
packages/ flattens into:
backends/ server protocol adapters (mizan-django, mizan-ts)
frontends/ client kernel + framework adapters (mizan-base, mizan-react, mizan-vue, mizan-svelte)
workers/ runtime workers (mizan-ssr)
cores/ shared language-level primitives (empty for now; mizan-python forthcoming)
The frontend kernel (was packages/mizan-runtime, now frontends/mizan-base) is
renamed to reflect its role — it's the shared base that frontend adapters
depend on directly. Reflects the substrate position that per-framework adapters
wrap a single shared kernel; codegen targets the adapter, not the raw kernel.
Path updates landed in: Makefile, two Gitea workflows, Dockerfile.test, four
example/harness config files, .claude/settings.local.json, four docs
(CLAUDE/ISSUES/ROADMAP/AFI_ARCHITECTURE), four codegen templates (stage1 +
react/vue/svelte adapters), and three package.jsons (the mizan-base rename
plus mizan-vue/svelte peerDeps).
Generated files under examples/django-react-site/harness/src/api/ still
reference @mizan/runtime — left as-is; they're regenerated artifacts and
the harness is non-functional pending the React wrapper-layer codegen.
Also folded in a pre-existing fix: the Gitea workflows had
working-directory: react / django pointing at a layout that predates
packages/, never updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ROADMAP: done items moved out of "Next" (codegen rewrite, SSR bridge,
edge manifest, X-Mizan-Invalidate, return-type branching, affects_params,
kernel extraction, two-stage codegen, mizan-ts). Real "Next" in:
framework-adapter wrapper layer (MizanContext + useMizan + DjangoError
on top of the kernel) for React/Vue/Svelte; A1–A4 from ISSUES.md.
CLAUDE: 4-package layout replaced with the actual 7-package layered
architecture (backend protocol adapters + frontend kernel + framework
adapters + SSR worker). "STALE codegen" section rewritten to describe
what's emitted vs. the wrapper layer that isn't yet.
docs/ now tracked (6 files). AFI_ARCHITECTURE rewritten — replaced
the speculative `mizan-ast`/`mizan-csr`/`mizan-rpc`/`mizan-schema`
package names with the real layout, dropped KDL-schema language for
the actual schema-export format. The other 5 docs/ files were already
current and are tracked as-is.
ARCHITECTURE-REWORK.md deleted — same expert review is re-tracked in
the fresher ISSUES.md, two parallel trackers was sediment.
README.md deleted — drift was beyond surgical fixes (`mizan_clients.py`
convention, `<DjangoContext>` provider, removed `@compose` and
`context='local'`, wrong codegen output filenames, 3-package structure
vs. 7). Rewrite waits for the wrapper-layer codegen to land so
user-facing examples reflect reality.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Discovery convention per MIZAN.md is `clients.py`. The example backend's
asgi.py was still importing the older `mizan_clients` name, causing the
example Django container to fail to start.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Added 6 architectural/cleanup items (A1-A6):
- Legacy MizanProvider not yet removed
- Allauth pending extraction to own package
- Forms codegen not adapted to kernel
- Vue/Svelte adapters not validated end-to-end
- ROADMAP.md and CLAUDE.md likely stale
Added 12 test coverage gaps (T1-T12):
- No tests for C6 kernel state machine
- No tests for generated Vue/Svelte output
- No tests verifying recent fixes (C3/C4/C5/C7/H3/H10/H11/H13)
- No end-to-end integration test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The kernel is no longer a blind refetch pipe. Each context entry has:
{ data, status: idle|loading|success|error, error }
registerContext() returns { getState, subscribe, refetch, unregister }.
Adapters subscribe to state changes via callbacks. The kernel does
the fetch and notifies subscribers with the new state.
React adapter uses useSyncExternalStore for tear-free reads.
Vue adapter uses ref + subscribe callback.
Svelte adapter uses readable store backed by kernel subscription.
All three adapters also get:
- Mutation hooks with { mutate, isPending, error } (fixes H5)
- Vue: onServerPrefetch for Nuxt SSR (fixes M9)
- Svelte: readable store auto-cleans up on unsubscribe (fixes H9)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
H3: mizanFetch retries 2x on server errors (5xx) and network
failures. 200ms/400ms backoff. Mutations NOT retried (not idempotent).
H6: refreshContext now uses GET /ctx/<name>/ instead of POST /call/.
Context reads go to the context endpoint, not the mutation endpoint.
H11: Python cache key derivation normalizes True→"true",
False→"false", None→"null" for cross-language HMAC consistency
with JavaScript's String() behavior.
H13: Forms isValid now checks that all required fields have been
touched, not just that touched fields have no errors.
M11: execute_function return type updated to include HttpResponseBase
for view-path functions.
M18: registerContext cleanup uses ?. instead of ! to prevent crash
if Map was cleared (already fixed in H2 commit but documenting).
373 Django + 33 React tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
C1+C7: Cache purge now passes user_id and works for view-path
mutations. Extracted _purge_cache_for_invalidation() shared helper
used by both RPC and view-path branches.
C2: initSession retries 3x with backoff. Resets on total failure
so next call tries again instead of permanently broken CSRF.
C3: SSR template backend injects __MIZAN_SSR_DATA__ script tag
with serialized props for client-side hydration.
C4: SSR bridge uses _write_lock to serialize stdin writes from
concurrent Django threads. Prevents JSON interleaving.
C5: SSR bridge registers atexit handler for process cleanup.
No more orphaned Bun processes on Django reload/shutdown.
H1: pendingScoped changed from Map to Array — multiple scoped
invalidations for the same context no longer overwrite.
H2: registerContext uses stableKey() (sorted JSON) instead of
bare JSON.stringify. Property order no longer matters.
H4: Named context providers skip refetch if SSR data exists
(matches global context behavior).
H10: _meta always assigned as fresh dict, preventing shared-dict
mutation across ServerFunction subclasses.
373 Django + 33 React tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Renamed:
DjangoError → MizanError
DjangoHTTPClient → MizanHTTPClient
DjangoFormState → MizanFormState
DjangoFormsetState → MizanFormsetState
createDjangoCSRClient → createMizanCSRClient
createDjangoSSRClient → createMizanSSRClient
ensureDjangoSession → ensureMizanSession
useDjangoCSRClient → useMizanCSRClient
TDjangoMessage → TServerMessage
Made CSRF configurable:
configureCsrf(cookieName, headerName) — defaults to Django
conventions but works with any backend that uses CSRF tokens.
Cookie name and header name are no longer hardcoded.
All old names preserved as deprecated aliases in index.ts exports
for backwards compatibility.
Removed dead RouterAdapter re-export (file moved to legacy/).
33 React tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
allauth/ (44 files) is a django-allauth React UI — a separate concern
from the Mizan protocol. Moved to legacy/ pending extraction into a
standalone mizan-django-allauth package.
Also moved to legacy/:
- client/AuthContext.tsx — generic auth state from /me endpoint
- client/RouterContext.tsx — framework-agnostic router adapter
- client/routing.tsx — UserRoute/StaffRoute/AnonymousRoute guards
- client/nextjs.tsx — Next.js router adapter for auth
These are auth UI infrastructure, not Mizan protocol. The Mizan core
only needs JWT for auth header selection (jwt/ stays — MizanProvider
depends on useJWT() to decide between Bearer and session auth).
Cleaned up re-exports in client/react.ts and vitest aliases.
33 React tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The worker receives a file path in the JSON message, dynamically
imports it, renders it. No registerComponent API, no app entry file,
no export maps. Django's template backend resolves the template name
to an absolute path against DIRS, same as every other template engine.
render(request, 'components/Hello.tsx', {'name': 'World'})
Verified working: curl http://localhost:8000/hello/ returns
<div id="mizan-root"><div>Hello, World!</div></div>
Changes:
- worker.tsx: receives file path, dynamic import with cache
- bridge.py: sends file path instead of component name
- backend.py: resolves template name against DIRS to absolute path
- Fix bridge.py:147 bug (referenced deleted 'component' variable)
- Example app: Hello.tsx component, /hello/ view, template config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix testapp/apps.py: import djarea_clients (file was never renamed)
- Fix fetch.mjs: command is export_djarea_schema not export_mizan_schema
- Fix harness package.json: dependency path to mizan-react after restructure
- Add package.json for generator (openapi-typescript dependency)
- Regenerate all example code with new protocol format:
- generated.provider.tsx uses raw context responses + SSR hydration
- generated.server.ts uses GET /ctx/global/ with response.ok check
- generated.forms.ts, channels.ts, channels.hooks.tsx refreshed
- Remove stale generated.django.tsx and generated.django.server.ts
- Update imports: fixtures.tsx and main.tsx import from ./api (index)
- Use MizanContext instead of deprecated DjangoContext in examples
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three bugs fixed:
1. MizanProvider.call() read data.data but server returns data.result.
Now reads data.result and processes data.invalidate for server-driven
invalidation (triggering refetch on mounted context providers).
2. GlobalContextLoader expected {error, data} wrapper but context GET
returns raw bundled data. Fixed to iterate response directly.
3. Named context providers had same wrapper assumption. Fixed to
setData(result) directly.
Two features added:
1. SSR hydration: GlobalContextLoader checks window.__MIZAN_SSR_DATA__
on mount. If present, populates contexts from it and skips fetch.
2. SSR hydration: Named context providers check __MIZAN_SSR_DATA__ in
useState initializer. If SSR data exists for their functions, they
render immediately without fetching.
3. Server-driven invalidation in MizanProvider.call(): reads the
invalidate array from mutation responses and triggers refetch on
mounted providers. Generated mutation hooks' hardcoded invalidation
is now redundant but idempotent — both paths coexist safely.
Also fixed FunctionSuccessResponse type to match new protocol:
{ result: T, invalidate?: [...] }
373 Django + 33 React tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Documents the three protocols (RPC, Invalidation-on-Mutation,
Frontend-Agnostic Rendering), the full @client decorator API surface
with all parameters and _meta structure, the HMAC cache key derivation
scheme, Redis/Memory backends, the MWT/JWT token systems with secret
separation, the SSR template backend + Bun worker bridge, the Edge
manifest format, and the current codegen gap.
Written from reading every source file, not from memory or prior docs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mizan's SSR is a Django template backend. Configure in TEMPLATES:
TEMPLATES = [{
'BACKEND': 'mizan.ssr.MizanTemplates',
'OPTIONS': {'worker_path': 'frontend/ssr-worker.tsx'},
}]
Then render(request, 'ProfilePage', {'user_id': 5}) renders the React
component via a persistent Bun subprocess. The component name is the
template name. The context dict becomes props.
Architecture:
- Bun worker: stdin/stdout JSON-RPC, renderToString, component registry
- Django bridge: subprocess lifecycle, crash recovery, concurrent renders
- Template backend: implements Django's BaseEngine interface
This is the AFI's SSR boundary:
- Backend adapter implements mizan.ssr() (data gathering)
- Frontend adapter implements renderToHTML() (component rendering)
- Bun subprocess is the runtime hosting the frontend adapter
11 tests: ping, render, error handling, crash recovery, concurrent
renders (5 threads), template backend integration. All require Bun.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mizan's protocol layers (origin Redis cache, Edge Worker) handle caching
autonomously. The origin emits Cache-Control: no-store on ALL responses —
browsers and non-Mizan intermediaries must not cache. The Edge Worker
controls CDN caching via cf object, independent of origin headers.
Also fixes:
- TS localeCompare → byte-order sort (localeCompare is locale-sensitive,
would produce different HMAC keys for non-ASCII params vs Python)
- Python cache_purge: empty {} params no longer treated as falsy
(was inconsistent with JS where {} is truthy)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
The TypeScript adapter produces the same manifest, the same
X-Mizan-Invalidate headers, the same JSON invalidation protocol,
and the same CDN-ready response headers as mizan-django.
One Edge Worker. Two backend languages. Same protocol.
Features:
- @client decorator (function wrapper + class method decorator)
- ReactContext class (same API as Django adapter)
- Registry with context groups and param tracking
- Context bundled GET: /api/mizan/ctx/<name>/
- Mutation POST: /api/mizan/call/ with server-driven invalidation
- Three-tier auto-scoping (argument name matching → broad fallback)
- Function-level affects targeting
- private=True (rejected from RPC, in manifest for Edge)
- X-Mizan-Invalidate header with URL-encoded params
- Edge manifest generation (identical format to Django's)
- render_strategy + user_scoped derivation
22 edge compatibility tests pass (Bun, 21ms):
- Deterministic JSON, sorted keys
- Cache-Control: public on GETs, no-store on mutations/errors
- Vary: Authorization, Cookie
- Header round-trip with special characters
- Auto-scoped invalidation matches body and header
- Function-level invalidation
- Private function rejection
- Manifest structure with PSR/dynamic_cached strategies
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>
Mizan is an Application Framework Interface (AFI) with five
independent packages:
packages/
mizan-ast/ Language layer (source → KDL schema)
mizan-schema/ IR layer (KDL schema definition)
mizan-rpc/ Protocol layer (client gen + server adapters)
adapters/django/ ← was django/
generator/ ← was react/src/generator/
mizan-csr/ State layer (client state engine)
adapters/react/ ← was react/
mizan-ssr/ Rendering layer (server-side rendering)
Each package is independent. The adapter directories contain the
framework-specific implementations. Stub packages (ast, schema, ssr)
establish the structure for future work.
264 Django tests + 33 React tests pass from new locations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>