Skip to main content

organism_intent/
lib.rs

1//! Intent system.
2//!
3//! Translates human goals into structured, machine-executable specifications.
4//!
5//! - [`IntentPacket`] — typed contract between humans and the runtime
6//! - [`admission`] — feasibility gate before any planning begins
7//! - [`decomposition`] — breaks intents into governed intent trees
8
9pub mod admission;
10pub mod convergence;
11pub mod decomposition;
12pub mod graded_admission;
13pub mod problem;
14pub mod resolution;
15
16pub use convergence::{ConvergenceCriteria, ConvergenceSignal};
17pub use graded_admission::{DimensionRulebook, GradedAdmissionController};
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23// ── Intent Packet ──────────────────────────────────────────────────
24
25/// The contract between humans and the runtime.
26///
27/// Authority is *not* granted by the existence of an IntentPacket — it is
28/// recomputed at the Converge commit boundary. The `authority` field is a
29/// declaration of what the system is *permitted* to attempt, not proof that
30/// it is allowed to commit.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct IntentPacket {
33    pub id: Uuid,
34    pub outcome: String,
35    pub context: serde_json::Value,
36    pub constraints: Vec<String>,
37    pub authority: Vec<String>,
38    pub forbidden: Vec<ForbiddenAction>,
39    pub reversibility: Reversibility,
40    pub expires: DateTime<Utc>,
41    pub expiry_action: ExpiryAction,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub convergence: Option<ConvergenceCriteria>,
44}
45
46impl IntentPacket {
47    pub fn new(outcome: impl Into<String>, expires: DateTime<Utc>) -> Self {
48        Self {
49            id: Uuid::new_v4(),
50            outcome: outcome.into(),
51            context: serde_json::Value::Null,
52            constraints: Vec::new(),
53            authority: Vec::new(),
54            forbidden: Vec::new(),
55            reversibility: Reversibility::Reversible,
56            expires,
57            expiry_action: ExpiryAction::Halt,
58            convergence: None,
59        }
60    }
61
62    pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
63        now >= self.expires
64    }
65
66    pub fn with_context(mut self, ctx: serde_json::Value) -> Self {
67        self.context = ctx;
68        self
69    }
70
71    pub fn with_authority(mut self, authority: Vec<String>) -> Self {
72        self.authority = authority;
73        self
74    }
75
76    pub fn with_reversibility(mut self, r: Reversibility) -> Self {
77        self.reversibility = r;
78        self
79    }
80
81    pub fn with_expiry_action(mut self, action: ExpiryAction) -> Self {
82        self.expiry_action = action;
83        self
84    }
85
86    pub fn with_convergence_criteria(mut self, criteria: ConvergenceCriteria) -> Self {
87        self.convergence = Some(criteria);
88        self
89    }
90}
91
92// ── Reversibility ──────────────────────────────────────────────────
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum Reversibility {
97    Reversible,
98    Partial,
99    Irreversible,
100}
101
102// ── Forbidden Actions ──────────────────────────────────────────────
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ForbiddenAction {
106    pub action: String,
107    pub reason: String,
108}
109
110// ── Expiry ─────────────────────────────────────────────────────────
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum ExpiryAction {
115    Halt,
116    Escalate,
117    CompleteAndHalt,
118}
119
120// ── Admission Control ──────────────────────────────────────────────
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct AdmissionResult {
124    pub feasible: bool,
125    pub dimensions: Vec<FeasibilityAssessment>,
126    pub rejection_reason: Option<String>,
127}
128
129impl AdmissionResult {
130    /// Build a result with `feasible` derived from the standard rule (any
131    /// Infeasible dimension blocks the intent) and `rejection_reason`
132    /// auto-composed from the infeasible reasons joined by "; ".
133    #[must_use]
134    pub fn from_dimensions(dimensions: Vec<FeasibilityAssessment>) -> Self {
135        let infeasible_reasons: Vec<String> = dimensions
136            .iter()
137            .filter(|d| d.kind == FeasibilityKind::Infeasible)
138            .map(|d| d.reason.clone())
139            .collect();
140        let feasible = infeasible_reasons.is_empty();
141        let rejection_reason = if feasible {
142            None
143        } else {
144            Some(infeasible_reasons.join("; "))
145        };
146        Self {
147            feasible,
148            dimensions,
149            rejection_reason,
150        }
151    }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct FeasibilityAssessment {
156    pub dimension: FeasibilityDimension,
157    pub kind: FeasibilityKind,
158    pub reason: String,
159}
160
161impl FeasibilityAssessment {
162    #[must_use]
163    pub fn feasible(dimension: FeasibilityDimension, reason: impl Into<String>) -> Self {
164        Self {
165            dimension,
166            kind: FeasibilityKind::Feasible,
167            reason: reason.into(),
168        }
169    }
170
171    #[must_use]
172    pub fn infeasible(dimension: FeasibilityDimension, reason: impl Into<String>) -> Self {
173        Self {
174            dimension,
175            kind: FeasibilityKind::Infeasible,
176            reason: reason.into(),
177        }
178    }
179
180    #[must_use]
181    pub fn uncertain(dimension: FeasibilityDimension, reason: impl Into<String>) -> Self {
182        Self {
183            dimension,
184            kind: FeasibilityKind::Uncertain,
185            reason: reason.into(),
186        }
187    }
188
189    #[must_use]
190    pub fn with_constraints(dimension: FeasibilityDimension, reason: impl Into<String>) -> Self {
191        Self {
192            dimension,
193            kind: FeasibilityKind::FeasibleWithConstraints,
194            reason: reason.into(),
195        }
196    }
197
198    #[must_use]
199    pub fn is_blocking(&self) -> bool {
200        self.kind == FeasibilityKind::Infeasible
201    }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum FeasibilityDimension {
207    Capability,
208    Context,
209    Resources,
210    Authority,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum FeasibilityKind {
216    Feasible,
217    FeasibleWithConstraints,
218    Uncertain,
219    Infeasible,
220}
221
222pub trait AdmissionController: Send + Sync {
223    fn evaluate(&self, intent: &IntentPacket) -> AdmissionResult;
224}
225
226// ── Intent Decomposition ───────────────────────────────────────────
227
228/// A node in the intent decomposition tree. Authority can only narrow
229/// during decomposition, never expand.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct IntentNode {
232    pub id: Uuid,
233    pub intent: IntentPacket,
234    pub children: Vec<IntentNode>,
235}
236
237impl IntentNode {
238    pub fn leaf(intent: IntentPacket) -> Self {
239        Self {
240            id: Uuid::new_v4(),
241            intent,
242            children: Vec::new(),
243        }
244    }
245
246    pub fn is_leaf(&self) -> bool {
247        self.children.is_empty()
248    }
249}
250
251// ── Errors ─────────────────────────────────────────────────────────
252
253#[derive(Debug, thiserror::Error)]
254pub enum IntentError {
255    #[error("intent expired at {0}")]
256    Expired(DateTime<Utc>),
257    #[error("intent forbidden by rule: {0}")]
258    Forbidden(String),
259    #[error("intent infeasible: {0}")]
260    Infeasible(String),
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use chrono::Duration;
267
268    fn future() -> DateTime<Utc> {
269        Utc::now() + Duration::hours(1)
270    }
271
272    fn past() -> DateTime<Utc> {
273        Utc::now() - Duration::seconds(10)
274    }
275
276    #[test]
277    fn new_sets_defaults() {
278        let intent = IntentPacket::new("ship q3", future());
279        assert_eq!(intent.outcome, "ship q3");
280        assert_eq!(intent.context, serde_json::Value::Null);
281        assert!(intent.constraints.is_empty());
282        assert!(intent.authority.is_empty());
283        assert!(intent.forbidden.is_empty());
284        assert_eq!(intent.reversibility, Reversibility::Reversible);
285        assert_eq!(intent.expiry_action, ExpiryAction::Halt);
286        assert_eq!(intent.convergence, None);
287    }
288
289    #[test]
290    fn new_generates_unique_ids() {
291        let a = IntentPacket::new("a", future());
292        let b = IntentPacket::new("b", future());
293        assert_ne!(a.id, b.id);
294    }
295
296    #[test]
297    fn is_expired_past() {
298        let intent = IntentPacket::new("late", past());
299        assert!(intent.is_expired(Utc::now()));
300    }
301
302    #[test]
303    fn is_expired_future() {
304        let intent = IntentPacket::new("on time", future());
305        assert!(!intent.is_expired(Utc::now()));
306    }
307
308    #[test]
309    fn is_expired_exact_boundary() {
310        let now = Utc::now();
311        let intent = IntentPacket::new("boundary", now);
312        assert!(intent.is_expired(now));
313    }
314
315    #[test]
316    fn with_context() {
317        let intent =
318            IntentPacket::new("ctx", future()).with_context(serde_json::json!({"key": "value"}));
319        assert_eq!(intent.context["key"], "value");
320    }
321
322    #[test]
323    fn with_authority() {
324        let intent = IntentPacket::new("auth", future())
325            .with_authority(vec!["admin".into(), "finance".into()]);
326        assert_eq!(intent.authority.len(), 2);
327        assert_eq!(intent.authority[0], "admin");
328    }
329
330    #[test]
331    fn with_reversibility() {
332        let intent =
333            IntentPacket::new("rev", future()).with_reversibility(Reversibility::Irreversible);
334        assert_eq!(intent.reversibility, Reversibility::Irreversible);
335    }
336
337    #[test]
338    fn with_expiry_action() {
339        let intent = IntentPacket::new("exp", future()).with_expiry_action(ExpiryAction::Escalate);
340        assert_eq!(intent.expiry_action, ExpiryAction::Escalate);
341    }
342
343    #[test]
344    fn with_convergence_criteria() {
345        let intent = IntentPacket::new("conv", future())
346            .with_convergence_criteria(ConvergenceCriteria::MaxRounds { rounds: 4 });
347        assert_eq!(
348            intent.convergence,
349            Some(ConvergenceCriteria::MaxRounds { rounds: 4 })
350        );
351    }
352
353    #[test]
354    fn builder_chain() {
355        let intent = IntentPacket::new("full", future())
356            .with_context(serde_json::json!(null))
357            .with_authority(vec![])
358            .with_reversibility(Reversibility::Partial)
359            .with_expiry_action(ExpiryAction::CompleteAndHalt);
360        assert_eq!(intent.reversibility, Reversibility::Partial);
361        assert_eq!(intent.expiry_action, ExpiryAction::CompleteAndHalt);
362    }
363
364    #[test]
365    fn serde_roundtrip() {
366        let intent = IntentPacket::new("roundtrip", future())
367            .with_context(serde_json::json!({"n": 42}))
368            .with_authority(vec!["ops".into()])
369            .with_reversibility(Reversibility::Partial)
370            .with_expiry_action(ExpiryAction::Escalate);
371
372        let json = serde_json::to_string(&intent).unwrap();
373        let back: IntentPacket = serde_json::from_str(&json).unwrap();
374        assert_eq!(back.id, intent.id);
375        assert_eq!(back.outcome, "roundtrip");
376        assert_eq!(back.context["n"], 42);
377        assert_eq!(back.authority, vec!["ops"]);
378        assert_eq!(back.reversibility, Reversibility::Partial);
379        assert_eq!(back.expiry_action, ExpiryAction::Escalate);
380        assert_eq!(back.convergence, None);
381    }
382
383    #[test]
384    fn convergence_criteria_roundtrip_on_intent_packet() {
385        let intent = IntentPacket::new("roundtrip convergence", future())
386            .with_convergence_criteria(ConvergenceCriteria::ConsensusAmongMembers);
387
388        let json = serde_json::to_string(&intent).unwrap();
389        assert!(json.contains("consensus_among_members"));
390
391        let back: IntentPacket = serde_json::from_str(&json).unwrap();
392        assert_eq!(
393            back.convergence,
394            Some(ConvergenceCriteria::ConsensusAmongMembers)
395        );
396    }
397
398    #[test]
399    fn older_intent_json_defaults_to_no_convergence_criteria() {
400        let id = Uuid::new_v4();
401        let expires = future().to_rfc3339();
402        let json = format!(
403            r#"{{
404                "id": "{id}",
405                "outcome": "old packet",
406                "context": null,
407                "constraints": [],
408                "authority": [],
409                "forbidden": [],
410                "reversibility": "reversible",
411                "expires": "{expires}",
412                "expiry_action": "halt"
413            }}"#
414        );
415
416        let back: IntentPacket = serde_json::from_str(&json).unwrap();
417        assert_eq!(back.convergence, None);
418    }
419
420    #[test]
421    fn serde_with_forbidden() {
422        let mut intent = IntentPacket::new("forbidden", future());
423        intent.forbidden.push(ForbiddenAction {
424            action: "delete_prod".into(),
425            reason: "destructive".into(),
426        });
427
428        let json = serde_json::to_string(&intent).unwrap();
429        let back: IntentPacket = serde_json::from_str(&json).unwrap();
430        assert_eq!(back.forbidden.len(), 1);
431        assert_eq!(back.forbidden[0].action, "delete_prod");
432    }
433
434    #[test]
435    fn reversibility_all_variants_serde() {
436        for v in [
437            Reversibility::Reversible,
438            Reversibility::Partial,
439            Reversibility::Irreversible,
440        ] {
441            let json = serde_json::to_string(&v).unwrap();
442            let back: Reversibility = serde_json::from_str(&json).unwrap();
443            assert_eq!(v, back);
444        }
445    }
446
447    #[test]
448    fn reversibility_snake_case() {
449        assert_eq!(
450            serde_json::to_string(&Reversibility::Reversible).unwrap(),
451            "\"reversible\""
452        );
453        assert_eq!(
454            serde_json::to_string(&Reversibility::Partial).unwrap(),
455            "\"partial\""
456        );
457        assert_eq!(
458            serde_json::to_string(&Reversibility::Irreversible).unwrap(),
459            "\"irreversible\""
460        );
461    }
462
463    #[test]
464    fn expiry_action_all_variants_serde() {
465        for v in [
466            ExpiryAction::Halt,
467            ExpiryAction::Escalate,
468            ExpiryAction::CompleteAndHalt,
469        ] {
470            let json = serde_json::to_string(&v).unwrap();
471            let back: ExpiryAction = serde_json::from_str(&json).unwrap();
472            assert_eq!(v, back);
473        }
474    }
475
476    #[test]
477    fn expiry_action_snake_case() {
478        assert_eq!(
479            serde_json::to_string(&ExpiryAction::Halt).unwrap(),
480            "\"halt\""
481        );
482        assert_eq!(
483            serde_json::to_string(&ExpiryAction::Escalate).unwrap(),
484            "\"escalate\""
485        );
486        assert_eq!(
487            serde_json::to_string(&ExpiryAction::CompleteAndHalt).unwrap(),
488            "\"complete_and_halt\""
489        );
490    }
491
492    #[test]
493    fn feasibility_dimension_all_variants_serde() {
494        for v in [
495            FeasibilityDimension::Capability,
496            FeasibilityDimension::Context,
497            FeasibilityDimension::Resources,
498            FeasibilityDimension::Authority,
499        ] {
500            let json = serde_json::to_string(&v).unwrap();
501            let back: FeasibilityDimension = serde_json::from_str(&json).unwrap();
502            assert_eq!(v, back);
503        }
504    }
505
506    #[test]
507    fn feasibility_kind_all_variants_serde() {
508        for v in [
509            FeasibilityKind::Feasible,
510            FeasibilityKind::FeasibleWithConstraints,
511            FeasibilityKind::Uncertain,
512            FeasibilityKind::Infeasible,
513        ] {
514            let json = serde_json::to_string(&v).unwrap();
515            let back: FeasibilityKind = serde_json::from_str(&json).unwrap();
516            assert_eq!(v, back);
517        }
518    }
519
520    #[test]
521    fn feasibility_kind_snake_case() {
522        assert_eq!(
523            serde_json::to_string(&FeasibilityKind::FeasibleWithConstraints).unwrap(),
524            "\"feasible_with_constraints\""
525        );
526    }
527
528    #[test]
529    fn admission_result_serde_roundtrip() {
530        let result = AdmissionResult {
531            feasible: false,
532            dimensions: vec![FeasibilityAssessment {
533                dimension: FeasibilityDimension::Authority,
534                kind: FeasibilityKind::Infeasible,
535                reason: "no authority".into(),
536            }],
537            rejection_reason: Some("not authorized".into()),
538        };
539        let json = serde_json::to_string(&result).unwrap();
540        let back: AdmissionResult = serde_json::from_str(&json).unwrap();
541        assert!(!back.feasible);
542        assert_eq!(back.dimensions.len(), 1);
543        assert_eq!(back.rejection_reason.as_deref(), Some("not authorized"));
544    }
545
546    #[test]
547    fn feasibility_constructors_set_kind() {
548        let f = FeasibilityAssessment::feasible(FeasibilityDimension::Capability, "ok");
549        assert_eq!(f.kind, FeasibilityKind::Feasible);
550        assert_eq!(f.reason, "ok");
551
552        let i = FeasibilityAssessment::infeasible(FeasibilityDimension::Context, "missing");
553        assert_eq!(i.kind, FeasibilityKind::Infeasible);
554        assert!(i.is_blocking());
555
556        let u = FeasibilityAssessment::uncertain(FeasibilityDimension::Authority, "unclear");
557        assert_eq!(u.kind, FeasibilityKind::Uncertain);
558        assert!(!u.is_blocking());
559
560        let c = FeasibilityAssessment::with_constraints(FeasibilityDimension::Resources, "tight");
561        assert_eq!(c.kind, FeasibilityKind::FeasibleWithConstraints);
562        assert!(!c.is_blocking());
563    }
564
565    #[test]
566    fn admission_from_dimensions_feasible_when_no_infeasible() {
567        let result = AdmissionResult::from_dimensions(vec![
568            FeasibilityAssessment::feasible(FeasibilityDimension::Capability, "ok"),
569            FeasibilityAssessment::with_constraints(FeasibilityDimension::Resources, "tight"),
570            FeasibilityAssessment::uncertain(FeasibilityDimension::Authority, "unclear"),
571        ]);
572        assert!(result.feasible);
573        assert!(result.rejection_reason.is_none());
574        assert_eq!(result.dimensions.len(), 3);
575    }
576
577    #[test]
578    fn admission_from_dimensions_infeasible_with_joined_reason() {
579        let result = AdmissionResult::from_dimensions(vec![
580            FeasibilityAssessment::feasible(FeasibilityDimension::Capability, "ok"),
581            FeasibilityAssessment::infeasible(FeasibilityDimension::Context, "missing outcome"),
582            FeasibilityAssessment::infeasible(FeasibilityDimension::Authority, "no authority"),
583        ]);
584        assert!(!result.feasible);
585        assert_eq!(
586            result.rejection_reason.as_deref(),
587            Some("missing outcome; no authority")
588        );
589    }
590
591    #[test]
592    fn admission_from_dimensions_empty_is_feasible() {
593        let result = AdmissionResult::from_dimensions(vec![]);
594        assert!(result.feasible);
595        assert!(result.rejection_reason.is_none());
596        assert!(result.dimensions.is_empty());
597    }
598
599    #[test]
600    fn forbidden_action_serde_roundtrip() {
601        let fa = ForbiddenAction {
602            action: "fire_all".into(),
603            reason: "HR policy".into(),
604        };
605        let json = serde_json::to_string(&fa).unwrap();
606        let back: ForbiddenAction = serde_json::from_str(&json).unwrap();
607        assert_eq!(back.action, "fire_all");
608        assert_eq!(back.reason, "HR policy");
609    }
610
611    #[test]
612    fn intent_node_leaf_is_leaf() {
613        let node = IntentNode::leaf(IntentPacket::new("leaf", future()));
614        assert!(node.is_leaf());
615    }
616
617    #[test]
618    fn intent_node_with_children_not_leaf() {
619        let child = IntentNode::leaf(IntentPacket::new("child", future()));
620        let parent = IntentNode {
621            id: Uuid::new_v4(),
622            intent: IntentPacket::new("parent", future()),
623            children: vec![child],
624        };
625        assert!(!parent.is_leaf());
626    }
627
628    #[test]
629    fn intent_error_display() {
630        let err = IntentError::Forbidden("no access".into());
631        assert_eq!(err.to_string(), "intent forbidden by rule: no access");
632
633        let err = IntentError::Infeasible("not enough resources".into());
634        assert_eq!(err.to_string(), "intent infeasible: not enough resources");
635    }
636
637    #[test]
638    fn intent_error_expired_display() {
639        let t = Utc::now();
640        let err = IntentError::Expired(t);
641        assert!(err.to_string().starts_with("intent expired at "));
642    }
643
644    #[test]
645    fn intent_packet_accepts_string_and_str() {
646        let from_str = IntentPacket::new("literal", future());
647        let from_string = IntentPacket::new(String::from("owned"), future());
648        assert_eq!(from_str.outcome, "literal");
649        assert_eq!(from_string.outcome, "owned");
650    }
651
652    #[test]
653    fn intent_packet_empty_outcome() {
654        let intent = IntentPacket::new("", future());
655        assert_eq!(intent.outcome, "");
656    }
657
658    #[test]
659    fn intent_packet_with_constraints() {
660        let mut intent = IntentPacket::new("constrained", future());
661        intent.constraints = vec!["budget < 10k".into(), "no external vendors".into()];
662        let json = serde_json::to_string(&intent).unwrap();
663        let back: IntentPacket = serde_json::from_str(&json).unwrap();
664        assert_eq!(back.constraints.len(), 2);
665    }
666}