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