Skip to main content

chio_kernel/
capability_lineage.rs

1//! Capability lineage index for Chio kernel.
2//!
3//! This module provides persistence and query functions for capability snapshots.
4//! Snapshots are recorded at issuance time and co-located with the receipt database
5//! for efficient JOINs. The delegation chain can be walked via WITH RECURSIVE CTE.
6
7use serde::{Deserialize, Serialize};
8
9use chio_core::capability::{scope::ChioScope, token::CapabilityToken};
10use chio_core::crypto::PublicKey;
11
12use crate::receipt_store::ReceiptStoreError;
13
14/// A point-in-time snapshot of a capability token persisted at issuance.
15///
16/// Stored in the `capability_lineage` table alongside `chio_tool_receipts`
17/// for efficient JOINs during audit queries.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum CapabilitySnapshotProvenance {
21    /// Projection derived from and bound to an exact signed capability token.
22    SignedToken,
23    /// Unsigned local anchor derived from a verified federated delegation policy.
24    SyntheticAnchor,
25    /// Pre-provenance local projection retained only for migration compatibility.
26    #[default]
27    LegacyProjection,
28}
29
30impl CapabilitySnapshotProvenance {
31    #[must_use]
32    pub const fn as_str(self) -> &'static str {
33        match self {
34            Self::SignedToken => "signed_token",
35            Self::SyntheticAnchor => "synthetic_anchor",
36            Self::LegacyProjection => "legacy_projection",
37        }
38    }
39}
40
41impl std::str::FromStr for CapabilitySnapshotProvenance {
42    type Err = String;
43
44    fn from_str(value: &str) -> Result<Self, Self::Err> {
45        match value {
46            "signed_token" => Ok(Self::SignedToken),
47            "synthetic_anchor" => Ok(Self::SyntheticAnchor),
48            "legacy_projection" => Ok(Self::LegacyProjection),
49            _ => Err(format!(
50                "unsupported capability snapshot provenance {value:?}"
51            )),
52        }
53    }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct CapabilitySnapshot {
58    /// The unique token ID (matches CapabilityToken.id).
59    pub capability_id: String,
60    /// Hex-encoded subject public key (agent bound to this capability).
61    pub subject_key: String,
62    /// Hex-encoded issuer public key (Capability Authority or delegating agent).
63    pub issuer_key: String,
64    /// Unix timestamp (seconds) when the token was issued.
65    pub issued_at: u64,
66    /// Unix timestamp (seconds) when the token expires.
67    pub expires_at: u64,
68    /// JSON-serialized ChioScope (grants, resource_grants, prompt_grants).
69    pub grants_json: String,
70    /// Depth in the delegation chain. Root capabilities have depth 0.
71    pub delegation_depth: u64,
72    /// Parent capability_id if this was delegated from another token.
73    pub parent_capability_id: Option<String>,
74    /// Unsigned cross-federation parent edge, separate from signed token lineage.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub federated_parent_capability_id: Option<String>,
77    /// Evidence source for this projection. Missing legacy payloads deserialize
78    /// as `LegacyProjection` and are rejected at transport boundaries.
79    #[serde(default)]
80    pub provenance: CapabilitySnapshotProvenance,
81    /// Exact signed capability token, when the snapshot originated from one.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub signed_capability: Option<CapabilityToken>,
84}
85
86impl CapabilitySnapshot {
87    /// Validate a snapshot crossing a replication or evidence trust boundary.
88    pub fn validate_for_transport(&self) -> Result<(), ReceiptStoreError> {
89        self.validate(false)
90    }
91
92    /// Validate a row read from the local store, including migrated projections
93    /// whose original signed token was not retained.
94    pub fn validate_for_local_read(&self) -> Result<(), ReceiptStoreError> {
95        self.validate(true)
96    }
97
98    fn validate(&self, allow_legacy: bool) -> Result<(), ReceiptStoreError> {
99        if self
100            .federated_parent_capability_id
101            .as_deref()
102            .is_some_and(|parent| parent == self.capability_id)
103        {
104            return Err(self.conflict("uses itself as its federated parent"));
105        }
106
107        match self.provenance {
108            CapabilitySnapshotProvenance::SignedToken => self.validate_signed_token(),
109            CapabilitySnapshotProvenance::SyntheticAnchor => self.validate_synthetic_anchor(),
110            CapabilitySnapshotProvenance::LegacyProjection if allow_legacy => {
111                if self.signed_capability.is_some() {
112                    return Err(self.conflict("marks a signed token as a legacy projection"));
113                }
114                Ok(())
115            }
116            CapabilitySnapshotProvenance::LegacyProjection => Err(self.conflict(
117                "uses legacy projection provenance outside the local migration boundary",
118            )),
119        }
120    }
121
122    fn validate_signed_token(&self) -> Result<(), ReceiptStoreError> {
123        let Some(token) = self.signed_capability.as_ref() else {
124            return Err(self.conflict("claims signed-token provenance without a signed token"));
125        };
126        token.validate_schema().map_err(|error| {
127            self.conflict(&format!(
128                "contains a signed token with an invalid schema: {error}"
129            ))
130        })?;
131        if !token.verify_signature().map_err(|error| {
132            self.conflict(&format!(
133                "contains a signed token that could not be verified: {error}"
134            ))
135        })? {
136            return Err(self.conflict("contains a signed token with an invalid signature"));
137        }
138
139        let persisted_scope: ChioScope = serde_json::from_str(&self.grants_json)?;
140        let scope_matches =
141            serde_json::to_value(&persisted_scope)? == serde_json::to_value(&token.scope)?;
142        let fields_match = self.capability_id == token.id
143            && self.subject_key == token.subject.to_hex()
144            && self.issuer_key == token.issuer.to_hex()
145            && self.issued_at == token.issued_at
146            && self.expires_at == token.expires_at
147            && scope_matches;
148        if !fields_match {
149            return Err(self.conflict("does not match its signed token projection"));
150        }
151
152        let expected_parent = token
153            .delegation_chain
154            .last()
155            .map(|link| link.capability_id.as_str());
156        if self.parent_capability_id.as_deref() != expected_parent
157            || self.delegation_depth != token.delegation_chain.len() as u64
158        {
159            return Err(self.conflict("does not match its signed delegation lineage"));
160        }
161        Ok(())
162    }
163
164    fn validate_synthetic_anchor(&self) -> Result<(), ReceiptStoreError> {
165        if self.signed_capability.is_some() {
166            return Err(self.conflict("marks a signed token as a synthetic anchor"));
167        }
168        let digest = self.capability_id.strip_prefix("fed-del-");
169        if !digest.is_some_and(|digest| {
170            digest.len() == 64
171                && digest
172                    .bytes()
173                    .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
174        }) {
175            return Err(self.conflict("is not a structurally valid fed-del anchor"));
176        }
177        if self.parent_capability_id.is_some() || self.delegation_depth != 0 {
178            return Err(self.conflict("places unsigned federation data in signed lineage fields"));
179        }
180        if self.issued_at >= self.expires_at {
181            return Err(self.conflict("has an empty or reversed validity window"));
182        }
183        PublicKey::from_hex(&self.subject_key)
184            .map_err(|error| self.conflict(&format!("has an invalid subject key: {error}")))?;
185        PublicKey::from_hex(&self.issuer_key)
186            .map_err(|error| self.conflict(&format!("has an invalid issuer key: {error}")))?;
187        let _: ChioScope = serde_json::from_str(&self.grants_json)?;
188        Ok(())
189    }
190
191    fn conflict(&self, reason: &str) -> ReceiptStoreError {
192        ReceiptStoreError::Conflict(format!(
193            "capability lineage {} {reason}",
194            self.capability_id
195        ))
196    }
197}
198
199/// A capability snapshot with the source database sequence used for cluster sync.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct StoredCapabilitySnapshot {
202    pub seq: u64,
203    pub snapshot: CapabilitySnapshot,
204}
205
206/// Errors from capability lineage operations.
207#[derive(Debug, thiserror::Error)]
208pub enum CapabilityLineageError {
209    #[error("receipt store error: {0}")]
210    ReceiptStore(#[from] ReceiptStoreError),
211
212    #[error("sqlite error: {0}")]
213    Sqlite(#[from] rusqlite::Error),
214
215    #[error("json error: {0}")]
216    Json(#[from] serde_json::Error),
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn legacy_snapshot_json_defaults_to_local_only_provenance(
225    ) -> Result<(), Box<dyn std::error::Error>> {
226        let snapshot: CapabilitySnapshot = serde_json::from_value(serde_json::json!({
227            "capability_id": "legacy-capability",
228            "subject_key": "legacy-subject",
229            "issuer_key": "legacy-issuer",
230            "issued_at": 1,
231            "expires_at": 2,
232            "grants_json": "{}",
233            "delegation_depth": 0,
234            "parent_capability_id": null
235        }))?;
236
237        assert_eq!(
238            snapshot.provenance,
239            CapabilitySnapshotProvenance::LegacyProjection
240        );
241        assert!(snapshot.federated_parent_capability_id.is_none());
242        assert!(snapshot.validate_for_local_read().is_ok());
243        assert!(snapshot.validate_for_transport().is_err());
244        Ok(())
245    }
246}