Skip to main content

made_core/value_objects/
output_contract.rs

1//! Structured output contract for a council invocation.
2//!
3//! This is intentionally generic and domain-agnostic. It does not know
4//! what a "decision", "report", or "event" means; it only describes
5//! the shape that a proposal must satisfy when a caller requires a
6//! structured output instead of free-form text.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::DomainError;
13
14const MAX_CONTRACT_ID_LEN: usize = 128;
15const MAX_FIELDS: usize = 128;
16const MAX_FIELD_NAME_LEN: usize = 128;
17const MAX_ALLOWED_VALUES_PER_FIELD: usize = 128;
18const MAX_ALLOWED_VALUE_LEN: usize = 256;
19/// Cap on the embedded JSON Schema body. 256 KiB is enough for an
20/// elaborate Report-shape schema with nested objects and several
21/// dozen enums; anything larger should live behind a `$ref` and be
22/// fetched by the validator if/when remote schemas are supported.
23const MAX_JSON_SCHEMA_LEN: usize = 256 * 1024;
24/// Cap on the evidence pack an evidence-grounding rule may carry. An
25/// evidence pack is a curated set of reference ids for one
26/// deliberation, not a corpus; anything larger belongs in an external
27/// store that the pack entries reference.
28const MAX_ALLOWED_EVIDENCE_REFS: usize = 1024;
29/// Cap on one evidence body a semantic-support rule may carry. Bodies
30/// are curated excerpts a judge reads per claim, not documents; a
31/// larger source belongs in an external store, with the excerpt that
32/// actually supports the claim quoted here.
33const MAX_EVIDENCE_BODY_LEN: usize = 16 * 1024;
34/// Default minimum confidence (percent) a support verdict must reach
35/// before a claim counts as semantically supported.
36pub const DEFAULT_SUPPORT_MIN_CONFIDENCE: u8 = 70;
37
38/// Wire- and storage-stable structured output format selector.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
40pub enum OutputFormat {
41    /// A single JSON object at the root.
42    #[default]
43    JsonObject,
44}
45
46impl OutputFormat {
47    #[must_use]
48    pub const fn as_str(self) -> &'static str {
49        match self {
50            Self::JsonObject => "json_object",
51        }
52    }
53}
54
55/// Validation rules for one named field in a structured output object.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
57pub struct OutputFieldRule {
58    required: bool,
59    #[serde(default)]
60    allowed_string_values: BTreeSet<String>,
61}
62
63impl OutputFieldRule {
64    pub fn new(
65        required: bool,
66        allowed_string_values: impl IntoIterator<Item = impl Into<String>>,
67    ) -> Result<Self, DomainError> {
68        let values = allowed_string_values
69            .into_iter()
70            .map(|value| {
71                let value = value.into();
72                validate_text(
73                    &value,
74                    "output_contract.field.allowed_value",
75                    MAX_ALLOWED_VALUE_LEN,
76                )
77            })
78            .collect::<Result<BTreeSet<_>, _>>()?;
79        if values.len() > MAX_ALLOWED_VALUES_PER_FIELD {
80            return Err(DomainError::OutOfRange {
81                field: "output_contract.field.allowed_values",
82                value: values.len() as f64,
83                min: 0.0,
84                max: MAX_ALLOWED_VALUES_PER_FIELD as f64,
85            });
86        }
87        Ok(Self {
88            required,
89            allowed_string_values: values,
90        })
91    }
92
93    #[must_use]
94    pub const fn required(&self) -> bool {
95        self.required
96    }
97
98    #[must_use]
99    pub fn allowed_string_values(&self) -> &BTreeSet<String> {
100        &self.allowed_string_values
101    }
102}
103
104/// Semantic-support rule for one invocation: the evidence *bodies*
105/// (ref id → excerpt text) a support judge reads to decide whether a
106/// claim's cited evidence actually supports what the claim says, and
107/// the minimum confidence (percent, 0–100) a verdict must reach.
108///
109/// This is the second gate behind [`EvidenceGroundingRule`]: grounding
110/// checks that the citation *exists*; semantic support checks that the
111/// citation *holds*. The judgment itself comes from a wired
112/// `EvidenceSupportJudgePort` implementation — the rule only carries
113/// what the judge needs and the deterministic acceptance threshold, so
114/// the decision stays a rule even when the signal comes from a model.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct SemanticSupportRule {
117    min_confidence: u8,
118    bodies: BTreeMap<String, String>,
119}
120
121impl SemanticSupportRule {
122    /// Build a semantic-support rule. `bodies` must be non-empty: a
123    /// support gate with nothing to read is a configuration error, not
124    /// a stricter gate (mirroring the grounding rule's posture on an
125    /// empty pack).
126    pub fn new(
127        min_confidence: u8,
128        bodies: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
129    ) -> Result<Self, DomainError> {
130        if min_confidence > 100 {
131            return Err(DomainError::OutOfRange {
132                field: "output_contract.evidence.semantic_support.min_confidence",
133                value: f64::from(min_confidence),
134                min: 0.0,
135                max: 100.0,
136            });
137        }
138        let bodies = bodies
139            .into_iter()
140            .map(|(reference, body)| {
141                let reference = validate_text(
142                    &reference.into(),
143                    "output_contract.evidence.semantic_support.body_ref",
144                    MAX_ALLOWED_VALUE_LEN,
145                )?;
146                let body = validate_text(
147                    &body.into(),
148                    "output_contract.evidence.semantic_support.body",
149                    MAX_EVIDENCE_BODY_LEN,
150                )?;
151                Ok::<_, DomainError>((reference, body))
152            })
153            .collect::<Result<BTreeMap<_, _>, _>>()?;
154        if bodies.is_empty() {
155            return Err(DomainError::EmptyField {
156                field: "output_contract.evidence.semantic_support.bodies",
157            });
158        }
159        if bodies.len() > MAX_ALLOWED_EVIDENCE_REFS {
160            return Err(DomainError::OutOfRange {
161                field: "output_contract.evidence.semantic_support.bodies",
162                value: bodies.len() as f64,
163                min: 1.0,
164                max: MAX_ALLOWED_EVIDENCE_REFS as f64,
165            });
166        }
167        Ok(Self {
168            min_confidence,
169            bodies,
170        })
171    }
172
173    /// Minimum confidence (percent, 0–100) a support verdict must
174    /// reach for the claim to count as supported.
175    #[must_use]
176    pub const fn min_confidence(&self) -> u8 {
177        self.min_confidence
178    }
179
180    /// Evidence bodies by reference id.
181    #[must_use]
182    pub fn bodies(&self) -> &BTreeMap<String, String> {
183        &self.bodies
184    }
185
186    /// The body for one evidence reference, when the rule carries it.
187    #[must_use]
188    pub fn body(&self, reference: &str) -> Option<&str> {
189        self.bodies.get(reference).map(String::as_str)
190    }
191}
192
193/// Evidence-grounding rule for one invocation: which output field
194/// carries the claims, which per-claim field carries the evidence
195/// references, and the closed set of reference ids that count as real
196/// evidence for this deliberation (the "evidence pack").
197///
198/// The rule is deliberately shape-only: the core does not know what an
199/// evidence ref points at (a document, a trace, a metric snapshot) —
200/// only that a claim citing a ref outside the pack is ungrounded. When
201/// a [`SemanticSupportRule`] is attached the contract additionally
202/// demands that every claim's cited evidence *supports* the claim, as
203/// judged through the `EvidenceSupportJudgePort`.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct EvidenceGroundingRule {
206    claims_field: String,
207    refs_field: String,
208    allowed_refs: BTreeSet<String>,
209    /// Optional second gate: semantic support of each claim by its
210    /// cited evidence bodies. `None` keeps the historical
211    /// citation-existence semantics (and the historical wire shape).
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    semantic_support: Option<SemanticSupportRule>,
214}
215
216impl EvidenceGroundingRule {
217    /// Build a grounding rule. `allowed_refs` must be non-empty: an
218    /// evidence-bound deliberation with an empty pack is a
219    /// configuration error, not a stricter gate.
220    pub fn new(
221        claims_field: impl Into<String>,
222        refs_field: impl Into<String>,
223        allowed_refs: impl IntoIterator<Item = impl Into<String>>,
224    ) -> Result<Self, DomainError> {
225        let claims_field = validate_text(
226            &claims_field.into(),
227            "output_contract.evidence.claims_field",
228            MAX_FIELD_NAME_LEN,
229        )?;
230        let refs_field = validate_text(
231            &refs_field.into(),
232            "output_contract.evidence.refs_field",
233            MAX_FIELD_NAME_LEN,
234        )?;
235        let allowed_refs = allowed_refs
236            .into_iter()
237            .map(|reference| {
238                let reference = reference.into();
239                validate_text(
240                    &reference,
241                    "output_contract.evidence.allowed_ref",
242                    MAX_ALLOWED_VALUE_LEN,
243                )
244            })
245            .collect::<Result<BTreeSet<_>, _>>()?;
246        if allowed_refs.is_empty() {
247            return Err(DomainError::EmptyField {
248                field: "output_contract.evidence.allowed_refs",
249            });
250        }
251        if allowed_refs.len() > MAX_ALLOWED_EVIDENCE_REFS {
252            return Err(DomainError::OutOfRange {
253                field: "output_contract.evidence.allowed_refs",
254                value: allowed_refs.len() as f64,
255                min: 1.0,
256                max: MAX_ALLOWED_EVIDENCE_REFS as f64,
257            });
258        }
259        Ok(Self {
260            claims_field,
261            refs_field,
262            allowed_refs,
263            semantic_support: None,
264        })
265    }
266
267    /// Attach a semantic-support rule. Every allowed reference must
268    /// carry a body: a pack entry the judge cannot read would make the
269    /// gate's outcome depend on which ref a proposal happens to cite —
270    /// a config gap must fail loudly at wiring time, not at judgment
271    /// time.
272    pub fn with_semantic_support(mut self, rule: SemanticSupportRule) -> Result<Self, DomainError> {
273        if self
274            .allowed_refs
275            .iter()
276            .any(|reference| !rule.bodies.contains_key(reference))
277        {
278            return Err(DomainError::EmptyField {
279                field: "output_contract.evidence.semantic_support.bodies",
280            });
281        }
282        self.semantic_support = Some(rule);
283        Ok(self)
284    }
285
286    #[must_use]
287    pub fn claims_field(&self) -> &str {
288        &self.claims_field
289    }
290
291    #[must_use]
292    pub fn refs_field(&self) -> &str {
293        &self.refs_field
294    }
295
296    #[must_use]
297    pub fn allowed_refs(&self) -> &BTreeSet<String> {
298        &self.allowed_refs
299    }
300
301    /// Semantic-support rule, when the contract demands one. `None`
302    /// means the support validator is a no-op for this invocation.
303    #[must_use]
304    pub fn semantic_support(&self) -> Option<&SemanticSupportRule> {
305        self.semantic_support.as_ref()
306    }
307}
308
309/// Typed structured-output contract attached to one invocation.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct OutputContract {
312    contract_id: String,
313    format: OutputFormat,
314    #[serde(default)]
315    fields: BTreeMap<String, OutputFieldRule>,
316    /// Optional embedded JSON Schema. When non-empty, the adapter
317    /// JSON-schema validator parses it once and validates every
318    /// proposal output against it in addition to the field-level
319    /// rules. Kept as a `String` here so the core stays free of any
320    /// schema-engine dependency.
321    #[serde(default, skip_serializing_if = "String::is_empty")]
322    json_schema: String,
323    /// Optional evidence-grounding rule. When present, the adapter
324    /// grounding validator rejects proposals whose claims do not cite
325    /// evidence from the allowed pack.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    evidence_grounding: Option<EvidenceGroundingRule>,
328}
329
330impl OutputContract {
331    pub fn new(
332        contract_id: impl Into<String>,
333        format: OutputFormat,
334        fields: BTreeMap<String, OutputFieldRule>,
335    ) -> Result<Self, DomainError> {
336        Self::new_with_schema(contract_id, format, fields, String::new())
337    }
338
339    /// Build a contract that also carries an embedded JSON Schema
340    /// body. The schema text is whitespace-trimmed and length-bounded
341    /// (`MAX_JSON_SCHEMA_LEN = 256 KiB`); validation that the body is
342    /// itself well-formed JSON / a valid JSON Schema document happens
343    /// at adapter wiring time (the core does not pull a schema
344    /// engine in).
345    pub fn new_with_schema(
346        contract_id: impl Into<String>,
347        format: OutputFormat,
348        fields: BTreeMap<String, OutputFieldRule>,
349        json_schema: impl Into<String>,
350    ) -> Result<Self, DomainError> {
351        let contract_id = contract_id.into();
352        let contract_id = validate_text(
353            &contract_id,
354            "output_contract.contract_id",
355            MAX_CONTRACT_ID_LEN,
356        )?;
357        if fields.len() > MAX_FIELDS {
358            return Err(DomainError::OutOfRange {
359                field: "output_contract.fields",
360                value: fields.len() as f64,
361                min: 0.0,
362                max: MAX_FIELDS as f64,
363            });
364        }
365
366        let mut normalized = BTreeMap::new();
367        for (name, rule) in fields {
368            let field_name =
369                validate_text(&name, "output_contract.field.name", MAX_FIELD_NAME_LEN)?;
370            normalized.insert(field_name, rule);
371        }
372
373        let json_schema = normalize_optional_schema(&json_schema.into())?;
374
375        Ok(Self {
376            contract_id,
377            format,
378            fields: normalized,
379            json_schema,
380            evidence_grounding: None,
381        })
382    }
383
384    pub fn json_object(
385        contract_id: impl Into<String>,
386        fields: BTreeMap<String, OutputFieldRule>,
387    ) -> Result<Self, DomainError> {
388        Self::new(contract_id, OutputFormat::JsonObject, fields)
389    }
390
391    #[must_use]
392    pub fn contract_id(&self) -> &str {
393        &self.contract_id
394    }
395
396    #[must_use]
397    pub const fn format(&self) -> OutputFormat {
398        self.format
399    }
400
401    #[must_use]
402    pub fn fields(&self) -> &BTreeMap<String, OutputFieldRule> {
403        &self.fields
404    }
405
406    /// Embedded JSON Schema body. Empty string means "no schema —
407    /// only field-level rules apply"; the JSON Schema validator
408    /// adapter treats empty as a no-op.
409    #[must_use]
410    pub fn json_schema(&self) -> &str {
411        &self.json_schema
412    }
413
414    /// Attach an evidence-grounding rule to this contract.
415    #[must_use]
416    pub fn with_evidence_grounding(mut self, rule: EvidenceGroundingRule) -> Self {
417        self.evidence_grounding = Some(rule);
418        self
419    }
420
421    /// Evidence-grounding rule, when the contract declares one. `None`
422    /// means the grounding validator is a no-op for this invocation.
423    #[must_use]
424    pub fn evidence_grounding(&self) -> Option<&EvidenceGroundingRule> {
425        self.evidence_grounding.as_ref()
426    }
427}
428
429fn normalize_optional_schema(raw: &str) -> Result<String, DomainError> {
430    let trimmed = raw.trim();
431    if trimmed.is_empty() {
432        return Ok(String::new());
433    }
434    if trimmed.len() > MAX_JSON_SCHEMA_LEN {
435        return Err(DomainError::FieldTooLong {
436            field: "output_contract.json_schema",
437            actual: trimmed.len(),
438            max: MAX_JSON_SCHEMA_LEN,
439        });
440    }
441    Ok(trimmed.to_owned())
442}
443
444fn validate_text(value: &str, field: &'static str, max_len: usize) -> Result<String, DomainError> {
445    let trimmed = value.trim();
446    if trimmed.is_empty() {
447        return Err(DomainError::EmptyField { field });
448    }
449    if trimmed.len() > max_len {
450        return Err(DomainError::FieldTooLong {
451            field,
452            actual: trimmed.len(),
453            max: max_len,
454        });
455    }
456    Ok(trimmed.to_owned())
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    fn sample_rule() -> OutputFieldRule {
464        OutputFieldRule::new(true, ["emit_event", "escalate"]).unwrap()
465    }
466
467    #[test]
468    fn json_object_contract_keeps_fields() {
469        let contract = OutputContract::json_object(
470            "decision-contract",
471            BTreeMap::from([("decision".to_owned(), sample_rule())]),
472        )
473        .unwrap();
474
475        assert_eq!(contract.contract_id(), "decision-contract");
476        assert_eq!(contract.format(), OutputFormat::JsonObject);
477        assert!(contract.fields()["decision"].required());
478        assert!(contract.fields()["decision"]
479            .allowed_string_values()
480            .contains("emit_event"));
481    }
482
483    #[test]
484    fn blank_contract_id_is_rejected() {
485        let err = OutputContract::json_object("   ", BTreeMap::new()).unwrap_err();
486        assert!(matches!(
487            err,
488            DomainError::EmptyField {
489                field: "output_contract.contract_id"
490            }
491        ));
492    }
493
494    #[test]
495    fn blank_field_name_is_rejected() {
496        let err = OutputContract::json_object(
497            "c1",
498            BTreeMap::from([("   ".to_owned(), OutputFieldRule::default())]),
499        )
500        .unwrap_err();
501        assert!(matches!(
502            err,
503            DomainError::EmptyField {
504                field: "output_contract.field.name"
505            }
506        ));
507    }
508
509    #[test]
510    fn blank_allowed_value_is_rejected() {
511        let err = OutputFieldRule::new(false, [" "]).unwrap_err();
512        assert!(matches!(
513            err,
514            DomainError::EmptyField {
515                field: "output_contract.field.allowed_value"
516            }
517        ));
518    }
519
520    #[test]
521    fn serde_roundtrip_is_stable() {
522        let contract = OutputContract::json_object(
523            "decision-contract",
524            BTreeMap::from([("decision".to_owned(), sample_rule())]),
525        )
526        .unwrap();
527        let serialized = serde_json::to_string(&contract).unwrap();
528        let back: OutputContract = serde_json::from_str(&serialized).unwrap();
529        assert_eq!(back, contract);
530    }
531
532    #[test]
533    fn json_schema_is_empty_by_default() {
534        let contract = OutputContract::json_object("c1", BTreeMap::new()).unwrap();
535        assert!(contract.json_schema().is_empty());
536    }
537
538    #[test]
539    fn new_with_schema_carries_trimmed_body() {
540        let raw = "  { \"type\": \"object\" }  ";
541        let contract = OutputContract::new_with_schema(
542            "decision-contract",
543            OutputFormat::JsonObject,
544            BTreeMap::new(),
545            raw,
546        )
547        .unwrap();
548        assert_eq!(contract.json_schema(), "{ \"type\": \"object\" }");
549    }
550
551    #[test]
552    fn overlong_schema_is_rejected() {
553        let body = "x".repeat(MAX_JSON_SCHEMA_LEN + 1);
554        let err =
555            OutputContract::new_with_schema("c1", OutputFormat::JsonObject, BTreeMap::new(), body)
556                .unwrap_err();
557        assert!(matches!(
558            err,
559            DomainError::FieldTooLong {
560                field: "output_contract.json_schema",
561                ..
562            }
563        ));
564    }
565
566    #[test]
567    fn evidence_grounding_rule_keeps_fields_and_refs() {
568        let rule = EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1", "ev-2"]).unwrap();
569        assert_eq!(rule.claims_field(), "claims");
570        assert_eq!(rule.refs_field(), "evidence_refs");
571        assert!(rule.allowed_refs().contains("ev-1"));
572        assert_eq!(rule.allowed_refs().len(), 2);
573    }
574
575    #[test]
576    fn evidence_grounding_rule_rejects_empty_pack() {
577        let err = EvidenceGroundingRule::new("claims", "evidence_refs", Vec::<String>::new())
578            .unwrap_err();
579        assert!(matches!(
580            err,
581            DomainError::EmptyField {
582                field: "output_contract.evidence.allowed_refs"
583            }
584        ));
585    }
586
587    #[test]
588    fn evidence_grounding_rule_rejects_blank_ref() {
589        let err = EvidenceGroundingRule::new("claims", "evidence_refs", ["  "]).unwrap_err();
590        assert!(matches!(
591            err,
592            DomainError::EmptyField {
593                field: "output_contract.evidence.allowed_ref"
594            }
595        ));
596    }
597
598    #[test]
599    fn contract_with_evidence_grounding_roundtrips() {
600        let contract = OutputContract::json_object("c1", BTreeMap::new())
601            .unwrap()
602            .with_evidence_grounding(
603                EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1"]).unwrap(),
604            );
605        let serialized = serde_json::to_string(&contract).unwrap();
606        let back: OutputContract = serde_json::from_str(&serialized).unwrap();
607        assert_eq!(back, contract);
608        assert_eq!(back.evidence_grounding().unwrap().claims_field(), "claims");
609    }
610
611    #[test]
612    fn semantic_support_rule_keeps_bodies_and_threshold() {
613        let rule =
614            SemanticSupportRule::new(80, [("ev-1", "typha held port 5473"), ("ev-2", "crun log")])
615                .unwrap();
616        assert_eq!(rule.min_confidence(), 80);
617        assert_eq!(rule.body("ev-1"), Some("typha held port 5473"));
618        assert_eq!(rule.bodies().len(), 2);
619    }
620
621    #[test]
622    fn semantic_support_rule_rejects_out_of_range_confidence() {
623        let err = SemanticSupportRule::new(101, [("ev-1", "body")]).unwrap_err();
624        assert!(matches!(
625            err,
626            DomainError::OutOfRange {
627                field: "output_contract.evidence.semantic_support.min_confidence",
628                ..
629            }
630        ));
631    }
632
633    #[test]
634    fn semantic_support_rule_rejects_empty_bodies() {
635        let err = SemanticSupportRule::new(70, Vec::<(String, String)>::new()).unwrap_err();
636        assert!(matches!(
637            err,
638            DomainError::EmptyField {
639                field: "output_contract.evidence.semantic_support.bodies"
640            }
641        ));
642    }
643
644    #[test]
645    fn semantic_support_rule_rejects_blank_body() {
646        let err = SemanticSupportRule::new(70, [("ev-1", "   ")]).unwrap_err();
647        assert!(matches!(
648            err,
649            DomainError::EmptyField {
650                field: "output_contract.evidence.semantic_support.body"
651            }
652        ));
653    }
654
655    #[test]
656    fn semantic_support_requires_a_body_for_every_allowed_ref() {
657        let grounding =
658            EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1", "ev-2"]).unwrap();
659        let partial = SemanticSupportRule::new(70, [("ev-1", "only one body")]).unwrap();
660        let err = grounding.with_semantic_support(partial).unwrap_err();
661        assert!(matches!(
662            err,
663            DomainError::EmptyField {
664                field: "output_contract.evidence.semantic_support.bodies"
665            }
666        ));
667    }
668
669    #[test]
670    fn grounding_with_semantic_support_roundtrips() {
671        let rule = EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1"])
672            .unwrap()
673            .with_semantic_support(SemanticSupportRule::new(70, [("ev-1", "body")]).unwrap())
674            .unwrap();
675        let contract = OutputContract::json_object("c1", BTreeMap::new())
676            .unwrap()
677            .with_evidence_grounding(rule);
678        let serialized = serde_json::to_string(&contract).unwrap();
679        let back: OutputContract = serde_json::from_str(&serialized).unwrap();
680        assert_eq!(back, contract);
681        let support = back
682            .evidence_grounding()
683            .unwrap()
684            .semantic_support()
685            .unwrap();
686        assert_eq!(support.min_confidence(), 70);
687        assert_eq!(support.body("ev-1"), Some("body"));
688    }
689
690    #[test]
691    fn grounding_without_semantic_support_deserializes_from_legacy_wire_shape() {
692        // Grounding rules serialized before the semantic-support field
693        // existed must keep deserializing.
694        let legacy =
695            r#"{"claims_field":"claims","refs_field":"evidence_refs","allowed_refs":["ev-1"]}"#;
696        let back: EvidenceGroundingRule = serde_json::from_str(legacy).unwrap();
697        assert!(back.semantic_support().is_none());
698    }
699
700    #[test]
701    fn contract_without_grounding_deserializes_from_legacy_wire_shape() {
702        // Contracts serialized before the grounding field existed must
703        // keep deserializing (registry/persistence compatibility).
704        let legacy = r#"{"contract_id":"c1","format":"JsonObject","fields":{}}"#;
705        let back: OutputContract = serde_json::from_str(legacy).unwrap();
706        assert!(back.evidence_grounding().is_none());
707    }
708
709    #[test]
710    fn schema_serde_roundtrip_preserves_body() {
711        let contract = OutputContract::new_with_schema(
712            "c1",
713            OutputFormat::JsonObject,
714            BTreeMap::new(),
715            "{\"type\":\"object\"}",
716        )
717        .unwrap();
718        let serialized = serde_json::to_string(&contract).unwrap();
719        let back: OutputContract = serde_json::from_str(&serialized).unwrap();
720        assert_eq!(back, contract);
721        assert_eq!(back.json_schema(), "{\"type\":\"object\"}");
722    }
723}