Skip to main content

axiom_truth/
intent.rs

1//! TruthDocument → IntentPacket compilation.
2//!
3//! Compiles a parsed [`TruthDocument`] into a runtime [`IntentPacket`].
4//! Truth lives here in axiom; the IntentPacket is organism's runtime contract.
5//! Callers (helms, atelier showcase) parse `.truths` source with
6//! [`crate::parse_truth_document`], then call [`compile_intent`] before
7//! handing the packet off to organism's runtime admission.
8
9use chrono::{DateTime, Duration, TimeZone, Utc};
10use organism_pack::{ExpiryAction, ForbiddenAction, IntentPacket, Reversibility};
11
12use crate::truths::{AuthorityBlock, ConstraintBlock, ExceptionBlock, IntentBlock, TruthDocument};
13
14/// Errors produced when compiling a [`TruthDocument`] into an [`IntentPacket`].
15#[derive(Debug, Clone, thiserror::Error)]
16pub enum CompileError {
17    /// The Truth's `Intent:` block is missing or has no outcome/goal text.
18    /// An IntentPacket needs a non-empty outcome to drive resolution.
19    #[error("truth document has no Intent: outcome or goal")]
20    MissingOutcome,
21
22    /// The `Authority: expires` field was present but could not be parsed as
23    /// an RFC-3339 timestamp or `YYYY-MM-DD` date.
24    #[error("could not parse Authority.expires '{value}': {message}")]
25    ExpiryParse { value: String, message: String },
26}
27
28/// Default expiry window applied when the Truth doesn't specify one. Intents
29/// without an explicit deadline get one day; the runtime can re-issue the
30/// IntentPacket if the work outlives that window.
31const DEFAULT_EXPIRY_HOURS: i64 = 24;
32
33/// Compile a parsed [`TruthDocument`] into an [`IntentPacket`].
34///
35/// Field mapping (Truth governance block → IntentPacket field):
36/// - `Intent.outcome` (or `Intent.goal` as fallback) → `outcome`
37/// - `Authority.may` → `authority`
38/// - `Authority.must_not` ⊕ `Constraint.must_not` → `forbidden`
39///   (deduplicated; Authority entries get an `authority` reason, Constraint
40///   entries get a `constraint` reason)
41/// - `Authority.requires_approval` → folded into `constraints` as
42///   `"requires_approval: <action>"` lines
43/// - `Authority.expires` → `expires` (RFC-3339; falls back to `YYYY-MM-DD`
44///   interpreted as midnight UTC)
45/// - `Constraint.budget` ⊕ `Constraint.cost_limit` → `constraints`
46/// - `Exception.escalates_to` ⊕ `Exception.requires` → `expiry_action`
47///   (presence flips the default `Halt` to `Escalate`)
48/// - Reversibility defaults to `Reversible`. Truths can override via a
49///   constraint of the form `"reversibility: irreversible"` (case-insensitive).
50///
51/// The Gherkin body itself is NOT folded into the IntentPacket; it is the
52/// validation/simulation surface, not the runtime contract.
53///
54/// # Errors
55///
56/// Returns [`CompileError::MissingOutcome`] if neither outcome nor goal is set,
57/// and [`CompileError::ExpiryParse`] if `Authority.expires` is malformed.
58pub fn compile_intent(doc: &TruthDocument) -> Result<IntentPacket, CompileError> {
59    let outcome = extract_outcome(doc.governance.intent.as_ref())?;
60    let expires = extract_expiry(doc.governance.authority.as_ref())?;
61    let authority = extract_authority(doc.governance.authority.as_ref());
62    let forbidden = extract_forbidden(
63        doc.governance.authority.as_ref(),
64        doc.governance.constraint.as_ref(),
65    );
66    let constraints = extract_constraints(
67        doc.governance.authority.as_ref(),
68        doc.governance.constraint.as_ref(),
69    );
70    let reversibility = extract_reversibility(&constraints);
71    let expiry_action = extract_expiry_action(doc.governance.exception.as_ref());
72
73    let packet = IntentPacket::new(outcome, expires)
74        .with_authority(authority)
75        .with_reversibility(reversibility)
76        .with_expiry_action(expiry_action);
77
78    Ok(IntentPacket {
79        constraints,
80        forbidden,
81        ..packet
82    })
83}
84
85/// Convenience: parse Truth source and compile to an IntentPacket in one step.
86///
87/// # Errors
88///
89/// Returns the parse error if the Truth source is malformed, or the compile
90/// error if it is structurally fine but missing required fields.
91pub fn compile_intent_from_source(source: &str) -> Result<IntentPacket, CompileFromSourceError> {
92    let doc =
93        crate::truths::parse_truth_document(source).map_err(CompileFromSourceError::ParseFailed)?;
94    compile_intent(&doc).map_err(CompileFromSourceError::CompileFailed)
95}
96
97/// Combined error for the source → IntentPacket convenience path.
98#[derive(Debug, thiserror::Error)]
99pub enum CompileFromSourceError {
100    #[error("truth source did not parse: {0}")]
101    ParseFailed(crate::gherkin::ValidationError),
102    #[error("truth document did not compile: {0}")]
103    CompileFailed(CompileError),
104}
105
106fn extract_outcome(intent: Option<&IntentBlock>) -> Result<String, CompileError> {
107    let block = intent.ok_or(CompileError::MissingOutcome)?;
108    block
109        .outcome
110        .as_ref()
111        .or(block.goal.as_ref())
112        .map(|s| s.trim().to_string())
113        .filter(|s| !s.is_empty())
114        .ok_or(CompileError::MissingOutcome)
115}
116
117fn extract_expiry(authority: Option<&AuthorityBlock>) -> Result<DateTime<Utc>, CompileError> {
118    let Some(value) = authority.and_then(|a| a.expires.as_ref()) else {
119        return Ok(Utc::now() + Duration::hours(DEFAULT_EXPIRY_HOURS));
120    };
121    let trimmed = value.trim();
122    if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
123        return Ok(dt.with_timezone(&Utc));
124    }
125    if let Some(dt) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d")
126        .ok()
127        .and_then(|d| d.and_hms_opt(0, 0, 0))
128        .and_then(|naive| Utc.from_local_datetime(&naive).single())
129    {
130        return Ok(dt);
131    }
132    Err(CompileError::ExpiryParse {
133        value: value.clone(),
134        message: "expected RFC-3339 timestamp or YYYY-MM-DD date".into(),
135    })
136}
137
138fn extract_authority(authority: Option<&AuthorityBlock>) -> Vec<String> {
139    let Some(block) = authority else {
140        return Vec::new();
141    };
142    let mut entries: Vec<String> = block.may.iter().map(|s| s.trim().to_string()).collect();
143    if let Some(actor) = block.actor.as_ref() {
144        let actor = actor.trim();
145        if !actor.is_empty() {
146            entries.insert(0, format!("actor: {actor}"));
147        }
148    }
149    entries
150}
151
152fn extract_forbidden(
153    authority: Option<&AuthorityBlock>,
154    constraint: Option<&ConstraintBlock>,
155) -> Vec<ForbiddenAction> {
156    let mut forbidden: Vec<ForbiddenAction> = Vec::new();
157    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
158
159    if let Some(auth) = authority {
160        for action in &auth.must_not {
161            let action = action.trim().to_string();
162            if !action.is_empty() && seen.insert(action.clone()) {
163                forbidden.push(ForbiddenAction {
164                    action,
165                    reason: "authority".into(),
166                });
167            }
168        }
169    }
170
171    if let Some(con) = constraint {
172        for action in &con.must_not {
173            let action = action.trim().to_string();
174            if !action.is_empty() && seen.insert(action.clone()) {
175                forbidden.push(ForbiddenAction {
176                    action,
177                    reason: "constraint".into(),
178                });
179            }
180        }
181    }
182
183    forbidden
184}
185
186fn extract_constraints(
187    authority: Option<&AuthorityBlock>,
188    constraint: Option<&ConstraintBlock>,
189) -> Vec<String> {
190    let mut entries: Vec<String> = Vec::new();
191    if let Some(con) = constraint {
192        entries.extend(con.budget.iter().map(|b| format!("budget: {}", b.trim())));
193        entries.extend(
194            con.cost_limit
195                .iter()
196                .map(|c| format!("cost_limit: {}", c.trim())),
197        );
198    }
199    if let Some(auth) = authority {
200        entries.extend(
201            auth.requires_approval
202                .iter()
203                .map(|a| format!("requires_approval: {}", a.trim())),
204        );
205    }
206    entries
207}
208
209fn extract_reversibility(constraints: &[String]) -> Reversibility {
210    for c in constraints {
211        let lower = c.to_lowercase();
212        if lower.contains("reversibility:") {
213            if lower.contains("irreversible") {
214                return Reversibility::Irreversible;
215            }
216            if lower.contains("partial") {
217                return Reversibility::Partial;
218            }
219        }
220    }
221    Reversibility::Reversible
222}
223
224fn extract_expiry_action(exception: Option<&ExceptionBlock>) -> ExpiryAction {
225    match exception {
226        Some(block) if !block.escalates_to.is_empty() || !block.requires.is_empty() => {
227            ExpiryAction::Escalate
228        }
229        _ => ExpiryAction::Halt,
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::truths::TruthGovernance;
237
238    fn document(
239        intent: Option<IntentBlock>,
240        authority: Option<AuthorityBlock>,
241        constraint: Option<ConstraintBlock>,
242        exception: Option<ExceptionBlock>,
243    ) -> TruthDocument {
244        TruthDocument {
245            gherkin: String::new(),
246            governance: TruthGovernance {
247                intent,
248                authority,
249                constraint,
250                evidence: None,
251                exception,
252            },
253        }
254    }
255
256    #[test]
257    fn missing_intent_block_rejected() {
258        let doc = document(None, None, None, None);
259        assert!(matches!(
260            compile_intent(&doc),
261            Err(CompileError::MissingOutcome)
262        ));
263    }
264
265    #[test]
266    fn intent_with_only_whitespace_outcome_rejected() {
267        let doc = document(
268            Some(IntentBlock {
269                outcome: Some("   ".into()),
270                goal: None,
271            }),
272            None,
273            None,
274            None,
275        );
276        assert!(matches!(
277            compile_intent(&doc),
278            Err(CompileError::MissingOutcome)
279        ));
280    }
281
282    #[test]
283    fn outcome_taken_from_intent_outcome_field() {
284        let doc = document(
285            Some(IntentBlock {
286                outcome: Some("qualify inbound leads".into()),
287                goal: None,
288            }),
289            None,
290            None,
291            None,
292        );
293        let packet = compile_intent(&doc).expect("compiles");
294        assert_eq!(packet.outcome, "qualify inbound leads");
295    }
296
297    #[test]
298    fn outcome_falls_back_to_goal() {
299        let doc = document(
300            Some(IntentBlock {
301                outcome: None,
302                goal: Some("qualify inbound leads".into()),
303            }),
304            None,
305            None,
306            None,
307        );
308        let packet = compile_intent(&doc).expect("compiles");
309        assert_eq!(packet.outcome, "qualify inbound leads");
310    }
311
312    #[test]
313    fn authority_actor_prefixes_authority_list() {
314        let doc = document(
315            Some(IntentBlock {
316                outcome: Some("ship".into()),
317                goal: None,
318            }),
319            Some(AuthorityBlock {
320                actor: Some("revops_team".into()),
321                may: vec!["approve_lead".into(), "request_demo".into()],
322                must_not: vec![],
323                requires_approval: vec![],
324                expires: None,
325            }),
326            None,
327            None,
328        );
329        let packet = compile_intent(&doc).expect("compiles");
330        assert_eq!(
331            packet.authority,
332            vec!["actor: revops_team", "approve_lead", "request_demo"]
333        );
334    }
335
336    #[test]
337    fn forbidden_collects_authority_and_constraint_must_not() {
338        let doc = document(
339            Some(IntentBlock {
340                outcome: Some("ship".into()),
341                goal: None,
342            }),
343            Some(AuthorityBlock {
344                actor: None,
345                may: vec![],
346                must_not: vec!["delete_account".into()],
347                requires_approval: vec![],
348                expires: None,
349            }),
350            Some(ConstraintBlock {
351                budget: vec![],
352                cost_limit: vec![],
353                must_not: vec!["spend_over_500".into()],
354            }),
355            None,
356        );
357        let packet = compile_intent(&doc).expect("compiles");
358        assert_eq!(packet.forbidden.len(), 2);
359        assert_eq!(packet.forbidden[0].action, "delete_account");
360        assert_eq!(packet.forbidden[0].reason, "authority");
361        assert_eq!(packet.forbidden[1].action, "spend_over_500");
362        assert_eq!(packet.forbidden[1].reason, "constraint");
363    }
364
365    #[test]
366    fn forbidden_deduplicates_same_action_across_blocks() {
367        let doc = document(
368            Some(IntentBlock {
369                outcome: Some("ship".into()),
370                goal: None,
371            }),
372            Some(AuthorityBlock {
373                actor: None,
374                may: vec![],
375                must_not: vec!["delete_account".into()],
376                requires_approval: vec![],
377                expires: None,
378            }),
379            Some(ConstraintBlock {
380                budget: vec![],
381                cost_limit: vec![],
382                must_not: vec!["delete_account".into()],
383            }),
384            None,
385        );
386        let packet = compile_intent(&doc).expect("compiles");
387        assert_eq!(packet.forbidden.len(), 1);
388        assert_eq!(packet.forbidden[0].reason, "authority");
389    }
390
391    #[test]
392    fn constraints_carry_budget_cost_and_approval_lines() {
393        let doc = document(
394            Some(IntentBlock {
395                outcome: Some("ship".into()),
396                goal: None,
397            }),
398            Some(AuthorityBlock {
399                actor: None,
400                may: vec![],
401                must_not: vec![],
402                requires_approval: vec!["spend_over_1000".into()],
403                expires: None,
404            }),
405            Some(ConstraintBlock {
406                budget: vec!["$500".into()],
407                cost_limit: vec!["$100/lead".into()],
408                must_not: vec![],
409            }),
410            None,
411        );
412        let packet = compile_intent(&doc).expect("compiles");
413        assert!(packet.constraints.contains(&"budget: $500".to_string()));
414        assert!(
415            packet
416                .constraints
417                .contains(&"cost_limit: $100/lead".to_string())
418        );
419        assert!(
420            packet
421                .constraints
422                .contains(&"requires_approval: spend_over_1000".to_string())
423        );
424    }
425
426    #[test]
427    fn expiry_parses_rfc3339() {
428        let doc = document(
429            Some(IntentBlock {
430                outcome: Some("ship".into()),
431                goal: None,
432            }),
433            Some(AuthorityBlock {
434                actor: None,
435                may: vec![],
436                must_not: vec![],
437                requires_approval: vec![],
438                expires: Some("2027-01-15T12:00:00Z".into()),
439            }),
440            None,
441            None,
442        );
443        let packet = compile_intent(&doc).expect("compiles");
444        assert_eq!(packet.expires.to_rfc3339(), "2027-01-15T12:00:00+00:00");
445    }
446
447    #[test]
448    fn expiry_parses_yyyy_mm_dd_as_midnight_utc() {
449        let doc = document(
450            Some(IntentBlock {
451                outcome: Some("ship".into()),
452                goal: None,
453            }),
454            Some(AuthorityBlock {
455                actor: None,
456                may: vec![],
457                must_not: vec![],
458                requires_approval: vec![],
459                expires: Some("2027-01-15".into()),
460            }),
461            None,
462            None,
463        );
464        let packet = compile_intent(&doc).expect("compiles");
465        assert_eq!(packet.expires.to_rfc3339(), "2027-01-15T00:00:00+00:00");
466    }
467
468    #[test]
469    fn malformed_expiry_rejected() {
470        let doc = document(
471            Some(IntentBlock {
472                outcome: Some("ship".into()),
473                goal: None,
474            }),
475            Some(AuthorityBlock {
476                actor: None,
477                may: vec![],
478                must_not: vec![],
479                requires_approval: vec![],
480                expires: Some("not-a-date".into()),
481            }),
482            None,
483            None,
484        );
485        assert!(matches!(
486            compile_intent(&doc),
487            Err(CompileError::ExpiryParse { .. })
488        ));
489    }
490
491    #[test]
492    fn missing_expiry_uses_default_window() {
493        let before = Utc::now();
494        let doc = document(
495            Some(IntentBlock {
496                outcome: Some("ship".into()),
497                goal: None,
498            }),
499            None,
500            None,
501            None,
502        );
503        let packet = compile_intent(&doc).expect("compiles");
504        let after = Utc::now();
505        let expected_min = before + Duration::hours(DEFAULT_EXPIRY_HOURS);
506        let expected_max = after + Duration::hours(DEFAULT_EXPIRY_HOURS);
507        assert!(packet.expires >= expected_min && packet.expires <= expected_max);
508    }
509
510    #[test]
511    fn reversibility_irreversible_when_constraint_says_so() {
512        let doc = document(
513            Some(IntentBlock {
514                outcome: Some("ship".into()),
515                goal: None,
516            }),
517            None,
518            Some(ConstraintBlock {
519                budget: vec!["reversibility: irreversible".into()],
520                cost_limit: vec![],
521                must_not: vec![],
522            }),
523            None,
524        );
525        let packet = compile_intent(&doc).expect("compiles");
526        assert_eq!(packet.reversibility, Reversibility::Irreversible);
527    }
528
529    #[test]
530    fn reversibility_defaults_to_reversible() {
531        let doc = document(
532            Some(IntentBlock {
533                outcome: Some("ship".into()),
534                goal: None,
535            }),
536            None,
537            None,
538            None,
539        );
540        let packet = compile_intent(&doc).expect("compiles");
541        assert_eq!(packet.reversibility, Reversibility::Reversible);
542    }
543
544    #[test]
545    fn exception_block_flips_expiry_action_to_escalate() {
546        let doc = document(
547            Some(IntentBlock {
548                outcome: Some("ship".into()),
549                goal: None,
550            }),
551            None,
552            None,
553            Some(ExceptionBlock {
554                escalates_to: vec!["legal".into()],
555                requires: vec![],
556            }),
557        );
558        let packet = compile_intent(&doc).expect("compiles");
559        assert_eq!(packet.expiry_action, ExpiryAction::Escalate);
560    }
561
562    #[test]
563    fn no_exception_block_keeps_default_halt() {
564        let doc = document(
565            Some(IntentBlock {
566                outcome: Some("ship".into()),
567                goal: None,
568            }),
569            None,
570            None,
571            None,
572        );
573        let packet = compile_intent(&doc).expect("compiles");
574        assert_eq!(packet.expiry_action, ExpiryAction::Halt);
575    }
576
577    #[test]
578    fn round_trip_from_real_truth_source() {
579        let source = r#"Truth: lead qualification
580
581  Intent:
582    Outcome: qualify inbound leads end-to-end
583    Goal: convert tier-1 leads within SLA
584
585  Authority:
586    Actor: revops_team
587    May: approve_qualified_lead
588    May: request_demo
589    Must Not: approve_unverified_lead
590    Requires Approval: approve_enterprise_lead
591    Expires: 2027-01-15T12:00:00Z
592
593  Constraint:
594    Budget: 500_USD/week
595    Cost Limit: 50_USD/lead
596
597  Exception:
598    Escalates To: sales_director
599
600  @invariant @acceptance
601  Scenario: a basic lead arrives
602    Given a lead from "acme.com"
603    When the lead is qualified
604    Then the lead is marked as approved
605"#;
606        let packet = compile_intent_from_source(source).expect("source parses + compiles");
607        assert_eq!(packet.outcome, "qualify inbound leads end-to-end");
608        assert!(packet.authority.iter().any(|a| a.contains("revops_team")));
609        assert!(
610            packet
611                .authority
612                .iter()
613                .any(|a| a == "approve_qualified_lead")
614        );
615        assert!(
616            packet
617                .forbidden
618                .iter()
619                .any(|f| f.action == "approve_unverified_lead" && f.reason == "authority")
620        );
621        assert!(packet.constraints.iter().any(|c| c.starts_with("budget: ")));
622        assert_eq!(packet.expiry_action, ExpiryAction::Escalate);
623    }
624}