Skip to main content

khive_runtime/pack/
registry_access.rs

1use std::sync::Arc;
2
3use khive_gate::{GateDecision, GateRequest};
4use khive_storage::EventStore;
5use khive_types::{EventOutcome, Namespace};
6use serde_json::Value;
7
8use crate::error::RuntimeError;
9use crate::runtime::NamespaceToken;
10use crate::KhiveRuntime;
11
12use super::{
13    append_audit_event_best_effort, audit_admission_refused_obligation_count,
14    audit_admission_refused_obligation_last_at_ms, audit_admission_unresolved_obligation_count,
15    audit_admission_unresolved_obligation_last_at_ms, audit_retry_exhausted_obligation_count,
16    audit_retry_exhausted_obligation_last_at_ms, build_audit_storage_event, fold_audit_obligation,
17    masked_audit_event, VerbRegistry, AUDIT_PERSISTENCE_SKIPPED_READ_ONLY,
18};
19#[cfg(doc)]
20use super::{PackRegistry, RequestIdentity, VerbCategory, VerbRegistryBuilder};
21
22impl VerbRegistry {
23    /// Select the owning pack's backend for a note-kind KG read. The caller
24    /// keeps its already-authorized token; this only selects storage.
25    pub fn kg_note_read_runtime_for_kind<'a>(
26        &'a self,
27        runtime: &'a KhiveRuntime,
28        kind: &str,
29    ) -> &'a KhiveRuntime {
30        let Some(resolver) = &self.kg_read_resolver else {
31            return runtime;
32        };
33        let Some(owner) = self
34            .packs
35            .iter()
36            .find(|pack| pack.note_kinds().contains(&kind))
37        else {
38            return runtime;
39        };
40        resolver.runtime_for_pack(owner.name())
41    }
42
43    /// Resolve a KG entity/note handle across the configured backend inventory.
44    ///
45    /// The caller must supply its dispatch-authorized token. By-ID reads do not
46    /// filter the stored namespace (ADR-007); no new token is minted here. With
47    /// ordinary single-runtime registration, retain the supplied runtime's
48    /// existing behavior. This does not route mutations or pack-private records.
49    pub async fn resolve_kg_read_by_id(
50        &self,
51        runtime: &KhiveRuntime,
52        token: &NamespaceToken,
53        id: uuid::Uuid,
54        include_deleted: bool,
55    ) -> Result<Option<crate::Resolved>, RuntimeError> {
56        match &self.kg_read_resolver {
57            Some(resolver) => resolver.by_id(token, id, include_deleted).await,
58            None if include_deleted => runtime.resolve_by_id_including_deleted(token, id).await,
59            None => runtime.resolve_by_id(token, id).await,
60        }
61    }
62
63    /// Find the unique configured backend holding an entity for deletion.
64    /// Includes tombstones so soft deletion cannot hide a duplicate owner.
65    /// The dispatch-authorized token is preserved; lookup is namespace-agnostic.
66    pub async fn resolve_entity_delete_runtime(
67        &self,
68        runtime: &KhiveRuntime,
69        token: &NamespaceToken,
70        id: uuid::Uuid,
71    ) -> Result<Option<KhiveRuntime>, RuntimeError> {
72        match &self.kg_read_resolver {
73            Some(resolver) => resolver.entity_runtime(token, id).await,
74            None => {
75                let store = runtime.entities(token)?;
76                let entity = store.get_entity_including_deleted(id).await?;
77                Ok(entity.map(|_| runtime.clone()))
78            }
79        }
80    }
81
82    /// Clean main-backend attachments after no live or tombstoned owner remains.
83    /// A live or tombstoned entity on any configured backend keeps its roots.
84    pub async fn cleanup_deleted_entity_attachments(
85        &self,
86        runtime: &KhiveRuntime,
87        token: &NamespaceToken,
88        id: uuid::Uuid,
89    ) -> Result<bool, RuntimeError> {
90        if self
91            .resolve_entity_delete_runtime(runtime, token, id)
92            .await?
93            .is_some()
94        {
95            return Ok(false);
96        }
97        runtime.delete_entity_attachments_on_core(id).await
98    }
99
100    /// Recheck a merged-entity read against the kept id before returning it.
101    /// The submitted argument shape is the verb's ordinary shape with the
102    /// effective id substituted. The dispatch's original check remains its
103    /// own audit row; this consultation records the effective target as a
104    /// second row without changing the public GateRequest schema.
105    pub async fn authorize_effective_kg_read(
106        &self,
107        token: &NamespaceToken,
108        verb: &str,
109        mut effective_args: Value,
110        effective_id: uuid::Uuid,
111    ) -> Result<(), RuntimeError> {
112        if let Some(namespace) = token.gate_explicit_namespace() {
113            effective_args["namespace"] = Value::String(namespace.to_owned());
114        }
115        let gate_req = GateRequest::new(
116            token.actor().clone(),
117            token.gate_namespace().clone(),
118            verb,
119            effective_args,
120        );
121        let decision = khive_gate::check_with_mailbox_policy(self.gate.as_ref(), &gate_req);
122        match decision {
123            Ok(decision) => {
124                let audit = masked_audit_event(&gate_req, &decision, self.gate.impl_name());
125                tracing::info!(
126                    audit_event = %serde_json::to_string(&audit)
127                        .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
128                    effective_target_id = %effective_id,
129                    "gate.check"
130                );
131                let denied = matches!(&decision, GateDecision::Deny { .. });
132                let receipt = if let Some(store) = &self.event_store {
133                    let event = build_audit_storage_event(
134                        &gate_req,
135                        &audit,
136                        if denied {
137                            EventOutcome::Denied
138                        } else {
139                            EventOutcome::Success
140                        },
141                        Some(crate::cost_unit::base_resource_payload(token.request_id())),
142                    )
143                    .with_target(effective_id);
144                    if denied {
145                        self.append_gate_denied_row(store, event, verb).await
146                    } else {
147                        let outcome = append_audit_event_best_effort(
148                            self.audit_batch.as_ref(),
149                            store,
150                            event,
151                            verb,
152                            crate::audit_batch::AuditProducer::EffectiveTargetCheck,
153                            false,
154                        )
155                        .await;
156                        fold_audit_obligation(Ok(()), outcome, |_| Value::Null)?;
157                        crate::error::DenialReceipt::no_store()
158                    }
159                } else {
160                    crate::error::DenialReceipt::no_store()
161                };
162                match decision {
163                    GateDecision::Allow { .. } => Ok(()),
164                    GateDecision::Deny { reason } => Err(RuntimeError::PermissionDenied {
165                        verb: verb.to_string(),
166                        reason,
167                        receipt: Box::new(receipt),
168                    }),
169                }
170            }
171            Err(error) => Err(self
172                .gate_unavailable_error(&gate_req, &error, token.request_id(), Some(effective_id))
173                .await),
174        }
175    }
176
177    /// Resolve a prefix across the same inventory, rejecting distinct UUIDs.
178    ///
179    /// Retains the local prefix scanner's entity/note/event/edge collision domain,
180    /// including sidecar events. The returned UUID is not a substrate assertion:
181    /// consumers must still fetch/type-check it. All backend failures propagate.
182    pub async fn resolve_kg_read_prefix(
183        &self,
184        runtime: &KhiveRuntime,
185        _token: &NamespaceToken,
186        prefix: &str,
187        include_deleted: bool,
188    ) -> Result<Option<uuid::Uuid>, RuntimeError> {
189        match &self.kg_read_resolver {
190            Some(resolver) => resolver.prefix(prefix, include_deleted).await,
191            None if include_deleted => {
192                runtime
193                    .resolve_prefix_unfiltered_including_deleted(prefix)
194                    .await
195            }
196            None => runtime.resolve_prefix_unfiltered(prefix).await,
197        }
198    }
199
200    /// This registry's construction-baked default namespace.
201    ///
202    /// Used as the fallback when a request carries no [`RequestIdentity`]
203    /// override (ADR-096 Fork 1) and by transports that need to advertise
204    /// their own resolved identity when forwarding to a warm daemon.
205    pub fn default_namespace(&self) -> &str {
206        &self.default_namespace
207    }
208
209    /// This registry's construction-baked actor identity label, if configured
210    /// (ADR-057). `None` means dispatch mints `ActorRef::anonymous()` absent a
211    /// per-request [`RequestIdentity`] override (ADR-096 Fork 1).
212    pub fn actor_id(&self) -> Option<&str> {
213        self.actor_id.as_deref()
214    }
215
216    /// This registry's construction-baked extra read-visibility namespaces
217    /// (ADR-007 Rev 4 Rule 3b), used absent a per-request [`RequestIdentity`]
218    /// override (ADR-096 Fork 1).
219    pub fn visible_namespaces(&self) -> &[Namespace] {
220        &self.visible_namespaces
221    }
222
223    /// This registry's configured audit `EventStore`, if any (ADR-094).
224    ///
225    /// Lets background tasks that hold a `VerbRegistry` but do not go through
226    /// `dispatch` (e.g. the email channel poll loop) append best-effort
227    /// lifecycle events to the same sink gate-check audit rows use, without
228    /// threading a second `Option<Arc<dyn EventStore>>` field through every
229    /// caller. `None` means either the historical tracing-only default or an
230    /// intentionally read-only audit backend; callers that need to distinguish
231    /// those cases use [`Self::audit_persistence_advisory`].
232    pub fn event_store(&self) -> Option<Arc<dyn EventStore>> {
233        self.event_store.clone()
234    }
235
236    /// Process-lifetime audit-batch health counters for this registry's
237    /// ADR-133 seam, if one is configured. `None` exactly when
238    /// [`Self::event_store`] is `None` — the same condition under which no
239    /// batch exists to report on. The `db_diagnostics` verb feeds this into
240    /// `KhiveRuntime::db_diagnostics_with_audit_metrics` so an operator can
241    /// see flush failures and pure-observability degradation instead of the
242    /// permanently-unavailable placeholder a bare `KhiveRuntime` reports.
243    ///
244    /// `admission_refused_obligations`, `retry_exhausted_obligations`,
245    /// and `admission_unresolved_obligations`
246    /// are sourced separately from [`audit_admission_refused_obligation_count`],
247    /// [`audit_retry_exhausted_obligation_count`], and
248    /// [`audit_admission_unresolved_obligation_count`] rather than from
249    /// `batch.health_metrics()`: they count a decision made in
250    /// `append_audit_event_best_effort` (ADR-103 Amendment 3 / ADR-133
251    /// Amendment 1), not a property of the batch itself, so they are
252    /// process-wide like the rest of this struct's fields rather than
253    /// per-`AuditBatch`.
254    pub fn audit_batch_metrics(&self) -> Option<khive_db::diagnostics::RuntimeAuditBatchMetrics> {
255        self.audit_batch.as_ref().map(|batch| {
256            let m = batch.health_metrics();
257            khive_db::diagnostics::RuntimeAuditBatchMetrics {
258                flush_failures: m.flush_failures,
259                degraded_rows: m.degraded_rows,
260                degraded: m.degraded,
261                admission_refused_obligations: audit_admission_refused_obligation_count(),
262                admission_refused_obligations_last_at_ms:
263                    audit_admission_refused_obligation_last_at_ms(),
264                retry_exhausted_obligations: audit_retry_exhausted_obligation_count(),
265                retry_exhausted_obligations_last_at_ms: audit_retry_exhausted_obligation_last_at_ms(
266                ),
267                admission_unresolved_obligations: audit_admission_unresolved_obligation_count(),
268                admission_unresolved_obligations_last_at_ms:
269                    audit_admission_unresolved_obligation_last_at_ms(),
270            }
271        })
272    }
273
274    /// This registry's shared ADR-133 audit-batch seam. The recall handler
275    /// uses this same instance for its best-effort `RecallExecuted` rows.
276    /// `None` when no `EventStore` was configured (the batch is constructed
277    /// from one). Admission-pressure mechanism tests also use it so they
278    /// can saturate and drain the SAME instance a real dispatch uses
279    /// (#2117, #2147, #2208, #2217) instead of testing a
280    /// look-alike.
281    pub fn audit_batch_handle(&self) -> Option<Arc<crate::audit_batch::AuditBatch>> {
282        self.audit_batch.clone()
283    }
284
285    /// Stop admitting new audit rows and wait for every already-accepted row
286    /// to reach a terminal state (ADR-133).
287    ///
288    /// A no-op returning `Ok(())` when no `EventStore` — and therefore no
289    /// audit-batch seam — is configured. Callers that own this registry's
290    /// shutdown sequence should call this before tearing down the writer or
291    /// database so no accepted audit row is silently dropped mid-flight.
292    pub async fn shutdown_audit_batch(
293        &self,
294    ) -> Result<(), crate::audit_batch::AuditTerminalReason> {
295        use crate::audit_batch::AuditBatchControl;
296        match &self.audit_batch {
297            Some(audit_batch) => audit_batch.close_and_drain().await,
298            None => Ok(()),
299        }
300    }
301
302    /// Advisory for a dispatch whose configured audit sink is read-only.
303    ///
304    /// The MCP transport places this beside successful per-operation results;
305    /// `None` means audit persistence is configured normally or was never
306    /// configured at all.
307    pub fn audit_persistence_advisory(&self) -> Option<Value> {
308        self.audit_store_read_only.then(|| {
309            serde_json::json!({
310                "code": AUDIT_PERSISTENCE_SKIPPED_READ_ONLY,
311                "severity": "warning",
312                "component": "audit_event_store",
313                "reason": "read_only_backend",
314                "message": "operation completed, but its dispatch audit event was not persisted because the audit backend is read-only",
315            })
316        })
317    }
318
319    /// Explicit, fail-closed opt-in for admission-pressure audit degradation
320    /// (#2147/#2217). `VerbCategory::Assertive` alone is NOT a
321    /// sound proxy for "safe to drop this dispatch's own audit row under
322    /// audit-lane admission pressure": several Assertive handlers have
323    /// their own durable or accounting-bearing side effects. The reviewed
324    /// exclusions are:
325    /// - `memory.recall` dispatches `brain.record_serve` as a background
326    ///   write; degrading `memory.recall`'s row raises the risk that a
327    ///   serve goes unaccounted for if the ledger dispatch itself later
328    ///   also races admission pressure.
329    /// - `db_diagnostics` may backfill WAL frames via a PASSIVE checkpoint
330    ///   probe — physical I/O, not a pure in-memory read.
331    /// - `knowledge.search`, `knowledge.suggest`, and auto
332    ///   `knowledge.compose` may start persistent ANN consumer/checkpoint
333    ///   maintenance from their nominal read path.
334    /// - `git.checkout`, `git.diff` and `git.reconcile` persist a durable
335    ///   receipt on every dispatch (checkout and diff also write a manifest
336    ///   or diff blob), so their accounting row is not droppable.
337    /// - `tool.check` persists a policy decision receipt. `git.receipts`,
338    ///   `git.gates`, `git.status` and `git.log` dispatch that same check,
339    ///   so their read results also carry a required decision write.
340    ///
341    /// What membership here means, precisely: the verb performs no domain
342    /// mutation, so its OWN per-dispatch audit/accounting row may be dropped
343    /// under transient admission pressure without the caller losing a
344    /// meaningful result (ADR-103 Amendment 3, ADR-133 Amendment 1). It does
345    /// NOT mean the handler is free of every event-plane write: `search`
346    /// still fires its own best-effort `SearchExecuted` telemetry, and
347    /// `context` still records a one-time `ConfigLocked` event, both on
348    /// independent code paths this mechanism never touches — those events
349    /// commit or fail on their own terms, unaffected by whether this
350    /// dispatch's own audit row degrades.
351    ///
352    /// Every entry here MUST be declared `VerbCategory::Assertive` in its
353    /// named pack's live vocabulary. The
354    /// `admission_degrade_safe_assertive_census_matches_live_pack_sources`
355    /// test below scans every pack that currently declares public Assertive
356    /// handlers and requires every such handler to be classified exactly
357    /// once as safe or as a known incidental writer. A new Assertive verb
358    /// therefore fails closed both at runtime and in the source census until
359    /// it receives an explicit side-effect review.
360    ///
361    /// Entries are `(owning pack name, verb)` pairs, not bare verb names:
362    /// [`Self::admission_degrade_safe`] requires the handler actually
363    /// resolved for `verb` to belong to the exact pack named here. A verb
364    /// name alone is not a sound key — any pack registered through the same
365    /// [`PackRegistry`]/[`VerbRegistryBuilder`] path can declare a handler
366    /// under any name it likes, including one that collides with a name on
367    /// this list, and unique-verb-name validation only rejects that
368    /// collision when the real owning pack is *also* loaded. A deployment
369    /// that omits the real pack (or loads a third-party pack instead) would
370    /// let a same-named write-performing handler inherit degrade-safety it
371    /// never earned. Binding to the pack closes that gap.
372    pub(super) const ADMISSION_DEGRADE_SAFE_VERBS: &'static [(&'static str, &'static str)] = &[
373        // agent
374        ("agent", "agent.observe"),
375        // exec (reads of the blob store, the run receipt and event tables, or
376        // the resolved configuration; the writers are exec.tree and
377        // exec.tree_put, Declarations, and exec.run, a Directive)
378        ("exec", "exec.tree_get"),
379        ("exec", "exec.tree_diff"),
380        ("exec", "exec.receipt"),
381        ("exec", "exec.runs"),
382        ("exec", "exec.events"),
383        ("exec", "exec.identity"),
384        // git
385        // Canonical get project check plus bounded cursor SELECT; no domain writes.
386        ("git", "git.ingest_cursor"),
387        // blob
388        ("blob", "blob.get"),
389        ("blob", "blob.stat"),
390        // brain
391        ("brain", "brain.event_counts"),
392        ("brain", "brain.event_page"),
393        ("brain", "brain.profiles"),
394        ("brain", "brain.profile"),
395        ("brain", "brain.resolve"),
396        ("brain", "brain.bindings"),
397        // comm
398        ("comm", "comm.delivered"),
399        ("comm", "comm.transport_status"),
400        ("comm", "comm.inbox"),
401        ("comm", "comm.unread"),
402        ("comm", "comm.thread"),
403        ("comm", "comm.health"),
404        ("comm", "comm.probe"),
405        // gtd
406        ("gtd", "gtd.census"),
407        ("gtd", "gtd.next"),
408        ("gtd", "gtd.tasks"),
409        // kg
410        ("kg", "get"),
411        ("kg", "list"),
412        ("kg", "stats"),
413        ("kg", "count"),
414        ("kg", "search"),
415        ("kg", "neighbors"),
416        ("kg", "traverse"),
417        ("kg", "context"),
418        ("kg", "query"),
419        ("kg", "resolve"),
420        ("kg", "whoami"),
421        // scan runs the secret gate over caller-supplied text in process: no
422        // store read, no store write, no event.
423        ("kg", "scan"),
424        ("kg", "verbs"),
425        ("kg", "stream.read"),
426        ("kg", "stream.stat"),
427        // knowledge (ANN-maintaining search/suggest/compose are excluded)
428        ("knowledge", "knowledge.get"),
429        ("knowledge", "knowledge.list"),
430        ("knowledge", "knowledge.export"),
431        ("knowledge", "knowledge.stats"),
432        ("knowledge", "knowledge.fold"),
433        ("knowledge", "knowledge.topic"),
434        // moodboard
435        ("moodboard", "moodboard.model"),
436        ("moodboard", "moodboard.search"),
437        ("moodboard", "moodboard.preference"),
438        // schedule
439        ("schedule", "schedule.agenda"),
440        // session
441        ("session", "session.list"),
442        ("session", "session.resume"),
443        ("session", "session.export"),
444        ("session", "session.search"),
445        // Fixed SQL reads and file metadata only; no domain or maintenance write.
446        ("session", "session.stats"),
447        // tool (registry, grant and policy reads; tool.suggest runs the same
448        // hybrid search as the kg search and context verbs above)
449        ("tool", "tool.suggest"),
450        ("tool", "tool.describe"),
451        ("tool", "tool.list"),
452        ("tool", "tool.requests"),
453        ("tool", "tool.policies"),
454    ];
455
456    /// Sorted copy of [`Self::ADMISSION_DEGRADE_SAFE_VERBS`], built once, so
457    /// [`VerbRegistryBuilder::build`] can decide each trusted handler's
458    /// eligibility with a binary search instead of a linear scan over every
459    /// entry. Consulted exactly once per registry, at `build()` time — see
460    /// [`Self::admission_degrade_safe`] for why no per-dispatch scan exists
461    /// anymore. The source list above stays grouped by pack (with a `//
462    /// <pack>` comment per group) for human review; this is a derived,
463    /// lookup-shaped view of the same data, not a second source of truth.
464    pub(super) fn admission_degrade_safe_sorted() -> &'static [(&'static str, &'static str)] {
465        static SORTED: std::sync::LazyLock<Vec<(&'static str, &'static str)>> =
466            std::sync::LazyLock::new(|| {
467                let mut pairs = VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS.to_vec();
468                pairs.sort_unstable();
469                pairs
470            });
471        &SORTED
472    }
473
474    /// Whether `verb` is both declared [`VerbCategory::Assertive`] (the
475    /// speech-act tag for handlers that "retrieve and present facts" rather
476    /// than committing a domain change) AND explicitly opted in to
477    /// admission-pressure audit degradation via
478    /// [`Self::ADMISSION_DEGRADE_SAFE_VERBS`] under the exact pack that
479    /// registered it, AND declared by a pack the composition root actually
480    /// vouches for (see [`VerbRegistryBuilder::register_boxed`]'s doc).
481    /// Unknown, non-opted-in, wrong-pack, or untrusted-pack verbs are
482    /// conservatively `false` — fail-closed, so a new Assertive handler (or
483    /// one registered by a pack other than the one the allowlist names, or
484    /// one registered through [`VerbRegistryBuilder::register`] rather than
485    /// the trusted path) hard-fails its audit obligation like any write
486    /// until someone deliberately reviews it and adds it to the allowlist.
487    ///
488    /// `pack.name()` is a value the `PackRuntime` trait object reports about
489    /// itself — any pack registered through the public
490    /// [`VerbRegistryBuilder::register`] path can claim any name, including
491    /// one on the allowlist, whether or not the pack that name actually
492    /// belongs to is also loaded (verb names are unique per registry, so an
493    /// impostor's same-named handler is only reachable when the real pack
494    /// is absent). Binding eligibility to registration-time trust — decided
495    /// by the *caller*, never by the pack instance — is why this checks
496    /// `degrade_safe_verbs` rather than resolving `pack.name()` at query
497    /// time; [`VerbRegistryBuilder::build`] already excluded every untrusted
498    /// pack's handlers from that set.
499    ///
500    /// The whole decision is precomputed once in `VerbRegistryBuilder::build`
501    /// into [`VerbRegistry::degrade_safe_verbs`] — a verb name is unique
502    /// across `Visibility::Verb` handlers within one registry
503    /// (`validate_unique_verb_names`), so this is a single hash-set lookup,
504    /// not a per-dispatch scan over every registered pack's handler list.
505    ///
506    /// Used only to decide whether a dispatch's own audit-obligation row may
507    /// degrade to best-effort on transient audit-lane admission pressure
508    /// (`append_audit_event_best_effort`) — a read that performed no domain
509    /// write must not fail the caller just because the audit lane is
510    /// momentarily saturated. Never used for permission checking, transport
511    /// routing, or return-shape selection.
512    pub(super) fn admission_degrade_safe(&self, verb: &str) -> bool {
513        self.degrade_safe_verbs.contains(verb)
514    }
515
516    /// Transport replay eligibility from the shared operation-effects table,
517    /// restricted to trusted canonical public handlers. A read that persists a
518    /// fresh serve or telemetry row is excluded because the request id is
519    /// correlation, not deduplication.
520    /// Custom and mounted handlers cannot inherit safety from a name/category.
521    pub fn is_read_replay_safe(&self, verb: &str) -> bool {
522        self.read_replay_safe_verbs.contains(verb)
523    }
524
525    /// White-box accessor for [`Self::admission_degrade_safe`], needed
526    /// because the admission-pressure regression tests in
527    /// `tests/read_verb_admission_exhaustion.rs` compile as a separate
528    /// external binary and cannot reach a crate-private method directly —
529    /// the same reason [`audit_admission_refused_obligation_count`] and
530    /// `AuditBatch::test_snapshot` are `pub` rather than `pub(crate)`.
531    #[cfg(any(test, feature = "test-internals"))]
532    pub fn admission_degrade_safe_probe(&self, verb: &str) -> bool {
533        self.admission_degrade_safe(verb)
534    }
535}