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        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}