Mizan-Rust backend adapter: server-side substrate + three-way parity

Adds first-class Rust-backed Mizan to sit alongside mizan-django and
mizan-fastapi. A Rust dev writes:

    #[derive(Mizan, Serialize, Deserialize)]
    pub struct ProfileOutput { pub user_id: i64, pub name: String }

    #[mizan::context("user")]
    pub struct UserCtx;

    #[mizan::client(context = UserCtx)]
    pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { ... }

…and gets byte-identical KDL to the Python emitters, served over the
same wire protocol the React / Rust / Vue / Svelte kernels speak.

New crates:
- cores/mizan-rust/         (Cargo: mizan-core)     — IR types, KDL emitter, traits, registry,
                                                       runtime (compute_invalidation / compute_merges
                                                       ported from mizan-fastapi), graph_check with
                                                       structural type-matching
- cores/mizan-rust-macros/  (Cargo: mizan-macros)   — #[derive(Mizan)], #[mizan::context],
                                                       #[mizan::client] proc macros
- backends/mizan-rust-axum/ (Cargo: mizan-axum)     — axum HTTP adapter: /session/, /call/, /ctx/:name/
- tests/afi/rust_app/                                — AFI fixture port + server / export-ir binaries

Substrate-shape moves required by cross-language equivalence:
- IR canonicalization: functions / contexts / context-members / shared-by
  now sort alphabetically in both Python and Rust emitters. The IR is a
  contract; linkme doesn't preserve declaration order, so canonical sort
  is the only stable mapping. afi_ir.kdl + per-target baselines regenerated.
- MizanType::TYPE_NAME is a const (with a default type_name() reader) so
  it's usable in linkme TypeEntry static initializers.
- Tree-shaken type registry: #[derive(Mizan)] only emits the trait impl;
  the #[mizan::client] macro registers canonical-named entries from
  fn signatures, including Vec<T> element types for ref resolution.
- Merge resolution is structural (NamedType shape comparison) rather than
  by name — matches the Python types_match_for_merge semantics.

Three-way forcing functions:
- tests/afi/test_codegen_parity.py — Django ≡ FastAPI ≡ Rust on KDL bytes (3 pass)
- tests/rust/run_wire_parity.py    — 12/12 probes against FastAPI + Rust (EXIT=0)

Incidental fixes surfaced by the new tests:
- Stale `from .registry import validate_registry` import removed from
  mizan-django/setup/discovery.py (referenced a function that no longer
  exists; was masking codegen-parity).
- BASE_DIR added to tests/afi/django_app/project/settings.py.
- /session/ endpoint added to mizan-fastapi for protocol-shaped readiness
  probe parity (wire-parity harness now polls /api/mizan/session/ on both
  backends rather than FastAPI's /openapi.json).
- Root .gitignore picks up Rust target/ across the tree so new crates
  don't need per-crate gitignore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:31:26 -04:00
parent 9900f8a36f
commit 45bde51166
47 changed files with 4187 additions and 147 deletions

View File

@@ -128,36 +128,6 @@ function "echo" {
output "echoOutput"
transport "http"
}
function "whoami" {
camel "whoami"
has-input #false
output "whoamiOutput"
transport "http"
}
function "user_profile" {
camel "userProfile"
has-input #true
input "userProfileInput"
output "userProfileOutput"
transport "http"
context "user"
}
function "user_orders" {
camel "userOrders"
has-input #true
input "userOrdersInput"
output "userOrdersOutput"
transport "http"
context "user"
}
function "update_profile" {
camel "updateProfile"
has-input #true
input "updateProfileInput"
output "updateProfileOutput"
transport "http"
affects "user"
}
function "find_user" {
camel "findUser"
has-input #true
@@ -174,14 +144,44 @@ function "rename_user" {
transport "http"
merge "user"
}
function "update_profile" {
camel "updateProfile"
has-input #true
input "updateProfileInput"
output "updateProfileOutput"
transport "http"
affects "user"
}
function "user_orders" {
camel "userOrders"
has-input #true
input "userOrdersInput"
output "userOrdersOutput"
transport "http"
context "user"
}
function "user_profile" {
camel "userProfile"
has-input #true
input "userProfileInput"
output "userProfileOutput"
transport "http"
context "user"
}
function "whoami" {
camel "whoami"
has-input #false
output "whoamiOutput"
transport "http"
}
context "user" {
function "user_profile"
function "user_orders"
function "user_profile"
param "user_id" {
type "integer"
required #true
shared-by "user_profile"
shared-by "user_orders"
shared-by "user_profile"
}
}

View File

@@ -36,14 +36,6 @@ class MizanClient:
raw = self._inner.call("echo", args.model_dump())
return EchoOutput(**raw)
def call_whoami(self) -> WhoamiOutput:
raw = self._inner.call("whoami", {})
return WhoamiOutput(**raw)
def call_update_profile(self, args: UpdateProfileInput) -> UpdateProfileOutput:
raw = self._inner.call("update_profile", args.model_dump())
return UpdateProfileOutput(**raw)
def call_find_user(self, args: FindUserInput) -> FindUserOutput | None:
raw = self._inner.call("find_user", args.model_dump())
return FindUserOutput(**raw) if raw is not None else None
@@ -52,6 +44,14 @@ class MizanClient:
raw = self._inner.call("rename_user", args.model_dump())
return RenameUserOutput(**raw)
def call_update_profile(self, args: UpdateProfileInput) -> UpdateProfileOutput:
raw = self._inner.call("update_profile", args.model_dump())
return UpdateProfileOutput(**raw)
def call_whoami(self) -> WhoamiOutput:
raw = self._inner.call("whoami", {})
return WhoamiOutput(**raw)
def invalidate(self, context: str) -> None:
self._inner.invalidate(context)
@@ -63,5 +63,5 @@ class MizanClient:
class UserContextData(BaseModel):
"""Bundled return of fetch_user_context."""
user_profile: UserProfileOutput
user_orders: UserOrdersOutput
user_profile: UserProfileOutput

View File

@@ -2,11 +2,11 @@
import { mizanFetch } from '@mizan/base'
import type { userProfileOutput, userOrdersOutput } from '../types'
import type { userOrdersOutput, userProfileOutput } from '../types'
export interface UserContextData {
user_profile: userProfileOutput
user_orders: userOrdersOutput
user_profile: userProfileOutput
}
export interface UserContextParams {

View File

@@ -5,10 +5,10 @@ export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callWhoami } from './functions/whoami'
export { callUpdateProfile } from './mutations/updateProfile'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
export { callUpdateProfile } from './mutations/updateProfile'
export { callWhoami } from './functions/whoami'
// Stage 2 framework adapter
export * from './react'

View File

@@ -22,7 +22,7 @@ import {
type ContextState,
} from '@mizan/base'
import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callWhoami, callFindUser, callRenameUser, type userProfileOutput, type userOrdersOutput } from './index'
import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callFindUser, callRenameUser, callWhoami, type userOrdersOutput, type userProfileOutput } from './index'
// Internal — runs inside a Provider, registers with the kernel exactly once.
function useContextSubscription<T>(
@@ -89,14 +89,14 @@ export function useUserContext(): ContextState<UserContextData> {
return ctx
}
export function useUserProfile(): userProfileOutput | null {
return useUserContext().data?.user_profile ?? null
}
export function useUserOrders(): userOrdersOutput | null {
return useUserContext().data?.user_orders ?? null
}
export function useUserProfile(): userProfileOutput | null {
return useUserContext().data?.user_profile ?? null
}
export function useUpdateProfile() {
return useMutation<Parameters<typeof callUpdateProfile>[0], Awaited<ReturnType<typeof callUpdateProfile>>>(callUpdateProfile)
}
@@ -105,10 +105,6 @@ export function useEcho() {
return useMutation<Parameters<typeof callEcho>[0], Awaited<ReturnType<typeof callEcho>>>(callEcho)
}
export function useWhoami() {
return useMutation<void, Awaited<ReturnType<typeof callWhoami>>>(() => callWhoami() as any)
}
export function useFindUser() {
return useMutation<Parameters<typeof callFindUser>[0], Awaited<ReturnType<typeof callFindUser>>>(callFindUser)
}
@@ -117,6 +113,10 @@ export function useRenameUser() {
return useMutation<Parameters<typeof callRenameUser>[0], Awaited<ReturnType<typeof callRenameUser>>>(callRenameUser)
}
export function useWhoami() {
return useMutation<void, Awaited<ReturnType<typeof callWhoami>>>(() => callWhoami() as any)
}
// ── MizanContext root provider ──
export interface MizanContextProps {

View File

@@ -5,12 +5,12 @@ use serde_json::Value;
use mizan_rust::{MizanClient, MizanError};
use crate::types::{UserProfileOutput, UserOrdersOutput};
use crate::types::{UserOrdersOutput, UserProfileOutput};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserContextData {
pub user_profile: UserProfileOutput,
pub user_orders: UserOrdersOutput,
pub user_profile: UserProfileOutput,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -2,11 +2,11 @@
import { mizanFetch } from '@mizan/base'
import type { userProfileOutput, userOrdersOutput } from '../types'
import type { userOrdersOutput, userProfileOutput } from '../types'
export interface UserContextData {
user_profile: userProfileOutput
user_orders: userOrdersOutput
user_profile: userProfileOutput
}
export interface UserContextParams {

View File

@@ -5,7 +5,7 @@ export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callWhoami } from './functions/whoami'
export { callUpdateProfile } from './mutations/updateProfile'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
export { callUpdateProfile } from './mutations/updateProfile'
export { callWhoami } from './functions/whoami'

View File

@@ -2,11 +2,11 @@
import { mizanFetch } from '@mizan/base'
import type { userProfileOutput, userOrdersOutput } from '../types'
import type { userOrdersOutput, userProfileOutput } from '../types'
export interface UserContextData {
user_profile: userProfileOutput
user_orders: userOrdersOutput
user_profile: userProfileOutput
}
export interface UserContextParams {

View File

@@ -5,10 +5,10 @@ export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callWhoami } from './functions/whoami'
export { callUpdateProfile } from './mutations/updateProfile'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
export { callUpdateProfile } from './mutations/updateProfile'
export { callWhoami } from './functions/whoami'
// Stage 2 framework adapter
export * from './svelte'

View File

@@ -3,7 +3,7 @@
import { readable, type Readable } from 'svelte/store'
import { registerContext, type ContextState } from '@mizan/base'
import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callWhoami, callFindUser, callRenameUser } from '../index'
import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callFindUser, callRenameUser, callWhoami } from '../index'
export function createUserContext(params: UserContextParams) {
const store = readable<ContextState<UserContextData>>(
@@ -21,9 +21,9 @@ export function createUserContext(params: UserContextParams) {
export { callUpdateProfile } from '../index'
export { callEcho } from '../index'
export { callWhoami } from '../index'
export { callFindUser } from '../index'
export { callRenameUser } from '../index'
export { callWhoami } from '../index'
export type { ContextState } from '@mizan/base'
export { configure, initSession, MizanError } from '@mizan/base'

View File

@@ -2,11 +2,11 @@
import { mizanFetch } from '@mizan/base'
import type { userProfileOutput, userOrdersOutput } from '../types'
import type { userOrdersOutput, userProfileOutput } from '../types'
export interface UserContextData {
user_profile: userProfileOutput
user_orders: userOrdersOutput
user_profile: userProfileOutput
}
export interface UserContextParams {

View File

@@ -5,10 +5,10 @@ export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callWhoami } from './functions/whoami'
export { callUpdateProfile } from './mutations/updateProfile'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
export { callUpdateProfile } from './mutations/updateProfile'
export { callWhoami } from './functions/whoami'
// Stage 2 framework adapter
export * from './vue'

View File

@@ -3,7 +3,7 @@
import { ref, computed, onMounted, onUnmounted, onServerPrefetch, type ComputedRef } from 'vue'
import { registerContext, type ContextState } from '@mizan/base'
import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callWhoami, callFindUser, callRenameUser } from '../index'
import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callFindUser, callRenameUser, callWhoami } from '../index'
export function useUserContext(params: UserContextParams) {
const state = ref<ContextState<UserContextData>>({ data: null, status: 'idle', error: null })
@@ -25,8 +25,8 @@ export function useUserContext(params: UserContextParams) {
return {
state,
userProfile: computed(() => state.value.data?.user_profile ?? null) as ComputedRef<userProfileOutput | null>,
userOrders: computed(() => state.value.data?.user_orders ?? null) as ComputedRef<userOrdersOutput | null>,
userProfile: computed(() => state.value.data?.user_profile ?? null) as ComputedRef<userProfileOutput | null>,
loading: computed(() => state.value.status === 'loading'),
error: computed(() => state.value.error),
}
@@ -56,18 +56,6 @@ export function useEcho() {
return { mutate, isPending, error }
}
export function useWhoami() {
const isPending = ref(false)
const error = ref<Error | null>(null)
async function mutate() {
isPending.value = true; error.value = null
try { return await callWhoami() }
catch (e) { error.value = e as Error; throw e }
finally { isPending.value = false }
}
return { mutate, isPending, error }
}
export function useFindUser() {
const isPending = ref(false)
const error = ref<Error | null>(null)
@@ -92,5 +80,17 @@ export function useRenameUser() {
return { mutate, isPending, error }
}
export function useWhoami() {
const isPending = ref(false)
const error = ref<Error | null>(null)
async function mutate() {
isPending.value = true; error.value = null
try { return await callWhoami() }
catch (e) { error.value = e as Error; throw e }
finally { isPending.value = false }
}
return { mutate, isPending, error }
}
export type { ContextState } from '@mizan/base'
export { configure, initSession, MizanError } from '@mizan/base'