Skip to main content

lean_ctx_protocol/
decision.rs

1//! Decision audit contract.
2
3use crate::common::{
4    DecisionId, ExtensionsV1, PlanId, TaskId, ValidationError, deserialize_schema_version,
5    validate_bounded_string, validate_schema_version, validate_unique_strings,
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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum DecisionStageV1 {
29    Admission,
30    Planning,
31    Execution,
32    Outcome,
33}
34
35/// Signed, evidence-linked record of one deterministic decision.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct DecisionRecordV1 {
38    #[serde(deserialize_with = "deserialize_schema_version")]
39    pub schema_version: u32,
40    pub decision_id: DecisionId,
41    pub task_id: TaskId,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub plan_id: Option<PlanId>,
44    pub decision_stage: DecisionStageV1,
45    pub decision_kind: DecisionKind,
46    pub input_refs: Vec<String>,
47    pub constraint_refs: Vec<String>,
48    pub selected_result: Value,
49    pub rationale_code: String,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub rationale_ref: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub policy_ref: Option<String>,
54    pub decision_system_name: String,
55    pub decision_system_version: String,
56    pub evidence_refs: Vec<EvidenceRefV1>,
57    pub observed_at: String,
58    pub signature: String,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub supersedes: Option<DecisionId>,
61    #[serde(default, flatten)]
62    pub extensions: ExtensionsV1,
63}
64
65const DECISION_RESERVED_FIELDS: &[&str] = &[
66    "schema_version",
67    "decision_id",
68    "task_id",
69    "plan_id",
70    "decision_stage",
71    "decision_kind",
72    "input_refs",
73    "constraint_refs",
74    "selected_result",
75    "rationale_code",
76    "rationale_ref",
77    "policy_ref",
78    "decision_system_name",
79    "decision_system_version",
80    "evidence_refs",
81    "observed_at",
82    "signature",
83    "supersedes",
84];
85
86impl DecisionRecordV1 {
87    /// Purpose/version separation for the Ed25519 decision-signature profile.
88    pub const SIGNATURE_DOMAIN: &'static [u8] = b"lean-ctx:decision-record:v1\0";
89
90    /// Compact UTF-8 JSON with recursively sorted object keys, omitting only
91    /// `signature`. This is serialization, not signature or authority validation.
92    /// Unknown extension fields remain covered by the signature payload.
93    pub fn unsigned_canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
94        self.validate()?;
95        let mut value = serde_json::to_value(self)
96            .map_err(|error| ValidationError::new(format!("serialize decision record: {error}")))?;
97        value
98            .as_object_mut()
99            .ok_or_else(|| ValidationError::new("decision record must serialize as an object"))?
100            .remove("signature");
101        serde_json::to_vec(&crate::receipt_document::sort_json(value))
102            .map_err(|error| ValidationError::new(format!("canonicalize decision record: {error}")))
103    }
104
105    /// Versioned domain prefix followed by the unsigned canonical document.
106    /// No key discovery, signing, verification, policy or I/O occurs here.
107    pub fn signing_bytes(&self) -> Result<Vec<u8>, ValidationError> {
108        let unsigned = self.unsigned_canonical_bytes()?;
109        let mut bytes = Vec::with_capacity(Self::SIGNATURE_DOMAIN.len() + unsigned.len());
110        bytes.extend_from_slice(Self::SIGNATURE_DOMAIN);
111        bytes.extend_from_slice(&unsigned);
112        Ok(bytes)
113    }
114
115    /// Validate schema invariants for a decision record.
116    pub fn validate(&self) -> Result<(), ValidationError> {
117        self.extensions
118            .validate_reserved(DECISION_RESERVED_FIELDS)?;
119        validate_schema_version(self.schema_version)?;
120        validate_unique_strings(&self.input_refs, "input_refs")?;
121        validate_unique_strings(&self.constraint_refs, "constraint_refs")?;
122        validate_bounded_string(&self.rationale_code, "rationale_code")?;
123        validate_bounded_string(&self.decision_system_name, "decision_system_name")?;
124        validate_bounded_string(&self.decision_system_version, "decision_system_version")?;
125        if self.evidence_refs.len() > crate::MAX_PROTOCOL_ITEMS {
126            return Err(ValidationError::new("evidence_refs exceeds item limit"));
127        }
128        for (value, field) in [
129            (&self.rationale_ref, "rationale_ref"),
130            (&self.policy_ref, "policy_ref"),
131        ] {
132            if let Some(value) = value {
133                validate_bounded_string(value, field)?;
134            }
135        }
136        if self.decision_stage != DecisionStageV1::Admission && self.plan_id.is_none() {
137            return Err(ValidationError::new(
138                "post-admission decision requires plan_id",
139            ));
140        }
141        for evidence in &self.evidence_refs {
142            evidence.validate()?;
143        }
144        Ok(())
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn id<T>(value: &str) -> T
153    where
154        T: TryFrom<String>,
155        <T as TryFrom<String>>::Error: std::fmt::Debug,
156    {
157        T::try_from(value.to_owned()).expect("identifier should be valid")
158    }
159
160    fn decision() -> DecisionRecordV1 {
161        DecisionRecordV1 {
162            schema_version: 1,
163            decision_id: id("decision-1"),
164            task_id: id("task-1"),
165            plan_id: Some(id("plan-1")),
166            decision_stage: DecisionStageV1::Planning,
167            decision_kind: DecisionKind::Routing,
168            input_refs: vec!["input:task".to_owned()],
169            constraint_refs: vec!["constraint:budget".to_owned()],
170            selected_result: serde_json::json!({ "model": "model-1" }),
171            rationale_code: "lowest_cost_within_slo".to_owned(),
172            rationale_ref: Some("rationale:1".to_owned()),
173            policy_ref: Some("policy:model".to_owned()),
174            decision_system_name: "scheduler".to_owned(),
175            decision_system_version: "1.0.0".to_owned(),
176            evidence_refs: vec![],
177            observed_at: "2026-08-09T12:00:00Z".to_owned(),
178            signature: "signature".to_owned(),
179            supersedes: None,
180            extensions: Default::default(),
181        }
182    }
183
184    #[test]
185    fn serialization_round_trip() {
186        let decision = decision();
187        let json = serde_json::to_string(&decision).expect("decision should serialize");
188        let decoded: DecisionRecordV1 =
189            serde_json::from_str(&json).expect("decision should deserialize");
190        assert_eq!(decision, decoded);
191        decision
192            .validate()
193            .expect("decision should satisfy invariants");
194    }
195
196    #[test]
197    fn signing_payload_is_domain_separated_and_omits_only_signature() {
198        let mut record = decision();
199        let unsigned = record.unsigned_canonical_bytes().unwrap();
200        let payload = record.signing_bytes().unwrap();
201        assert_eq!(
202            payload,
203            [DecisionRecordV1::SIGNATURE_DOMAIN, &unsigned].concat()
204        );
205        let value: Value = serde_json::from_slice(&unsigned).unwrap();
206        assert!(value.get("signature").is_none());
207        let mut expected = serde_json::to_value(&record).unwrap();
208        expected.as_object_mut().unwrap().remove("signature");
209        assert_eq!(value, expected);
210        record.signature = "a different signature".into();
211        assert_eq!(record.signing_bytes().unwrap(), payload);
212        record.selected_result = serde_json::json!({"model":"different"});
213        assert_ne!(record.signing_bytes().unwrap(), payload);
214    }
215
216    #[test]
217    fn signing_payload_covers_extensions_and_recursively_sorts_objects() {
218        let mut record = decision();
219        record.selected_result = serde_json::from_str(r#"{"z":{"b":2,"a":1},"a":0}"#).unwrap();
220        let before = record.signing_bytes().unwrap();
221        record.selected_result = serde_json::from_str(r#"{"a":0,"z":{"a":1,"b":2}}"#).unwrap();
222        assert_eq!(before, record.signing_bytes().unwrap());
223        let encoded = String::from_utf8(record.unsigned_canonical_bytes().unwrap()).unwrap();
224        assert!(encoded.contains(r#""selected_result":{"a":0,"z":{"a":1,"b":2}}"#));
225        record
226            .extensions
227            .insert("audit_extra", serde_json::json!({"x":1}))
228            .unwrap();
229        assert_ne!(before, record.signing_bytes().unwrap());
230    }
231}