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

@@ -264,12 +264,28 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minijinja"
version = "2.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
dependencies = [
"memo-map",
"serde",
]
[[package]]
name = "mio"
version = "1.2.0"
@@ -300,6 +316,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"linkme",
"minijinja",
"mizan-macros",
"serde",
"serde_json",

View File

@@ -1,6 +1,6 @@
//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's
//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}`
//! with a Cache-Control: no-store header.
//! Render a `MizanError` as an axum `Response`: the JSON envelope
//! `{"error": {"code": ..., "message": ..., "details": ...}}` under a
//! `Cache-Control: no-store` header.
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
@@ -15,11 +15,24 @@ impl From<MizanError> for ApiError {
}
}
/// Each variant's status spelled as an axum constant. Naming the constant
/// rather than round-tripping a `u16` leaves no numeric value axum could
/// reject, so the mapping is total.
fn status_of(err: &MizanError) -> StatusCode {
match err {
MizanError::NotFound(_) => StatusCode::NOT_FOUND,
MizanError::BadRequest(_) => StatusCode::BAD_REQUEST,
MizanError::ValidationFailed { .. } => StatusCode::UNPROCESSABLE_ENTITY,
MizanError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
MizanError::Forbidden(_) => StatusCode::FORBIDDEN,
MizanError::NotImplementedYet(_) => StatusCode::NOT_IMPLEMENTED,
MizanError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = StatusCode::from_u16(self.0.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let mut resp = (status, Json(self.0.to_json())).into_response();
let mut resp = (status_of(&self.0), Json(self.0.to_json())).into_response();
resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
resp

View File

@@ -1,15 +1,15 @@
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
//! HTTP handlers for the Mizan endpoints.
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mizan_core::{
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
InvalidationTarget, MergeEntry, MizanError, Primitive, RequestHandle,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use serde_json::{Map, Number, Value};
use std::any::Any;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -21,24 +21,16 @@ use crate::errors::ApiError;
/// `Arc` keeps the clone cheap across per-request handler invocations.
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
/// Body for POST /call/. Matches the Python `CallBody` shape.
/// Body for POST /call/.
#[derive(Debug, Deserialize)]
pub struct CallBody {
pub fn_: Option<String>,
/// `fn` is a Rust keyword, hence the serde rename.
#[serde(rename = "fn")]
pub function_name: Option<String>,
pub function_name: String,
#[serde(default)]
pub args: Map<String, Value>,
}
impl CallBody {
fn resolved_name(&self) -> Option<&str> {
self.function_name
.as_deref()
.or(self.fn_.as_deref())
}
}
#[derive(Debug, Serialize)]
pub struct CallResponse {
pub result: Value,
@@ -47,28 +39,37 @@ pub struct CallResponse {
pub merge: Option<Vec<Value>>,
}
fn no_store(json: Value) -> Response {
let mut resp = (StatusCode::OK, Json(json)).into_response();
fn no_store<T: Serialize>(body: T) -> Response {
let mut resp = (StatusCode::OK, Json(body)).into_response();
resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
resp
}
/// POST /call/ — RPC dispatch.
/// POST /call/ — RPC dispatch. The caller picks the `fn` string, so the
/// handler selects the registrations that string names and matches over the
/// two shapes that selection has; `[]` is the selection a string nothing
/// registered under makes, and it is answered with the NOT_FOUND envelope.
pub async fn function_call(
State(app_state): State<AppStateAny>,
Json(body): Json<CallBody>,
) -> Result<Response, ApiError> {
let fn_name = body
.resolved_name()
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
.to_string();
let fn_spec = lookup_function(&fn_name)
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
let registered = function_named(&body.function_name);
let fn_spec = match registered.as_slice() {
[] => {
return Err(ApiError(MizanError::NotFound(format!(
"function {:?} not registered",
body.function_name
))))
}
[fn_spec, ..] => *fn_spec,
};
let req = RequestHandle::from_dyn(app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
let result = match fn_spec.dispatch(req, Value::Object(body.args.clone())).await {
Ok(result) => result,
Err(e) => return Err(ApiError(e)),
};
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
.iter()
@@ -81,82 +82,86 @@ pub async fn function_call(
Some(merges.iter().map(MergeEntry::to_json).collect())
};
let payload = CallResponse {
Ok(no_store(CallResponse {
result,
invalidate,
merge: merge_payload,
};
Ok(no_store(serde_json::to_value(&payload).unwrap()))
}))
}
/// GET /ctx/:context_name/ — bundled context fetch.
/// GET /ctx/:context_name/ — bundled context fetch. The caller picks the
/// path segment, so `[]` is the selection a segment no registered function
/// declares membership in makes, answered with the NOT_FOUND envelope.
pub async fn context_fetch(
State(app_state): State<AppStateAny>,
Path(context_name): Path<String>,
Query(params): Query<BTreeMap<String, String>>,
) -> Result<Response, ApiError> {
if lookup_context(&context_name).is_none() {
return Err(ApiError(MizanError::NotFound(format!(
"context {context_name:?} not registered"
))));
}
let members = context_members(&context_name);
let selected = match members.as_slice() {
[] => {
return Err(ApiError(MizanError::NotFound(format!(
"context {context_name:?} names no registered functions"
))))
}
selected => selected,
};
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
.filter(|f| f.context() == Some(&context_name))
.collect();
if members.is_empty() {
return Err(ApiError(MizanError::NotFound(format!(
"context {context_name:?} has no registered members"
))));
}
// Convert query params (all-string values) to the JSON arg map. Numeric
// params get parsed via the per-function input_params primitive table.
let mut bundled = Map::new();
for fn_spec in &members {
for fn_spec in selected {
let args = coerce_query_args(*fn_spec, &params);
let req = RequestHandle::from_dyn(app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
bundled.insert(fn_spec.name().to_string(), result);
match fn_spec.dispatch(req, Value::Object(args)).await {
Ok(result) => {
bundled.insert(fn_spec.name().to_string(), result);
}
Err(e) => return Err(ApiError(e)),
}
}
Ok(no_store(Value::Object(bundled)))
}
/// Coerce string-valued query params into typed JSON values using the
/// function's declared input_params. Strings that don't parse stay as
/// strings — the dispatch wrapper will raise ValidationFailed downstream.
/// A query string carries every value as text, so each declared input param
/// reads its raw text as the primitive it declares. Text spelling something
/// else stays the text it already is: `dispatch` validates every arg against
/// the declared shape and is the one step that words the VALIDATION_FAILED
/// answer, so re-wording it here would give one request two spellings of the
/// same complaint.
fn coerce_query_args(
fn_spec: &dyn FunctionSpec,
params: &BTreeMap<String, String>,
) -> Map<String, Value> {
let mut out = Map::new();
for ip in fn_spec.input_params() {
if let Some(raw) = params.get(ip.name) {
let parsed = match ip.primitive {
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
serde_json::Number::from_f64(v).map(Value::Number)
}),
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
for (_, raw) in params.iter().filter(|(name, _)| name.as_str() == ip.name) {
let as_text = Value::from(raw.clone());
let coerced = match ip.primitive {
Primitive::String => as_text,
Primitive::Boolean => match raw.as_str() {
"true" => Value::Bool(true),
"false" => Value::Bool(false),
_spells_neither => as_text,
},
Primitive::Integer => match raw.parse::<i64>() {
Ok(integer) => Value::from(integer),
Err(_spells_no_integer) => as_text,
},
Primitive::Number => match raw.parse::<f64>() {
Ok(float) => match Number::from_f64(float) {
Some(number) => Value::Number(number),
None => as_text,
},
Err(_spells_no_number) => as_text,
},
};
if let Some(v) = parsed {
out.insert(ip.name.into(), v);
} else {
out.insert(ip.name.into(), Value::from(raw.clone()));
}
out.insert(ip.name.into(), coerced);
}
}
out
}
/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint.
/// CSRF is a Django-only concern; the Rust adapter returns a null token so
/// readiness-probe consumers see a well-formed response.
/// GET /session/ — emits `{"csrfToken": null}`.
pub async fn session_init() -> Response {
let body = serde_json::json!({ "csrfToken": null });
no_store(body)
no_store(serde_json::json!({ "csrfToken": null }))
}

View File

@@ -13,8 +13,8 @@
//! }
//! ```
//!
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
//! * `GET /session/` — session-init probe (placeholder CSRF token)
//! Exposed endpoints:
//! * `GET /session/` — session-init probe
//! * `POST /call/` — RPC dispatch with invalidate+merge response
//! * `GET /ctx/:name/` — bundled context fetch
@@ -51,8 +51,7 @@ where
}
/// Router variant for callers that have no app state to thread — the
/// dispatch path receives a unit-typed handle. Used by the AFI fixture
/// and other stateless test apps.
/// dispatch path receives a unit-typed handle.
pub fn router_stateless() -> Router {
router(())
}