Skip to main content

lean_ctx_protocol/
execution.rs

1//! Planning and execution receipt contracts.
2
3use crate::common::{
4    AgentId, AttemptId, CapabilityId, ContextPlanId, ExtensionsV1, PlanId, ReceiptId, TaskId,
5    ValidationError, deserialize_milliunit, deserialize_schema_version, validate_bounded_string,
6    validate_milliunit, validate_schema_version, validate_unique_strings,
7};
8use crate::evidence::EvidenceRefV1;
9use serde::{Deserialize, Serialize};
10
11/// Strategy used to assemble context for a plan.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum ContextStrategy {
15    Minimal,
16    Balanced,
17    Comprehensive,
18    CachedFirst,
19}
20
21/// Terminal condition selected by an execution plan.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum StopCondition {
25    OnCompletion,
26    OnAcceptance,
27    OnBudgetExhaustion,
28    OnError,
29    Manual,
30}
31
32/// Explicit token policy; no token limit does not disable other resource limits.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
35pub enum ContextBudgetPolicyV1 {
36    TokenLimit { tokens: u64 },
37    NoTokenLimit {},
38}
39
40/// Optional estimates are unknown when null, never measured values or targets.
41/// When present, this additive projection is authoritative over legacy scalars.
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct ExecutionPlanEstimatesV1 {
45    pub cost_micros: Option<u64>,
46    pub quality_milli: Option<u16>,
47    pub latency_ms: Option<u64>,
48}
49
50/// V1 execution plan produced from a task envelope.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct ExecutionPlanV1 {
53    #[serde(deserialize_with = "deserialize_schema_version")]
54    pub schema_version: u32,
55    pub plan_id: PlanId,
56    pub task_id: TaskId,
57    pub context_budget_tokens: u64,
58    /// Absent retains legacy scalar semantics; explicit no-limit uses scalar zero.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub context_budget_policy: Option<ContextBudgetPolicyV1>,
61    pub context_strategy: ContextStrategy,
62    pub knowledge_refs: Vec<String>,
63    pub capability_ids: Vec<CapabilityId>,
64    pub model: String,
65    pub provider: String,
66    #[serde(deserialize_with = "deserialize_milliunit")]
67    pub reasoning_allocation_milli: u16,
68    pub max_retries: u32,
69    pub fallback_refs: Vec<String>,
70    pub stop_condition: StopCondition,
71    pub expected_cost_micros: u64,
72    #[serde(deserialize_with = "deserialize_milliunit")]
73    pub expected_quality_milli: u16,
74    pub expected_latency_ms: u64,
75    /// Unknown entries require zero only in the legacy compatibility scalars.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub estimates: Option<ExecutionPlanEstimatesV1>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub policy_decision_ref: Option<String>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub scheduler_decision_ref: Option<String>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub executor_agent_id: Option<AgentId>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub context_plan_id: Option<ContextPlanId>,
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub capability_bindings: Vec<CapabilityBindingV1>,
88    /// Unknown additive V1 fields retained for lossless forwarding.
89    #[serde(default, flatten)]
90    pub extensions: ExtensionsV1,
91}
92
93const EXECUTION_PLAN_RESERVED_FIELDS: &[&str] = &[
94    "schema_version",
95    "plan_id",
96    "task_id",
97    "context_budget_tokens",
98    "context_budget_policy",
99    "context_strategy",
100    "knowledge_refs",
101    "capability_ids",
102    "model",
103    "provider",
104    "reasoning_allocation_milli",
105    "max_retries",
106    "fallback_refs",
107    "stop_condition",
108    "expected_cost_micros",
109    "expected_quality_milli",
110    "expected_latency_ms",
111    "estimates",
112    "policy_decision_ref",
113    "scheduler_decision_ref",
114    "executor_agent_id",
115    "context_plan_id",
116    "capability_bindings",
117];
118
119impl ExecutionPlanV1 {
120    /// Resolve the token policy after validating the plan's dual projections.
121    pub fn context_token_limit(&self) -> Option<u64> {
122        match self.context_budget_policy {
123            Some(ContextBudgetPolicyV1::NoTokenLimit {}) => None,
124            _ => Some(self.context_budget_tokens),
125        }
126    }
127
128    /// Validate invariants that also apply to values constructed in Rust.
129    pub fn validate(&self) -> Result<(), ValidationError> {
130        self.extensions
131            .validate_reserved(EXECUTION_PLAN_RESERVED_FIELDS)?;
132        validate_schema_version(self.schema_version)?;
133        let projected_budget = match self.context_budget_policy {
134            Some(ContextBudgetPolicyV1::TokenLimit { tokens }) => tokens,
135            Some(ContextBudgetPolicyV1::NoTokenLimit {}) => 0,
136            None => self.context_budget_tokens,
137        };
138        if projected_budget != self.context_budget_tokens {
139            return Err(ValidationError::new(
140                "context budget policy disagrees with legacy scalar",
141            ));
142        }
143        if let Some(estimates) = &self.estimates {
144            if let Some(quality) = estimates.quality_milli {
145                validate_milliunit(quality, "estimated quality_milli")?;
146            }
147            if estimates.cost_micros.unwrap_or(0) != self.expected_cost_micros
148                || estimates.quality_milli.unwrap_or(0) != self.expected_quality_milli
149                || estimates.latency_ms.unwrap_or(0) != self.expected_latency_ms
150            {
151                return Err(ValidationError::new(
152                    "plan estimates disagree with legacy scalars",
153                ));
154            }
155        }
156        validate_milliunit(
157            self.reasoning_allocation_milli,
158            "reasoning_allocation_milli",
159        )?;
160        validate_milliunit(self.expected_quality_milli, "expected_quality_milli")?;
161        validate_bounded_string(&self.model, "model")?;
162        validate_bounded_string(&self.provider, "provider")?;
163        validate_unique_strings(&self.knowledge_refs, "knowledge_refs")?;
164        validate_unique_strings(&self.capability_ids, "capability_ids")?;
165        if self.capability_ids.is_empty() {
166            return Err(ValidationError::new(
167                "execution plan requires at least one capability_id",
168            ));
169        }
170        validate_unique_strings(&self.fallback_refs, "fallback_refs")?;
171        for (value, field) in [
172            (&self.policy_decision_ref, "policy_decision_ref"),
173            (&self.scheduler_decision_ref, "scheduler_decision_ref"),
174        ] {
175            if let Some(value) = value {
176                validate_bounded_string(value, field)?;
177            }
178        }
179        validate_capability_bindings(&self.capability_bindings, &self.capability_ids)?;
180        Ok(())
181    }
182}
183
184/// Four-stage token balance carried by an execution receipt.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct ContextBalanceV1 {
188    pub original_tokens: u64,
189    pub materialized_tokens: u64,
190    pub delivered_tokens: u64,
191    pub provider_billed_tokens: u64,
192}
193
194/// Version-pinned capability selected by an execution plan.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct CapabilityBindingV1 {
198    pub capability_id: CapabilityId,
199    pub version: String,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub manifest_digest: Option<String>,
202}
203
204/// Explicit observations; absence means unavailable rather than factual zero.
205#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct ExecutionObservationsV1 {
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub model_calls: Option<u32>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub retries: Option<u32>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub latency_ms: Option<u64>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub actual_cost_micros: Option<u64>,
216}
217
218impl ContextBalanceV1 {
219    /// Validate monotonic context accounting across the four stages.
220    pub fn validate(&self) -> Result<(), ValidationError> {
221        if self.materialized_tokens > self.original_tokens {
222            return Err(ValidationError::new(
223                "materialized_tokens exceeds original_tokens",
224            ));
225        }
226        if self.delivered_tokens > self.materialized_tokens {
227            return Err(ValidationError::new(
228                "delivered_tokens exceeds materialized_tokens",
229            ));
230        }
231        Ok(())
232    }
233}
234
235/// Auditable result of executing an execution plan.
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct ExecutionReceiptV1 {
238    #[serde(deserialize_with = "deserialize_schema_version")]
239    pub schema_version: u32,
240    pub receipt_id: ReceiptId,
241    pub task_id: TaskId,
242    pub plan_id: PlanId,
243    pub context_balance: ContextBalanceV1,
244    pub fresh_input_tokens: u64,
245    pub cached_input_tokens: u64,
246    pub output_tokens: u64,
247    pub reasoning_tokens: u64,
248    pub requested_model: String,
249    pub selected_model: String,
250    pub provider: String,
251    /// Capability that produced this receipt, when the producer is known.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub capability_id: Option<String>,
254    /// Version of the capability that produced this receipt.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub capability_version: Option<String>,
257    pub model_calls: u32,
258    pub retries: u32,
259    pub latency_ms: u64,
260    pub actual_cost_micros: u64,
261    pub baseline_cost_micros: u64,
262    pub avoided_cost_micros: u64,
263    pub etpao_milli: u64,
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub outcome_ref: Option<String>,
266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
267    pub knowledge_refs: Vec<String>,
268    pub decision_refs: Vec<String>,
269    pub evidence_refs: Vec<EvidenceRefV1>,
270    pub signature: String,
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub executor_agent_id: Option<AgentId>,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub attempt_id: Option<AttemptId>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub context_plan_id: Option<ContextPlanId>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub context_receipt_ref: Option<String>,
279    #[serde(default, skip_serializing_if = "Vec::is_empty")]
280    pub capability_bindings: Vec<CapabilityBindingV1>,
281    #[serde(default, skip_serializing_if = "ExecutionObservationsV1::is_empty")]
282    pub observations: ExecutionObservationsV1,
283    /// Unknown additive V1 fields retained for lossless forwarding.
284    #[serde(default, flatten)]
285    pub extensions: ExtensionsV1,
286}
287
288const EXECUTION_RECEIPT_RESERVED_FIELDS: &[&str] = &[
289    "schema_version",
290    "receipt_id",
291    "task_id",
292    "plan_id",
293    "context_balance",
294    "fresh_input_tokens",
295    "cached_input_tokens",
296    "output_tokens",
297    "reasoning_tokens",
298    "requested_model",
299    "selected_model",
300    "provider",
301    "capability_id",
302    "capability_version",
303    "model_calls",
304    "retries",
305    "latency_ms",
306    "actual_cost_micros",
307    "baseline_cost_micros",
308    "avoided_cost_micros",
309    "etpao_milli",
310    "outcome_ref",
311    "knowledge_refs",
312    "decision_refs",
313    "evidence_refs",
314    "signature",
315    "executor_agent_id",
316    "attempt_id",
317    "context_plan_id",
318    "context_receipt_ref",
319    "capability_bindings",
320    "observations",
321    // Reserved ingress-only field from the canonical receipt envelope. It must
322    // never be silently accepted as a V1 extension by validation.
323    "canonical_receipt",
324];
325
326impl ExecutionReceiptV1 {
327    /// Validate receipt accounting and schema invariants.
328    pub fn validate(&self) -> Result<(), ValidationError> {
329        self.extensions
330            .validate_reserved(EXECUTION_RECEIPT_RESERVED_FIELDS)?;
331        validate_schema_version(self.schema_version)?;
332        self.context_balance.validate()?;
333        if self.avoided_cost_micros > self.baseline_cost_micros {
334            return Err(ValidationError::new(
335                "avoided_cost_micros exceeds baseline_cost_micros",
336            ));
337        }
338        validate_bounded_string(&self.requested_model, "requested_model")?;
339        validate_bounded_string(&self.selected_model, "selected_model")?;
340        validate_bounded_string(&self.provider, "provider")?;
341        validate_unique_strings(&self.knowledge_refs, "knowledge_refs")?;
342        validate_unique_strings(&self.decision_refs, "decision_refs")?;
343        validate_capability_bindings(&self.capability_bindings, &[])?;
344        if self.evidence_refs.len() > crate::MAX_PROTOCOL_ITEMS {
345            return Err(ValidationError::new("evidence_refs exceeds item limit"));
346        }
347        if self.capability_id.is_some() != self.capability_version.is_some() {
348            return Err(ValidationError::new(
349                "legacy capability_id and capability_version must appear together",
350            ));
351        }
352        for (value, field) in [
353            (&self.capability_id, "capability_id"),
354            (&self.capability_version, "capability_version"),
355            (&self.outcome_ref, "outcome_ref"),
356            (&self.context_receipt_ref, "context_receipt_ref"),
357        ] {
358            if let Some(value) = value {
359                validate_bounded_string(value, field)?;
360            }
361        }
362        validate_observation(
363            self.observations.model_calls,
364            self.model_calls,
365            "model_calls",
366        )?;
367        validate_observation(self.observations.retries, self.retries, "retries")?;
368        validate_observation(self.observations.latency_ms, self.latency_ms, "latency_ms")?;
369        validate_observation(
370            self.observations.actual_cost_micros,
371            self.actual_cost_micros,
372            "actual_cost_micros",
373        )?;
374        for evidence in &self.evidence_refs {
375            evidence.validate()?;
376        }
377        Ok(())
378    }
379}
380
381impl ExecutionObservationsV1 {
382    pub fn is_empty(&self) -> bool {
383        self.model_calls.is_none()
384            && self.retries.is_none()
385            && self.latency_ms.is_none()
386            && self.actual_cost_micros.is_none()
387    }
388}
389
390fn validate_capability_bindings(
391    bindings: &[CapabilityBindingV1],
392    selected: &[CapabilityId],
393) -> Result<(), ValidationError> {
394    if bindings.len() > crate::common::MAX_PROTOCOL_ITEMS {
395        return Err(ValidationError::new(
396            "capability_bindings exceeds item limit",
397        ));
398    }
399    let mut seen = std::collections::BTreeSet::new();
400    for binding in bindings {
401        validate_bounded_string(&binding.version, "capability version")?;
402        if let Some(digest) = &binding.manifest_digest {
403            validate_bounded_string(digest, "capability manifest digest")?;
404            let digest = digest
405                .strip_prefix("sha256:")
406                .or_else(|| digest.strip_prefix("blake3:"))
407                .unwrap_or(digest);
408            if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
409                return Err(ValidationError::new(
410                    "capability manifest digest must contain a supported 64-digit hexadecimal digest",
411                ));
412            }
413        }
414        if !seen.insert(binding.capability_id.as_str()) {
415            return Err(ValidationError::new("duplicate capability binding"));
416        }
417        if !selected.is_empty() && !selected.contains(&binding.capability_id) {
418            return Err(ValidationError::new(
419                "capability binding is not selected by plan",
420            ));
421        }
422    }
423    Ok(())
424}
425
426fn validate_observation<T: PartialEq>(
427    observation: Option<T>,
428    legacy_value: T,
429    field: &str,
430) -> Result<(), ValidationError> {
431    if observation
432        .as_ref()
433        .is_some_and(|value| value != &legacy_value)
434    {
435        return Err(ValidationError::new(format!(
436            "observations.{field} contradicts legacy {field}"
437        )));
438    }
439    Ok(())
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    fn id<T>(value: &str) -> T
447    where
448        T: TryFrom<String>,
449        <T as TryFrom<String>>::Error: std::fmt::Debug,
450    {
451        T::try_from(value.to_owned()).expect("identifier should be valid")
452    }
453
454    fn balance() -> ContextBalanceV1 {
455        ContextBalanceV1 {
456            original_tokens: 1_000,
457            materialized_tokens: 800,
458            delivered_tokens: 700,
459            provider_billed_tokens: 650,
460        }
461    }
462
463    fn plan_fixture() -> ExecutionPlanV1 {
464        ExecutionPlanV1 {
465            schema_version: 1,
466            plan_id: id("plan-1"),
467            task_id: id("task-1"),
468            context_budget_tokens: 10_000,
469            context_budget_policy: None,
470            context_strategy: ContextStrategy::Balanced,
471            knowledge_refs: vec!["knowledge:1".to_owned()],
472            capability_ids: vec![id("capability:search")],
473            model: "model-1".to_owned(),
474            provider: "provider-1".to_owned(),
475            reasoning_allocation_milli: 500,
476            max_retries: 2,
477            fallback_refs: vec!["model:fallback".to_owned()],
478            stop_condition: StopCondition::OnAcceptance,
479            expected_cost_micros: 5_000,
480            estimates: None,
481            expected_quality_milli: 850,
482            expected_latency_ms: 1_500,
483            policy_decision_ref: Some("decision:policy".to_owned()),
484            scheduler_decision_ref: Some("decision:scheduler".to_owned()),
485            executor_agent_id: Some(id("agent-1")),
486            context_plan_id: Some(id("context-plan-1")),
487            capability_bindings: vec![CapabilityBindingV1 {
488                capability_id: id("capability:search"),
489                version: "1.0.0".to_owned(),
490                manifest_digest: None,
491            }],
492            extensions: Default::default(),
493        }
494    }
495
496    #[test]
497    fn plan_serialization_round_trip() {
498        let plan = plan_fixture();
499        let json = serde_json::to_string(&plan).expect("plan should serialize");
500        assert!(!json.contains("context_budget_policy"));
501        assert!(!json.contains("estimates"));
502        let decoded: ExecutionPlanV1 =
503            serde_json::from_str(&json).expect("plan should deserialize");
504        assert_eq!(plan, decoded);
505        plan.validate().expect("plan should satisfy invariants");
506        let mut without_capability = plan.clone();
507        without_capability.capability_ids.clear();
508        assert!(without_capability.validate().is_err());
509    }
510
511    #[test]
512    fn explicit_budget_policy_preserves_legacy_scalar_and_unlimited_distinction() {
513        let mut plan = plan_fixture();
514        assert_eq!(plan.context_token_limit(), Some(10_000));
515        plan.context_budget_policy = Some(ContextBudgetPolicyV1::TokenLimit { tokens: 10_000 });
516        plan.validate().unwrap();
517        plan.context_budget_tokens = 0;
518        assert!(plan.validate().is_err());
519        plan.context_budget_policy = Some(ContextBudgetPolicyV1::NoTokenLimit {});
520        plan.validate().unwrap();
521        assert_eq!(plan.context_token_limit(), None);
522        let decoded: ExecutionPlanV1 =
523            serde_json::from_value(serde_json::to_value(&plan).unwrap()).unwrap();
524        assert_eq!(decoded, plan);
525        plan.context_budget_tokens = 1;
526        assert!(plan.validate().is_err());
527        plan.context_budget_policy = None;
528        plan.context_budget_tokens = 0;
529        assert_eq!(plan.context_token_limit(), Some(0));
530    }
531
532    #[test]
533    fn explicit_estimates_distinguish_unknown_from_known_zero() {
534        let mut plan = plan_fixture();
535        plan.estimates = Some(ExecutionPlanEstimatesV1::default());
536        assert!(plan.validate().is_err());
537        plan.expected_cost_micros = 0;
538        plan.expected_quality_milli = 0;
539        plan.expected_latency_ms = 0;
540        plan.validate().unwrap();
541        let unknown = serde_json::to_value(&plan).unwrap();
542        assert!(unknown["estimates"]["cost_micros"].is_null());
543        plan.estimates.as_mut().unwrap().cost_micros = Some(0);
544        plan.validate().unwrap();
545        assert_eq!(
546            serde_json::to_value(&plan).unwrap()["estimates"]["cost_micros"],
547            0
548        );
549        plan.estimates.as_mut().unwrap().quality_milli = Some(1001);
550        plan.expected_quality_milli = 1001;
551        assert!(plan.validate().is_err());
552        assert!(
553            serde_json::from_value::<ExecutionPlanEstimatesV1>(serde_json::json!({"invented":0}))
554                .is_err()
555        );
556        assert!(
557            serde_json::from_value::<ContextBudgetPolicyV1>(
558                serde_json::json!({"kind":"no_token_limit","tokens":0})
559            )
560            .is_err()
561        );
562    }
563
564    #[test]
565    fn receipt_serialization_round_trip() {
566        let receipt = ExecutionReceiptV1 {
567            schema_version: 1,
568            receipt_id: id("receipt-1"),
569            task_id: id("task-1"),
570            plan_id: id("plan-1"),
571            context_balance: balance(),
572            fresh_input_tokens: 600,
573            cached_input_tokens: 100,
574            output_tokens: 200,
575            reasoning_tokens: 50,
576            requested_model: "model-requested".to_owned(),
577            selected_model: "model-selected".to_owned(),
578            provider: "provider-1".to_owned(),
579            capability_id: Some("capability://leanctx/context".to_owned()),
580            capability_version: Some("1.0.0".to_owned()),
581            model_calls: 2,
582            retries: 1,
583            latency_ms: 900,
584            actual_cost_micros: 2_000,
585            baseline_cost_micros: 3_000,
586            avoided_cost_micros: 1_000,
587            etpao_milli: 1_250,
588            outcome_ref: Some("outcome:1".to_owned()),
589            knowledge_refs: vec!["knowledge:1".to_owned()],
590            decision_refs: vec!["decision:1".to_owned()],
591            evidence_refs: vec![],
592            signature: "signature".to_owned(),
593            executor_agent_id: Some(id("agent-1")),
594            attempt_id: Some(id("attempt-1")),
595            context_plan_id: Some(id("context-plan-1")),
596            context_receipt_ref: Some("context-receipt-1".to_owned()),
597            capability_bindings: vec![CapabilityBindingV1 {
598                capability_id: id("capability:search"),
599                version: "1.0.0".to_owned(),
600                manifest_digest: None,
601            }],
602            observations: ExecutionObservationsV1 {
603                model_calls: Some(2),
604                retries: Some(1),
605                latency_ms: Some(900),
606                actual_cost_micros: Some(2_000),
607            },
608            extensions: Default::default(),
609        };
610        let json = serde_json::to_string(&receipt).expect("receipt should serialize");
611        let decoded: ExecutionReceiptV1 =
612            serde_json::from_str(&json).expect("receipt should deserialize");
613        assert_eq!(receipt, decoded);
614        receipt
615            .validate()
616            .expect("receipt should satisfy invariants");
617    }
618
619    #[test]
620    fn receipt_capability_metadata_is_optional_and_backward_compatible() {
621        let receipt = ExecutionReceiptV1 {
622            schema_version: 1,
623            receipt_id: id("receipt-1"),
624            task_id: id("task-1"),
625            plan_id: id("plan-1"),
626            context_balance: balance(),
627            fresh_input_tokens: 600,
628            cached_input_tokens: 100,
629            output_tokens: 200,
630            reasoning_tokens: 50,
631            requested_model: "model-requested".to_owned(),
632            selected_model: "model-selected".to_owned(),
633            provider: "provider-1".to_owned(),
634            capability_id: Some("capability://leanctx/context".to_owned()),
635            capability_version: Some("1.0.0".to_owned()),
636            model_calls: 2,
637            retries: 1,
638            latency_ms: 900,
639            actual_cost_micros: 2_000,
640            baseline_cost_micros: 3_000,
641            avoided_cost_micros: 1_000,
642            etpao_milli: 1_250,
643            outcome_ref: Some("outcome:1".to_owned()),
644            knowledge_refs: vec!["knowledge:1".to_owned()],
645            decision_refs: vec!["decision:1".to_owned()],
646            evidence_refs: vec![],
647            signature: "signature".to_owned(),
648            executor_agent_id: None,
649            attempt_id: None,
650            context_plan_id: None,
651            context_receipt_ref: None,
652            capability_bindings: vec![],
653            observations: ExecutionObservationsV1::default(),
654            extensions: Default::default(),
655        };
656        let json = serde_json::to_value(&receipt).expect("receipt should serialize");
657        assert_eq!(json["capability_id"], "capability://leanctx/context");
658        assert_eq!(
659            serde_json::from_value::<ExecutionReceiptV1>(json.clone())
660                .expect("receipt with capability metadata should deserialize"),
661            receipt
662        );
663
664        let mut legacy = json;
665        let object = legacy
666            .as_object_mut()
667            .expect("serialized receipt should be an object");
668        object.remove("capability_id");
669        object.remove("capability_version");
670
671        let decoded: ExecutionReceiptV1 =
672            serde_json::from_value(legacy).expect("legacy receipt should deserialize");
673        assert_eq!(decoded.capability_id, None);
674        assert_eq!(decoded.capability_version, None);
675
676        let without_capability = serde_json::to_value(decoded)
677            .expect("receipt without capability metadata should serialize");
678        assert!(without_capability.get("capability_id").is_none());
679        assert!(without_capability.get("capability_version").is_none());
680    }
681}