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
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/// Canonical handoff manifest. Collections are canonically sorted before
62/// identity is assigned, so independently produced equivalent manifests share
63/// one stable capsule ID and can be transmitted as deltas.
64#[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    /// Project this capsule as an agent envelope for an allowed target.
224    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
247// ─── Agent Envelope ──────────────────────────────────────────────────────────
248
249pub const AGENT_ENVELOPE_SCHEMA_VERSION: u16 = 1;
250
251/// Payload-free relay envelope for authorized agent-to-agent delivery.
252#[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// ─── Signed Capsule (Ed25519) ────────────────────────────────────────────────
303
304/// Ed25519-authenticated capsule manifest for trusted handoffs.
305#[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
380// ─── Helpers ─────────────────────────────────────────────────────────────────
381
382fn 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// ─── Errors ──────────────────────────────────────────────────────────────────
470
471#[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// ─── Tests ───────────────────────────────────────────────────────────────────
482
483#[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}