1use std::collections::HashMap;
51
52use serde_json::Value;
53
54use crate::ast::{Declaration, Program, TypeDefinition};
55
56#[derive(Debug, Clone, PartialEq)]
61pub struct TypeSchema {
62 pub name: String,
63 pub fields: Vec<FieldSchema>,
64 pub range: Option<(f64, f64)>,
67}
68
69#[derive(Debug, Clone, PartialEq)]
72pub struct FieldSchema {
73 pub name: String,
74 pub type_name: String,
75 pub generic_param: String,
78 pub optional: bool,
79}
80
81#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
85pub struct BodyValidationError {
86 pub expected_type: String,
89 pub field_path: String,
94 pub expected: String,
96 pub got: String,
99 pub hint: String,
102 #[serde(default, skip_serializing_if = "String::is_empty")]
109 pub expected_cardinality: String,
110 #[serde(default, skip_serializing_if = "String::is_empty")]
115 pub got_cardinality: String,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub got_length: Option<u64>,
123 #[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
139pub const BUILTIN_PRIMITIVES: &[&str] = &[
146 "String",
147 "Integer",
148 "Float",
149 "Boolean",
150 "Duration",
151 "Any",
152];
153
154pub 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
165pub 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
204fn 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
224pub 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 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 if inner == "Any" {
296 return Ok(());
297 }
298 return validate_body(&result_slot, &inner, table);
301 }
302 let (head, generic) = parse_generic_head(t);
309 validate_value(body, &head, &generic, "", table, t)
310}
311
312fn 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
322fn 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
348fn 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 if type_name == "Stream" {
376 return Ok(());
377 }
378 if BUILTIN_PRIMITIVES.contains(&type_name) {
380 return validate_primitive(v, type_name, field_path, body_type);
381 }
382 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 if type_name == "List" {
388 return validate_list(v, generic_param, field_path, table, body_type);
389 }
390 if let Some(schema) = table.get(type_name) {
392 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 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
450pub 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 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 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 return Ok(());
572 }
573 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 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 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 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 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 #[test]
904 fn fase38xf9_validate_body_accepts_list_of_primitive() {
905 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 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 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 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 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 #[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 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 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 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 let mut table: HashMap<String, TypeSchema> = HashMap::new();
1114 table.insert("Person".to_string(), person_schema());
1115 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 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 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 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 });
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 let src = std::fs::read_to_string("src/route_schema.rs")
1198 .expect("read route_schema.rs");
1199 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 let src = std::fs::read_to_string("src/axon_server.rs")
1216 .expect("read axon_server.rs");
1217 let active_extract_calls = src.matches(
1221 "crate::wire_envelope::extract_inner_ontological_type(&route.output_type)"
1222 ).count();
1223 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}