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 self.authorize_secondary_kg_operation(token, gate_req, Some(effective_id))
122 .await
123 }
124
125 /// Check the canonical wrapper for a proposal worker using its applying caller.
126 pub async fn authorize_canonical_changeset(
127 &self,
128 token: &NamespaceToken,
129 delta: &str,
130 ) -> Result<(), RuntimeError> {
131 let verb = "apply_changeset";
132 if self.is_verb_disabled(verb) {
133 return Err(self.unknown_verb_error(verb));
134 }
135 let mut args = serde_json::json!({"delta": delta});
136 if let Some(namespace) = token.gate_explicit_namespace() {
137 args["namespace"] = Value::String(namespace.to_owned());
138 }
139 let request = GateRequest::new(
140 token.actor().clone(),
141 token.gate_namespace().clone(),
142 verb,
143 args,
144 );
145 self.authorize_secondary_kg_operation(token, request, None)
146 .await
147 }
148
149 pub(crate) async fn authorize_canonical_operation(
150 &self,
151 token: &NamespaceToken,
152 verb: &str,
153 mut args: Value,
154 target_id: uuid::Uuid,
155 ) -> Result<NamespaceToken, RuntimeError> {
156 if !matches!(verb, "create" | "link" | "update" | "delete") {
157 return Err(RuntimeError::InvalidInput(
158 "unsupported canonical constituent verb".into(),
159 ));
160 }
161 if self.is_verb_disabled(verb) {
162 return Err(self.unknown_verb_error(verb));
163 }
164 if args.get("namespace").is_none() {
165 if let Some(namespace) = token.gate_explicit_namespace() {
166 args["namespace"] = Value::String(namespace.to_owned());
167 }
168 }
169 let namespace = match args.get("namespace") {
170 Some(Value::String(value)) => Namespace::parse(value)
171 .map_err(|_| RuntimeError::InvalidInput("invalid namespace".into()))?,
172 Some(_) => return Err(RuntimeError::InvalidInput("invalid namespace".into())),
173 None => token.gate_namespace().clone(),
174 };
175 let request =
176 GateRequest::new(token.actor().clone(), namespace.clone(), verb, args.clone());
177 self.authorize_secondary_kg_operation(token, request, Some(target_id))
178 .await?;
179 Ok(token
180 .with_namespace(namespace.clone())
181 .with_gate_namespace(namespace)
182 .with_gate_explicit_namespace(
183 args.get("namespace")
184 .and_then(Value::as_str)
185 .map(str::to_owned),
186 )
187 .with_request_id(token.request_id()))
188 }
189
190 async fn authorize_secondary_kg_operation(
191 &self,
192 token: &NamespaceToken,
193 gate_req: GateRequest,
194 effective_id: Option<uuid::Uuid>,
195 ) -> Result<(), RuntimeError> {
196 let verb = gate_req.verb.as_str();
197 let decision = khive_gate::check_with_mailbox_policy(self.gate.as_ref(), &gate_req);
198 match decision {
199 Ok(decision) => {
200 let audit = masked_audit_event(&gate_req, &decision, self.gate.impl_name());
201 tracing::info!(
202 audit_event = %serde_json::to_string(&audit)
203 .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
204 effective_target_id = ?effective_id,
205 "gate.check"
206 );
207 let denied = matches!(&decision, GateDecision::Deny { .. });
208 let receipt = if let Some(store) = &self.event_store {
209 let mut event = build_audit_storage_event(
210 &gate_req,
211 &audit,
212 if denied {
213 EventOutcome::Denied
214 } else {
215 EventOutcome::Success
216 },
217 Some(crate::cost_unit::base_resource_payload(token.request_id())),
218 );
219 if let Some(id) = effective_id {
220 event = event.with_target(id);
221 }
222 if denied {
223 self.append_gate_denied_row(store, event, verb).await
224 } else {
225 let outcome = append_audit_event_best_effort(
226 self.audit_batch.as_ref(),
227 store,
228 event,
229 verb,
230 crate::audit_batch::AuditProducer::EffectiveTargetCheck,
231 false,
232 )
233 .await;
234 fold_audit_obligation(Ok(()), outcome, |_| Value::Null)?;
235 crate::error::DenialReceipt::no_store()
236 }
237 } else {
238 crate::error::DenialReceipt::no_store()
239 };
240 match decision {
241 GateDecision::Allow { .. } => Ok(()),
242 GateDecision::Deny { reason } => Err(RuntimeError::PermissionDenied {
243 verb: verb.to_string(),
244 reason,
245 receipt: Box::new(receipt),
246 }),
247 }
248 }
249 Err(error) => Err(self
250 .gate_unavailable_error(&gate_req, &error, token.request_id(), effective_id)
251 .await),
252 }
253 }
254
255 /// Resolve a prefix across the same inventory, rejecting distinct UUIDs.
256 ///
257 /// Retains the local prefix scanner's entity/note/event/edge collision domain,
258 /// including sidecar events. The returned UUID is not a substrate assertion:
259 /// consumers must still fetch/type-check it. All backend failures propagate.
260 pub async fn resolve_kg_read_prefix(
261 &self,
262 runtime: &KhiveRuntime,
263 _token: &NamespaceToken,
264 prefix: &str,
265 include_deleted: bool,
266 ) -> Result<Option<uuid::Uuid>, RuntimeError> {
267 match &self.kg_read_resolver {
268 Some(resolver) => resolver.prefix(prefix, include_deleted).await,
269 None if include_deleted => {
270 runtime
271 .resolve_prefix_unfiltered_including_deleted(prefix)
272 .await
273 }
274 None => runtime.resolve_prefix_unfiltered(prefix).await,
275 }
276 }
277
278 /// This registry's construction-baked default namespace.
279 ///
280 /// Used as the fallback when a request carries no [`RequestIdentity`]
281 /// override (ADR-096 Fork 1) and by transports that need to advertise
282 /// their own resolved identity when forwarding to a warm daemon.
283 pub fn default_namespace(&self) -> &str {
284 &self.default_namespace
285 }
286
287 /// This registry's construction-baked actor identity label, if configured
288 /// (ADR-057). `None` means dispatch mints `ActorRef::anonymous()` absent a
289 /// per-request [`RequestIdentity`] override (ADR-096 Fork 1).
290 pub fn actor_id(&self) -> Option<&str> {
291 self.actor_id.as_deref()
292 }
293
294 /// This registry's construction-baked extra read-visibility namespaces
295 /// (ADR-007 Rev 4 Rule 3b), used absent a per-request [`RequestIdentity`]
296 /// override (ADR-096 Fork 1).
297 pub fn visible_namespaces(&self) -> &[Namespace] {
298 &self.visible_namespaces
299 }
300
301 /// This registry's configured audit `EventStore`, if any (ADR-094).
302 ///
303 /// Lets background tasks that hold a `VerbRegistry` but do not go through
304 /// `dispatch` (e.g. the email channel poll loop) append best-effort
305 /// lifecycle events to the same sink gate-check audit rows use, without
306 /// threading a second `Option<Arc<dyn EventStore>>` field through every
307 /// caller. `None` means either the historical tracing-only default or an
308 /// intentionally read-only audit backend; callers that need to distinguish
309 /// those cases use [`Self::audit_persistence_advisory`].
310 pub fn event_store(&self) -> Option<Arc<dyn EventStore>> {
311 self.event_store.clone()
312 }
313
314 /// Process-lifetime audit-batch health counters for this registry's
315 /// ADR-133 seam, if one is configured. `None` exactly when
316 /// [`Self::event_store`] is `None` — the same condition under which no
317 /// batch exists to report on. The `db_diagnostics` verb feeds this into
318 /// `KhiveRuntime::db_diagnostics_with_audit_metrics` so an operator can
319 /// see flush failures and pure-observability degradation instead of the
320 /// permanently-unavailable placeholder a bare `KhiveRuntime` reports.
321 ///
322 /// `admission_refused_obligations`, `retry_exhausted_obligations`,
323 /// and `admission_unresolved_obligations`
324 /// are sourced separately from [`audit_admission_refused_obligation_count`],
325 /// [`audit_retry_exhausted_obligation_count`], and
326 /// [`audit_admission_unresolved_obligation_count`] rather than from
327 /// `batch.health_metrics()`: they count a decision made in
328 /// `append_audit_event_best_effort` (ADR-103 Amendment 3 / ADR-133
329 /// Amendment 1), not a property of the batch itself, so they are
330 /// process-wide like the rest of this struct's fields rather than
331 /// per-`AuditBatch`.
332 pub fn audit_batch_metrics(&self) -> Option<khive_db::diagnostics::RuntimeAuditBatchMetrics> {
333 self.audit_batch.as_ref().map(|batch| {
334 let m = batch.health_metrics();
335 khive_db::diagnostics::RuntimeAuditBatchMetrics {
336 flush_failures: m.flush_failures,
337 degraded_rows: m.degraded_rows,
338 degraded: m.degraded,
339 admission_refused_obligations: audit_admission_refused_obligation_count(),
340 admission_refused_obligations_last_at_ms:
341 audit_admission_refused_obligation_last_at_ms(),
342 retry_exhausted_obligations: audit_retry_exhausted_obligation_count(),
343 retry_exhausted_obligations_last_at_ms: audit_retry_exhausted_obligation_last_at_ms(
344 ),
345 admission_unresolved_obligations: audit_admission_unresolved_obligation_count(),
346 admission_unresolved_obligations_last_at_ms:
347 audit_admission_unresolved_obligation_last_at_ms(),
348 }
349 })
350 }
351
352 /// This registry's shared ADR-133 audit-batch seam. The recall handler
353 /// uses this same instance for its best-effort `RecallExecuted` rows.
354 /// `None` when no `EventStore` was configured (the batch is constructed
355 /// from one). Admission-pressure mechanism tests also use it so they
356 /// can saturate and drain the SAME instance a real dispatch uses
357 /// (#2117, #2147, #2208, #2217) instead of testing a
358 /// look-alike.
359 pub fn audit_batch_handle(&self) -> Option<Arc<crate::audit_batch::AuditBatch>> {
360 self.audit_batch.clone()
361 }
362
363 /// Stop admitting new audit rows and wait for every already-accepted row
364 /// to reach a terminal state (ADR-133).
365 ///
366 /// A no-op returning `Ok(())` when no `EventStore` — and therefore no
367 /// audit-batch seam — is configured. Callers that own this registry's
368 /// shutdown sequence should call this before tearing down the writer or
369 /// database so no accepted audit row is silently dropped mid-flight.
370 pub async fn shutdown_audit_batch(
371 &self,
372 ) -> Result<(), crate::audit_batch::AuditTerminalReason> {
373 use crate::audit_batch::AuditBatchControl;
374 match &self.audit_batch {
375 Some(audit_batch) => audit_batch.close_and_drain().await,
376 None => Ok(()),
377 }
378 }
379
380 /// Advisory for a dispatch whose configured audit sink is read-only.
381 ///
382 /// The MCP transport places this beside successful per-operation results;
383 /// `None` means audit persistence is configured normally or was never
384 /// configured at all.
385 pub fn audit_persistence_advisory(&self) -> Option<Value> {
386 self.audit_store_read_only.then(|| {
387 serde_json::json!({
388 "code": AUDIT_PERSISTENCE_SKIPPED_READ_ONLY,
389 "severity": "warning",
390 "component": "audit_event_store",
391 "reason": "read_only_backend",
392 "message": "operation completed, but its dispatch audit event was not persisted because the audit backend is read-only",
393 })
394 })
395 }
396
397 /// Explicit, fail-closed opt-in for admission-pressure audit degradation
398 /// (#2147/#2217). `VerbCategory::Assertive` alone is NOT a
399 /// sound proxy for "safe to drop this dispatch's own audit row under
400 /// audit-lane admission pressure": several Assertive handlers have
401 /// their own durable or accounting-bearing side effects. The reviewed
402 /// exclusions are:
403 /// - `memory.recall` dispatches `brain.record_serve` as a background
404 /// write; degrading `memory.recall`'s row raises the risk that a
405 /// serve goes unaccounted for if the ledger dispatch itself later
406 /// also races admission pressure.
407 /// - `db_diagnostics` may backfill WAL frames via a PASSIVE checkpoint
408 /// probe — physical I/O, not a pure in-memory read.
409 /// - `knowledge.search`, `knowledge.suggest`, and auto
410 /// `knowledge.compose` may start persistent ANN consumer/checkpoint
411 /// maintenance from their nominal read path.
412 /// - `git.checkout`, `git.diff` and `git.reconcile` persist a durable
413 /// receipt on every dispatch (checkout and diff also write a manifest
414 /// or diff blob), so their accounting row is not droppable.
415 /// - `tool.check` persists a policy decision receipt. `git.receipts`,
416 /// `git.gates`, `git.status` and `git.log` dispatch that same check,
417 /// so their read results also carry a required decision write.
418 /// - `comm.withheld_body` writes an access audit event on every call;
419 /// the record of who read a withheld original is the point of the
420 /// verb, so its accounting row is never droppable.
421 ///
422 /// What membership here means, precisely: the verb performs no domain
423 /// mutation, so its OWN per-dispatch audit/accounting row may be dropped
424 /// under transient admission pressure without the caller losing a
425 /// meaningful result (ADR-103 Amendment 3, ADR-133 Amendment 1). It does
426 /// NOT mean the handler is free of every event-plane write: `search`
427 /// still fires its own best-effort `SearchExecuted` telemetry, and
428 /// `context` still records a one-time `ConfigLocked` event, both on
429 /// independent code paths this mechanism never touches — those events
430 /// commit or fail on their own terms, unaffected by whether this
431 /// dispatch's own audit row degrades.
432 ///
433 /// Every entry here MUST be declared `VerbCategory::Assertive` in its
434 /// named pack's live vocabulary. The
435 /// `admission_degrade_safe_assertive_census_matches_live_pack_sources`
436 /// test below scans every pack that currently declares public Assertive
437 /// handlers and requires every such handler to be classified exactly
438 /// once as safe or as a known incidental writer. A new Assertive verb
439 /// therefore fails closed both at runtime and in the source census until
440 /// it receives an explicit side-effect review.
441 ///
442 /// Entries are `(owning pack name, verb)` pairs, not bare verb names:
443 /// [`Self::admission_degrade_safe`] requires the handler actually
444 /// resolved for `verb` to belong to the exact pack named here. A verb
445 /// name alone is not a sound key — any pack registered through the same
446 /// [`PackRegistry`]/[`VerbRegistryBuilder`] path can declare a handler
447 /// under any name it likes, including one that collides with a name on
448 /// this list, and unique-verb-name validation only rejects that
449 /// collision when the real owning pack is *also* loaded. A deployment
450 /// that omits the real pack (or loads a third-party pack instead) would
451 /// let a same-named write-performing handler inherit degrade-safety it
452 /// never earned. Binding to the pack closes that gap.
453 pub(super) const ADMISSION_DEGRADE_SAFE_VERBS: &'static [(&'static str, &'static str)] = &[
454 // agent
455 ("agent", "agent.observe"),
456 // exec (reads of the blob store, the run receipt and event tables, or
457 // the resolved configuration; the writers are exec.tree and
458 // exec.tree_put, Declarations, and exec.run, a Directive)
459 ("exec", "exec.tree_get"),
460 ("exec", "exec.tree_diff"),
461 ("exec", "exec.receipt"),
462 ("exec", "exec.runs"),
463 ("exec", "exec.events"),
464 ("exec", "exec.identity"),
465 // git
466 // Canonical get project check plus bounded cursor SELECT; no domain writes.
467 ("git", "git.ingest_cursor"),
468 // blob
469 ("blob", "blob.get"),
470 ("blob", "blob.stat"),
471 // brain
472 ("brain", "brain.event_counts"),
473 ("brain", "brain.event_page"),
474 ("brain", "brain.profiles"),
475 ("brain", "brain.profile"),
476 ("brain", "brain.resolve"),
477 ("brain", "brain.bindings"),
478 // Registered rollback-mode map reads: no domain or maintenance writes.
479 ("code", "code.deps"),
480 ("code", "code.list"),
481 ("code", "code.search"),
482 // comm
483 ("comm", "comm.delivered"),
484 ("comm", "comm.transport_status"),
485 ("comm", "comm.inbox"),
486 ("comm", "comm.unread"),
487 ("comm", "comm.thread"),
488 ("comm", "comm.health"),
489 ("comm", "comm.probe"),
490 // gtd
491 ("gtd", "gtd.census"),
492 ("gtd", "gtd.next"),
493 ("gtd", "gtd.tasks"),
494 // kg
495 ("kg", "get"),
496 ("kg", "list"),
497 ("kg", "stats"),
498 ("kg", "count"),
499 ("kg", "search"),
500 ("kg", "neighbors"),
501 ("kg", "traverse"),
502 ("kg", "context"),
503 ("kg", "query"),
504 ("kg", "resolve"),
505 ("kg", "whoami"),
506 // schema reports the loaded vocabulary and the declared endpoint
507 // matrix from the registry's in-memory declarations: no store read,
508 // no store write, no event.
509 ("kg", "schema"),
510 // scan runs the secret gate over caller-supplied text in process: no
511 // store read, no store write, no event.
512 ("kg", "scan"),
513 ("kg", "verbs"),
514 ("kg", "stream.read"),
515 ("kg", "stream.stat"),
516 // knowledge (ANN-maintaining search/suggest/compose are excluded)
517 ("knowledge", "knowledge.get"),
518 ("knowledge", "knowledge.list"),
519 ("knowledge", "knowledge.export"),
520 ("knowledge", "knowledge.stats"),
521 ("knowledge", "knowledge.fold"),
522 ("knowledge", "knowledge.topic"),
523 // moodboard
524 ("moodboard", "moodboard.model"),
525 ("moodboard", "moodboard.search"),
526 ("moodboard", "moodboard.preference"),
527 // schedule
528 ("schedule", "schedule.agenda"),
529 // session
530 ("session", "session.list"),
531 ("session", "session.resume"),
532 ("session", "session.export"),
533 ("session", "session.search"),
534 // Fixed SQL reads and file metadata only; no domain or maintenance write.
535 ("session", "session.stats"),
536 // tool (registry, grant and policy reads; tool.suggest runs the same
537 // hybrid search as the kg search and context verbs above)
538 ("tool", "tool.suggest"),
539 ("tool", "tool.describe"),
540 ("tool", "tool.list"),
541 ("tool", "tool.requests"),
542 ("tool", "tool.policies"),
543 ];
544
545 /// Sorted copy of [`Self::ADMISSION_DEGRADE_SAFE_VERBS`], built once, so
546 /// [`VerbRegistryBuilder::build`] can decide each trusted handler's
547 /// eligibility with a binary search instead of a linear scan over every
548 /// entry. Consulted exactly once per registry, at `build()` time — see
549 /// [`Self::admission_degrade_safe`] for why no per-dispatch scan exists
550 /// anymore. The source list above stays grouped by pack (with a `//
551 /// <pack>` comment per group) for human review; this is a derived,
552 /// lookup-shaped view of the same data, not a second source of truth.
553 pub(super) fn admission_degrade_safe_sorted() -> &'static [(&'static str, &'static str)] {
554 static SORTED: std::sync::LazyLock<Vec<(&'static str, &'static str)>> =
555 std::sync::LazyLock::new(|| {
556 let mut pairs = VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS.to_vec();
557 pairs.sort_unstable();
558 pairs
559 });
560 &SORTED
561 }
562
563 /// Whether `verb` is both declared [`VerbCategory::Assertive`] (the
564 /// speech-act tag for handlers that "retrieve and present facts" rather
565 /// than committing a domain change) AND explicitly opted in to
566 /// admission-pressure audit degradation via
567 /// [`Self::ADMISSION_DEGRADE_SAFE_VERBS`] under the exact pack that
568 /// registered it, AND declared by a pack the composition root actually
569 /// vouches for (see [`VerbRegistryBuilder::register_boxed`]'s doc).
570 /// Unknown, non-opted-in, wrong-pack, or untrusted-pack verbs are
571 /// conservatively `false` — fail-closed, so a new Assertive handler (or
572 /// one registered by a pack other than the one the allowlist names, or
573 /// one registered through [`VerbRegistryBuilder::register`] rather than
574 /// the trusted path) hard-fails its audit obligation like any write
575 /// until someone deliberately reviews it and adds it to the allowlist.
576 ///
577 /// `pack.name()` is a value the `PackRuntime` trait object reports about
578 /// itself — any pack registered through the public
579 /// [`VerbRegistryBuilder::register`] path can claim any name, including
580 /// one on the allowlist, whether or not the pack that name actually
581 /// belongs to is also loaded (verb names are unique per registry, so an
582 /// impostor's same-named handler is only reachable when the real pack
583 /// is absent). Binding eligibility to registration-time trust — decided
584 /// by the *caller*, never by the pack instance — is why this checks
585 /// `degrade_safe_verbs` rather than resolving `pack.name()` at query
586 /// time; [`VerbRegistryBuilder::build`] already excluded every untrusted
587 /// pack's handlers from that set.
588 ///
589 /// The whole decision is precomputed once in `VerbRegistryBuilder::build`
590 /// into [`VerbRegistry::degrade_safe_verbs`] — a verb name is unique
591 /// across `Visibility::Verb` handlers within one registry
592 /// (`validate_unique_verb_names`), so this is a single hash-set lookup,
593 /// not a per-dispatch scan over every registered pack's handler list.
594 ///
595 /// Used only to decide whether a dispatch's own audit-obligation row may
596 /// degrade to best-effort on transient audit-lane admission pressure
597 /// (`append_audit_event_best_effort`) — a read that performed no domain
598 /// write must not fail the caller just because the audit lane is
599 /// momentarily saturated. Never used for permission checking, transport
600 /// routing, or return-shape selection.
601 pub(super) fn admission_degrade_safe(&self, verb: &str) -> bool {
602 self.degrade_safe_verbs.contains(verb)
603 }
604
605 /// Transport replay eligibility from the shared operation-effects table,
606 /// restricted to trusted canonical public handlers. A read that persists a
607 /// fresh serve or telemetry row is excluded because the request id is
608 /// correlation, not deduplication.
609 /// Custom and mounted handlers cannot inherit safety from a name/category.
610 pub fn is_read_replay_safe(&self, verb: &str) -> bool {
611 self.read_replay_safe_verbs.contains(verb)
612 }
613
614 /// White-box accessor for [`Self::admission_degrade_safe`], needed
615 /// because the admission-pressure regression tests in
616 /// `tests/read_verb_admission_exhaustion.rs` compile as a separate
617 /// external binary and cannot reach a crate-private method directly —
618 /// the same reason [`audit_admission_refused_obligation_count`] and
619 /// `AuditBatch::test_snapshot` are `pub` rather than `pub(crate)`.
620 #[cfg(any(test, feature = "test-internals"))]
621 pub fn admission_degrade_safe_probe(&self, verb: &str) -> bool {
622 self.admission_degrade_safe(verb)
623 }
624}