A channel's message slots are named from the client, on every backend

The IR called them react-message and django-message, so a FastAPI channel had
to declare a DjangoMessage. They are client-message and server-message now,
and the direction words hold wherever a channel is declared: Params /
ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and
friends so no backend names a type itself. Django's ReactChannel and
FastAPI's ReactChannel are both Channel.

mizan-fastapi never registered a channels extension, so build_ir() emitted no
channel at all and every payload type was invisible to codegen. It registers
one now. RegistryExtension is an ABC requiring all(), which is what the IR
reads — an extension that cannot enumerate its registrations no longer exists.

The gate that should have caught the rename could not: tests/afi registered no
channel because mizan-rust had no channel registry to register one in, so a
five-package rename of the wire contract passed byte-parity without a channel
byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a
#[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's
split; the AFI fixture now carries a channel with every slot and one with a
single slot, so all three backends prove the contract byte for byte.

MizanChannel held three Option<String> beside three has_*() predicates and
unwrapped them with defaults; it holds an ordered slot vector, so an absent
slot is absent rather than defaulted. The channels target emitted a React
hooks file that a stage1-only consumer could not compile — react emits that
now. The codegen's parity tests byte-compared emitted source against baselines
without ever compiling it: they compile the generated crate and run its tests,
import the generated Python package and call every method, and typecheck each
TypeScript target against a consumer.

Also fixed at source: app_visitor printed its import diagnostic to stdout, the
stream export_mizan_ir writes KDL to, so a failed import silently corrupted the
IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on
any non-JSON-serializable field default; Django and mizan-core derived Pascal
names two different ways, disagreeing on every dotted channel name.

ir.py builds a document and renders templates/ir/document.kdl.j2 rather than
appending KDL strings with hand-tracked indentation, and named types resolve to
a fixed point — a model reachable only through a union branch was referenced by
a ref that no type block ever defined.

The rest is the write-gate's own classifiers run over the standing tree:
relative imports, silent swallows, Protocol contracts that should be ABCs,
emitters hand-rendering target source, catch-all arms over closed enums, and
comments narrating the project rather than the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:03:19 -04:00
parent 398c90fc8b
commit 3aafec6dd4
345 changed files with 11054 additions and 17359 deletions

View File

@@ -1,9 +1,9 @@
/**
* Cache backends — MemoryCache for testing.
* A cache backend is a flat string-to-string store.
*
* Simple key-value store. No reverse indexes.
* There is no reverse index from a context to its keys, so `deleteByPrefix` is
* what a broad purge relies on and every backend owes it.
*/
export interface CacheBackend {
get(key: string): string | null
set(key: string, value: string): void
@@ -29,6 +29,7 @@ export class MemoryCache implements CacheBackend {
deleteByPrefix(prefix: string): number {
let count = 0
// Snapshot the keys — deleting while iterating the live view is UB.
for (const key of [...this._store.keys()]) {
if (key.startsWith(prefix)) {
this._store.delete(key)

View File

@@ -1,11 +1,3 @@
/**
* mizan cache — TypeScript adapter.
*
* Same protocol as Python's mizan.cache. Cross-language conformance
* verified by pin tests. No reverse indexes — scoped purge recomputes
* the key directly, broad purge uses prefix scan.
*/
export { MemoryCache } from './backend'
export type { CacheBackend } from './backend'
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
@@ -52,6 +44,13 @@ export function cachePut(
backend.set(key, value)
}
/**
* Delete cached entries for a context. Returns the number removed.
*
* With params and a secret the exact key is recomputed and dropped; without
* them every key carrying the context prefix is scanned and dropped. There is
* no reverse index from a context to its keys, so those are the only two forms.
*/
export function cachePurge(
backend: CacheBackend,
context: string,
@@ -61,11 +60,9 @@ export function cachePurge(
rev: number = 0,
): number {
if (params && secret) {
// Scoped purge — recompute key and delete directly
const key = deriveCacheKey(secret, context, params, userId, rev)
return backend.delete(key) ? 1 : 0
} else {
// Broad purge — prefix scan
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
return backend.deleteByPrefix(prefix)
}

View File

@@ -1,10 +1,8 @@
/**
* Cache key derivation — HMAC-SHA256 over JSON-canonical form.
* Cache key derivation — HMAC-SHA256 over a JSON-canonical form.
*
* Protocol-critical: must produce identical output to Python's derive_cache_key.
* Cross-language conformance verified by pin tests.
*
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
* Key format: "ctx:{context}:{hmac_hex}". The context prefix is what lets a
* broad purge run as a prefix scan over the backend's keyspace.
*/
import { createHmac } from 'crypto'
@@ -13,7 +11,11 @@ const CONTEXT_KEY_PREFIX = 'ctx:'
/**
* JSON.stringify with recursively sorted keys and no whitespace.
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
*
* Hand-rolled rather than JSON.stringify because the bytes must match
* Python's json.dumps(obj, sort_keys=True, separators=(",", ":")) exactly —
* a key derived here is looked up by the Python side under the same secret,
* so any serialization drift silently splits the keyspace in two.
*/
function stableStringify(obj: any): string {
if (obj === null || obj === undefined) return 'null'