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 /// - `comm.withheld_body` writes an access audit event on every call;
341 /// the record of who read a withheld original is the point of the
342 /// verb, so its accounting row is never droppable.
343 ///
344 /// What membership here means, precisely: the verb performs no domain
345 /// mutation, so its OWN per-dispatch audit/accounting row may be dropped
346 /// under transient admission pressure without the caller losing a
347 /// meaningful result (ADR-103 Amendment 3, ADR-133 Amendment 1). It does
348 /// NOT mean the handler is free of every event-plane write: `search`
349 /// still fires its own best-effort `SearchExecuted` telemetry, and
350 /// `context` still records a one-time `ConfigLocked` event, both on
351 /// independent code paths this mechanism never touches — those events
352 /// commit or fail on their own terms, unaffected by whether this
353 /// dispatch's own audit row degrades.
354 ///
355 /// Every entry here MUST be declared `VerbCategory::Assertive` in its
356 /// named pack's live vocabulary. The
357 /// `admission_degrade_safe_assertive_census_matches_live_pack_sources`
358 /// test below scans every pack that currently declares public Assertive
359 /// handlers and requires every such handler to be classified exactly
360 /// once as safe or as a known incidental writer. A new Assertive verb
361 /// therefore fails closed both at runtime and in the source census until
362 /// it receives an explicit side-effect review.
363 ///
364 /// Entries are `(owning pack name, verb)` pairs, not bare verb names:
365 /// [`Self::admission_degrade_safe`] requires the handler actually
366 /// resolved for `verb` to belong to the exact pack named here. A verb
367 /// name alone is not a sound key — any pack registered through the same
368 /// [`PackRegistry`]/[`VerbRegistryBuilder`] path can declare a handler
369 /// under any name it likes, including one that collides with a name on
370 /// this list, and unique-verb-name validation only rejects that
371 /// collision when the real owning pack is *also* loaded. A deployment
372 /// that omits the real pack (or loads a third-party pack instead) would
373 /// let a same-named write-performing handler inherit degrade-safety it
374 /// never earned. Binding to the pack closes that gap.
375 pub(super) const ADMISSION_DEGRADE_SAFE_VERBS: &'static [(&'static str, &'static str)] = &[
376 // agent
377 ("agent", "agent.observe"),
378 // exec (reads of the blob store, the run receipt and event tables, or
379 // the resolved configuration; the writers are exec.tree and
380 // exec.tree_put, Declarations, and exec.run, a Directive)
381 ("exec", "exec.tree_get"),
382 ("exec", "exec.tree_diff"),
383 ("exec", "exec.receipt"),
384 ("exec", "exec.runs"),
385 ("exec", "exec.events"),
386 ("exec", "exec.identity"),
387 // git
388 // Canonical get project check plus bounded cursor SELECT; no domain writes.
389 ("git", "git.ingest_cursor"),
390 // blob
391 ("blob", "blob.get"),
392 ("blob", "blob.stat"),
393 // brain
394 ("brain", "brain.event_counts"),
395 ("brain", "brain.event_page"),
396 ("brain", "brain.profiles"),
397 ("brain", "brain.profile"),
398 ("brain", "brain.resolve"),
399 ("brain", "brain.bindings"),
400 // comm
401 ("comm", "comm.delivered"),
402 ("comm", "comm.transport_status"),
403 ("comm", "comm.inbox"),
404 ("comm", "comm.unread"),
405 ("comm", "comm.thread"),
406 ("comm", "comm.health"),
407 ("comm", "comm.probe"),
408 // gtd
409 ("gtd", "gtd.census"),
410 ("gtd", "gtd.next"),
411 ("gtd", "gtd.tasks"),
412 // kg
413 ("kg", "get"),
414 ("kg", "list"),
415 ("kg", "stats"),
416 ("kg", "count"),
417 ("kg", "search"),
418 ("kg", "neighbors"),
419 ("kg", "traverse"),
420 ("kg", "context"),
421 ("kg", "query"),
422 ("kg", "resolve"),
423 ("kg", "whoami"),
424 // scan runs the secret gate over caller-supplied text in process: no
425 // store read, no store write, no event.
426 ("kg", "scan"),
427 ("kg", "verbs"),
428 ("kg", "stream.read"),
429 ("kg", "stream.stat"),
430 // knowledge (ANN-maintaining search/suggest/compose are excluded)
431 ("knowledge", "knowledge.get"),
432 ("knowledge", "knowledge.list"),
433 ("knowledge", "knowledge.export"),
434 ("knowledge", "knowledge.stats"),
435 ("knowledge", "knowledge.fold"),
436 ("knowledge", "knowledge.topic"),
437 // moodboard
438 ("moodboard", "moodboard.model"),
439 ("moodboard", "moodboard.search"),
440 ("moodboard", "moodboard.preference"),
441 // schedule
442 ("schedule", "schedule.agenda"),
443 // session
444 ("session", "session.list"),
445 ("session", "session.resume"),
446 ("session", "session.export"),
447 ("session", "session.search"),
448 // Fixed SQL reads and file metadata only; no domain or maintenance write.
449 ("session", "session.stats"),
450 // tool (registry, grant and policy reads; tool.suggest runs the same
451 // hybrid search as the kg search and context verbs above)
452 ("tool", "tool.suggest"),
453 ("tool", "tool.describe"),
454 ("tool", "tool.list"),
455 ("tool", "tool.requests"),
456 ("tool", "tool.policies"),
457 ];
458
459 /// Sorted copy of [`Self::ADMISSION_DEGRADE_SAFE_VERBS`], built once, so
460 /// [`VerbRegistryBuilder::build`] can decide each trusted handler's
461 /// eligibility with a binary search instead of a linear scan over every
462 /// entry. Consulted exactly once per registry, at `build()` time — see
463 /// [`Self::admission_degrade_safe`] for why no per-dispatch scan exists
464 /// anymore. The source list above stays grouped by pack (with a `//
465 /// <pack>` comment per group) for human review; this is a derived,
466 /// lookup-shaped view of the same data, not a second source of truth.
467 pub(super) fn admission_degrade_safe_sorted() -> &'static [(&'static str, &'static str)] {
468 static SORTED: std::sync::LazyLock<Vec<(&'static str, &'static str)>> =
469 std::sync::LazyLock::new(|| {
470 let mut pairs = VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS.to_vec();
471 pairs.sort_unstable();
472 pairs
473 });
474 &SORTED
475 }
476
477 /// Whether `verb` is both declared [`VerbCategory::Assertive`] (the
478 /// speech-act tag for handlers that "retrieve and present facts" rather
479 /// than committing a domain change) AND explicitly opted in to
480 /// admission-pressure audit degradation via
481 /// [`Self::ADMISSION_DEGRADE_SAFE_VERBS`] under the exact pack that
482 /// registered it, AND declared by a pack the composition root actually
483 /// vouches for (see [`VerbRegistryBuilder::register_boxed`]'s doc).
484 /// Unknown, non-opted-in, wrong-pack, or untrusted-pack verbs are
485 /// conservatively `false` — fail-closed, so a new Assertive handler (or
486 /// one registered by a pack other than the one the allowlist names, or
487 /// one registered through [`VerbRegistryBuilder::register`] rather than
488 /// the trusted path) hard-fails its audit obligation like any write
489 /// until someone deliberately reviews it and adds it to the allowlist.
490 ///
491 /// `pack.name()` is a value the `PackRuntime` trait object reports about
492 /// itself — any pack registered through the public
493 /// [`VerbRegistryBuilder::register`] path can claim any name, including
494 /// one on the allowlist, whether or not the pack that name actually
495 /// belongs to is also loaded (verb names are unique per registry, so an
496 /// impostor's same-named handler is only reachable when the real pack
497 /// is absent). Binding eligibility to registration-time trust — decided
498 /// by the *caller*, never by the pack instance — is why this checks
499 /// `degrade_safe_verbs` rather than resolving `pack.name()` at query
500 /// time; [`VerbRegistryBuilder::build`] already excluded every untrusted
501 /// pack's handlers from that set.
502 ///
503 /// The whole decision is precomputed once in `VerbRegistryBuilder::build`
504 /// into [`VerbRegistry::degrade_safe_verbs`] — a verb name is unique
505 /// across `Visibility::Verb` handlers within one registry
506 /// (`validate_unique_verb_names`), so this is a single hash-set lookup,
507 /// not a per-dispatch scan over every registered pack's handler list.
508 ///
509 /// Used only to decide whether a dispatch's own audit-obligation row may
510 /// degrade to best-effort on transient audit-lane admission pressure
511 /// (`append_audit_event_best_effort`) — a read that performed no domain
512 /// write must not fail the caller just because the audit lane is
513 /// momentarily saturated. Never used for permission checking, transport
514 /// routing, or return-shape selection.
515 pub(super) fn admission_degrade_safe(&self, verb: &str) -> bool {
516 self.degrade_safe_verbs.contains(verb)
517 }
518
519 /// Transport replay eligibility from the shared operation-effects table,
520 /// restricted to trusted canonical public handlers. A read that persists a
521 /// fresh serve or telemetry row is excluded because the request id is
522 /// correlation, not deduplication.
523 /// Custom and mounted handlers cannot inherit safety from a name/category.
524 pub fn is_read_replay_safe(&self, verb: &str) -> bool {
525 self.read_replay_safe_verbs.contains(verb)
526 }
527
528 /// White-box accessor for [`Self::admission_degrade_safe`], needed
529 /// because the admission-pressure regression tests in
530 /// `tests/read_verb_admission_exhaustion.rs` compile as a separate
531 /// external binary and cannot reach a crate-private method directly —
532 /// the same reason [`audit_admission_refused_obligation_count`] and
533 /// `AuditBatch::test_snapshot` are `pub` rather than `pub(crate)`.
534 #[cfg(any(test, feature = "test-internals"))]
535 pub fn admission_degrade_safe_probe(&self, verb: &str) -> bool {
536 self.admission_degrade_safe(verb)
537 }
538}