Skip to main content

organism_planning/
huddle_invocation.rs

1//! Huddle invocation — the request that asks for a huddle.
2//!
3//! `Huddle` (in `huddle.rs`) is the *executor*: it runs a set of `Reasoner`s
4//! against an `IntentPacket`. `HuddleInvocation` is the *invocation packet*:
5//! a structured "convene a huddle on subject X because of Y, at urgency Z".
6//!
7//! The envelope is domain-agnostic. Triggers are free-form strings so each
8//! consumer can encode its own taxonomy without touching this crate. Strongly
9//! typed domain triggers can be JSON-serialised into `domain_context`, which
10//! mirrors the `AdversarialSignal::context` pattern in `organism-adversarial`.
11//!
12//! Classification rules — "is this brief contested? sensitive? high-risk?" —
13//! depend on domain inputs (claim records, drafts, trust labels, …) and so
14//! stay in the consuming crate. Organism only owns the envelope.
15
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum HuddleInvocationKind {
21    /// Subject carries internal contradiction or contested verification.
22    Contested,
23    /// Subject involves sensitive content that demands editorial care.
24    Sensitive,
25    /// Subject has unresolved gaps or open obligations that elevate risk.
26    HighRisk,
27    /// Subject was produced or materially shaped by AI assistance.
28    AiAssisted,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum HuddleUrgency {
34    Routine,
35    Elevated,
36    Urgent,
37}
38
39/// Domain-agnostic huddle invocation envelope.
40///
41/// `subject_id` identifies whatever the huddle is about (a story, a plan,
42/// a decision, …). `triggers` is a free-form list of human-readable reason
43/// labels. `domain_context` carries optional structured domain data (e.g.
44/// strongly typed trigger enums serialised to JSON).
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct HuddleInvocation {
47    pub id: String,
48    pub subject_id: String,
49    pub kind: HuddleInvocationKind,
50    pub urgency: HuddleUrgency,
51    pub triggers: Vec<String>,
52    pub rationale: String,
53    pub correlation_id: String,
54    pub reviewer: String,
55    #[serde(default, skip_serializing_if = "is_null")]
56    pub domain_context: serde_json::Value,
57}
58
59fn is_null(v: &serde_json::Value) -> bool {
60    v.is_null()
61}
62
63impl HuddleInvocation {
64    pub fn new(
65        subject_id: impl Into<String>,
66        kind: HuddleInvocationKind,
67        urgency: HuddleUrgency,
68        correlation_id: impl Into<String>,
69    ) -> Self {
70        let subject = subject_id.into();
71        Self {
72            id: format!("huddle:{subject}"),
73            subject_id: subject,
74            kind,
75            urgency,
76            triggers: Vec::new(),
77            rationale: String::new(),
78            correlation_id: correlation_id.into(),
79            reviewer: String::new(),
80            domain_context: serde_json::Value::Null,
81        }
82    }
83
84    #[must_use]
85    pub fn with_id(mut self, id: impl Into<String>) -> Self {
86        self.id = id.into();
87        self
88    }
89
90    #[must_use]
91    pub fn with_triggers(mut self, triggers: Vec<String>) -> Self {
92        self.triggers = triggers;
93        self
94    }
95
96    #[must_use]
97    pub fn with_rationale(mut self, rationale: impl Into<String>) -> Self {
98        self.rationale = rationale.into();
99        self
100    }
101
102    #[must_use]
103    pub fn with_reviewer(mut self, reviewer: impl Into<String>) -> Self {
104        self.reviewer = reviewer.into();
105        self
106    }
107
108    #[must_use]
109    pub fn with_domain_context(mut self, context: serde_json::Value) -> Self {
110        self.domain_context = context;
111        self
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn new_sets_default_id_and_empty_fields() {
121        let inv = HuddleInvocation::new(
122            "story-001",
123            HuddleInvocationKind::Contested,
124            HuddleUrgency::Elevated,
125            "corr-1",
126        );
127        assert_eq!(inv.id, "huddle:story-001");
128        assert_eq!(inv.subject_id, "story-001");
129        assert_eq!(inv.kind, HuddleInvocationKind::Contested);
130        assert_eq!(inv.urgency, HuddleUrgency::Elevated);
131        assert_eq!(inv.correlation_id, "corr-1");
132        assert!(inv.triggers.is_empty());
133        assert!(inv.rationale.is_empty());
134        assert!(inv.reviewer.is_empty());
135        assert!(inv.domain_context.is_null());
136    }
137
138    #[test]
139    fn builder_chain_sets_fields() {
140        let inv = HuddleInvocation::new(
141            "story-001",
142            HuddleInvocationKind::Sensitive,
143            HuddleUrgency::Urgent,
144            "corr-1",
145        )
146        .with_id("huddle:custom")
147        .with_triggers(vec!["sensitive-subject".into(), "policy/x".into()])
148        .with_rationale("sensitive keyword in title")
149        .with_reviewer("organism-huddle")
150        .with_domain_context(serde_json::json!({"tag": "newspaper"}));
151        assert_eq!(inv.id, "huddle:custom");
152        assert_eq!(inv.triggers.len(), 2);
153        assert_eq!(inv.rationale, "sensitive keyword in title");
154        assert_eq!(inv.reviewer, "organism-huddle");
155        assert_eq!(inv.domain_context["tag"], "newspaper");
156    }
157
158    #[test]
159    fn serde_round_trip_uses_snake_case() {
160        let inv = HuddleInvocation::new(
161            "story-001",
162            HuddleInvocationKind::AiAssisted,
163            HuddleUrgency::Routine,
164            "corr-1",
165        );
166        let json = serde_json::to_string(&inv).expect("serialize");
167        assert!(json.contains("\"kind\":\"ai_assisted\""), "got {json}");
168        assert!(json.contains("\"urgency\":\"routine\""), "got {json}");
169        let back: HuddleInvocation = serde_json::from_str(&json).expect("deserialize");
170        assert_eq!(back, inv);
171    }
172
173    #[test]
174    fn null_domain_context_is_omitted_from_serialization() {
175        let inv = HuddleInvocation::new(
176            "story-001",
177            HuddleInvocationKind::HighRisk,
178            HuddleUrgency::Elevated,
179            "corr-1",
180        );
181        let json = serde_json::to_string(&inv).expect("serialize");
182        assert!(!json.contains("domain_context"), "got {json}");
183    }
184
185    #[test]
186    fn populated_domain_context_round_trips() {
187        let inv = HuddleInvocation::new(
188            "story-001",
189            HuddleInvocationKind::HighRisk,
190            HuddleUrgency::Elevated,
191            "corr-1",
192        )
193        .with_domain_context(serde_json::json!({"adversarial_triggers": ["foo", "bar"]}));
194        let json = serde_json::to_string(&inv).expect("serialize");
195        let back: HuddleInvocation = serde_json::from_str(&json).expect("deserialize");
196        assert_eq!(back.domain_context["adversarial_triggers"][0], "foo");
197        assert_eq!(back, inv);
198    }
199
200    #[test]
201    fn all_kind_variants_serialize() {
202        for kind in [
203            HuddleInvocationKind::Contested,
204            HuddleInvocationKind::Sensitive,
205            HuddleInvocationKind::HighRisk,
206            HuddleInvocationKind::AiAssisted,
207        ] {
208            let json = serde_json::to_string(&kind).expect("serialize");
209            let back: HuddleInvocationKind = serde_json::from_str(&json).expect("deserialize");
210            assert_eq!(kind, back);
211        }
212    }
213
214    #[test]
215    fn all_urgency_variants_serialize() {
216        for urgency in [
217            HuddleUrgency::Routine,
218            HuddleUrgency::Elevated,
219            HuddleUrgency::Urgent,
220        ] {
221            let json = serde_json::to_string(&urgency).expect("serialize");
222            let back: HuddleUrgency = serde_json::from_str(&json).expect("deserialize");
223            assert_eq!(urgency, back);
224        }
225    }
226}