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

@@ -1747,6 +1747,12 @@ 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 = "memoffset"
version = "0.9.1"
@@ -1762,6 +1768,16 @@ 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 = "miniz_oxide"
version = "0.8.9"
@@ -1789,6 +1805,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"linkme",
"minijinja",
"mizan-macros",
"serde",
"serde_json",

View File

@@ -1,21 +1,17 @@
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
//!
//! Ships as a Tauri plugin. The consumer installs it with one line:
//! Ships as a Tauri plugin:
//!
//! ```ignore
//! tauri::Builder::default()
//! .plugin(mizan_tauri::init())
//! .run(tauri::generate_context!())
//! .expect("error while running tauri application");
//! ```
//!
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport`
//! sends call/fetch envelopes to it; the dispatch routes through
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
//! consumes. There is no per-function tauri::command; the registry IS
//! the dispatch table.
//! `plugin:mizan|mizan_invoke`), which routes through `mizan-core`'s
//! FUNCTIONS / CONTEXTS registries. There is no per-function
//! `tauri::command`; the registry IS the dispatch table.
//!
//! Wire envelope:
//!
@@ -24,23 +20,20 @@
//! { "op": "fetch", "context": "session", "params": {} }
//! ```
//!
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
//! mizan-rust-axum:
//! Response shapes:
//!
//! * `call` → `{ result, invalidate, merge? }`
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
//!
//! Error responses come back as the `Err` variant of the Tauri command's
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
//! The TS-side transport re-wraps it into a `MizanError` so consumers
//! see one error surface regardless of transport.
//! Errors come back as the `Err` variant of the command's `Result`, which
//! Tauri serializes into the JS-side `Promise.reject`.
use mizan_core::{
compute_invalidation, compute_merges, lookup_context, lookup_function,
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
InvalidationTarget, MergeEntry, MizanError, RequestHandle,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use serde_json::{Map, Value};
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
@@ -79,9 +72,8 @@ pub enum Envelope {
},
}
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
/// this and constructs a `MizanError`.
/// Error payload returned to the frontend. The JS-side transport reads
/// `code` / `message` / `details` and constructs a `MizanError`.
#[derive(Debug, Serialize)]
pub struct ErrorPayload {
pub code: &'static str,
@@ -114,6 +106,11 @@ impl From<MizanError> for ErrorPayload {
/// it into a `RequestHandle` so `#[mizan::client]` functions can
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
/// emission. Stateless functions ignore the handle.
///
/// Each arm selects the registrations its envelope names and matches over
/// the two shapes that selection has. Both shapes are ordinary: the JS side
/// picks the string, so `[]` is the selection a string nothing registered
/// under makes, and it is answered with the NOT_FOUND envelope.
#[tauri::command]
async fn mizan_invoke<R: Runtime>(
app: tauri::AppHandle<R>,
@@ -123,98 +120,79 @@ async fn mizan_invoke<R: Runtime>(
Envelope::Call {
function_name,
args,
} => handle_call(&app, &function_name, args).await,
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
} => {
let registered = function_named(&function_name);
let fn_spec = match registered.as_slice() {
[] => {
return Err(ErrorPayload::from(MizanError::NotFound(format!(
"function {function_name:?} not registered"
))))
}
[fn_spec, ..] => *fn_spec,
};
let req = RequestHandle::new(&app);
match fn_spec.dispatch(req, Value::Object(args.clone())).await {
Ok(result) => Ok(call_payload(fn_spec, &args, result)),
Err(e) => Err(ErrorPayload::from(e)),
}
}
Envelope::Fetch { context, params } => {
let members = context_members(&context);
let selected = match members.as_slice() {
[] => {
return Err(ErrorPayload::from(MizanError::NotFound(format!(
"context {context:?} names no registered functions"
))))
}
selected => selected,
};
let mut bundled = Map::new();
for fn_spec in selected {
let args = filter_args(*fn_spec, &params);
let req = RequestHandle::new(&app);
match fn_spec.dispatch(req, Value::Object(args)).await {
Ok(result) => {
bundled.insert(fn_spec.name().to_string(), result);
}
Err(e) => return Err(ErrorPayload::from(e)),
}
}
Ok(Value::Object(bundled))
}
}
}
async fn handle_call<R: Runtime>(
app: &tauri::AppHandle<R>,
fn_name: &str,
args: Map<String, Value>,
) -> Result<Value, ErrorPayload> {
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
ErrorPayload::from(MizanError::NotFound(format!(
"function {fn_name:?} not registered"
)))
})?;
let req = RequestHandle::new(app);
let result = fn_spec
.dispatch(req, Value::Object(args.clone()))
.await
.map_err(ErrorPayload::from)?;
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
/// The `call` response body — the handler's result alongside the
/// invalidation targets and merge entries the registry derives from the
/// arguments and that result.
fn call_payload(fn_spec: &dyn FunctionSpec, args: &Map<String, Value>, result: Value) -> Value {
let invalidate: Vec<Value> = compute_invalidation(fn_spec, args)
.iter()
.map(InvalidationTarget::to_json)
.collect();
let merges = compute_merges(fn_spec, &args, &result);
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
None
} else {
Some(merges.iter().map(MergeEntry::to_json).collect())
};
let merges = compute_merges(fn_spec, args, &result);
let mut payload = json!({
"result": result,
"invalidate": invalidate,
});
if let Some(merge) = merge_payload {
payload
.as_object_mut()
.expect("payload is a JSON object")
.insert("merge".into(), Value::Array(merge));
let mut payload = Map::new();
payload.insert("result".into(), result);
payload.insert("invalidate".into(), Value::Array(invalidate));
if !merges.is_empty() {
let entries: Vec<Value> = merges.iter().map(MergeEntry::to_json).collect();
payload.insert("merge".into(), Value::Array(entries));
}
Ok(payload)
Value::Object(payload)
}
async fn handle_fetch<R: Runtime>(
app: &tauri::AppHandle<R>,
context_name: &str,
params: Map<String, Value>,
) -> Result<Value, ErrorPayload> {
if lookup_context(context_name).is_none() {
return Err(ErrorPayload::from(MizanError::NotFound(format!(
"context {context_name:?} not registered"
))));
}
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
.filter(|f| f.context() == Some(context_name))
.collect();
if members.is_empty() {
return Err(ErrorPayload::from(MizanError::NotFound(format!(
"context {context_name:?} has no registered members"
))));
}
let mut bundled = Map::new();
for fn_spec in &members {
let args = filter_args(*fn_spec, &params);
let req = RequestHandle::new(app);
let result = fn_spec
.dispatch(req, Value::Object(args))
.await
.map_err(ErrorPayload::from)?;
bundled.insert(fn_spec.name().to_string(), result);
}
Ok(Value::Object(bundled))
}
/// Filter the envelope's params down to keys this function declares as
/// input. The HTTP/axum adapter coerces string-typed query params to
/// JSON primitives in the equivalent step; the Tauri arg channel already
/// carries typed JSON, so the filter is sufficient on its own.
/// The envelope's params narrowed to the keys this function declares as
/// input. The Tauri arg channel already carries typed JSON, so no
/// string-to-primitive coercion is needed here.
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
let mut out = Map::new();
for ip in fn_spec.input_params() {
if let Some(v) = params.get(ip.name) {
out.insert(ip.name.into(), v.clone());
}
}
out
let declared = fn_spec.input_params();
params
.iter()
.filter(|(name, _)| declared.iter().any(|ip| ip.name == name.as_str()))
.map(|(name, value)| (name.clone(), value.clone()))
.collect()
}