Skip to main content

khive_runtime/
visibility_receipts.rs

1//! Runtime custody and authorization for opaque memory visibility receipts.
2
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
5
6use khive_types::{Details, ErrorKind, KhiveError};
7use uuid::Uuid;
8
9use crate::credentials::receipt_sealer::{ReceiptFields, ReceiptSealError, ReceiptSealer};
10use crate::credentials::{CredentialRegistry, VisibilityReceiptConfig};
11use crate::{KhiveRuntime, RuntimeConfig, RuntimeError, RuntimeResult};
12
13const MAX_AGE_MS: i64 = 24 * 60 * 60 * 1_000;
14const MAX_FUTURE_MS: i64 = 5 * 60 * 1_000;
15const REPLAY_PHASE: &str = "exact_replay";
16
17/// Positive receipt admission shared by clones bound to one runtime backend.
18/// Construction performs no check; the first successful check latches, a failed
19/// one stays retryable, and unlatched calls retain synchronous validation.
20pub(crate) struct ReceiptCutover {
21    backend: Arc<khive_db::StorageBackend>,
22    ready: AtomicBool,
23    check: parking_lot::Mutex<()>,
24}
25
26impl ReceiptCutover {
27    pub(crate) fn new(backend: Arc<khive_db::StorageBackend>, validated: bool) -> Self {
28        Self {
29            backend,
30            ready: AtomicBool::new(validated),
31            check: parking_lot::Mutex::new(()),
32        }
33    }
34
35    pub(crate) fn is_bound_to(&self, backend: &Arc<khive_db::StorageBackend>) -> bool {
36        Arc::ptr_eq(&self.backend, backend)
37    }
38
39    fn ensure(&self) -> RuntimeResult<()> {
40        if self.ready.load(Ordering::Acquire) {
41            return Ok(());
42        }
43        let _check = self.check.lock();
44        if self.ready.load(Ordering::Acquire) {
45            return Ok(());
46        }
47        self.backend
48            .validate_memory_visibility_cutover()
49            .map_err(|_| receipt_failure("receipt_store_unavailable", None, false))?;
50        self.ready.store(true, Ordering::Release);
51        Ok(())
52    }
53
54    /// Validate again after a receipt-bearing write rolled back, so a receipt
55    /// store that became unusable after the latch was set is still refused as
56    /// `receipt_store_unavailable`. A failed check clears the latch.
57    fn recheck(&self) -> RuntimeResult<()> {
58        let _check = self.check.lock();
59        if self.backend.validate_memory_visibility_cutover().is_ok() {
60            return Ok(());
61        }
62        self.ready.store(false, Ordering::Release);
63        Err(receipt_failure("receipt_store_unavailable", None, false))
64    }
65}
66
67pub(crate) enum ReceiptCapability {
68    Absent,
69    Configured(ReceiptSealer),
70    Unavailable,
71}
72
73impl ReceiptCapability {
74    pub(crate) fn from_config(config: &RuntimeConfig) -> Self {
75        let Some(ring) = &config.visibility_receipts else {
76            return Self::Absent;
77        };
78        let Ok(registry) = CredentialRegistry::new(config.credentials.clone()) else {
79            return Self::Unavailable;
80        };
81        match ReceiptSealer::new(ring.clone(), Arc::new(registry)) {
82            Ok(sealer) => Self::Configured(sealer),
83            Err(_) => Self::Unavailable,
84        }
85    }
86
87    fn sealer(&self) -> RuntimeResult<&ReceiptSealer> {
88        match self {
89            Self::Configured(sealer) => Ok(sealer),
90            Self::Absent | Self::Unavailable => {
91                Err(receipt_failure("visibility_key_unavailable", None, false))
92            }
93        }
94    }
95}
96
97/// Authenticated and scope-checked receipt fields for trusted proof consumers.
98/// Deliberately has no formatting or serialization implementation.
99pub struct AuthenticatedVisibilityReceipt(ReceiptFields);
100
101impl AuthenticatedVisibilityReceipt {
102    pub fn namespace(&self) -> &str {
103        &self.0.namespace
104    }
105
106    pub fn sequence_for_model(&self, model: &str) -> Option<u64> {
107        self.0
108            .fences
109            .iter()
110            .find(|(name, _)| name == model)
111            .map(|(_, seq)| *seq)
112    }
113}
114
115fn invalid_receipt() -> RuntimeError {
116    RuntimeError::InvalidInput("memory.recall invalid visibility receipt".into())
117}
118
119fn seal_error(error: ReceiptSealError) -> RuntimeError {
120    match error {
121        ReceiptSealError::KeyUnavailable => {
122            receipt_failure("visibility_key_unavailable", None, false)
123        }
124        ReceiptSealError::NonceUnavailable => {
125            receipt_failure("visibility_nonce_unavailable", None, false)
126        }
127        ReceiptSealError::InvalidReceipt => invalid_receipt(),
128    }
129}
130
131pub(crate) fn receipt_failure(
132    reason: &'static str,
133    memory_id: Option<Uuid>,
134    replay: bool,
135) -> RuntimeError {
136    let mut fields = vec![("reason", reason.to_owned())];
137    if let Some(id) = memory_id {
138        fields.push(("memory_id", id.to_string()));
139    }
140    if replay {
141        fields.push(("receipt_phase", REPLAY_PHASE.to_owned()));
142    }
143    KhiveError::unavailable("freshness_unmet: memory visibility receipt unavailable")
144        .with_details(Details::new_owned(fields))
145        .into()
146}
147
148/// A closed projection; arbitrary detail values cannot assert write disposition.
149pub(crate) fn receipt_error_projection(error: &RuntimeError) -> Option<(bool, bool)> {
150    let RuntimeError::Khive(error) = error else {
151        return None;
152    };
153    if error.kind() != ErrorKind::Unavailable {
154        return None;
155    }
156    let details = error.details()?;
157    let reason = details.get("reason")?;
158    let retryable = match reason {
159        "receipt_temporarily_unavailable"
160        | "receipt_store_unavailable"
161        | "visibility_key_unavailable"
162        | "visibility_nonce_unavailable"
163        | "visibility_receipt_unavailable" => true,
164        "legacy_receipt_absent" | "receipt_epoch_unknown" | "visibility_token_expired" => false,
165        _ => return None,
166    };
167    let exact_replay = matches!(
168        reason,
169        "receipt_temporarily_unavailable" | "legacy_receipt_absent" | "receipt_epoch_unknown"
170    ) && details.get("receipt_phase") == Some(REPLAY_PHASE)
171        && details
172            .get("memory_id")
173            .is_some_and(|id| Uuid::parse_str(id).is_ok());
174    Some((retryable, exact_replay))
175}
176
177fn validate_fields(
178    fields: ReceiptFields,
179    namespaces: &[&str],
180    models: &[String],
181    now: i64,
182) -> RuntimeResult<AuthenticatedVisibilityReceipt> {
183    if !namespaces.contains(&fields.namespace.as_str())
184        || fields.fences.iter().any(|(name, _)| !models.contains(name))
185    {
186        return Err(invalid_receipt());
187    }
188    if fields.issued_at > now.saturating_add(MAX_FUTURE_MS) {
189        return Err(invalid_receipt());
190    }
191    if fields.issued_at < now.saturating_sub(MAX_AGE_MS) {
192        return Err(receipt_failure("visibility_token_expired", None, false));
193    }
194    Ok(AuthenticatedVisibilityReceipt(fields))
195}
196
197impl KhiveRuntime {
198    /// Return a fixed startup notice for absent or unusable receipt custody.
199    ///
200    /// This only projects configuration state: it neither resolves credentials
201    /// nor reads storage. `None` means custody was configured, not that its
202    /// provider is currently available. Memory-pack activation logs this notice;
203    /// hosts serving memory through direct runtime APIs may report it themselves.
204    pub fn visibility_receipt_custody_notice(&self) -> Option<&'static str> {
205        match self.visibility_receipts.as_ref() {
206            ReceiptCapability::Absent => Some(
207                "no [visibility_receipts] section is configured: memory.remember stores memories \
208                 without a visibility token and session recall refuses until receipt keys are \
209                 configured",
210            ),
211            ReceiptCapability::Unavailable => Some(
212                "configured [visibility_receipts] custody is unusable: memory.remember and \
213                 session recall refuse with visibility_key_unavailable; check configuration",
214            ),
215            ReceiptCapability::Configured(_) => None,
216        }
217    }
218
219    /// Install host-owned custody before cloning or passing the runtime to packs.
220    /// The registry exposes references/providers; resolved material stays private.
221    pub fn with_visibility_receipt_credentials(
222        mut self,
223        ring: VisibilityReceiptConfig,
224        credentials: Arc<CredentialRegistry>,
225    ) -> RuntimeResult<Self> {
226        let declarations = credentials.declarations();
227        let sealer = ReceiptSealer::new(ring.clone(), credentials).map_err(seal_error)?;
228        self.install_visibility_receipt_capability(
229            ring,
230            declarations,
231            ReceiptCapability::Configured(sealer),
232        );
233        Ok(self)
234    }
235
236    pub(crate) fn require_visibility_cutover(&self) -> RuntimeResult<()> {
237        self.visibility_cutover.ensure()
238    }
239
240    /// Re-check the cutover after a receipt-bearing write rolled back.
241    pub(crate) fn recheck_visibility_cutover(&self) -> RuntimeResult<()> {
242        self.visibility_cutover.recheck()
243    }
244
245    /// Verify current key availability without issuing a receipt or reserving a nonce.
246    pub fn ensure_visibility_receipt_key(&self) -> RuntimeResult<()> {
247        self.require_visibility_cutover()?;
248        self.visibility_receipts
249            .sealer()?
250            .ensure_key()
251            .map_err(seal_error)
252    }
253
254    /// Preflight for a writer that proceeds without custody. `Ok(true)` means
255    /// custody is configured and its key resolves, so the write must be sealed.
256    /// `Ok(false)` means no receipt configuration exists. A schema that has not
257    /// completed the cutover, a configuration that cannot be used and a key
258    /// that cannot be resolved all refuse.
259    pub fn ensure_visibility_receipt_key_if_configured(&self) -> RuntimeResult<bool> {
260        self.require_visibility_cutover()?;
261        if matches!(*self.visibility_receipts, ReceiptCapability::Absent) {
262            return Ok(false);
263        }
264        self.visibility_receipts
265            .sealer()?
266            .ensure_key()
267            .map_err(seal_error)?;
268        Ok(true)
269    }
270
271    /// Seal exact original fences. Never reconstructs fences from the current log.
272    pub fn seal_visibility_receipt(
273        &self,
274        namespace: &str,
275        fences: &[(String, u64)],
276    ) -> RuntimeResult<String> {
277        self.require_visibility_cutover()?;
278        self.visibility_receipts
279            .sealer()?
280            .seal(namespace, fences)
281            .map_err(seal_error)
282    }
283
284    /// Authenticate and constrain a token to the already-authorized effective scope.
285    pub fn open_visibility_receipt(
286        &self,
287        token: &str,
288        effective_namespaces: &[&str],
289        requested_models: &[String],
290    ) -> RuntimeResult<AuthenticatedVisibilityReceipt> {
291        self.open_visibility_receipt_at(
292            token,
293            effective_namespaces,
294            requested_models,
295            chrono::Utc::now().timestamp_millis(),
296        )
297    }
298
299    fn open_visibility_receipt_at(
300        &self,
301        token: &str,
302        effective_namespaces: &[&str],
303        requested_models: &[String],
304        now: i64,
305    ) -> RuntimeResult<AuthenticatedVisibilityReceipt> {
306        self.require_visibility_cutover()?;
307        ReceiptSealer::validate_envelope(token).map_err(seal_error)?;
308        let fields = self
309            .visibility_receipts
310            .sealer()?
311            .open(token)
312            .map_err(seal_error)?;
313        validate_fields(fields, effective_namespaces, requested_models, now)
314    }
315}
316
317#[cfg(test)]
318#[path = "visibility_receipts_tests.rs"]
319mod tests;