1use ed25519_dalek::Signer;
9use serde::{Deserialize, Serialize};
10
11pub const CONTEXT_CAPSULE_SCHEMA_VERSION: u16 = 1;
12const MAX_CAPSULE_REFERENCES: usize = 256;
13const MAX_CAPSULE_REF_LIST_ITEMS: usize = 256;
14const MAX_CAPSULE_ALLOWED_AGENTS: usize = 64;
15const MAX_CAPSULE_HOPS: u16 = 256;
16
17#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum CapsuleReferenceKindV1 {
20 File,
21 Symbol,
22 Evidence,
23 Recovery,
24}
25
26#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
27pub struct ContextCapsuleReferenceV1 {
28 pub kind: CapsuleReferenceKindV1,
29 pub content_ref: String,
30 pub freshness_ref: String,
31 pub recovery_ref: Option<String>,
32}
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum CapsuleSensitivityV1 {
37 Public,
38 Internal,
39 Restricted,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
43pub struct ContextCapsuleBudgetV1 {
44 pub tokens_used: u64,
45 pub tokens_remaining: u64,
46 pub cost_micros_used: u64,
47 pub cost_micros_remaining: u64,
48 pub latency_ms_used: u64,
49 pub latency_ms_remaining: u64,
50}
51
52#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
53pub struct ContextCapsuleChainV1 {
54 pub chain_id: String,
55 pub parent_capsule_ref: Option<String>,
56 pub owner_agent_id: String,
57 pub attribution_ref: String,
58 pub hop: u16,
59}
60
61#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct ContextCapsuleV1 {
67 pub schema_version: u16,
68 pub capsule_id: String,
69 pub request_id: String,
70 pub session_id: String,
71 pub agent_id: String,
72 pub intent_ref: String,
73 pub task_ref: String,
74 pub expected_outcome_ref: String,
75 pub acceptance_criteria_refs: Vec<String>,
76 pub references: Vec<ContextCapsuleReferenceV1>,
77 pub finding_refs: Vec<String>,
78 pub decision_refs: Vec<String>,
79 pub uncertainty_refs: Vec<String>,
80 pub negative_result_refs: Vec<String>,
81 pub source_ref: String,
82 pub policy_ref: String,
83 pub contract_ref: String,
84 pub freshness_ref: String,
85 pub sensitivity: CapsuleSensitivityV1,
86 pub allowed_agent_ids: Vec<String>,
87 pub budget: ContextCapsuleBudgetV1,
88 pub chain: ContextCapsuleChainV1,
89 pub quality_signal_refs: Vec<String>,
90 pub recovery_refs: Vec<String>,
91 pub delta_from: Option<String>,
92}
93
94impl ContextCapsuleV1 {
95 pub fn canonicalize(&mut self) {
96 self.acceptance_criteria_refs.sort();
97 self.references.sort_by(|left, right| {
98 left.kind
99 .cmp(&right.kind)
100 .then_with(|| left.content_ref.cmp(&right.content_ref))
101 });
102 for refs in [
103 &mut self.finding_refs,
104 &mut self.decision_refs,
105 &mut self.uncertainty_refs,
106 &mut self.negative_result_refs,
107 &mut self.quality_signal_refs,
108 &mut self.recovery_refs,
109 &mut self.allowed_agent_ids,
110 ] {
111 refs.sort();
112 refs.dedup();
113 }
114 }
115
116 pub fn assign_capsule_id(&mut self) -> Result<(), ContextCapsuleError> {
117 self.canonicalize();
118 self.capsule_id = self.computed_capsule_id()?;
119 Ok(())
120 }
121
122 pub fn computed_capsule_id(&self) -> Result<String, ContextCapsuleError> {
123 let mut canonical = self.clone();
124 canonical.canonicalize();
125 canonical.capsule_id = "capsule:pending".to_string();
126 let bytes = serde_json::to_vec(&canonical)
127 .map_err(|e| ContextCapsuleError::Serialize(e.to_string()))?;
128 Ok(format!("capsule:{}", blake3::hash(&bytes).to_hex()))
129 }
130
131 pub fn validate(&self) -> Result<(), ContextCapsuleError> {
132 if self.schema_version != CONTEXT_CAPSULE_SCHEMA_VERSION {
133 return Err(ContextCapsuleError::UnsupportedVersion(self.schema_version));
134 }
135 for (label, value) in [
136 ("capsule_id", self.capsule_id.as_str()),
137 ("request_id", self.request_id.as_str()),
138 ("session_id", self.session_id.as_str()),
139 ("intent_ref", self.intent_ref.as_str()),
140 ("task_ref", self.task_ref.as_str()),
141 ("expected_outcome_ref", self.expected_outcome_ref.as_str()),
142 ("source_ref", self.source_ref.as_str()),
143 ("policy_ref", self.policy_ref.as_str()),
144 ("contract_ref", self.contract_ref.as_str()),
145 ("freshness_ref", self.freshness_ref.as_str()),
146 ("chain_id", self.chain.chain_id.as_str()),
147 ("attribution_ref", self.chain.attribution_ref.as_str()),
148 ] {
149 opaque_ref(label, value)?;
150 agent_id_value(&self.agent_id)?;
151 }
152 if !self.capsule_id.starts_with("capsule:") {
153 return Err(ContextCapsuleError::Invalid(
154 "capsule_id must use capsule: scheme".into(),
155 ));
156 }
157 if let Some(base) = self.delta_from.as_deref() {
158 opaque_ref("delta_from", base)?;
159 if base == self.capsule_id {
160 return Err(ContextCapsuleError::Invalid(
161 "delta_from cannot self-reference".into(),
162 ));
163 }
164 }
165 if let Some(parent) = self.chain.parent_capsule_ref.as_deref() {
166 opaque_ref("parent_capsule_ref", parent)?;
167 if parent == self.capsule_id {
168 return Err(ContextCapsuleError::Invalid(
169 "parent_capsule_ref cannot self-reference".into(),
170 ));
171 }
172 }
173 if self.chain.hop > MAX_CAPSULE_HOPS {
174 return Err(ContextCapsuleError::Invalid(format!(
175 "chain hop exceeds {MAX_CAPSULE_HOPS}"
176 )));
177 }
178 bounded("references", self.references.len(), MAX_CAPSULE_REFERENCES)?;
179 for (label, count) in [
180 (
181 "acceptance_criteria_refs",
182 self.acceptance_criteria_refs.len(),
183 ),
184 ("finding_refs", self.finding_refs.len()),
185 ("decision_refs", self.decision_refs.len()),
186 ("uncertainty_refs", self.uncertainty_refs.len()),
187 ("negative_result_refs", self.negative_result_refs.len()),
188 ("quality_signal_refs", self.quality_signal_refs.len()),
189 ("recovery_refs", self.recovery_refs.len()),
190 ] {
191 bounded(label, count, MAX_CAPSULE_REF_LIST_ITEMS)?;
192 }
193 validate_references(&self.references)?;
194 validate_ref_lists(&[
195 &self.acceptance_criteria_refs,
196 &self.finding_refs,
197 &self.decision_refs,
198 &self.uncertainty_refs,
199 &self.negative_result_refs,
200 &self.quality_signal_refs,
201 &self.recovery_refs,
202 ])?;
203 if self.allowed_agent_ids.is_empty() {
204 return Err(ContextCapsuleError::Invalid(
205 "allowed_agent_ids cannot be empty".into(),
206 ));
207 }
208 bounded(
209 "allowed_agent_ids",
210 self.allowed_agent_ids.len(),
211 MAX_CAPSULE_ALLOWED_AGENTS,
212 )?;
213 validate_agent_ids(&self.allowed_agent_ids)?;
214 agent_id_value(&self.chain.owner_agent_id)?;
215 if self.capsule_id != self.computed_capsule_id()? {
216 return Err(ContextCapsuleError::Invalid(
217 "capsule_id does not match canonical content".into(),
218 ));
219 }
220 Ok(())
221 }
222
223 pub fn agent_envelope(
225 &self,
226 to_agent_id: &str,
227 ) -> Result<AgentEnvelopeV1, ContextCapsuleError> {
228 self.validate()?;
229 if !self.allowed_agent_ids.iter().any(|a| a == to_agent_id) {
230 return Err(ContextCapsuleError::Invalid(
231 "target agent is not in allowed_agent_ids".into(),
232 ));
233 }
234 Ok(AgentEnvelopeV1 {
235 schema_version: AGENT_ENVELOPE_SCHEMA_VERSION,
236 relay_id: "agent-relay:pending".to_string(),
237 from_agent_id: self.chain.owner_agent_id.clone(),
238 to_agent_id: to_agent_id.to_string(),
239 capsule_ref: self.capsule_id.clone(),
240 budget_tokens: self.budget.tokens_remaining,
241 request_id: self.request_id.clone(),
242 session_id: self.session_id.clone(),
243 })
244 }
245}
246
247pub const AGENT_ENVELOPE_SCHEMA_VERSION: u16 = 1;
250
251#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
253#[serde(deny_unknown_fields)]
254pub struct AgentEnvelopeV1 {
255 pub schema_version: u16,
256 pub relay_id: String,
257 pub from_agent_id: String,
258 pub to_agent_id: String,
259 pub capsule_ref: String,
260 pub budget_tokens: u64,
261 pub request_id: String,
262 pub session_id: String,
263}
264
265impl AgentEnvelopeV1 {
266 pub fn assign_relay_id(&mut self) -> Result<(), ContextCapsuleError> {
267 self.relay_id = self.computed_relay_id()?;
268 Ok(())
269 }
270
271 pub fn computed_relay_id(&self) -> Result<String, ContextCapsuleError> {
272 let mut canonical = self.clone();
273 canonical.relay_id = "agent-relay:pending".to_string();
274 let bytes = serde_json::to_vec(&canonical)
275 .map_err(|e| ContextCapsuleError::Serialize(e.to_string()))?;
276 Ok(format!("agent-relay:{}", blake3::hash(&bytes).to_hex()))
277 }
278
279 pub fn validate(&self) -> Result<(), ContextCapsuleError> {
280 if self.schema_version != AGENT_ENVELOPE_SCHEMA_VERSION {
281 return Err(ContextCapsuleError::UnsupportedVersion(self.schema_version));
282 }
283 agent_id_value(&self.from_agent_id)?;
284 agent_id_value(&self.to_agent_id)?;
285 if self.from_agent_id == self.to_agent_id {
286 return Err(ContextCapsuleError::Invalid(
287 "relay requires distinct source and target agents".into(),
288 ));
289 }
290 if !self.capsule_ref.starts_with("capsule:") {
291 return Err(ContextCapsuleError::Invalid("invalid capsule_ref".into()));
292 }
293 if self.budget_tokens == 0 {
294 return Err(ContextCapsuleError::Invalid(
295 "budget_tokens must be positive".into(),
296 ));
297 }
298 Ok(())
299 }
300}
301
302#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
306pub struct SignedContextCapsuleV1 {
307 pub capsule: ContextCapsuleV1,
308 pub signer_public_key: String,
309 pub signature: String,
310}
311
312impl SignedContextCapsuleV1 {
313 pub fn sign(
314 capsule: &ContextCapsuleV1,
315 keypair: &ed25519_dalek::SigningKey,
316 ) -> Result<Self, ContextCapsuleError> {
317 capsule.validate()?;
318 let bytes = serde_json::to_vec(capsule)
319 .map_err(|e| ContextCapsuleError::Serialize(e.to_string()))?;
320 let signature = keypair.sign(&bytes);
321 let public_key = keypair.verifying_key();
322 Ok(Self {
323 capsule: capsule.clone(),
324 signer_public_key: encode_hex(public_key.as_bytes()),
325 signature: encode_hex(&signature.to_bytes()),
326 })
327 }
328
329 pub fn validate_structure(&self) -> Result<(), ContextCapsuleError> {
330 self.capsule.validate()?;
331 if self.signer_public_key.len() != 64 {
332 return Err(ContextCapsuleError::Invalid(
333 "invalid signer_public_key length".into(),
334 ));
335 }
336 if self.signature.len() != 128 {
337 return Err(ContextCapsuleError::Invalid(
338 "invalid signature length".into(),
339 ));
340 }
341 Ok(())
342 }
343
344 pub fn verify(
345 &self,
346 pinned_key: &ed25519_dalek::VerifyingKey,
347 ) -> Result<(), ContextCapsuleError> {
348 self.validate_structure()?;
349 let key_bytes = decode_hex(&self.signer_public_key)
350 .map_err(|_| ContextCapsuleError::Invalid("invalid signer hex".into()))?;
351 let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(
352 key_bytes
353 .as_slice()
354 .try_into()
355 .map_err(|_| ContextCapsuleError::Invalid("key must be 32 bytes".into()))?,
356 )
357 .map_err(|_| ContextCapsuleError::Invalid("invalid Ed25519 key".into()))?;
358 if verifying_key != *pinned_key {
359 return Err(ContextCapsuleError::Invalid(
360 "signer key does not match pinned key".into(),
361 ));
362 }
363 let sig_bytes = decode_hex(&self.signature)
364 .map_err(|_| ContextCapsuleError::Invalid("invalid signature hex".into()))?;
365 let signature = ed25519_dalek::Signature::from_bytes(
366 sig_bytes
367 .as_slice()
368 .try_into()
369 .map_err(|_| ContextCapsuleError::Invalid("signature must be 64 bytes".into()))?,
370 );
371 let capsule_bytes = serde_json::to_vec(&self.capsule)
372 .map_err(|e| ContextCapsuleError::Serialize(e.to_string()))?;
373 use ed25519_dalek::Verifier;
374 verifying_key
375 .verify(&capsule_bytes, &signature)
376 .map_err(|_| ContextCapsuleError::Invalid("signature verification failed".into()))
377 }
378}
379
380fn bounded(label: &str, count: usize, maximum: usize) -> Result<(), ContextCapsuleError> {
383 (count <= maximum)
384 .then_some(())
385 .ok_or_else(|| ContextCapsuleError::Invalid(format!("{label} exceeds {maximum}")))
386}
387
388fn opaque_ref(label: &str, value: &str) -> Result<(), ContextCapsuleError> {
389 let (scheme, identifier) = value.split_once(':').ok_or_else(|| {
390 ContextCapsuleError::Invalid(format!("{label} must use scheme:identifier form"))
391 })?;
392 let scheme_valid = !scheme.is_empty()
393 && scheme
394 .bytes()
395 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
396 let identifier_valid = !identifier.is_empty()
397 && value.len() <= 256
398 && identifier.bytes().all(|b| b.is_ascii_graphic());
399 (scheme_valid && identifier_valid)
400 .then_some(())
401 .ok_or_else(|| ContextCapsuleError::Invalid(format!("invalid {label}")))
402}
403
404fn agent_id_value(value: &str) -> Result<(), ContextCapsuleError> {
405 (!value.is_empty() && value.len() <= 256 && value.bytes().all(|b| b.is_ascii_graphic()))
406 .then_some(())
407 .ok_or_else(|| ContextCapsuleError::Invalid("invalid agent ID".into()))
408}
409
410fn validate_references(refs: &[ContextCapsuleReferenceV1]) -> Result<(), ContextCapsuleError> {
411 let mut seen = std::collections::BTreeSet::new();
412 for r in refs {
413 opaque_ref("reference content_ref", &r.content_ref)?;
414 opaque_ref("reference freshness_ref", &r.freshness_ref)?;
415 if let Some(recovery) = r.recovery_ref.as_deref() {
416 opaque_ref("reference recovery_ref", recovery)?;
417 }
418 if !seen.insert((r.kind, r.content_ref.as_str())) {
419 return Err(ContextCapsuleError::Invalid(
420 "duplicate reference kind/content_ref pair".into(),
421 ));
422 }
423 }
424 Ok(())
425}
426
427fn validate_ref_lists(lists: &[&Vec<String>]) -> Result<(), ContextCapsuleError> {
428 for list in lists {
429 for r in *list {
430 opaque_ref("list reference", r)?;
431 }
432 }
433 Ok(())
434}
435
436fn validate_agent_ids(ids: &[String]) -> Result<(), ContextCapsuleError> {
437 let mut seen = std::collections::BTreeSet::new();
438 for id in ids {
439 agent_id_value(id)?;
440 if !seen.insert(id) {
441 return Err(ContextCapsuleError::Invalid(
442 "allowed_agent_ids must be unique".into(),
443 ));
444 }
445 }
446 Ok(())
447}
448
449fn encode_hex(bytes: &[u8]) -> String {
450 const HEX: &[u8; 16] = b"0123456789abcdef";
451 let mut out = String::with_capacity(bytes.len() * 2);
452 for &b in bytes {
453 out.push(HEX[(b >> 4) as usize] as char);
454 out.push(HEX[(b & 0x0f) as usize] as char);
455 }
456 out
457}
458
459fn decode_hex(s: &str) -> Result<Vec<u8>, String> {
460 if !s.len().is_multiple_of(2) {
461 return Err("odd length".into());
462 }
463 (0..s.len())
464 .step_by(2)
465 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
466 .collect()
467}
468
469#[derive(Debug, thiserror::Error)]
472pub enum ContextCapsuleError {
473 #[error("unsupported schema version {0}")]
474 UnsupportedVersion(u16),
475 #[error("invalid capsule: {0}")]
476 Invalid(String),
477 #[error("serialization failed: {0}")]
478 Serialize(String),
479}
480
481#[cfg(test)]
484mod tests {
485 use super::*;
486
487 fn test_capsule() -> ContextCapsuleV1 {
488 let mut capsule = ContextCapsuleV1 {
489 schema_version: CONTEXT_CAPSULE_SCHEMA_VERSION,
490 capsule_id: "capsule:pending".to_string(),
491 request_id: "request:1".to_string(),
492 session_id: "session:1".to_string(),
493 agent_id: "parent-agent".to_string(),
494 intent_ref: "intent:refactor".to_string(),
495 task_ref: "task:42".to_string(),
496 expected_outcome_ref: "outcome:tests-pass".to_string(),
497 acceptance_criteria_refs: vec!["criteria:green".to_string()],
498 references: vec![ContextCapsuleReferenceV1 {
499 kind: CapsuleReferenceKindV1::File,
500 content_ref: "blake3:file".to_string(),
501 freshness_ref: "freshness:1".to_string(),
502 recovery_ref: Some("recovery:file".to_string()),
503 }],
504 finding_refs: vec!["finding:1".to_string()],
505 decision_refs: vec!["decision:1".to_string()],
506 uncertainty_refs: vec!["uncertainty:1".to_string()],
507 negative_result_refs: vec!["negative:1".to_string()],
508 source_ref: "source:workspace".to_string(),
509 policy_ref: "policy:default".to_string(),
510 contract_ref: "contract:ocla-v1".to_string(),
511 freshness_ref: "freshness:1".to_string(),
512 sensitivity: CapsuleSensitivityV1::Internal,
513 allowed_agent_ids: vec!["reviewer-agent".to_string()],
514 budget: ContextCapsuleBudgetV1 {
515 tokens_used: 100,
516 tokens_remaining: 900,
517 cost_micros_used: 10,
518 cost_micros_remaining: 90,
519 latency_ms_used: 20,
520 latency_ms_remaining: 80,
521 },
522 chain: ContextCapsuleChainV1 {
523 chain_id: "chain:1".to_string(),
524 parent_capsule_ref: None,
525 owner_agent_id: "parent-agent".to_string(),
526 attribution_ref: "attribution:1".to_string(),
527 hop: 1,
528 },
529 quality_signal_refs: vec!["quality:1".to_string()],
530 recovery_refs: vec!["recovery:full".to_string()],
531 delta_from: None,
532 };
533 capsule.assign_capsule_id().expect("capsule ID assigns");
534 capsule
535 }
536
537 #[test]
538 fn capsule_is_payload_free_and_deterministic() {
539 let c = test_capsule();
540 c.validate().expect("valid capsule");
541 assert_eq!(c.capsule_id, test_capsule().capsule_id);
542 let json = serde_json::to_string(&c).expect("serializes");
543 assert!(!json.contains("prompt"));
544 assert!(!json.contains("payload"));
545 }
546
547 #[test]
548 fn capsule_rejects_tampered_id() {
549 let mut tampered = test_capsule();
550 tampered.budget.tokens_remaining = 1;
551 assert!(tampered.validate().is_err());
552 }
553
554 #[test]
555 fn capsule_rejects_duplicate_references() {
556 let mut c = test_capsule();
557 c.references.push(c.references[0].clone());
558 c.assign_capsule_id().unwrap();
559 assert!(c.validate().is_err());
560 }
561
562 #[test]
563 fn capsule_envelope_requires_allowed_target() {
564 let c = test_capsule();
565 let env = c.agent_envelope("reviewer-agent").expect("allowed");
566 assert_eq!(env.capsule_ref, c.capsule_id);
567 assert_eq!(env.budget_tokens, 900);
568 assert!(c.agent_envelope("other-agent").is_err());
569 }
570
571 #[test]
572 fn envelope_validates_distinct_agents() {
573 let c = test_capsule();
574 let mut env = c.agent_envelope("reviewer-agent").unwrap();
575 env.assign_relay_id().unwrap();
576 env.validate().expect("valid envelope");
577
578 let mut self_relay = env.clone();
579 self_relay.to_agent_id = self_relay.from_agent_id.clone();
580 assert!(self_relay.validate().is_err());
581 }
582
583 #[test]
584 fn signed_capsule_roundtrip() {
585 let c = test_capsule();
586 let keypair = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
587 let signed = SignedContextCapsuleV1::sign(&c, &keypair).expect("signs");
588 signed.verify(&keypair.verifying_key()).expect("verifies");
589
590 let wrong_key = ed25519_dalek::SigningKey::from_bytes(&[99u8; 32]);
591 assert!(signed.verify(&wrong_key.verifying_key()).is_err());
592 }
593}