diff --git a/ARCHITECTURE-REWORK.md b/ARCHITECTURE-REWORK.md deleted file mode 100644 index b712c6d..0000000 --- a/ARCHITECTURE-REWORK.md +++ /dev/null @@ -1,336 +0,0 @@ -# Architecture Rework: Cache Keying & Invalidation - -**Date:** 2026-04-06 -**Source:** 8 independent expert reviews (Cloudflare, Enterprise Backend, Django, SaaS Founder, Next.js, React Query, Framework Authoring, Serverless Architecture) - ---- - -## Status Key - -- [x] Fixed -- [ ] **BUG** — broken in shipped code -- [ ] **DESIGN** — must resolve before implementing cache layer -- [ ] **SPEC** — needs specification before building -- [ ] **OPS** — operational gap for production readiness -- [ ] **DX** — developer experience issue -- [ ] **BUSINESS** — product/pricing concern - ---- - -## Bugs in Shipped Code - -### [x] BUG: `Vary: Authorization, Cookie` does nothing on Cloudflare -**Files:** `executor.py:787`, `dispatch.ts:77`, `edge-compat.test.ts:75-79` - -Cloudflare ignores all Vary values except `Accept-Encoding` and `Accept` (images only). This header creates a false sense of security — someone reading the code assumes different Authorization headers produce different cache entries. They do not. The edge-compat tests assert the presence of this non-functional header, reinforcing the illusion. - -**Origin:** Claude hallucination in a prior session. Not a design decision. - -**Fix:** Remove the header from both Python and TypeScript. Remove test assertions. Add a code comment explaining why Vary is not used and pointing to the HMAC cache key strategy (when implemented). - -### [x] BUG: `fn` vs `function` wire protocol key mismatch -**Files:** `executor.py:619`, `runtime/index.ts:128` - -The Django executor reads `body.get("fn")`. The TypeScript runtime sends `{ function: functionName }`. These don't match. Would break on first real use of the new TS runtime against the Django backend. - -**Fix:** Align on one key name. Whichever is chosen, document it as stable wire format. - -### [x] BUG: `max-age=0` defeats the PSR caching model -**File:** `executor.py:786` - -`Cache-Control: public, max-age=0, stale-while-revalidate=300` means the origin gets hit on every request for background revalidation. This conflicts with PSR's purge-based freshness model, where content should be cached until explicitly invalidated. - -**Fix:** For PSR-eligible contexts, emit `Cache-Control: public, s-maxage=31536000`. The CDN caches forever; purge is the only freshness mechanism. Reserve `max-age=0, stale-while-revalidate` for contexts that opt out of PSR or use time-based revalidation. - ---- - -## Critical Design Flaws - -### [ ] DESIGN: HMAC concatenation without delimiter -**Severity:** Security vulnerability — cache key collisions across different logical entries - -`HMAC(secret, context + user_id + params)` without structured separation means `"user" + "12" + "3"` collides with `"user1" + "2" + "3"`. - -**Fix:** Use null-byte delimiters: `HMAC(secret, context + "\x00" + user_id + "\x00" + canonical_sorted_params)`. Or HMAC over a JSON-canonical form. Document the canonical form as part of the AFI protocol spec. - -### [ ] DESIGN: Full context flush on deploy = thundering herd -**Severity:** Operational — self-inflicted DDoS on every deploy that changes a decorator - -Every deploy that changes any `@client` decorator nukes all cached content for affected contexts. Teams deploying 3-5x/day means the Edge cache is cold 3-5x/day. 100K concurrent users + 10 contexts = 1M origin requests in seconds post-deploy. - -**Preferred fix:** Versioned cache keys. Include a manifest content hash in the cache key. Old and new entries coexist during transition. No purge, no thundering herd. 2x cache storage during transition (negligible). Old entries expire naturally via TTL or LRU eviction. - -**Alternative fix:** Granular per-context diffing. Only flush contexts whose function signatures, params, or auth requirements actually changed. The manifest already contains per-context param lists to support this. - -### [ ] DESIGN: Purge token in customer Workers exposes shared cache -**Severity:** Security — one compromised customer can purge all customers' cache - -Every customer Edge Worker deployment carries a Cloudflare API token with `Zone:Cache Purge` permission for `render.mizan.cloud`. - -**Fix:** Build a purge proxy Worker on the Mizan zone. Validates purge requests (HMAC signature + customer-scoped URL pattern matching) before forwarding to the Cloudflare purge API. No customer Worker ever holds a direct zone API token. - -### [ ] DESIGN: Permission key race condition -**Severity:** Data correctness — stale content served for duration of JWT lifetime - -User permission changes (e.g., tier upgrade) don't take effect until JWT expires because: (1) cache key uses only `user_id`, not tier, and (2) permission key comparison uses the JWT-derived value, which is stale until refresh. - -**Options:** -- (a) Make permission-relevant attributes part of the cache key (increases cardinality). -- (b) Accept the JWT-lifetime staleness window, document as known constraint. -- (c) Add short-TTL revalidation for permission-sensitive contexts. - -**Decision needed before implementation.** - -### [ ] DESIGN: No `waitUntil()` in purge/warm flow -**Severity:** Latency — client blocks on cache management operations - -If a mutation invalidates N URLs, the Edge Worker must complete all purge API calls before responding. Each call is 50-200ms. - -**Fix:** Return mutation response immediately. Fire all purge and warming fetches inside `waitUntil()`. Same Worker invocation, no extra billing, client doesn't block. - ---- - -## Missing Specifications - -### [ ] SPEC: Secret rotation protocol -No rotation mechanism, no dual-secret acceptance window, no compromise recovery procedure. Rotating the single secret invalidates every HMAC globally. - -**Need:** Key derivation hierarchy (master secret -> per-context derived keys). Rotation at context level. Dual-secret acceptance window during rotation. Document compromise recovery procedure. - -### [ ] SPEC: GDPR right-to-erasure for cached content -HMAC keys make targeted per-user cache purge difficult. Must reconstruct every possible HMAC for every context x param combination for a given user. - -**Need:** `purge_by_user(user_id)` operation that iterates manifest contexts to reconstruct all HMACs. Tractable if context count is bounded. Audit trail for compliance proof. - -### [ ] SPEC: Cache adapter conformance requirements - -Every Mizan backend adapter (Python, TypeScript, and future: PHP, C#, Go, etc.) must -implement the origin-side cache protocol. This is NOT a binary ABI or pluggable backend -interface. It is a set of operations each adapter implements in its own language, backed -by Redis. Conformance is verified by a shared test suite (same model as the existing -edge-compat tests that prove Python and TypeScript produce identical protocol output). - -**Storage:** Redis. Not pluggable. Not in-memory-only. Redis handles persistence, -cross-worker sharing, and crash recovery. The adapter is a thin protocol layer over -Redis commands. - -**Required operations:** - -``` -cache_get(context: string, params: dict, user_id: string | null, rev: int) -> CachedResponse | null -``` -Derives HMAC key from inputs using JSON-canonical form, fetches from Redis. - -``` -cache_put(context: string, params: dict, user_id: string | null, rev: int, response: CachedResponse) -> void -``` -Derives HMAC key, stores response in Redis. Also maintains a reverse index -(context + params -> HMAC keys) so `cache_purge` can find entries to delete. - -``` -cache_purge(context: string, params: dict | null) -> int -``` -Looks up the reverse index for matching entries, deletes them from Redis. -Returns number of entries purged. When `params` is null, purges entire context. - -``` -cache_purge_user(user_id: string) -> int -``` -Iterates all contexts in the manifest, reconstructs HMAC keys for the given -user_id across all param combinations in the reverse index, deletes them. -Required for GDPR right-to-erasure. - -**HMAC key derivation (must be identical across all adapters):** - -``` -key = HMAC-SHA256(secret, JSON.stringify({ - "c": context, - "p": sorted_params, - "r": rev, - "u": user_id // omitted for public content -}, sort_keys=True)) -``` - -**MWT validation (must be identical across all adapters):** - -Validate the `X-Mizan-Token` header as a standard JWT (HMAC-SHA256). Extract `sub` -(user_id) for cache key derivation, check `exp` for token freshness. - -**Conformance test suite:** - -Each adapter must pass a shared set of protocol conformance tests verifying: -- Identical HMAC output for identical inputs (cross-language determinism) -- Identical MWT validation behavior -- Correct purge semantics (scoped and broad) -- Correct reverse index maintenance -- Correct `cache_purge_user` behavior - -### [ ] SPEC: Client-side cache lifecycle -Runtime is ~95 lines. No `staleTime`, `isFetching`/`isLoading` distinction, garbage collection, retry logic, optimistic updates, `refetchOnWindowFocus`. - -**Minimum viable:** -- Loading/fetching state distinction (don't throw on missing data) -- Error return shape: `{ data, isLoading, isFetching, error }` -- `refetchOnWindowFocus` as default -- Mutation lifecycle with rollback support for optimistic updates -- Garbage collection for unmounted context data (configurable delay) - -### [x] SPEC: Per-context cache policy - -`cache=` on `@client` accepts three forms: - -- **Omitted (default):** Invalidation-based. Emits `s-maxage=31536000`. Cache forever, - purge on mutation. Use when your backend is the source of truth. -- **`cache=60` (integer seconds):** TTL-based. Emits `s-maxage=60`. Accept bounded - staleness. Use for unobservable mutations — when your backend mirrors external data - (third-party APIs, aggregations, upstream services) and cannot know when it changes. -- **`cache=False`:** Never cache. Emits `Cache-Control: no-store`. Use for - non-deterministic functions (`random()`, `datetime.now()`). - -This is the escape hatch for data the backend doesn't own the mutation scope for. -Positioned in docs as: "Are you the source of truth, or a mirror? Source of truth → -use `affects=`. Mirror → use `cache=N`." - -The `cache=int` value flows into the edge manifest per-context, so the Edge Worker -and CDN respect it without special handling (`s-maxage` is standard CDN behavior). - -### [ ] SPEC: Extension points for cache/invalidation lifecycle -Zero hooks for third-party code. No pre-invalidation hook, no custom cache key function, no invalidation transport plugin. - -**Minimum viable:** -- `CacheBackend` protocol (third parties implement custom backends) -- `on_invalidate(context, params)` event hook (monitoring/debugging) -- Document these as public API from day one - -### [x] SPEC: Manifest versioning -The manifest has no version field. When the schema evolves, Edge Workers can't distinguish v1 from v2 format. - -**Fix:** Add `"version": 1` to manifest root before anyone deploys it. Edge Workers check version and fail fast on unknown versions. - -### [x] SPEC: Wire format convention -Python emits `snake_case` params (`user_id`). TypeScript conventionally uses `camelCase` (`userId`). The `USER_SCOPED_PARAMS` set in `manifest.ts` contains both conventions. Invalidation headers from Python won't match TypeScript keys expecting `camelCase`. - -**Fix:** Document `snake_case` as the wire format convention. TypeScript adapters convert at the boundary. - ---- - -## Operational Gaps - -### [ ] OPS: No cache observability -No hit/miss metrics, no cache key debugging, no invalidation audit trail, no manifest version tracking. - -**Need:** `X-Mizan-Cache-Status` response header (HIT/MISS/BYPASS/STALE/PURGED/DYNAMIC). Structured logging in Edge Worker. Console-level invalidation event log for devtools. - -### [ ] OPS: Purge rate limits at scale -Cloudflare zone purge API: 500 req/10s (free/pro), 2500/10s (Enterprise). Bulk operations can exceed this. - -**Need:** Batch purge requests (up to 30 URLs per API call). Document rate limits. Design Cache Tags upgrade path for Enterprise. - -### [ ] OPS: Purge-then-warm race condition -Warming fetch arriving at a PoP before purge propagates gets a cache HIT on stale data. - -**Fix:** Use `Cache-Control: no-cache` or `cf: { cacheTtl: 0 }` on warming requests to force revalidation. - -### [ ] OPS: PSR warming only warms one colo -Warming fetch from a Worker runs in a single datacenter. Only warms that colo's cache (+ upper-tier if Tiered Cache active). Does not warm all 300+ PoPs. - -**Document:** PSR warming reduces origin load by warming the shield tier. First request from each edge PoP is still a cache miss to the shield. Not zero-latency for all users. - ---- - -## Django Integration Concerns - -### [ ] DX: `@client` breaks decorator stacking -`@client` returns a class (`FunctionWrapper`), not a callable. `@login_required`, `@csrf_exempt`, `@cache_page` cannot compose with it. - -**Options:** -- (a) Make `@client` return a wraps-compatible callable that also carries metadata (Django Ninja approach). -- (b) Document incompatibility prominently. Provide Mizan-native equivalents. State that `@client` replaces `@login_required` (via `auth=`), `@cache_page` (via context caching), etc. - -### [ ] DX: `JWTUser` too thin for complex auth checks -Works for `is_staff`/`is_superuser`. Fails for allauth relations, DRF permissions, `request.user.groups.all()`, user model relations. - -**Need:** Document limitation. Provide `get_full_user()` helper that does DB lookup when needed. Or optionally expand JWT claims. - -### [ ] DX: Transaction safety of invalidation -Invalidation in response body is optimistic — fires before `ATOMIC_REQUESTS` commits. If transaction rolls back, invalidation was already sent. - -**Need:** Document as known behavior. Recommend `transaction.on_commit()` for critical paths. When building `mizan-cache`, consider two-phase: mark for invalidation during request, execute purge on commit. - -### [ ] DX: Admin/ORM writes invisible to invalidation -Only `@client(affects=...)` functions trigger invalidation. Django admin saves, management commands, direct ORM writes are invisible. - -**Need:** Document clearly. Provide manual purge API: `purge_context('products', params={'product_id': 42})`. - -### [ ] DX: Cache adapter integration for Django -The Python cache adapter is a thin protocol layer over Redis (not a Django cache backend). -Django developers call `mizan.cache.get(context, params, user_id, rev)` directly. -Provide a `mizan.cache.clear()` for test fixture teardown. Document that this is -separate from Django's `CACHES` framework — Mizan owns its own cache protocol. - ---- - -## Business/Product Concerns - -### [ ] BUSINESS: Free tier + Cloudflare free = 80% of paid product -Existing `Cache-Control` headers on context fetches are CDN-ready. A developer puts Cloudflare free tier in front and gets stale-while-revalidate at 300+ PoPs for $0. The 20% gap (user-scoped HMAC keying, PSR, render Workers) doesn't exist in code yet. - -### [ ] BUSINESS: $20/seat wrong pricing model -"Seat" is undefined for a framework. Usage-based ($0.50/100K requests with generous free tier) or flat-per-project ($29/month) converts better for infrastructure products. - -### [ ] BUSINESS: Ship framework first, cloud second -The framework has working code. The cloud product has zero. Risk: building both depletes runway before either has adoption. Recommended: get 500 devs using `@client` + `affects=` on their VPS first, then build the Edge product for the gap they actually hit. - ---- - -## Validated Design Decisions (No Changes Needed) - -These were confirmed sound by multiple reviewers: - -- **Declarative invalidation graph** (`affects=` + auto-scoping) — unanimously praised as genuinely novel -- **Two-zone `fetch()` pattern** — correct architecture for global CDN caching from Workers -- **Cross-language protocol** — Python/TS with identical manifests, proven by parallel test suites -- **Manifest-driven URL resolution** — eliminates need for cache inventory state (no KV/DOs needed) -- **Typed `ReactContext` for `affects` targeting** — prevents the string-fragility concern (string form is escape hatch only) -- **Replacing React Query** — correct decision given context bundling + transport transparency goals -- **Cost model** — ~$5/month Cloudflare at 10K DAU, ~$20/month at 10x. Origin infra is the real cost. -- **Origin-side Redis cache as L2** — viable fallback behind CDN, same protocol as Edge - ---- - -## Unique Expert Insights - -**Cloudflare Expert:** -- Add `cf.cacheTtl` and `cf.cacheEverything` to all `fetch()` subrequests — don't rely solely on response headers -- Consider Cache Tags (`Cache-Tag` response header) from day one for Enterprise upgrade path -- Consider Durable Objects for per-user cache coordination as alternative to HMAC-in-URL - -**Enterprise Architect:** -- Key derivation hierarchy: master secret derives per-context keys. Compromise of one context doesn't affect others. -- `X-Mizan-Cache-Version` header on every response for self-healing on version mismatch - -**Serverless Expert:** -- Use `renderToReadableStream` (streaming SSR) in Render Worker, not `renderToString`. Memory and CPU budget are tight (128MB / 50ms). -- Cache manifest in `globalThis` in Edge Worker — do not read from KV per-request -- AWS portability: CloudFront invalidation pricing is 10-100x more expensive. Design TTL-based alternative. - -**Next.js Expert:** -- PSR doesn't address cold-start pages (initial population before any mutation) or render fan-out (10K parameterized variants re-rendering on one mutation) -- No streaming/Suspense/progressive delivery — entire context response blocks on slowest function - -**React Query Expert:** -- Wire existing WebSocket push infrastructure to emit invalidation events for named contexts -- Generated hooks should return `{ data, isLoading, isFetching, error }`, not throw on missing data - -**Django Architect:** -- DRF `TokenAuthentication` collision: both use `Authorization: Bearer`, Mizan's JWT decode rejects DRF tokens with a 401 -- `mizan-cache` as Django cache backend, not separate system - -**Framework Authoring:** -- Define `CacheBackend` protocol before implementing — the abstraction is cheaper to get right before users exist -- Add `"version": 1` to manifest root now — adding it later is harder -- `@client` is approaching parameter overload — if `cache` becomes extensible, use `CachePolicy` object pattern, not more kwargs - -**SaaS Founder:** -- The debugging UX for HMAC cache is a black box — invest in an invalidation graph debugging UI as a paid feature -- The `affects=` auto-refetch is the "wow" moment — optimize time-to-that-moment in onboarding diff --git a/CLAUDE.md b/CLAUDE.md index cbaf2e2..79f34a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,13 +10,23 @@ Django + React ships first. The protocol is language-agnostic (proven by mizan-t ## Package Layout -``` -packages/ - mizan-django/ Python backend adapter (323+ tests) - mizan-react/ React frontend adapter (33 tests) - mizan-ts/ TypeScript backend adapter (22+ tests, proves AFI) - mizan-ssr/ Bun SSR worker subprocess -``` +Two layers per side. Per-framework adapters wrap a single shared kernel; codegen targets the adapter. + +**Backend protocol adapters** — implement the wire protocol on a server stack: + +- `mizan-django/` — Django adapter +- `mizan-ts/` — TypeScript adapter (proves the protocol is language-agnostic) + +**Frontend kernel + framework adapters** — kernel is the imperative client primitive set; each framework adapter wraps the kernel in its own idiomatic constructs: + +- `mizan-runtime/` — framework-agnostic kernel; owns data, status, error; adapters subscribe +- `mizan-react/` — React contexts + hooks over the kernel +- `mizan-vue/` — Vue composables over the kernel +- `mizan-svelte/` — Svelte stores/runes over the kernel + +**SSR worker:** + +- `mizan-ssr/` — Bun subprocess used by the Django template backend --- @@ -432,8 +442,24 @@ urlpatterns = [ --- -## What the Codegen Currently Produces (STALE) +## Codegen — Current State -The codegen in `mizan-django/generate/` still produces the **old pattern** that wraps `MizanProvider`. This needs rewriting to use the runtime (`mizanFetch`/`mizanCall`/`registerContext`) directly. See the `mizan-react/src/runtime/index.ts` for the target API. +The codegen is two-stage and per-framework. Stage 1 (in `mizan-django/generate/`) emits the framework-agnostic protocol layer (`callXxx` for mutations, `fetchXxx` for context bundles, types). Stage 2 emits per-framework hooks/composables/stores that subscribe to the `mizan-runtime` kernel. -The SSR pipeline works independently of the codegen — it renders whatever components are registered in the Bun worker. The codegen gap only affects the client-side hooks and context providers. +**What's in place:** + +- Function hooks (`useEcho`, `useUserProfile`, etc.) in the React adapter, subscribing to kernel state via `useSyncExternalStore` +- Context hooks for named contexts and `global` +- Channel hooks for WebSocket transport +- Vue and Svelte equivalents (Stage 2 templates compile, but no live-backend example exercises them — see `ISSUES.md` A4) + +**What's not yet emitted (the wrapper layer):** + +- `` provider component for React (calls `configure()` and mounts the kernel into the component tree) +- `useMizan()` hook for accessing the kernel from React +- Framework-named error class (e.g. `DjangoError`) wrapping `MizanError` from the kernel +- Vue and Svelte equivalents + +The legacy `MizanProvider` in `mizan-react/src/context.tsx` (~750 lines) and the `forms.ts` consumer (~1163 lines) still depend on the pre-kernel API. They're tracked as `ISSUES.md` A1 and A3. Removing them is gated on the wrapper layer above being emitted. + +The SSR pipeline is independent of the codegen — it renders whatever components are registered in the Bun worker. diff --git a/README.md b/README.md deleted file mode 100644 index 496d250..0000000 --- a/README.md +++ /dev/null @@ -1,297 +0,0 @@ -# mizan - -Django + React server functions framework. RPC, not REST. - -You define Python functions. mizan generates typed React hooks. No API routes, no serializers, no endpoint boilerplate. - -```python -# Django -@client(context='global') -def current_user(request) -> UserOutput: - return UserOutput(email=request.user.email) -``` - -```tsx -// React (generated) -const user = useCurrentUser() // typed, SSR-hydrated, auto-refreshed -``` - -## Packages - -| Package | Path | Install | -|---------|------|---------| -| `mizan` (Python) | `django/` | `uv add "mizan[channels] @ git+..."` | -| `@rythazhur/mizan` (TypeScript) | `react/` | `npm install @rythazhur/mizan@git+...` | - -## Quick Start - -### 1. Django setup - -```python -# settings.py -INSTALLED_APPS = [ - "mizan", - "myapp", -] - -# urls.py -from django.urls import include, path -urlpatterns = [ - path("api/mizan/", include("mizan.urls")), -] - -# asgi.py (for WebSocket support) -from mizan import wrap_asgi -from django.core.asgi import get_asgi_application -application = wrap_asgi(get_asgi_application()) -``` - -### 2. Define server functions - -```python -# myapp/mizan_clients.py -from django.http import HttpRequest -from mizan.client import client -from mizan.setup.registry import register -from pydantic import BaseModel - -class EchoOutput(BaseModel): - message: str - -@client -def echo(request: HttpRequest, text: str) -> EchoOutput: - return EchoOutput(message=text) - -register(echo, "echo") -``` - -### 3. Register in apps.py - -```python -class MyAppConfig(AppConfig): - name = "myapp" - - def ready(self): - import myapp.mizan_clients # noqa: F401 -``` - -### 4. Generate TypeScript - -```bash -# django.config.mjs -export default { - source: { - django: { - managePath: '../backend/manage.py', - command: ['uv', 'run', 'python'], - }, - }, - output: 'src/api/generated.ts', -} -``` - -```bash -npx mizan-generate -``` - -This produces typed hooks, a typed provider, form hooks with Zod validation, and channel hooks. - -### 5. Use in React - -```tsx -// layout.tsx -import { DjangoContext } from '@/api' - -export default function Layout({ children }) { - return {children} -} -``` - -```tsx -// page.tsx -import { useEcho, useCurrentUser, DjangoError } from '@/api' - -function MyComponent() { - const user = useCurrentUser() - const echo = useEcho() - - const handleClick = async () => { - try { - const result = await echo({ text: 'hello' }) - console.log(result.message) // typed - } catch (e) { - if (e instanceof DjangoError) { - console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc. - } - } - } -} -``` - -## Features - -| Backend | Frontend (generated) | Transport | -|---------|---------------------|-----------| -| `@client` | `useXxx()` | HTTP | -| `@client(context='global')` | `useXxx()` + SSR hydration | HTTP | -| `@client(context='local')` | `useXxx()` with params | HTTP | -| `@client(websocket=True)` | `useXxx()` | WebSocket RPC | -| `@client(auth=True\|'staff'\|callable)` | Auth errors as `DjangoError` | HTTP | -| `mizanFormMixin` | `useXxxForm()` + Zod validation | HTTP | -| `ReactChannel` | `useXxxChannel()` | WebSocket | -| `@compose(...)` | Combined providers | varies | - -## Architecture - -``` -React app - └─ ← generated provider (includes ChannelProvider) - ├─ useCurrentUser() ← generated context hook (SSR-hydrated) - ├─ useEcho() ← generated function hook - ├─ useContactForm() ← generated form hook (Zod + server validation) - └─ useChatChannel() ← generated channel hook (WebSocket) - │ - ├─ HTTP: POST /api/mizan/call/ { fn: "echo", args: { text: "hi" } } - └─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } } - │ - Django executor - ├─ Pydantic input validation - ├─ Auth check (session, JWT, or custom) - ├─ Function execution - └─ Pydantic output serialization -``` - -The generated `DjangoContext` is the **only provider** needed. It wraps `mizanProvider` + `ChannelProvider` and handles session init, CSRF, context auto-fetching, and WebSocket connection. - -## Code Generation - -`npx mizan-generate` reads Django schemas (no running server needed) and produces: - -| File | Contents | -|------|----------| -| `generated.mizan.ts` | Pydantic model types (via openapi-typescript) | -| `generated.django.tsx` | `DjangoContext` provider + all typed hooks | -| `generated.django.server.ts` | SSR hydration helper (`getDjangoHydration`) | -| `generated.forms.ts` | Form hooks with Zod schemas (`useContactForm`, etc.) | -| `generated.channels.ts` | Channel message types | -| `generated.channels.hooks.tsx` | Channel hooks (`useChatChannel`, etc.) | -| `index.ts` | Consolidated re-exports | - -## Error Handling - -All errors from server functions are thrown as `DjangoError`: - -```tsx -try { - await echo({ text: 'hello' }) -} catch (e) { - if (e instanceof DjangoError) { - e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | 'FORBIDDEN' | ... - e.message // Human-readable message - e.details // Field-level validation errors, etc. - e.isAuthError() - e.isValidationError() - e.getFieldErrors('email') - } -} -``` - -Error codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `BAD_REQUEST`, `INTERNAL_ERROR`, `NOT_IMPLEMENTED`. - -## Forms - -Django forms get typed React hooks with client-side Zod validation: - -```python -# Django -class ContactForm(mizanFormMixin, forms.Form): - mizan = mizanFormMeta( - name="contact", - title="Contact Us", - submit_label="Send", - live_validation=True, - ) - name = forms.CharField(max_length=100) - email = forms.EmailField() - message = forms.CharField(widget=forms.Textarea) - - def on_submit_success(self, request): - send_email(self.cleaned_data) - return {"sent": True} -``` - -```tsx -// React (generated) -const form = useContactForm() - -form.schema // { fields: { name: {...}, email: {...} }, title, submit_label } -form.data // { name: '', email: '', message: '' } -form.set('email', v) // typed setter -form.errors // field-level errors (Zod + server) -form.submit() // → { success: true, data: { sent: true } } -``` - -## Channels - -WebSocket channels with typed messages: - -```python -# Django -class ChatChannel(ReactChannel): - class Params(BaseModel): - room: str - class ReactMessage(BaseModel): - text: str - class DjangoMessage(BaseModel): - text: str - user: str - - def authorize(self, params): - return self.user.is_authenticated - - def group(self, params): - return f"chat_{params.room}" - - def receive(self, params, msg): - return self.DjangoMessage(text=msg.text, user=self.user.email) -``` - -```tsx -// React (generated) -const chat = useChatChannel({ room: 'general' }) - -chat.status // 'connecting' | 'connected' | 'disconnected' -chat.messages // ChatDjangoMessage[] -chat.send({ text: 'hello' }) -``` - -## Testing - -```bash -# Django unit tests -cd packages/mizan-django && uv sync --extra dev --extra channels && uv run pytest - -# React unit tests -cd packages/mizan-react && npm test - -# E2E integration tests (real browser, real backend) -docker compose -f examples/django-react-site/docker-compose.test.yml up -d -cd examples/django-react-site/harness && npm install && npx mizan-generate && npx vite --port 5174 & -npx playwright test - -# All at once -make test-all -``` - -## Project Structure - -``` -mizan/ - packages/ - mizan-runtime/ Client state engine (~150 lines, framework-agnostic) - mizan-django/ Django server adapter (decorators, dispatch, contexts, SSR) - mizan-react/ React adapter (thin wrapper around runtime) - examples/ - django-react-site/ E2E tests + Django backend - django-react-desktop-app/ PyWebView desktop app -``` diff --git a/ROADMAP.md b/ROADMAP.md index 877924a..e2fa05f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,88 +1,50 @@ # Mizan Roadmap -## v1 — Django + React +## v1 — Django + Multi-Framework (React, Vue, Svelte) ### Done -- **@client decorator** — `context=`, `affects=`, `auth=`, `websocket=` -- **ReactContext class** — type-safe context/affects references with linting -- **Named contexts** — functions sharing a context name are grouped into one provider and one fetch +- **`@client` decorator** — `context=`, `affects=`, `auth=`, `websocket=`, `private=`, `route=`, `methods=`, `rev=`, `cache=` +- **`ReactContext` class** — type-safe context/affects references with linting +- **Named contexts** — functions sharing a context name grouped into one provider and one fetch - **Context bundling endpoint** — `GET /api/mizan/ctx//` returns all functions in one response - **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}` -- **Scoped invalidation** — runtime supports `invalidate: [{context: "user", params: {user_id: 5}}]` -- **Param elevation** — shared params become required provider props, non-shared become optional -- **Schema export** — `x-mizan-functions` + `x-mizan-contexts` for codegen +- **`X-Mizan-Invalidate` header** — second invalidation transport for view-path responses (redirects, HTML) +- **Return-type branching** — data return → RPC path; `HttpResponse` return → view path +- **Scoped invalidation** — `affects_params` lambda; runtime supports `{context, params}` form - **Auth guards** — `auth=True`, `auth='staff'`, `auth='superuser'`, `auth=callable` - **JWT + session auth** — auto-detected, CSRF handled +- **MWT** — Mizan Web Token for Edge cache keying (separate secret from JWT/cache) - **Shapes** — Pydantic + django-readers for typed query projections - **WebSocket channels** — real-time bidirectional communication -- **Codegen** — generates typed React providers, hooks, mutations from schema -- **Protocol-managed caching** — `no-store` on all origin responses, deterministic JSON on context GETs +- **HMAC cache keying** — origin-side cache with cross-language HMAC conformance (Python + TypeScript pin) +- **Edge manifest** — `python manage.py export_edge_manifest`; both RPC and view-path functions +- **SSR bridge** — Django template backend → persistent Bun subprocess via JSON-RPC +- **`mizan-runtime` kernel** — framework-agnostic imperative client primitives (data/status/error owned by kernel) +- **Two-stage codegen** — Stage 1 emits framework-agnostic protocol layer; Stage 2 emits per-framework hooks (React, Vue, Svelte) +- **`mizan-ts`** — TypeScript backend adapter; proves the protocol is language-agnostic -### Next: X-Mizan-Invalidate Header +--- -Second invalidation transport. For view responses (redirects, HTML), invalidation goes in an HTTP header instead of the JSON body. Both transports are first-class AFI spec. +### Next (in progress) -- Header format: `X-Mizan-Invalidate: user;user_id=5, notifications` -- Comma-separated contexts, semicolon-separated params per context -- Decorator auto-adds header to any HttpResponse with `affects=` -- Edge reads this header to purge cached pages -- Runtime also reads it on XHR/fetch responses (htmx path) +- **React adapter wrapper layer** — codegen emits `MizanContext` provider, `useMizan` hook, `DjangoError` class on top of the `mizan-runtime` kernel. Equivalent wrapper layers for Vue and Svelte adapters. The harness in `examples/django-react-site` is blocked on this. +- **Legacy `MizanProvider` removal (A1)** — `mizan-react/src/context.tsx` (~750 lines) replaced by codegen-emitted wrappers. Blocks v1 `mizan-react` publishing. +- **Forms migration to kernel (A3)** — `mizan-react/src/forms.ts` (~1163 lines) currently consumes legacy `MizanProvider`. Rewrite to use `mizanCall` from the kernel. Blocks A1. +- **Allauth extraction (A2)** — `legacy/allauth/` becomes `mizan-django-allauth` package consuming Mizan's public API. +- **Vue/Svelte e2e validation (A4)** — example apps exercising a live backend end-to-end, like `examples/django-react-site` does for React. +- **Test coverage gaps** — T1–T12 in `ISSUES.md` (kernel state machine, view-path purge, SSR thread safety, retry logic, cross-language HMAC pin, etc.) -### Next: Return-Type Branching +--- -`@client` serves both RPC developers (React/SPA) and view developers (htmx/templates). Return type determines behavior: +### Quality -- **Data return** (dict, Shape, BaseModel) → RPC path. Generates typed hooks. Invalidation in JSON body. -- **HttpResponse return** (render, redirect) → View path. No codegen. Invalidation in `X-Mizan-Invalidate` header. - -Same decorator. Same `affects=`. Same invalidation graph. Two paths. - -### Next: affects_params - -Scoped invalidation with a lambda that extracts which params were affected: - -```python -@client(affects='user', affects_params=lambda req: {'user_id': req.user.pk}) -def update_name(request, name: str) -> dict: - ... -``` - -Produces `invalidate: [{context: "user", params: {user_id: 5}}]` in JSON body or `X-Mizan-Invalidate: user;user_id=5` in header. - -### Next: Edge Manifest - -`mizan-generate --manifest` compiles the decorator registry + Django URL conf into static JSON for Edge: - -```json -{ - "contexts": { - "user": { - "endpoints": ["/api/mizan/ctx/user/"], - "views": ["/profile/:user_id/"], - "params": ["user_id"] - } - } -} -``` - -Edge reads the manifest at deploy time. When it receives `X-Mizan-Invalidate: user;user_id=5`, it resolves URL patterns with params and purges `/profile/5/` and `/api/mizan/ctx/user/?user_id=5`. - -Generated alongside React code. Covers both RPC and view-path functions. - -### Next: Codegen Rewrite - -Generated code uses the runtime directly (`mizanFetch`, `mizanCall`, `registerContext`) instead of the legacy `MizanProvider` pattern. Mutations have zero invalidation knowledge — the runtime reads the server response. - -### Next: SSR Bridge - -Django renders React components server-side via a persistent Bun subprocess. - -- Bun worker: stdin/stdout JSON-RPC, `renderToString`, component registry -- Django bridge: subprocess management, IPC, request synthesis -- Template tag: `{% mizan_render "ProfilePage" user_profile=profile %}` -- Hydration: `window.__MIZAN_SSR_DATA__` consumed by generated providers -- Generated contexts check SSR data before first fetch +- **H5** — Mutation hooks expose no loading/error state +- **H7** — Redis SCAN blocks request path at scale +- **H8** — Svelte codegen uses Svelte 4 stores; should use Svelte 5 runes +- **H9** — Svelte `destroy()` not auto-called (memory leak) +- **H12** — Forms `triggerValidation` captures stale data +- Medium issues (M1–M18) per developer judgment --- @@ -92,7 +54,7 @@ Django renders React components server-side via a persistent Bun subprocess. Cloudflare Workers for automatic edge caching. -- Reads the Edge manifest to configure cache rules +- Reads the edge manifest to configure cache rules - Context GETs cached at edge, keyed by context name + params - Reads `X-Mizan-Invalidate` header from mutation responses to purge caches - Reads JSON `invalidate` key from RPC responses for the same purpose @@ -118,74 +80,13 @@ One-command deployment for Django + React apps. --- -## Protocol Spec (AFI) +## Reference -The protocol is the product. Two invalidation transports. Every endpoint CDN-ready. +Wire protocol shapes (context fetch, mutation call, invalidation transports) are documented in `CLAUDE.md`. Architectural details for specific subsystems live in `docs/`: -### Context fetch - -``` -GET /api/mizan/ctx//?param=value - -200 OK -Cache-Control: no-store - -{ - "function_a": { ... }, - "function_b": [ ... ] -} -``` - -### Mutation call (RPC path — JSON body transport) - -``` -POST /api/mizan/call/ -Cache-Control: no-store - -{ - "result": { ... }, - "invalidate": ["context_name"] -} -``` - -### Mutation call (View path — header transport) - -``` -POST /profile/update/ -302 Found -Location: /profile/5/ -Cache-Control: no-store -X-Mizan-Invalidate: user;user_id=5, notifications -``` - -### Scoped invalidation (JSON) - -```json -{ - "result": { ... }, - "invalidate": [ - "notifications", - { "context": "user", "params": { "user_id": 5 } } - ] -} -``` - -### Scoped invalidation (Header) - -``` -X-Mizan-Invalidate: user;user_id=5, notifications -``` - -### Edge manifest - -```json -{ - "contexts": { - "user": { - "endpoints": ["/api/mizan/ctx/user/"], - "views": ["/profile/:user_id/"], - "params": ["user_id"] - } - } -} -``` +- `docs/AFI_ARCHITECTURE.md` — package architecture, kernel model, adapter strategy +- `docs/CACHE_KEYING.md` — HMAC cache key derivation +- `docs/MWT_SPEC.md` — Mizan Web Token format +- `docs/SSR_ARCHITECTURE.md` — Django template backend, Bun bridge +- `docs/PSR_VS_EDGE.md` — protocol-level rendering vs. paid Edge layer +- `docs/PRODUCT_ARCHITECTURE.md` — product surface and pricing tiers diff --git a/docs/AFI_ARCHITECTURE.md b/docs/AFI_ARCHITECTURE.md new file mode 100644 index 0000000..1ff78e7 --- /dev/null +++ b/docs/AFI_ARCHITECTURE.md @@ -0,0 +1,77 @@ +# AFI Architecture + +Mizan is an **Application Framework Interface (AFI)** — the +server-client unification layer. + +## Package layout + +Two layers per side. Independent packages, single shared protocol. + +**Backend protocol adapters** — implement the wire protocol on a +server stack: + +| Package | Role | +|---|---| +| `mizan-django` | Django adapter | +| `mizan-ts` | TypeScript adapter (proves the protocol is language-agnostic) | + +**Frontend kernel + framework adapters** — the kernel is the +imperative client primitive set; each framework adapter wraps the +kernel in its own idiomatic constructs: + +| Package | Role | +|---|---| +| `mizan-runtime` | Framework-agnostic client kernel — owns data, status, error; adapters subscribe | +| `mizan-react` | React contexts + hooks over the kernel | +| `mizan-vue` | Vue composables over the kernel | +| `mizan-svelte` | Svelte stores/runes over the kernel | + +**SSR worker:** + +| Package | Role | +|---|---| +| `mizan-ssr` | Bun subprocess used by the Django template backend | + +## Two orthogonal products + +- **RPC** — typed client generation via codegen +- **SSR** — server rendering via the Bun bridge + +Independent and composable. Either ships standalone; together they +compose. + +## Kernel model + +The client kernel (`mizan-runtime`) is the one hard thing. Per- +framework adapters are thin idiomatic wrappers around it. Codegen +emits typed bindings against the framework adapter's surface, not +against the raw kernel — so a React developer gets `useEcho()` and +``, a Vue developer gets `useEcho()` composables, a +Svelte developer gets readable stores. Same kernel underneath. + +## Schema is load-bearing + +The backend exports a JSON schema describing every `@client`-decorated +function and context (`x-mizan-functions`, `x-mizan-contexts`). The +schema IS the contract: codegen reads it, the edge manifest derives +from it, MWT auth gates against it. + +## Launch surface + +Python (Django) + React. Vue and Svelte ship as v1 alongside React. +TypeScript backend (`mizan-ts`) proves the protocol is portable. + +## Why the AFI shape + +Quadratic ecosystem growth (N server adapters × M client adapters) +collapses to linear (one adapter per stack) when both sides +communicate through a shared protocol. + +## Invariants + +- All cross-package communication goes through the protocol. No + direct cross-package dependencies. +- New adapters land as new packages, not as modifications to existing + ones. +- Framework adapters wrap the kernel in framework idioms — they + don't bypass it. Codegen targets the adapter, not the raw kernel. diff --git a/docs/CACHE_KEYING.md b/docs/CACHE_KEYING.md new file mode 100644 index 0000000..182169b --- /dev/null +++ b/docs/CACHE_KEYING.md @@ -0,0 +1,73 @@ +# Cache Keying + +*Discovered 2026-04-06.* + +## The gap + +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 doesn't work + +All major CDNs ignore `Vary` for personalized content. No +standardized replacement exists. + +## Resolution: HMAC cache key (JSON-canonical form) + +``` +HMAC-SHA256(secret, JSON.stringify({ + "c": context, + "p": sorted_params, + "r": rev, + "u": user_id // omitted for public content +}, sort_keys=True)) +``` + +### Key derivation rules + +- **Public content** — URL path + query params (standard CDN). +- **User-scoped content** — HMAC key derivation above. +- **`@client(auth=...)`** determines whether content is user-scoped. +- **`rev` parameter** on `@client` for deploy-time logic + invalidation. Bumped by the developer when function logic changes. + +## 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. + +## 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, backed by Redis. +**Conformance verified by a shared test suite.** + +### Required operations + +- `cache_get` +- `cache_put` +- `cache_purge` +- `cache_purge_user` + +### Storage + +Redis only. Handles persistence, cross-worker sharing, crash +recovery. + +## Deploy invalidation + +No full context flush. The `rev` parameter on `@client` is part of +the HMAC key. When the developer bumps `rev`, old cache entries +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. diff --git a/docs/MWT_SPEC.md b/docs/MWT_SPEC.md new file mode 100644 index 0000000..0a9b6cb --- /dev/null +++ b/docs/MWT_SPEC.md @@ -0,0 +1,59 @@ +# MWT — Mizan Web Token + +*Decided 2026-04-06.* + +MWT is a standard JWT (RFC 7519, HMAC-SHA256) with Mizan-specific +claims, traveling on the `X-Mizan-Token` header. It is the +protocol's identity layer for cache keying and permission staleness +detection. + +## Claims + +| Claim | Purpose | +|---|---| +| `sub` | User ID — goes into HMAC cache key derivation | +| `pkey` | Deterministic hash of user's permission state at issuance | +| `exp` | Configurable short TTL — controls permission staleness window (Django setting) | +| `iat` | Issued at | +| `kid` | Key ID — for secret rotation | +| `aud` | Audience binding — prevents cross-tenant replay | + +## Key decisions + +- **Standard JWT envelope, not proprietary.** Uses standard libraries + for signing and validation. +- **`X-Mizan-Token` header, not `Authorization: Bearer`.** Avoids + collision with DRF, allauth, and existing JWT systems. Cloudflare + WAF/Access do not inspect custom headers. +- **Replaces `JWTUser` + `_try_jwt_auth` entirely.** Old approach is + deleted. +- **App handles authentication** (session, social, etc.). Mizan + issues MWT *from* the authenticated identity. +- **Edge Worker** validates MWT, extracts `sub` for HMAC cache key, + checks `exp`. +- **`pkey` computation must be deterministic:** + `sorted(user.get_all_permissions())` then hash. +- **Client-side: proactive refresh before expiry.** Check TTL before + dispatch, not reactively after a 401. +- **Header-based, not cookie-based.** A cookie would force + `Vary: Cookie`, destroying PSR cache. + +## HMAC canonical form + +JSON with sorted keys: + +``` +HMAC(secret, JSON.stringify({"c": context, "p": sorted_params, "u": user_id})) +``` + +## What this solves + +- DRF token collision +- `JWTUser`-too-thin problem +- Permission staleness race condition +- Single validation path across Python and TypeScript Edge + +## Usage rule + +All cache-layer auth code uses MWT, not Django session or raw JWT. +The `@client(auth=...)` parameter gates on MWT validity. diff --git a/docs/PRODUCT_ARCHITECTURE.md b/docs/PRODUCT_ARCHITECTURE.md new file mode 100644 index 0000000..ec4798f --- /dev/null +++ b/docs/PRODUCT_ARCHITECTURE.md @@ -0,0 +1,61 @@ +# Product Architecture + +*Revised April 2026.* + +## Launch product: Mizan Render + +**$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. + +## Deferred: Mizan Deploy + +Django hosting requires IaaS compliance: gVisor, KMS, NIS2, +multi-state privacy. ~$5–8K legal costs. + +**Deferred until Render revenue funds it.** + +TS "Deploy" exists via Workers for Platforms at no additional +compliance cost. + +## Free framework: mizan-cache (origin-side cache) + +Python package implementing the **full cache protocol locally** — +same HMAC key derivation, metadata schema, and purge semantics as +Edge. + +Three backends: + +- In-memory dict (default) +- Redis +- SQLite + +### Dual purpose + +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 + +- `@client(cache=False)` — uncacheable; emits `Cache-Control: no-store`. +- Cache ABI: `get(key)`, `put(key, response, metadata)`, + `purge(context, params)`. + +## 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. diff --git a/docs/PSR_VS_EDGE.md b/docs/PSR_VS_EDGE.md new file mode 100644 index 0000000..ac692fe --- /dev/null +++ b/docs/PSR_VS_EDGE.md @@ -0,0 +1,38 @@ +# PSR vs Edge Delivery + +Two distinct layers that prior conversations have conflated. They are +independent. + +## PSR — Preemptive Static Rendering + +**Protocol feature.** Render HTML on mutation, not on request. + +Mechanism: `@client` fires mutation → backend adapter triggers local +render runtime → HTML stored locally. + +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. + +## Edge Delivery — Mizan Render (Paid Product) + +Pre-rendered HTML cached globally on Cloudflare CDN. + +Uses `fetch()` to a render Worker on a separate domain +(`render.mizan.cloud`) instead of `cache.put()`, because Workers +Cache API is per-datacenter only. `fetch()` across zones goes through +the global CDN cache path with Tiered Cache. + +This layer is the paid Mizan Render product. + +## Caching modes + +- **Public content** — preemptive (render on mutation) +- **User-scoped content** — reactive only (purge on mutation, render + on next request) + +## Invariant + +PSR logic must not couple to Cloudflare-specific APIs. PSR must work +without any cloud infrastructure. Edge delivery extends PSR; it does +not replace it. diff --git a/docs/SSR_ARCHITECTURE.md b/docs/SSR_ARCHITECTURE.md new file mode 100644 index 0000000..718c63b --- /dev/null +++ b/docs/SSR_ARCHITECTURE.md @@ -0,0 +1,50 @@ +# SSR Architecture + +*Decided 2026-04-07.* + +Mizan's SSR adapter is a **Django template backend**. It plugs into +Django's existing `TEMPLATES` setting, replacing the template +rendering engine. + +```python +TEMPLATES = [ + { + 'BACKEND': 'mizan.ssr.MizanTemplates', + ... + } +] +``` + +Then `render(request, 'ProfilePage', context)` calls the Bun +subprocess bridge instead of rendering a Django/Jinja2 template. +**The component name IS the template name.** + +## AFI boundary + +| Side | Responsibility | +|---|---| +| Backend adapter | Implements `mizan.ssr()` — executes context functions, gathers data | +| Frontend adapter | Implements `renderToHTML()` — takes component + props, produces HTML | +| Bun subprocess | Hosts the frontend adapter | +| stdin/stdout JSON-RPC | Transport between the two | + +## Why template backend + +- Django's template system is swappable by design (batteries + included, but replaceable). +- Django developers already use `render(request, template, context)` + — no new API to learn. +- URL routing, views, middleware, auth — all unchanged. +- The template tag `{% mizan_render %}` is a convenience for + developers who *also* use Django templates (e.g., a base.html shell + with Mizan components inside). + +## Implementation surface + +The SSR bridge module implements Django's template backend interface: + +- `BaseEngine` subclass +- `Template` class with `.render(context, request)` + +Everything Django expects from a template backend, but the actual +rendering routes to Bun.