Skip to main content

made_core/entities/
external_context.rs

1//! Bounded external context bundle passed into a deliberation.
2//!
3//! This keeps external context first-class and typed without baking
4//! any domain taxonomy into the core. Callers choose their own
5//! `item.kind` labels (for example `finding`, `decision`, `action`,
6//! `note`) and attach machine-readable detail through `Attributes`.
7
8use serde::{Deserialize, Serialize};
9
10use super::external_context_validation::{
11    validate_collection_len, validate_text, MAX_BUNDLE_ID_LEN, MAX_ITEMS, MAX_REFERENCES,
12    MAX_SCHEMA_VERSION_LEN,
13};
14use crate::entities::{ContextItem, ContextReference, ContextSummary};
15use crate::error::DomainError;
16use crate::value_objects::Attributes;
17
18/// Immutable bounded context handed to a council invocation.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct ExternalContextBundle {
21    bundle_id: String,
22    schema_version: String,
23    summary: Option<ContextSummary>,
24    #[serde(default)]
25    items: Vec<ContextItem>,
26    #[serde(default)]
27    references: Vec<ContextReference>,
28    #[serde(default)]
29    metadata: Attributes,
30}
31
32impl ExternalContextBundle {
33    pub fn new(
34        bundle_id: impl Into<String>,
35        schema_version: impl Into<String>,
36        summary: Option<ContextSummary>,
37        items: Vec<ContextItem>,
38        references: Vec<ContextReference>,
39        metadata: Attributes,
40    ) -> Result<Self, DomainError> {
41        let bundle_id = bundle_id.into();
42        let bundle_id = validate_text(&bundle_id, "external_context.bundle_id", MAX_BUNDLE_ID_LEN)?;
43        let schema_version = schema_version.into();
44        let schema_version = validate_text(
45            &schema_version,
46            "external_context.schema_version",
47            MAX_SCHEMA_VERSION_LEN,
48        )?;
49        validate_collection_len("external_context.items", items.len(), MAX_ITEMS)?;
50        validate_collection_len(
51            "external_context.references",
52            references.len(),
53            MAX_REFERENCES,
54        )?;
55
56        Ok(Self {
57            bundle_id,
58            schema_version,
59            summary,
60            items,
61            references,
62            metadata,
63        })
64    }
65
66    #[must_use]
67    pub fn bundle_id(&self) -> &str {
68        &self.bundle_id
69    }
70
71    #[must_use]
72    pub fn schema_version(&self) -> &str {
73        &self.schema_version
74    }
75
76    #[must_use]
77    pub fn summary(&self) -> Option<&ContextSummary> {
78        self.summary.as_ref()
79    }
80
81    #[must_use]
82    pub fn items(&self) -> &[ContextItem] {
83        &self.items
84    }
85
86    #[must_use]
87    pub fn references(&self) -> &[ContextReference] {
88        &self.references
89    }
90
91    #[must_use]
92    pub fn metadata(&self) -> &Attributes {
93        &self.metadata
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use serde_json::json;
101    use std::collections::BTreeMap;
102
103    fn attrs(key: &str, value: serde_json::Value) -> Attributes {
104        Attributes::new(BTreeMap::from([(key.to_owned(), value)])).unwrap()
105    }
106
107    fn sample_bundle() -> ExternalContextBundle {
108        ExternalContextBundle::new(
109            "ctx-1",
110            "v1",
111            Some(
112                ContextSummary::new(
113                    "Complex state assembled from external systems",
114                    attrs("source", json!("kernel")),
115                )
116                .unwrap(),
117            ),
118            vec![
119                ContextItem::new(
120                    "finding-1",
121                    "finding",
122                    "Primary observation",
123                    Some("A recent deployment correlates with the symptom".to_owned()),
124                    attrs("score", json!(0.92)),
125                    vec!["ref-1".to_owned()],
126                )
127                .unwrap(),
128                ContextItem::new(
129                    "decision-1",
130                    "decision",
131                    "Previous decision",
132                    None,
133                    attrs("decision", json!("rollback rejected")),
134                    vec!["ref-2".to_owned()],
135                )
136                .unwrap(),
137            ],
138            vec![
139                ContextReference::new(
140                    "ref-1",
141                    "s3://evidence/1.json",
142                    Some("evidence snapshot".to_owned()),
143                    Some("application/json".to_owned()),
144                    Attributes::empty(),
145                )
146                .unwrap(),
147                ContextReference::new(
148                    "ref-2",
149                    "graph://decision/2",
150                    None,
151                    None,
152                    attrs("kind", json!("decision")),
153                )
154                .unwrap(),
155            ],
156            attrs("bundle_kind", json!("external")),
157        )
158        .unwrap()
159    }
160
161    #[test]
162    fn bundle_preserves_typed_sections() {
163        let bundle = sample_bundle();
164        assert_eq!(bundle.bundle_id(), "ctx-1");
165        assert_eq!(bundle.schema_version(), "v1");
166        assert_eq!(
167            bundle.summary().unwrap().text(),
168            "Complex state assembled from external systems"
169        );
170        assert_eq!(bundle.items().len(), 2);
171        assert_eq!(bundle.references().len(), 2);
172        assert_eq!(bundle.items()[0].kind(), "finding");
173        assert_eq!(
174            bundle.items()[1].attributes().get("decision"),
175            Some(&json!("rollback rejected"))
176        );
177    }
178
179    #[test]
180    fn empty_bundle_id_is_rejected() {
181        let err = ExternalContextBundle::new("", "v1", None, vec![], vec![], Attributes::empty())
182            .unwrap_err();
183        assert!(matches!(
184            err,
185            DomainError::EmptyField {
186                field: "external_context.bundle_id"
187            }
188        ));
189    }
190
191    #[test]
192    fn context_item_requires_kind_and_title() {
193        let err =
194            ContextItem::new("item-1", "", "", None, Attributes::empty(), vec![]).unwrap_err();
195        assert!(matches!(
196            err,
197            DomainError::EmptyField {
198                field: "external_context.item.kind"
199            }
200        ));
201    }
202
203    #[test]
204    fn reference_requires_uri() {
205        let err = ContextReference::new("ref-1", " ", None, None, Attributes::empty()).unwrap_err();
206        assert!(matches!(
207            err,
208            DomainError::EmptyField {
209                field: "external_context.reference.uri"
210            }
211        ));
212    }
213
214    #[test]
215    fn serde_roundtrip_preserves_structure() {
216        let bundle = sample_bundle();
217        let json = serde_json::to_value(&bundle).unwrap();
218        let back: ExternalContextBundle = serde_json::from_value(json).unwrap();
219        assert_eq!(back, bundle);
220    }
221}