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 crate::error::DomainError;
11use crate::value_objects::Attributes;
12
13const MAX_BUNDLE_ID_LEN: usize = 128;
14const MAX_SCHEMA_VERSION_LEN: usize = 64;
15const MAX_ITEM_ID_LEN: usize = 128;
16const MAX_ITEM_KIND_LEN: usize = 64;
17const MAX_ITEM_TITLE_LEN: usize = 256;
18const MAX_REFERENCE_ID_LEN: usize = 128;
19const MAX_URI_LEN: usize = 2048;
20const MAX_ITEMS: usize = 256;
21const MAX_REFERENCES: usize = 512;
22
23/// Immutable bounded context handed to a council invocation.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ExternalContextBundle {
26    bundle_id: String,
27    schema_version: String,
28    summary: Option<ContextSummary>,
29    #[serde(default)]
30    items: Vec<ContextItem>,
31    #[serde(default)]
32    references: Vec<ContextReference>,
33    #[serde(default)]
34    metadata: Attributes,
35}
36
37impl ExternalContextBundle {
38    pub fn new(
39        bundle_id: impl Into<String>,
40        schema_version: impl Into<String>,
41        summary: Option<ContextSummary>,
42        items: Vec<ContextItem>,
43        references: Vec<ContextReference>,
44        metadata: Attributes,
45    ) -> Result<Self, DomainError> {
46        let bundle_id = bundle_id.into();
47        let bundle_id = validate_text(&bundle_id, "external_context.bundle_id", MAX_BUNDLE_ID_LEN)?;
48        let schema_version = schema_version.into();
49        let schema_version = validate_text(
50            &schema_version,
51            "external_context.schema_version",
52            MAX_SCHEMA_VERSION_LEN,
53        )?;
54        validate_collection_len("external_context.items", items.len(), MAX_ITEMS)?;
55        validate_collection_len(
56            "external_context.references",
57            references.len(),
58            MAX_REFERENCES,
59        )?;
60
61        Ok(Self {
62            bundle_id,
63            schema_version,
64            summary,
65            items,
66            references,
67            metadata,
68        })
69    }
70
71    #[must_use]
72    pub fn bundle_id(&self) -> &str {
73        &self.bundle_id
74    }
75
76    #[must_use]
77    pub fn schema_version(&self) -> &str {
78        &self.schema_version
79    }
80
81    #[must_use]
82    pub fn summary(&self) -> Option<&ContextSummary> {
83        self.summary.as_ref()
84    }
85
86    #[must_use]
87    pub fn items(&self) -> &[ContextItem] {
88        &self.items
89    }
90
91    #[must_use]
92    pub fn references(&self) -> &[ContextReference] {
93        &self.references
94    }
95
96    #[must_use]
97    pub fn metadata(&self) -> &Attributes {
98        &self.metadata
99    }
100}
101
102/// Top-level bundle summary meant for fast orientation.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct ContextSummary {
105    text: String,
106    #[serde(default)]
107    attributes: Attributes,
108}
109
110impl ContextSummary {
111    pub fn new(text: impl Into<String>, attributes: Attributes) -> Result<Self, DomainError> {
112        let text = text.into();
113        Ok(Self {
114            text: validate_text(&text, "external_context.summary.text", MAX_ITEM_TITLE_LEN)?,
115            attributes,
116        })
117    }
118
119    #[must_use]
120    pub fn text(&self) -> &str {
121        &self.text
122    }
123
124    #[must_use]
125    pub fn attributes(&self) -> &Attributes {
126        &self.attributes
127    }
128}
129
130/// One structured item inside the external bundle.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct ContextItem {
133    item_id: String,
134    kind: String,
135    title: String,
136    narrative: Option<String>,
137    #[serde(default)]
138    attributes: Attributes,
139    #[serde(default)]
140    reference_ids: Vec<String>,
141}
142
143impl ContextItem {
144    pub fn new(
145        item_id: impl Into<String>,
146        kind: impl Into<String>,
147        title: impl Into<String>,
148        narrative: Option<String>,
149        attributes: Attributes,
150        reference_ids: Vec<String>,
151    ) -> Result<Self, DomainError> {
152        let reference_ids = reference_ids
153            .into_iter()
154            .map(|reference_id| {
155                validate_text(
156                    &reference_id,
157                    "external_context.item.reference_id",
158                    MAX_REFERENCE_ID_LEN,
159                )
160            })
161            .collect::<Result<Vec<_>, _>>()?;
162        let item_id = item_id.into();
163        let kind = kind.into();
164        let title = title.into();
165
166        Ok(Self {
167            item_id: validate_text(&item_id, "external_context.item_id", MAX_ITEM_ID_LEN)?,
168            kind: validate_text(&kind, "external_context.item.kind", MAX_ITEM_KIND_LEN)?,
169            title: validate_text(&title, "external_context.item.title", MAX_ITEM_TITLE_LEN)?,
170            narrative: narrative.and_then(|text| {
171                let trimmed = text.trim().to_owned();
172                if trimmed.is_empty() {
173                    None
174                } else {
175                    Some(trimmed)
176                }
177            }),
178            attributes,
179            reference_ids,
180        })
181    }
182
183    #[must_use]
184    pub fn item_id(&self) -> &str {
185        &self.item_id
186    }
187
188    #[must_use]
189    pub fn kind(&self) -> &str {
190        &self.kind
191    }
192
193    #[must_use]
194    pub fn title(&self) -> &str {
195        &self.title
196    }
197
198    #[must_use]
199    pub fn narrative(&self) -> Option<&str> {
200        self.narrative.as_deref()
201    }
202
203    #[must_use]
204    pub fn attributes(&self) -> &Attributes {
205        &self.attributes
206    }
207
208    #[must_use]
209    pub fn reference_ids(&self) -> &[String] {
210        &self.reference_ids
211    }
212}
213
214/// Structured reference material associated with a context bundle.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ContextReference {
217    reference_id: String,
218    uri: String,
219    title: Option<String>,
220    media_type: Option<String>,
221    #[serde(default)]
222    attributes: Attributes,
223}
224
225impl ContextReference {
226    pub fn new(
227        reference_id: impl Into<String>,
228        uri: impl Into<String>,
229        title: Option<String>,
230        media_type: Option<String>,
231        attributes: Attributes,
232    ) -> Result<Self, DomainError> {
233        let reference_id = reference_id.into();
234        let uri = uri.into();
235        Ok(Self {
236            reference_id: validate_text(
237                &reference_id,
238                "external_context.reference_id",
239                MAX_REFERENCE_ID_LEN,
240            )?,
241            uri: validate_text(&uri, "external_context.reference.uri", MAX_URI_LEN)?,
242            title: normalize_optional(title),
243            media_type: normalize_optional(media_type),
244            attributes,
245        })
246    }
247
248    #[must_use]
249    pub fn reference_id(&self) -> &str {
250        &self.reference_id
251    }
252
253    #[must_use]
254    pub fn uri(&self) -> &str {
255        &self.uri
256    }
257
258    #[must_use]
259    pub fn title(&self) -> Option<&str> {
260        self.title.as_deref()
261    }
262
263    #[must_use]
264    pub fn media_type(&self) -> Option<&str> {
265        self.media_type.as_deref()
266    }
267
268    #[must_use]
269    pub fn attributes(&self) -> &Attributes {
270        &self.attributes
271    }
272}
273
274fn validate_text(value: &str, field: &'static str, max_len: usize) -> Result<String, DomainError> {
275    let trimmed = value.trim();
276    if trimmed.is_empty() {
277        return Err(DomainError::EmptyField { field });
278    }
279    if trimmed.len() > max_len {
280        return Err(DomainError::FieldTooLong {
281            field,
282            actual: trimmed.len(),
283            max: max_len,
284        });
285    }
286    Ok(trimmed.to_owned())
287}
288
289fn validate_collection_len(
290    field: &'static str,
291    actual: usize,
292    max: usize,
293) -> Result<(), DomainError> {
294    if actual > max {
295        return Err(DomainError::OutOfRange {
296            field,
297            value: actual as f64,
298            min: 0.0,
299            max: max as f64,
300        });
301    }
302    Ok(())
303}
304
305fn normalize_optional(value: Option<String>) -> Option<String> {
306    value.and_then(|value| {
307        let trimmed = value.trim().to_owned();
308        if trimmed.is_empty() {
309            None
310        } else {
311            Some(trimmed)
312        }
313    })
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use serde_json::json;
320    use std::collections::BTreeMap;
321
322    fn attrs(key: &str, value: serde_json::Value) -> Attributes {
323        Attributes::new(BTreeMap::from([(key.to_owned(), value)])).unwrap()
324    }
325
326    fn sample_bundle() -> ExternalContextBundle {
327        ExternalContextBundle::new(
328            "ctx-1",
329            "v1",
330            Some(
331                ContextSummary::new(
332                    "Complex state assembled from external systems",
333                    attrs("source", json!("kernel")),
334                )
335                .unwrap(),
336            ),
337            vec![
338                ContextItem::new(
339                    "finding-1",
340                    "finding",
341                    "Primary observation",
342                    Some("A recent deployment correlates with the symptom".to_owned()),
343                    attrs("score", json!(0.92)),
344                    vec!["ref-1".to_owned()],
345                )
346                .unwrap(),
347                ContextItem::new(
348                    "decision-1",
349                    "decision",
350                    "Previous decision",
351                    None,
352                    attrs("decision", json!("rollback rejected")),
353                    vec!["ref-2".to_owned()],
354                )
355                .unwrap(),
356            ],
357            vec![
358                ContextReference::new(
359                    "ref-1",
360                    "s3://evidence/1.json",
361                    Some("evidence snapshot".to_owned()),
362                    Some("application/json".to_owned()),
363                    Attributes::empty(),
364                )
365                .unwrap(),
366                ContextReference::new(
367                    "ref-2",
368                    "graph://decision/2",
369                    None,
370                    None,
371                    attrs("kind", json!("decision")),
372                )
373                .unwrap(),
374            ],
375            attrs("bundle_kind", json!("external")),
376        )
377        .unwrap()
378    }
379
380    #[test]
381    fn bundle_preserves_typed_sections() {
382        let bundle = sample_bundle();
383        assert_eq!(bundle.bundle_id(), "ctx-1");
384        assert_eq!(bundle.schema_version(), "v1");
385        assert_eq!(
386            bundle.summary().unwrap().text(),
387            "Complex state assembled from external systems"
388        );
389        assert_eq!(bundle.items().len(), 2);
390        assert_eq!(bundle.references().len(), 2);
391        assert_eq!(bundle.items()[0].kind(), "finding");
392        assert_eq!(
393            bundle.items()[1].attributes().get("decision"),
394            Some(&json!("rollback rejected"))
395        );
396    }
397
398    #[test]
399    fn empty_bundle_id_is_rejected() {
400        let err = ExternalContextBundle::new("", "v1", None, vec![], vec![], Attributes::empty())
401            .unwrap_err();
402        assert!(matches!(
403            err,
404            DomainError::EmptyField {
405                field: "external_context.bundle_id"
406            }
407        ));
408    }
409
410    #[test]
411    fn context_item_requires_kind_and_title() {
412        let err =
413            ContextItem::new("item-1", "", "", None, Attributes::empty(), vec![]).unwrap_err();
414        assert!(matches!(
415            err,
416            DomainError::EmptyField {
417                field: "external_context.item.kind"
418            }
419        ));
420    }
421
422    #[test]
423    fn reference_requires_uri() {
424        let err = ContextReference::new("ref-1", " ", None, None, Attributes::empty()).unwrap_err();
425        assert!(matches!(
426            err,
427            DomainError::EmptyField {
428                field: "external_context.reference.uri"
429            }
430        ));
431    }
432
433    #[test]
434    fn serde_roundtrip_preserves_structure() {
435        let bundle = sample_bundle();
436        let json = serde_json::to_value(&bundle).unwrap();
437        let back: ExternalContextBundle = serde_json::from_value(json).unwrap();
438        assert_eq!(back, bundle);
439    }
440}