1use 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#[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#[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#[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#[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#[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 #[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 #[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 #[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 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 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#[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#[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#[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 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub capability_id: Option<String>,
254 #[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 #[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 "canonical_receipt",
324];
325
326impl ExecutionReceiptV1 {
327 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}