Skip to main content

lean_ctx_protocol/
decision.rs

1//! Decision audit contract.
2
3use crate::common::{
4    DecisionId, PlanId, TaskId, ValidationError, deserialize_schema_version,
5    validate_schema_version,
6};
7use crate::evidence::EvidenceRefV1;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11/// Kind of policy, routing, or execution decision being recorded.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum DecisionKind {
15    Routing,
16    Scheduling,
17    CapabilitySelection,
18    ContextSelection,
19    Policy,
20    Fallback,
21    Retry,
22    Stop,
23    Other,
24}
25
26/// Signed, evidence-linked record of one deterministic decision.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct DecisionRecordV1 {
30    #[serde(deserialize_with = "deserialize_schema_version")]
31    pub schema_version: u32,
32    pub decision_id: DecisionId,
33    pub task_id: TaskId,
34    pub plan_id: PlanId,
35    pub decision_kind: DecisionKind,
36    pub input_refs: Vec<String>,
37    pub constraint_refs: Vec<String>,
38    pub selected_result: Value,
39    pub rationale_code: String,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub rationale_ref: Option<String>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub policy_ref: Option<String>,
44    pub decision_system_name: String,
45    pub decision_system_version: String,
46    pub evidence_refs: Vec<EvidenceRefV1>,
47    pub observed_at: String,
48    pub signature: String,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub supersedes: Option<DecisionId>,
51}
52
53impl DecisionRecordV1 {
54    /// Validate schema invariants for a decision record.
55    pub fn validate(&self) -> Result<(), ValidationError> {
56        validate_schema_version(self.schema_version)
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    fn id<T>(value: &str) -> T
65    where
66        T: TryFrom<String>,
67        <T as TryFrom<String>>::Error: std::fmt::Debug,
68    {
69        T::try_from(value.to_owned()).expect("identifier should be valid")
70    }
71
72    #[test]
73    fn serialization_round_trip() {
74        let decision = DecisionRecordV1 {
75            schema_version: 1,
76            decision_id: id("decision-1"),
77            task_id: id("task-1"),
78            plan_id: id("plan-1"),
79            decision_kind: DecisionKind::Routing,
80            input_refs: vec!["input:task".to_owned()],
81            constraint_refs: vec!["constraint:budget".to_owned()],
82            selected_result: serde_json::json!({ "model": "model-1" }),
83            rationale_code: "lowest_cost_within_slo".to_owned(),
84            rationale_ref: Some("rationale:1".to_owned()),
85            policy_ref: Some("policy:model".to_owned()),
86            decision_system_name: "scheduler".to_owned(),
87            decision_system_version: "1.0.0".to_owned(),
88            evidence_refs: vec![],
89            observed_at: "2026-08-09T12:00:00Z".to_owned(),
90            signature: "signature".to_owned(),
91            supersedes: None,
92        };
93        let json = serde_json::to_string(&decision).expect("decision should serialize");
94        let decoded: DecisionRecordV1 =
95            serde_json::from_str(&json).expect("decision should deserialize");
96        assert_eq!(decision, decoded);
97        decision
98            .validate()
99            .expect("decision should satisfy invariants");
100    }
101}