AFI parity: close all 35 gaps — every adapter wires every AFI-common capability

The conformance board (tests/afi/test_capability_parity.py) is now fully green:
90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The
gaps the prose table used to launder as "Django-only" / "out of scope" are
wired, against the pinned-spec model (single-authored spec, byte-identical
conformance across languages) — never per-language reimplementation.

FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest),
WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic
SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes
(SQLAlchemy projection, same declaration surface as django-readers), Forms
(Pydantic schema/validate/submit).

Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth=
enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC
subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT
mint+verify and cache-key derivation byte-pinned to the Python reference
(cache_keys_pin, token_pin, invalidate_header_pin).

TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS
backend can feed the codegen — the largest gap), multipart upload, session-init,
WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms.

Verified in the merged tree: core 25, fastapi 74, django 353/21-skip,
mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 13:44:35 -04:00
parent 58d2cb2848
commit 6c5f6f1fba
81 changed files with 9893 additions and 463 deletions

View File

@@ -1,4 +1,5 @@
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC, riding the
//! shared `mizan-core` dispatch/auth/cache/invalidation/shapes logic.
//!
//! Ships as a Tauri plugin. The consumer installs it with one line:
//!
@@ -9,79 +10,137 @@
//! .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.
//! The plugin exposes commands reachable from the JS-side
//! `@mizan/tauri-transport`:
//!
//! Wire envelope:
//! * `mizan_invoke` — call / fetch / shape / form dispatch (the request/
//! response surface, mirroring the HTTP adapter's POST /call/ + GET /ctx/).
//! * `mizan_subscribe` — opens an IPC subscription `Channel` for a
//! `#[mizan(websocket)]` function; this is the IPC transport's analogue of
//! the HTTP WebSocket — there are no sockets in a desktop shell, so a
//! Tauri `Channel<T>` carries the push stream instead.
//!
//! Wire envelope (the `mizan_invoke` payload's `envelope` field):
//!
//! ```json
//! { "op": "call", "fn": "list_sessions", "args": {} }
//! { "op": "fetch", "context": "session", "params": {} }
//! { "op": "call", "fn": "list_sessions", "args": {}, "token": "..."? }
//! { "op": "fetch", "context": "session", "params": {}, "token": "..."? }
//! { "op": "shape", "fn": "user_profile" }
//! { "op": "form", "form": "contact", "role": "submit", "args": {} }
//! ```
//!
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
//! mizan-rust-axum:
//! Response shapes mirror the HTTP adapter:
//!
//! * `call` → `{ result, invalidate, merge? }`
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
//! * `call` → `{ result, invalidate, merge? }`
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
//! * `shape` → `{ type, fields }`
//! * `form` → the form function's result
//!
//! 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.
//! Auth: the envelope's optional `token` carries an MWT (`X-Mizan-Token`
//! equivalent) or a `Bearer <jwt>`; it is resolved through the shared
//! `authenticate` and enforced against each function's `auth=` requirement.
//! There is no header channel over IPC, so the token rides the envelope.
//!
//! Errors come back as the `Err` variant of the command's `Result`, which
//! Tauri serializes into the JS-side rejection; the TS transport re-wraps it
//! into a `MizanError`.
mod ssr;
pub use ssr::{ssr_render, MizanSsr};
use mizan_core::{
compute_invalidation, compute_merges, lookup_context, lookup_function,
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
authenticate, compute_invalidation, compute_merges, enforce_auth, lookup_context,
lookup_function, now_unix, shapes, AuthConfig, AuthOutcome, AuthRequirement, CacheOrchestrator,
FunctionSpec, Identity, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use tauri::ipc::Channel;
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
Manager, Runtime,
};
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`
/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch
/// command is reachable from JS as `plugin:mizan|mizan_invoke`.
/// The Mizan config Tauri manages: auth (token → identity) + the origin cache.
/// The consumer registers it with `app.manage(MizanTauriConfig { .. })`; the
/// dispatch commands read it from managed state.
pub struct MizanTauriConfig {
pub auth: AuthConfig,
pub cache: CacheOrchestrator,
}
impl Default for MizanTauriConfig {
fn default() -> Self {
Self {
auth: AuthConfig::new(),
cache: CacheOrchestrator::disabled(),
}
}
}
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`.
/// Registers a default (auth-off, cache-disabled) config if the consumer
/// hasn't managed one; commands are reachable as `plugin:mizan|mizan_invoke`
/// and `plugin:mizan|mizan_subscribe`.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::<R>::new("mizan")
.invoke_handler(tauri::generate_handler![mizan_invoke])
.invoke_handler(tauri::generate_handler![
mizan_invoke,
mizan_subscribe,
ssr::ssr_render
])
.setup(|app, _api| {
if app.try_state::<MizanTauriConfig>().is_none() {
app.manage(MizanTauriConfig::default());
}
Ok(())
})
.build()
}
// === Wire envelope ===
/// One Mizan request. The JS-side transport sends `{ envelope: ... }`;
/// Tauri's serde deserializer pulls this struct out of the `envelope`
/// field of the invoke payload.
/// One Mizan request. Tauri's serde deserializer pulls this out of the
/// `envelope` field of the invoke payload.
#[derive(Debug, Deserialize)]
#[serde(tag = "op")]
pub enum Envelope {
#[serde(rename = "call")]
Call {
/// Wire-level function name — registered name on the Rust side.
#[serde(rename = "fn")]
function_name: String,
#[serde(default)]
args: Map<String, Value>,
/// Optional auth token (MWT, or `Bearer <jwt>`) — the IPC analogue of
/// the HTTP `X-Mizan-Token` / `Authorization` headers.
#[serde(default)]
token: Option<String>,
},
#[serde(rename = "fetch")]
Fetch {
context: String,
#[serde(default)]
params: Map<String, Value>,
#[serde(default)]
token: Option<String>,
},
#[serde(rename = "shape")]
Shape {
#[serde(rename = "fn")]
function_name: String,
},
#[serde(rename = "form")]
Form {
form: String,
role: String,
#[serde(default)]
args: Value,
},
}
/// 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`.
/// `{"code", "message", "details?"}` shape.
#[derive(Debug, Serialize)]
pub struct ErrorPayload {
pub code: &'static str,
@@ -105,110 +164,336 @@ impl From<MizanError> for ErrorPayload {
}
}
// === Dispatch ===
// === Auth ===
/// The single Mizan dispatch command. Registered on the plugin's invoke
/// handler — the consumer never wires it directly.
///
/// `app: AppHandle` is auto-injected by Tauri; the function body borrows
/// 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.
/// Resolve identity from an envelope `token`. An MWT is tried first (raw
/// token), then a `Bearer <jwt>`. A present-but-invalid token rejects (the
/// `INVALID`-sentinel contract); absent → anonymous.
fn identity_from_token(
token: Option<&str>,
config: &MizanTauriConfig,
) -> Result<Option<Identity>, MizanError> {
let (mwt, bearer) = match token {
Some(t) if t.starts_with("Bearer ") => (None, Some(t)),
Some(t) => (Some(t), None),
None => (None, None),
};
match authenticate(mwt, bearer, &config.auth, now_unix()) {
AuthOutcome::Authenticated(id) => Ok(Some(id)),
AuthOutcome::Anonymous => Ok(None),
AuthOutcome::Invalid => Err(MizanError::Unauthorized("Invalid or expired token".into())),
}
}
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), MizanError> {
enforce_auth(identity, &AuthRequirement::from_str_opt(fn_spec.auth()))
}
// === Dispatch commands ===
/// The single Mizan request/response command. Tauri auto-injects `app`; the
/// body borrows it into a `RequestHandle` so `#[mizan::client]` functions can
/// `req.downcast::<tauri::AppHandle>()` for managed state or event emission.
#[tauri::command]
async fn mizan_invoke<R: Runtime>(
app: tauri::AppHandle<R>,
envelope: Envelope,
) -> Result<Value, ErrorPayload> {
dispatch(&app, envelope).await.map_err(ErrorPayload::from)
}
/// Dispatch one Mizan [`Envelope`] against an `AppHandle`, returning the JSON
/// response (or a `MizanError`). This is the programmatic entry point the
/// `mizan_invoke` IPC command wraps — exposed so embedders (and behavior
/// tests) can drive the Mizan protocol without the IPC serialization layer.
pub async fn dispatch<R: Runtime>(
app: &tauri::AppHandle<R>,
envelope: Envelope,
) -> Result<Value, MizanError> {
// Read the managed config (lifetime-bound to `app`, which outlives this
// dispatch); fall back to a default if none was registered. The `State`
// guard is held across the awaits below.
let managed = app.try_state::<MizanTauriConfig>();
let default;
let cfg: &MizanTauriConfig = match managed.as_ref() {
Some(state) => state.inner(),
None => {
default = MizanTauriConfig::default();
&default
}
};
match envelope {
Envelope::Call {
function_name,
args,
} => handle_call(&app, &function_name, args).await,
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
token,
} => handle_call(app, cfg, &function_name, args, token.as_deref()).await,
Envelope::Fetch {
context,
params,
token,
} => handle_fetch(app, cfg, &context, params, token.as_deref()).await,
Envelope::Shape { function_name } => handle_shape(&function_name),
Envelope::Form { form, role, args } => handle_form(app, &form, &role, args).await,
}
}
async fn handle_call<R: Runtime>(
app: &tauri::AppHandle<R>,
cfg: &MizanTauriConfig,
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"
)))
})?;
mut args: Map<String, Value>,
token: Option<&str>,
) -> Result<Value, MizanError> {
let identity = identity_from_token(token, cfg)?;
let fn_spec = lookup_function(fn_name)
.ok_or_else(|| MizanError::NotFound(format!("function {fn_name:?} not registered")))?;
if fn_spec.private() {
return Err(MizanError::Forbidden("Function is not client-callable".into()));
}
guard(fn_spec, identity.as_ref())?;
// Bind any file parts the envelope carries into the call args (see
// `bind_uploads`).
bind_uploads(fn_spec, &mut args)?;
let req = RequestHandle::new(app);
let result = fn_spec
.dispatch(req, Value::Object(args.clone()))
.await
.map_err(ErrorPayload::from)?;
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
.iter()
.map(InvalidationTarget::to_json)
.collect();
let targets = compute_invalidation(fn_spec, &args);
let invalidate: Vec<Value> = targets.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 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));
// Purge the origin cache for everything this mutation invalidated.
if !targets.is_empty() {
let uid = identity.as_ref().map(|i| i.user_id.clone());
cfg.cache.purge(&targets, uid.as_deref());
}
let mut payload = json!({ "result": result, "invalidate": invalidate });
if !merges.is_empty() {
payload.as_object_mut().unwrap().insert(
"merge".into(),
Value::Array(merges.iter().map(MergeEntry::to_json).collect()),
);
}
Ok(payload)
}
async fn handle_fetch<R: Runtime>(
app: &tauri::AppHandle<R>,
cfg: &MizanTauriConfig,
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"
))));
}
token: Option<&str>,
) -> Result<Value, MizanError> {
let identity = identity_from_token(token, cfg)?;
if lookup_context(context_name).is_none() {
return Err(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!(
return Err(MizanError::NotFound(format!(
"context {context_name:?} has no registered members"
))));
)));
}
// Origin cache: a desktop shell still benefits from memoizing a context
// bundle by (context, params, user). Key the params as JSON values.
let cache_params: std::collections::BTreeMap<String, Value> = params
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let uid = identity.as_ref().map(|i| i.user_id.clone());
if let Some(cached) = cfg
.cache
.get(context_name, &cache_params, uid.as_deref(), 0)
{
if let Ok(v) = serde_json::from_slice::<Value>(&cached) {
return Ok(v);
}
}
let mut bundled = Map::new();
for fn_spec in &members {
guard(*fn_spec, identity.as_ref())?;
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)?;
let result = fn_spec.dispatch(req, Value::Object(args)).await?;
bundled.insert(fn_spec.name().to_string(), result);
}
Ok(Value::Object(bundled))
let body = Value::Object(bundled);
if cfg.cache.enabled() {
let bytes = serde_json::to_vec(&body).unwrap();
cfg.cache
.put(context_name, &cache_params, bytes, uid.as_deref(), 0);
}
Ok(body)
}
/// 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.
/// `shape` op — the typed query projection for a function's output, derived by
/// the shared `mizan_core::shapes` (the IPC adapter's Shapes binding).
fn handle_shape(fn_name: &str) -> Result<Value, MizanError> {
let proj = shapes::project_function_output(fn_name)
.ok_or_else(|| MizanError::NotFound(format!("no shape projection for {fn_name:?}")))?;
Ok(projection_to_json(&proj))
}
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
let mut fields = Vec::new();
for f in &proj.fields {
match f {
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
shapes::ShapeField::Nested(n, sub) => {
fields.push(json!({ n.clone(): projection_to_json(sub) }));
}
}
}
json!({ "type": proj.type_name, "fields": fields })
}
/// `form` op — dispatch a form's schema/validate/submit function (the IPC
/// Forms binding). `form_validate` / `form_submit` map to the registered
/// function whose `(form_name, form_role)` matches.
async fn handle_form<R: Runtime>(
app: &tauri::AppHandle<R>,
form_name: &str,
role: &str,
args: Value,
) -> Result<Value, MizanError> {
match role {
"schema" => form_schema(app, form_name).await,
"validate" => form_validate(app, form_name, args).await,
"submit" => form_submit(app, form_name, args).await,
other => Err(MizanError::BadRequest(format!(
"unknown form role {other:?} (expected schema|validate|submit)"
))),
}
}
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
FUNCTIONS
.iter()
.copied()
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
}
async fn dispatch_form_role<R: Runtime>(
app: &tauri::AppHandle<R>,
form_name: &str,
role: &str,
args: Value,
) -> Result<Value, MizanError> {
let fn_spec = lookup_form_fn(form_name, role)
.ok_or_else(|| MizanError::NotFound(format!("no form {form_name:?} with role {role:?}")))?;
let args_value = match args {
Value::Object(_) | Value::Null => args,
other => json!({ "data": other }),
};
let req = RequestHandle::new(app);
fn_spec.dispatch(req, args_value).await
}
async fn form_schema<R: Runtime>(
app: &tauri::AppHandle<R>,
form_name: &str,
) -> Result<Value, MizanError> {
dispatch_form_role(app, form_name, "schema", Value::Null).await
}
async fn form_validate<R: Runtime>(
app: &tauri::AppHandle<R>,
form_name: &str,
args: Value,
) -> Result<Value, MizanError> {
dispatch_form_role(app, form_name, "validate", args).await
}
async fn form_submit<R: Runtime>(
app: &tauri::AppHandle<R>,
form_name: &str,
args: Value,
) -> Result<Value, MizanError> {
dispatch_form_role(app, form_name, "submit", args).await
}
// === WebSocket-equivalent: IPC subscription channel ===
/// One frame pushed down a subscription `Channel`. Mirrors the WS reply shape.
#[derive(Clone, Serialize)]
pub struct SubscriptionFrame {
pub result: Value,
pub invalidate: Vec<Value>,
}
/// `mizan_subscribe` — open an IPC subscription for a `#[mizan(websocket)]`
/// function. A desktop shell has no WebSocket; a Tauri `Channel<T>` carries
/// the push stream instead — the IPC transport's co-equal of the HTTP
/// WebSocket. The initial dispatch result is emitted immediately on the
/// channel; subsequent server-side pushes use the same `on_event` channel.
#[tauri::command]
async fn mizan_subscribe<R: Runtime>(
app: tauri::AppHandle<R>,
function_name: String,
args: Map<String, Value>,
on_event: Channel<SubscriptionFrame>,
) -> Result<(), ErrorPayload> {
subscribe(&app, &function_name, args, on_event)
.await
.map_err(ErrorPayload::from)
}
/// Open a subscription for a `#[mizan(websocket)]` function, pushing frames on
/// `on_event`. The programmatic entry point the `mizan_subscribe` IPC command
/// wraps — exposed for embedders and behavior tests.
pub async fn subscribe<R: Runtime>(
app: &tauri::AppHandle<R>,
function_name: &str,
args: Map<String, Value>,
on_event: Channel<SubscriptionFrame>,
) -> Result<(), MizanError> {
let fn_spec = lookup_function(function_name)
.ok_or_else(|| MizanError::NotFound(format!("function {function_name:?} not registered")))?;
if fn_spec.private() {
return Err(MizanError::Forbidden("Function is not client-callable".into()));
}
// Only `#[mizan(websocket)]` functions are exposed over the subscription
// channel — the same transport boundary the HTTP WebSocket enforces.
if !matches!(
fn_spec.transport(),
mizan_core::Transport::Websocket | mizan_core::Transport::Both
) {
return Err(MizanError::BadRequest(format!(
"function {function_name:?} is not exposed over the subscription transport"
)));
}
let req = RequestHandle::new(app);
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
let invalidate = compute_invalidation(fn_spec, &args)
.iter()
.map(InvalidationTarget::to_json)
.collect();
on_event
.send(SubscriptionFrame { result, invalidate })
.map_err(|e| MizanError::InternalError(format!("subscription channel send failed: {e}")))?;
Ok(())
}
// === Helpers ===
/// Filter the envelope's params down to keys this function declares as input.
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() {
@@ -218,3 +503,45 @@ fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<S
}
out
}
/// Bind file parts carried in the IPC envelope into the call args.
///
/// Over IPC there is no `multipart/form-data`; a file rides the envelope as a
/// JSON object `{filename, content_type, data_b64}` (the JS transport
/// base64-packs the bytes). That object is exactly what `mizan_core::Upload`
/// deserializes, so for a single file the arg is already in place. This binder
/// performs the one transform IPC needs: a top-level `_files` map
/// (`{ field: <file-obj> | [<file-obj>, ...] }`) is merged into the args under
/// each field name, mirroring how the HTTP adapter binds multipart parts. It
/// also validates that anything presenting as a file carries `data_b64`,
/// surfacing a clear error before the typed `Upload` deserialize runs.
fn bind_uploads(
fn_spec: &dyn FunctionSpec,
args: &mut Map<String, Value>,
) -> Result<(), MizanError> {
if let Some(Value::Object(files)) = args.remove("_files") {
for (field, parts) in files {
args.insert(field, parts);
}
}
// The set of param names this function declares — only validate args that
// could land in a typed field.
let declared: std::collections::HashSet<&str> =
fn_spec.input_params().iter().map(|p| p.name).collect();
for (name, value) in args.iter() {
if !declared.contains(name.as_str()) {
continue;
}
if let Value::Object(obj) = value {
let looks_like_file =
obj.contains_key("filename") || obj.contains_key("content_type");
if looks_like_file && !obj.contains_key("data_b64") {
return Err(MizanError::BadRequest(format!(
"upload field {name:?} is missing `data_b64` (the base64 file bytes)"
)));
}
}
}
Ok(())
}