1use 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#[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#[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 pub const SIGNATURE_DOMAIN: &'static [u8] = b"lean-ctx:decision-record:v1\0";
89
90 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 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 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}