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,40 +1,41 @@
//! Runtime helpers — error envelope, request handle, invalidation/merge
//! resolution. Ports `compute_invalidation` / `compute_merges` /
//! `_resolve_merge_slot` / `_scoped_params` from
//! `backends/mizan-fastapi/src/mizan_fastapi/executor.py:189-263`.
//! Runtime helpers — error envelope, request handle, and the per-response
//! invalidation / merge resolution the adapters call after a dispatch.
use crate::registry::context_members;
use crate::traits::FunctionSpec;
use serde_json::Value;
use std::any::Any;
/// Type-erased handle to the framework's request object. The HTTP adapter
/// stuffs its native `Request` here; user code casts back via the adapter's
/// helper types.
/// A borrow of the request object a hosting framework owns.
///
/// `FUNCTIONS` is a non-generic `distributed_slice`, so `FunctionSpec` has to
/// be object-safe and no type parameter can reach this handle. The reference
/// therefore rides erased, and the crate that names the framework's own type
/// is the one that casts back to it.
#[derive(Clone)]
pub struct RequestHandle<'a> {
pub inner: &'a (dyn Any + Send + Sync),
inner: &'a (dyn Any + Send + Sync),
}
impl<'a> RequestHandle<'a> {
/// Wrap a typed reference. The most common path — handlers downcast back
/// to `T` via `downcast::<T>()`.
/// Wrap a typed reference.
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
Self { inner: req }
}
/// Wrap an already-erased `dyn Any` reference. Used by HTTP adapters
/// that thread an `Arc<dyn Any + Send + Sync>` app state in.
/// Wrap a reference the caller has already erased.
pub fn from_dyn(req: &'a (dyn Any + Send + Sync)) -> Self {
Self { inner: req }
}
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&'a T> {
self.inner.downcast_ref::<T>()
/// The reference the adapter installed.
pub fn installed(&self) -> &'a (dyn Any + Send + Sync) {
self.inner
}
}
/// Mizan's standard error envelope. Mirrors FastAPI's MizanError enum.
/// Mizan's standard error envelope — the closed set of failures an adapter
/// renders onto the wire.
#[derive(Debug, Clone)]
pub enum MizanError {
NotFound(String),
@@ -186,59 +187,28 @@ pub fn compute_invalidation(
.collect()
}
/// Build the `merge` list from a function's `merge` metadata. Each entry
/// names the slot inside the context bundle the return value lands in.
/// Build the `merge` list from the function's already-resolved merge entries.
/// Each names the slot inside the context bundle the return value lands in.
pub fn compute_merges(
fn_spec: &dyn FunctionSpec,
args: &serde_json::Map<String, Value>,
result: &Value,
) -> Vec<MergeEntry> {
let targets = fn_spec.merge();
if targets.is_empty() {
return Vec::new();
}
let mutation_output = fn_spec.output_type();
let mut out = Vec::new();
for ctx_name in targets {
let slot = match resolve_merge_slot(ctx_name, mutation_output) {
Some(s) => s,
None => continue,
};
let scoped = scoped_params(ctx_name, args);
out.push(MergeEntry {
context: (*ctx_name).into(),
slot,
value: result.clone(),
params: if scoped.is_empty() {
None
} else {
Some(scoped)
},
});
}
out
}
/// Find the unique function-name slot whose Output type matches the
/// mutation's Output type. Matches Python's `types_match_for_merge` —
/// structural shape comparison, not name comparison. Returns None on no
/// match or ambiguous match.
fn resolve_merge_slot(context_name: &str, mutation_output: &str) -> Option<String> {
let mutation_shape = crate::graph_check::resolve_type_shape(mutation_output)?;
let mut matches: Vec<&'static str> = Vec::new();
for fn_spec in context_members(context_name) {
if let Some(candidate_shape) = crate::graph_check::resolve_type_shape(fn_spec.output_type())
{
if crate::graph_check::types_match(&candidate_shape, &mutation_shape) {
matches.push(fn_spec.name());
crate::graph_check::merges_for(fn_spec.name())
.map(|resolved| {
let scoped = scoped_params(resolved.context, args);
MergeEntry {
context: resolved.context.into(),
slot: resolved.slot.into(),
value: result.clone(),
params: if scoped.is_empty() {
None
} else {
Some(scoped)
},
}
}
}
if matches.len() == 1 {
Some(matches[0].into())
} else {
None
}
})
.collect()
}
/// Match input args against the context's declared Input field names.
@@ -258,3 +228,36 @@ fn scoped_params(
.collect()
}
#[cfg(test)]
mod tests {
use super::RequestHandle;
use std::any::Any;
fn installed_addr(handle: &RequestHandle<'_>) -> *const () {
handle.installed() as *const (dyn Any + Send + Sync) as *const ()
}
#[test]
fn a_handle_installs_the_very_reference_it_was_built_over() {
let state = String::from("app-state");
let source = &state as *const String as *const ();
assert_eq!(installed_addr(&RequestHandle::new(&state)), source);
}
#[test]
fn an_erased_handle_installs_what_a_typed_one_does() {
let state = String::from("app-state");
assert_eq!(
installed_addr(&RequestHandle::from_dyn(&state)),
installed_addr(&RequestHandle::new(&state))
);
}
#[test]
fn the_installed_reference_keeps_the_type_it_was_built_over() {
let state = String::from("app-state");
let handle = RequestHandle::new(&state);
assert!(handle.installed().is::<String>());
assert!(!handle.installed().is::<i64>());
}
}