Mizan IR: cut over to KDL, delete OpenAPI envelope

Replaces the transitional OpenAPI 3.0 + `x-mizan-*` extensions
substrate with the canonical Mizan IR as KDL, per docs/AFI_ARCHITECTURE.md:
"KDL is the contract; everything else (REST envelopes, OpenAPI
documents, framework idioms) is sediment around it."

End-to-end cutover. No transitional path left on main.

Forward direction:
  cores/mizan-python/src/mizan_core/ir.py
    build_ir() walks mizan_core.registry, introspects Pydantic
    models directly (no JSON-Schema indirection), and emits the
    Mizan IR document. The KDL grammar is locked in this file's
    module docstring.

Backends emit KDL:
  backends/mizan-fastapi/src/mizan_fastapi/ir.py
    `python -m mizan_fastapi.ir <module>` — CLI entry point.
  backends/mizan-django/.../management/commands/export_mizan_ir.py
    `manage.py export_mizan_ir` — Django mgmt command.

Codegen consumes KDL:
  protocol/mizan-codegen/Cargo.toml: + kdl = "6"
  protocol/mizan-codegen/src/ir.rs: NamedType { Struct/List/Enum/Alias }
    + TypeShape { Primitive/Ref/List/Optional/Enum/Union } sum types,
    replacing the JsonSchema sprawl. KDL parser walks the
    `kdl::KdlDocument` tree into typed Rust structs.
  protocol/mizan-codegen/src/fetch.rs: subprocess command switches
    to the new IR-export entry points.
  All emit modules (stage1 / react / python / rust / vue / svelte /
    channels) port their type-walkers from JsonSchema to the new
    sum types — case analysis collapses substantially.

Substrate-honesty wins beyond the moat closure:
  - `int | bool` multi-arm unions land as `TypeShape::Union` (was
    silently coerced to "string" before).
  - `<CamelName>Output = list[T]` returns emit as named alias
    types instead of struct-shaped wrappers, so consumer code
    `.map()` works directly on the type.
  - Pydantic field defaults flow through to `default` properties
    in KDL, then back to non-optional shape in every target.

Deleted:
  - backends/mizan-fastapi/src/mizan_fastapi/{cli,schema}.py
  - backends/mizan-django/.../export_mizan_schema.py
  - openapi-bearing half of mizan/export/__init__.py (edge
    manifest generator preserved — separate concern).
  - tests/afi/schema_normalizer.py
  - tests/fixtures/{afi_schema.json, channels_schema.json}
  - tests/fixtures/js_* baseline directories.

Verification:
  - 20 mizan-codegen unit tests green (IR deserialization,
    byte-equivalence parity across stage1/rust/python/react/vue/svelte
    against fresh KDL-driven baselines, channels structural).
  - tests/rust/run_wire_parity.py: 12/12 probes green driving
    the binary end-to-end through KDL.
  - Blazr studio-ui typechecks against the regenerated React client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 19:14:47 -04:00
parent 7fb0c4a400
commit 9900f8a36f
86 changed files with 2231 additions and 2272 deletions

View File

@@ -1,122 +1,121 @@
//! Mizan IR — strongly-typed deserialization of the backends' schema export.
//!
//! Every Mizan backend (Django, FastAPI, mizan-ts) emits the same OpenAPI
//! document with three load-bearing extension fields:
//! - `x-mizan-functions` — array of function entries
//! - `x-mizan-contexts` — map of context groups
//! - `components.schemas` — OpenAPI Pydantic→JSONSchema per Input/Output
//!
//! The structs here deserialize that JSON envelope into typed Rust values
//! the emit targets walk. The OpenAPI document body (paths, info, etc.) is
//! intentionally not modeled — the codegen consumes only the extensions.
use std::collections::BTreeMap;
//! Mizan IR — the canonical KDL document every backend adapter emits and
//! every codegen target consumes. See `docs/AFI_ARCHITECTURE.md` and
//! `cores/mizan-python/src/mizan_core/ir.py` for the locked grammar.
use anyhow::{anyhow, bail, Context, Result};
use indexmap::IndexMap;
use serde::Deserialize;
use kdl::{KdlDocument, KdlNode, KdlValue};
#[derive(Debug, Deserialize)]
#[derive(Debug, Default)]
pub struct MizanIR {
#[serde(rename = "x-mizan-functions", default)]
pub types: IndexMap<String, NamedType>,
pub functions: Vec<MizanFunction>,
#[serde(rename = "x-mizan-contexts", default)]
pub contexts: IndexMap<String, MizanContext>,
/// Django-only channel registrations. FastAPI backends emit an empty list.
#[serde(rename = "x-mizan-channels", default)]
pub channels: Vec<MizanChannel>,
#[serde(default)]
pub components: Components,
}
#[derive(Debug, Deserialize, Clone)]
pub struct MizanChannel {
// ─── Type system ────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum NamedType {
Struct(Vec<StructField>),
List(TypeShape),
Enum(Vec<String>),
Alias(TypeShape),
}
#[derive(Debug, Clone)]
pub struct StructField {
pub name: String,
#[serde(rename = "pascalName")]
pub pascal_name: String,
#[serde(rename = "hasParams", default)]
pub has_params: bool,
#[serde(rename = "hasReactMessage", default)]
pub has_react_message: bool,
#[serde(rename = "hasDjangoMessage", default)]
pub has_django_message: bool,
#[serde(rename = "paramsType", default)]
pub params_type: Option<String>,
#[serde(rename = "reactMessageType", default)]
pub react_message_type: Option<String>,
#[serde(rename = "djangoMessageType", default)]
pub django_message_type: Option<String>,
pub required: bool,
pub default: Option<DefaultValue>,
pub shape: TypeShape,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Clone)]
pub enum TypeShape {
Primitive(Primitive),
Ref(String),
List(Box<TypeShape>),
Optional(Box<TypeShape>),
Enum(Vec<String>),
/// Multi-arm union with two or more non-null branches.
Union(Vec<TypeShape>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Primitive { Integer, Number, Boolean, String }
impl Primitive {
fn parse(s: &str) -> Result<Self> {
match s {
"integer" => Ok(Primitive::Integer),
"number" => Ok(Primitive::Number),
"boolean" => Ok(Primitive::Boolean),
"string" => Ok(Primitive::String),
other => bail!("unknown primitive {other:?}"),
}
}
}
#[derive(Debug, Clone)]
pub enum DefaultValue {
Integer(i64),
Number(f64),
Boolean(bool),
String(String),
Null,
}
// ─── Functions ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct MizanFunction {
pub name: String,
#[serde(rename = "camelName")]
pub camel_name: String,
#[serde(rename = "hasInput")]
pub has_input: bool,
#[serde(rename = "inputType")]
pub input_type: Option<String>,
#[serde(rename = "outputType")]
pub output_type: String,
#[serde(rename = "outputNullable", default)]
pub output_nullable: bool,
pub transport: Transport,
#[serde(rename = "isContext", default)]
pub is_context: IsContext,
#[serde(rename = "isForm", default)]
pub is_form: bool,
#[serde(rename = "formName", default)]
pub form_name: Option<String>,
#[serde(rename = "formRole", default)]
pub form_role: Option<String>,
#[serde(default)]
pub affects: Vec<AffectTarget>,
/// Names of contexts whose state is patched by this function's return
/// body via the kernel's `splice_slot` merger. Empty when the function
/// is not a merge target.
#[serde(default)]
pub merge: Vec<String>,
}
#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Transport {
#[default]
Http,
Websocket,
Both,
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport { Http, Websocket, Both }
impl Transport {
fn parse(s: &str) -> Result<Self> {
match s {
"http" => Ok(Transport::Http),
"websocket" => Ok(Transport::Websocket),
"both" => Ok(Transport::Both),
other => bail!("unknown transport {other:?}"),
}
}
}
/// IR-level `isContext` value. The backends emit `false` for non-context
/// functions and a string (`"global"`, `"user"`, …) for context-grouped
/// functions. Custom Deserialize bridges the boolean/string union into a
/// typed Rust enum.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum IsContext {
#[default]
No,
Yes(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IsContext { No, Yes(String) }
impl IsContext {
pub fn as_str(&self) -> Option<&str> {
@@ -127,122 +126,343 @@ impl IsContext {
}
}
impl<'de> Deserialize<'de> for IsContext {
fn deserialize<D>(de: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = serde_json::Value::deserialize(de)?;
match v {
serde_json::Value::Bool(false) => Ok(IsContext::No),
serde_json::Value::Bool(true) => Err(serde::de::Error::custom(
"isContext: bare `true` is not a valid context name",
)),
serde_json::Value::String(s) => Ok(IsContext::Yes(s)),
serde_json::Value::Null => Ok(IsContext::No),
other => Err(serde::de::Error::custom(format!(
"isContext: expected `false` or string, got {other:?}"
))),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Clone)]
pub struct AffectTarget {
#[serde(rename = "type")]
pub kind: AffectKind,
pub name: String,
#[serde(default)]
pub context: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AffectKind {
Context,
Function,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AffectKind { Context, Function }
#[derive(Debug, Deserialize, Default, Clone)]
// ─── Contexts ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Default)]
pub struct MizanContext {
#[serde(default)]
pub functions: Vec<String>,
#[serde(default)]
pub params: IndexMap<String, ContextParam>,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Clone)]
pub struct ContextParam {
#[serde(rename = "type")]
pub ty: String,
pub ty: Primitive,
pub required: bool,
#[serde(rename = "sharedBy", default)]
pub shared_by: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct Components {
#[serde(default)]
pub schemas: IndexMap<String, JsonSchema>,
// ─── Channels (Django-only) ─────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct MizanChannel {
pub name: String,
pub pascal_name: String,
pub params_type: Option<String>,
pub react_message_type: Option<String>,
pub django_message_type: Option<String>,
}
/// JSON Schema subset used by the emit targets. Mirrors the surface the
/// existing JS adapters traverse (`$ref`, `anyOf`, `enum`, `type`, `items`,
/// `properties`, `required`, `nullable`). Unknown fields are stashed in
/// `extra` so backends can include schema annotations the codegen ignores.
#[derive(Debug, Deserialize, Default, Clone)]
pub struct JsonSchema {
#[serde(rename = "type", default)]
pub ty: Option<String>,
#[serde(rename = "$ref", default)]
pub r#ref: Option<String>,
#[serde(rename = "enum", default)]
pub r#enum: Option<Vec<serde_json::Value>>,
#[serde(rename = "anyOf", default)]
pub any_of: Option<Vec<JsonSchema>>,
#[serde(default)]
pub nullable: bool,
#[serde(default)]
pub items: Option<Box<JsonSchema>>,
#[serde(default)]
pub properties: Option<IndexMap<String, JsonSchema>>,
#[serde(default)]
pub required: Vec<String>,
#[serde(rename = "additionalProperties", default)]
pub additional_properties: Option<serde_json::Value>,
/// Presence of this field means the schema has a default — the server
/// always populates it. Consumers can treat the field as non-optional
/// even if it's absent from `required`.
#[serde(default)]
pub default: Option<serde_json::Value>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
impl MizanChannel {
pub fn has_params(&self) -> bool { self.params_type.is_some() }
pub fn has_react_message(&self) -> bool { self.react_message_type.is_some() }
pub fn has_django_message(&self) -> bool { self.django_message_type.is_some() }
}
impl JsonSchema {
/// `$ref: "#/components/schemas/Foo"` → `Some("Foo")`.
pub fn ref_name(&self) -> Option<&str> {
self.r#ref
.as_deref()
.and_then(|s| s.strip_prefix("#/components/schemas/"))
// ─── KDL parsing ────────────────────────────────────────────────────────────
pub fn parse_ir(source: &str) -> Result<MizanIR> {
let doc: KdlDocument = source.parse()
.map_err(|e| anyhow!("KDL parse error: {e}"))?;
let mut ir = MizanIR::default();
for node in doc.nodes() {
match node.name().value() {
"type" => {
let (name, ty) = parse_named_type(node)?;
ir.types.insert(name, ty);
}
"function" => ir.functions.push(parse_function(node)?),
"context" => {
let (name, ctx) = parse_context(node)?;
ir.contexts.insert(name, ctx);
}
"channel" => ir.channels.push(parse_channel(node)?),
other => bail!("unknown top-level KDL node {other:?}"),
}
}
Ok(ir)
}
fn parse_named_type(node: &KdlNode) -> Result<(String, NamedType)> {
let name = first_string_arg(node)
.context("`type` requires a name as its first argument")?;
let children = node.children()
.ok_or_else(|| anyhow!("type {name:?}: missing children block"))?;
let kind_node = single_child(children, &format!("type {name:?}"))?;
let kind = match kind_node.name().value() {
"struct" => NamedType::Struct(parse_struct_fields(kind_node)?),
"list" => NamedType::List(type_child_of(kind_node, &format!("type {name:?} list"))?),
"enum" => NamedType::Enum(parse_string_args(kind_node)),
"alias" => NamedType::Alias(type_child_of(kind_node, &format!("type {name:?} alias"))?),
other => bail!("type {name:?}: unknown shape node {other:?}"),
};
Ok((name, kind))
}
fn parse_struct_fields(struct_node: &KdlNode) -> Result<Vec<StructField>> {
let mut fields = Vec::new();
let Some(children) = struct_node.children() else { return Ok(fields); };
for child in children.nodes() {
if child.name().value() != "field" {
bail!("struct: unexpected node {:?}", child.name().value());
}
fields.push(parse_struct_field(child)?);
}
Ok(fields)
}
fn parse_struct_field(field_node: &KdlNode) -> Result<StructField> {
let name = first_string_arg(field_node).context("`field` requires a name")?;
let required = bool_prop(field_node, "required").unwrap_or(true);
let default = field_node.entry("default")
.map(|e| parse_default_value(e.value()))
.transpose()?;
let shape = type_child_of(field_node, &format!("field {name:?}"))?;
Ok(StructField { name, required, default, shape })
}
fn parse_default_value(v: &KdlValue) -> Result<DefaultValue> {
if v.is_null() { return Ok(DefaultValue::Null); }
if let Some(b) = v.as_bool() { return Ok(DefaultValue::Boolean(b)); }
if let Some(i) = v.as_integer() { return Ok(DefaultValue::Integer(i as i64)); }
if let Some(f) = v.as_float() { return Ok(DefaultValue::Number(f)); }
if let Some(s) = v.as_string() { return Ok(DefaultValue::String(s.to_string())); }
bail!("unsupported default literal: {v:?}")
}
fn type_child_of(parent: &KdlNode, label: &str) -> Result<TypeShape> {
let children = parent.children()
.ok_or_else(|| anyhow!("{label}: missing children for type-shape"))?;
let nodes = children.nodes();
if nodes.len() != 1 {
bail!("{label}: expected exactly one type-shape child, got {}", nodes.len());
}
parse_type_shape(&nodes[0])
}
fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
match node.name().value() {
"primitive" => Ok(TypeShape::Primitive(Primitive::parse(&first_string_arg(node)?)?)),
"ref" => Ok(TypeShape::Ref(first_string_arg(node)?)),
"list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))),
"optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))),
"enum" => Ok(TypeShape::Enum(parse_string_args(node))),
"union" => {
let children = node.children()
.ok_or_else(|| anyhow!("union: missing children"))?;
let branches: Result<Vec<TypeShape>> = children.nodes().iter()
.map(parse_type_shape).collect();
Ok(TypeShape::Union(branches?))
}
other => bail!("unknown type-shape node {other:?}"),
}
}
fn parse_function(node: &KdlNode) -> Result<MizanFunction> {
let name = first_string_arg(node)
.context("`function` requires a name as its first argument")?;
let children = node.children()
.ok_or_else(|| anyhow!("function {name:?}: missing children"))?;
let mut camel = None;
let mut has_input = false;
let mut input_type = None;
let mut output_type = None;
let mut output_nullable = false;
let mut transport = Transport::Http;
let mut is_context = IsContext::No;
let mut is_form = false;
let mut form_name = None;
let mut form_role = None;
let mut affects: Vec<AffectTarget> = Vec::new();
let mut merge: Vec<String> = Vec::new();
for child in children.nodes() {
match child.name().value() {
"camel" => camel = Some(string_arg(child, "camel")?),
"has-input" => has_input = bool_arg(child, "has-input")?,
"input" => input_type = Some(string_arg(child, "input")?),
"output" => output_type = Some(string_arg(child, "output")?),
"output-nullable" => output_nullable = bool_arg(child, "output-nullable")?,
"transport" => transport = Transport::parse(&string_arg(child, "transport")?)?,
"context" => is_context = IsContext::Yes(string_arg(child, "context")?),
"is-form" => is_form = bool_arg(child, "is-form")?,
"form-name" => form_name = Some(string_arg(child, "form-name")?),
"form-role" => form_role = Some(string_arg(child, "form-role")?),
"affects" => affects.push(AffectTarget {
kind: AffectKind::Context,
name: string_arg(child, "affects")?,
context: None,
}),
"merge" => merge.push(string_arg(child, "merge")?),
other => bail!("function {name:?}: unknown child {other:?}"),
}
}
Ok(MizanFunction {
name: name.clone(),
camel_name: camel.ok_or_else(|| anyhow!("function {name:?}: missing `camel`"))?,
has_input,
input_type,
output_type: output_type.ok_or_else(|| anyhow!("function {name:?}: missing `output`"))?,
output_nullable,
transport,
is_context,
is_form,
form_name,
form_role,
affects,
merge,
})
}
fn parse_context(node: &KdlNode) -> Result<(String, MizanContext)> {
let name = first_string_arg(node).context("`context` requires a name")?;
let mut ctx = MizanContext::default();
let Some(children) = node.children() else { return Ok((name, ctx)); };
for child in children.nodes() {
match child.name().value() {
"function" => ctx.functions.push(string_arg(child, "function")?),
"param" => {
let (pname, param) = parse_context_param(child)?;
ctx.params.insert(pname, param);
}
other => bail!("context {name:?}: unknown child {other:?}"),
}
}
Ok((name, ctx))
}
fn parse_context_param(node: &KdlNode) -> Result<(String, ContextParam)> {
let pname = first_string_arg(node).context("`param` requires a name")?;
let children = node.children()
.ok_or_else(|| anyhow!("param {pname:?}: missing children"))?;
let mut ty = None;
let mut required = false;
let mut shared_by = Vec::new();
for child in children.nodes() {
match child.name().value() {
"type" => ty = Some(Primitive::parse(&string_arg(child, "type")?)?),
"required" => required = bool_arg(child, "required")?,
"shared-by" => shared_by.push(string_arg(child, "shared-by")?),
other => bail!("param {pname:?}: unknown child {other:?}"),
}
}
Ok((pname.clone(), ContextParam {
ty: ty.ok_or_else(|| anyhow!("param {pname:?}: missing `type`"))?,
required,
shared_by,
}))
}
fn parse_channel(node: &KdlNode) -> Result<MizanChannel> {
let name = first_string_arg(node).context("`channel` requires a name")?;
let children = node.children()
.ok_or_else(|| anyhow!("channel {name:?}: missing children"))?;
let mut pascal_name = None;
let mut params_type = None;
let mut react_message_type = None;
let mut django_message_type = None;
for child in children.nodes() {
match child.name().value() {
"pascal-name" => pascal_name = Some(string_arg(child, "pascal-name")?),
"params" => params_type = Some(string_arg(child, "params")?),
"react-message" => react_message_type = Some(string_arg(child, "react-message")?),
"django-message" => django_message_type = Some(string_arg(child, "django-message")?),
other => bail!("channel {name:?}: unknown child {other:?}"),
}
}
Ok(MizanChannel {
name: name.clone(),
pascal_name: pascal_name.ok_or_else(|| anyhow!("channel {name:?}: missing `pascal-name`"))?,
params_type,
react_message_type,
django_message_type,
})
}
// ─── KDL accessor helpers ───────────────────────────────────────────────────
fn first_string_arg(node: &KdlNode) -> Result<String> {
let entry = node.entries().iter()
.find(|e| e.name().is_none())
.ok_or_else(|| anyhow!("node {:?}: missing positional argument", node.name().value()))?;
entry.value().as_string()
.map(str::to_string)
.ok_or_else(|| anyhow!("node {:?}: positional argument is not a string", node.name().value()))
}
fn string_arg(node: &KdlNode, label: &str) -> Result<String> {
first_string_arg(node).context(format!("{label}: requires a string argument"))
}
fn bool_arg(node: &KdlNode, label: &str) -> Result<bool> {
node.entries().iter()
.find(|e| e.name().is_none())
.and_then(|e| e.value().as_bool())
.ok_or_else(|| anyhow!("{label}: missing positional bool argument"))
}
fn bool_prop(node: &KdlNode, key: &str) -> Option<bool> {
node.entry(key).and_then(|e| e.value().as_bool())
}
fn parse_string_args(node: &KdlNode) -> Vec<String> {
node.entries().iter()
.filter(|e| e.name().is_none())
.filter_map(|e| e.value().as_string().map(str::to_string))
.collect()
}
fn single_child<'a>(children: &'a KdlDocument, label: &str) -> Result<&'a KdlNode> {
let nodes = children.nodes();
if nodes.len() != 1 {
bail!("{label}: expected exactly one child node, got {}", nodes.len());
}
Ok(&nodes[0])
}
// ─── Library entry point ────────────────────────────────────────────────────
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
parse_ir(source)
}