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;
9
10use serde::{Deserialize, Serialize};
11
12use super::output_contract_validation::{
13    normalize_optional_schema, validate_text, MAX_FIELDS, MAX_FIELD_NAME_LEN,
14};
15use crate::error::DomainError;
16use crate::value_objects::{
17    EvidenceGroundingRule, OutputContractId, OutputFieldRule, OutputFormat,
18};
19
20/// Typed structured-output contract attached to one invocation.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct OutputContract {
23    contract_id: OutputContractId,
24    format: OutputFormat,
25    #[serde(default)]
26    fields: BTreeMap<String, OutputFieldRule>,
27    /// Optional embedded JSON Schema. When non-empty, the adapter
28    /// JSON-schema validator parses it once and validates every
29    /// proposal output against it in addition to the field-level
30    /// rules. Kept as a `String` here so the core stays free of any
31    /// schema-engine dependency.
32    #[serde(default, skip_serializing_if = "String::is_empty")]
33    json_schema: String,
34    /// Optional evidence-grounding rule. When present, the adapter
35    /// grounding validator rejects proposals whose claims do not cite
36    /// evidence from the allowed pack.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    evidence_grounding: Option<EvidenceGroundingRule>,
39}
40
41impl OutputContract {
42    pub fn new(
43        contract_id: impl Into<String>,
44        format: OutputFormat,
45        fields: BTreeMap<String, OutputFieldRule>,
46    ) -> Result<Self, DomainError> {
47        Self::new_with_schema(contract_id, format, fields, String::new())
48    }
49
50    /// Build a contract that also carries an embedded JSON Schema
51    /// body. The schema text is whitespace-trimmed and length-bounded
52    /// (`MAX_JSON_SCHEMA_LEN = 256 KiB`); validation that the body is
53    /// itself well-formed JSON / a valid JSON Schema document happens
54    /// at adapter wiring time (the core does not pull a schema
55    /// engine in).
56    pub fn new_with_schema(
57        contract_id: impl Into<String>,
58        format: OutputFormat,
59        fields: BTreeMap<String, OutputFieldRule>,
60        json_schema: impl Into<String>,
61    ) -> Result<Self, DomainError> {
62        let contract_id = OutputContractId::new(contract_id)?;
63        if fields.len() > MAX_FIELDS {
64            return Err(DomainError::OutOfRange {
65                field: "output_contract.fields",
66                value: fields.len() as f64,
67                min: 0.0,
68                max: MAX_FIELDS as f64,
69            });
70        }
71
72        let mut normalized = BTreeMap::new();
73        for (name, rule) in fields {
74            let field_name =
75                validate_text(&name, "output_contract.field.name", MAX_FIELD_NAME_LEN)?;
76            normalized.insert(field_name, rule);
77        }
78
79        let json_schema = normalize_optional_schema(&json_schema.into())?;
80
81        Ok(Self {
82            contract_id,
83            format,
84            fields: normalized,
85            json_schema,
86            evidence_grounding: None,
87        })
88    }
89
90    pub fn json_object(
91        contract_id: impl Into<String>,
92        fields: BTreeMap<String, OutputFieldRule>,
93    ) -> Result<Self, DomainError> {
94        Self::new(contract_id, OutputFormat::JsonObject, fields)
95    }
96
97    #[must_use]
98    pub const fn contract_id(&self) -> &OutputContractId {
99        &self.contract_id
100    }
101
102    #[must_use]
103    pub const fn format(&self) -> OutputFormat {
104        self.format
105    }
106
107    #[must_use]
108    pub fn fields(&self) -> &BTreeMap<String, OutputFieldRule> {
109        &self.fields
110    }
111
112    /// Embedded JSON Schema body. Empty string means "no schema —
113    /// only field-level rules apply"; the JSON Schema validator
114    /// adapter treats empty as a no-op.
115    #[must_use]
116    pub fn json_schema(&self) -> &str {
117        &self.json_schema
118    }
119
120    /// Attach an evidence-grounding rule to this contract.
121    #[must_use]
122    pub fn with_evidence_grounding(mut self, rule: EvidenceGroundingRule) -> Self {
123        self.evidence_grounding = Some(rule);
124        self
125    }
126
127    /// Evidence-grounding rule, when the contract declares one. `None`
128    /// means the grounding validator is a no-op for this invocation.
129    #[must_use]
130    pub fn evidence_grounding(&self) -> Option<&EvidenceGroundingRule> {
131        self.evidence_grounding.as_ref()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::super::output_contract_validation::MAX_JSON_SCHEMA_LEN;
138    use super::*;
139    use crate::value_objects::SemanticSupportRule;
140
141    fn sample_rule() -> OutputFieldRule {
142        OutputFieldRule::new(true, ["emit_event", "escalate"]).unwrap()
143    }
144
145    #[test]
146    fn json_object_contract_keeps_fields() {
147        let contract = OutputContract::json_object(
148            "decision-contract",
149            BTreeMap::from([("decision".to_owned(), sample_rule())]),
150        )
151        .unwrap();
152
153        assert_eq!(contract.contract_id(), "decision-contract");
154        assert_eq!(contract.format(), OutputFormat::JsonObject);
155        assert!(contract.fields()["decision"].required());
156        assert!(contract.fields()["decision"]
157            .allowed_string_values()
158            .contains("emit_event"));
159    }
160
161    #[test]
162    fn blank_contract_id_is_rejected() {
163        let err = OutputContract::json_object("   ", BTreeMap::new()).unwrap_err();
164        assert!(matches!(
165            err,
166            DomainError::EmptyField {
167                field: "output_contract.contract_id"
168            }
169        ));
170    }
171
172    #[test]
173    fn blank_field_name_is_rejected() {
174        let err = OutputContract::json_object(
175            "c1",
176            BTreeMap::from([("   ".to_owned(), OutputFieldRule::default())]),
177        )
178        .unwrap_err();
179        assert!(matches!(
180            err,
181            DomainError::EmptyField {
182                field: "output_contract.field.name"
183            }
184        ));
185    }
186
187    #[test]
188    fn blank_allowed_value_is_rejected() {
189        let err = OutputFieldRule::new(false, [" "]).unwrap_err();
190        assert!(matches!(
191            err,
192            DomainError::EmptyField {
193                field: "output_contract.field.allowed_value"
194            }
195        ));
196    }
197
198    #[test]
199    fn serde_roundtrip_is_stable() {
200        let contract = OutputContract::json_object(
201            "decision-contract",
202            BTreeMap::from([("decision".to_owned(), sample_rule())]),
203        )
204        .unwrap();
205        let serialized = serde_json::to_string(&contract).unwrap();
206        let back: OutputContract = serde_json::from_str(&serialized).unwrap();
207        assert_eq!(back, contract);
208    }
209
210    #[test]
211    fn json_schema_is_empty_by_default() {
212        let contract = OutputContract::json_object("c1", BTreeMap::new()).unwrap();
213        assert!(contract.json_schema().is_empty());
214    }
215
216    #[test]
217    fn new_with_schema_carries_trimmed_body() {
218        let raw = "  { \"type\": \"object\" }  ";
219        let contract = OutputContract::new_with_schema(
220            "decision-contract",
221            OutputFormat::JsonObject,
222            BTreeMap::new(),
223            raw,
224        )
225        .unwrap();
226        assert_eq!(contract.json_schema(), "{ \"type\": \"object\" }");
227    }
228
229    #[test]
230    fn overlong_schema_is_rejected() {
231        let body = "x".repeat(MAX_JSON_SCHEMA_LEN + 1);
232        let err =
233            OutputContract::new_with_schema("c1", OutputFormat::JsonObject, BTreeMap::new(), body)
234                .unwrap_err();
235        assert!(matches!(
236            err,
237            DomainError::FieldTooLong {
238                field: "output_contract.json_schema",
239                ..
240            }
241        ));
242    }
243
244    #[test]
245    fn evidence_grounding_rule_keeps_fields_and_refs() {
246        let rule = EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1", "ev-2"]).unwrap();
247        assert_eq!(rule.claims_field(), "claims");
248        assert_eq!(rule.refs_field(), "evidence_refs");
249        assert!(rule.allowed_refs().contains("ev-1"));
250        assert_eq!(rule.allowed_refs().len(), 2);
251    }
252
253    #[test]
254    fn evidence_grounding_rule_rejects_empty_pack() {
255        let err = EvidenceGroundingRule::new("claims", "evidence_refs", Vec::<String>::new())
256            .unwrap_err();
257        assert!(matches!(
258            err,
259            DomainError::EmptyField {
260                field: "output_contract.evidence.allowed_refs"
261            }
262        ));
263    }
264
265    #[test]
266    fn evidence_grounding_rule_rejects_blank_ref() {
267        let err = EvidenceGroundingRule::new("claims", "evidence_refs", ["  "]).unwrap_err();
268        assert!(matches!(
269            err,
270            DomainError::EmptyField {
271                field: "output_contract.evidence.allowed_ref"
272            }
273        ));
274    }
275
276    #[test]
277    fn contract_with_evidence_grounding_roundtrips() {
278        let contract = OutputContract::json_object("c1", BTreeMap::new())
279            .unwrap()
280            .with_evidence_grounding(
281                EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1"]).unwrap(),
282            );
283        let serialized = serde_json::to_string(&contract).unwrap();
284        let back: OutputContract = serde_json::from_str(&serialized).unwrap();
285        assert_eq!(back, contract);
286        assert_eq!(back.evidence_grounding().unwrap().claims_field(), "claims");
287    }
288
289    #[test]
290    fn semantic_support_rule_keeps_bodies_and_threshold() {
291        let rule =
292            SemanticSupportRule::new(80, [("ev-1", "typha held port 5473"), ("ev-2", "crun log")])
293                .unwrap();
294        assert_eq!(rule.min_confidence(), 80);
295        assert_eq!(rule.body("ev-1"), Some("typha held port 5473"));
296        assert_eq!(rule.bodies().len(), 2);
297    }
298
299    #[test]
300    fn semantic_support_rule_rejects_out_of_range_confidence() {
301        let err = SemanticSupportRule::new(101, [("ev-1", "body")]).unwrap_err();
302        assert!(matches!(
303            err,
304            DomainError::OutOfRange {
305                field: "output_contract.evidence.semantic_support.min_confidence",
306                ..
307            }
308        ));
309    }
310
311    #[test]
312    fn semantic_support_rule_rejects_empty_bodies() {
313        let err = SemanticSupportRule::new(70, Vec::<(String, String)>::new()).unwrap_err();
314        assert!(matches!(
315            err,
316            DomainError::EmptyField {
317                field: "output_contract.evidence.semantic_support.bodies"
318            }
319        ));
320    }
321
322    #[test]
323    fn semantic_support_rule_rejects_blank_body() {
324        let err = SemanticSupportRule::new(70, [("ev-1", "   ")]).unwrap_err();
325        assert!(matches!(
326            err,
327            DomainError::EmptyField {
328                field: "output_contract.evidence.semantic_support.body"
329            }
330        ));
331    }
332
333    #[test]
334    fn semantic_support_requires_a_body_for_every_allowed_ref() {
335        let grounding =
336            EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1", "ev-2"]).unwrap();
337        let partial = SemanticSupportRule::new(70, [("ev-1", "only one body")]).unwrap();
338        let err = grounding.with_semantic_support(partial).unwrap_err();
339        assert!(matches!(
340            err,
341            DomainError::EmptyField {
342                field: "output_contract.evidence.semantic_support.bodies"
343            }
344        ));
345    }
346
347    #[test]
348    fn grounding_with_semantic_support_roundtrips() {
349        let rule = EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1"])
350            .unwrap()
351            .with_semantic_support(SemanticSupportRule::new(70, [("ev-1", "body")]).unwrap())
352            .unwrap();
353        let contract = OutputContract::json_object("c1", BTreeMap::new())
354            .unwrap()
355            .with_evidence_grounding(rule);
356        let serialized = serde_json::to_string(&contract).unwrap();
357        let back: OutputContract = serde_json::from_str(&serialized).unwrap();
358        assert_eq!(back, contract);
359        let support = back
360            .evidence_grounding()
361            .unwrap()
362            .semantic_support()
363            .unwrap();
364        assert_eq!(support.min_confidence(), 70);
365        assert_eq!(support.body("ev-1"), Some("body"));
366    }
367
368    #[test]
369    fn grounding_without_semantic_support_deserializes_from_legacy_wire_shape() {
370        // Grounding rules serialized before the semantic-support field
371        // existed must keep deserializing.
372        let legacy =
373            r#"{"claims_field":"claims","refs_field":"evidence_refs","allowed_refs":["ev-1"]}"#;
374        let back: EvidenceGroundingRule = serde_json::from_str(legacy).unwrap();
375        assert!(back.semantic_support().is_none());
376    }
377
378    #[test]
379    fn contract_without_grounding_deserializes_from_legacy_wire_shape() {
380        // Contracts serialized before the grounding field existed must
381        // keep deserializing (registry/persistence compatibility).
382        let legacy = r#"{"contract_id":"c1","format":"JsonObject","fields":{}}"#;
383        let back: OutputContract = serde_json::from_str(legacy).unwrap();
384        assert!(back.evidence_grounding().is_none());
385    }
386
387    #[test]
388    fn schema_serde_roundtrip_preserves_body() {
389        let contract = OutputContract::new_with_schema(
390            "c1",
391            OutputFormat::JsonObject,
392            BTreeMap::new(),
393            "{\"type\":\"object\"}",
394        )
395        .unwrap();
396        let serialized = serde_json::to_string(&contract).unwrap();
397        let back: OutputContract = serde_json::from_str(&serialized).unwrap();
398        assert_eq!(back, contract);
399        assert_eq!(back.json_schema(), "{\"type\":\"object\"}");
400    }
401}