Skip to main content

lean_ctx/core/
context_capsule.rs

1//! Payload-free, delta-capable contract for multi-agent context handoffs.
2//!
3//! A capsule is a manifest: it names content-addressed context, evidence and
4//! recovery handles but never carries prompt text, file contents or
5//! chain-of-thought. Materializers resolve references after policy/freshness
6//! checks.
7
8use 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/// Canonical handoff manifest. Collections are canonically sorted before
72/// identity is assigned, so independently produced equivalent manifests share
73/// one stable capsule ID and can be transmitted as deltas.
74#[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    /// Project this capsule as an agent envelope for an allowed target.
234    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
259// ─── Agent Envelope ──────────────────────────────────────────────────────────
260
261pub const AGENT_ENVELOPE_SCHEMA_VERSION: u16 = 1;
262
263/// Payload-free relay envelope for authorized agent-to-agent delivery.
264#[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// ─── Signed Capsule (Ed25519) ────────────────────────────────────────────────
315
316/// Ed25519-authenticated capsule manifest for trusted handoffs.
317#[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
392// ─── Helpers ─────────────────────────────────────────────────────────────────
393
394fn 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// ─── Errors ──────────────────────────────────────────────────────────────────
482
483#[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// ─── Tests ───────────────────────────────────────────────────────────────────
494
495#[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}