Skip to main content

axon/
route_schema.rs

1//! §Fase 32.c + 32.d — Schema validation for first-class axonendpoint routes.
2//!
3//! Given an axonendpoint's declared `body: T` (request side, D4) or
4//! `output: T` (response side, D5), validate that every accepted body
5//! matches `T`'s schema verbatim. The validation function is **pure +
6//! total over the declared type system**.
7//!
8//! ## Same primitive, two call sites
9//!
10//! `validate_body` is consumed twice in the dynamic-route fallback:
11//!
12//! 1. **Request side (D4)** — before flow dispatch. On violation the
13//!    HTTP layer returns 400 Bad Request with the full structured
14//!    `BodyValidationError` so the adopter client can correct the
15//!    request.
16//! 2. **Response side (D5)** — after flow dispatch, before returning
17//!    to the client. On violation the HTTP layer returns **GENERIC
18//!    500** to the client (OWASP — schema details never leak to a
19//!    potentially malicious caller) but records the full
20//!    `BodyValidationError` in the audit log so the adopter inspects
21//!    the trail to fix the FLOW.
22//!
23//! The validator itself does not care which side it runs on — same
24//! primitive, same drift gate.
25//!
26//! ## Pillar trace (D12)
27//!
28//! - **MATHEMATICS** — `validate_body : (RequestBody, Type) → Result<(),
29//!   ValidationError>` is a pure function. Given the same input and the
30//!   same type table, the function is deterministic and total: every input
31//!   maps to exactly one result.
32//! - **LOGIC** — every accepted body matches the declared schema. No
33//!   widening, no coercion, no "kinda matches". A body of `{amount: "50"}`
34//!   does NOT satisfy `LoanApplication { amount: Float }` — string-to-float
35//!   coercion is the client's responsibility, not the server's.
36//! - **PHILOSOPHY** — the declaration IS the contract. An auditor reads
37//!   source + KNOWS exactly what bodies are accepted at every endpoint.
38//!   Free-form bodies require explicitly omitting `body:` (D9).
39//! - **COMPUTING** — backwards-compat: when `body_type` is empty, no
40//!   validation runs (free-form JSON, as before Fase 32). Adopters opt in
41//!   by declaring `body:` on their axonendpoints.
42//!
43//! ## Cross-stack mirror (D11)
44//!
45//! Python sibling lives at `axon/runtime/route_schema.py`. Both stacks
46//! produce byte-identical `(type_name, field_path, expected, got)` tuples
47//! for the same input under the shared drift-gate corpus at
48//! `tests/fixtures/fase32_body_schema/corpus.json`.
49
50use std::collections::HashMap;
51
52use serde_json::Value;
53
54use crate::ast::{Declaration, Program, TypeDefinition};
55
56/// Snapshot of a `type T { … }` declaration relevant to body validation.
57/// Only the fields the validator consults are projected — `compliance`,
58/// `where_clause`, and `range_constraint` are out of scope for 32.c
59/// (where/compliance ship in their own future fases).
60#[derive(Debug, Clone, PartialEq)]
61pub struct TypeSchema {
62    pub name: String,
63    pub fields: Vec<FieldSchema>,
64    /// Closed numeric range constraint per `RANGED_TYPES` semantics. The
65    /// parser sets this for `type X(0.0..1.0)` declarations.
66    pub range: Option<(f64, f64)>,
67}
68
69/// One field inside a structured type. `optional == true` if the source
70/// declared the field as `name: T?`.
71#[derive(Debug, Clone, PartialEq)]
72pub struct FieldSchema {
73    pub name: String,
74    pub type_name: String,
75    /// `List<X>`'s `generic_param` is `"X"`. Empty string for
76    /// non-parameterised types.
77    pub generic_param: String,
78    pub optional: bool,
79}
80
81/// Structured body-validation error. The HTTP layer projects this into a
82/// 400 Bad Request with the field/expected/got triple so adopter clients
83/// can correct their request without server-side log diving.
84#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
85pub struct BodyValidationError {
86    /// Top-level body type the validation was attempted against (e.g.
87    /// `"LoanApplication"`).
88    pub expected_type: String,
89    /// Dotted path to the offending field: `"applicant.address.street"`
90    /// for nested structures, `"[2].name"` for list-element index 2.
91    /// Empty string when the violation is at the top-level body itself
92    /// (e.g. expected object, got string).
93    pub field_path: String,
94    /// Declared type the validator expected.
95    pub expected: String,
96    /// JSON-type tag observed (`"string"`, `"number"`, `"integer"`,
97    /// `"boolean"`, `"array"`, `"object"`, `"null"`, `"missing"`).
98    pub got: String,
99    /// Adopter-facing diagnostic — full sentence with a corrective hint.
100    /// Stable across versions per D8 backwards-compat surface.
101    pub hint: String,
102    /// §Fase 38.x.f (D2) — Declared cardinality kind of the expected
103    /// type: `"singular"` | `"plural"` | `"stream"` | `"unit"` |
104    /// `"unknown"`. Empty string for primitive-type validation errors
105    /// where the cardinality isn't load-bearing (the existing v1.39.0
106    /// surface). Serde `#[serde(default)]` keeps adopter consumers of
107    /// older versions byte-compatible.
108    #[serde(default, skip_serializing_if = "String::is_empty")]
109    pub expected_cardinality: String,
110    /// §Fase 38.x.f (D2) — Observed cardinality kind of the response
111    /// body: same alphabet as `expected_cardinality`. Empty when not
112    /// applicable. The asymmetry expected/got is the diagnostic
113    /// payload adopters reach for first when D5 fires.
114    #[serde(default, skip_serializing_if = "String::is_empty")]
115    pub got_cardinality: String,
116    /// §Fase 38.x.f (D2) — Length of the observed value when it is
117    /// `array` (plural). `None` for non-array gots. Helps adopters
118    /// confirm "the flow returned 1 row, but the contract said
119    /// singular — collapse with `result[0]` or change the endpoint
120    /// to `output: List<T>`".
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub got_length: Option<u64>,
123    /// §Fase 38.x.f (D2) — Documentation URL adopters can follow for
124    /// the canonical remediation steps. Empty when the error is not
125    /// a cardinality mismatch (the existing v1.39.0 surface). The
126    /// URL is stable; the page may evolve.
127    #[serde(default, skip_serializing_if = "String::is_empty")]
128    pub remediation_url: String,
129}
130
131impl std::fmt::Display for BodyValidationError {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        write!(f, "{}", self.hint)
134    }
135}
136
137impl std::error::Error for BodyValidationError {}
138
139/// Built-in primitive type names recognised by the validator. Any name
140/// in this set is checked directly against the JSON value's tag; names
141/// NOT in this set are looked up in the per-deploy type table (structured
142/// types). Anything missing from both is reported as `unknown_type` so
143/// adopters who misspell `Strng` get a clear diagnostic instead of a
144/// silent "everything passes" trap.
145pub const BUILTIN_PRIMITIVES: &[&str] = &[
146    "String",
147    "Integer",
148    "Float",
149    "Boolean",
150    "Duration",
151    "Any",
152];
153
154/// Built-in range-constrained numeric types. Mirrors
155/// `RANGED_TYPES` in `axon/compiler/type_checker.py`. These accept any
156/// JSON number that falls within the closed interval.
157pub fn builtin_range(name: &str) -> Option<(f64, f64)> {
158    match name {
159        "RiskScore" | "ConfidenceScore" => Some((0.0, 1.0)),
160        "SentimentScore" => Some((-1.0, 1.0)),
161        _ => None,
162    }
163}
164
165/// Walk every `type T { … }` declaration in the deployed program and
166/// produce a `name → TypeSchema` lookup table. Last-wins on collision
167/// is the same semantics as Rust's `HashMap::insert` — type-name
168/// collisions across deploys are out of scope for 32.c (deferred to a
169/// future type-registry fase). For 32.c the only consumer is the
170/// dynamic-route fallback handler which captures the table once per
171/// deploy.
172pub fn collect_type_table(program: &Program) -> HashMap<String, TypeSchema> {
173    let mut table = HashMap::new();
174    for decl in &program.declarations {
175        if let Declaration::Type(td) = decl {
176            table.insert(td.name.clone(), type_schema_from(td));
177        }
178    }
179    table
180}
181
182fn type_schema_from(td: &TypeDefinition) -> TypeSchema {
183    let fields = td
184        .fields
185        .iter()
186        .map(|f| FieldSchema {
187            name: f.name.clone(),
188            type_name: f.type_expr.name.clone(),
189            generic_param: f.type_expr.generic_param.clone(),
190            optional: f.type_expr.optional,
191        })
192        .collect();
193    let range = td
194        .range_constraint
195        .as_ref()
196        .map(|rc| (rc.min_value, rc.max_value));
197    TypeSchema {
198        name: td.name.clone(),
199        fields,
200        range,
201    }
202}
203
204/// Tag the JSON value with the lowercase string the validator reports as
205/// `got`. Numbers split into `"integer"` vs `"number"` so adopters
206/// declaring `Integer` get the precise "got a number with decimals" path.
207fn json_tag(v: &Value) -> &'static str {
208    match v {
209        Value::Null => "null",
210        Value::Bool(_) => "boolean",
211        Value::Number(n) => {
212            if n.is_i64() || n.is_u64() {
213                "integer"
214            } else {
215                "number"
216            }
217        }
218        Value::String(_) => "string",
219        Value::Array(_) => "array",
220        Value::Object(_) => "object",
221    }
222}
223
224/// Validate `body` against the type named `type_name` looked up in
225/// `table` (or matched against `BUILTIN_PRIMITIVES`).
226///
227/// Returns `Ok(())` on success. Returns `Err(BodyValidationError)` with
228/// the first violation encountered (depth-first, field declaration
229/// order). The error carries enough structure for the HTTP layer to
230/// emit a stable 400 Bad Request body.
231///
232/// **Backwards-compat (D9)**: when `type_name` is empty, returns
233/// `Ok(())` immediately. Adopters who don't declare `body:` keep the
234/// pre-Fase-32 free-form behavior.
235///
236/// **§Fase 39.d — Canonical FlowEnvelope-aware entry**. As of v2.0.0
237/// (39.d) `validate_body` is the SINGLE place that knows about wire
238/// shapes:
239///
240///   1. `FlowEnvelope<T>` declarations — `validate_body` unwraps
241///      `body["result"]` and recurses with the inner T. The outer
242///      envelope shape is verified by construction (object with
243///      `result` slot).
244///   2. Bare generics (`List<T>`, `Stream<T>`) — parsed at the
245///      canonical entry via [`parse_generic_head`]. The internal
246///      [`validate_value`] no longer carries a §0 preamble for
247///      string-stripping (the v1.40.2 / v1.40.3 bridge is retired).
248///   3. Primitives, structs, ranges — passed through to validate_value.
249///
250/// The convergence dividend retires ~46 lines of v1.x bridge code
251/// in favour of one well-named canonical entry. D5 callers (the
252/// runtime gate in `axon_server::apply_output_validation_gate`)
253/// pass the raw declared type verbatim — no manual unwrapping
254/// needed.
255pub fn validate_body(
256    body: &Value,
257    type_name: &str,
258    table: &HashMap<String, TypeSchema>,
259) -> Result<(), BodyValidationError> {
260    let t = type_name.trim();
261    if t.is_empty() {
262        return Ok(());
263    }
264    // §Fase 39.d — FlowEnvelope<T> canonical unwrap. When the adopter
265    // declares `output: FlowEnvelope<T>` (the v2.0.0 mandatory wire
266    // shape), the body is `{ontological_type, result, certainty, …}`
267    // and we validate `result` against T. The outer envelope shape
268    // (object with `result` slot) is verified by construction at
269    // the seal() layer; here we trust + unwrap.
270    if let Some(inner) = strip_flow_envelope(t) {
271        let obj = match body.as_object() {
272            Some(o) => o,
273            None => {
274                return Err(BodyValidationError {
275                    expected_type: type_name.to_string(),
276                    field_path: String::new(),
277                    expected: t.to_string(),
278                    got: json_tag(body).to_string(),
279                    hint: format!(
280                        "axonendpoint declared `output: {t}` but the response \
281                         body is not a JSON object — the FlowEnvelope wire \
282                         shape requires `{{ontological_type, result, …}}`. \
283                         This typically indicates a bug in the response wrapper."
284                    ),
285                    ..Default::default()
286                });
287            }
288        };
289        let result_slot = obj
290            .get("result")
291            .cloned()
292            .unwrap_or(Value::Null);
293        // `FlowEnvelope<Any>` is the universal accept (degraded
294        // surface) — no further validation on the inner.
295        if inner == "Any" {
296            return Ok(());
297        }
298        // Recurse on the inner T (which may itself be a generic
299        // like `List<X>` or a struct or a primitive).
300        return validate_body(&result_slot, &inner, table);
301    }
302    // §Fase 39.d — bare generic parsing at the canonical entry.
303    // `List<T>` / `Stream<T>` get split into `(head, inner)` before
304    // dispatching to validate_value, which now assumes pre-parsed
305    // input. Pre-39.d this parsing lived in validate_value's §0
306    // preamble (v1.40.2 / v1.40.3 bridge); 39.d retires it because
307    // FlowEnvelope<T> is the canonical wire shape.
308    let (head, generic) = parse_generic_head(t);
309    validate_value(body, &head, &generic, "", table, t)
310}
311
312/// §Fase 39.d — Strip the outer `FlowEnvelope<…>` wrapper from a
313/// declared type string. Returns the inner T verbatim (which may
314/// be a nested generic like `List<X>` or a struct name). Returns
315/// `None` when the input is NOT a FlowEnvelope wrapper.
316fn strip_flow_envelope(t: &str) -> Option<String> {
317    let rest = t.strip_prefix("FlowEnvelope<")?;
318    let inner = rest.strip_suffix('>')?;
319    Some(inner.trim().to_string())
320}
321
322/// §Fase 39.d — Parse the closed-catalog generic head + inner. Used
323/// by [`validate_body`] (the canonical entry) and by recursive
324/// callers like [`validate_list`] that need to split a string-form
325/// element type before calling [`validate_value`].
326///
327/// Closed grammar at v2.0.0:
328///   - `List<X>`   → `("List", "X")`
329///   - `Stream<X>` → `("Stream", "X")`
330///   - anything else → `(t, "")`
331///
332/// Future generics (Map<K,V>, Optional<T>, …) extend this helper
333/// additively without touching the validators downstream.
334fn parse_generic_head(t: &str) -> (String, String) {
335    if let Some(rest) = t.strip_prefix("List<") {
336        if let Some(inner) = rest.strip_suffix('>') {
337            return ("List".to_string(), inner.trim().to_string());
338        }
339    }
340    if let Some(rest) = t.strip_prefix("Stream<") {
341        if let Some(inner) = rest.strip_suffix('>') {
342            return ("Stream".to_string(), inner.trim().to_string());
343        }
344    }
345    (t.to_string(), String::new())
346}
347
348/// Internal recursive validator.
349///
350/// `body_type` is the top-level type the user declared (kept invariant
351/// across recursion for diagnostic continuity).
352/// `field_path` is the dotted path accumulated so far ("" at top level).
353/// `generic_param` carries `List<T>`'s element type when validating a
354/// list — empty otherwise.
355///
356/// **§Fase 39.d**: post-39.d this function assumes the input is
357/// PRE-PARSED. The v1.40.2/v1.40.3 §0 preamble (string-stripping for
358/// `List<T>` / `Stream<T>`) is retired in favour of one canonical
359/// parse at the [`validate_body`] entry. Recursive callers
360/// ([`validate_list`], [`validate_struct`]) pre-parse via
361/// [`parse_generic_head`] before calling here.
362fn validate_value(
363    v: &Value,
364    type_name: &str,
365    generic_param: &str,
366    field_path: &str,
367    table: &HashMap<String, TypeSchema>,
368    body_type: &str,
369) -> Result<(), BodyValidationError> {
370    // §Fase 39.d — `Stream<T>` defensive accept. Top-level Stream<T>
371    // body validation is structurally unreachable from the v2.0.0
372    // production path (SSE chunks validate at the streaming wire,
373    // not via this body validator — D9 of plan vivo Fase 39). When
374    // we DO observe it defensively, return Ok early.
375    if type_name == "Stream" {
376        return Ok(());
377    }
378    // §1 — primitives
379    if BUILTIN_PRIMITIVES.contains(&type_name) {
380        return validate_primitive(v, type_name, field_path, body_type);
381    }
382    // §2 — range-constrained built-ins (RiskScore, ConfidenceScore, …)
383    if let Some((lo, hi)) = builtin_range(type_name) {
384        return validate_ranged_number(v, type_name, lo, hi, field_path, body_type);
385    }
386    // §3 — generic List<T>
387    if type_name == "List" {
388        return validate_list(v, generic_param, field_path, table, body_type);
389    }
390    // §4 — structured types declared in the program
391    if let Some(schema) = table.get(type_name) {
392        // Numeric range-constrained user types (`type RiskScore(0.0..1.0)`)
393        if let Some((lo, hi)) = schema.range {
394            return validate_ranged_number(v, type_name, lo, hi, field_path, body_type);
395        }
396        return validate_struct(v, schema, field_path, table, body_type);
397    }
398    // §5 — unknown type. Adopter misspell or undeclared type. We surface
399    // it instead of silently passing so the diagnostic is actionable.
400    Err(BodyValidationError {
401        expected_type: body_type.to_string(),
402        field_path: field_path.to_string(),
403        expected: type_name.to_string(),
404        got: json_tag(v).to_string(),
405        hint: format!(
406            "axonendpoint declared an unknown body type `{type_name}` for field \
407             `{field_path}` — neither a built-in primitive nor a declared \
408             `type` in the deployed source. Add `type {type_name} {{ … }}` to \
409             the source or correct the spelling."
410        ),
411        ..Default::default()
412    })
413}
414
415fn validate_primitive(
416    v: &Value,
417    type_name: &str,
418    field_path: &str,
419    body_type: &str,
420) -> Result<(), BodyValidationError> {
421    let ok = match (type_name, v) {
422        ("String", Value::String(_)) => true,
423        ("Integer", Value::Number(n)) => n.is_i64() || n.is_u64(),
424        ("Float", Value::Number(_)) => true,
425        ("Boolean", Value::Bool(_)) => true,
426        ("Duration", Value::String(_)) => true,
427        ("Any", _) => true,
428        _ => false,
429    };
430    if ok {
431        return Ok(());
432    }
433    Err(BodyValidationError {
434        expected_type: body_type.to_string(),
435        field_path: field_path.to_string(),
436        expected: type_name.to_string(),
437        got: json_tag(v).to_string(),
438        hint: format!(
439            "Body field `{field_path}` must be a `{type_name}` but received a \
440             {got}. Adjust the request body or the axonendpoint's `body:` \
441             declaration.",
442            field_path = if field_path.is_empty() { "<body>" } else { field_path },
443            type_name = type_name,
444            got = json_tag(v),
445        ),
446        ..Default::default()
447    })
448}
449
450/// Format an `f64` the same way both stacks render bounds + `got`
451/// values inside validation errors. Whole-valued floats render as the
452/// integer ("0", "1", "-1"); fractional values render via `{f64}`'s
453/// shortest round-trip representation ("1.5", "-1.5"). This locks the
454/// drift gate against Rust's `Display for f64` quirks vs Python's
455/// `str(float)` adding ".0".
456pub fn fmt_f64(n: f64) -> String {
457    if n.is_finite() && n.fract() == 0.0 && n.abs() < 1e16 {
458        return format!("{}", n as i64);
459    }
460    format!("{n}")
461}
462
463fn validate_ranged_number(
464    v: &Value,
465    type_name: &str,
466    lo: f64,
467    hi: f64,
468    field_path: &str,
469    body_type: &str,
470) -> Result<(), BodyValidationError> {
471    // §32.c — `Number::is_i64() || is_u64() || is_f64()` already covers
472    // every JSON-number variant; bool excluded explicitly because
473    // `serde_json::Value::as_f64` does NOT coerce booleans.
474    let n = match (v, v.as_f64()) {
475        (Value::Number(_), Some(n)) => n,
476        _ => {
477            return Err(BodyValidationError {
478                expected_type: body_type.to_string(),
479                field_path: field_path.to_string(),
480                expected: type_name.to_string(),
481                got: json_tag(v).to_string(),
482                hint: format!(
483                    "Body field `{path}` must be a `{type_name}` (numeric in \
484                     [{lo}, {hi}]) but received a {got}.",
485                    path = if field_path.is_empty() { "<body>" } else { field_path },
486                    type_name = type_name,
487                    got = json_tag(v),
488                    lo = fmt_f64(lo),
489                    hi = fmt_f64(hi),
490                ),
491                ..Default::default()
492            });
493        }
494    };
495    if n < lo || n > hi {
496        let lo_s = fmt_f64(lo);
497        let hi_s = fmt_f64(hi);
498        let n_s = fmt_f64(n);
499        return Err(BodyValidationError {
500            expected_type: body_type.to_string(),
501            field_path: field_path.to_string(),
502            expected: format!("{type_name} ∈ [{lo_s}, {hi_s}]"),
503            got: n_s.clone(),
504            hint: format!(
505                "Body field `{path}` must satisfy `{type_name} ∈ [{lo_s}, \
506                 {hi_s}]` but received `{n_s}`.",
507                path = if field_path.is_empty() { "<body>" } else { field_path },
508            ),
509            ..Default::default()
510        });
511    }
512    Ok(())
513}
514
515fn validate_list(
516    v: &Value,
517    element_type: &str,
518    field_path: &str,
519    table: &HashMap<String, TypeSchema>,
520    body_type: &str,
521) -> Result<(), BodyValidationError> {
522    let arr = match v.as_array() {
523        Some(a) => a,
524        None => {
525            return Err(BodyValidationError {
526                expected_type: body_type.to_string(),
527                field_path: field_path.to_string(),
528                expected: format!("List<{element_type}>"),
529                got: json_tag(v).to_string(),
530                hint: format!(
531                    "Body field `{path}` must be a `List<{element_type}>` \
532                     (JSON array) but received a {got}.",
533                    path = if field_path.is_empty() { "<body>" } else { field_path },
534                    got = json_tag(v),
535                ),
536                // §Fase 38.x.f (D2) — When this validation fires at
537                // the TOP-LEVEL body (empty field_path), the mismatch
538                // is between the declared `List<T>` (plural) and the
539                // observed JSON shape (not an array). Populate the
540                // cardinality diagnostic fields so adopters reaching
541                // for the audit_log entry see the cardinality story
542                // directly. For nested field violations the fields
543                // stay empty (the mismatch is at a sub-field, not
544                // load-bearing for the endpoint-level contract).
545                expected_cardinality: if field_path.is_empty() {
546                    "plural".to_string()
547                } else {
548                    String::new()
549                },
550                got_cardinality: if field_path.is_empty() {
551                    match v {
552                        Value::Object(_) => "singular".to_string(),
553                        Value::Null => "unit".to_string(),
554                        _ => "singular".to_string(),
555                    }
556                } else {
557                    String::new()
558                },
559                got_length: None,
560                remediation_url: if field_path.is_empty() {
561                    "https://axon-lang.io/docs/cardinality-mismatch".to_string()
562                } else {
563                    String::new()
564                },
565            });
566        }
567    };
568    if element_type.is_empty() {
569        // `List` with no generic param — accept any element (degenerate
570        // declaration; parser should ideally warn but doesn't today).
571        return Ok(());
572    }
573    // §Fase 39.d — pre-parse the element type ONCE for the whole
574    // iteration. This replaces the per-element string-stripping that
575    // the v1.40.2/v1.40.3 §0 preamble in validate_value used to do.
576    let (elem_head, elem_generic) = parse_generic_head(element_type);
577    for (idx, elem) in arr.iter().enumerate() {
578        let elem_path = if field_path.is_empty() {
579            format!("[{idx}]")
580        } else {
581            format!("{field_path}[{idx}]")
582        };
583        validate_value(
584            elem,
585            &elem_head,
586            &elem_generic,
587            &elem_path,
588            table,
589            body_type,
590        )?;
591    }
592    Ok(())
593}
594
595fn validate_struct(
596    v: &Value,
597    schema: &TypeSchema,
598    field_path: &str,
599    table: &HashMap<String, TypeSchema>,
600    body_type: &str,
601) -> Result<(), BodyValidationError> {
602    let obj = match v.as_object() {
603        Some(o) => o,
604        None => {
605            return Err(BodyValidationError {
606                expected_type: body_type.to_string(),
607                field_path: field_path.to_string(),
608                expected: schema.name.clone(),
609                got: json_tag(v).to_string(),
610                hint: format!(
611                    "Body field `{path}` must be a `{type_name}` (JSON object) \
612                     but received a {got}. {cardinality_hint}",
613                    path = if field_path.is_empty() { "<body>" } else { field_path },
614                    type_name = schema.name,
615                    got = json_tag(v),
616                    cardinality_hint = if field_path.is_empty() && v.is_array() {
617                        format!(
618                            "The flow returned a `List<{tn}>` (array of {n} \
619                             items) but the endpoint declared `output: {tn}` \
620                             (singular). Either change the endpoint to \
621                             `output: List<{tn}>` or collapse the flow's tail \
622                             to a single item (e.g. `return result[0]`). \
623                             (Fase 38.x.f D2)",
624                            tn = schema.name,
625                            n = v.as_array().map(|a| a.len()).unwrap_or(0),
626                        )
627                    } else {
628                        String::new()
629                    },
630                ),
631                // §Fase 38.x.f (D2) — when the top-level body got an
632                // array but expected an object, this is the canonical
633                // singular-vs-plural mismatch. Populate the structured
634                // cardinality diagnostic fields for the audit_log.
635                expected_cardinality: if field_path.is_empty() {
636                    "singular".to_string()
637                } else {
638                    String::new()
639                },
640                got_cardinality: if field_path.is_empty() {
641                    match v {
642                        Value::Array(_) => "plural".to_string(),
643                        Value::Null => "unit".to_string(),
644                        _ => "singular".to_string(),
645                    }
646                } else {
647                    String::new()
648                },
649                got_length: if field_path.is_empty() {
650                    v.as_array().map(|a| a.len() as u64)
651                } else {
652                    None
653                },
654                remediation_url: if field_path.is_empty() && v.is_array() {
655                    "https://axon-lang.io/docs/cardinality-mismatch".to_string()
656                } else {
657                    String::new()
658                },
659            });
660        }
661    };
662    for field in &schema.fields {
663        let child_path = if field_path.is_empty() {
664            field.name.clone()
665        } else {
666            format!("{field_path}.{}", field.name)
667        };
668        match obj.get(&field.name) {
669            None => {
670                if field.optional {
671                    continue;
672                }
673                return Err(BodyValidationError {
674                    expected_type: body_type.to_string(),
675                    field_path: child_path.clone(),
676                    expected: field.type_name.clone(),
677                    got: "missing".to_string(),
678                    hint: format!(
679                        "Body field `{child_path}` is required (declared as \
680                         `{type_name}` on `{struct_name}`) but is absent from \
681                         the request body.",
682                        type_name = field.type_name,
683                        struct_name = schema.name,
684                    ),
685                    ..Default::default()
686                });
687            }
688            Some(child) => {
689                // Optional `T?` fields with explicit JSON null are accepted.
690                if field.optional && child.is_null() {
691                    continue;
692                }
693                validate_value(
694                    child,
695                    &field.type_name,
696                    &field.generic_param,
697                    &child_path,
698                    table,
699                    body_type,
700                )?;
701            }
702        }
703    }
704    // Unknown extra fields are NOT rejected — adopters can pass extra
705    // payload the flow ignores (industry-standard "be liberal in what you
706    // accept" for forwards-compat with client-side additions). Strict
707    // mode is a future opt-in if vertical compliance demands.
708    Ok(())
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    fn t_string() -> TypeSchema {
716        TypeSchema {
717            name: "String".to_string(),
718            fields: vec![],
719            range: None,
720        }
721    }
722
723    fn person_schema() -> TypeSchema {
724        TypeSchema {
725            name: "Person".to_string(),
726            fields: vec![
727                FieldSchema {
728                    name: "name".to_string(),
729                    type_name: "String".to_string(),
730                    generic_param: String::new(),
731                    optional: false,
732                },
733                FieldSchema {
734                    name: "age".to_string(),
735                    type_name: "Integer".to_string(),
736                    generic_param: String::new(),
737                    optional: true,
738                },
739            ],
740            range: None,
741        }
742    }
743
744    #[test]
745    fn empty_body_type_passes_any_body() {
746        let table = HashMap::new();
747        let body = serde_json::json!({"anything": "goes"});
748        assert!(validate_body(&body, "", &table).is_ok());
749    }
750
751    #[test]
752    fn primitive_string_ok() {
753        let table = HashMap::new();
754        let body = serde_json::json!("hello");
755        assert!(validate_body(&body, "String", &table).is_ok());
756    }
757
758    #[test]
759    fn primitive_string_rejects_number() {
760        let table = HashMap::new();
761        let body = serde_json::json!(42);
762        let err = validate_body(&body, "String", &table).unwrap_err();
763        assert_eq!(err.expected, "String");
764        assert_eq!(err.got, "integer");
765    }
766
767    #[test]
768    fn integer_rejects_float() {
769        let table = HashMap::new();
770        let body = serde_json::json!(3.14);
771        let err = validate_body(&body, "Integer", &table).unwrap_err();
772        assert_eq!(err.expected, "Integer");
773        assert_eq!(err.got, "number");
774    }
775
776    #[test]
777    fn float_accepts_integer_json() {
778        let table = HashMap::new();
779        let body = serde_json::json!(42);
780        assert!(validate_body(&body, "Float", &table).is_ok());
781        let body = serde_json::json!(3.14);
782        assert!(validate_body(&body, "Float", &table).is_ok());
783    }
784
785    #[test]
786    fn structured_missing_required_field() {
787        let mut table = HashMap::new();
788        table.insert("Person".to_string(), person_schema());
789        let body = serde_json::json!({"age": 30});
790        let err = validate_body(&body, "Person", &table).unwrap_err();
791        assert_eq!(err.field_path, "name");
792        assert_eq!(err.got, "missing");
793    }
794
795    #[test]
796    fn structured_optional_field_can_be_absent() {
797        let mut table = HashMap::new();
798        table.insert("Person".to_string(), person_schema());
799        let body = serde_json::json!({"name": "alice"});
800        assert!(validate_body(&body, "Person", &table).is_ok());
801    }
802
803    #[test]
804    fn structured_optional_field_can_be_null() {
805        let mut table = HashMap::new();
806        table.insert("Person".to_string(), person_schema());
807        let body = serde_json::json!({"name": "alice", "age": null});
808        assert!(validate_body(&body, "Person", &table).is_ok());
809    }
810
811    #[test]
812    fn structured_unknown_extra_fields_accepted() {
813        let mut table = HashMap::new();
814        table.insert("Person".to_string(), person_schema());
815        let body = serde_json::json!({"name": "alice", "extra": "data"});
816        assert!(validate_body(&body, "Person", &table).is_ok());
817    }
818
819    #[test]
820    fn list_validates_each_element() {
821        let mut table = HashMap::new();
822        table.insert("String".to_string(), t_string());
823        let body = serde_json::json!(["a", "b", "c"]);
824        let err = validate_body(&body, "List", &table);
825        assert!(err.is_ok());
826    }
827
828    #[test]
829    fn list_rejects_non_array() {
830        let table = HashMap::new();
831        let body = serde_json::json!({"not": "array"});
832        // Use a synthetic harness for the generic_param flavor.
833        let r = validate_value(&body, "List", "String", "", &table, "List");
834        let err = r.unwrap_err();
835        assert!(err.expected.contains("List"));
836        assert_eq!(err.got, "object");
837    }
838
839    #[test]
840    fn list_element_violation_reports_indexed_path() {
841        let table = HashMap::new();
842        let body = serde_json::json!(["a", 42, "c"]);
843        let r = validate_value(&body, "List", "String", "", &table, "List");
844        let err = r.unwrap_err();
845        assert_eq!(err.field_path, "[1]");
846        assert_eq!(err.got, "integer");
847    }
848
849    #[test]
850    fn range_type_rejects_out_of_bounds() {
851        let table = HashMap::new();
852        let body = serde_json::json!(1.5);
853        let err = validate_body(&body, "RiskScore", &table).unwrap_err();
854        assert!(err.expected.contains("RiskScore"));
855    }
856
857    #[test]
858    fn range_type_accepts_in_bounds() {
859        let table = HashMap::new();
860        let body = serde_json::json!(0.7);
861        assert!(validate_body(&body, "RiskScore", &table).is_ok());
862    }
863
864    #[test]
865    fn unknown_type_returns_diagnostic() {
866        let table = HashMap::new();
867        let body = serde_json::json!({});
868        let err = validate_body(&body, "NotDeclared", &table).unwrap_err();
869        assert!(err.hint.contains("NotDeclared"));
870    }
871
872    #[test]
873    fn nested_struct_field_path_is_dotted() {
874        let mut table = HashMap::new();
875        table.insert("Person".to_string(), person_schema());
876        table.insert(
877            "Loan".to_string(),
878            TypeSchema {
879                name: "Loan".to_string(),
880                fields: vec![FieldSchema {
881                    name: "applicant".to_string(),
882                    type_name: "Person".to_string(),
883                    generic_param: String::new(),
884                    optional: false,
885                }],
886                range: None,
887            },
888        );
889        let body = serde_json::json!({"applicant": {"age": 30}});
890        let err = validate_body(&body, "Loan", &table).unwrap_err();
891        assert_eq!(err.field_path, "applicant.name");
892        assert_eq!(err.expected_type, "Loan");
893    }
894
895    #[test]
896    fn json_tag_distinguishes_integer_and_number() {
897        assert_eq!(json_tag(&serde_json::json!(42)), "integer");
898        assert_eq!(json_tag(&serde_json::json!(3.14)), "number");
899    }
900
901    // ── §Fase 38.x.f.9 — generic-aware §0 preamble tests ────────────
902
903    #[test]
904    fn fase38xf9_validate_body_accepts_list_of_primitive() {
905        // §Fase 38.x.f.9 — pre-hotfix the T9XX hint suggested
906        // `output: List<String>` but `validate_body` rejected it as
907        // unknown_type. Post-hotfix: §0 preamble strips the generic
908        // and dispatches to §3 (`validate_list`) properly.
909        let table: HashMap<String, TypeSchema> = HashMap::new();
910        let body = serde_json::json!(["alice", "bob"]);
911        let r = validate_body(&body, "List<String>", &table);
912        assert!(
913            r.is_ok(),
914            "List<String> over a String array must validate. Got: {r:?}"
915        );
916    }
917
918    #[test]
919    fn fase38xf9_validate_body_accepts_list_of_struct() {
920        let mut table: HashMap<String, TypeSchema> = HashMap::new();
921        table.insert("Person".to_string(), person_schema());
922        let body = serde_json::json!([{"name": "alice", "age": 30}, {"name": "bob", "age": 25}]);
923        let r = validate_body(&body, "List<Person>", &table);
924        assert!(
925            r.is_ok(),
926            "List<Person> over a Person array must validate. Got: {r:?}"
927        );
928    }
929
930    #[test]
931    fn fase38xf9_validate_body_rejects_list_of_unknown_inner() {
932        // Inner type unknown → unknown_type error from §5 with the
933        // inner type name, NOT a generic-string failure.
934        let table: HashMap<String, TypeSchema> = HashMap::new();
935        let body = serde_json::json!([{}]);
936        let r = validate_body(&body, "List<UnknownType>", &table);
937        assert!(r.is_err(), "List<UnknownType> must surface the inner-type miss.");
938        let err = r.unwrap_err();
939        assert!(
940            err.hint.contains("UnknownType"),
941            "diagnostic must name the inner type (`UnknownType`), not the outer `List<...>` shape. \
942             Got hint: {}",
943            err.hint
944        );
945    }
946
947    #[test]
948    fn fase38xf9_validate_body_rejects_list_against_non_array() {
949        // §3 (validate_list) handles the non-array case; we test the
950        // wiring catches it via the §0 preamble.
951        let table: HashMap<String, TypeSchema> = HashMap::new();
952        let body = serde_json::json!({"not": "an array"});
953        let r = validate_body(&body, "List<String>", &table);
954        assert!(r.is_err(), "object against List<String> must error.");
955        let err = r.unwrap_err();
956        assert_eq!(err.got, "object");
957        assert!(err.expected.contains("List"));
958    }
959
960    #[test]
961    fn fase38xf9_validate_body_accepts_nested_list_of_list() {
962        // Recursive — §0 strips outer, recurses with type_name="List",
963        // generic_param="List<String>". §3's validate_list iterates
964        // the outer array's elements; per-element validate_value lands
965        // back in §0 which strips the inner.
966        let table: HashMap<String, TypeSchema> = HashMap::new();
967        let body = serde_json::json!([["a", "b"], ["c"]]);
968        let r = validate_body(&body, "List<List<String>>", &table);
969        assert!(
970            r.is_ok(),
971            "Nested List<List<String>> over an array-of-arrays must validate. Got: {r:?}"
972        );
973    }
974
975    #[test]
976    fn fase38xf9_validate_body_stream_returns_ok_early() {
977        // §Fase 38.x.f.9 — Stream<T> body validation is structurally
978        // unreachable (SSE chunks are validated at the wire layer, not
979        // the body layer). Defensive Ok early.
980        //
981        // §Fase 39.d — preserved verbatim. The §0 preamble that
982        // implemented this case in v1.40.2/v1.40.3 was deleted; the
983        // defensive Ok now lives in validate_value (top of function)
984        // for the `Stream` head case after the canonical entry parses.
985        let table: HashMap<String, TypeSchema> = HashMap::new();
986        let body = serde_json::json!({"anything": "goes"});
987        let r = validate_body(&body, "Stream<Token>", &table);
988        assert!(
989            r.is_ok(),
990            "Stream<T> at the body validator layer must be a defensive Ok. \
991             Got: {r:?}"
992        );
993    }
994
995    // ── §Fase 39.d — canonical entry + helpers ────────────────────
996
997    #[test]
998    fn fase39d_parse_generic_head_list() {
999        let (h, g) = parse_generic_head("List<TenantRecord>");
1000        assert_eq!(h, "List");
1001        assert_eq!(g, "TenantRecord");
1002    }
1003
1004    #[test]
1005    fn fase39d_parse_generic_head_stream() {
1006        let (h, g) = parse_generic_head("Stream<Token>");
1007        assert_eq!(h, "Stream");
1008        assert_eq!(g, "Token");
1009    }
1010
1011    #[test]
1012    fn fase39d_parse_generic_head_nested_list() {
1013        // Nested generic `List<List<X>>` returns the outer split.
1014        // The inner `List<X>` is parsed by the recursive entry into
1015        // validate_list → parse_generic_head again.
1016        let (h, g) = parse_generic_head("List<List<X>>");
1017        assert_eq!(h, "List");
1018        assert_eq!(g, "List<X>");
1019    }
1020
1021    #[test]
1022    fn fase39d_parse_generic_head_bare_type() {
1023        let (h, g) = parse_generic_head("TenantRecord");
1024        assert_eq!(h, "TenantRecord");
1025        assert_eq!(g, "");
1026    }
1027
1028    #[test]
1029    fn fase39d_parse_generic_head_inner_whitespace_trimmed() {
1030        let (h, g) = parse_generic_head("List<  TenantRecord  >");
1031        assert_eq!(h, "List");
1032        assert_eq!(g, "TenantRecord");
1033    }
1034
1035    #[test]
1036    fn fase39d_strip_flow_envelope_singular() {
1037        assert_eq!(
1038            strip_flow_envelope("FlowEnvelope<TenantRecord>"),
1039            Some("TenantRecord".to_string())
1040        );
1041    }
1042
1043    #[test]
1044    fn fase39d_strip_flow_envelope_list() {
1045        assert_eq!(
1046            strip_flow_envelope("FlowEnvelope<List<TenantRecord>>"),
1047            Some("List<TenantRecord>".to_string())
1048        );
1049    }
1050
1051    #[test]
1052    fn fase39d_strip_flow_envelope_returns_none_on_bare() {
1053        assert_eq!(strip_flow_envelope("TenantRecord"), None);
1054        assert_eq!(strip_flow_envelope("List<X>"), None);
1055        assert_eq!(strip_flow_envelope(""), None);
1056    }
1057
1058    #[test]
1059    fn fase39d_validate_body_unwraps_flow_envelope_with_struct() {
1060        // §39.d canonical: declared `FlowEnvelope<Person>`, body is
1061        // the FlowEnvelope wire shape, validation targets `result`
1062        // slot against `Person`.
1063        let mut table: HashMap<String, TypeSchema> = HashMap::new();
1064        table.insert("Person".to_string(), person_schema());
1065        let envelope = serde_json::json!({
1066            "ontological_type": "Person",
1067            "result": {"name": "alice", "age": 30},
1068            "certainty": 1.0,
1069            "provenance_chain": [],
1070            "step_audit": {},
1071            "audit_chain_hash": "",
1072            "blame_attribution": null,
1073            "execution_metrics": {},
1074            "trace_id": "t"
1075        });
1076        let r = validate_body(&envelope, "FlowEnvelope<Person>", &table);
1077        assert!(r.is_ok(), "FlowEnvelope<Person> over a Person body must validate. Got: {r:?}");
1078    }
1079
1080    #[test]
1081    fn fase39d_validate_body_unwraps_flow_envelope_with_list() {
1082        // §39.d canonical: declared `FlowEnvelope<List<Person>>`, the
1083        // result slot is an array of Person.
1084        let mut table: HashMap<String, TypeSchema> = HashMap::new();
1085        table.insert("Person".to_string(), person_schema());
1086        let envelope = serde_json::json!({
1087            "ontological_type": "List<Person>",
1088            "result": [
1089                {"name": "alice", "age": 30},
1090                {"name": "bob", "age": 25}
1091            ],
1092            "certainty": 1.0,
1093            "provenance_chain": [],
1094            "step_audit": {},
1095            "audit_chain_hash": "",
1096            "blame_attribution": null,
1097            "execution_metrics": {},
1098            "trace_id": "t"
1099        });
1100        let r = validate_body(&envelope, "FlowEnvelope<List<Person>>", &table);
1101        assert!(
1102            r.is_ok(),
1103            "FlowEnvelope<List<Person>> over a Person array result must \
1104             validate. Got: {r:?}"
1105        );
1106    }
1107
1108    #[test]
1109    fn fase39d_validate_body_rejects_flow_envelope_with_wrong_inner_type() {
1110        // §39.d — the canonical unwrap recurses on the inner T;
1111        // if the `result` slot doesn't match T, validation fails
1112        // with the inner-T error context.
1113        let mut table: HashMap<String, TypeSchema> = HashMap::new();
1114        table.insert("Person".to_string(), person_schema());
1115        // Result has a wrong-type field (age is a string, not int).
1116        let envelope = serde_json::json!({
1117            "ontological_type": "Person",
1118            "result": {"name": "alice", "age": "thirty"},
1119            "certainty": 1.0,
1120            "provenance_chain": [],
1121            "step_audit": {},
1122            "audit_chain_hash": "",
1123            "blame_attribution": null,
1124            "execution_metrics": {},
1125            "trace_id": "t"
1126        });
1127        let r = validate_body(&envelope, "FlowEnvelope<Person>", &table);
1128        assert!(
1129            r.is_err(),
1130            "Wrong inner-type MUST surface as validation error"
1131        );
1132        let err = r.unwrap_err();
1133        assert_eq!(err.field_path, "age");
1134    }
1135
1136    #[test]
1137    fn fase39d_validate_body_rejects_flow_envelope_with_non_object_body() {
1138        // §39.d — when declared is FlowEnvelope<T> but the body isn't
1139        // a JSON object, validation surfaces a structural error (the
1140        // wire wrapper is mandated to be an object).
1141        let table: HashMap<String, TypeSchema> = HashMap::new();
1142        let body = serde_json::json!("not an object");
1143        let r = validate_body(&body, "FlowEnvelope<Any>", &table);
1144        assert!(
1145            r.is_err(),
1146            "Non-object body MUST fail FlowEnvelope<T> shape check"
1147        );
1148        let err = r.unwrap_err();
1149        assert!(err.hint.contains("FlowEnvelope"));
1150    }
1151
1152    #[test]
1153    fn fase39d_validate_body_flow_envelope_any_skips_inner_validation() {
1154        // §39.d — `FlowEnvelope<Any>` is the universal accept
1155        // (degraded surface); the inner result is not validated.
1156        let table: HashMap<String, TypeSchema> = HashMap::new();
1157        let envelope = serde_json::json!({
1158            "ontological_type": "Any",
1159            "result": {"anything": "goes"},
1160            "certainty": 1.0,
1161            "provenance_chain": [],
1162            "step_audit": {},
1163            "audit_chain_hash": "",
1164            "blame_attribution": null,
1165            "execution_metrics": {},
1166            "trace_id": "t"
1167        });
1168        let r = validate_body(&envelope, "FlowEnvelope<Any>", &table);
1169        assert!(r.is_ok());
1170    }
1171
1172    #[test]
1173    fn fase39d_validate_body_flow_envelope_with_missing_result_slot() {
1174        // §39.d — when the body lacks `result`, the unwrapper
1175        // treats it as Value::Null and validates Null against T.
1176        // For T=Any this is Ok; for T=Person it's a struct mismatch.
1177        let mut table: HashMap<String, TypeSchema> = HashMap::new();
1178        table.insert("Person".to_string(), person_schema());
1179        let envelope = serde_json::json!({
1180            "ontological_type": "Person",
1181            "certainty": 1.0
1182            // no `result` slot
1183        });
1184        let r = validate_body(&envelope, "FlowEnvelope<Person>", &table);
1185        assert!(
1186            r.is_err(),
1187            "Missing result slot MUST fail when inner type is non-Any"
1188        );
1189    }
1190
1191    #[test]
1192    fn fase39d_validate_value_no_longer_carries_section_0_preamble() {
1193        // §Fase 39.d — STATIC grep gate. The §0 preamble that
1194        // string-stripped List<X> / Stream<X> in v1.40.2/v1.40.3 is
1195        // RETIRED. Any future PR that reintroduces it inside
1196        // validate_value breaks this assertion.
1197        let src = std::fs::read_to_string("src/route_schema.rs")
1198            .expect("read route_schema.rs");
1199        // The §0 marker text was unique; if it reappears we know the
1200        // bridge was reinstated.
1201        assert!(
1202            !src.contains("§0 — §Fase 38.x.f.9 (POST-CLOSE HOTFIX 2026-05-21) — generic-\n    // aware parsing"),
1203            "§Fase 39.d §S — the v1.40.2/v1.40.3 §0 preamble inside \
1204             validate_value MUST stay retired. Generic parsing belongs \
1205             at the canonical validate_body entry now."
1206        );
1207    }
1208
1209    #[test]
1210    fn fase39d_d5_gate_simplified_calls_validate_body_directly() {
1211        // §Fase 39.d — STATIC grep gate on axon_server.rs. The pre-39.d
1212        // gate manually extracted inner-T + result slot; post-39.d
1213        // validate_body is the canonical entry and the gate just calls
1214        // it with the raw declared type.
1215        let src = std::fs::read_to_string("src/axon_server.rs")
1216            .expect("read axon_server.rs");
1217        // The manual extract pattern from 39.b should be gone.
1218        // (Allow it to still APPEAR in comments — only the active
1219        // code path matters.)
1220        let active_extract_calls = src.matches(
1221            "crate::wire_envelope::extract_inner_ontological_type(&route.output_type)"
1222        ).count();
1223        // 39.b had this in the active path; 39.d removes the active
1224        // call. The taxonomy might still be referenced in comments
1225        // but not as the active gate logic.
1226        assert!(
1227            active_extract_calls <= 1,
1228            "§Fase 39.d §S — the D5 gate MUST NOT manually call \
1229             `extract_inner_ontological_type` for unwrapping (that work \
1230             moved into validate_body). Found {active_extract_calls} \
1231             active references."
1232        );
1233    }
1234}