Skip to main content

khive_runtime/
pack.rs

1// FILE SIZE JUSTIFICATION: pack.rs is the load-bearing dispatch core — VerbRegistry,
2// VerbRegistryBuilder, PackRuntime, DispatchHook, and their test scaffolding all
3// share internal state (packs Vec, gate, event_store) that cannot be cleanly split
4// without exposing private fields or duplicating the scaffolding. Inline tests cover
5// collision detection and dispatch path that require direct access to VerbRegistry
6// internals. Split plan: when the verb surface reaches a stable v1 API, extract
7// VerbRegistryBuilder into `pack/builder.rs` and gate/event logic into `pack/dispatch.rs`.
8//! Pack runtime trait and verb registry.
9//!
10//! `PackRuntime` mirrors `Pack`'s const associated items as methods for object safety.
11//! Build a [`VerbRegistry`] via `VerbRegistryBuilder::build()`; registration is builder-only.
12
13use std::any::Any;
14use std::collections::{HashMap, HashSet, VecDeque};
15use std::sync::Arc;
16use std::time::Instant;
17
18use crate::operations::{LinkSpec, Resolved};
19use crate::runtime::NamespaceToken;
20use async_trait::async_trait;
21use khive_gate::{AllowAllGate, AuditEvent, GateDecision, GateRef, GateRequest};
22use khive_storage::{Event, EventStore, EventView, SubstrateKind};
23use khive_types::{EventKind, EventOutcome, Namespace};
24use serde_json::Value;
25
26pub use khive_types::{
27    EdgeEndpointRule, EndpointKind, EntityTypeDef, HandlerDef, IdResolutionMode, NoteKindSpec,
28    NoteLifecycleSpec, PackColumnAddition, PackColumnAffinity, PackSchemaPlan, ParamDef,
29    VerbCategory, VerbPresentationPolicy, Visibility, RESERVED_ENVELOPE_ARGS,
30};
31// Backward-compat re-export.
32#[allow(deprecated)]
33pub use khive_types::VerbDef;
34
35use crate::validation::ValidationRule;
36
37/// Name of the pack providing the shared CRUD verbs and the general-purpose
38/// note kinds those verbs exist to serve.
39///
40/// Its note kinds are the ones any caller may author freely through `create`
41/// and `update`; every other pack's note kinds are records maintained by that
42/// pack's own verbs. Used by
43/// [`VerbRegistry::pack_owned_note_kinds`].
44pub const GENERIC_CRUD_PACK: &str = "kg";
45
46/// Stable advisory code emitted when a successful inspection cannot persist
47/// its dispatch audit because the configured audit backend is read-only.
48pub const AUDIT_PERSISTENCE_SKIPPED_READ_ONLY: &str = "audit_persistence_skipped_read_only";
49
50const FULL_UUID_IDENTIFIER_HELP: &str = "A complete UUID spelling accepted by the consuming \
51    parameter directly names one globally unique record; direct UUID lookup is not a namespace \
52    search. Strict identifier responses use canonical lowercase dashed UUIDs.";
53const SHORT_PREFIX_IDENTIFIER_HELP: &str = "A short UUID prefix is at least 8 hexadecimal \
54    characters without dashes that do not parse as a complete UUID. It is a resolution, not a \
55    direct identifier; a 32-character compact UUID is complete input instead. Its lookup scope \
56    belongs to the consuming parameter — see `identifier_resolution.resolution_modes` for the \
57    exhaustive per-mode rule, and each `uuid`/`array of uuid` parameter's own description for \
58    which mode it uses. A prefix can be missing or ambiguous.";
59const IDENTIFIER_PARAMETER_HELP: &str = "A parameter that requires a full UUID rejects prefixes \
60    and explains the resolution consequence. Its corresponding response field remains a \
61    canonical full UUID so the value can be submitted again.";
62
63/// Single-source, per-[`IdResolutionMode`] contract text.
64///
65/// Every `uuid`/`array of uuid` [`ParamDef`] declares which of these modes its
66/// handler actually implements (see [`IdResolutionMode`]'s own doc comment).
67/// [`VerbRegistry::describe_verb`] renders the SAME text in two places: once
68/// per matching parameter's description, and once in the top-level
69/// `identifier_resolution.resolution_modes` map — so the wording can never
70/// drift between the two call sites, and a caller reading only the top-level
71/// envelope still sees every mode that exists on the wire, not just the ones
72/// this particular verb happens to use.
73///
74/// `None` for [`IdResolutionMode::NotApplicable`]: nothing is appended to a
75/// non-identifier parameter's description, and it is never listed in
76/// `resolution_modes`.
77fn resolution_mode_contract(mode: IdResolutionMode) -> Option<&'static str> {
78    match mode {
79        IdResolutionMode::NotApplicable => None,
80        IdResolutionMode::UnscopedById => Some(
81            "ID contract (unscoped by-ID, ADR-007 Rev 6): a full UUID and a short hex prefix \
82             (8+ hex chars) both resolve with no namespace filter — the caller already knows \
83             the specific record, and authorization is the Gate's seam, not resolution's. A \
84             prefix matching nothing or matching more than one record is rejected. Used by \
85             get/update/delete/merge/link (link's source_id/target_id resolve through the same \
86             unfiltered path as the four record-level by-ID verbs), GTD's lifecycle id \
87             parameters, and brain's feedback target_id.",
88        ),
89        IdResolutionMode::PrefixScopedToPrimary => Some(
90            "ID contract (prefix scoped to primary namespace): a full UUID resolves as given, \
91             with no namespace check performed by this resolver. A short hex prefix (8+ hex \
92             chars) is resolved by searching only the caller's primary namespace, and is \
93             rejected if it matches nothing or matches more than one record there.",
94        ),
95        IdResolutionMode::FullAndPrefixScopedToPrimary => Some(
96            "ID contract (full UUID and prefix both scoped to primary namespace): both a full \
97             UUID and a short hex prefix (8+ hex chars) are validated against the caller's \
98             primary namespace — a record that exists but belongs to a different namespace \
99             resolves as not found. A prefix matching more than one record in that namespace \
100             is rejected as ambiguous.",
101        ),
102        IdResolutionMode::FullUuidOnlyScopedToPrimary => Some(
103            "ID contract (full UUID only, scoped to primary namespace): only a complete UUID \
104             is accepted — a short hex prefix is rejected outright because this field stores \
105             an explicit stable reference — and the UUID is validated against the caller's own \
106             (primary) namespace; a record that exists in a different namespace resolves as \
107             not found.",
108        ),
109        IdResolutionMode::UnscopedFullUuidOnly => Some(
110            "ID contract (full UUID only, unscoped): only a complete UUID is accepted — a \
111             short hex prefix is rejected outright — and no namespace check is performed on \
112             this parameter itself; any namespace scoping comes from the enclosing operation, \
113             not from this identifier.",
114        ),
115        IdResolutionMode::EdgeOrEventTarget => Some(
116            "ID contract (list target by kind): kind=event accepts only a full subject UUID; \
117             prefixes and names are rejected without graph resolution. Event rows remain \
118             scoped to the authorized event namespace. For kind=edge, a full UUID resolves as \
119             given; a unique 8+ hex prefix or entity name resolves in the primary namespace.",
120        ),
121    }
122}
123
124/// Stable wire key for an [`IdResolutionMode`], used as the key under
125/// `identifier_resolution.resolution_modes`.
126fn resolution_mode_key(mode: IdResolutionMode) -> &'static str {
127    match mode {
128        IdResolutionMode::NotApplicable => "not_applicable",
129        IdResolutionMode::UnscopedById => "unscoped_by_id",
130        IdResolutionMode::PrefixScopedToPrimary => "prefix_scoped_to_primary",
131        IdResolutionMode::FullAndPrefixScopedToPrimary => "full_and_prefix_scoped_to_primary",
132        IdResolutionMode::FullUuidOnlyScopedToPrimary => "full_uuid_only_scoped_to_primary",
133        IdResolutionMode::UnscopedFullUuidOnly => "unscoped_full_uuid_only",
134        IdResolutionMode::EdgeOrEventTarget => "edge_or_event_target",
135    }
136}
137
138fn identifier_resolution_help() -> Value {
139    let modes: serde_json::Map<String, Value> = [
140        IdResolutionMode::UnscopedById,
141        IdResolutionMode::PrefixScopedToPrimary,
142        IdResolutionMode::FullAndPrefixScopedToPrimary,
143        IdResolutionMode::FullUuidOnlyScopedToPrimary,
144        IdResolutionMode::UnscopedFullUuidOnly,
145        IdResolutionMode::EdgeOrEventTarget,
146    ]
147    .into_iter()
148    .map(|mode| {
149        (
150            resolution_mode_key(mode).to_string(),
151            Value::String(
152                resolution_mode_contract(mode)
153                    .expect("every non-NotApplicable mode has contract text")
154                    .to_string(),
155            ),
156        )
157    })
158    .collect();
159
160    serde_json::json!({
161        "full_uuid": FULL_UUID_IDENTIFIER_HELP,
162        "short_prefix": SHORT_PREFIX_IDENTIFIER_HELP,
163        "parameter_rule": IDENTIFIER_PARAMETER_HELP,
164        "resolution_modes": modes,
165    })
166}
167
168/// Pack-auxiliary schema plan.
169///
170/// Declares `CREATE TABLE IF NOT EXISTS` statements for pack-owned tables that
171/// are NOT part of the core substrate schema (entities, notes, edges, events).
172/// Applied at boot via `StorageBackend::apply_pack_ddl_statements_with_columns`,
173/// together with [`PackRuntime::schema_column_additions`].
174///
175/// Core substrate tables evolve through versioned migrations. Pack schema is
176/// strictly for pack-auxiliary tables (e.g. GTD lifecycle audit, memory index).
177/// v1 pack schemas are non-versioned.
178#[derive(Debug, Default, Clone)]
179pub struct SchemaPlan {
180    /// Owning pack name.
181    pub pack: &'static str,
182    /// DDL statements applied idempotently at boot.
183    /// Each entry must be a self-contained `CREATE TABLE IF NOT EXISTS` or
184    /// similar idempotent statement.
185    pub statements: &'static [&'static str],
186}
187
188impl SchemaPlan {
189    /// Construct a `SchemaPlan` with no statements.
190    ///
191    /// Packs whose state lives entirely in the core substrate tables (entities,
192    /// notes, edges) use this as their `schema_plan()` return value.
193    pub const fn empty() -> Self {
194        Self {
195            pack: "",
196            statements: &[],
197        }
198    }
199
200    /// Returns `true` when the plan contains no DDL statements.
201    pub fn is_empty(&self) -> bool {
202        self.statements.is_empty()
203    }
204}
205
206/// Best-effort hook called after every successful verb dispatch.
207///
208/// The runtime supplies a synthetic [`EventView`] whose `event` describes the
209/// dispatch outcome and whose `observations` vector is currently empty. Loading
210/// persisted provenance observations belongs to an explicit caller or the
211/// deferred event-consumer contract; this hook does not provide it.
212#[async_trait]
213pub trait DispatchHook: Send + Sync {
214    /// Called with the dispatch-outcome event view after a successful pack dispatch.
215    ///
216    /// Errors are logged via `tracing::warn!` and never propagated to the
217    /// caller; the dispatch has already succeeded.
218    async fn on_dispatch(&self, view: &EventView);
219}
220
221use crate::error::{
222    AuditObligationFailure, CircularPackDependency, DispatchError, MissingPackDependencies,
223    MissingPackDependency, RuntimeError,
224};
225use crate::KhiveRuntime;
226
227/// Async dispatch trait for packs.
228///
229/// This is the object-safe behavioral counterpart to `khive_types::Pack`.
230/// `Pack` uses const associated items (not object-safe in Rust); this trait
231/// mirrors that metadata as methods and adds async dispatch.
232///
233/// Registration requires `P: Pack + PackRuntime` — the compiler enforces
234/// that every runtime pack also declares its vocabulary via `Pack`.
235#[async_trait]
236pub trait PackRuntime: Send + Sync {
237    /// Pack name — must equal `<Self as Pack>::NAME`.
238    fn name(&self) -> &str;
239
240    /// Optional instance-owned state for host work outside verb dispatch.
241    /// Return the same shared state used by this pack's handlers. The host
242    /// owns task startup and shutdown; this accessor must not start work.
243    fn host_state(&self) -> Option<Arc<dyn Any + Send + Sync>> {
244        None
245    }
246
247    /// Validate this pack instance's configuration before it can execute.
248    /// Metadata-only construction does not activate packs.
249    fn validate_config(&self) -> Result<(), RuntimeError> {
250        Ok(())
251    }
252
253    /// Note kinds this pack owns — must equal `<Self as Pack>::NOTE_KINDS`.
254    fn note_kinds(&self) -> &'static [&'static str];
255
256    /// Entity kinds this pack owns — must equal `<Self as Pack>::ENTITY_KINDS`.
257    fn entity_kinds(&self) -> &'static [&'static str];
258
259    /// Brain profile consumer kinds this pack requests — must equal
260    /// `<Self as Pack>::BRAIN_CONSUMER_KINDS`.
261    fn brain_consumer_kinds(&self) -> &'static [&'static str] {
262        &[]
263    }
264
265    /// Trusted in-process section feedback after the calling pack has validated
266    /// its own target. This is not a registered handler or a wire entry point.
267    async fn apply_profile_section_feedback(
268        &self,
269        _token: &NamespaceToken,
270        _profile_id: &str,
271        _section_signals: Value,
272        _target_attribution: Option<String>,
273    ) -> Result<Value, RuntimeError> {
274        Err(RuntimeError::InvalidInput(format!(
275            "pack {:?} does not support profile section feedback",
276            self.name()
277        )))
278    }
279
280    /// Handlers this pack registers — must equal `<Self as Pack>::HANDLERS`.
281    fn handlers(&self) -> &'static [HandlerDef];
282
283    /// Optional canonical input schema owned by the pack; ParamDefs remain available.
284    fn input_schema(&self, _verb: &str) -> Option<Value> {
285        None
286    }
287
288    /// Pack-extensible edge endpoint rules — must equal `<Self as Pack>::EDGE_RULES`.
289    /// Defaults to empty so existing packs that don't extend the edge contract
290    /// can ignore it.
291    fn edge_rules(&self) -> &'static [EdgeEndpointRule] {
292        &[]
293    }
294
295    /// Pack-extensible entity-type subtypes — must equal `<Self as Pack>::ENTITY_TYPES`.
296    /// Defaults to empty so existing packs that don't extend the entity_type
297    /// registry can ignore it.
298    fn entity_types(&self) -> &'static [EntityTypeDef] {
299        &[]
300    }
301
302    /// Pack names whose vocabulary this pack references.
303    /// Defaults to empty so existing packs compile without changes.
304    fn requires(&self) -> &'static [&'static str] {
305        &[]
306    }
307
308    /// NoteKindSpec declarations for note kinds this pack owns.
309    ///
310    /// Packs that introduce note kinds with explicit lifecycle semantics
311    /// declare the spec here.  The runtime collects these for introspection
312    /// and future enforcement.  Defaults to empty so existing packs compile
313    /// without changes.
314    fn note_kind_specs(&self) -> &'static [NoteKindSpec] {
315        &[]
316    }
317
318    /// Optional per-kind hook for shared CRUD specialization.
319    ///
320    /// When a kind is owned by this pack (declared in `note_kinds()` or
321    /// `entity_kinds()`), returning `Some(hook)` opts that kind into
322    /// pack-specific behavior — defaults, derived properties, side-effect
323    /// edges — through the shared `create` path. Returning `None` keeps
324    /// the kind as plain storage with no specialization.
325    fn kind_hook(&self, _kind: &str) -> Option<Arc<dyn KindHook>> {
326        None
327    }
328
329    /// Accept the trusted channel-ingest capability grant for this specific
330    /// pack instance.
331    ///
332    /// Called at most once per instance, immediately after this instance is
333    /// constructed via [`PackFactory::create_install`], and only for packs
334    /// whose name appears in `CHANNEL_INGEST_CAPABLE_PACKS`. Storing the
335    /// grant on `self` (rather than on the `&'static dyn PackFactory`, which
336    /// is a single process-wide singleton shared by every instance the
337    /// factory ever creates) makes the grant instance-bound: a `CommPack`
338    /// built outside [`PackRegistry::register_packs`] holds no capability
339    /// unless something calls this on that specific instance. Defaults to a
340    /// no-op so packs outside the allowlist compile without changes.
341    fn accept_channel_ingest_capability(&self, _capability: ChannelIngestCapability) {}
342
343    /// Pack-auxiliary schema.
344    ///
345    /// Returns DDL statements for pack-owned tables that are NOT part of the
346    /// core substrate schema. Statements are idempotent (`CREATE TABLE IF NOT
347    /// EXISTS`) so callers can apply them safely on every registration. Core
348    /// substrate tables evolve through versioned migrations; pack schema is
349    /// strictly pack-auxiliary.
350    ///
351    /// Defaults to an empty plan — packs that store everything in the core
352    /// substrate tables (entities, notes, edges, events) return this default.
353    ///
354    /// Plans are aggregated via [`VerbRegistry::all_schema_plans`] and applied
355    /// at startup via `KhiveMcpServer::with_packs`. Packs that need their
356    /// schema present (e.g. GTD) also self-bootstrap lazily on first call for
357    /// robustness in test contexts that create fresh in-memory databases.
358    fn schema_plan(&self) -> SchemaPlan {
359        SchemaPlan::empty()
360    }
361
362    /// Nullable-column upgrades for this pack's auxiliary tables.
363    ///
364    /// Must equal `Pack::SCHEMA_COLUMN_ADDITIONS`. The backend validates and
365    /// adds missing columns on existing tables before applying the full schema
366    /// plan, then validates every declared column. Both steps share the plan's
367    /// transaction. Defaults to empty for packs with no auxiliary upgrades.
368    fn schema_column_additions(&self) -> &'static [PackColumnAddition] {
369        &[]
370    }
371
372    /// Domain-specific validation rules contributed by this pack.
373    ///
374    /// Rule IDs MUST follow the `<pack>/<rule-id>` namespace convention.
375    /// Built-in rules (no pack prefix) are reserved for the `khive-runtime`
376    /// validation infrastructure.
377    ///
378    /// Defaults to empty — packs with no domain-specific rules return `&[]`.
379    fn validation_rules(&self) -> &'static [ValidationRule] {
380        &[]
381    }
382
383    /// Register custom embedding providers with the runtime. Called during pack
384    /// initialisation, before the first verb dispatch, so `KhiveRuntime::embedder(name)`
385    /// resolves provider names declared here. Default no-op — packs that only use
386    /// built-in lattice models do not need to override this.
387    /// See `docs/api/pack.md#register_embedders` for a usage example.
388    fn register_embedders(&self, _runtime: &KhiveRuntime) {}
389
390    /// Install a pack-owned entity-type validator on the runtime, called during pack
391    /// initialisation (after the registry is built, before the first dispatch) so
392    /// `create_many`/`create_entity` reject unregistered `entity_type` values at the
393    /// runtime layer. Default no-op leaves the validator absent (skip-when-None).
394    /// See `docs/api/pack.md#register_entity_type_validator` for the two-hook compatibility contract.
395    fn register_entity_type_validator(&self, _runtime: &KhiveRuntime) {}
396
397    /// Install a pack-owned entity-type validator that also receives the boot-time
398    /// composed set of every loaded pack's `ENTITY_TYPES` ([`VerbRegistry::all_entity_types`]).
399    /// Defaults to calling [`register_entity_type_validator`](Self::register_entity_type_validator)
400    /// with just the runtime. `call_register_entity_type_validators` calls this hook, not
401    /// the simpler one — override this to receive the composed vocabulary.
402    /// See `docs/api/pack.md#register_entity_type_validator` for the two-hook compatibility contract.
403    fn register_entity_type_validator_with_types(
404        &self,
405        runtime: &KhiveRuntime,
406        _pack_entity_types: &[EntityTypeDef],
407    ) {
408        self.register_entity_type_validator(runtime);
409    }
410
411    /// Install a pack-owned note-mutation hook on the runtime, called during pack
412    /// initialisation with the same timing as `register_entity_type_validator`. Packs
413    /// that cache derived state keyed by note content (e.g. `khive-pack-memory`'s warm
414    /// ANN index) override this to install a hook via
415    /// `KhiveRuntime::install_note_mutation_hook`. Default no-op leaves the hook absent.
416    /// See `docs/api/pack.md#register_note_mutation_hook` for cross-pack notification rationale.
417    fn register_note_mutation_hook(&self, _runtime: &KhiveRuntime) {}
418
419    /// Install a note-write validator on the runtime, called at pack
420    /// initialisation with the same timing as `register_note_mutation_hook`.
421    ///
422    /// A pack owning a note kind whose properties carry identity that the
423    /// runtime can derive from the authorization token implements this and
424    /// calls `KhiveRuntime::install_note_write_validator`, so the identity is
425    /// derived at every note-write site rather than trusted from caller input
426    /// on the write paths that reach no pack verb. Default no-op leaves the
427    /// slot absent. The slot holds one validator, so an implementation must
428    /// return properties for kinds it does not own unchanged.
429    fn register_note_write_validator(&self, _runtime: &KhiveRuntime) {}
430
431    /// Warm up any in-memory state from persisted snapshots (optional). Called after
432    /// all packs are registered but before serving the first request. Must be
433    /// idempotent and infallible — errors are logged internally, never propagated.
434    async fn warm(&self) {}
435
436    /// Names of all embedding models registered on this pack's underlying runtime
437    /// handle. Defaults to empty — only packs that own embedding-bearing verbs
438    /// (kg, memory) need to override this.
439    /// See `docs/api/pack.md#registered_embedding_model_names` for the ADR-103 consumer.
440    fn registered_embedding_model_names(&self) -> Vec<String> {
441        Vec::new()
442    }
443
444    fn mounted_namespace(&self) -> Option<&str> {
445        None
446    }
447
448    /// Advisory owned catalog only: no storage, process, gate, or audit work.
449    fn mounted_catalog_snapshot(&self) -> Vec<crate::mounted_verb::MountedVerb> {
450        Vec::new()
451    }
452
453    async fn mounted_catalog(&self) -> Result<Vec<crate::mounted_verb::MountedVerb>, RuntimeError> {
454        Ok(Vec::new())
455    }
456
457    async fn dispatch_mounted(
458        &self,
459        _definition: &crate::mounted_verb::MountedVerb,
460        verb: &str,
461        params: Value,
462        registry: &VerbRegistry,
463        token: &NamespaceToken,
464    ) -> Result<Value, RuntimeError> {
465        self.dispatch(verb, params, registry, token).await
466    }
467
468    /// Dispatch a verb call. Returns serialized JSON response.
469    ///
470    /// The `registry` parameter gives the handler access to the merged
471    /// vocabulary and kind hooks across all loaded packs.
472    /// The `token` is an authorized namespace token minted by the dispatch
473    /// boundary after gate authorization — handlers must use it directly.
474    async fn dispatch(
475        &self,
476        verb: &str,
477        params: Value,
478        registry: &VerbRegistry,
479        token: &NamespaceToken,
480    ) -> Result<Value, RuntimeError>;
481}
482
483/// Per-kind specialization for shared CRUD.
484///
485/// Packs implement `KindHook` for kinds they own that need:
486/// - **Defaults** filled into create args (e.g. `status="inbox"` for tasks)
487/// - **Derived properties** computed from args (e.g. salience from priority)
488/// - **Side-effect writes** after the storage commit (e.g. `depends_on` edges)
489/// - **Cross-pack validation** before shared CRUD mutates an owned kind
490///
491/// Hooks are stateless from the framework's perspective — they receive the
492/// runtime and the current mutation inputs as method parameters. The pack
493/// registers them via [`PackRuntime::kind_hook`].
494///
495/// Lifecycle verbs (e.g. gtd's `complete`, `transition`) remain pack-owned
496/// verbs. Shared `create`, note `update`, entity `update`, and `link` calls
497/// flow through this trait when an endpoint kind has an owning pack hook.
498///
499/// A hook that still overrides the removed sequencing method does not compile, which is the point
500/// of the move: an implementor cannot replace the validator by replacing the sequence, because
501/// there is no sequence on this trait to replace.
502///
503/// ```compile_fail
504/// use async_trait::async_trait;
505/// use khive_runtime::{KhiveRuntime, KindHook, NamespaceToken, RuntimeError};
506/// use serde_json::Value;
507///
508/// #[derive(Debug)]
509/// struct Sequencing;
510///
511/// #[async_trait]
512/// impl KindHook for Sequencing {
513///     async fn prepare_create(
514///         &self,
515///         _runtime: &KhiveRuntime,
516///         _args: &mut Value,
517///     ) -> Result<(), RuntimeError> {
518///         Ok(())
519///     }
520///
521///     async fn after_create(
522///         &self,
523///         _runtime: &KhiveRuntime,
524///         _id: uuid::Uuid,
525///         _args: &Value,
526///     ) -> Result<(), RuntimeError> {
527///         Ok(())
528///     }
529///
530///     async fn prepare_note_update(
531///         &self,
532///         _runtime: &KhiveRuntime,
533///         _token: &NamespaceToken,
534///         _note: &khive_storage::Note,
535///         _args: &mut Value,
536///     ) -> Result<(), RuntimeError> {
537///         Ok(())
538///     }
539/// }
540/// ```
541///
542/// The companion below is the control, and it is what makes the arm above mean anything: a
543/// `compile_fail` doctest passes when the code fails to compile for ANY reason, including a stale
544/// import or a renamed type. This one is structurally identical and overrides the two halves a pack
545/// is meant to implement, so it must compile — if it stops compiling, the arm above has stopped
546/// testing the method and is passing on the scaffolding instead.
547///
548/// ```
549/// use async_trait::async_trait;
550/// use khive_runtime::{KhiveRuntime, KindHook, NamespaceToken, RuntimeError};
551/// use serde_json::Value;
552///
553/// #[derive(Debug)]
554/// struct Halves;
555///
556/// #[async_trait]
557/// impl KindHook for Halves {
558///     async fn prepare_create(
559///         &self,
560///         _runtime: &KhiveRuntime,
561///         _args: &mut Value,
562///     ) -> Result<(), RuntimeError> {
563///         Ok(())
564///     }
565///
566///     async fn after_create(
567///         &self,
568///         _runtime: &KhiveRuntime,
569///         _id: uuid::Uuid,
570///         _args: &Value,
571///     ) -> Result<(), RuntimeError> {
572///         Ok(())
573///     }
574///
575///     async fn normalize_note_update(
576///         &self,
577///         _runtime: &KhiveRuntime,
578///         _token: &NamespaceToken,
579///         _note: &khive_storage::Note,
580///         _args: &mut Value,
581///     ) -> Result<(), RuntimeError> {
582///         Ok(())
583///     }
584///
585///     async fn validate_note_update(
586///         &self,
587///         _runtime: &KhiveRuntime,
588///         _token: &NamespaceToken,
589///         _note: &khive_storage::Note,
590///         _properties: Option<&Value>,
591///     ) -> Result<(), RuntimeError> {
592///         Ok(())
593///     }
594/// }
595/// ```
596#[async_trait]
597pub trait KindHook: Send + Sync + std::fmt::Debug {
598    /// Mutate args before the storage write. Fill defaults, normalize values,
599    /// rearrange user-facing fields into the storage shape expected by the
600    /// shared CRUD handler.
601    ///
602    /// Returning an error aborts the create call (no storage write happens).
603    async fn prepare_create(
604        &self,
605        runtime: &KhiveRuntime,
606        args: &mut Value,
607    ) -> Result<(), RuntimeError>;
608
609    /// Fire side effects after a successful storage write — graph edges,
610    /// derived observations, etc. The newly created record's UUID is passed
611    /// so the hook can attach metadata referencing it.
612    ///
613    /// Errors here are **logged but not propagated** — the storage write has
614    /// already succeeded; failing the call would mislead the caller.
615    /// Implementations should `tracing::warn!` and return `Ok(())` for
616    /// best-effort side effects.
617    async fn after_create(
618        &self,
619        runtime: &KhiveRuntime,
620        id: uuid::Uuid,
621        args: &Value,
622    ) -> Result<(), RuntimeError>;
623
624    /// Validate an approved AddEntity draft before preparing domain writes.
625    /// The draft kind is canonical.
626    /// This must not mutate storage or normalize the approved draft. The default
627    /// accepts it. This separate seam never invokes shared-create lifecycle
628    /// hooks and does not apply to AddNote; see `validate_proposal_note` below
629    /// for that route.
630    fn validate_proposal_entity(
631        &self,
632        _entity: &khive_types::EntityDraft,
633    ) -> Result<(), RuntimeError> {
634        Ok(())
635    }
636
637    /// Validate an `AddNote` draft on the proposal-note route, analogous to
638    /// [`Self::validate_proposal_entity`] but for notes. The kg pack's
639    /// proposal route calls this against the same immutable changeset at two
640    /// points: once when a new `propose` call is accepted, and again when an
641    /// approved proposal is applied, so a kind that refuses shared creation
642    /// is not bypassed by proposing the same creation instead. The draft's
643    /// kind is the owning pack's canonical spelling. This must not mutate
644    /// storage or normalize the draft; it only accepts or refuses. The
645    /// default accepts it.
646    fn validate_proposal_note(&self, _note: &khive_types::NoteDraft) -> Result<(), RuntimeError> {
647        Ok(())
648    }
649
650    /// Normalize caller-facing note-update fields before validation runs.
651    ///
652    /// Override this when a kind-owning pack's caller-facing note fields
653    /// mirror owned properties and must be changed together (for example, a
654    /// task's searchable `content` and `properties.description`). Validation
655    /// is not this method's job: the registry runs
656    /// [`Self::validate_note_update`] after this method returns, regardless of
657    /// what this method did. The default does nothing.
658    async fn normalize_note_update(
659        &self,
660        _runtime: &KhiveRuntime,
661        _token: &NamespaceToken,
662        _note: &khive_storage::Note,
663        _args: &mut Value,
664    ) -> Result<(), RuntimeError> {
665        Ok(())
666    }
667
668    /// Validate a shared note-property update before storage is mutated.
669    ///
670    /// The default accepts the update. Kind-owning packs override this when a
671    /// property has invariants that generic CRUD cannot know about (for
672    /// example, GTD task dependency acyclicity). This always runs after
673    /// [`Self::normalize_note_update`], because
674    /// [`VerbRegistry::prepare_note_update_policy`] calls them in that order.
675    async fn validate_note_update(
676        &self,
677        _runtime: &KhiveRuntime,
678        _token: &NamespaceToken,
679        _note: &khive_storage::Note,
680        _properties: Option<&Value>,
681    ) -> Result<(), RuntimeError> {
682        Ok(())
683    }
684
685    /// Describe graph changes coupled to a validated note patch, without writing.
686    ///
687    /// The dispatcher calls this only after normalization, kind validation, and
688    /// preparation of the note's guarded write. The runtime prepares these typed
689    /// effects and commits them with that write in one atomic unit. Implementors
690    /// must derive effects from this exact snapshot and patch; omitted or unchanged
691    /// owned fields should return no effects. This is not an after-update hook.
692    async fn note_update_effects(
693        &self,
694        _runtime: &KhiveRuntime,
695        _token: &NamespaceToken,
696        _note: &khive_storage::Note,
697        _patch: &crate::curation::NotePatch,
698    ) -> Result<Vec<NoteUpdateEffect>, RuntimeError> {
699        Ok(Vec::new())
700    }
701
702    /// Optional top-level properties whose explicit null update deletes the
703    /// stored key after the shared merge. Omission still preserves the key.
704    /// The default changes no property semantics. This policy is returned only
705    /// after normalization and validation have accepted the update.
706    fn note_update_null_clearing_properties(&self) -> &'static [&'static str] {
707        &[]
708    }
709
710    /// Validate a shared entity-property update before storage is mutated.
711    ///
712    /// Runs after the caller's patch has been merged into the entity's
713    /// stored properties, so `properties` reflects the resulting record
714    /// rather than the raw patch — the invariant this validates (e.g. "a
715    /// required key must be present and typed") is a claim about the
716    /// record, not about what one caller happened to send. This is
717    /// deliberately NOT a re-run of `prepare_create`: a `prepare_create`
718    /// body may also enforce create-shape requirements (an argument the
719    /// caller must supply at create time) that a partial update
720    /// legitimately omits, and re-running it would reject valid updates
721    /// with an error message written for create.
722    ///
723    /// The default accepts the update. Kind-owning packs override this when
724    /// a `prepare_create` invariant must also hold after a generic
725    /// `update`, sharing one predicate between both methods the way
726    /// [`validate_note_update`](Self::validate_note_update)'s implementors
727    /// already do for notes.
728    async fn validate_entity_update(
729        &self,
730        _runtime: &KhiveRuntime,
731        _token: &NamespaceToken,
732        _entity: &khive_storage::Entity,
733        _properties: Option<&Value>,
734    ) -> Result<(), RuntimeError> {
735        Ok(())
736    }
737
738    /// Validate one or more shared graph links before any edge is written.
739    ///
740    /// A batch is supplied as a unit so a hook can reject a cycle formed only
741    /// by the proposed edges. The default accepts every link.
742    async fn validate_links(
743        &self,
744        _runtime: &KhiveRuntime,
745        _token: &NamespaceToken,
746        _links: &[crate::LinkSpec],
747    ) -> Result<(), RuntimeError> {
748        Ok(())
749    }
750}
751
752/// A kind-owned graph mutation committed with its note update. SQL and deferred
753/// callbacks are deliberately not part of this interface.
754#[derive(Clone, Debug)]
755pub enum NoteUpdateEffect {
756    /// Create or explicitly resurrect an outgoing edge using normal link guards.
757    Link(LinkSpec),
758    /// Soft-delete one existing outgoing edge by ID, guarded by this snapshot.
759    DeleteEdge(khive_storage::types::Edge),
760    /// Preserve a live outgoing edge and assert its identity and endpoints at
761    /// commit time without changing its ID, timestamps, weight, or metadata.
762    AssertLink(khive_storage::types::Edge),
763}
764
765/// Optional sub-trait for packs that own private SQL tables and issue UUIDs
766/// that must be reachable through the generic `get(id)` and `delete(id)` verbs.
767///
768/// Implementing both methods is required — the sub-trait bundles them atomically
769/// so partial implementation is a compile-time error, not a runtime surprise.
770/// Packs whose records live in the shared entity/note substrate (gtd, memory)
771/// do not implement this sub-trait.
772#[async_trait]
773pub trait PackByIdResolver: Send + Sync {
774    /// Attempt to resolve a live (non-deleted) UUID owned by this pack's private tables.
775    ///
776    /// Returns `Some(Resolved::PackRecord { ... })` if this pack owns the UUID,
777    /// `None` if it does not (the caller continues to the next resolver),
778    /// or `Err(...)` on a storage error.
779    ///
780    /// Must query domain-authoritative tables before mirror tables.
781    /// Must NOT filter by namespace. UUID v4 is globally unique; by-ID
782    /// resolution is namespace-blind per ADR-007.
783    async fn resolve_by_id(
784        &self,
785        id: uuid::Uuid,
786    ) -> Result<Option<crate::Resolved>, crate::RuntimeError>;
787
788    /// Attempt to resolve a UUID including already-soft-deleted records.
789    ///
790    /// Used by the hard-delete path. Default delegates to `resolve_by_id`;
791    /// packs with `deleted_at` columns override this to query without the filter.
792    async fn resolve_by_id_including_deleted(
793        &self,
794        id: uuid::Uuid,
795    ) -> Result<Option<crate::Resolved>, crate::RuntimeError> {
796        self.resolve_by_id(id).await
797    }
798
799    /// Delete a record owned by this pack's private tables.
800    ///
801    /// `hard` mirrors the `delete` verb's `hard?` argument.
802    /// Default behavior for packs with a `deleted_at` column MUST be soft-delete;
803    /// `hard=true` performs permanent removal.
804    ///
805    /// Returns `Ok(Value)` with a `{ deleted: true, id, kind, hard }` body on success.
806    /// Returns `Err(RuntimeError::NotFound(...))` if the record does not exist.
807    async fn delete_by_id(
808        &self,
809        id: uuid::Uuid,
810        hard: bool,
811    ) -> Result<serde_json::Value, crate::RuntimeError>;
812
813    /// Verbs that change this pack's private records, named as examples when
814    /// a generic verb refuses one of them (ADR-061 Amendment 1).
815    ///
816    /// The refusal names the owning pack either way. A pack that returns
817    /// nothing is named without examples rather than borrowing another pack's.
818    fn private_record_verbs(&self) -> &'static [&'static str] {
819        &[]
820    }
821}
822
823/// Builder for constructing a `VerbRegistry`.
824///
825/// Packs are registered here; once `.build()` is called the registry is
826/// immutable and cheaply cloneable.
827pub struct VerbRegistryBuilder {
828    packs: Vec<Box<dyn PackRuntime>>,
829    /// Parallel to `packs`: whether the composition root vouches for the
830    /// pack at the same index, recorded by the registration method the
831    /// *caller* chose rather than anything the pack reports about itself.
832    /// [`Self::register`] (public, reachable from any pack crate) always
833    /// pushes `false`; `register_boxed` (crate-private, exercised only by
834    /// [`PackRegistry::register_packs`]'s `inventory`-discovered factories)
835    /// and the test-only `register_trusted` push `true`. A pack has no API
836    /// surface to set its own entry here — see
837    /// [`VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS`]'s doc for why
838    /// `pack.name()` alone cannot be trusted for this decision.
839    pack_trusted: Vec<bool>,
840    resolvers: Vec<(String, Box<dyn PackByIdResolver>)>,
841    kg_read_resolver: Option<Arc<crate::kg_read::KgReadResolver>>,
842    gate: GateRef,
843    default_namespace: String,
844    /// Operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
845    ///
846    /// Threads into `VerbRegistry::visible_namespaces` and is consumed by the
847    /// default dispatch path to widen read scope to `['local'] ∪ visible_namespaces`.
848    /// Writes remain pinned to `'local'`. An explicit `namespace=` request param
849    /// is a precise escape and is not widened by this set. A cloud gate may also
850    /// consult the list as policy input at its own layer.
851    visible_namespaces: Vec<Namespace>,
852    /// Configured actor identity label (ADR-057). When set, dispatch mints tokens
853    /// carrying this actor so that `comm.inbox` filters by `to_actor`.
854    actor_id: Option<String>,
855    /// Optional audit event sink.
856    ///
857    /// When set, every gate check writes a storage `Event` in addition to the
858    /// `tracing::info!` emission. The store is `Arc<dyn EventStore>` so the
859    /// registry does not depend on the full `KhiveRuntime` surface — only the
860    /// audit-persistence capability is needed here.
861    event_store: Option<Arc<dyn EventStore>>,
862    /// Defers the runtime sink's namespace-scoped read binding until build.
863    runtime_event_store: Option<KhiveRuntime>,
864    /// The configured audit backend is intentionally read-only, so dispatch
865    /// omits the known-failing append and the transport surfaces an advisory.
866    audit_store_read_only: bool,
867    /// Optional post-dispatch hook.
868    ///
869    /// When set, every successful pack dispatch calls `hook.on_dispatch(view)`
870    /// with a synthetic `EventView` describing the outcome and carrying no
871    /// observations. Opt-in: when None, no overhead is incurred.
872    dispatch_hook: Option<Arc<dyn DispatchHook>>,
873    /// ADR-133 audit-batch config override, applied when `build()` lazily
874    /// constructs the batch seam from `event_store`. `None` uses
875    /// `AuditBatchConfig::default()`.
876    audit_batch_config: Option<crate::audit_batch::AuditBatchConfig>,
877}
878
879impl VerbRegistryBuilder {
880    /// Create a builder with no packs, `AllowAllGate`, and the local namespace as default.
881    pub fn new() -> Self {
882        Self {
883            packs: Vec::new(),
884            pack_trusted: Vec::new(),
885            resolvers: Vec::new(),
886            kg_read_resolver: None,
887            gate: std::sync::Arc::new(AllowAllGate),
888            default_namespace: Namespace::local().as_str().to_string(),
889            visible_namespaces: vec![],
890            actor_id: None,
891            event_store: None,
892            runtime_event_store: None,
893            audit_store_read_only: false,
894            dispatch_hook: None,
895            audit_batch_config: None,
896        }
897    }
898
899    /// Set the operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
900    ///
901    /// On the default (no explicit `namespace=` param) dispatch path, reads fan
902    /// out over `['local'] ∪ ns`. Writes remain pinned to `'local'`. An explicit
903    /// `namespace=` request parameter is a precise single-namespace escape and
904    /// is not widened by this set. A cloud gate may also consult the list as
905    /// policy input at its own layer.
906    pub fn with_visible_namespaces(&mut self, ns: Vec<Namespace>) -> &mut Self {
907        self.visible_namespaces = ns;
908        self
909    }
910
911    /// Set the configured actor identity label (ADR-057).
912    ///
913    /// When set, the dispatch path mints tokens carrying this actor so that
914    /// `comm.inbox` applies the `to_actor` filter for directed delivery.
915    /// When `None` (default), tokens carry `ActorRef::anonymous()` and inbox
916    /// falls back to party-line behavior.
917    pub fn with_actor_id(&mut self, actor_id: Option<String>) -> &mut Self {
918        self.actor_id = actor_id;
919        self
920    }
921
922    /// Register a pack. The bound `P: Pack + PackRuntime` ensures the pack
923    /// declares vocabulary via `Pack` consts alongside runtime dispatch.
924    ///
925    /// This is the untrusted path: reachable from any external pack crate,
926    /// so the pack registered here is never eligible for admission-degrade
927    /// under `VerbRegistry::admission_degrade_safe`, regardless of what
928    /// `pack.name()`/handler category it reports. Use `register_boxed`
929    /// (composition root) or `register_trusted` (tests) for a pack the
930    /// caller actually vouches for.
931    pub fn register<P: khive_types::Pack + PackRuntime + 'static>(&mut self, pack: P) -> &mut Self {
932        self.packs.push(Box::new(pack));
933        self.pack_trusted.push(false);
934        self
935    }
936
937    /// Register a boxed pack directly, vouched for by the composition root.
938    ///
939    /// Crate-private: only [`PackRegistry::register_packs`]/
940    /// `register_packs_with_runtimes` should call this — both resolve the
941    /// pack from an `inventory`-discovered `&'static dyn PackFactory`
942    /// (collected at link time from `inventory::submit!` call sites, not
943    /// from request-time data), so the trust grant recorded here reflects a
944    /// decision the composition root made, never something the pack itself
945    /// supplied. External callers must use the typed [`Self::register`]
946    /// which enforces the `Pack + PackRuntime` dual-impl contract at the
947    /// call site but is never trusted. Here the `Pack + PackRuntime`
948    /// contract is satisfied upstream at the [`PackFactory::create`] site.
949    pub(crate) fn register_boxed(&mut self, pack: Box<dyn PackRuntime>) -> &mut Self {
950        self.packs.push(pack);
951        self.pack_trusted.push(true);
952        self
953    }
954
955    /// Register an owned mounted namespace without native-pack trust privileges.
956    pub fn register_mounted(
957        &mut self,
958        pack: Box<dyn PackRuntime>,
959    ) -> Result<&mut Self, RuntimeError> {
960        if pack.mounted_namespace() != Some(pack.name()) || !pack.handlers().is_empty() {
961            return Err(RuntimeError::InvalidInput(
962                "invalid mounted namespace registration".into(),
963            ));
964        }
965        self.packs.push(pack);
966        self.pack_trusted.push(false);
967        Ok(self)
968    }
969
970    /// Test-only trusted registration, mirroring `register_boxed`'s trust
971    /// grant for external test binaries (e.g.
972    /// `tests/read_verb_admission_exhaustion.rs`) that cannot reach a
973    /// crate-private method directly — the same reason
974    /// [`VerbRegistry::admission_degrade_safe_probe`] is `pub` rather than
975    /// `pub(crate)`. A test using this method is asserting that the pack it
976    /// registers stands in for a pack the real composition root would load,
977    /// not an untrusted/third-party one.
978    #[cfg(any(test, feature = "test-internals"))]
979    pub fn register_trusted<P: khive_types::Pack + PackRuntime + 'static>(
980        &mut self,
981        pack: P,
982    ) -> &mut Self {
983        self.packs.push(Box::new(pack));
984        self.pack_trusted.push(true);
985        self
986    }
987
988    /// Register a by-ID resolver for a pack that owns private SQL tables.
989    ///
990    /// Packs that implement `PackByIdResolver` call this during their boot path
991    /// so that `get(id)` and `delete(id)` can reach their records.
992    pub fn register_resolver(
993        &mut self,
994        name: impl Into<String>,
995        resolver: Box<dyn PackByIdResolver>,
996    ) -> &mut Self {
997        self.resolvers.push((name.into(), resolver));
998        self
999    }
1000
1001    /// Set the authorization gate consulted on every dispatch.
1002    ///
1003    /// Defaults to `AllowAllGate` if not set. `Deny` is authoritative — a deny
1004    /// decision aborts dispatch with `RuntimeError::PermissionDenied`. Gate
1005    /// infrastructure errors abort dispatch with `RuntimeError::GateUnavailable`.
1006    pub fn with_gate(&mut self, gate: GateRef) -> &mut Self {
1007        self.gate = gate;
1008        self
1009    }
1010
1011    /// Set the namespace surfaced to the gate when a verb does not carry an
1012    /// explicit `namespace` argument. Transports should plumb the runtime's
1013    /// `default_namespace` so the gate's `input.namespace` always reflects
1014    /// the operation's true tenant.
1015    pub fn with_default_namespace(&mut self, ns: impl Into<String>) -> &mut Self {
1016        self.default_namespace = ns.into();
1017        self
1018    }
1019
1020    /// Set the `EventStore` used to persist audit events.
1021    ///
1022    /// When configured, every gate check appends one `Event` (substrate =
1023    /// `Event`, outcome = `Success` on allow, `Denied` on deny, or `Error` on
1024    /// gate unavailability) in addition to the `tracing::info!` emission.
1025    ///
1026    /// Callers that do not set this field continue to use tracing-only emission
1027    /// (the v0.2 default), except `git.digest`: its successful response carries
1028    /// a durable receipt and therefore fails safely when no store is configured.
1029    pub fn with_event_store(&mut self, store: Arc<dyn EventStore>) -> &mut Self {
1030        self.event_store = Some(store);
1031        self.runtime_event_store = None;
1032        self.audit_store_read_only = false;
1033        self
1034    }
1035
1036    /// Configure the registry's trusted audit sink from a runtime.
1037    ///
1038    /// Registry audit constructors stamp namespace and actor directly from
1039    /// each resolved [`GateRequest`], including per-request daemon identity
1040    /// overrides. This deliberately uses the runtime's undecorated sink: the
1041    /// public token-scoped [`KhiveRuntime::events`] decorator would otherwise
1042    /// replace every per-request stamp with the single actor that happened to
1043    /// construct the registry.
1044    ///
1045    /// The sink is resolved during [`Self::build`] using the final default
1046    /// namespace, so the order of namespace and sink configuration does not
1047    /// change its read scope. Sink initialization errors are returned by build:
1048    /// a serving registry never silently drops a configured runtime audit sink.
1049    /// Metadata builds and explicit replacement sinks do not open this sink.
1050    pub fn with_runtime_event_store(
1051        &mut self,
1052        runtime: &KhiveRuntime,
1053    ) -> Result<&mut Self, RuntimeError> {
1054        self.event_store = None;
1055        self.runtime_event_store = Some(runtime.clone());
1056        self.audit_store_read_only = false;
1057        Ok(self)
1058    }
1059
1060    /// Override the ADR-133 audit-batch seam's tunables, applied when
1061    /// `build()` lazily constructs the batch from `event_store`.
1062    /// `None` (the default) uses `AuditBatchConfig::default()`. Exposed for
1063    /// tests that need to force a small `max_pending_rows` or a short
1064    /// `admission_deadline` to exercise admission-pressure paths
1065    /// deterministically (#2117, #2147, #2208, #2217).
1066    pub fn with_audit_batch_config(
1067        &mut self,
1068        config: crate::audit_batch::AuditBatchConfig,
1069    ) -> &mut Self {
1070        self.audit_batch_config = Some(config);
1071        self
1072    }
1073
1074    /// Mark audit persistence unavailable because its backend is read-only.
1075    ///
1076    /// No `EventStore` is retained, so dispatch never attempts a write that is
1077    /// known to fail. Successful request entries expose a machine-readable
1078    /// advisory without changing their canonical verb result shape.
1079    pub fn with_read_only_audit_store(&mut self) -> &mut Self {
1080        self.event_store = None;
1081        self.runtime_event_store = None;
1082        self.audit_store_read_only = true;
1083        self
1084    }
1085
1086    /// Register a post-dispatch hook.
1087    ///
1088    /// When set, every successful pack dispatch calls `hook.on_dispatch(view)`
1089    /// with a synthetic [`EventView`] describing the verb outcome. Its
1090    /// `observations` vector is empty; callers that need persisted provenance
1091    /// must load it explicitly. The hook is opt-in: registries without a hook
1092    /// incur zero overhead on the dispatch hot path.
1093    ///
1094    /// Brain pack uses this as a best-effort in-memory update path. Errors from
1095    /// `on_dispatch` are logged via `tracing::warn!` and never propagated.
1096    pub fn with_dispatch_hook(&mut self, hook: Arc<dyn DispatchHook>) -> &mut Self {
1097        self.dispatch_hook = Some(hook);
1098        self
1099    }
1100
1101    /// Consume the builder and produce an immutable, cloneable registry.
1102    ///
1103    /// Performs a topological sort of packs using Kahn's algorithm.
1104    /// Returns an error if any declared dependency is missing from the loaded
1105    /// pack set, or if a circular dependency is detected.
1106    pub fn build(self) -> Result<VerbRegistry, RuntimeError> {
1107        self.build_registry(true)
1108    }
1109
1110    /// Inspect pack metadata without activating any registered pack.
1111    /// The result exposes no dispatch, preparation hooks, or serving-registry conversion.
1112    pub fn build_metadata(mut self) -> Result<PackMetadataRegistry, RuntimeError> {
1113        self.event_store = None;
1114        self.runtime_event_store = None;
1115        self.dispatch_hook = None;
1116        self.resolvers.clear();
1117        self.build_registry(false)
1118            .map(|registry| PackMetadataRegistry { registry })
1119    }
1120
1121    fn build_registry(self, activate: bool) -> Result<VerbRegistry, RuntimeError> {
1122        let packs = self.packs;
1123        let mut name_to_idx: HashMap<&str, usize> = HashMap::with_capacity(packs.len());
1124        for (idx, pack) in packs.iter().enumerate() {
1125            if let Some(prev_idx) = name_to_idx.insert(pack.name(), idx) {
1126                return Err(RuntimeError::PackRedeclared {
1127                    name: pack.name().to_string(),
1128                    first_idx: prev_idx,
1129                    second_idx: idx,
1130                });
1131            }
1132        }
1133
1134        for mounted in packs
1135            .iter()
1136            .filter(|pack| pack.mounted_namespace().is_some())
1137        {
1138            let prefix = format!("{}.", mounted.name());
1139            if packs
1140                .iter()
1141                .flat_map(|pack| pack.handlers())
1142                .any(|handler| handler.name.starts_with(&prefix))
1143            {
1144                return Err(RuntimeError::InvalidInput(
1145                    "mounted namespace collides with a native verb".into(),
1146                ));
1147            }
1148        }
1149
1150        // Apply this metadata invariant to every HandlerDef, including Subhandlers. Subhandlers
1151        // are not top-level MCP-callable, but their describe/help contract still cannot truthfully
1152        // advertise a name rejected by every typed request parser before visibility dispatch.
1153        for pack in &packs {
1154            for handler in pack.handlers() {
1155                for parameter in handler.params {
1156                    if RESERVED_ENVELOPE_ARGS.contains(&parameter.name) {
1157                        return Err(RuntimeError::ReservedEnvelopeParam {
1158                            pack: pack.name().to_string(),
1159                            verb: handler.name.to_string(),
1160                            param: parameter.name.to_string(),
1161                        });
1162                    }
1163                }
1164            }
1165        }
1166
1167        let mut missing: Vec<MissingPackDependency> = Vec::new();
1168        let mut indegree = vec![0usize; packs.len()];
1169        let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); packs.len()];
1170
1171        for (idx, pack) in packs.iter().enumerate() {
1172            for &requires in pack.requires() {
1173                match name_to_idx.get(requires).copied() {
1174                    Some(dep_idx) => {
1175                        dependents[dep_idx].push(idx);
1176                        indegree[idx] += 1;
1177                    }
1178                    None => missing.push(MissingPackDependency {
1179                        from: pack.name().to_string(),
1180                        requires: requires.to_string(),
1181                    }),
1182                }
1183            }
1184        }
1185
1186        if !missing.is_empty() {
1187            return if missing.len() == 1 {
1188                Err(RuntimeError::MissingPackDependency(missing.remove(0)))
1189            } else {
1190                Err(RuntimeError::MissingPackDependencies(
1191                    MissingPackDependencies { missing },
1192                ))
1193            };
1194        }
1195
1196        let mut ready: VecDeque<usize> = indegree
1197            .iter()
1198            .enumerate()
1199            .filter_map(|(idx, degree)| (*degree == 0).then_some(idx))
1200            .collect();
1201        let mut ordered_indices = Vec::with_capacity(packs.len());
1202
1203        while let Some(idx) = ready.pop_front() {
1204            ordered_indices.push(idx);
1205            for &dep_idx in &dependents[idx] {
1206                indegree[dep_idx] -= 1;
1207                if indegree[dep_idx] == 0 {
1208                    ready.push_back(dep_idx);
1209                }
1210            }
1211        }
1212
1213        if ordered_indices.len() != packs.len() {
1214            let cycle_nodes: HashSet<usize> = indegree
1215                .iter()
1216                .enumerate()
1217                .filter_map(|(idx, degree)| (*degree > 0).then_some(idx))
1218                .collect();
1219            let cycle = find_pack_dependency_cycle(&packs, &name_to_idx, &cycle_nodes);
1220            return Err(RuntimeError::CircularPackDependency(
1221                CircularPackDependency { cycle },
1222            ));
1223        }
1224
1225        let mut pack_slots: Vec<Option<Box<dyn PackRuntime>>> =
1226            packs.into_iter().map(Some).collect();
1227        let mut trusted_slots: Vec<Option<bool>> =
1228            self.pack_trusted.into_iter().map(Some).collect();
1229        let mut ordered_packs: Vec<Box<dyn PackRuntime>> = Vec::with_capacity(pack_slots.len());
1230        let mut ordered_trusted: Vec<bool> = Vec::with_capacity(trusted_slots.len());
1231        for idx in ordered_indices {
1232            ordered_packs.push(
1233                pack_slots[idx]
1234                    .take()
1235                    .expect("topological index must exist"),
1236            );
1237            ordered_trusted.push(
1238                trusted_slots[idx]
1239                    .take()
1240                    .expect("topological index must exist"),
1241            );
1242        }
1243
1244        validate_unique_note_kinds(&ordered_packs)?;
1245        validate_unique_verb_names(&ordered_packs)?;
1246        validate_unique_entity_types(&ordered_packs)?;
1247        validate_brain_consumer_kinds(&ordered_packs)?;
1248        if activate {
1249            for pack in &ordered_packs {
1250                pack.validate_config()?;
1251            }
1252        }
1253
1254        let available_verbs: Vec<&'static str> = ordered_packs
1255            .iter()
1256            .flat_map(|p| p.handlers().iter())
1257            .filter(|h| matches!(h.visibility, Visibility::Verb))
1258            .map(|h| h.name)
1259            .collect();
1260
1261        // Admission-degrade eligibility (#2147/#2217, khive-oss#2311): decided
1262        // once here, from the trust bit the composition root recorded at
1263        // registration time (never from `pack.name()`'s self-report) plus
1264        // each handler's declared category and the `(pack, verb)` allowlist —
1265        // see `VerbRegistry::admission_degrade_safe`'s doc. A verb is
1266        // globally unique across `Visibility::Verb` handlers at this point
1267        // (`validate_unique_verb_names` above already enforced that), so a
1268        // flat `HashSet<&'static str>` is an unambiguous key: no dispatch
1269        // call site needs to re-resolve which pack owns a verb to answer
1270        // this question, and none does (`VerbRegistry::admission_degrade_safe`
1271        // is a single hash-set lookup with no per-call pack/handler scan).
1272        let mut degrade_safe_verbs: HashSet<&'static str> = HashSet::new();
1273        let mut read_replay_safe_verbs = HashSet::new();
1274        for (pack, &trusted) in ordered_packs.iter().zip(ordered_trusted.iter()) {
1275            if !trusted {
1276                continue;
1277            }
1278            let pack_name = pack.name();
1279            for handler in pack.handlers() {
1280                let canonical_owner = handler
1281                    .name
1282                    .split_once('.')
1283                    .map_or("kg", |(owner, _)| owner);
1284                if matches!(handler.visibility, Visibility::Verb)
1285                    && pack_name == canonical_owner
1286                    && crate::classify_operation(handler.name) == Some(crate::OperationAccess::Read)
1287                    && !VerbRegistry::SIDE_EFFECTING_ASSERTIVE_VERBS.contains(&handler.name)
1288                {
1289                    read_replay_safe_verbs.insert(handler.name);
1290                }
1291                if !matches!(handler.visibility, Visibility::Verb)
1292                    || handler.category != VerbCategory::Assertive
1293                {
1294                    continue;
1295                }
1296                let eligible = VerbRegistry::admission_degrade_safe_sorted()
1297                    .binary_search_by(|&(p, v)| p.cmp(pack_name).then_with(|| v.cmp(handler.name)))
1298                    .is_ok();
1299                if eligible {
1300                    degrade_safe_verbs.insert(handler.name);
1301                }
1302            }
1303        }
1304
1305        // ADR-133: incidental audit writes route through one batch seam per
1306        // configured `EventStore` instead of taking a writer-task
1307        // acquisition per dispatch. No store configured (tracing-only or
1308        // read-only-audit registries) means no seam to construct.
1309        //
1310        // A configured store that does not implement the seam's
1311        // `preflight_event`/`append_events_idempotent` pair would otherwise
1312        // build silently: every submitted row is rejected at preflight, the
1313        // dispatch that produced it still reports success, and nothing here
1314        // distinguishes that from a healthy registry. Reject it now, with an
1315        // actionable message, instead of at the first audited dispatch.
1316        let event_store = match self.runtime_event_store {
1317            Some(runtime) => Some(runtime.raw_events_for_namespace(&self.default_namespace)?),
1318            None => self.event_store,
1319        };
1320        if let Some(store) = &event_store {
1321            if !store.supports_idempotent_audit_batch() {
1322                return Err(RuntimeError::IncompatibleEventStore(
1323                    "the configured EventStore does not implement ADR-133's \
1324                     preflight_event/append_events_idempotent pair \
1325                     (supports_idempotent_audit_batch() returned false); every \
1326                     audited dispatch would silently lose its audit row while \
1327                     still reporting success. Implement both methods and \
1328                     override supports_idempotent_audit_batch() to opt in, or \
1329                     do not call with_event_store() for this backend."
1330                        .to_string(),
1331                ));
1332            }
1333        }
1334        let audit_batch = event_store.clone().map(|store| {
1335            crate::audit_batch::AuditBatch::new(
1336                store,
1337                self.audit_batch_config.clone().unwrap_or_default(),
1338            )
1339        });
1340
1341        Ok(VerbRegistry {
1342            packs: Arc::new(ordered_packs),
1343            resolvers: Arc::new(self.resolvers),
1344            kg_read_resolver: self.kg_read_resolver,
1345            gate: self.gate,
1346            default_namespace: self.default_namespace,
1347            visible_namespaces: self.visible_namespaces,
1348            actor_id: self.actor_id,
1349            event_store,
1350            audit_store_read_only: self.audit_store_read_only,
1351            dispatch_hook: self.dispatch_hook,
1352            available_verbs: Arc::new(available_verbs),
1353            degrade_safe_verbs: Arc::new(degrade_safe_verbs),
1354            read_replay_safe_verbs: Arc::new(read_replay_safe_verbs),
1355            reference_ring: Arc::new(crate::reference_ring::ReferenceRing::new()),
1356            audit_batch,
1357        })
1358    }
1359}
1360
1361/// Validate that no two packs declare the same note kind.
1362///
1363/// Boot-time duplicate detection prevents pack configuration errors from
1364/// silently corrupting note kind routing. Returns an error naming the
1365/// duplicate kind and the two packs that claim it.
1366fn validate_unique_note_kinds(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
1367    let mut seen: HashMap<&str, &str> = HashMap::new();
1368    for pack in packs {
1369        for &kind in pack.note_kinds() {
1370            if let Some(first_pack) = seen.insert(kind, pack.name()) {
1371                return Err(RuntimeError::InvalidInput(format!(
1372                    "duplicate note kind {kind:?}: claimed by both {first_pack:?} and {:?}",
1373                    pack.name()
1374                )));
1375            }
1376        }
1377    }
1378    Ok(())
1379}
1380
1381/// Validate pack-declared brain consumer kinds at the composition boundary.
1382///
1383/// The wildcard belongs to the binding matcher rather than any consumer, and
1384/// whitespace-bearing values can never equal the exact wire values callers
1385/// request. Reject both at boot so a malformed declaration cannot make an
1386/// otherwise unreachable binding appear valid.
1387fn validate_brain_consumer_kinds(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
1388    for pack in packs {
1389        for &kind in pack.brain_consumer_kinds() {
1390            if kind == "*" || kind.trim().is_empty() || kind.trim() != kind {
1391                return Err(RuntimeError::InvalidInput(format!(
1392                    "pack {:?} declares invalid brain consumer kind {kind:?}; declarations must be non-empty exact wire values and must not use the registry-owned \"*\" wildcard",
1393                    pack.name()
1394                )));
1395            }
1396        }
1397    }
1398    Ok(())
1399}
1400
1401/// Validate that no two packs declare the same `Visibility::Verb` handler name.
1402///
1403/// `Visibility::Subhandler` entries are pack-prefixed by convention and excluded
1404/// from cross-pack collision detection. Two packs declaring the same subhandler
1405/// name prefix (e.g. `recall.embed`) would be a pack-authoring error but does not
1406/// produce a cross-pack routing conflict since only the owning pack dispatches them.
1407fn validate_unique_verb_names(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
1408    let mut seen: HashMap<&str, &str> = HashMap::new();
1409    for pack in packs {
1410        for handler in pack.handlers() {
1411            if !matches!(handler.visibility, Visibility::Verb) {
1412                continue;
1413            }
1414            if let Some(first_pack) = seen.insert(handler.name, pack.name()) {
1415                return Err(RuntimeError::VerbCollision {
1416                    verb: handler.name.to_string(),
1417                    first_pack: first_pack.to_string(),
1418                    second_pack: pack.name().to_string(),
1419                });
1420            }
1421        }
1422    }
1423    Ok(())
1424}
1425
1426/// Validate that no two owners (the built-in table or a loaded pack) declare
1427/// a colliding `entity_type` canonical name or alias.
1428///
1429/// Boot-time duplicate detection prevents pack configuration errors from
1430/// silently applying insertion-order semantics to entity-type resolution
1431/// (ADR-001's registry-ownership collision rule: same `(base_kind,
1432/// canonical_name)` from two different packs, or an alias collision, is a
1433/// boot error). Returns an error naming the colliding key and both
1434/// contributing owners.
1435fn validate_unique_entity_types(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
1436    let owned_defs = packs
1437        .iter()
1438        .flat_map(|p| p.entity_types().iter().map(move |def| (p.name(), def)));
1439    khive_types::EntityTypeRegistry::check_extra_collisions(owned_defs)
1440        .map_err(RuntimeError::InvalidInput)
1441}
1442
1443fn find_pack_dependency_cycle(
1444    packs: &[Box<dyn PackRuntime>],
1445    name_to_idx: &HashMap<&str, usize>,
1446    cycle_nodes: &HashSet<usize>,
1447) -> Vec<String> {
1448    fn visit(
1449        idx: usize,
1450        packs: &[Box<dyn PackRuntime>],
1451        name_to_idx: &HashMap<&str, usize>,
1452        cycle_nodes: &HashSet<usize>,
1453        visiting: &mut Vec<usize>,
1454        visited: &mut HashSet<usize>,
1455    ) -> Option<Vec<String>> {
1456        if let Some(pos) = visiting.iter().position(|&seen| seen == idx) {
1457            let mut cycle: Vec<String> = visiting[pos..]
1458                .iter()
1459                .map(|&i| packs[i].name().to_string())
1460                .collect();
1461            cycle.push(packs[idx].name().to_string());
1462            return Some(cycle);
1463        }
1464        if !visited.insert(idx) {
1465            return None;
1466        }
1467        visiting.push(idx);
1468        for &req in packs[idx].requires() {
1469            let Some(&dep_idx) = name_to_idx.get(req) else {
1470                continue;
1471            };
1472            if cycle_nodes.contains(&dep_idx) {
1473                if let Some(cycle) =
1474                    visit(dep_idx, packs, name_to_idx, cycle_nodes, visiting, visited)
1475                {
1476                    return Some(cycle);
1477                }
1478            }
1479        }
1480        visiting.pop();
1481        None
1482    }
1483
1484    let mut visited = HashSet::new();
1485    for &idx in cycle_nodes {
1486        let mut visiting = Vec::new();
1487        if let Some(cycle) = visit(
1488            idx,
1489            packs,
1490            name_to_idx,
1491            cycle_nodes,
1492            &mut visiting,
1493            &mut visited,
1494        ) {
1495            return cycle;
1496        }
1497    }
1498    cycle_nodes
1499        .iter()
1500        .map(|&idx| packs[idx].name().to_string())
1501        .collect()
1502}
1503
1504impl Default for VerbRegistryBuilder {
1505    fn default() -> Self {
1506        Self::new()
1507    }
1508}
1509
1510/// Pack metadata with no executable registry capability.
1511///
1512/// ```compile_fail
1513/// fn dispatch(metadata: &khive_runtime::PackMetadataRegistry) {
1514///     metadata.dispatch("telemetry.emit", serde_json::json!({}));
1515/// }
1516/// ```
1517pub struct PackMetadataRegistry {
1518    registry: VerbRegistry,
1519}
1520
1521impl PackMetadataRegistry {
1522    pub fn has_verb(&self, verb: &str) -> bool {
1523        self.registry.has_verb(verb)
1524    }
1525
1526    pub fn describe_verb(&self, verb: &str) -> Result<Value, RuntimeError> {
1527        self.registry.describe_verb(verb)
1528    }
1529
1530    pub fn all_handlers_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
1531        self.registry.all_handlers_with_names()
1532    }
1533
1534    pub fn all_verbs(&self) -> Vec<&'static HandlerDef> {
1535        self.registry.all_verbs()
1536    }
1537
1538    pub fn pack_names(&self) -> Vec<&str> {
1539        self.registry.pack_names()
1540    }
1541
1542    pub fn pack_requires(&self, name: &str) -> Option<&'static [&'static str]> {
1543        self.registry.pack_requires(name)
1544    }
1545
1546    pub fn pack_note_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
1547        self.registry.pack_note_kinds(name)
1548    }
1549
1550    pub fn pack_entity_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
1551        self.registry.pack_entity_kinds(name)
1552    }
1553
1554    pub fn pack_verbs(&self, name: &str) -> Option<&'static [HandlerDef]> {
1555        self.registry.pack_verbs(name)
1556    }
1557
1558    pub fn all_entity_kinds(&self) -> Vec<&'static str> {
1559        self.registry.all_entity_kinds()
1560    }
1561
1562    pub fn all_note_kinds(&self) -> Vec<&'static str> {
1563        self.registry.all_note_kinds()
1564    }
1565
1566    pub fn all_edge_rules(&self) -> Vec<EdgeEndpointRule> {
1567        self.registry.all_edge_rules()
1568    }
1569}
1570
1571/// Immutable registry that dispatches verb calls to registered packs.
1572///
1573/// Clone is cheap (Arc-wrapped). Constructed via `VerbRegistryBuilder`.
1574#[derive(Clone)]
1575pub struct VerbRegistry {
1576    packs: std::sync::Arc<Vec<Box<dyn PackRuntime>>>,
1577    /// Pack-level by-ID resolvers, in registration order.
1578    resolvers: std::sync::Arc<Vec<(String, Box<dyn PackByIdResolver>)>>,
1579    /// Read-only KG lookup topology; never used to redirect a pack write.
1580    kg_read_resolver: Option<Arc<crate::kg_read::KgReadResolver>>,
1581    gate: GateRef,
1582    default_namespace: String,
1583    /// Operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
1584    ///
1585    /// On the default (no explicit `namespace=` param) dispatch path, reads fan
1586    /// out over `['local'] ∪ visible_namespaces`. Writes are unaffected — they
1587    /// still pin to `'local'`. An explicit `namespace=` request param is a
1588    /// precise single-namespace escape and is not widened by this set.
1589    visible_namespaces: Vec<Namespace>,
1590    /// Configured actor identity label (ADR-057). When `Some`, dispatch mints
1591    /// tokens carrying this actor so that `comm.inbox` applies the `to_actor`
1592    /// filter. When `None`, tokens carry `ActorRef::anonymous()` (party-line).
1593    actor_id: Option<String>,
1594    /// Audit event sink — `None` means tracing-only (v0.2 default).
1595    event_store: Option<Arc<dyn EventStore>>,
1596    /// Distinguishes ordinary tracing-only construction from a sink omitted
1597    /// deliberately because its configured backend is read-only.
1598    audit_store_read_only: bool,
1599    /// Post-dispatch hook: `None` means no real-time observation.
1600    dispatch_hook: Option<Arc<dyn DispatchHook>>,
1601    /// Names of all `Visibility::Verb` handlers across all packs, precomputed
1602    /// once at `build()` time. Used only to render the unknown-verb error
1603    /// message — the pack set is fixed after construction, so there is no
1604    /// need to re-scan every pack's handlers on every miss.
1605    available_verbs: Arc<Vec<&'static str>>,
1606    /// Verbs eligible for admission-pressure audit degradation, precomputed
1607    /// once at `build()` time from registration-time pack trust plus each
1608    /// handler's declared category and
1609    /// [`VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS`]. See
1610    /// [`VerbRegistry::admission_degrade_safe`].
1611    degrade_safe_verbs: Arc<HashSet<&'static str>>,
1612    /// Trusted canonical public handlers classified Read by the shared effects table.
1613    read_replay_safe_verbs: Arc<HashSet<&'static str>>,
1614    /// Recently-referenced ring (unified-verb draft ADR, Slice 1). Daemon-warm,
1615    /// actor-scoped, never persisted — see `crate::reference_ring`. Shared
1616    /// across every clone of this registry via the `Arc`, so admissions made
1617    /// by one dispatch are visible to the next on the same warm daemon.
1618    reference_ring: Arc<crate::reference_ring::ReferenceRing>,
1619    /// ADR-133 audit-batch seam. `None` exactly when `event_store` is
1620    /// `None` — no store configured means no seam to construct, and every
1621    /// audit call site falls back to its pre-ADR-133 tracing-only/no-op
1622    /// path.
1623    audit_batch: Option<Arc<crate::audit_batch::AuditBatch>>,
1624}
1625
1626/// Result of an operation handled outside normal pack dispatch, paired with
1627/// typed transport metadata that must survive the gate/audit boundary.
1628///
1629/// The canonical `result` remains the value used for audit accounting. The
1630/// metadata is returned to the intercepting transport without being smuggled
1631/// through a mutex side channel or folded into the verb's public result shape.
1632#[derive(Debug, Clone, PartialEq)]
1633pub struct InterceptedDispatchResult<M> {
1634    /// Canonical verb result used for audit and resource accounting.
1635    pub result: Value,
1636    /// Transport-owned metadata that must accompany the canonical result.
1637    pub metadata: M,
1638}
1639
1640impl<M> InterceptedDispatchResult<M> {
1641    /// Pair a canonical result with its typed transport metadata.
1642    pub fn new(result: Value, metadata: M) -> Self {
1643        Self { result, metadata }
1644    }
1645}
1646
1647/// Per-request identity context that overrides a [`VerbRegistry`]'s
1648/// construction-baked `default_namespace` / `actor_id` / `visible_namespaces`
1649/// for exactly one [`VerbRegistry::dispatch_with_identity`] call (ADR-096
1650/// Fork 1 — warm-daemon per-request identity).
1651///
1652/// A single warm registry is built once with a baked identity, but must be
1653/// able to serve requests whose caller resolved a *different* attribution
1654/// identity (e.g. a different project-local `[actor]`) without a cold
1655/// fallback and without mis-stamping writes under the registry's own baked
1656/// actor. Supplying `Some(RequestIdentity { .. })` threads the caller's
1657/// identity through token minting for that one call; the registry's fields
1658/// (and every other in-flight call) are untouched. `None` is exactly
1659/// [`VerbRegistry::dispatch`] — the baked scalars apply, unchanged from
1660/// before this type existed.
1661#[derive(Debug, Clone, Default)]
1662pub struct RequestIdentity {
1663    /// Storage/gate default namespace for this request (used when the verb's
1664    /// own params carry no explicit `namespace` field). Overrides
1665    /// `VerbRegistry::default_namespace`.
1666    pub namespace: String,
1667    /// Write-stamp / gate actor label for this request (ADR-057). Overrides
1668    /// `VerbRegistry::actor_id`. `None` mints `ActorRef::anonymous()`, same
1669    /// as an unconfigured baked `actor_id`.
1670    pub actor_id: Option<String>,
1671    /// Extra read-visibility namespaces for this request (ADR-007 Rev 4 Rule
1672    /// 3b). Overrides `VerbRegistry::visible_namespaces`. Entries that fail
1673    /// `Namespace::parse` are skipped with a `tracing::warn!` rather than
1674    /// failing the whole request — a single malformed visibility entry from a
1675    /// caller-supplied frame must not block dispatch.
1676    pub visible_namespaces: Vec<String>,
1677    /// Opaque process provenance resolved by the originating request process.
1678    /// `None` means the origin did not set one; a warm daemon must not replace
1679    /// it with its own process environment. This field is attribution-only and
1680    /// never participates in the gate or token authority.
1681    pub process_ref: Option<String>,
1682    /// Caller-supplied correlation id for this request (khive#948), carried
1683    /// unchanged from the daemon frame's `request_id` field. Every operation
1684    /// in one batch or chain receives the same value: it is a request-group
1685    /// selector, never an operation-unique id. Stamped into the audit event's
1686    /// `resource.request_id` on every outcome (success, error, and denied) so
1687    /// a client can join its own pre-send sample to all server-side audit rows
1688    /// for that request. `None` means the caller
1689    /// supplied no id (a pre-#948 client, or an internal/non-benchmark
1690    /// caller) — the audit row then carries no `request_id` key at all.
1691    pub request_id: Option<u64>,
1692}
1693
1694impl RequestIdentity {
1695    /// Reconstruct the effective principal and namespace scope carried by an
1696    /// already-authorized token for a nested registry dispatch.
1697    ///
1698    /// Cross-pack calls must still pass through the registry Gate, but using
1699    /// the registry's construction-baked identity would silently replace a
1700    /// warm daemon request's actor and visibility (ADR-096). This projection
1701    /// preserves the token's exact primary namespace, actor, and read-visible
1702    /// namespaces, and the origin's process provenance rider. Nested calls
1703    /// intentionally use `request_id: None` even when the token retains the
1704    /// ingress id for audit rows within its originating dispatch; `process_ref` IS carried by
1705    /// the token (ADR-096: an absent value stays absent, a present origin
1706    /// rider survives nested dispatch without reading the daemon
1707    /// environment).
1708    pub fn from_token(token: &NamespaceToken) -> Self {
1709        Self {
1710            namespace: token.namespace().as_str().to_string(),
1711            actor_id: token.actor().binding_id().map(str::to_string),
1712            visible_namespaces: token
1713                .visible_namespaces()
1714                .iter()
1715                .map(|namespace| namespace.as_str().to_string())
1716                .collect(),
1717            process_ref: token.process_ref().map(str::to_owned),
1718            request_id: None,
1719        }
1720    }
1721}
1722
1723/// A non-blank, out-of-band authenticated principal for [`VerbRegistry::dispatch_as`].
1724///
1725/// Embedding hosts authenticate a principal through their own channel (not the
1726/// request DSL) and then need that principal to become the effective actor
1727/// for one dispatch. The constructor rejects an empty or whitespace-only
1728/// identifier so an authentication-integration failure (an empty subject)
1729/// fails closed at construction time instead of silently resolving to the
1730/// anonymous/local actor at dispatch time — see [`crate::actor_identity::resolve_actor`].
1731#[derive(Debug, Clone, PartialEq, Eq)]
1732pub struct VerifiedActor(String);
1733
1734impl VerifiedActor {
1735    /// Validate and wrap a verified principal identifier.
1736    ///
1737    /// Returns `RuntimeError::InvalidInput` when `id` is empty or contains
1738    /// only whitespace.
1739    pub fn new(id: impl Into<String>) -> Result<Self, RuntimeError> {
1740        let id = id.into();
1741        if id.trim().is_empty() {
1742            return Err(RuntimeError::InvalidInput(
1743                "VerifiedActor: identifier must not be empty or whitespace-only".to_string(),
1744            ));
1745        }
1746        Ok(Self(id))
1747    }
1748
1749    /// Borrow the validated identifier.
1750    pub fn as_str(&self) -> &str {
1751        &self.0
1752    }
1753
1754    fn into_inner(self) -> String {
1755        self.0
1756    }
1757}
1758
1759/// Error returned by [`VerbRegistry::apply_schema_plans_with_map`] when two
1760/// packs on the same backend declare the same auxiliary table (ADR-028 §7).
1761#[derive(Debug)]
1762pub struct PackSchemaCollisionError {
1763    /// First pack to declare the table.
1764    pub pack_a: &'static str,
1765    /// Second pack that collides with `pack_a`.
1766    pub pack_b: &'static str,
1767    /// Table name or DDL error description.
1768    pub table: String,
1769}
1770
1771impl std::fmt::Display for PackSchemaCollisionError {
1772    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1773        if self.pack_a == self.pack_b {
1774            write!(
1775                f,
1776                "pack schema boot failure for pack {:?}: {}",
1777                self.pack_a, self.table
1778            )
1779        } else {
1780            write!(
1781                f,
1782                "pack schema collision: packs {:?} and {:?} both declare table {:?} \
1783                 on the same backend — move one pack to a separate backend or rename the table",
1784                self.pack_a, self.pack_b, self.table
1785            )
1786        }
1787    }
1788}
1789
1790impl std::error::Error for PackSchemaCollisionError {}
1791
1792/// Extract table names from a single DDL statement.
1793///
1794/// Handles SQL trivia, SQLite identifier quoting, optional TEMP/VIRTUAL and a
1795/// `main.` qualifier. Index and other non-table DDL return no table names.
1796fn extract_table_names(stmt: &str) -> Vec<String> {
1797    enum SqlToken {
1798        Bare(String),
1799        Quoted(String),
1800        Punctuation(char),
1801    }
1802
1803    let mut tokens = Vec::new();
1804    let mut chars = stmt.chars().peekable();
1805    while let Some(ch) = chars.next() {
1806        if ch.is_whitespace() {
1807            continue;
1808        }
1809        if ch == '-' && chars.peek() == Some(&'-') {
1810            chars.next();
1811            for next in chars.by_ref() {
1812                if next == '\n' {
1813                    break;
1814                }
1815            }
1816            continue;
1817        }
1818        if ch == '/' && chars.peek() == Some(&'*') {
1819            chars.next();
1820            let mut previous = '\0';
1821            for next in chars.by_ref() {
1822                if previous == '*' && next == '/' {
1823                    break;
1824                }
1825                previous = next;
1826            }
1827            continue;
1828        }
1829        if matches!(ch, '"' | '`' | '[') {
1830            let closing = if ch == '[' { ']' } else { ch };
1831            let mut token = String::new();
1832            while let Some(next) = chars.next() {
1833                if next == closing {
1834                    if chars.peek() == Some(&closing) {
1835                        chars.next();
1836                        token.push(closing);
1837                    } else {
1838                        break;
1839                    }
1840                } else {
1841                    token.push(next);
1842                }
1843            }
1844            tokens.push(SqlToken::Quoted(token));
1845            continue;
1846        }
1847        if matches!(ch, '.' | '(' | ';') {
1848            tokens.push(SqlToken::Punctuation(ch));
1849            continue;
1850        }
1851        let mut token = ch.to_string();
1852        while let Some(next) = chars.peek().copied() {
1853            let begins_comment = (next == '-' && chars.clone().nth(1) == Some('-'))
1854                || (next == '/' && chars.clone().nth(1) == Some('*'));
1855            if next.is_whitespace()
1856                || matches!(next, '.' | '(' | ';' | '"' | '`' | '[')
1857                || begins_comment
1858            {
1859                break;
1860            }
1861            token.push(next);
1862            chars.next();
1863        }
1864        tokens.push(SqlToken::Bare(token));
1865    }
1866
1867    let keyword = |index: usize, word: &str| matches!(tokens.get(index), Some(SqlToken::Bare(token)) if token.eq_ignore_ascii_case(word));
1868    if !keyword(0, "CREATE") {
1869        return Vec::new();
1870    }
1871    let mut index = 1;
1872    if keyword(index, "TEMP") || keyword(index, "TEMPORARY") {
1873        index += 1;
1874    }
1875    if keyword(index, "VIRTUAL") {
1876        index += 1;
1877    }
1878    if !keyword(index, "TABLE") {
1879        return Vec::new();
1880    }
1881    index += 1;
1882    if keyword(index, "IF") && keyword(index + 1, "NOT") && keyword(index + 2, "EXISTS") {
1883        index += 3;
1884    }
1885    let main_qualifier = matches!(
1886        tokens.get(index),
1887        Some(SqlToken::Bare(name) | SqlToken::Quoted(name)) if name.eq_ignore_ascii_case("main")
1888    );
1889    if main_qualifier && matches!(tokens.get(index + 1), Some(SqlToken::Punctuation('.'))) {
1890        index += 2;
1891    }
1892    match tokens.get(index) {
1893        Some(SqlToken::Bare(name) | SqlToken::Quoted(name)) if !name.is_empty() => {
1894            vec![name.to_ascii_lowercase()]
1895        }
1896        _ => Vec::new(),
1897    }
1898}
1899
1900/// Render an [`EndpointKind`] as the `"<substrate>:<kind>"` label used in
1901/// `link(help=true)`'s `endpoint_rules` table.
1902fn endpoint_kind_label(kind: &EndpointKind) -> String {
1903    match kind {
1904        EndpointKind::EntityOfKind(k) => format!("entity:{k}"),
1905        EndpointKind::NoteOfKind(k) => format!("note:{k}"),
1906        EndpointKind::EntityOfType { kind, entity_type } => {
1907            format!("entity:{kind}({entity_type})")
1908        }
1909    }
1910}
1911
1912/// Relations `validate_edge_relation_endpoints`
1913/// (`crates/khive-runtime/src/operations.rs`) resolves in its own dedicated
1914/// branch — before the generic pack-rule branch (`pack_rule_allows`) is ever
1915/// reached. For these three relations the validator additionally accepts
1916/// any `note -> note` pair unconditionally, regardless of note kind
1917/// (ADR-002 §"Versioning" and §"Epistemic"), and never consults pack
1918/// `EDGE_RULES` at all, on either substrate.
1919pub(crate) const SPECIAL_RELATIONS: &[khive_types::EdgeRelation] = &[
1920    khive_types::EdgeRelation::Supersedes,
1921    khive_types::EdgeRelation::Supports,
1922    khive_types::EdgeRelation::Refutes,
1923];
1924
1925pub(crate) fn is_special_relation(relation: khive_types::EdgeRelation) -> bool {
1926    SPECIAL_RELATIONS.contains(&relation)
1927}
1928
1929/// Compose the full per-relation endpoint allowlist surfaced by
1930/// `link(help=true)` (issue #964).
1931///
1932/// Combines the base entity-to-entity endpoint contract
1933/// (`operations::base_entity_endpoint_rules`) with every loaded pack's
1934/// additive `EDGE_RULES`, the unconditional `note -> note` allowance for the
1935/// three special relations (`supersedes` / `supports` / `refutes` —
1936/// `operations.rs`'s dedicated special-relation branch), and the
1937/// `annotates` note-to-any special case — the exact same sources
1938/// `valid_relations_for_entity_pair` (`khive-pack-kg`) consults when
1939/// enriching a rejected `link` call, so a caller reading this table cannot
1940/// diverge from what the validator itself accepts.
1941///
1942/// Pack `EDGE_RULES` for a special relation are deliberately excluded: the
1943/// validator's special-relation branch returns before `pack_rule_allows` is
1944/// ever reached (`operations.rs`), so advertising such a rule here would
1945/// claim enforcement that never actually happens.
1946fn edge_endpoint_table(packs: &[Box<dyn PackRuntime>]) -> Vec<Value> {
1947    let mut rows: Vec<Value> = crate::operations::base_entity_endpoint_rules()
1948        .iter()
1949        .map(|(src, rel, tgt)| {
1950            serde_json::json!({
1951                "relation": rel.as_str(),
1952                "source": format!("entity:{src}"),
1953                "target": format!("entity:{tgt}"),
1954            })
1955        })
1956        .collect();
1957
1958    for rel in SPECIAL_RELATIONS {
1959        rows.push(serde_json::json!({
1960            "relation": rel.as_str(),
1961            "source": "note:*",
1962            "target": "note:*",
1963        }));
1964    }
1965
1966    for pack in packs.iter() {
1967        for rule in pack.edge_rules().iter() {
1968            if is_special_relation(rule.relation) {
1969                continue;
1970            }
1971            rows.push(serde_json::json!({
1972                "relation": rule.relation.as_str(),
1973                "source": endpoint_kind_label(&rule.source),
1974                "target": endpoint_kind_label(&rule.target),
1975            }));
1976        }
1977    }
1978
1979    rows.push(serde_json::json!({
1980        "relation": "annotates",
1981        "source": "note:*",
1982        "target": "any (entity, note, edge, or event)",
1983    }));
1984
1985    rows
1986}
1987
1988impl VerbRegistry {
1989    /// Resolve a KG entity/note handle across the configured backend inventory.
1990    ///
1991    /// The caller must supply its dispatch-authorized token. By-ID reads do not
1992    /// filter the stored namespace (ADR-007); no new token is minted here. With
1993    /// ordinary single-runtime registration, retain the supplied runtime's
1994    /// existing behavior. This does not route mutations or pack-private records.
1995    pub async fn resolve_kg_read_by_id(
1996        &self,
1997        runtime: &KhiveRuntime,
1998        token: &NamespaceToken,
1999        id: uuid::Uuid,
2000        include_deleted: bool,
2001    ) -> Result<Option<crate::Resolved>, RuntimeError> {
2002        match &self.kg_read_resolver {
2003            Some(resolver) => resolver.by_id(token, id, include_deleted).await,
2004            None if include_deleted => runtime.resolve_by_id_including_deleted(token, id).await,
2005            None => runtime.resolve_by_id(token, id).await,
2006        }
2007    }
2008
2009    /// Find the unique configured backend holding an entity for deletion.
2010    /// Includes tombstones so soft deletion cannot hide a duplicate owner.
2011    /// The dispatch-authorized token is preserved; lookup is namespace-agnostic.
2012    pub async fn resolve_entity_delete_runtime(
2013        &self,
2014        runtime: &KhiveRuntime,
2015        token: &NamespaceToken,
2016        id: uuid::Uuid,
2017    ) -> Result<Option<KhiveRuntime>, RuntimeError> {
2018        match &self.kg_read_resolver {
2019            Some(resolver) => resolver.entity_runtime(token, id).await,
2020            None => {
2021                let store = runtime.entities(token)?;
2022                let entity = store.get_entity_including_deleted(id).await?;
2023                Ok(entity.map(|_| runtime.clone()))
2024            }
2025        }
2026    }
2027
2028    /// Clean main-backend attachments after no live or tombstoned owner remains.
2029    /// A live or tombstoned entity on any configured backend keeps its roots.
2030    pub async fn cleanup_deleted_entity_attachments(
2031        &self,
2032        runtime: &KhiveRuntime,
2033        token: &NamespaceToken,
2034        id: uuid::Uuid,
2035    ) -> Result<bool, RuntimeError> {
2036        if self
2037            .resolve_entity_delete_runtime(runtime, token, id)
2038            .await?
2039            .is_some()
2040        {
2041            return Ok(false);
2042        }
2043        runtime.delete_entity_attachments_on_core(id).await
2044    }
2045
2046    /// Recheck a merged-entity read against the kept id before returning it.
2047    /// The submitted argument shape is the verb's ordinary shape with the
2048    /// effective id substituted. The dispatch's original check remains its
2049    /// own audit row; this consultation records the effective target as a
2050    /// second row without changing the public GateRequest schema.
2051    pub async fn authorize_effective_kg_read(
2052        &self,
2053        token: &NamespaceToken,
2054        verb: &str,
2055        mut effective_args: Value,
2056        effective_id: uuid::Uuid,
2057    ) -> Result<(), RuntimeError> {
2058        if let Some(namespace) = token.gate_explicit_namespace() {
2059            effective_args["namespace"] = Value::String(namespace.to_owned());
2060        }
2061        let gate_req = GateRequest::new(
2062            token.actor().clone(),
2063            token.gate_namespace().clone(),
2064            verb,
2065            effective_args,
2066        );
2067        let decision = khive_gate::check_with_mailbox_policy(self.gate.as_ref(), &gate_req);
2068        match decision {
2069            Ok(decision) => {
2070                let audit = masked_audit_event(&gate_req, &decision, self.gate.impl_name());
2071                tracing::info!(
2072                    audit_event = %serde_json::to_string(&audit)
2073                        .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
2074                    effective_target_id = %effective_id,
2075                    "gate.check"
2076                );
2077                let denied = matches!(&decision, GateDecision::Deny { .. });
2078                let receipt = if let Some(store) = &self.event_store {
2079                    let event = build_audit_storage_event(
2080                        &gate_req,
2081                        &audit,
2082                        if denied {
2083                            EventOutcome::Denied
2084                        } else {
2085                            EventOutcome::Success
2086                        },
2087                        Some(crate::cost_unit::base_resource_payload(token.request_id())),
2088                    )
2089                    .with_target(effective_id);
2090                    if denied {
2091                        self.append_gate_denied_row(store, event, verb).await
2092                    } else {
2093                        let outcome = append_audit_event_best_effort(
2094                            self.audit_batch.as_ref(),
2095                            store,
2096                            event,
2097                            verb,
2098                            crate::audit_batch::AuditProducer::EffectiveTargetCheck,
2099                            false,
2100                        )
2101                        .await;
2102                        fold_audit_obligation(Ok(()), outcome, |_| Value::Null)?;
2103                        crate::error::DenialReceipt::no_store()
2104                    }
2105                } else {
2106                    crate::error::DenialReceipt::no_store()
2107                };
2108                match decision {
2109                    GateDecision::Allow { .. } => Ok(()),
2110                    GateDecision::Deny { reason } => Err(RuntimeError::PermissionDenied {
2111                        verb: verb.to_string(),
2112                        reason,
2113                        receipt: Box::new(receipt),
2114                    }),
2115                }
2116            }
2117            Err(error) => Err(self
2118                .gate_unavailable_error(&gate_req, &error, token.request_id(), Some(effective_id))
2119                .await),
2120        }
2121    }
2122
2123    /// Resolve a prefix across the same inventory, rejecting distinct UUIDs.
2124    ///
2125    /// Retains the local prefix scanner's entity/note/event/edge collision domain,
2126    /// including sidecar events. The returned UUID is not a substrate assertion:
2127    /// consumers must still fetch/type-check it. All backend failures propagate.
2128    pub async fn resolve_kg_read_prefix(
2129        &self,
2130        runtime: &KhiveRuntime,
2131        _token: &NamespaceToken,
2132        prefix: &str,
2133        include_deleted: bool,
2134    ) -> Result<Option<uuid::Uuid>, RuntimeError> {
2135        match &self.kg_read_resolver {
2136            Some(resolver) => resolver.prefix(prefix, include_deleted).await,
2137            None if include_deleted => {
2138                runtime
2139                    .resolve_prefix_unfiltered_including_deleted(prefix)
2140                    .await
2141            }
2142            None => runtime.resolve_prefix_unfiltered(prefix).await,
2143        }
2144    }
2145
2146    /// This registry's construction-baked default namespace.
2147    ///
2148    /// Used as the fallback when a request carries no [`RequestIdentity`]
2149    /// override (ADR-096 Fork 1) and by transports that need to advertise
2150    /// their own resolved identity when forwarding to a warm daemon.
2151    pub fn default_namespace(&self) -> &str {
2152        &self.default_namespace
2153    }
2154
2155    /// This registry's construction-baked actor identity label, if configured
2156    /// (ADR-057). `None` means dispatch mints `ActorRef::anonymous()` absent a
2157    /// per-request [`RequestIdentity`] override (ADR-096 Fork 1).
2158    pub fn actor_id(&self) -> Option<&str> {
2159        self.actor_id.as_deref()
2160    }
2161
2162    /// This registry's construction-baked extra read-visibility namespaces
2163    /// (ADR-007 Rev 4 Rule 3b), used absent a per-request [`RequestIdentity`]
2164    /// override (ADR-096 Fork 1).
2165    pub fn visible_namespaces(&self) -> &[Namespace] {
2166        &self.visible_namespaces
2167    }
2168
2169    /// This registry's configured audit `EventStore`, if any (ADR-094).
2170    ///
2171    /// Lets background tasks that hold a `VerbRegistry` but do not go through
2172    /// `dispatch` (e.g. the email channel poll loop) append best-effort
2173    /// lifecycle events to the same sink gate-check audit rows use, without
2174    /// threading a second `Option<Arc<dyn EventStore>>` field through every
2175    /// caller. `None` means either the historical tracing-only default or an
2176    /// intentionally read-only audit backend; callers that need to distinguish
2177    /// those cases use [`Self::audit_persistence_advisory`].
2178    pub fn event_store(&self) -> Option<Arc<dyn EventStore>> {
2179        self.event_store.clone()
2180    }
2181
2182    /// Process-lifetime audit-batch health counters for this registry's
2183    /// ADR-133 seam, if one is configured. `None` exactly when
2184    /// [`Self::event_store`] is `None` — the same condition under which no
2185    /// batch exists to report on. The `db_diagnostics` verb feeds this into
2186    /// `KhiveRuntime::db_diagnostics_with_audit_metrics` so an operator can
2187    /// see flush failures and pure-observability degradation instead of the
2188    /// permanently-unavailable placeholder a bare `KhiveRuntime` reports.
2189    ///
2190    /// `admission_refused_obligations` and `admission_unresolved_obligations`
2191    /// are sourced separately from [`audit_admission_refused_obligation_count`]
2192    /// and [`audit_admission_unresolved_obligation_count`] rather than from
2193    /// `batch.health_metrics()`: they count a decision made in
2194    /// `append_audit_event_best_effort` (ADR-103 Amendment 3 / ADR-133
2195    /// Amendment 1), not a property of the batch itself, so they are
2196    /// process-wide like the rest of this struct's fields rather than
2197    /// per-`AuditBatch`.
2198    pub fn audit_batch_metrics(&self) -> Option<khive_db::diagnostics::RuntimeAuditBatchMetrics> {
2199        self.audit_batch.as_ref().map(|batch| {
2200            let m = batch.health_metrics();
2201            khive_db::diagnostics::RuntimeAuditBatchMetrics {
2202                flush_failures: m.flush_failures,
2203                degraded_rows: m.degraded_rows,
2204                degraded: m.degraded,
2205                admission_refused_obligations: audit_admission_refused_obligation_count(),
2206                admission_refused_obligations_last_at_ms:
2207                    audit_admission_refused_obligation_last_at_ms(),
2208                admission_unresolved_obligations: audit_admission_unresolved_obligation_count(),
2209                admission_unresolved_obligations_last_at_ms:
2210                    audit_admission_unresolved_obligation_last_at_ms(),
2211            }
2212        })
2213    }
2214
2215    /// Test/diagnostic-only accessor for the underlying ADR-133 audit-batch
2216    /// seam. `None` when no `EventStore` was configured (the batch is lazily
2217    /// constructed from one). Exposed so admission-pressure mechanism tests
2218    /// can saturate and drain the SAME instance a real dispatch uses
2219    /// (#2117, #2147, #2208, #2217) instead of testing a
2220    /// look-alike.
2221    pub fn audit_batch_handle(&self) -> Option<Arc<crate::audit_batch::AuditBatch>> {
2222        self.audit_batch.clone()
2223    }
2224
2225    /// Stop admitting new audit rows and wait for every already-accepted row
2226    /// to reach a terminal state (ADR-133).
2227    ///
2228    /// A no-op returning `Ok(())` when no `EventStore` — and therefore no
2229    /// audit-batch seam — is configured. Callers that own this registry's
2230    /// shutdown sequence should call this before tearing down the writer or
2231    /// database so no accepted audit row is silently dropped mid-flight.
2232    pub async fn shutdown_audit_batch(
2233        &self,
2234    ) -> Result<(), crate::audit_batch::AuditTerminalReason> {
2235        use crate::audit_batch::AuditBatchControl;
2236        match &self.audit_batch {
2237            Some(audit_batch) => audit_batch.close_and_drain().await,
2238            None => Ok(()),
2239        }
2240    }
2241
2242    /// Advisory for a dispatch whose configured audit sink is read-only.
2243    ///
2244    /// The MCP transport places this beside successful per-operation results;
2245    /// `None` means audit persistence is configured normally or was never
2246    /// configured at all.
2247    pub fn audit_persistence_advisory(&self) -> Option<Value> {
2248        self.audit_store_read_only.then(|| {
2249            serde_json::json!({
2250                "code": AUDIT_PERSISTENCE_SKIPPED_READ_ONLY,
2251                "severity": "warning",
2252                "component": "audit_event_store",
2253                "reason": "read_only_backend",
2254                "message": "operation completed, but its dispatch audit event was not persisted because the audit backend is read-only",
2255            })
2256        })
2257    }
2258
2259    /// Explicit, fail-closed opt-in for admission-pressure audit degradation
2260    /// (#2147/#2217). `VerbCategory::Assertive` alone is NOT a
2261    /// sound proxy for "safe to drop this dispatch's own audit row under
2262    /// audit-lane admission pressure": several Assertive handlers have
2263    /// their own durable or accounting-bearing side effects. The reviewed
2264    /// exclusions are:
2265    /// - `memory.recall` dispatches `brain.record_serve` as a background
2266    ///   write; degrading `memory.recall`'s row raises the risk that a
2267    ///   serve goes unaccounted for if the ledger dispatch itself later
2268    ///   also races admission pressure.
2269    /// - `db_diagnostics` may backfill WAL frames via a PASSIVE checkpoint
2270    ///   probe — physical I/O, not a pure in-memory read.
2271    /// - `knowledge.search`, `knowledge.suggest`, and auto
2272    ///   `knowledge.compose` may start persistent ANN consumer/checkpoint
2273    ///   maintenance from their nominal read path.
2274    /// - `git.checkout`, `git.diff` and `git.reconcile` persist a durable
2275    ///   receipt on every dispatch (checkout and diff also write a manifest
2276    ///   or diff blob), so their accounting row is not droppable.
2277    ///
2278    /// What membership here means, precisely: the verb performs no domain
2279    /// mutation, so its OWN per-dispatch audit/accounting row may be dropped
2280    /// under transient admission pressure without the caller losing a
2281    /// meaningful result (ADR-103 Amendment 3, ADR-133 Amendment 1). It does
2282    /// NOT mean the handler is free of every event-plane write: `search`
2283    /// still fires its own best-effort `SearchExecuted` telemetry, and
2284    /// `context` still records a one-time `ConfigLocked` event, both on
2285    /// independent code paths this mechanism never touches — those events
2286    /// commit or fail on their own terms, unaffected by whether this
2287    /// dispatch's own audit row degrades.
2288    ///
2289    /// Every entry here MUST be declared `VerbCategory::Assertive` in its
2290    /// named pack's live vocabulary. The
2291    /// `admission_degrade_safe_assertive_census_matches_live_pack_sources`
2292    /// test below scans every pack that currently declares public Assertive
2293    /// handlers and requires every such handler to be classified exactly
2294    /// once as safe or as a known incidental writer. A new Assertive verb
2295    /// therefore fails closed both at runtime and in the source census until
2296    /// it receives an explicit side-effect review.
2297    ///
2298    /// Entries are `(owning pack name, verb)` pairs, not bare verb names:
2299    /// [`Self::admission_degrade_safe`] requires the handler actually
2300    /// resolved for `verb` to belong to the exact pack named here. A verb
2301    /// name alone is not a sound key — any pack registered through the same
2302    /// [`PackRegistry`]/[`VerbRegistryBuilder`] path can declare a handler
2303    /// under any name it likes, including one that collides with a name on
2304    /// this list, and unique-verb-name validation only rejects that
2305    /// collision when the real owning pack is *also* loaded. A deployment
2306    /// that omits the real pack (or loads a third-party pack instead) would
2307    /// let a same-named write-performing handler inherit degrade-safety it
2308    /// never earned. Binding to the pack closes that gap.
2309    const ADMISSION_DEGRADE_SAFE_VERBS: &'static [(&'static str, &'static str)] = &[
2310        // agent
2311        ("agent", "agent.observe"),
2312        // exec (reads of the blob store, the run receipt and event tables, or
2313        // the resolved configuration; the writers are exec.tree and
2314        // exec.tree_put, Declarations, and exec.run, a Directive)
2315        ("exec", "exec.tree_get"),
2316        ("exec", "exec.tree_diff"),
2317        ("exec", "exec.receipt"),
2318        ("exec", "exec.runs"),
2319        ("exec", "exec.events"),
2320        ("exec", "exec.identity"),
2321        // git (receipt list, allowlist, working-tree and history reads;
2322        // checkout, diff and reconcile persist receipts and are excluded)
2323        ("git", "git.receipts"),
2324        ("git", "git.gates"),
2325        ("git", "git.status"),
2326        ("git", "git.log"),
2327        // Canonical get project check plus bounded cursor SELECT; no domain writes.
2328        ("git", "git.ingest_cursor"),
2329        // blob
2330        ("blob", "blob.get"),
2331        ("blob", "blob.stat"),
2332        // brain
2333        ("brain", "brain.event_counts"),
2334        ("brain", "brain.profiles"),
2335        ("brain", "brain.profile"),
2336        ("brain", "brain.resolve"),
2337        ("brain", "brain.bindings"),
2338        // comm
2339        ("comm", "comm.delivered"),
2340        ("comm", "comm.inbox"),
2341        ("comm", "comm.unread"),
2342        ("comm", "comm.thread"),
2343        ("comm", "comm.health"),
2344        ("comm", "comm.probe"),
2345        // gtd
2346        ("gtd", "gtd.census"),
2347        ("gtd", "gtd.next"),
2348        ("gtd", "gtd.tasks"),
2349        // kg
2350        ("kg", "get"),
2351        ("kg", "list"),
2352        ("kg", "stats"),
2353        ("kg", "search"),
2354        ("kg", "neighbors"),
2355        ("kg", "traverse"),
2356        ("kg", "context"),
2357        ("kg", "query"),
2358        ("kg", "resolve"),
2359        ("kg", "whoami"),
2360        // scan runs the secret gate over caller-supplied text in process: no
2361        // store read, no store write, no event.
2362        ("kg", "scan"),
2363        ("kg", "verbs"),
2364        ("kg", "stream.read"),
2365        ("kg", "stream.stat"),
2366        // knowledge (ANN-maintaining search/suggest/compose are excluded)
2367        ("knowledge", "knowledge.get"),
2368        ("knowledge", "knowledge.list"),
2369        ("knowledge", "knowledge.stats"),
2370        ("knowledge", "knowledge.fold"),
2371        ("knowledge", "knowledge.topic"),
2372        // moodboard
2373        ("moodboard", "moodboard.model"),
2374        ("moodboard", "moodboard.search"),
2375        ("moodboard", "moodboard.preference"),
2376        // schedule
2377        ("schedule", "schedule.agenda"),
2378        // session
2379        ("session", "session.list"),
2380        ("session", "session.resume"),
2381        ("session", "session.export"),
2382        ("session", "session.search"),
2383        // tool (registry, grant and policy reads; tool.suggest runs the same
2384        // hybrid search as the kg search and context verbs above)
2385        ("tool", "tool.suggest"),
2386        ("tool", "tool.describe"),
2387        ("tool", "tool.list"),
2388        ("tool", "tool.check"),
2389        ("tool", "tool.requests"),
2390        ("tool", "tool.policies"),
2391    ];
2392
2393    /// Sorted copy of [`Self::ADMISSION_DEGRADE_SAFE_VERBS`], built once, so
2394    /// [`VerbRegistryBuilder::build`] can decide each trusted handler's
2395    /// eligibility with a binary search instead of a linear scan over every
2396    /// entry. Consulted exactly once per registry, at `build()` time — see
2397    /// [`Self::admission_degrade_safe`] for why no per-dispatch scan exists
2398    /// anymore. The source list above stays grouped by pack (with a `//
2399    /// <pack>` comment per group) for human review; this is a derived,
2400    /// lookup-shaped view of the same data, not a second source of truth.
2401    fn admission_degrade_safe_sorted() -> &'static [(&'static str, &'static str)] {
2402        static SORTED: std::sync::LazyLock<Vec<(&'static str, &'static str)>> =
2403            std::sync::LazyLock::new(|| {
2404                let mut pairs = VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS.to_vec();
2405                pairs.sort_unstable();
2406                pairs
2407            });
2408        &SORTED
2409    }
2410
2411    /// Whether `verb` is both declared [`VerbCategory::Assertive`] (the
2412    /// speech-act tag for handlers that "retrieve and present facts" rather
2413    /// than committing a domain change) AND explicitly opted in to
2414    /// admission-pressure audit degradation via
2415    /// [`Self::ADMISSION_DEGRADE_SAFE_VERBS`] under the exact pack that
2416    /// registered it, AND declared by a pack the composition root actually
2417    /// vouches for (see [`VerbRegistryBuilder::register_boxed`]'s doc).
2418    /// Unknown, non-opted-in, wrong-pack, or untrusted-pack verbs are
2419    /// conservatively `false` — fail-closed, so a new Assertive handler (or
2420    /// one registered by a pack other than the one the allowlist names, or
2421    /// one registered through [`VerbRegistryBuilder::register`] rather than
2422    /// the trusted path) hard-fails its audit obligation like any write
2423    /// until someone deliberately reviews it and adds it to the allowlist.
2424    ///
2425    /// `pack.name()` is a value the `PackRuntime` trait object reports about
2426    /// itself — any pack registered through the public
2427    /// [`VerbRegistryBuilder::register`] path can claim any name, including
2428    /// one on the allowlist, whether or not the pack that name actually
2429    /// belongs to is also loaded (verb names are unique per registry, so an
2430    /// impostor's same-named handler is only reachable when the real pack
2431    /// is absent). Binding eligibility to registration-time trust — decided
2432    /// by the *caller*, never by the pack instance — is why this checks
2433    /// `degrade_safe_verbs` rather than resolving `pack.name()` at query
2434    /// time; [`VerbRegistryBuilder::build`] already excluded every untrusted
2435    /// pack's handlers from that set.
2436    ///
2437    /// The whole decision is precomputed once in `VerbRegistryBuilder::build`
2438    /// into [`VerbRegistry::degrade_safe_verbs`] — a verb name is unique
2439    /// across `Visibility::Verb` handlers within one registry
2440    /// (`validate_unique_verb_names`), so this is a single hash-set lookup,
2441    /// not a per-dispatch scan over every registered pack's handler list.
2442    ///
2443    /// Used only to decide whether a dispatch's own audit-obligation row may
2444    /// degrade to best-effort on transient audit-lane admission pressure
2445    /// (`append_audit_event_best_effort`) — a read that performed no domain
2446    /// write must not fail the caller just because the audit lane is
2447    /// momentarily saturated. Never used for permission checking, transport
2448    /// routing, or return-shape selection.
2449    fn admission_degrade_safe(&self, verb: &str) -> bool {
2450        self.degrade_safe_verbs.contains(verb)
2451    }
2452
2453    /// Transport replay eligibility from the shared operation-effects table,
2454    /// restricted to trusted canonical public handlers. A read that persists a
2455    /// fresh serve or telemetry row is excluded because the request id is
2456    /// correlation, not deduplication.
2457    /// Custom and mounted handlers cannot inherit safety from a name/category.
2458    pub fn is_read_replay_safe(&self, verb: &str) -> bool {
2459        self.read_replay_safe_verbs.contains(verb)
2460    }
2461
2462    /// White-box accessor for [`Self::admission_degrade_safe`], needed
2463    /// because the admission-pressure regression tests in
2464    /// `tests/read_verb_admission_exhaustion.rs` compile as a separate
2465    /// external binary and cannot reach a crate-private method directly —
2466    /// the same reason [`audit_admission_refused_obligation_count`] and
2467    /// `AuditBatch::test_snapshot` are `pub` rather than `pub(crate)`.
2468    #[cfg(any(test, feature = "test-internals"))]
2469    pub fn admission_degrade_safe_probe(&self, verb: &str) -> bool {
2470        self.admission_degrade_safe(verb)
2471    }
2472
2473    /// Return the help schema envelope for a verb.
2474    ///
2475    /// Walks registered packs for the first matching `HandlerDef` and returns a
2476    /// structured JSON envelope. Subhandlers carry `callable_via_mcp: false`.
2477    /// Every envelope carries the shared `identifier_resolution` contract.
2478    /// `link`'s envelope additionally carries `endpoint_rules` — the composed
2479    /// per-relation source/target allowlist (issue #964) — so batch callers can
2480    /// defer to the kernel's own table instead of re-implementing it locally.
2481    /// Every `uuid`/`array of uuid` parameter description has its
2482    /// declared [`IdResolutionMode`]'s contract text appended — the same
2483    /// text rendered under `identifier_resolution.resolution_modes` — so the
2484    /// full-UUID-vs-short-prefix rule is stated once per mode (in
2485    /// `resolution_mode_contract`) and inherited by every matching param,
2486    /// instead of restating it per param across every `HandlerDef` in every
2487    /// pack. Parameters whose mode is [`IdResolutionMode::NotApplicable`]
2488    /// (every non-identifier parameter) are left unchanged.
2489    /// Unknown verbs return `RuntimeError::InvalidInput`. Full shape documented
2490    /// in `docs/protocol.md` §Request Schema.
2491    pub fn describe_verb(&self, verb: &str) -> Result<Value, RuntimeError> {
2492        for pack in self.packs.iter() {
2493            for handler in pack.handlers().iter() {
2494                if handler.name == verb {
2495                    let category = format!("{:?}", handler.category);
2496                    let params_arr: Vec<Value> = handler
2497                        .params
2498                        .iter()
2499                        .map(|p| {
2500                            let description = match resolution_mode_contract(p.resolution_mode) {
2501                                Some(contract) => format!("{} {}", p.description, contract),
2502                                None => p.description.to_string(),
2503                            };
2504                            serde_json::json!({
2505                                "name": p.name,
2506                                "type": p.param_type,
2507                                "required": p.required,
2508                                "description": description,
2509                            })
2510                        })
2511                        .collect();
2512                    // Subhandlers are not callable via the MCP request surface;
2513                    // the help payload must match the behaviour the dispatch
2514                    // path enforces so callers reading `help=true` before
2515                    // probing see accurate availability.
2516                    if matches!(handler.visibility, Visibility::Subhandler) {
2517                        return Ok(serde_json::json!({
2518                            "verb": verb,
2519                            "pack": pack.name(),
2520                            "description": handler.description,
2521                            "category": category,
2522                            "params": params_arr,
2523                            "identifier_resolution": identifier_resolution_help(),
2524                            "visibility": "internal",
2525                            "callable_via_mcp": false,
2526                            "note": "This is an internal subhandler. Calling it via the MCP \
2527                                     request surface returns permission denied. It can only be \
2528                                     invoked by internal runtime callers.",
2529                        }));
2530                    }
2531                    let mut envelope = serde_json::json!({
2532                        "verb": verb,
2533                        "pack": pack.name(),
2534                        "description": handler.description,
2535                        "category": category,
2536                        "params": params_arr,
2537                        "identifier_resolution": identifier_resolution_help(),
2538                    });
2539                    // A pack that authored its own schema keeps it; every other
2540                    // verb gets one derived from the declarations the runtime
2541                    // already holds, so a bridged model has a schema to read
2542                    // instead of parsing the prose `params[].type`.
2543                    if let Some(schema) = pack.input_schema(verb) {
2544                        envelope["input_schema"] = schema;
2545                    } else {
2546                        let described: Vec<(String, String)> = params_arr
2547                            .iter()
2548                            .map(|p| {
2549                                (
2550                                    p["name"].as_str().unwrap_or_default().to_string(),
2551                                    p["description"].as_str().unwrap_or_default().to_string(),
2552                                )
2553                            })
2554                            .collect();
2555                        if let Some(schema) =
2556                            crate::input_schema::derive_input_schema(handler.params, &described)
2557                        {
2558                            envelope["input_schema"] = schema;
2559                        }
2560                    }
2561                    if verb == "link" {
2562                        envelope["endpoint_rules"] = Value::Array(edge_endpoint_table(&self.packs));
2563                    }
2564                    return Ok(envelope);
2565                }
2566            }
2567        }
2568        // Verb-visibility handler names, precomputed at build() time (internal
2569        // subhandlers are excluded so they are not advertised in the
2570        // unknown-verb error).
2571        Err(RuntimeError::UnknownVerb(format!(
2572            "unknown verb {verb:?}; available: {}",
2573            self.available_verbs.join(", ")
2574        )))
2575    }
2576
2577    /// Check whether the gate permits writes into `ns`.
2578    ///
2579    /// Performs a gate evaluation with verb `"authorize"` before any background
2580    /// loop is spawned (ADR-056 §6).  Returns `Ok(())` when the gate allows the
2581    /// namespace, or `Err(RuntimeError::PermissionDenied{..})` when denied.
2582    /// Gate errors (implementation failures) are surfaced as
2583    /// `RuntimeError::Internal` carrying the stable classified reason; the
2584    /// bounded, masked backend detail goes to the server-side log here, since
2585    /// callers log the returned error.
2586    pub fn authorize_namespace(&self, ns: Namespace) -> Result<(), RuntimeError> {
2587        let actor = crate::actor_identity::resolve_actor(self.actor_id.as_deref());
2588        let req = GateRequest::new(actor, ns, "authorize", serde_json::Value::Null);
2589        match self.gate.check(&req) {
2590            Ok(decision) if decision.is_allow() => Ok(()),
2591            Ok(GateDecision::Deny { reason }) => {
2592                Err(RuntimeError::permission_denied("authorize", reason))
2593            }
2594            Ok(_) => Err(RuntimeError::permission_denied("authorize", "gate denied")),
2595            Err(e) => {
2596                tracing::warn!(
2597                    error = %crate::secret_gate::bounded_masked_log_text(&e.to_string()),
2598                    "authorize_namespace: gate check failed (fail-closed)"
2599                );
2600                Err(RuntimeError::Internal(format!(
2601                    "gate error: {}",
2602                    e.wire_reason()
2603                )))
2604            }
2605        }
2606    }
2607
2608    /// Gate and execute an operation handled outside normal pack dispatch.
2609    ///
2610    /// Multi-backend transports use this to route an operation through a
2611    /// coordinator while retaining [`Self::dispatch_with_identity`]'s gate and
2612    /// audit lifecycle. Deny is authoritative, gate errors fail closed, and an
2613    /// allowed audit is persisted after the intercepted operation resolves so
2614    /// its outcome and duration reflect the operation result. Successful
2615    /// `git.digest` interception uses the same strict durable-receipt exception
2616    /// as normal pack dispatch.
2617    pub async fn dispatch_intercepted_with_identity<F, Fut>(
2618        &self,
2619        verb: &str,
2620        params: &Value,
2621        identity: Option<&RequestIdentity>,
2622        dispatch: F,
2623    ) -> Result<Value, RuntimeError>
2624    where
2625        F: FnOnce(Namespace) -> Fut,
2626        Fut: std::future::Future<Output = Result<Value, RuntimeError>>,
2627    {
2628        self.dispatch_intercepted_with_metadata_with_identity(
2629            verb,
2630            params,
2631            identity,
2632            |namespace| async move {
2633                dispatch(namespace)
2634                    .await
2635                    .map(|result| InterceptedDispatchResult::new(result, ()))
2636            },
2637        )
2638        .await
2639        .map(|outcome| outcome.result)
2640    }
2641
2642    /// Gate and execute an intercepted operation whose transport needs typed
2643    /// metadata in addition to the canonical verb result.
2644    ///
2645    /// Audit accounting always receives `outcome.result`; `outcome.metadata`
2646    /// crosses the dispatch seam unchanged for the transport to place beside
2647    /// that result in its own envelope.
2648    pub async fn dispatch_intercepted_with_metadata_with_identity<M, F, Fut>(
2649        &self,
2650        verb: &str,
2651        params: &Value,
2652        identity: Option<&RequestIdentity>,
2653        dispatch: F,
2654    ) -> Result<InterceptedDispatchResult<M>, RuntimeError>
2655    where
2656        F: FnOnce(Namespace) -> Fut,
2657        Fut: std::future::Future<Output = Result<InterceptedDispatchResult<M>, RuntimeError>>,
2658    {
2659        self.dispatch_intercepted_with_metadata_and_disposition(verb, params, identity, dispatch)
2660            .await
2661            .map_err(DispatchError::into_source)
2662    }
2663
2664    /// Append the `GateDenied` row of a refused dispatch and report what the
2665    /// caller may cite: the row's id when it committed, otherwise why not.
2666    async fn append_gate_denied_row(
2667        &self,
2668        store: &Arc<dyn EventStore>,
2669        event: Event,
2670        verb: &str,
2671    ) -> crate::error::DenialReceipt {
2672        let audit_event_id = event.id;
2673        match append_audit_event_best_effort(
2674            self.audit_batch.as_ref(),
2675            store,
2676            event,
2677            verb,
2678            crate::audit_batch::AuditProducer::GateDenied,
2679            false,
2680        )
2681        .await
2682        {
2683            Ok(()) => crate::error::DenialReceipt {
2684                audit_event_id: Some(audit_event_id),
2685                audit_outcome: crate::error::DenialAuditOutcome::Committed,
2686            },
2687            Err(failure) => crate::error::DenialReceipt {
2688                audit_event_id: None,
2689                audit_outcome: crate::error::DenialAuditOutcome::NotCommitted(failure.wire_code()),
2690            },
2691        }
2692    }
2693
2694    /// Execute an intercepted operation while retaining this boundary's failure provenance.
2695    /// Successful canonical results and typed metadata are returned unchanged.
2696    pub async fn dispatch_intercepted_with_metadata_and_disposition<M, F, Fut>(
2697        &self,
2698        verb: &str,
2699        params: &Value,
2700        identity: Option<&RequestIdentity>,
2701        dispatch: F,
2702    ) -> Result<InterceptedDispatchResult<M>, DispatchError>
2703    where
2704        F: FnOnce(Namespace) -> Fut,
2705        Fut: std::future::Future<Output = Result<InterceptedDispatchResult<M>, RuntimeError>>,
2706    {
2707        let request_id = identity.and_then(|id| id.request_id);
2708        let gate_req = self
2709            .gate_request_with_identity(verb, params, identity)
2710            .map_err(DispatchError::before_dispatch)?;
2711        let gate_decision = khive_gate::check_with_mailbox_policy(self.gate.as_ref(), &gate_req);
2712        let mut deferred_audit = match gate_decision {
2713            Ok(decision) => {
2714                let audit = masked_audit_event(&gate_req, &decision, self.gate.impl_name());
2715                tracing::info!(
2716                    audit_event = %serde_json::to_string(&audit)
2717                        .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
2718                    "gate.check"
2719                );
2720                if let GateDecision::Deny { reason } = decision {
2721                    let receipt = match &self.event_store {
2722                        Some(store) => {
2723                            let event = build_audit_storage_event(
2724                                &gate_req,
2725                                &audit,
2726                                EventOutcome::Denied,
2727                                Some(crate::cost_unit::base_resource_payload(request_id)),
2728                            );
2729                            // The dispatch returns `PermissionDenied` below
2730                            // whether or not this row commits — a deny never
2731                            // reports success — so a commit failure has no
2732                            // caller-visible outcome to fold into; the receipt
2733                            // on the refusal says whether the row the caller
2734                            // could cite exists.
2735                            self.append_gate_denied_row(store, event, verb).await
2736                        }
2737                        None => crate::error::DenialReceipt::no_store(),
2738                    };
2739                    return Err(DispatchError::before_dispatch(
2740                        RuntimeError::PermissionDenied {
2741                            verb: verb.to_string(),
2742                            reason,
2743                            receipt: Box::new(receipt),
2744                        },
2745                    ));
2746                }
2747                Some(audit)
2748            }
2749            Err(err) => {
2750                return Err(DispatchError::before_dispatch(
2751                    self.gate_unavailable_error(&gate_req, &err, request_id, None)
2752                        .await,
2753                ));
2754            }
2755        };
2756
2757        let started = Instant::now();
2758        let mut result = dispatch(gate_req.namespace.clone()).await;
2759        let domain_succeeded = result.is_ok();
2760        let duration_us = started.elapsed().as_micros() as i64;
2761        let receipt_outcome = if verb == "git.digest" && result.is_ok() {
2762            let resource = result.as_ref().ok().map(|outcome| {
2763                crate::cost_unit::resource_payload(
2764                    verb,
2765                    &gate_req.args,
2766                    &outcome.result,
2767                    || 0,
2768                    request_id,
2769                )
2770            });
2771            // The receipt helper operates on the canonical verb result. Move
2772            // that value out temporarily so it can turn receipt failures into
2773            // the outer dispatch error without discarding successful typed
2774            // transport metadata.
2775            let mut receipt_result: Result<Value, RuntimeError> = match result.as_mut() {
2776                Ok(outcome) => Ok(std::mem::take(&mut outcome.result)),
2777                Err(_) => unreachable!("git.digest receipt path is guarded by result.is_ok()"),
2778            };
2779            let outcome = persist_git_digest_receipt(
2780                self.event_store.as_ref(),
2781                self.audit_batch.as_ref(),
2782                &gate_req,
2783                deferred_audit.as_ref(),
2784                &mut receipt_result,
2785                duration_us,
2786                resource,
2787            )
2788            .await;
2789            match receipt_result {
2790                Ok(receipted_result) => {
2791                    if let Ok(intercepted) = &mut result {
2792                        intercepted.result = receipted_result;
2793                    }
2794                }
2795                Err(error) => result = Err(error),
2796            }
2797            Some(outcome)
2798        } else {
2799            None
2800        };
2801        if receipt_outcome.is_none()
2802            || receipt_outcome == Some(GitDigestReceiptOutcome::BuildRejected)
2803        {
2804            if let Some(audit) = deferred_audit.take() {
2805                let audit_outcome = self
2806                    .persist_intercepted_audit(
2807                        verb,
2808                        &gate_req,
2809                        audit,
2810                        result.as_ref().map(|outcome| &outcome.result),
2811                        duration_us,
2812                        request_id,
2813                    )
2814                    .await;
2815                result = fold_audit_obligation(result, audit_outcome, |outcome| outcome.result);
2816            }
2817        }
2818        result.map_err(|error| DispatchError::after_handler(error, domain_succeeded))
2819    }
2820
2821    async fn persist_intercepted_audit(
2822        &self,
2823        verb: &str,
2824        gate_req: &GateRequest,
2825        audit: AuditEvent,
2826        result: Result<&Value, &RuntimeError>,
2827        duration_us: i64,
2828        request_id: Option<u64>,
2829    ) -> Result<(), AuditObligationFailure> {
2830        let Some(store) = &self.event_store else {
2831            return Ok(());
2832        };
2833        let event = match result {
2834            Ok(value) if verb == "link" && gate_req.args.get("links").is_none() => {
2835                let resource = crate::cost_unit::resource_payload(
2836                    verb,
2837                    &gate_req.args,
2838                    value,
2839                    || 0,
2840                    request_id,
2841                );
2842                match link_audit_success_from_result(audit.clone(), value) {
2843                    Some((edge_id, mut payload)) => {
2844                        if let Value::Object(ref mut map) = payload {
2845                            map.insert("resource".to_string(), resource);
2846                        }
2847                        Event::new(
2848                            gate_req.namespace.as_str(),
2849                            gate_req.verb.as_str(),
2850                            EventKind::Audit,
2851                            SubstrateKind::Event,
2852                            format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
2853                        )
2854                        .with_outcome(EventOutcome::Success)
2855                        .with_target(edge_id)
2856                        .with_payload(payload)
2857                        .with_payload_schema_version(2)
2858                        .with_duration_us(duration_us)
2859                    }
2860                    None => build_audit_storage_event(
2861                        gate_req,
2862                        &audit,
2863                        EventOutcome::Success,
2864                        Some(resource),
2865                    )
2866                    .with_duration_us(duration_us),
2867                }
2868            }
2869            Ok(value) => build_audit_storage_event(
2870                gate_req,
2871                &audit,
2872                EventOutcome::Success,
2873                Some(crate::cost_unit::resource_payload(
2874                    verb,
2875                    &gate_req.args,
2876                    value,
2877                    || 0,
2878                    request_id,
2879                )),
2880            )
2881            .with_duration_us(duration_us),
2882            Err(_) => build_audit_storage_event(
2883                gate_req,
2884                &audit,
2885                EventOutcome::Error,
2886                Some(crate::cost_unit::base_resource_payload(request_id)),
2887            )
2888            .with_duration_us(duration_us),
2889        };
2890        let producer = if result.is_ok() {
2891            crate::audit_batch::AuditProducer::DispatchSucceeded
2892        } else {
2893            crate::audit_batch::AuditProducer::DispatchFailed
2894        };
2895        append_audit_event_best_effort(
2896            self.audit_batch.as_ref(),
2897            store,
2898            event,
2899            verb,
2900            producer,
2901            self.admission_degrade_safe(verb),
2902        )
2903        .await
2904    }
2905
2906    /// A create refusal may reveal its key holder only when the same caller can list it.
2907    pub fn allows_note_key_disclosure(
2908        &self,
2909        token: &NamespaceToken,
2910        kind: &str,
2911        key: &str,
2912    ) -> bool {
2913        let request = GateRequest::new(
2914            token.actor().clone(),
2915            token.namespace().clone(),
2916            "list",
2917            serde_json::json!({"kind":"note", "note_kind":kind, "key_prefix":key}),
2918        );
2919        self.gate
2920            .check(&request)
2921            .is_ok_and(|decision| decision.is_allow())
2922    }
2923
2924    fn gate_request_with_identity(
2925        &self,
2926        verb: &str,
2927        params: &Value,
2928        identity: Option<&RequestIdentity>,
2929    ) -> Result<GateRequest, RuntimeError> {
2930        let default_namespace = identity
2931            .map(|id| id.namespace.as_str())
2932            .unwrap_or(self.default_namespace.as_str());
2933        let namespace = resolve_explicit_namespace(params, default_namespace)?;
2934        let actor_id = identity
2935            .map(|id| id.actor_id.as_deref())
2936            .unwrap_or(self.actor_id.as_deref());
2937        let actor = crate::actor_identity::resolve_actor(actor_id);
2938        // GateRequest.args deliberately captures submitted dispatch arguments.
2939        // The handler's canonicalization and kind hooks have not run; a policy
2940        // requiring their effective values belongs after that handler work.
2941        let req = GateRequest::new(actor, namespace, verb, params.clone());
2942        crate::mailbox_view::validate_mailbox_request(&req)?;
2943        Ok(req)
2944    }
2945
2946    async fn gate_unavailable_error(
2947        &self,
2948        gate_req: &GateRequest,
2949        error: &khive_gate::GateError,
2950        request_id: Option<u64>,
2951        effective_target: Option<uuid::Uuid>,
2952    ) -> RuntimeError {
2953        let audit = AuditEvent::gate_unavailable(gate_req, self.gate.impl_name())
2954            .with_operation_attribution(
2955                khive_storage::operation_context::current_operation_attribution(),
2956            );
2957        tracing::info!(
2958            audit_event = %serde_json::to_string(&audit)
2959                .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
2960            "gate.check"
2961        );
2962        tracing::warn!(
2963            verb = %gate_req.verb,
2964            error = %crate::secret_gate::bounded_masked_log_text(&error.to_string()),
2965            "gate check failed (fail-closed)"
2966        );
2967        if let Some(store) = &self.event_store {
2968            let mut event = build_audit_storage_event(
2969                gate_req,
2970                &audit,
2971                EventOutcome::Error,
2972                Some(crate::cost_unit::base_resource_payload(request_id)),
2973            );
2974            if let Some(target) = effective_target {
2975                event = event.with_target(target);
2976            }
2977            let _ = append_audit_event_best_effort(
2978                self.audit_batch.as_ref(),
2979                store,
2980                event,
2981                gate_req.verb.as_str(),
2982                crate::audit_batch::AuditProducer::GateUnavailable,
2983                false,
2984            )
2985            .await;
2986        }
2987        RuntimeError::GateUnavailable {
2988            verb: gate_req.verb.clone(),
2989            // Caller-visible: a stable, classified reason derived from the
2990            // `GateError` variant only. `error`'s `Display` text is logged
2991            // above (server-side, via `tracing::warn!`) and must never be
2992            // interpolated here — a gate backend's error message can embed
2993            // connection details, addresses, or credentials.
2994            reason: error.wire_reason().to_string(),
2995        }
2996    }
2997
2998    /// Dispatch a verb to the first pack that handles it.
2999    ///
3000    /// Routes through the gate, then invokes the matching pack handler. When
3001    /// `params["help"] == true`, short-circuits to `describe_verb` with no side effects.
3002    /// Gate errors fail closed. Full dispatch flow documented in `docs/protocol.md`.
3003    ///
3004    /// Equivalent to `self.dispatch_with_identity(verb, params, None)` — uses
3005    /// this registry's construction-baked `default_namespace` / `actor_id` /
3006    /// `visible_namespaces`.
3007    pub async fn dispatch(&self, verb: &str, params: Value) -> Result<Value, RuntimeError> {
3008        self.dispatch_with_identity(verb, params, None).await
3009    }
3010
3011    /// Dispatch a verb, optionally overriding this registry's baked identity
3012    /// scalars for exactly this call (ADR-096 Fork 1).
3013    ///
3014    /// `identity = None` behaves exactly like [`Self::dispatch`]. `identity =
3015    /// Some(id)` uses `id.namespace` / `id.actor_id` / `id.visible_namespaces`
3016    /// in place of `self.default_namespace` / `self.actor_id` /
3017    /// `self.visible_namespaces` for this call's namespace resolution, gate
3018    /// request, and token minting. The registry's own fields are never mutated,
3019    /// so concurrent calls with different (or no) identity are independent.
3020    /// See `docs/api/pack.md#dispatch_with_identity` for why this enables one warm
3021    /// registry to serve many attribution identities over a shared backend.
3022    pub async fn dispatch_with_identity(
3023        &self,
3024        verb: &str,
3025        params: Value,
3026        identity: Option<RequestIdentity>,
3027    ) -> Result<Value, RuntimeError> {
3028        self.dispatch_with_disposition(verb, params, identity)
3029            .await
3030            .map_err(DispatchError::into_source)
3031    }
3032
3033    /// Dispatch with provenance for this operation's own domain result.
3034    /// Errors returned by a nested dispatch remain handler errors at this boundary.
3035    pub async fn dispatch_with_disposition(
3036        &self,
3037        verb: &str,
3038        params: Value,
3039        identity: Option<RequestIdentity>,
3040    ) -> Result<Value, DispatchError> {
3041        // help=true interception: short-circuit before gate/pack.
3042        if params.get("help").and_then(Value::as_bool) == Some(true) {
3043            let result = match self.describe_verb(verb) {
3044                Ok(value) => Ok(value),
3045                Err(error) => match self.mounted_verb_catalog().await {
3046                    Ok(catalog) => catalog
3047                        .into_iter()
3048                        .find(|entry| entry["verb"] == verb)
3049                        .ok_or(error),
3050                    Err(error) => Err(error),
3051                },
3052            };
3053            return result.map_err(DispatchError::before_dispatch);
3054        }
3055        // Resolve namespace before `params` is moved into pack.dispatch, so the
3056        // post-dispatch hook can reference it.
3057        //
3058        // Absent `namespace` and a present-but-malformed `namespace` are
3059        // different cases. A present non-string value (null, number, bool,
3060        // array, object) is explicit caller input that failed to parse and
3061        // must fail closed, not silently coerce to the default namespace.
3062        // Only a genuinely absent key defaults. Shared with the multi-backend
3063        // coordinator intercept via `resolve_explicit_namespace` so every MCP
3064        // ingress path applies the same fail-closed rule.
3065        let explicit_namespace = params.get("namespace").is_some_and(Value::is_string);
3066        // The caller-supplied correlation id (khive#948), if any. Read once
3067        // here so it is in scope for every audit-append site below,
3068        // including the ones that run before pack dispatch is attempted.
3069        let request_id: Option<u64> = identity.as_ref().and_then(|id| id.request_id);
3070        // Thread the configured actor identity into the gate request so the
3071        // gate can distinguish human vs agent callers at the dispatch seam.
3072        // Resolved once via the shared actor-identity policy and reused for
3073        // token minting below, so the gate's notion of "who is the caller"
3074        // and the storage token's notion can never drift apart.
3075        let gate_req = self
3076            .gate_request_with_identity(verb, &params, identity.as_ref())
3077            .map_err(DispatchError::before_dispatch)?;
3078        let ns = gate_req.namespace.clone();
3079        let resolved_actor = gate_req.actor.clone();
3080
3081        // Consult the gate.
3082        //
3083        // - Ok(Allow) → proceed to pack dispatch (tracing + optional EventStore).
3084        // - Ok(Deny) → emit audit, persist if store configured, return PermissionDenied.
3085        // - Err(_) → emit an outage audit and return GateUnavailable.
3086        let gate_decision = khive_gate::check_with_mailbox_policy(self.gate.as_ref(), &gate_req);
3087        let (gate_blocked, mut deferred_audit) = match gate_decision {
3088            Ok(decision) => {
3089                let is_deny = matches!(decision, GateDecision::Deny { .. });
3090
3091                // Emit audit event via tracing.
3092                let audit = masked_audit_event(&gate_req, &decision, self.gate.impl_name());
3093                tracing::info!(
3094                    audit_event = %serde_json::to_string(&audit)
3095                        .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
3096                    "gate.check"
3097                );
3098
3099                // Drain any process-lifetime `OnceLock` config locks queued
3100                // since the last dispatch and persist them as `ConfigLocked`
3101                // events, riding this same audit-persistence gate. The
3102                // namespace/actor stamped on these rows are whichever
3103                // dispatch happens to observe the queue non-empty first:
3104                // an accepted provenance quirk, preferred over threading an
3105                // `EventStore` handle into every synchronous
3106                // `OnceLock::get_or_init` call site. The verb column is NOT
3107                // inherited from that bystander dispatch: a config-lock row
3108                // wearing an operation verb pollutes verb-filtered queries
3109                // (e.g. per-verb receipt counts), so these rows carry their
3110                // own `config.lock` pseudo-verb and remain discoverable by
3111                // `EventKind::ConfigLocked`.
3112                if let Some(store) = &self.event_store {
3113                    if crate::config_ledger::PENDING
3114                        .swap(false, std::sync::atomic::Ordering::AcqRel)
3115                    {
3116                        for (key, value) in crate::config_ledger::drain_config_locked() {
3117                            let payload = serde_json::json!({ "key": key, "value": value });
3118                            let storage_event = Event::new(
3119                                gate_req.namespace.as_str(),
3120                                "config.lock",
3121                                EventKind::ConfigLocked,
3122                                SubstrateKind::Event,
3123                                format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
3124                            )
3125                            .with_payload(payload);
3126                            // ConfigLocked is pure observability: the helper
3127                            // never returns `Err` for it, so there is
3128                            // nothing to fold.
3129                            let _ = append_audit_event_best_effort(
3130                                self.audit_batch.as_ref(),
3131                                store,
3132                                storage_event,
3133                                "config.lock",
3134                                crate::audit_batch::AuditProducer::ConfigLocked,
3135                                false,
3136                            )
3137                            .await;
3138                        }
3139                    }
3140                }
3141
3142                // Every Allow-outcome audit row defers its append until pack
3143                // dispatch returns, so the row can carry the measured
3144                // dispatch time in `duration_us` (persisting before dispatch
3145                // ran always recorded the `Event::new` default of 0). A
3146                // singleton `link` call (no `links` bulk array) additionally
3147                // enriches the deferred row with the created/resolved edge
3148                // fields (schema v2) once dispatch resolves. Denied calls
3149                // have no dispatch to wait for and keep the immediate v1
3150                // append below.
3151                //
3152                // Accepted trade-off for ordinary verbs: a crash between this
3153                // Allow decision and the deferred append loses the audit row.
3154                // `git.digest` narrows the caller-visible contract below: it
3155                // never returns success until the deferred receipt append is
3156                // confirmed, though a process crash can still leave committed
3157                // ingest writes with no response and no completed receipt.
3158                let defer_audit = !is_deny;
3159
3160                // Persist to EventStore immediately only for denied calls;
3161                // the receipt rides on the refusal so the caller can cite
3162                // the row.
3163                let reason = if is_deny {
3164                    let reason = match decision {
3165                        GateDecision::Deny { reason } => reason,
3166                        _ => String::new(),
3167                    };
3168                    let receipt = match &self.event_store {
3169                        Some(store) => {
3170                            // ADR-103 Decision (a): the closed `work_class` enum
3171                            // is stamped on every event, denial included -- only
3172                            // `resource.cost_unit` is scoped to a successful
3173                            // dispatch by Amendment 1. `base_resource_payload()`
3174                            // carries `work_class` alone, no `cost_unit` key.
3175                            let storage_event = build_audit_storage_event(
3176                                &gate_req,
3177                                &audit,
3178                                EventOutcome::Denied,
3179                                Some(crate::cost_unit::base_resource_payload(request_id)),
3180                            );
3181                            // This path always returns `PermissionDenied`
3182                            // below, so there is no success outcome to fold a
3183                            // commit failure into; the receipt says whether
3184                            // the row exists.
3185                            self.append_gate_denied_row(store, storage_event, verb)
3186                                .await
3187                        }
3188                        None => crate::error::DenialReceipt::no_store(),
3189                    };
3190                    Some((reason, receipt))
3191                } else {
3192                    None
3193                };
3194                let deferred = if defer_audit { Some(audit) } else { None };
3195                (reason, deferred)
3196            }
3197            Err(err) => {
3198                return Err(DispatchError::before_dispatch(
3199                    self.gate_unavailable_error(&gate_req, &err, request_id, None)
3200                        .await,
3201                ));
3202            }
3203        };
3204
3205        // Hard enforcement: Deny is authoritative.
3206        if let Some((reason, receipt)) = gate_blocked {
3207            return Err(DispatchError::before_dispatch(
3208                RuntimeError::PermissionDenied {
3209                    verb: verb.to_string(),
3210                    reason,
3211                    receipt: Box::new(receipt),
3212                },
3213            ));
3214        }
3215
3216        // Mint the authorized storage token at the dispatch boundary.
3217        //
3218        // Writes pin to `local` by default. Actor identity and config
3219        // `[actor] id` are attribution and gate-context inputs only: they
3220        // never route storage. The explicit `namespace=` request param is a
3221        // precise single-namespace escape: the caller deliberately
3222        // reads/writes exactly that one set; it is NOT widened by `visible_namespaces`.
3223        //
3224        // When actor_id is configured, mint a token carrying that actor
3225        // label so that comm.inbox applies the to_actor filter for directed delivery.
3226        // Otherwise, use ActorRef::anonymous() and inbox falls back to party-line.
3227        // `actor_id_str` already reflects the per-request identity override
3228        // when supplied (resolved above into `resolved_actor`, mirrored into
3229        // the gate request). Reusing the same value here guarantees the
3230        // gate's actor and the storage token's actor can never diverge.
3231        //
3232        // On the default (no explicit `namespace=`) path, the read scope
3233        // widens to `['local'] ∪ visible_namespaces` (baked, or the
3234        // per-request override). `'local'` is always included
3235        // (mint_with_visibility deduplicates). Writes remain pinned to
3236        // `'local'`. Per-actor distinctions use view-layer tag filters
3237        // (assignee, actor_id, from/to), not namespace partitions. `ns`/
3238        // `explicit_namespace` were already validated above: reuse them
3239        // instead of re-reading `params["namespace"]` with `as_str()`, which
3240        // would silently drop malformed non-string values again.
3241        let token = if explicit_namespace {
3242            // Explicit escape: precise single-namespace scope, read+write. NOT widened.
3243            NamespaceToken::mint_with_visibility(ns.clone(), vec![], resolved_actor)
3244        } else {
3245            // Default path: write namespace = local; read scope = ['local'] ∪ visible_namespaces.
3246            let primary = Namespace::local();
3247            let mut extra_visible: Vec<Namespace> = match identity.as_ref() {
3248                Some(id) => id
3249                    .visible_namespaces
3250                    .iter()
3251                    .filter_map(|s| match Namespace::parse(s) {
3252                        Ok(parsed) => Some(parsed),
3253                        Err(e) => {
3254                            tracing::warn!(
3255                                namespace = %s,
3256                                error = %e,
3257                                "dispatch_with_identity: skipping invalid visible_namespace \
3258                                 entry from per-request identity"
3259                            );
3260                            None
3261                        }
3262                    })
3263                    .collect(),
3264                None => self.visible_namespaces.clone(),
3265            };
3266            // ADR-007 Rev 4 Rule 3b, applied once at the seam every identity
3267            // path shares: a non-`local` actor reads its own namespace by
3268            // default (its episodic memories land there), whether the identity
3269            // came from the config loader, a daemon frame, a scheduled replay
3270            // or an embedding host. Writes stay pinned to `local` (Rule 0).
3271            if let Some(actor_namespace) = resolved_actor
3272                .binding_id()
3273                .filter(|id| *id != Namespace::LOCAL)
3274                .and_then(|id| Namespace::parse(id).ok())
3275            {
3276                extra_visible.push(actor_namespace);
3277            }
3278            extra_visible.push(Namespace::local()); // 'local' always readable; mint dedups
3279            NamespaceToken::mint_with_visibility(primary, extra_visible, resolved_actor)
3280        }
3281        .with_gate_namespace(ns.clone())
3282        .with_gate_explicit_namespace(
3283            params
3284                .get("namespace")
3285                .and_then(Value::as_str)
3286                .map(str::to_owned),
3287        )
3288        .with_request_id(request_id)
3289        .with_process_ref(match identity.as_ref() {
3290            Some(id) => id.process_ref.clone(),
3291            None => crate::config::process_ref_from_env(),
3292        });
3293
3294        for pack in self.packs.iter() {
3295            let handler_def = pack.handlers().iter().find(|v| v.name == verb);
3296            let mounted_name = pack.mounted_namespace().and_then(|prefix| {
3297                verb.strip_prefix(prefix)
3298                    .and_then(|suffix| suffix.strip_prefix('.'))
3299            });
3300            if handler_def.is_some() || mounted_name.is_some() {
3301                let definition = if let Some(name) = mounted_name {
3302                    pack.mounted_catalog().await.and_then(|catalog| {
3303                        catalog
3304                            .into_iter()
3305                            .find(|definition| definition.name == name)
3306                            .map(Some)
3307                            .ok_or_else(|| RuntimeError::UnknownVerb(verb.to_owned()))
3308                    })
3309                } else {
3310                    Ok(None)
3311                };
3312                // Strip `namespace` from params before forwarding to packs.
3313                // The registry has already consumed it to mint the NamespaceToken.
3314                //
3315                // Exception: if the handler's own `params` schema declares
3316                // `"namespace"` as a valid field (e.g. brain.bind, brain.unbind,
3317                // brain.bindings, brain.resolve), the field is a *business* argument
3318                // — not a transport routing key — and must be passed through
3319                // unchanged. Stripping it would silently default the binding to the
3320                // "*" wildcard, broadening profile scope across namespaces.
3321                let handler_accepts_namespace = handler_def
3322                    .is_some_and(|h| h.params.iter().any(|p| p.name == "namespace"))
3323                    || definition
3324                        .as_ref()
3325                        .ok()
3326                        .and_then(|value| value.as_ref())
3327                        .is_some_and(|definition| {
3328                            definition
3329                                .input_schema
3330                                .get("properties")
3331                                .is_some_and(|properties| properties.get("namespace").is_some())
3332                        });
3333                let params = if !handler_accepts_namespace {
3334                    if let Value::Object(mut map) = params {
3335                        map.remove("namespace");
3336                        Value::Object(map)
3337                    } else {
3338                        params
3339                    }
3340                } else {
3341                    params
3342                };
3343                let dispatch_start = Instant::now();
3344                let mounted_audit = definition.as_ref().ok().and_then(|v| v.as_ref()).map(|v| {
3345                    serde_json::json!({"mount": pack.name(), "effect": v.effect, "generation": v.generation})
3346                });
3347                let mut result = match definition {
3348                    Ok(Some(definition)) => {
3349                        pack.dispatch_mounted(&definition, verb, params, self, &token)
3350                            .await
3351                    }
3352                    Ok(None) => pack.dispatch(verb, params, self, &token).await,
3353                    Err(error) => Err(error),
3354                };
3355                let domain_succeeded = result.is_ok();
3356                let dispatch_us = dispatch_start.elapsed().as_micros() as i64;
3357
3358                // Unlike ordinary audit rows, a successful `git.digest`
3359                // response is returned only after its complete report has
3360                // been durably persisted as a schema-v2 audit receipt. The
3361                // receipt helper borrows the deferred audit row so malformed
3362                // handler output can still fall back to one generic Error
3363                // audit. Handler errors use that same ordinary path below.
3364                let git_digest_receipt_outcome = if verb == "git.digest" && result.is_ok() {
3365                    let resource = result.as_ref().ok().map(|value| {
3366                        crate::cost_unit::resource_payload(
3367                            verb,
3368                            &gate_req.args,
3369                            value,
3370                            || pack.registered_embedding_model_names().len() as i64,
3371                            request_id,
3372                        )
3373                    });
3374                    Some(
3375                        persist_git_digest_receipt(
3376                            self.event_store.as_ref(),
3377                            self.audit_batch.as_ref(),
3378                            &gate_req,
3379                            deferred_audit.as_ref(),
3380                            &mut result,
3381                            dispatch_us,
3382                            resource,
3383                        )
3384                        .await,
3385                    )
3386                } else {
3387                    None
3388                };
3389
3390                // Append the deferred Allow-outcome audit row now that
3391                // dispatch has resolved, so `duration_us` carries the
3392                // measured `dispatch_us` instead of the `Event::new` default
3393                // of 0. A successful singleton `link` call enriches the row
3394                // with the created/resolved edge (schema v2); anything that
3395                // cannot be enriched, or is not a singleton `link` call,
3396                // falls back to the generic v1 audit shape so no audit row
3397                // is ever dropped for the deferred path.
3398                let needs_generic_audit = git_digest_receipt_outcome.is_none()
3399                    || git_digest_receipt_outcome == Some(GitDigestReceiptOutcome::BuildRejected);
3400                if let (true, Some(audit)) = (needs_generic_audit, deferred_audit.take()) {
3401                    if let Some(store) = &self.event_store {
3402                        let is_link_singleton =
3403                            verb == "link" && gate_req.args.get("links").is_none();
3404                        // Read-only pass over `result` first: every arm below
3405                        // only needs `audit_outcome` afterward, and folding a
3406                        // failure into `result` requires a mutable borrow
3407                        // that cannot coexist with the `&result` match below.
3408                        let audit_outcome: Result<(), AuditObligationFailure> = match &result {
3409                            Ok(ok_val) if is_link_singleton => {
3410                                // ADR-103 Amendment 1: `link` (singleton or
3411                                // bulk) has no embedding-bearing path — edges
3412                                // carry no embedded body — so cost_unit is
3413                                // always base_weight("link") alone. The
3414                                // registered-model closure is never invoked
3415                                // (per_item_weight("link", ..) short-circuits
3416                                // to 0 before `model_count` reads it).
3417                                let resource = crate::cost_unit::resource_payload(
3418                                    verb,
3419                                    &gate_req.args,
3420                                    ok_val,
3421                                    || pack.registered_embedding_model_names().len() as i64,
3422                                    request_id,
3423                                );
3424                                match link_audit_success_from_result(audit.clone(), ok_val) {
3425                                    Some((edge_id, mut payload)) => {
3426                                        if let Value::Object(ref mut map) = payload {
3427                                            map.insert("resource".to_string(), resource);
3428                                        }
3429                                        let storage_event = Event::new(
3430                                            gate_req.namespace.as_str(),
3431                                            gate_req.verb.as_str(),
3432                                            EventKind::Audit,
3433                                            SubstrateKind::Event,
3434                                            format!(
3435                                                "{}:{}",
3436                                                gate_req.actor.kind, gate_req.actor.id
3437                                            ),
3438                                        )
3439                                        .with_outcome(EventOutcome::Success)
3440                                        .with_target(edge_id)
3441                                        .with_payload(payload)
3442                                        .with_payload_schema_version(2)
3443                                        .with_duration_us(dispatch_us);
3444                                        append_audit_event_best_effort(
3445                                            self.audit_batch.as_ref(),
3446                                            store,
3447                                            storage_event,
3448                                            verb,
3449                                            crate::audit_batch::AuditProducer::DispatchSucceeded,
3450                                            self.admission_degrade_safe(verb),
3451                                        )
3452                                        .await
3453                                    }
3454                                    None => {
3455                                        tracing::warn!(
3456                                            verb,
3457                                            "link audit v2 enrichment parse failed; \
3458                                             falling back to v1 audit shape"
3459                                        );
3460                                        let storage_event = build_audit_storage_event(
3461                                            &gate_req,
3462                                            &audit,
3463                                            EventOutcome::Success,
3464                                            Some(resource),
3465                                        )
3466                                        .with_duration_us(dispatch_us);
3467                                        append_audit_event_best_effort(
3468                                            self.audit_batch.as_ref(),
3469                                            store,
3470                                            storage_event,
3471                                            verb,
3472                                            crate::audit_batch::AuditProducer::DispatchSucceeded,
3473                                            self.admission_degrade_safe(verb),
3474                                        )
3475                                        .await
3476                                    }
3477                                }
3478                            }
3479                            _ => {
3480                                // The persisted audit outcome must reflect
3481                                // the dispatch result, not be hardcoded to
3482                                // Success — otherwise a failed dispatch is
3483                                // recorded as successful work and disappears
3484                                // from `outcome=error` queries.
3485                                //
3486                                // ADR-103 Amendment 1: `resource.cost_unit` is
3487                                // computed ONLY on a successful dispatch —
3488                                // there is no handler `Value` to read
3489                                // `item_count` from on an error, and the
3490                                // amendment's "absence has exactly two
3491                                // meanings" rule requires the field be
3492                                // omitted, never defaulted to 0, on an
3493                                // errored dispatch. `work_class` itself is
3494                                // NOT one of those two omission cases
3495                                // (ADR-103 Decision (a) stamps it on every
3496                                // event), so an errored dispatch still gets
3497                                // `resource: {"work_class": "interactive"}`,
3498                                // just with no `cost_unit` key.
3499                                let (outcome, resource) = match &result {
3500                                    Ok(ok_val) => (
3501                                        EventOutcome::Success,
3502                                        Some(crate::cost_unit::resource_payload(
3503                                            verb,
3504                                            &gate_req.args,
3505                                            ok_val,
3506                                            || pack.registered_embedding_model_names().len() as i64,
3507                                            request_id,
3508                                        )),
3509                                    ),
3510                                    Err(_) => (
3511                                        EventOutcome::Error,
3512                                        Some(crate::cost_unit::base_resource_payload(request_id)),
3513                                    ),
3514                                };
3515                                let producer = if result.is_ok() {
3516                                    crate::audit_batch::AuditProducer::DispatchSucceeded
3517                                } else {
3518                                    crate::audit_batch::AuditProducer::DispatchFailed
3519                                };
3520                                let mut storage_event =
3521                                    build_audit_storage_event(&gate_req, &audit, outcome, resource)
3522                                        .with_duration_us(dispatch_us);
3523                                if let Some(metadata) = &mounted_audit {
3524                                    storage_event.payload["mounted_tool"] = metadata.clone();
3525                                }
3526                                append_audit_event_best_effort(
3527                                    self.audit_batch.as_ref(),
3528                                    store,
3529                                    storage_event,
3530                                    verb,
3531                                    producer,
3532                                    self.admission_degrade_safe(verb),
3533                                )
3534                                .await
3535                            }
3536                        };
3537                        // Only a would-be-success dispatch can be flipped by
3538                        // an obligation failure (ADR-133 D2/D3/D4): an
3539                        // already-erroring dispatch (DispatchFailed producer)
3540                        // keeps its original error, matching
3541                        // `fold_audit_obligation`'s contract.
3542                        result =
3543                            fold_audit_obligation(result, audit_outcome, std::convert::identity);
3544                    }
3545                }
3546
3547                // Post-dispatch hook: fires on success, opt-in.
3548                if let (Ok(ref ok_val), Some(hook)) = (&result, &self.dispatch_hook) {
3549                    let mut dispatch_event = Event::new(
3550                        ns.as_str(),
3551                        verb,
3552                        EventKind::Audit,
3553                        SubstrateKind::Event,
3554                        pack.name(),
3555                    )
3556                    .with_outcome(EventOutcome::Success)
3557                    .with_duration_us(dispatch_us);
3558
3559                    // For recall verbs: extract the first result's id as
3560                    // target_id so the brain temporal posterior can observe
3561                    // real hit/miss and latency. Copy the serve-attribution
3562                    // fields from that same hit so the hook credits the profile
3563                    // that actually served instead of always crediting default.
3564                    if verb == "memory.recall" {
3565                        let first_result =
3566                            ok_val.as_array().and_then(|arr| arr.first()).or_else(|| {
3567                                ok_val
3568                                    .get("results")
3569                                    .and_then(Value::as_array)
3570                                    .and_then(|arr| arr.first())
3571                            });
3572                        let first_note_id = first_result
3573                            .and_then(|v| v.get("id"))
3574                            .and_then(|v| v.as_str())
3575                            .and_then(|s| s.parse::<uuid::Uuid>().ok());
3576                        if let Some(note_id) = first_note_id {
3577                            dispatch_event = dispatch_event.with_target(note_id);
3578                        }
3579                        let mut payload = serde_json::Map::new();
3580                        if let Some(profile_id) = first_result
3581                            .and_then(|v| v.get("served_by_profile_id"))
3582                            .and_then(Value::as_str)
3583                        {
3584                            payload.insert(
3585                                "served_by_profile_id".to_string(),
3586                                Value::String(profile_id.to_string()),
3587                            );
3588                        }
3589                        if let Some(attribution) = first_result
3590                            .and_then(|v| v.get("serve_attribution"))
3591                            .and_then(Value::as_str)
3592                        {
3593                            payload.insert(
3594                                "serve_attribution".to_string(),
3595                                Value::String(attribution.to_string()),
3596                            );
3597                        }
3598                        dispatch_event = dispatch_event.with_payload(Value::Object(payload));
3599                        // No first result → target_id stays None (RecallMiss
3600                        // in brain's event interpreter).
3601                    }
3602
3603                    let dispatch_view = EventView {
3604                        event: dispatch_event,
3605                        observations: Vec::new(),
3606                    };
3607                    let hook = Arc::clone(hook);
3608                    hook.on_dispatch(&dispatch_view).await;
3609                }
3610
3611                // Recently-referenced ring admission: only by-id touches admit
3612                // an id. Runs unconditionally (not gated on `dispatch_hook`,
3613                // which is opt-in) because the ring is a core
3614                // dispatch-boundary capability, not an observer.
3615                //
3616                // Keyed on `token.namespace()`, NOT `ns`: `ns` is the
3617                // gate-resolved namespace, which on the default
3618                // (non-explicit) dispatch path can be a non-local
3619                // `default_namespace` (e.g. "foreign") while the storage
3620                // token that actually created/touched the record is pinned
3621                // to `local`. The ring must be keyed on the namespace the
3622                // record actually lives in: the same namespace
3623                // `resolve_reference`'s ring lookup uses: or admission and
3624                // lookup silently diverge on any non-local `default_namespace`
3625                // config.
3626                if let Ok(ref ok_val) = result {
3627                    let admissions = crate::reference_ring::ring_admissions_for(verb, ok_val);
3628                    if !admissions.is_empty() {
3629                        let actor_key = format!("{}:{}", gate_req.actor.kind, gate_req.actor.id);
3630                        for (id, name) in admissions {
3631                            self.reference_ring.admit(
3632                                token.namespace().as_str(),
3633                                &actor_key,
3634                                id,
3635                                name,
3636                            );
3637                        }
3638                    }
3639                }
3640
3641                return result
3642                    .map_err(|error| DispatchError::after_handler(error, domain_succeeded));
3643            }
3644        }
3645
3646        // No pack owns this verb: the gate allowed it, but no dispatch runs.
3647        // Persist the deferred audit row now (duration stays at the
3648        // `Event::new` default of 0 — no dispatch occurred to measure) so an
3649        // allowed-but-unknown verb is never silently dropped from the audit
3650        // trail (matches the "no audit row is ever dropped" contract above).
3651        if let Some(audit) = deferred_audit.take() {
3652            if let Some(store) = &self.event_store {
3653                // Dispatch is about to return `UnknownVerb` below (no pack
3654                // owns this verb), so the persisted outcome must be `Error`,
3655                // not `Success`. `work_class` is still stamped (ADR-103
3656                // Decision (a)); `resource.cost_unit` is omitted, matching
3657                // every other errored-dispatch row.
3658                let storage_event = build_audit_storage_event(
3659                    &gate_req,
3660                    &audit,
3661                    EventOutcome::Error,
3662                    Some(crate::cost_unit::base_resource_payload(request_id)),
3663                );
3664                // Dispatch already returns `UnknownVerb` below regardless, so
3665                // — as with the deny paths above — there is no success
3666                // outcome to fold a commit failure into.
3667                let _ = append_audit_event_best_effort(
3668                    self.audit_batch.as_ref(),
3669                    store,
3670                    storage_event,
3671                    verb,
3672                    crate::audit_batch::AuditProducer::UnknownVerb,
3673                    false,
3674                )
3675                .await;
3676            }
3677        }
3678
3679        // Verb-visibility handler names, precomputed at build() time (internal
3680        // subhandlers are excluded so they are not advertised in the
3681        // unknown-verb error).
3682        Err(DispatchError::before_dispatch(RuntimeError::UnknownVerb(
3683            format!(
3684                "unknown verb {verb:?}; available: {}",
3685                self.available_verbs.join(", ")
3686            ),
3687        )))
3688    }
3689
3690    /// Dispatch a verb under an out-of-band verified actor identity.
3691    ///
3692    /// `verified_actor` is a typed [`VerifiedActor`] (constructor rejects blank
3693    /// identifiers) — only code holding a `VerbRegistry` handle can supply it.
3694    /// `dispatch_as` never reads `params["actor"]` to derive the effective actor;
3695    /// individual verbs may still accept an `actor` field for their own documented
3696    /// business semantics, unrelated to the acting principal. Every pack handler
3697    /// that reads "who is calling" resolves it from the `NamespaceToken` the
3698    /// dispatch boundary mints, so `verified_actor` becomes exactly the principal
3699    /// those handlers observe.
3700    ///
3701    /// Equivalent to `dispatch_with_identity(verb, params, Some(identity))` with
3702    /// `identity.actor_id = Some(verified_actor)` and every other identity scalar
3703    /// (namespace, visible namespaces) left at this registry's construction-baked
3704    /// value. [`Self::dispatch`] and [`Self::dispatch_with_identity`] are unaffected.
3705    /// See `docs/api/pack.md#dispatch_as` for the embedding-host use case and the
3706    /// blank-identifier safety rationale.
3707    pub async fn dispatch_as(
3708        &self,
3709        verb: &str,
3710        params: Value,
3711        verified_actor: VerifiedActor,
3712    ) -> Result<Value, RuntimeError> {
3713        let identity = RequestIdentity {
3714            namespace: self.default_namespace.clone(),
3715            actor_id: Some(verified_actor.into_inner()),
3716            visible_namespaces: self
3717                .visible_namespaces
3718                .iter()
3719                .map(|ns| ns.as_str().to_string())
3720                .collect(),
3721            process_ref: crate::config::process_ref_from_env(),
3722            request_id: None,
3723        };
3724        self.dispatch_with_identity(verb, params, Some(identity))
3725            .await
3726    }
3727
3728    /// Registered pack-level by-ID resolvers, in registration order.
3729    ///
3730    /// Each element is `(pack_name, resolver)`. The kg `get` and `delete` handlers
3731    /// iterate this slice to probe pack-private tables when the standard KG
3732    /// substrates (entity/note/edge/event) return `None` for a given UUID.
3733    pub fn resolvers(&self) -> &[(String, Box<dyn PackByIdResolver>)] {
3734        &self.resolvers
3735    }
3736
3737    /// The daemon-warm recently-referenced ring (unified-verb draft ADR,
3738    /// Slice 1). Consumed by `resolve_reference` (Layer 0 stage 2) and by the
3739    /// `resolve` verb handler; admitted-to by every successful by-id
3740    /// dispatch (see the admission block in `dispatch_with_identity`).
3741    pub fn reference_ring(&self) -> &Arc<crate::reference_ring::ReferenceRing> {
3742        &self.reference_ring
3743    }
3744
3745    /// Find a kind hook among the registered packs.
3746    ///
3747    /// Walks packs in registration order; the first pack that both owns the
3748    /// kind (declares it in `note_kinds()` or `entity_kinds()`) and returns
3749    /// a hook from `kind_hook(kind)` wins. Returns `None` if the kind is
3750    /// unknown to all packs or no owning pack registered a hook.
3751    pub fn find_kind_hook(&self, kind: &str) -> Option<Arc<dyn KindHook>> {
3752        for pack in self.packs.iter() {
3753            let owns = pack.note_kinds().contains(&kind) || pack.entity_kinds().contains(&kind);
3754            if owns {
3755                if let Some(hook) = pack.kind_hook(kind) {
3756                    return Some(hook);
3757                }
3758            }
3759        }
3760        None
3761    }
3762
3763    /// Every `(entity kind, hook)` pair for which the owning pack declares
3764    /// the entity kind and registers a `KindHook` — the entity-scoped
3765    /// subset of [`Self::find_kind_hook`]'s ownership check, computed once.
3766    ///
3767    /// `khive-runtime` does not hold a `VerbRegistry` (ownership runs the
3768    /// other way: packs are constructed FROM a runtime handle), so
3769    /// `KhiveRuntime::install_entity_kind_hooks` is the extension point
3770    /// that carries this aggregate to the runtime layer — the transport
3771    /// calls this after the registry is built, same timing as
3772    /// [`Self::all_edge_rules`]. `Arc<dyn KindHook>` values returned here
3773    /// hold no reference back to the pack or registry that produced them
3774    /// (every production `kind_hook()` implementation constructs a fresh,
3775    /// stateless hook per call), so installing this aggregate on the
3776    /// runtime creates no ownership cycle.
3777    pub fn entity_kind_hooks(&self) -> crate::runtime::EntityKindHooks {
3778        let mut hooks = Vec::new();
3779        for pack in self.packs.iter() {
3780            for kind in pack.entity_kinds().iter().copied() {
3781                if let Some(hook) = pack.kind_hook(kind) {
3782                    hooks.push((kind.to_string(), hook));
3783                }
3784            }
3785        }
3786        hooks
3787    }
3788
3789    /// Run the owning kind's shared-note-update normalizer/validator, if it declares one.
3790    ///
3791    /// Compatibility wrapper for callers that only need normalization and
3792    /// validation. Writers use [`Self::prepare_note_update_policy`] and attach
3793    /// its returned policy so kind-specific property removals reach storage.
3794    ///
3795    /// The ordering lives here, at the single dispatch site, rather than in a
3796    /// [`KindHook`] method a pack could override: a pack implements the two
3797    /// halves and cannot express a sequence, so it cannot replace the
3798    /// validator by overriding the sequence. See ADR-017.
3799    pub async fn prepare_note_update_hook(
3800        &self,
3801        runtime: &KhiveRuntime,
3802        token: &NamespaceToken,
3803        note: &khive_storage::Note,
3804        args: &mut Value,
3805    ) -> Result<(), RuntimeError> {
3806        self.prepare_note_update_policy(runtime, token, note, args)
3807            .await
3808            .map(|_| ())
3809    }
3810
3811    /// Normalize and validate a note update, then carry the owning kind's
3812    /// property policy into the shared prepared write. Writers must attach the
3813    /// returned policy to their `NotePatch` or snapshot update preparation;
3814    /// [`Self::prepare_note_update_hook`] remains the validation-only wrapper.
3815    pub async fn prepare_note_update_policy(
3816        &self,
3817        runtime: &KhiveRuntime,
3818        token: &NamespaceToken,
3819        note: &khive_storage::Note,
3820        args: &mut Value,
3821    ) -> Result<crate::NoteUpdatePolicy, RuntimeError> {
3822        crate::curation::normalize_note_update_tags(args)?;
3823        if let Some(hook) = self.find_kind_hook(&note.kind) {
3824            hook.normalize_note_update(runtime, token, note, args)
3825                .await?;
3826            let properties = args.get("properties").filter(|value| !value.is_null());
3827            hook.validate_note_update(runtime, token, note, properties)
3828                .await?;
3829            return Ok(crate::NoteUpdatePolicy::for_kind(
3830                &note.kind,
3831                hook.note_update_null_clearing_properties(),
3832            ));
3833        }
3834        Ok(crate::NoteUpdatePolicy::default())
3835    }
3836
3837    /// Run the owning kind's shared-note-update property validator, if it
3838    /// declares one.
3839    ///
3840    /// Kept as the validation-only compatibility seam for callers that do not
3841    /// own a mutable request object. Canonical and atomic CRUD use
3842    /// [`Self::prepare_note_update_hook`] instead, so a hook's
3843    /// [`KindHook::normalize_note_update`] can run before its validation does.
3844    /// Reaching a hook through this seam therefore runs the validator alone:
3845    /// that is the point of it, and it is why callers that CAN supply a
3846    /// mutable request should not use it.
3847    pub async fn validate_note_update_hook(
3848        &self,
3849        runtime: &KhiveRuntime,
3850        token: &NamespaceToken,
3851        note: &khive_storage::Note,
3852        properties: Option<&Value>,
3853    ) -> Result<(), RuntimeError> {
3854        if let Some(hook) = self.find_kind_hook(&note.kind) {
3855            hook.validate_note_update(runtime, token, note, properties)
3856                .await?;
3857        }
3858        Ok(())
3859    }
3860
3861    /// Run shared-link validators grouped by the owning source-note kind.
3862    ///
3863    /// Supplying the whole proposed batch lets a kind hook reject an invariant
3864    /// violation formed only by multiple entries in that batch. Sources that
3865    /// are not live notes, or whose kind has no hook, remain the canonical
3866    /// endpoint validator's responsibility.
3867    pub async fn validate_link_hooks(
3868        &self,
3869        runtime: &KhiveRuntime,
3870        token: &NamespaceToken,
3871        specs: &[LinkSpec],
3872    ) -> Result<(), RuntimeError> {
3873        let mut specs_by_kind: HashMap<String, Vec<LinkSpec>> = HashMap::new();
3874        for spec in specs {
3875            let Some(Resolved::Note(source)) = runtime.resolve_by_id(token, spec.source_id).await?
3876            else {
3877                continue;
3878            };
3879            specs_by_kind
3880                .entry(source.kind)
3881                .or_default()
3882                .push(spec.clone());
3883        }
3884        for (kind, kind_specs) in specs_by_kind {
3885            if let Some(hook) = self.find_kind_hook(&kind) {
3886                hook.validate_links(runtime, token, &kind_specs).await?;
3887            }
3888        }
3889        Ok(())
3890    }
3891
3892    /// Whether any registered pack declares a handler with this verb name.
3893    ///
3894    /// A non-dispatch capability check: callers that would otherwise pay a
3895    /// guaranteed-failed `dispatch` (and its audit write) when an optional
3896    /// pack is absent can probe first and skip the call entirely.
3897    pub fn has_verb(&self, verb: &str) -> bool {
3898        self.packs
3899            .iter()
3900            .flat_map(|p| p.handlers().iter())
3901            .any(|h| h.name == verb)
3902    }
3903
3904    /// Advisory metadata for synchronous planning and MCP initialization.
3905    pub fn mounted_verb_snapshot(&self) -> Vec<Value> {
3906        self.packs
3907            .iter()
3908            .flat_map(|pack| {
3909                pack.mounted_catalog_snapshot()
3910                    .into_iter()
3911                    .map(|verb| verb.describe(pack.name()))
3912            })
3913            .collect()
3914    }
3915
3916    pub async fn mounted_verb_catalog(&self) -> Result<Vec<Value>, RuntimeError> {
3917        let mut catalog = Vec::new();
3918        for pack in self.packs.iter() {
3919            for definition in pack.mounted_catalog().await? {
3920                catalog.push(definition.describe(pack.name()));
3921            }
3922        }
3923        Ok(catalog)
3924    }
3925
3926    /// Apply section evidence through the installed brain instance. Callers must
3927    /// validate their domain target and authorize their own operation first;
3928    /// this trusted Rust hook adds no handler to dispatch or the wire catalog.
3929    pub async fn apply_profile_section_feedback(
3930        &self,
3931        token: &NamespaceToken,
3932        profile_id: &str,
3933        section_signals: Value,
3934        target_attribution: Option<String>,
3935    ) -> Result<Value, RuntimeError> {
3936        let brain = self
3937            .packs
3938            .iter()
3939            .find(|pack| pack.name() == "brain")
3940            .ok_or_else(|| {
3941                RuntimeError::InvalidInput(
3942                    "profile section feedback requires the brain pack".into(),
3943                )
3944            })?;
3945        brain
3946            .apply_profile_section_feedback(token, profile_id, section_signals, target_attribution)
3947            .await
3948    }
3949
3950    /// All MCP-exposed handlers across all registered packs (`Visibility::Verb` only).
3951    ///
3952    /// Subhandlers (`Visibility::Subhandler`) are excluded — they are internal
3953    /// pipeline steps not surfaced on the MCP wire. Returned with `'static`
3954    /// lifetime since pack handlers are `&'static [HandlerDef]` constants.
3955    pub fn all_verbs(&self) -> Vec<&'static HandlerDef> {
3956        self.packs
3957            .iter()
3958            .flat_map(|p| p.handlers().iter())
3959            .filter(|h| matches!(h.visibility, Visibility::Verb))
3960            .collect()
3961    }
3962
3963    /// All MCP-exposed handlers paired with the name of the pack that owns them
3964    /// (`Visibility::Verb` only).
3965    ///
3966    /// Subhandlers (`Visibility::Subhandler`) are excluded from the MCP catalog
3967    /// Use `all_handlers_with_names` when internal handlers must
3968    /// also be enumerated (e.g. runtime introspection).
3969    pub fn all_verbs_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
3970        self.packs
3971            .iter()
3972            .flat_map(|p| p.handlers().iter().map(move |v| (p.name(), v)))
3973            .filter(|(_, h)| matches!(h.visibility, Visibility::Verb))
3974            .collect()
3975    }
3976
3977    /// All handler definitions across all registered packs, including subhandlers.
3978    ///
3979    /// Unlike `all_verbs`, this includes `Visibility::Subhandler` entries. Useful
3980    /// for runtime introspection (e.g. `list_handlers`) and tooling that needs
3981    /// the complete handler surface.
3982    pub fn all_handlers_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
3983        self.packs
3984            .iter()
3985            .flat_map(|p| p.handlers().iter().map(move |v| (p.name(), v)))
3986            .collect()
3987    }
3988
3989    /// Merged set of note kinds across all registered packs (deduplicated,
3990    /// first-seen order preserved).
3991    pub fn all_note_kinds(&self) -> Vec<&'static str> {
3992        let mut seen = std::collections::HashSet::new();
3993        self.packs
3994            .iter()
3995            .flat_map(|p| p.note_kinds().iter().copied())
3996            .filter(|k| seen.insert(*k))
3997            .collect()
3998    }
3999
4000    /// Note kinds owned by a pack, i.e. every kind in [`all_note_kinds`] that
4001    /// is not one of the generic-CRUD pack's own kinds.
4002    ///
4003    /// [`GENERIC_CRUD_PACK`] declares the general-purpose note kinds the shared
4004    /// CRUD verbs exist to serve (`observation`, `insight`, …); every other
4005    /// pack's kinds are records that pack's own verbs create and maintain.
4006    /// Derived from the packs' `NOTE_KINDS` constants, so a pack that adds or
4007    /// drops a kind moves this set with it — nothing is hardcoded here but the
4008    /// name of the generic pack itself.
4009    ///
4010    /// [`all_note_kinds`]: Self::all_note_kinds
4011    pub fn pack_owned_note_kinds(&self) -> Vec<&'static str> {
4012        let generic: std::collections::HashSet<&'static str> = self
4013            .packs
4014            .iter()
4015            .filter(|p| p.name() == GENERIC_CRUD_PACK)
4016            .flat_map(|p| p.note_kinds().iter().copied())
4017            .collect();
4018        let mut seen = std::collections::HashSet::new();
4019        self.packs
4020            .iter()
4021            .filter(|p| p.name() != GENERIC_CRUD_PACK)
4022            .flat_map(|p| p.note_kinds().iter().copied())
4023            .filter(|k| !generic.contains(k) && seen.insert(*k))
4024            .collect()
4025    }
4026
4027    /// Merged set of entity kinds across all registered packs (deduplicated,
4028    /// first-seen order preserved).
4029    pub fn all_entity_kinds(&self) -> Vec<&'static str> {
4030        let mut seen = std::collections::HashSet::new();
4031        self.packs
4032            .iter()
4033            .flat_map(|p| p.entity_kinds().iter().copied())
4034            .filter(|k| seen.insert(*k))
4035            .collect()
4036    }
4037
4038    /// Merged set of brain profile consumer kinds requested by registered
4039    /// packs (deduplicated, first-seen order preserved).
4040    pub fn all_brain_consumer_kinds(&self) -> Vec<&'static str> {
4041        let mut seen = std::collections::HashSet::new();
4042        self.packs
4043            .iter()
4044            .flat_map(|p| p.brain_consumer_kinds().iter().copied())
4045            .filter(|kind| seen.insert(*kind))
4046            .collect()
4047    }
4048
4049    /// Names of packs in topological load order.
4050    pub fn pack_names(&self) -> Vec<&str> {
4051        self.packs.iter().map(|p| p.name()).collect()
4052    }
4053
4054    /// Borrow a registered pack's shared host state without reconstructing
4055    /// that pack. Missing packs, absent state, and type mismatches return None.
4056    pub fn pack_host_state<T: Any + Send + Sync>(&self, name: &str) -> Option<Arc<T>> {
4057        self.packs
4058            .iter()
4059            .find(|pack| pack.name() == name)?
4060            .host_state()?
4061            .downcast::<T>()
4062            .ok()
4063    }
4064
4065    /// Declared dependencies for a registered pack.
4066    pub fn pack_requires(&self, name: &str) -> Option<&'static [&'static str]> {
4067        self.packs
4068            .iter()
4069            .find(|p| p.name() == name)
4070            .map(|p| p.requires())
4071    }
4072
4073    /// Note kinds owned by a specific registered pack.
4074    ///
4075    /// Returns `None` if no pack with `name` is registered. The slice is
4076    /// the pack's `NOTE_KINDS` constant — `'static` lifetime, no allocation.
4077    pub fn pack_note_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
4078        self.packs
4079            .iter()
4080            .find(|p| p.name() == name)
4081            .map(|p| p.note_kinds())
4082    }
4083
4084    /// Entity kinds owned by a specific registered pack.
4085    ///
4086    /// Returns `None` if no pack with `name` is registered. The slice is
4087    /// the pack's `ENTITY_KINDS` constant — `'static` lifetime, no allocation.
4088    pub fn pack_entity_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
4089        self.packs
4090            .iter()
4091            .find(|p| p.name() == name)
4092            .map(|p| p.entity_kinds())
4093    }
4094
4095    /// Handlers declared by a specific registered pack.
4096    ///
4097    /// Returns `None` if no pack with `name` is registered. Each `HandlerDef`
4098    /// carries name + description + visibility — sufficient for introspection clients.
4099    pub fn pack_verbs(&self, name: &str) -> Option<&'static [HandlerDef]> {
4100        self.packs
4101            .iter()
4102            .find(|p| p.name() == name)
4103            .map(|p| p.handlers())
4104    }
4105
4106    /// All pack-declared edge endpoint rules across registered packs.
4107    ///
4108    /// Order follows topological pack registration; duplicates are *not* deduplicated —
4109    /// validation only checks membership, and an exact-duplicate rule is a
4110    /// harmless restatement.
4111    pub fn all_edge_rules(&self) -> Vec<EdgeEndpointRule> {
4112        self.packs
4113            .iter()
4114            .flat_map(|p| p.edge_rules().iter().copied())
4115            .collect()
4116    }
4117
4118    /// All pack-declared entity-type subtypes across registered packs.
4119    ///
4120    /// Order follows topological pack registration; duplicates are *not*
4121    /// deduplicated here — same posture as [`all_edge_rules`](Self::all_edge_rules).
4122    /// Consumers compose this with `EntityTypeRegistry::builtin()` via
4123    /// `EntityTypeRegistry::with_extra` to get the boot-time composed registry.
4124    pub fn all_entity_types(&self) -> Vec<EntityTypeDef> {
4125        self.packs
4126            .iter()
4127            .flat_map(|p| p.entity_types().iter().cloned())
4128            .collect()
4129    }
4130
4131    /// Collect all `NoteKindSpec` declarations from every loaded pack.
4132    ///
4133    /// Used by the runtime for lifecycle introspection and future enforcement.
4134    pub fn all_note_kind_specs(&self) -> Vec<&'static NoteKindSpec> {
4135        self.packs
4136            .iter()
4137            .flat_map(|p| p.note_kind_specs().iter())
4138            .collect()
4139    }
4140
4141    /// All pack-contributed validation rules across registered packs.
4142    ///
4143    /// Returns references into the pack-owned `'static` slices — no allocation
4144    /// beyond the outer `Vec`. Rule IDs are namespaced by pack; callers can
4145    /// group by `rule.id.split_once('/')` to attribute rules to their packs.
4146    pub fn all_validation_rules(&self) -> Vec<&'static ValidationRule> {
4147        self.packs
4148            .iter()
4149            .flat_map(|p| p.validation_rules().iter())
4150            .collect()
4151    }
4152
4153    /// Pack-auxiliary schema plans for all registered packs.
4154    ///
4155    /// Returns one `SchemaPlan` per pack. Callers (typically the runtime
4156    /// bootstrap) apply each plan to the pack's assigned backend. Empty plans
4157    /// are included so the caller can iterate uniformly; callers that want to
4158    /// skip empty plans should check `plan.is_empty()`. Schema application must
4159    /// use [`Self::all_schema_plans_with_columns`] to retain column upgrades.
4160    pub fn all_schema_plans(&self) -> Vec<SchemaPlan> {
4161        self.packs.iter().map(|p| p.schema_plan()).collect()
4162    }
4163
4164    /// Schema plans paired with the same owning pack's nullable-column upgrades.
4165    ///
4166    /// Callers applying plans directly must pass both entries to
4167    /// `StorageBackend::apply_pack_ddl_statements_with_columns`.
4168    pub fn all_schema_plans_with_columns(
4169        &self,
4170    ) -> Vec<(SchemaPlan, &'static [PackColumnAddition])> {
4171        self.packs
4172            .iter()
4173            .map(|pack| (pack.schema_plan(), pack.schema_column_additions()))
4174            .collect()
4175    }
4176
4177    /// Invoke `PackRuntime::register_embedders` on every registered pack.
4178    ///
4179    /// Called by the transport during startup, after the registry is built and
4180    /// before the first verb dispatch, so that custom embedding providers
4181    /// contributed by packs are reachable via `KhiveRuntime::embedder(name)`.
4182    ///
4183    /// Packs whose `register_embedders` is the default no-op pay no overhead.
4184    /// The method is idempotent when the underlying registry uses last-wins
4185    /// semantics for duplicate provider names.
4186    pub fn call_register_embedders(&self, runtime: &KhiveRuntime) {
4187        for pack in self.packs.iter() {
4188            pack.register_embedders(runtime);
4189        }
4190    }
4191
4192    /// Invoke `PackRuntime::register_entity_type_validator` on every registered pack.
4193    ///
4194    /// Called by the transport during startup, after the registry is built and
4195    /// before the first verb dispatch, so that entity-type validation at the
4196    /// runtime layer is active for all write paths including direct `create_many`
4197    /// callers that bypass the handler layer.
4198    ///
4199    /// Packs whose `register_entity_type_validator` is the default no-op pay
4200    /// no overhead.
4201    ///
4202    /// Composes [`all_entity_types`](Self::all_entity_types) once and passes
4203    /// the same aggregate to every pack, mirroring how `install_edge_rules`
4204    /// installs one `all_edge_rules()` aggregate for the whole registry.
4205    pub fn call_register_entity_type_validators(&self, runtime: &KhiveRuntime) {
4206        let entity_types = self.all_entity_types();
4207        for pack in self.packs.iter() {
4208            pack.register_entity_type_validator_with_types(runtime, &entity_types);
4209        }
4210    }
4211
4212    /// Invoke `PackRuntime::register_note_mutation_hook` on every registered pack.
4213    ///
4214    /// Called by the transport during startup, after the registry is built and
4215    /// before the first verb dispatch, so that note-mutation notifications at
4216    /// the runtime layer are active for all write paths — including KG's
4217    /// `update`/`delete` verbs reaching a `kind="memory"` note, which have no
4218    /// crate-level dependency on `khive-pack-memory`.
4219    ///
4220    /// Packs whose `register_note_mutation_hook` is the default no-op pay no
4221    /// overhead.
4222    pub fn call_register_note_mutation_hooks(&self, runtime: &KhiveRuntime) {
4223        for pack in self.packs.iter() {
4224            pack.register_note_mutation_hook(runtime);
4225        }
4226    }
4227
4228    /// Invoke `PackRuntime::register_note_write_validator` on every registered pack.
4229    ///
4230    /// Called by the transport during startup with the same timing as
4231    /// `call_register_note_mutation_hooks`, so note-write validation is active
4232    /// at the runtime layer for every write path — the generic `create` verb,
4233    /// direct Rust callers, and proposal apply, none of which dispatch a pack
4234    /// hook of their own on the note-write.
4235    pub fn call_register_note_write_validators(&self, runtime: &KhiveRuntime) {
4236        for pack in self.packs.iter() {
4237            pack.register_note_write_validator(runtime);
4238        }
4239    }
4240
4241    /// Invoke `PackRuntime::warm` on every registered pack.
4242    /// Called by the daemon at boot (in a background task) so expensive in-memory
4243    /// state (ANN indexes) is pre-loaded without blocking request serving.
4244    pub async fn call_warm_all(&self) {
4245        for pack in self.packs.iter() {
4246            pack.warm().await;
4247        }
4248    }
4249
4250    /// Resolve the presentation policy for a verb name.
4251    ///
4252    /// Walks all registered handlers (including subhandlers) for the first
4253    /// matching name and returns its declared [`VerbPresentationPolicy`].
4254    /// Returns `Standard` for unknown verbs — unknown verbs will fail at
4255    /// dispatch anyway, so the fallback here is safe.
4256    pub fn presentation_policy_for(&self, verb: &str) -> khive_types::VerbPresentationPolicy {
4257        for pack in self.packs.iter() {
4258            if let Some(handler) = pack.handlers().iter().find(|h| h.name == verb) {
4259                return handler.presentation_policy();
4260            }
4261        }
4262        khive_types::VerbPresentationPolicy::Standard
4263    }
4264
4265    /// Resolve the declared [`VerbCategory`] for a verb name.
4266    ///
4267    /// Walks all registered handlers (including subhandlers) for the first
4268    /// matching name and returns its speech-act category. Returns `None` for
4269    /// an unregistered verb name, so a caller deciding transport-level
4270    /// behavior (e.g. whether a post-dispatch condition is safe to retry)
4271    /// can fail closed on an unknown verb instead of guessing a category.
4272    pub fn verb_category(&self, verb: &str) -> Option<VerbCategory> {
4273        self.packs
4274            .iter()
4275            .find_map(|pack| pack.handlers().iter().find(|h| h.name == verb))
4276            .map(|handler| handler.category)
4277    }
4278
4279    /// Verbs classified [`VerbCategory::Assertive`] that nonetheless schedule
4280    /// can schedule a persisted write on a successful dispatch, so a caller re-issuing
4281    /// a call in this list after a lost response duplicates that write:
4282    ///
4283    /// - `memory.recall` schedules `brain.record_serve`, which inserts a
4284    ///   serve-ledger row keyed in part on a `served_at` timestamp captured
4285    ///   fresh at dispatch time — a second dispatch inserts a second row
4286    ///   rather than colliding with the first.
4287    /// - `search` (the `kg` pack's bare verb) appends a `search_executed`
4288    ///   event with a freshly generated id and no natural key at all.
4289    /// - `telemetry.emit` can append a durable stream record with a fresh
4290    ///   identity and sequence, depending on the configured channel policy.
4291    ///
4292    /// The speech-act category alone cannot rule this out — it describes
4293    /// what the verb tells the *caller*, not what it schedules against
4294    /// storage. Adding a verb here (or removing one because its side effect
4295    /// was made idempotent) is a correctness decision requiring the same
4296    /// scrutiny as the categorization itself.
4297    pub const SIDE_EFFECTING_ASSERTIVE_VERBS: &'static [&'static str] =
4298        &["memory.recall", "search", "telemetry.emit"];
4299
4300    /// Whether a response lost to the daemon frame budget may be truthfully
4301    /// advertised as safe to re-issue: the verb is [`VerbCategory::Assertive`]
4302    /// (no institutional commitment was made) and is not on
4303    /// `Self::SIDE_EFFECTING_ASSERTIVE_VERBS` (no persisted write to
4304    /// duplicate on a second dispatch). An unregistered verb name resolves to
4305    /// `None` from [`Self::verb_category`] and fails closed here.
4306    ///
4307    /// Used only by the MCP daemon's frame-budget omission decision; never
4308    /// for permission checking or return-shape selection.
4309    pub fn is_retry_safe_after_frame_omission(&self, verb: &str) -> bool {
4310        matches!(self.verb_category(verb), Some(VerbCategory::Assertive))
4311            && !Self::SIDE_EFFECTING_ASSERTIVE_VERBS.contains(&verb)
4312    }
4313
4314    /// Returns `true` if the named verb exists and is tagged
4315    /// `Visibility::Subhandler` (internal / operator-only).
4316    ///
4317    /// Used by the MCP server to gate subhandler invocation at the wire
4318    /// boundary without blocking internal callers that invoke the same verbs
4319    /// through the runtime directly.
4320    pub fn is_subhandler_verb(&self, verb: &str) -> bool {
4321        for pack in self.packs.iter() {
4322            if let Some(handler) = pack.handlers().iter().find(|h| h.name == verb) {
4323                return matches!(handler.visibility, Visibility::Subhandler);
4324            }
4325        }
4326        false
4327    }
4328
4329    /// Apply all non-empty pack-auxiliary schema plans to the given backend.
4330    ///
4331    /// This is the centralized startup hook that replaced the previous lazy
4332    /// per-pack self-bootstrap pattern. Each pack's `SchemaPlan` carries
4333    /// idempotent `CREATE TABLE IF NOT EXISTS` DDL; calling this more than once
4334    /// is safe. Plans with neither SQL nor column upgrades are skipped.
4335    ///
4336    /// Errors from individual plans are logged via `tracing::warn!` and not
4337    /// propagated so that a single pack's schema failure does not prevent the
4338    /// rest from loading. Serving hosts must instead use the fallible
4339    /// [`Self::apply_schema_plans_with_map`] (with an empty map for one backend)
4340    /// so a required schema failure cannot leave a pack's verbs unavailable.
4341    pub fn apply_schema_plans(&self, backend: &khive_db::StorageBackend) {
4342        if backend.is_read_only() {
4343            tracing::info!(
4344                "skipping pack schema plans because the backend is read-only; snapshot schema is used as-is"
4345            );
4346            return;
4347        }
4348        for (plan, additions) in self.all_schema_plans_with_columns() {
4349            if plan.is_empty() && additions.is_empty() {
4350                continue;
4351            }
4352            if let Err(e) =
4353                backend.apply_pack_ddl_statements_with_columns(plan.statements, additions)
4354            {
4355                tracing::warn!(
4356                    pack = plan.pack,
4357                    error = %e,
4358                    "failed to apply pack schema plan at startup (non-fatal)"
4359                );
4360            }
4361        }
4362    }
4363
4364    /// Pack-auxiliary schema plans with their owning pack names.
4365    ///
4366    /// Returns `(pack_name, SchemaPlan)` pairs for every registered pack.
4367    /// Used by the multi-backend boot path to apply each plan to the pack's
4368    /// assigned backend rather than a single shared backend. Direct schema
4369    /// application must use [`Self::all_schema_plans_with_columns`] so column
4370    /// upgrades are retained.
4371    pub fn all_schema_plans_named(&self) -> Vec<(&'static str, SchemaPlan)> {
4372        self.packs
4373            .iter()
4374            .map(|p| {
4375                let plan = p.schema_plan();
4376                (plan.pack, plan)
4377            })
4378            .collect()
4379    }
4380
4381    /// Apply pack-auxiliary schema plans using a per-pack backend map.
4382    ///
4383    /// For each plan and its owning pack's column additions, applies the full
4384    /// plan to `backend_for_pack[plan.pack]` when present,
4385    /// falling back to `default_backend` for any pack not in the map.
4386    ///
4387    /// Returns an error when two packs on the same backend declare the same
4388    /// auxiliary table (ADR-028 §7 collision policy: boot failure naming both
4389    /// packs and the conflicting table).
4390    ///
4391    /// Both single- and multi-backend hosts use this boot path (ADR-028).
4392    /// An empty map selects the default backend for every pack. Read-only
4393    /// backends validate declared columns without applying SQL or acquiring a
4394    /// writer; missing or incompatible columns refuse boot with the pack name.
4395    pub fn apply_schema_plans_with_map(
4396        &self,
4397        backend_for_pack: &HashMap<&str, &khive_db::StorageBackend>,
4398        default_backend: &khive_db::StorageBackend,
4399    ) -> Result<(), crate::PackSchemaCollisionError> {
4400        // Track which pack first claimed each table on each backend.
4401        // Backend identity is the raw pointer of the underlying connection pool Arc.
4402        let mut claimed: HashMap<(*const (), String), &'static str> = HashMap::new();
4403
4404        let plans = self.all_schema_plans_with_columns();
4405        // Check every declaration before applying any pack DDL. A collision
4406        // must not leave earlier plans installed on a failed boot.
4407        for (plan, additions) in &plans {
4408            if plan.is_empty() && additions.is_empty() {
4409                continue;
4410            }
4411            let pack_name = plan.pack;
4412            let backend = backend_for_pack
4413                .get(pack_name)
4414                .copied()
4415                .unwrap_or(default_backend);
4416            let backend_ptr = std::sync::Arc::as_ptr(&backend.pool_arc()) as *const ();
4417
4418            // Collect DDL table ownership for the full plan set.
4419            for stmt in plan.statements {
4420                for table_name in extract_table_names(stmt) {
4421                    let key = (backend_ptr, table_name.clone());
4422                    match claimed.entry(key) {
4423                        std::collections::hash_map::Entry::Vacant(e) => {
4424                            e.insert(pack_name);
4425                        }
4426                        std::collections::hash_map::Entry::Occupied(e) => {
4427                            let prior_pack = *e.get();
4428                            return Err(crate::PackSchemaCollisionError {
4429                                pack_a: prior_pack,
4430                                pack_b: pack_name,
4431                                table: table_name,
4432                            });
4433                        }
4434                    }
4435                }
4436            }
4437            for addition in *additions {
4438                let table_name = addition.table.to_ascii_lowercase();
4439                let key = (backend_ptr, table_name.clone());
4440                match claimed.entry(key) {
4441                    std::collections::hash_map::Entry::Vacant(entry) => {
4442                        entry.insert(pack_name);
4443                    }
4444                    std::collections::hash_map::Entry::Occupied(entry) => {
4445                        let prior_pack = *entry.get();
4446                        // A pack's full CREATE and its upgrades declare the
4447                        // same table; this is one ownership claim.
4448                        if prior_pack != pack_name {
4449                            return Err(crate::PackSchemaCollisionError {
4450                                pack_a: prior_pack,
4451                                pack_b: pack_name,
4452                                table: table_name,
4453                            });
4454                        }
4455                    }
4456                }
4457            }
4458        }
4459
4460        for (plan, additions) in plans {
4461            if plan.is_empty() && additions.is_empty() {
4462                continue;
4463            }
4464            let pack_name = plan.pack;
4465            let backend = backend_for_pack
4466                .get(pack_name)
4467                .copied()
4468                .unwrap_or(default_backend);
4469            if backend.is_read_only() {
4470                backend.validate_pack_schema_columns(additions).map_err(|error| {
4471                    crate::PackSchemaCollisionError {
4472                        pack_a: pack_name,
4473                        pack_b: pack_name,
4474                        table: format!("read-only schema validation failed: {error}; open the database writable to apply the pack schema upgrade"),
4475                    }
4476                })?;
4477                continue;
4478            }
4479
4480            backend
4481                .apply_pack_ddl_statements_with_columns(plan.statements, additions)
4482                .map_err(|e| crate::PackSchemaCollisionError {
4483                    pack_a: pack_name,
4484                    pack_b: pack_name,
4485                    table: format!("DDL error: {e}"),
4486                })?;
4487        }
4488        Ok(())
4489    }
4490}
4491
4492// ── Inventory-based dynamic pack loading ────────────────────────────────────
4493
4494/// Output of [`PackFactory::create_install`] — bundles the pack runtime with
4495/// its optional by-ID resolver and dispatch hook so a factory can hand back
4496/// all three built from one shared instance (see `BrainPackFactory` for why
4497/// this matters: the dispatch hook must observe the same state the runtime
4498/// mutates, not a second unrelated instance).
4499pub struct PackInstall {
4500    /// The pack runtime, registered into the builder's pack list.
4501    pub runtime: Box<dyn PackRuntime>,
4502    /// Optional by-ID resolver, registered when present.
4503    pub resolver: Option<Box<dyn PackByIdResolver>>,
4504    /// Optional post-dispatch observer, wired via `VerbRegistryBuilder::with_dispatch_hook`.
4505    pub dispatch_hook: Option<Arc<dyn DispatchHook>>,
4506}
4507
4508/// Factory for creating pack instances registered via `inventory` at link time.
4509/// Each pack crate submits a `&'static dyn PackFactory` wrapped in a
4510/// [`PackRegistration`]; the binary's linker collects them all into a single
4511/// slice iterable at runtime.
4512///
4513/// Implementors must be `Send + Sync + 'static` because the registry is built
4514/// once and shared across async tasks.
4515/// Possession-bounded capability for the trusted channel-ingest note path.
4516///
4517/// Constructible only inside `khive-runtime` (the field is private), and
4518/// granted during pack registration exclusively to factories named in
4519/// `CHANNEL_INGEST_CAPABLE_PACKS`. Every call to
4520/// [`crate::KhiveRuntime::try_create_note_as_trusted_ingest`] must present a
4521/// reference to one, so the set of callers able to establish transport-owned
4522/// message properties is bounded by possession at the composition root, not
4523/// by a documentation prohibition. Two ways to obtain one: registering
4524/// through [`PackRegistry::register_packs`]/`register_packs_with_runtimes`
4525/// under the `comm` name (the allowlisted, automatic path), or a composition
4526/// root that builds packs directly calling
4527/// [`ChannelIngestCapability::grant_for_direct_composition`] and passing the
4528/// result to [`crate::PackRuntime::accept_channel_ingest_capability`] (or a
4529/// pack's constructor variant that does so) itself. The residual trust
4530/// assumption is unchanged either way: whoever assembles the
4531/// `VerbRegistryBuilder` already decides which packs are wired in and already
4532/// holds a `KhiveRuntime`, so minting the grant explicitly carries no more
4533/// privilege than that composition already had by choosing to register `comm`
4534/// at all.
4535pub struct ChannelIngestCapability {
4536    pub(crate) _sealed: (),
4537}
4538
4539impl ChannelIngestCapability {
4540    /// Mint a capability for a composition root that constructs
4541    /// channel-transport packs directly, bypassing
4542    /// [`PackRegistry::register_packs`] (which grants this automatically).
4543    ///
4544    /// See the type-level doc for the trust argument: this carries no more
4545    /// privilege than the caller already has by virtue of holding a
4546    /// `KhiveRuntime` and choosing to wire the pack in.
4547    pub fn grant_for_direct_composition() -> Self {
4548        Self { _sealed: () }
4549    }
4550}
4551
4552/// Pack names entitled to a [`ChannelIngestCapability`] grant at registration.
4553pub(crate) const CHANNEL_INGEST_CAPABLE_PACKS: &[&str] = &["comm"];
4554
4555pub trait PackFactory: Send + Sync + 'static {
4556    /// Canonical lowercase name for this pack (e.g. `"kg"`, `"gtd"`).
4557    fn name(&self) -> &'static str;
4558
4559    /// Names of packs that must be loaded before this one.
4560    ///
4561    /// Defaults to empty so pack crates that have no dependencies compile
4562    /// without changes. [`PackRegistry::register_packs`] validates that every
4563    /// name listed here is present in the caller's explicit pack list — absent
4564    /// dependencies are a boot error, not silently auto-added.
4565    fn requires(&self) -> &'static [&'static str] {
4566        &[]
4567    }
4568
4569    /// Whether this pack intentionally exposes no top-level MCP verbs.
4570    ///
4571    /// Defaults to `false` so a declared pack whose runtime contributes no
4572    /// [`Visibility::Verb`] handlers fails at registration instead of silently
4573    /// disappearing from the served surface. Vocabulary- or ontology-only
4574    /// packs must opt in explicitly.
4575    fn intentionally_verbless(&self) -> bool {
4576        false
4577    }
4578
4579    /// Create a new pack instance for the given runtime.
4580    fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime>;
4581
4582    /// Build the full installation bundle for this pack: runtime, optional
4583    /// resolver, optional dispatch hook.
4584    ///
4585    /// Defaults to composing `create` and `create_resolver` with no dispatch
4586    /// hook, so existing factories compile unchanged. Packs whose dispatch
4587    /// hook must observe the same instance as the runtime (e.g. `brain`)
4588    /// override this method instead of `create`, since the default would
4589    /// otherwise require two independent instances to share state.
4590    fn create_install(&self, runtime: KhiveRuntime) -> PackInstall {
4591        let resolver = self.create_resolver(runtime.clone());
4592        PackInstall {
4593            runtime: self.create(runtime),
4594            resolver,
4595            dispatch_hook: None,
4596        }
4597    }
4598
4599    /// Optionally create a `PackByIdResolver` for this pack.
4600    ///
4601    /// Packs that own private SQL tables implement this to hook into
4602    /// `get(id)` and `delete(id)`. Defaults to `None` so existing packs
4603    /// compile without changes.
4604    fn create_resolver(&self, _runtime: KhiveRuntime) -> Option<Box<dyn PackByIdResolver>> {
4605        None
4606    }
4607}
4608
4609/// Newtype wrapper collected by `inventory` so pack crates can submit
4610/// `&'static dyn PackFactory` references without the type-ascription syntax
4611/// that `inventory::submit!` does not support for bare trait-object references.
4612pub struct PackRegistration(pub &'static dyn PackFactory);
4613
4614inventory::collect!(PackRegistration);
4615
4616/// Error returned by [`PackRegistry::register_packs`] when boot validation fails.
4617#[derive(Debug)]
4618pub enum PackLoadError {
4619    /// The requested pack name was not found in the inventory.
4620    UnknownPack(String),
4621    /// A pack was requested but a declared dependency is absent from the list.
4622    MissingDependency {
4623        /// The pack that declared the dependency.
4624        pack: String,
4625        /// The dependency that is missing from the requested pack list.
4626        dep: String,
4627    },
4628    /// A declared pack contributed no top-level verbs without explicitly
4629    /// declaring itself vocabulary/ontology-only.
4630    NoPublicVerbs {
4631        /// The declared pack name.
4632        pack: String,
4633    },
4634}
4635
4636impl std::fmt::Display for PackLoadError {
4637    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4638        match self {
4639            PackLoadError::UnknownPack(name) => write!(f, "unknown pack {name:?}"),
4640            PackLoadError::MissingDependency { pack, dep } => write!(
4641                f,
4642                "pack {pack:?} requires {dep:?}, which is not in the requested pack list; \
4643                 add --pack {dep} before --pack {pack}"
4644            ),
4645            PackLoadError::NoPublicVerbs { pack } => write!(
4646                f,
4647                "declared pack {pack:?} registers no public verbs; if this pack is \
4648                 intentionally vocabulary- or ontology-only, its factory must declare \
4649                 intentionally_verbless() = true"
4650            ),
4651        }
4652    }
4653}
4654
4655impl std::error::Error for PackLoadError {}
4656
4657/// Reject a declared pack whose runtime contributes no [`Visibility::Verb`]
4658/// handlers unless its factory explicitly opts out via
4659/// [`PackFactory::intentionally_verbless`].
4660fn check_pack_has_public_verbs(
4661    factory: &dyn PackFactory,
4662    install: &PackInstall,
4663    name: &str,
4664) -> Result<(), PackLoadError> {
4665    if !factory.intentionally_verbless()
4666        && !install
4667            .runtime
4668            .handlers()
4669            .iter()
4670            .any(|handler| matches!(handler.visibility, Visibility::Verb))
4671    {
4672        return Err(PackLoadError::NoPublicVerbs {
4673            pack: name.to_string(),
4674        });
4675    }
4676    Ok(())
4677}
4678
4679/// Registry of pack factories discovered via `inventory` at link time.
4680///
4681/// No instance is needed — all methods are associated functions that walk the
4682/// globally-collected [`PackRegistration`] slice.
4683pub struct PackRegistry;
4684
4685/// Whether [`PackRegistry::build_ingest_registry`] attaches the runtime's
4686/// event store to the registry it builds.
4687#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4688pub enum IngestAuditStore {
4689    /// Mirror `KhiveMcpServer::with_packs` (`khive-mcp/src/server.rs`): a
4690    /// writable runtime attaches its own event store and refuses to build if
4691    /// sink initialization fails; a read-only runtime retains no `EventStore`
4692    /// handle and an advisory travels beside each result instead.
4693    Attach,
4694    /// Build the registry with no audit event store, for a caller with no use
4695    /// for persisted audit rows.
4696    Detach,
4697}
4698
4699impl PackRegistry {
4700    /// Names of all pack factories discovered via `inventory`.
4701    pub fn discovered_names() -> Vec<&'static str> {
4702        inventory::iter::<PackRegistration>
4703            .into_iter()
4704            .map(|r| r.0.name())
4705            .collect()
4706    }
4707
4708    /// Register the named packs into `builder` using the supplied `runtime`.
4709    ///
4710    /// Validates the explicit pack list against `PackFactory::requires()` —
4711    /// if any requested pack declares a dependency that is absent from `names`,
4712    /// registration fails (missing dependency is a boot error, not silently
4713    /// auto-added). Callers must include all required packs explicitly.
4714    ///
4715    /// The [`VerbRegistryBuilder::build`] topo-sort enforces correct load order.
4716    ///
4717    /// Returns `Ok(())` when all names are recognised and all declared
4718    /// dependencies are satisfied; returns `Err(PackLoadError)` with a
4719    /// distinct variant for unknown pack vs missing dependency.
4720    pub fn register_packs(
4721        names: &[String],
4722        runtime: KhiveRuntime,
4723        builder: &mut VerbRegistryBuilder,
4724    ) -> Result<(), PackLoadError> {
4725        // Build a name→factory index once.
4726        let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
4727            .into_iter()
4728            .map(|r| r.0)
4729            .collect();
4730        let factory_for = |name: &str| -> Option<&'static dyn PackFactory> {
4731            all.iter().copied().find(|f| f.name() == name)
4732        };
4733
4734        // Validate that every requested name is a known factory.
4735        let requested: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
4736        for name in names {
4737            factory_for(name.as_str()).ok_or_else(|| PackLoadError::UnknownPack(name.clone()))?;
4738        }
4739
4740        // Validate that all requires() dependencies are explicitly present in
4741        // the requested set. Missing dep → boot error, not auto-add.
4742        for name in names {
4743            let factory = factory_for(name.as_str()).unwrap(); // validated above
4744            for &dep in factory.requires() {
4745                if !requested.contains(dep) {
4746                    return Err(PackLoadError::MissingDependency {
4747                        pack: name.clone(),
4748                        dep: dep.to_string(),
4749                    });
4750                }
4751            }
4752        }
4753
4754        // Register every requested pack; VerbRegistryBuilder::build()
4755        // performs the topo-sort, so insertion order here does not matter.
4756        for name in names {
4757            let factory = factory_for(name.as_str()).unwrap(); // validated above
4758            let install = factory.create_install(runtime.clone());
4759            check_pack_has_public_verbs(factory, &install, name)?;
4760            if CHANNEL_INGEST_CAPABLE_PACKS.contains(&name.as_str()) {
4761                install
4762                    .runtime
4763                    .accept_channel_ingest_capability(ChannelIngestCapability { _sealed: () });
4764            }
4765            builder.register_boxed(install.runtime);
4766            if let Some(resolver) = install.resolver {
4767                builder.register_resolver(name.clone(), resolver);
4768            }
4769            if let Some(hook) = install.dispatch_hook {
4770                builder.with_dispatch_hook(hook);
4771            }
4772        }
4773
4774        Ok(())
4775    }
4776
4777    /// Build a `VerbRegistry` from `runtime`'s own configuration: gate,
4778    /// default namespace, visible namespaces, actor id, and the configured
4779    /// pack set, then install the registry's aggregated edge rules back onto
4780    /// `runtime`. This is the wiring shared by every one-shot CLI ingest path
4781    /// (`kkernel code-ingest`, `kkernel git-ingest`) that needs a real
4782    /// registry to dispatch through outside of a live MCP server.
4783    ///
4784    /// `audit_store` selects whether the registry gets the runtime's event
4785    /// store; see [`IngestAuditStore`] for what each variant does.
4786    ///
4787    /// This helper carries only the subset every ingest path duplicated
4788    /// verbatim. The MCP server's own registry construction additionally
4789    /// wires channel-loop admission, `config_id`, embedder/entity-type/
4790    /// note-mutation-hook registration, schema-plan application, and the WAL
4791    /// checkpoint pool handle — all server-only concerns a one-shot CLI pass
4792    /// has no use for, so `KhiveMcpServer::with_packs` keeps its own
4793    /// construction rather than calling this helper.
4794    pub fn build_ingest_registry(
4795        runtime: &KhiveRuntime,
4796        audit_store: IngestAuditStore,
4797    ) -> Result<VerbRegistry, RuntimeError> {
4798        let mut builder = VerbRegistryBuilder::new();
4799        builder.with_gate(runtime.config().gate.clone());
4800        builder.with_default_namespace(runtime.config().default_namespace.as_str());
4801        builder.with_visible_namespaces(runtime.config().visible_namespaces.clone());
4802        builder.with_actor_id(runtime.config().actor_id.clone());
4803        if audit_store == IngestAuditStore::Attach {
4804            if runtime.is_read_only() {
4805                builder.with_read_only_audit_store();
4806            } else {
4807                // Attach requires a usable sink; build propagates open failures.
4808                builder.with_runtime_event_store(runtime)?;
4809            }
4810        }
4811        Self::register_packs(
4812            &runtime.config().packs.clone(),
4813            runtime.clone(),
4814            &mut builder,
4815        )
4816        .map_err(|e| RuntimeError::Internal(format!("pack registration failed: {e:?}")))?;
4817        let registry = builder.build()?;
4818        runtime.install_edge_rules(registry.all_edge_rules());
4819        Ok(registry)
4820    }
4821
4822    /// Register the named packs into `builder`, routing each pack to its own runtime.
4823    ///
4824    /// `runtimes` maps pack name → `KhiveRuntime` (one per backend assignment).
4825    /// `default_runtime` is used for any pack whose name is not in `runtimes`.
4826    /// The validation logic (unknown pack, missing dependency) is identical to
4827    /// [`PackRegistry::register_packs`].
4828    ///
4829    /// This is the multi-backend boot path (ADR-028). Single-backend callers
4830    /// should continue using [`PackRegistry::register_packs`].
4831    pub fn register_packs_with_runtimes(
4832        names: &[String],
4833        runtimes: &HashMap<String, KhiveRuntime>,
4834        default_runtime: &KhiveRuntime,
4835        builder: &mut VerbRegistryBuilder,
4836    ) -> Result<(), PackLoadError> {
4837        let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
4838            .into_iter()
4839            .map(|r| r.0)
4840            .collect();
4841        Self::register_packs_with_runtimes_from(&all, names, runtimes, default_runtime, builder)
4842    }
4843
4844    /// Like [`Self::register_packs_with_runtimes`], but resolves pack names
4845    /// against the link-time `inventory` registry **plus** `extra_factories` —
4846    /// pack factories the composition root supplies directly rather than
4847    /// discovers through `inventory::iter::<PackRegistration>` (ADR-191 D6,
4848    /// ADR-192 S4: "a pack compiled outside this repository ... extends the
4849    /// web ontology without any change here" — a host binary that depends on
4850    /// a pinned khive revision plus an out-of-tree pack crate, or a
4851    /// composition root registering a credential-provider/request-hook
4852    /// consumer pack, has no `inventory` presence in *this* binary short of
4853    /// its own force-link anchor). An inventory-discovered factory always
4854    /// wins a name collision with an `extra_factories` entry — the linked set
4855    /// is the trusted default; an extra factory only fills a name inventory
4856    /// does not already answer.
4857    ///
4858    /// This is the seam D6 describes as "kkernel exposes its server
4859    /// construction as a library entry point that accepts additional pack
4860    /// factories" — the `kkernel` library entry point itself lives in
4861    /// `kkernel::compose`, built on this function exactly as
4862    /// `khive-mcp/src/serve.rs` builds on [`Self::register_packs_with_runtimes`].
4863    pub fn register_packs_with_runtimes_with_extra_factories(
4864        extra_factories: &[&'static dyn PackFactory],
4865        names: &[String],
4866        runtimes: &HashMap<String, KhiveRuntime>,
4867        default_runtime: &KhiveRuntime,
4868        builder: &mut VerbRegistryBuilder,
4869    ) -> Result<(), PackLoadError> {
4870        let mut all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
4871            .into_iter()
4872            .map(|r| r.0)
4873            .collect();
4874        all.extend(extra_factories.iter().copied());
4875        Self::register_packs_with_runtimes_from(&all, names, runtimes, default_runtime, builder)
4876    }
4877
4878    /// Shared body for [`Self::register_packs_with_runtimes`] and
4879    /// [`Self::register_packs_with_runtimes_with_extra_factories`]: both
4880    /// build a `factories` index (inventory-only, or inventory-plus-extra)
4881    /// and delegate here. `factory_for` resolves by first match, so a
4882    /// duplicate name earlier in `factories` wins over a later one — the two
4883    /// public callers above rely on that for their stated collision rule.
4884    fn register_packs_with_runtimes_from(
4885        factories: &[&'static dyn PackFactory],
4886        names: &[String],
4887        runtimes: &HashMap<String, KhiveRuntime>,
4888        default_runtime: &KhiveRuntime,
4889        builder: &mut VerbRegistryBuilder,
4890    ) -> Result<(), PackLoadError> {
4891        let factory_for = |name: &str| -> Option<&'static dyn PackFactory> {
4892            factories.iter().copied().find(|f| f.name() == name)
4893        };
4894
4895        let requested: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
4896        for name in names {
4897            factory_for(name.as_str()).ok_or_else(|| PackLoadError::UnknownPack(name.clone()))?;
4898        }
4899
4900        for name in names {
4901            let factory = factory_for(name.as_str()).unwrap();
4902            for &dep in factory.requires() {
4903                if !requested.contains(dep) {
4904                    return Err(PackLoadError::MissingDependency {
4905                        pack: name.clone(),
4906                        dep: dep.to_string(),
4907                    });
4908                }
4909            }
4910        }
4911
4912        builder.kg_read_resolver = Some(Arc::new(crate::kg_read::KgReadResolver::new(
4913            default_runtime,
4914            runtimes,
4915        )));
4916
4917        for name in names {
4918            let factory = factory_for(name.as_str()).unwrap();
4919            let runtime = runtimes
4920                .get(name.as_str())
4921                .cloned()
4922                .unwrap_or_else(|| default_runtime.clone());
4923            let install = factory.create_install(runtime);
4924            check_pack_has_public_verbs(factory, &install, name)?;
4925            if CHANNEL_INGEST_CAPABLE_PACKS.contains(&name.as_str()) {
4926                install
4927                    .runtime
4928                    .accept_channel_ingest_capability(ChannelIngestCapability { _sealed: () });
4929            }
4930            builder.register_boxed(install.runtime);
4931            if let Some(resolver) = install.resolver {
4932                builder.register_resolver(name.clone(), resolver);
4933            }
4934            if let Some(hook) = install.dispatch_hook {
4935                builder.with_dispatch_hook(hook);
4936            }
4937        }
4938
4939        Ok(())
4940    }
4941}
4942
4943fn target_id_from_args(args: &serde_json::Value) -> Option<uuid::Uuid> {
4944    args.get("target_id")
4945        .and_then(serde_json::Value::as_str)
4946        .and_then(|s| s.parse::<uuid::Uuid>().ok())
4947}
4948
4949/// Build the [`AuditEvent`] for one gate check, masking `deny_reason` before
4950/// it can reach either downstream sink.
4951///
4952/// `deny_reason` is gate-authored text this crate does not control: a custom
4953/// `Gate` implementation (a Rego policy, an external backend) can echo
4954/// request content into why it denied, so the same secret-detection pass
4955/// applied to backend error text elsewhere in this file also has to run on a
4956/// denial's stated reason. This can't live on [`AuditEvent`] itself —
4957/// `khive-gate` cannot depend on `khive-runtime`'s masking, which itself
4958/// depends on `khive-gate` (see `khive-runtime/Cargo.toml`); a masker inside
4959/// `AuditEvent::from_check` would be a dependency cycle. So masking happens
4960/// once, here, immediately after construction and before the event is used
4961/// anywhere: every call site that turns a [`GateDecision`] into an
4962/// [`AuditEvent`] must go through this function, never `AuditEvent::from_check`
4963/// directly, so the `gate.check` tracing line and the row
4964/// [`build_audit_storage_event`] re-serializes for the event store always see
4965/// the same masked value rather than each needing its own redaction.
4966fn masked_audit_event(
4967    gate_req: &GateRequest,
4968    decision: &GateDecision,
4969    gate_impl: &str,
4970) -> AuditEvent {
4971    let mut audit = AuditEvent::from_check(gate_req, decision, gate_impl)
4972        .with_operation_attribution(
4973            khive_storage::operation_context::current_operation_attribution(),
4974        );
4975    if let Some(reason) = audit.deny_reason.take() {
4976        audit.deny_reason = Some(crate::secret_gate::bounded_masked_log_text(&reason));
4977    }
4978    audit
4979}
4980
4981/// Build a v1-shape audit storage event from a gate check outcome.
4982/// See `docs/api/pack.md#build_audit_storage_event` for the `resource` payload contract.
4983fn build_audit_storage_event(
4984    gate_req: &GateRequest,
4985    audit: &AuditEvent,
4986    outcome: EventOutcome,
4987    resource: Option<Value>,
4988) -> Event {
4989    let mut audit_data = serde_json::to_value(audit).unwrap_or_else(|e| {
4990        tracing::warn!(error = %e, "failed to serialize AuditEvent for EventStore");
4991        serde_json::Value::Null
4992    });
4993    if let Some(resource) = resource {
4994        if let Value::Object(ref mut map) = audit_data {
4995            map.insert("resource".to_string(), resource);
4996        }
4997    }
4998    let mut storage_event = Event::new(
4999        gate_req.namespace.as_str(),
5000        gate_req.verb.as_str(),
5001        EventKind::Audit,
5002        SubstrateKind::Event,
5003        format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
5004    )
5005    .with_outcome(outcome)
5006    .with_payload(audit_data);
5007    storage_event.op_index = audit.op_index;
5008    storage_event.ref_resolution = audit.ref_resolution;
5009    if let Some(target_id) = target_id_from_args(&gate_req.args) {
5010        storage_event = storage_event.with_target(target_id);
5011    }
5012    storage_event
5013}
5014
5015/// Process-wide pure-observability audit appends whose errors were logged
5016/// and swallowed — never an obligation-bearing row, which fails its dispatch
5017/// instead and is counted separately by
5018/// [`AUDIT_OBLIGATION_APPEND_FAILURES`]/[`audit_obligation_append_failure_count`].
5019/// Keeping this counter obligation-free preserves its documented contract
5020/// (`docs/guide/api-reference.md`, `khive-db`'s `WriterContentionDiagnostics::audit_append_failures`
5021/// doc comment): every unit counted here was swallowed, none was propagated.
5022static AUDIT_APPEND_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
5023
5024pub(crate) fn audit_append_failure_count() -> u64 {
5025    AUDIT_APPEND_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
5026}
5027
5028/// Process-wide commit failures for obligation-bearing audit rows (ADR-133
5029/// D2/D3/D4): gate denials, dispatch outcomes, unknown-verb rows, and
5030/// `git.digest` success receipts. Most call sites fold this failure into the
5031/// dispatch's own error (a would-be success becomes an error, per
5032/// [`fold_audit_obligation`]); a denial's own audit row is the one
5033/// exception — its dispatch already returns `PermissionDenied` independent
5034/// of whether this row commits, so the failure is logged and counted here
5035/// but not separately propagated. Disjoint from [`AUDIT_APPEND_FAILURES`] —
5036/// each failing row is classified by [`crate::audit_batch::classify`] into
5037/// exactly one of the two classes and increments exactly one of these two
5038/// counters, never both.
5039static AUDIT_OBLIGATION_APPEND_FAILURES: std::sync::atomic::AtomicU64 =
5040    std::sync::atomic::AtomicU64::new(0);
5041
5042/// Runtime diagnostics exposes this process-wide counter separately from
5043/// swallowed audit errors and batch-generation failures (#2784).
5044pub(crate) fn audit_obligation_append_failure_count() -> u64 {
5045    AUDIT_OBLIGATION_APPEND_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
5046}
5047
5048/// Process-wide count of `DispatchObligation` rows **refused before they
5049/// could be enqueued** (`AuditTerminalReason::QueueAdmissionExhausted`) for an
5050/// [`VerbRegistry::admission_degrade_safe`] verb (#2147/#2217).
5051/// This is a confirmed, terminal accounting loss: the row never shared a
5052/// generation with anyone and will never commit. Disjoint from both
5053/// [`AUDIT_APPEND_FAILURES`] and [`AUDIT_OBLIGATION_APPEND_FAILURES`]: this
5054/// case is neither. It is not [`AUDIT_APPEND_FAILURES`] — that counter's own
5055/// contract (`khive-db`'s `WriterContentionDiagnostics::audit_append_failures`
5056/// doc) says an obligation-bearing row's commit failure "either fail[s] the
5057/// dispatch... or [is] tracked by the runtime's own separate
5058/// obligation-failure counter instead", and this dispatch does neither: it
5059/// reports the caller's already-computed success with no error. It is not
5060/// [`AUDIT_OBLIGATION_APPEND_FAILURES`] either — that counter's contract is
5061/// "most call sites fold this failure into the dispatch's own error", which
5062/// is exactly the propagation this admission-degrade path exists to avoid.
5063/// Also disjoint from [`AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS`] — that
5064/// counter's row was enqueued and may still commit; this one's was not.
5065/// Read in production by [`VerbRegistry::audit_batch_metrics`], which feeds
5066/// it into `khive_db::diagnostics::RuntimeAuditBatchMetrics::admission_refused_obligations`
5067/// and from there into the `db_diagnostics` verb's
5068/// `writer_contention.audit_admission_refused_obligations` field (ADR-103
5069/// Amendment 3) — an operator can read this counter without a test-only
5070/// feature gate. The mechanism tests also read it directly, including the
5071/// admission-pressure regression tests in `tests/read_verb_admission_exhaustion.rs`,
5072/// which (like `khive-runtime/src/audit_batch.rs`'s own `test_internals`
5073/// module) need it as `pub`, not `pub(crate)`, since they compile as a
5074/// separate external binary outside this crate.
5075///
5076/// This counter is CUMULATIVE for the life of the process. Nothing decrements
5077/// it and nothing resolves it: the only writes in the tree are this
5078/// declaration and one `fetch_add`. A value that does not move therefore means
5079/// no refusal happened in that window, which is the healthy reading, not a
5080/// stalled subsystem (#2791). Because a total cannot say when it was last
5081/// earned, it is paired with
5082/// [`AUDIT_ADMISSION_REFUSED_OBLIGATIONS_LAST_MS`].
5083static AUDIT_ADMISSION_REFUSED_OBLIGATIONS: std::sync::atomic::AtomicU64 =
5084    std::sync::atomic::AtomicU64::new(0);
5085
5086/// Wall-clock milliseconds at which [`AUDIT_ADMISSION_REFUSED_OBLIGATIONS`]
5087/// last moved; `0` means it has never moved in this process. This is the field
5088/// that makes a static count readable: an old mark beside a non-zero count is
5089/// history, a recent mark beside the same count is an active condition (#2791).
5090static AUDIT_ADMISSION_REFUSED_OBLIGATIONS_LAST_MS: std::sync::atomic::AtomicU64 =
5091    std::sync::atomic::AtomicU64::new(0);
5092
5093pub fn audit_admission_refused_obligation_count() -> u64 {
5094    AUDIT_ADMISSION_REFUSED_OBLIGATIONS.load(std::sync::atomic::Ordering::Relaxed)
5095}
5096
5097/// `None` until the counter first moves in this process.
5098pub fn audit_admission_refused_obligation_last_at_ms() -> Option<u64> {
5099    match AUDIT_ADMISSION_REFUSED_OBLIGATIONS_LAST_MS.load(std::sync::atomic::Ordering::Relaxed) {
5100        0 => None,
5101        at => Some(at),
5102    }
5103}
5104
5105/// Process-wide count of `DispatchObligation` rows that were **already
5106/// enqueued but had not resolved by the time the caller's admission wait
5107/// deadline elapsed** (`AuditTerminalReason::AdmissionDeadlineExpired`) for a
5108/// succeeded dispatch of any verb (#2147/#2217 introduced the count for
5109/// [`VerbRegistry::admission_degrade_safe`] reads; writes joined it once a
5110/// committed write stopped reporting failure over a row that still commits).
5111/// Unlike [`AUDIT_ADMISSION_REFUSED_OBLIGATIONS`], a row counted here is not
5112/// a confirmed loss: per `AuditTerminalReason::AdmissionDeadlineExpired`'s own
5113/// doc, the row may still be committed (or terminally failed) by the
5114/// generation driver independently of the caller's timeout, so this counter
5115/// is an upper bound on the eventual undercount, not the undercount itself.
5116/// Read in production by [`VerbRegistry::audit_batch_metrics`], which feeds
5117/// it into `khive_db::diagnostics::RuntimeAuditBatchMetrics::admission_unresolved_obligations`
5118/// and from there into the `db_diagnostics` verb's
5119/// `writer_contention.audit_admission_unresolved_obligations` field (ADR-103
5120/// Amendment 3).
5121///
5122/// This counter is CUMULATIVE for the life of the process, and its name is the
5123/// one that misleads: "unresolved obligations" reads as the size of a live set
5124/// that something drains. There is no such set and no resolver. The only
5125/// writes in the tree are this declaration and one `fetch_add`, so a value that
5126/// does not move means no admission deadline expired in that window — the
5127/// healthy reading (#2791). Each increment records one past event whose row,
5128/// per `AuditTerminalReason::AdmissionDeadlineExpired`, most likely committed
5129/// afterwards. Paired with [`AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS_LAST_MS`]
5130/// so a reader can tell history from an active condition.
5131static AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS: std::sync::atomic::AtomicU64 =
5132    std::sync::atomic::AtomicU64::new(0);
5133
5134/// Wall-clock milliseconds at which [`AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS`]
5135/// last moved; `0` means it has never moved in this process (#2791).
5136static AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS_LAST_MS: std::sync::atomic::AtomicU64 =
5137    std::sync::atomic::AtomicU64::new(0);
5138
5139pub fn audit_admission_unresolved_obligation_count() -> u64 {
5140    AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS.load(std::sync::atomic::Ordering::Relaxed)
5141}
5142
5143/// `None` until the counter first moves in this process.
5144pub fn audit_admission_unresolved_obligation_last_at_ms() -> Option<u64> {
5145    match AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS_LAST_MS.load(std::sync::atomic::Ordering::Relaxed)
5146    {
5147        0 => None,
5148        at => Some(at),
5149    }
5150}
5151
5152/// Stamp an admission-obligation counter's "last moved" mark.
5153///
5154/// A clock that reads before 1970, or a host clock stepped backwards, must not
5155/// be able to write `0` and make a counter that HAS moved report that it never
5156/// did, so a non-positive reading is clamped to 1ms.
5157fn mark_admission_obligation_counter(mark: &std::sync::atomic::AtomicU64) {
5158    let now = chrono::Utc::now().timestamp_millis();
5159    let now = u64::try_from(now).unwrap_or(1).max(1);
5160    mark.store(now, std::sync::atomic::Ordering::Relaxed);
5161}
5162
5163const GIT_DIGEST_RECEIPT_FAILURE: &str =
5164    "git_digest_receipt_persist_failed: git.digest writes may have committed, but no durable \
5165     success receipt was confirmed; inspect ingest state before retrying";
5166
5167/// Tells the dispatch seam whether it should consume the deferred audit or
5168/// reuse it for the ordinary generic Error row.
5169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5170enum GitDigestReceiptOutcome {
5171    /// The schema-v2 receipt landed; no second audit row may be appended.
5172    Persisted,
5173    /// The handler's nominal success could not be shaped into a receipt. The
5174    /// helper has converted it to an error, and the original audit remains
5175    /// available for one generic Error row.
5176    BuildRejected,
5177    /// Persistence could not be attempted or its append failed. A second
5178    /// best-effort append would either be impossible or duplicate the same
5179    /// known store failure, so the caller must not retry it here.
5180    PersistenceUnavailable,
5181}
5182
5183fn fail_git_digest_receipt(
5184    result: &mut Result<Value, RuntimeError>,
5185    failure: AuditObligationFailure,
5186) {
5187    let Ok(value) = result else {
5188        return;
5189    };
5190    let domain_result = std::mem::take(value);
5191    *result = Err(RuntimeError::AuditObligation {
5192        failure: Box::new(failure),
5193        domain_result,
5194    });
5195}
5196
5197/// Persist the complete successful `git.digest` report as a schema-v2 audit
5198/// event and add that event's UUID to the returned report as `receipt_id`.
5199///
5200/// This is intentionally strict while every other dispatch audit remains
5201/// best-effort: a caller must never receive an unqualified digest success if
5202/// response loss would leave it unable to recover the exact per-pass report.
5203/// Missing audit/store configuration, an invalid handler report, or an append
5204/// failure therefore replaces the handler success with a stable safe error.
5205/// The error does not expose storage paths, source URLs, or command stderr and
5206/// explicitly warns that ingest writes may already have committed.
5207async fn persist_git_digest_receipt(
5208    store: Option<&Arc<dyn EventStore>>,
5209    audit_batch: Option<&Arc<crate::audit_batch::AuditBatch>>,
5210    gate_req: &GateRequest,
5211    audit: Option<&AuditEvent>,
5212    result: &mut Result<Value, RuntimeError>,
5213    duration_us: i64,
5214    resource: Option<Value>,
5215) -> GitDigestReceiptOutcome {
5216    let Ok(report) = result else {
5217        return GitDigestReceiptOutcome::PersistenceUnavailable;
5218    };
5219    let Some(store) = store else {
5220        tracing::error!(
5221            verb = "git.digest",
5222            "durable receipt store is not configured"
5223        );
5224        fail_git_digest_receipt(
5225            result,
5226            AuditObligationFailure::git_digest_receipt("event store is not configured"),
5227        );
5228        return GitDigestReceiptOutcome::PersistenceUnavailable;
5229    };
5230    let Some(audit) = audit else {
5231        tracing::error!(
5232            verb = "git.digest",
5233            "durable receipt cannot be built because the gate produced no audit decision"
5234        );
5235        fail_git_digest_receipt(
5236            result,
5237            AuditObligationFailure::git_digest_receipt("gate audit decision is absent"),
5238        );
5239        return GitDigestReceiptOutcome::PersistenceUnavailable;
5240    };
5241
5242    let Some(report_object) = report.as_object_mut() else {
5243        tracing::error!(
5244            verb = "git.digest",
5245            "digest handler returned a non-object report"
5246        );
5247        fail_git_digest_receipt(
5248            result,
5249            AuditObligationFailure::git_digest_receipt("handler report is not an object"),
5250        );
5251        return GitDigestReceiptOutcome::BuildRejected;
5252    };
5253    let Some(project_id) = report_object
5254        .get("project_id")
5255        .and_then(Value::as_str)
5256        .and_then(|raw| raw.parse::<uuid::Uuid>().ok())
5257    else {
5258        tracing::error!(
5259            verb = "git.digest",
5260            "digest handler report omitted a valid project_id"
5261        );
5262        fail_git_digest_receipt(
5263            result,
5264            AuditObligationFailure::git_digest_receipt("handler report has no valid project_id"),
5265        );
5266        return GitDigestReceiptOutcome::BuildRejected;
5267    };
5268
5269    // Allocate the event first so the exact durable key can be embedded in
5270    // both the caller-visible report and the report snapshot stored in it.
5271    let mut event = Event::new(
5272        gate_req.namespace.as_str(),
5273        gate_req.verb.as_str(),
5274        EventKind::Audit,
5275        SubstrateKind::Event,
5276        format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
5277    )
5278    .with_outcome(EventOutcome::Success)
5279    .with_target(project_id)
5280    .with_payload_schema_version(2)
5281    .with_duration_us(duration_us);
5282    let receipt_id = event.id;
5283    report_object.insert(
5284        "receipt_id".to_string(),
5285        Value::String(receipt_id.to_string()),
5286    );
5287
5288    let mut payload = serde_json::to_value(audit).unwrap_or_else(|serialize_err| {
5289        tracing::error!(
5290            verb = "git.digest",
5291            error = %serialize_err,
5292            "failed to serialize gate audit for durable digest receipt"
5293        );
5294        Value::Null
5295    });
5296    let Value::Object(payload_object) = &mut payload else {
5297        tracing::error!(
5298            verb = "git.digest",
5299            "gate audit serialization did not produce an object"
5300        );
5301        fail_git_digest_receipt(
5302            result,
5303            AuditObligationFailure::git_digest_receipt("gate audit payload is not an object"),
5304        );
5305        return GitDigestReceiptOutcome::BuildRejected;
5306    };
5307    if let Some(resource) = resource {
5308        payload_object.insert("resource".to_string(), resource);
5309    }
5310    payload_object.insert("result".to_string(), report.clone());
5311    event.payload = payload;
5312
5313    // Strict path (ADR-133): a git.digest success receipt must still commit
5314    // exactly once before the caller can see success, so this row waits on
5315    // its generation's commit through the batch seam rather than
5316    // best-effort — the batching only changes whether it shares a writer
5317    // acquisition with concurrent rows, never whether it is durable before
5318    // the caller observes success.
5319    let submit_result = if let Some(audit_batch) = audit_batch {
5320        audit_batch
5321            .submit_until_resolved(crate::audit_batch::PreparedAuditRow {
5322                event,
5323                producer: crate::audit_batch::AuditProducer::GitDigestReceipt,
5324            })
5325            .await
5326            .map(|_outcome| ())
5327            .map_err(|reason| AuditObligationFailure::new("git.digest", reason))
5328    } else {
5329        store
5330            .append_event(event)
5331            .await
5332            .map_err(|error| AuditObligationFailure::from_store("git.digest", error))
5333    };
5334    if let Err(mut failure) = submit_result {
5335        // `GitDigestReceipt` is always `DispatchObligation` (see
5336        // `crate::audit_batch::classify`) and this failure always
5337        // propagates below, so it belongs on the obligation counter, not
5338        // the swallowed-failures one.
5339        AUDIT_OBLIGATION_APPEND_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5340        tracing::error!(
5341            verb = "git.digest",
5342            error = %failure,
5343            receipt_id = %receipt_id,
5344            "durable digest receipt append failed"
5345        );
5346        failure.message = format!(
5347            "{GIT_DIGEST_RECEIPT_FAILURE}; audit submission failed ({})",
5348            failure.wire_code()
5349        );
5350        fail_git_digest_receipt(result, failure);
5351        return GitDigestReceiptOutcome::PersistenceUnavailable;
5352    }
5353    GitDigestReceiptOutcome::Persisted
5354}
5355
5356/// Append an audit event, propagating a persistent failure for
5357/// obligation-bearing producers and swallowing it for pure-observability
5358/// producers.
5359///
5360/// ADR-133 D2/D3/D4: a dispatch must not report success when the row that
5361/// accounts for, authorizes, or audits it did not commit. Producers
5362/// classified [`crate::audit_batch::AuditProductionClass::DispatchObligation`]
5363/// (gate denials, dispatch outcomes, unknown-verb, git.digest receipts)
5364/// therefore return `Err` here on a persistent commit failure; the caller is
5365/// responsible for folding that into the dispatch result on the
5366/// success path — see [`fold_audit_obligation`]. Producers classified
5367/// [`crate::audit_batch::AuditProductionClass::PureObservability`]
5368/// (config-lock rows, `memory.recall` execution) degrade gracefully: the
5369/// failure is logged and counted but never returned, matching the pre-ADR-133
5370/// best-effort contract.
5371///
5372/// Every failure — obligation or observability — increments one of the
5373/// process-wide diagnostics counters above; the one exception is the
5374/// admission-degrade case below, which increments one of its own dedicated
5375/// [`AUDIT_ADMISSION_REFUSED_OBLIGATIONS`] /
5376/// [`AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS`] counters instead — it is
5377/// neither a swallowed observability failure nor a propagated obligation
5378/// failure.
5379///
5380/// `degrade_allowlisted` (#2147/#2217) narrows that obligation for
5381/// one specific case: a *successful* dispatch (`AuditProducer::DispatchSucceeded`)
5382/// for a verb that [`VerbRegistry::admission_degrade_safe`] has explicitly
5383/// opted in (Assertive alone is not a sufficient signal — see that method's
5384/// doc) performs no domain write, so this row's own admission being
5385/// transiently refused or timed out (`AuditTerminalReason::QueueAdmissionExhausted`
5386/// / `AdmissionDeadlineExpired`) degrades to best-effort instead of failing
5387/// the dispatch — the caller-visible read result is preserved. This function
5388/// derives eligibility from `producer` itself rather than trusting the
5389/// caller's `degrade_allowlisted` answer in isolation, so a `DispatchFailed`
5390/// row can never take the degrade path no matter what a caller passes: every
5391/// failed dispatch and every gate-denial/unknown-verb/git.digest row stays
5392/// strictly obligation-bearing. A succeeded write degrades on exactly one
5393/// reason, `AdmissionDeadlineExpired`: its row is already enqueued and its
5394/// generation commits it independently of the caller's wait, so failing the
5395/// dispatch would report a committed domain write as failed while changing
5396/// nothing about the row. `QueueAdmissionExhausted` (refused before enqueue,
5397/// a confirmed loss) still fails a write's dispatch.
5398///
5399/// When the registry has an audit-batch seam configured (it is whenever
5400/// `store` is), the row routes through
5401/// [`crate::audit_batch::AuditBatchControl::submit`] instead of taking its
5402/// own writer-task acquisition — concurrent producers collapse onto one
5403/// commit per generation. `audit_batch: None` (a `VerbRegistry` predating
5404/// the seam, or constructed without going through the builder) falls back to
5405/// the pre-ADR-133 direct append, classified the same way.
5406async fn append_audit_event_best_effort(
5407    audit_batch: Option<&Arc<crate::audit_batch::AuditBatch>>,
5408    store: &Arc<dyn EventStore>,
5409    event: Event,
5410    verb: &str,
5411    producer: crate::audit_batch::AuditProducer,
5412    degrade_allowlisted: bool,
5413) -> Result<(), AuditObligationFailure> {
5414    use crate::audit_batch::{
5415        classify, AuditBatchControl, AuditProducer, AuditProductionClass, AuditTerminalReason,
5416    };
5417
5418    let is_obligation = classify(producer) == AuditProductionClass::DispatchObligation;
5419    let admission_degrade_eligible =
5420        degrade_allowlisted && producer == AuditProducer::DispatchSucceeded;
5421    // A row that was enqueued before the caller's admission wait elapsed is
5422    // committed by its generation independently of this response, so the
5423    // only thing failing the dispatch would do is report a committed domain
5424    // write as failed. That holds for every succeeded dispatch, allowlisted
5425    // read or not; the refused-before-enqueue arm below stays strict for
5426    // writes because that one is a confirmed audit loss.
5427    let enqueued_row_outlives_deadline = producer == AuditProducer::DispatchSucceeded;
5428
5429    if let Some(audit_batch) = audit_batch {
5430        let row = crate::audit_batch::PreparedAuditRow { event, producer };
5431        // khive#2256: for a successful non-degrade-safe operation, the
5432        // domain effect may already be committed. Once its audit row is
5433        // enqueued, keep awaiting the generation's real result past the
5434        // ordinary admission deadline instead of reporting a false failure
5435        // that invites an unsafe retry. Admission-degrade-safe reads retain
5436        // their bounded-wait behavior, as do error/denial observations whose
5437        // caller-visible outcome is already fixed.
5438        let submit_result =
5439            if producer == AuditProducer::DispatchSucceeded && !admission_degrade_eligible {
5440                audit_batch.submit_until_resolved(row).await
5441            } else {
5442                audit_batch.submit(row).await
5443            };
5444        if let Err(reason) = submit_result {
5445            if is_obligation {
5446                // #2147/#2217: a read verb performs no domain write, so
5447                // when the audit-lane's OWN admission is merely under transient
5448                // pressure (the row was refused before enqueue, or the caller's
5449                // wait deadline elapsed on a row that is still likely to commit),
5450                // failing the read discards a valid result to protect an
5451                // obligation the read never needed as strictly as a write does.
5452                // Any other reason (a definite store/durability failure) still
5453                // fails the dispatch for reads exactly as it does for writes.
5454                //
5455                // The two admission-pressure reasons are not the same fact and
5456                // are counted on separate counters: `QueueAdmissionExhausted`
5457                // never enqueued, so it is a confirmed terminal loss, while
5458                // `AdmissionDeadlineExpired` was already enqueued and may still
5459                // commit later — see `AuditTerminalReason::AdmissionDeadlineExpired`'s
5460                // own doc.
5461                if enqueued_row_outlives_deadline
5462                    && reason == AuditTerminalReason::AdmissionDeadlineExpired
5463                {
5464                    AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS
5465                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5466                    mark_admission_obligation_counter(
5467                        &AUDIT_ADMISSION_UNRESOLVED_OBLIGATIONS_LAST_MS,
5468                    );
5469                    tracing::warn!(
5470                        verb,
5471                        reason = ?reason,
5472                        degrade_allowlisted,
5473                        "audit obligation row was still enqueued and unresolved when \
5474                         the caller's admission wait deadline elapsed; its generation \
5475                         commits it independently of this response. Dispatch reports \
5476                         its own committed result (non-fatal)"
5477                    );
5478                    return Ok(());
5479                }
5480                if admission_degrade_eligible
5481                    && reason == AuditTerminalReason::QueueAdmissionExhausted
5482                {
5483                    AUDIT_ADMISSION_REFUSED_OBLIGATIONS
5484                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5485                    mark_admission_obligation_counter(&AUDIT_ADMISSION_REFUSED_OBLIGATIONS_LAST_MS);
5486                    tracing::warn!(
5487                        verb,
5488                        reason = ?reason,
5489                        "read verb's audit obligation row was refused before \
5490                         enqueue under audit-lane admission pressure; dispatch \
5491                         still reports its own result (non-fatal)"
5492                    );
5493                    return Ok(());
5494                }
5495                AUDIT_OBLIGATION_APPEND_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5496                tracing::error!(
5497                    verb,
5498                    reason = ?reason,
5499                    "audit obligation batch submission failed; failing dispatch"
5500                );
5501                return Err(AuditObligationFailure::new(verb, reason));
5502            }
5503            AUDIT_APPEND_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5504            tracing::warn!(
5505                verb,
5506                reason = ?reason,
5507                "audit event batch submission failed (non-fatal)"
5508            );
5509        }
5510        return Ok(());
5511    }
5512
5513    if let Err(store_err) = store.append_event(event).await {
5514        if is_obligation {
5515            AUDIT_OBLIGATION_APPEND_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5516            tracing::error!(
5517                verb,
5518                error = %store_err,
5519                "audit obligation store write failed; failing dispatch"
5520            );
5521            return Err(AuditObligationFailure::from_store(verb, store_err));
5522        }
5523        AUDIT_APPEND_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5524        tracing::warn!(
5525            verb,
5526            error = %store_err,
5527            "audit event store write failed (non-fatal)"
5528        );
5529    }
5530    Ok(())
5531}
5532
5533/// Fold an audit-obligation outcome into a dispatch result.
5534///
5535/// A dispatch that would otherwise report success cannot claim it once the
5536/// row accounting for it fails to commit (ADR-133 D2/D3/D4), so `Ok` becomes
5537/// the audit's `Err`. A dispatch that already reports failure keeps its
5538/// original error — the obligation is on never reporting a false success,
5539/// not on replacing one error with another.
5540fn fold_audit_obligation<T>(
5541    result: Result<T, RuntimeError>,
5542    audit_outcome: Result<(), AuditObligationFailure>,
5543    domain_value: impl FnOnce(T) -> Value,
5544) -> Result<T, RuntimeError> {
5545    match (result, audit_outcome) {
5546        (Ok(value), Ok(())) => Ok(value),
5547        (Ok(value), Err(failure)) => Err(RuntimeError::AuditObligation {
5548            failure: Box::new(failure),
5549            domain_result: domain_value(value),
5550        }),
5551        (Err(err), _) => Err(err),
5552    }
5553}
5554
5555/// Schema v2 audit payload for a successful singleton `link` call — additive
5556/// over v1 via `#[serde(flatten)]`. See `docs/api/pack.md#linkauditsuccessv2`.
5557#[derive(Debug, Clone, serde::Serialize)]
5558struct LinkAuditSuccessV2 {
5559    #[serde(flatten)]
5560    audit: AuditEvent,
5561    edge_id: uuid::Uuid,
5562    source_id: uuid::Uuid,
5563    target_id: uuid::Uuid,
5564    relation: String,
5565    weight: f64,
5566}
5567
5568/// Extract edge fields to enrich a successful singleton `link` audit row.
5569/// Returns `None` on any missing/malformed field (falls back to v1 shape).
5570/// See `docs/api/pack.md#link_audit_success_from_result`.
5571fn link_audit_success_from_result(
5572    audit: AuditEvent,
5573    result: &serde_json::Value,
5574) -> Option<(uuid::Uuid, serde_json::Value)> {
5575    let edge_id = result.get("id")?.as_str()?.parse::<uuid::Uuid>().ok()?;
5576    let source_id = result
5577        .get("source_id")?
5578        .as_str()?
5579        .parse::<uuid::Uuid>()
5580        .ok()?;
5581    let target_id = result
5582        .get("target_id")?
5583        .as_str()?
5584        .parse::<uuid::Uuid>()
5585        .ok()?;
5586    let relation = result.get("relation")?.as_str()?.to_string();
5587    let weight = result.get("weight")?.as_f64()?;
5588    let enriched = LinkAuditSuccessV2 {
5589        audit,
5590        edge_id,
5591        source_id,
5592        target_id,
5593        relation,
5594        weight,
5595    };
5596    let payload = serde_json::to_value(&enriched).ok()?;
5597    Some((edge_id, payload))
5598}
5599
5600/// Resolve and validate a caller-supplied `namespace` argument the same way
5601/// on every MCP ingress path.
5602///
5603/// - Absent `namespace` key → parse `default_namespace`.
5604/// - Present `namespace: "<string>"` → parse the caller's value.
5605/// - Present non-string `namespace` (null, number, bool, array, object) →
5606///   fail closed with `RuntimeError::InvalidInput`. ADR-018 requires this:
5607///   a malformed explicit value must never be silently coerced to the
5608///   default namespace.
5609///
5610/// Single chokepoint for both `VerbRegistry::dispatch` and the multi-backend
5611/// coordinator intercept — see `docs/api/pack.md#resolve_explicit_namespace`.
5612pub fn resolve_explicit_namespace(
5613    params: &Value,
5614    default_namespace: &str,
5615) -> Result<Namespace, RuntimeError> {
5616    match params.get("namespace") {
5617        None => Namespace::parse(default_namespace)
5618            .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}"))),
5619        Some(Value::String(ns_str)) => Namespace::parse(ns_str)
5620            .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace {ns_str:?}: {e}"))),
5621        Some(other) => Err(RuntimeError::InvalidInput(format!(
5622            "invalid namespace: expected string when present, got {}",
5623            json_type_name(other),
5624        ))),
5625    }
5626}
5627
5628/// JSON type name for error messages: describes a present-but-malformed
5629/// `namespace` value without echoing its contents.
5630pub fn json_type_name(v: &Value) -> &'static str {
5631    match v {
5632        Value::Null => "null",
5633        Value::Bool(_) => "boolean",
5634        Value::Number(_) => "number",
5635        Value::String(_) => "string",
5636        Value::Array(_) => "array",
5637        Value::Object(_) => "object",
5638    }
5639}
5640
5641// INLINE TEST JUSTIFICATION: tests here exercise VerbRegistry collision detection,
5642// gate enforcement, and dispatch ordering that depend on direct access to the
5643// registry's private `packs` Vec and gate field. Moving them to tests/ would
5644// require pub-exporting registry internals. Broad behavioral dispatch tests
5645// live in tests/integration.rs.
5646#[cfg(test)]
5647pub(crate) mod tests {
5648    use super::*;
5649    use crate::ActorRef;
5650    use khive_types::Pack;
5651
5652    mod disposition {
5653        include!("pack_disposition_tests.rs");
5654    }
5655
5656    #[tokio::test]
5657    async fn pack_host_state_shares_the_registered_dispatch_instance_across_clones() {
5658        struct HostStatePack(Arc<AtomicUsize>);
5659
5660        #[async_trait]
5661        impl PackRuntime for HostStatePack {
5662            fn name(&self) -> &str {
5663                "host_state"
5664            }
5665            fn host_state(&self) -> Option<Arc<dyn Any + Send + Sync>> {
5666                Some(self.0.clone())
5667            }
5668            fn note_kinds(&self) -> &'static [&'static str] {
5669                &[]
5670            }
5671            fn entity_kinds(&self) -> &'static [&'static str] {
5672                &[]
5673            }
5674            fn handlers(&self) -> &'static [HandlerDef] {
5675                &[HandlerDef {
5676                    name: "host_state.touch",
5677                    description: "shared state fixture",
5678                    visibility: Visibility::Verb,
5679                    category: VerbCategory::Commissive,
5680                    params: &[],
5681                }]
5682            }
5683            async fn dispatch(
5684                &self,
5685                _verb: &str,
5686                _params: Value,
5687                _registry: &VerbRegistry,
5688                _token: &NamespaceToken,
5689            ) -> Result<Value, RuntimeError> {
5690                self.0.fetch_add(1, Ordering::SeqCst);
5691                Ok(Value::Null)
5692            }
5693        }
5694
5695        let state = Arc::new(AtomicUsize::new(0));
5696        let mut builder = VerbRegistryBuilder::new();
5697        builder.register_boxed(Box::new(HostStatePack(state.clone())));
5698        builder.register(AlphaPack);
5699        let registry = builder.build().expect("registry");
5700        let host = registry
5701            .pack_host_state::<AtomicUsize>("host_state")
5702            .expect("registered state");
5703        let cloned_host = registry
5704            .clone()
5705            .pack_host_state::<AtomicUsize>("host_state")
5706            .expect("cloned registry state");
5707        assert!(Arc::ptr_eq(&state, &host));
5708        assert!(Arc::ptr_eq(&host, &cloned_host));
5709        registry
5710            .dispatch("host_state.touch", serde_json::json!({}))
5711            .await
5712            .expect("dispatch");
5713        assert_eq!(host.load(Ordering::SeqCst), 1);
5714        assert!(registry.pack_host_state::<String>("host_state").is_none());
5715        assert!(registry.pack_host_state::<AtomicUsize>("missing").is_none());
5716        assert!(registry.pack_host_state::<AtomicUsize>("alpha").is_none());
5717    }
5718
5719    /// Verbs known, by cross-pack source review (#2147/#2217), to have
5720    /// durable/accounting side effects or telemetry answers that must refuse
5721    /// when admission cannot record their audit, despite being declared
5722    /// `VerbCategory::Assertive` — see [`VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS`]'s
5723    /// doc for why each is excluded. `VerbCategory::Assertive` alone cannot
5724    /// distinguish these from a genuinely side-effect-free read (that is the
5725    /// whole reason the allowlist exists instead of a bare category check),
5726    /// so this denylist is the mechanizable guard against silently
5727    /// reintroducing one of them: a category-only census would stay green if
5728    /// any name were re-added to the allowlist.
5729    const KNOWN_ADMISSION_UNSAFE_VERBS: &[&str] = &[
5730        "db_diagnostics",
5731        "git.checkout",
5732        "git.diff",
5733        "git.reconcile",
5734        "knowledge.compose",
5735        "knowledge.search",
5736        "knowledge.suggest",
5737        "memory.recall",
5738        "telemetry.channels",
5739        "telemetry.counts",
5740        "telemetry.emit",
5741        "telemetry.read",
5742    ];
5743
5744    /// Classification outcome for one `HandlerDef {` occurrence in pack
5745    /// source, returned by [`classify_handler_def_occurrence`]. `Signature`
5746    /// and `StructLiteral` are the two shapes the live cross-pack census
5747    /// currently expects; `Unclassified` exists so neither the census nor a
5748    /// direct unit test has to rely on a panic to observe a shape that is
5749    /// neither — see `classify_handler_def_occurrence_reports_unclassifiable_shapes`
5750    /// below.
5751    #[derive(Debug, Clone, PartialEq, Eq)]
5752    enum HandlerDefOccurrence {
5753        /// A function/closure signature merely naming the type in
5754        /// return-tail position (`-> &'static HandlerDef {`, possibly
5755        /// qualified), not a declared handler.
5756        Signature,
5757        /// A struct-literal field block whose `name`/`visibility`/`category`
5758        /// fields were all found at one consistent indentation.
5759        StructLiteral {
5760            name: String,
5761            visibility: String,
5762            category: String,
5763        },
5764        /// Neither of the above: not a signature tail, and the block does
5765        /// not parse as a `name`/`visibility`/`category` struct literal at a
5766        /// single consistent indentation either.
5767        Unclassified { first_field_line: String },
5768    }
5769
5770    /// Classify one `HandlerDef {` occurrence at `source[match_start..match_end]`
5771    /// (`match_end` is the byte offset just past the token). Shared by the
5772    /// live cross-pack census
5773    /// (`admission_degrade_safe_assertive_census_matches_live_pack_sources`)
5774    /// and `classify_handler_def_occurrence_reports_unclassifiable_shapes`'s
5775    /// direct unit coverage of the `Unclassified` arm — extracting this as
5776    /// its own function is what makes the negative arm testable without
5777    /// corrupting a real pack source file to trigger it.
5778    fn classify_handler_def_occurrence(
5779        source: &str,
5780        match_start: usize,
5781        match_end: usize,
5782    ) -> HandlerDefOccurrence {
5783        // A struct literal is never preceded on its own line by `->`; a
5784        // signature tail always is.
5785        let line_start = source[..match_start]
5786            .rfind('\n')
5787            .map(|i| i + 1)
5788            .unwrap_or(0);
5789        if source[line_start..match_start].contains("->") {
5790            return HandlerDefOccurrence::Signature;
5791        }
5792
5793        let next_marker = source[match_end..].find("HandlerDef {");
5794        let block_end = next_marker.map(|o| match_end + o).unwrap_or(source.len());
5795        let block = &source[match_end..block_end];
5796
5797        // The field indentation is read from the block's own first line
5798        // rather than hardcoded: array-element declarations (`&[HandlerDef
5799        // {`) indent fields one level deeper than the single-element
5800        // `static X: [HandlerDef; 1] = [HandlerDef {` shape, and a
5801        // hardcoded depth would silently stop matching whichever shape it
5802        // didn't anticipate — exactly how the narrower delimiter this
5803        // replaced went unnoticed.
5804        let Some(first_field_line) = block.lines().find(|line| !line.trim().is_empty()) else {
5805            return HandlerDefOccurrence::Unclassified {
5806                first_field_line: String::new(),
5807            };
5808        };
5809        let indent_len = first_field_line.len() - first_field_line.trim_start().len();
5810        let indent = &first_field_line[..indent_len];
5811        let name_prefix = format!("{indent}name: \"");
5812        let visibility_prefix = format!("{indent}visibility: ");
5813        let category_prefix = format!("{indent}category: ");
5814
5815        let name = block.lines().find_map(|line| {
5816            line.strip_prefix(name_prefix.as_str())
5817                .and_then(|rest| rest.strip_suffix("\","))
5818        });
5819        let visibility = block
5820            .lines()
5821            .find_map(|line| line.strip_prefix(visibility_prefix.as_str()));
5822        let category = block
5823            .lines()
5824            .find_map(|line| line.strip_prefix(category_prefix.as_str()));
5825
5826        match (name, visibility, category) {
5827            (Some(name), Some(visibility), Some(category)) => HandlerDefOccurrence::StructLiteral {
5828                name: name.to_string(),
5829                visibility: visibility.to_string(),
5830                category: category.to_string(),
5831            },
5832            _ => HandlerDefOccurrence::Unclassified {
5833                first_field_line: first_field_line.to_string(),
5834            },
5835        }
5836    }
5837
5838    /// khive-oss#2311: before this fix, the live census's per-file
5839    /// `classified_count == raw_token_count` assertion incremented
5840    /// `classified_count` once per loop iteration — before any
5841    /// classification ran — so it counted exactly the same occurrences
5842    /// `raw_token_count` counts, by the same method, and could never
5843    /// disagree regardless of what the loop body did afterward: a silently
5844    /// dropped classification branch would have stayed green. This proves
5845    /// the replacement — [`classify_handler_def_occurrence`], now called
5846    /// once per occurrence and the sole source of the census's per-branch
5847    /// counters — actually distinguishes an unclassifiable shape from the
5848    /// two shapes the census expects, using a hand-built snippet with a
5849    /// `HandlerDef {` block that is neither a signature tail nor a
5850    /// well-formed struct literal (its `category:` field is missing at the
5851    /// expected indentation).
5852    #[test]
5853    fn classify_handler_def_occurrence_reports_unclassifiable_shapes() {
5854        let signature_snippet = "fn describe() -> &'static HandlerDef {\n    HANDLER\n}\n";
5855        let match_start = signature_snippet.find("HandlerDef {").unwrap();
5856        let match_end = match_start + "HandlerDef {".len();
5857        assert_eq!(
5858            classify_handler_def_occurrence(signature_snippet, match_start, match_end),
5859            HandlerDefOccurrence::Signature
5860        );
5861
5862        let struct_literal_snippet = "        HandlerDef {\n            name: \"probe\",\n            visibility: Visibility::Verb,\n            category: VerbCategory::Assertive,\n        }\n";
5863        let match_start = struct_literal_snippet.find("HandlerDef {").unwrap();
5864        let match_end = match_start + "HandlerDef {".len();
5865        assert_eq!(
5866            classify_handler_def_occurrence(struct_literal_snippet, match_start, match_end),
5867            HandlerDefOccurrence::StructLiteral {
5868                name: "probe".to_string(),
5869                visibility: "Visibility::Verb,".to_string(),
5870                category: "VerbCategory::Assertive,".to_string(),
5871            }
5872        );
5873
5874        // Missing `category:` at the expected indentation: not a signature
5875        // tail (no `->`), and not a parseable struct literal either.
5876        let unclassifiable_snippet = "        HandlerDef {\n            name: \"probe\",\n            visibility: Visibility::Verb,\n        }\n";
5877        let match_start = unclassifiable_snippet.find("HandlerDef {").unwrap();
5878        let match_end = match_start + "HandlerDef {".len();
5879        assert!(
5880            matches!(
5881                classify_handler_def_occurrence(unclassifiable_snippet, match_start, match_end),
5882                HandlerDefOccurrence::Unclassified { .. }
5883            ),
5884            "a HandlerDef block missing an expected field must classify as Unclassified, not \
5885             silently fall through as a recognized shape"
5886        );
5887    }
5888
5889    /// khive-runtime links no real pack crates in its own test binary (see
5890    /// the comment on `CommProbeFactory` below), so
5891    /// [`VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS`] cannot be checked
5892    /// against a live registered `HandlerDef` here. Instead this re-derives
5893    /// the complete public Assertive surface from each owning pack's live
5894    /// source — the same fail-closed pattern as `adr133_writer_census.rs`'s
5895    /// `reclassify_from_live_source`.
5896    ///
5897    /// Every occurrence of the literal `HandlerDef {` token in scanned source
5898    /// is classified into exactly one of: a struct-literal field block, or a
5899    /// function/closure signature merely naming the type
5900    /// (`-> &'static HandlerDef {`) — a per-file count assertion fails
5901    /// closed if any occurrence goes unclassified, so a handler declared in
5902    /// an unanticipated shape (a prior version of this census silently
5903    /// dropped the single-element `static X: [HandlerDef; 1] = [HandlerDef {`
5904    /// shape used by `khive-pack-code` and `khive-pack-template`) cannot
5905    /// drop out of the count without failing the test. This also verifies
5906    /// that each [`VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS`] entry's
5907    /// claimed owning pack matches the pack whose source actually declares
5908    /// that verb.
5909    ///
5910    /// This test proves category membership (`VerbCategory::Assertive`),
5911    /// pack ownership, non-membership in [`KNOWN_ADMISSION_UNSAFE_VERBS`],
5912    /// and exhaustive classification of every currently public Assertive
5913    /// handler. It does NOT prove general effect-purity: an Assertive
5914    /// handler may still emit its own
5915    /// observability/config events on an independent, best-effort background
5916    /// path (`search`'s `SearchExecuted` telemetry, `context`'s one-time
5917    /// `ConfigLocked` event) that this test does not inspect and that this
5918    /// PR's admission-degrade mechanism does not touch — those events commit
5919    /// or fail on their own path regardless of what happens to this
5920    /// dispatch's own audit row. Proving general effect-purity would require
5921    /// an explicit per-handler effect/accounting capability tag, which is
5922    /// out of scope here (see ADR-103 Amendment 3's "why this is accepted"
5923    /// section); this census instead locks down the properties that are
5924    /// mechanizable today: declared category and an exhaustive, reviewed
5925    /// safe-versus-incidental classification.
5926    #[test]
5927    fn admission_degrade_safe_assertive_census_matches_live_pack_sources() {
5928        use std::collections::{BTreeMap, BTreeSet};
5929        use std::path::{Path, PathBuf};
5930
5931        fn collect_rust_sources(dir: &Path, sources: &mut Vec<PathBuf>) {
5932            let entries = std::fs::read_dir(dir).unwrap_or_else(|e| {
5933                panic!("failed to read source directory {}: {e}", dir.display())
5934            });
5935            for entry in entries {
5936                let entry = entry.unwrap_or_else(|e| {
5937                    panic!("failed to read entry under {}: {e}", dir.display())
5938                });
5939                let path = entry.path();
5940                let file_type = entry.file_type().unwrap_or_else(|e| {
5941                    panic!("failed to stat source entry {}: {e}", path.display())
5942                });
5943                if file_type.is_dir() {
5944                    collect_rust_sources(&path, sources);
5945                } else if path.extension().is_some_and(|extension| extension == "rs") {
5946                    sources.push(path);
5947                }
5948            }
5949        }
5950
5951        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
5952        let crates_dir = manifest_dir
5953            .parent()
5954            .expect("khive-runtime manifest must live under the workspace crates directory");
5955        let mut handler_sources = Vec::new();
5956        let crate_entries = std::fs::read_dir(crates_dir).unwrap_or_else(|e| {
5957            panic!(
5958                "failed to enumerate pack crates under {}: {e}",
5959                crates_dir.display()
5960            )
5961        });
5962        for entry in crate_entries {
5963            let entry = entry.unwrap_or_else(|e| {
5964                panic!("failed to read entry under {}: {e}", crates_dir.display())
5965            });
5966            if !entry
5967                .file_type()
5968                .unwrap_or_else(|e| {
5969                    panic!("failed to stat crate entry {}: {e}", entry.path().display())
5970                })
5971                .is_dir()
5972                || !entry
5973                    .file_name()
5974                    .to_string_lossy()
5975                    .starts_with("khive-pack-")
5976            {
5977                continue;
5978            }
5979            collect_rust_sources(&entry.path().join("src"), &mut handler_sources);
5980        }
5981        handler_sources.sort_unstable();
5982        assert!(
5983            !handler_sources.is_empty(),
5984            "cross-pack Assertive census found no pack source files"
5985        );
5986
5987        // verb name -> (owning pack, relative source path)
5988        let mut live_assertive = BTreeMap::<String, (String, String)>::new();
5989        for path in handler_sources {
5990            let relative = path
5991                .strip_prefix(crates_dir)
5992                .expect("pack source must be inside the workspace crates directory");
5993            let relative_path = relative.display().to_string();
5994            let crate_dir_name = relative
5995                .components()
5996                .next()
5997                .map(|c| c.as_os_str().to_string_lossy().into_owned())
5998                .unwrap_or_default();
5999            let owning_pack = crate_dir_name
6000                .strip_prefix("khive-pack-")
6001                .unwrap_or_else(|| {
6002                    panic!("{relative_path}: expected a khive-pack-<name> crate directory")
6003                })
6004                .to_string();
6005            let source = std::fs::read_to_string(&path)
6006                .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
6007
6008            // Every literal occurrence of `HandlerDef {` is classified by
6009            // `classify_handler_def_occurrence` into a struct-literal field
6010            // block, a function/closure signature merely naming the type
6011            // (`-> &'static HandlerDef {`), or `Unclassified`. The count
6012            // assertion below sums the first two — counted only where each
6013            // branch actually fires, not once per occurrence found — against
6014            // `raw_token_count`, computed by an independent method
6015            // (`str::matches`). Unlike comparing two counts of the same
6016            // occurrences by the same method, this sum can fall short: an
6017            // `Unclassified` occurrence increments neither counter, so a
6018            // future shape neither branch recognizes fails this assertion
6019            // instead of silently passing (`classify_handler_def_occurrence_reports_unclassifiable_shapes`
6020            // proves the classifier itself reports `Unclassified` rather
6021            // than mis-slotting such a shape into one of the two branches).
6022            let raw_token_count = source.matches("HandlerDef {").count();
6023            let mut signature_count = 0usize;
6024            let mut struct_literal_count = 0usize;
6025            let mut unclassified: Vec<String> = Vec::new();
6026            let mut search_from = 0usize;
6027            while let Some(rel_pos) = source[search_from..].find("HandlerDef {") {
6028                let match_start = search_from + rel_pos;
6029                let match_end = match_start + "HandlerDef {".len();
6030                search_from = match_end;
6031
6032                match classify_handler_def_occurrence(&source, match_start, match_end) {
6033                    HandlerDefOccurrence::Signature => {
6034                        signature_count += 1;
6035                    }
6036                    HandlerDefOccurrence::StructLiteral {
6037                        name,
6038                        visibility,
6039                        category,
6040                    } => {
6041                        struct_literal_count += 1;
6042                        if !visibility.contains("Visibility::Verb")
6043                            || !category.contains("VerbCategory::Assertive")
6044                        {
6045                            continue;
6046                        }
6047
6048                        let prior = live_assertive
6049                            .insert(name.clone(), (owning_pack.clone(), relative_path.clone()));
6050                        assert!(
6051                            prior.is_none(),
6052                            "public Assertive verb {name:?} is declared in both {prior:?} and \
6053                             ({owning_pack:?}, {relative_path:?}); the registry surface must \
6054                             remain collision-free"
6055                        );
6056                    }
6057                    HandlerDefOccurrence::Unclassified { first_field_line } => {
6058                        unclassified.push(format!(
6059                            "byte {match_start} (first field line {first_field_line:?})"
6060                        ));
6061                    }
6062                }
6063            }
6064            assert_eq!(
6065                signature_count + struct_literal_count,
6066                raw_token_count,
6067                "{relative_path}: found {raw_token_count} occurrences of the `HandlerDef {{` \
6068                 token but classified {signature_count} as signatures and \
6069                 {struct_literal_count} as struct literals; unclassified: {unclassified:?} — \
6070                 extend this census's parser to handle the shape instead of silently excluding it"
6071            );
6072        }
6073
6074        let safe: BTreeSet<(&str, &str)> = VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS
6075            .iter()
6076            .copied()
6077            .collect();
6078        assert_eq!(
6079            safe.len(),
6080            VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS.len(),
6081            "ADMISSION_DEGRADE_SAFE_VERBS contains duplicate (pack, verb) pairs"
6082        );
6083        let safe_verbs: BTreeSet<&str> = safe.iter().map(|&(_, v)| v).collect();
6084        assert_eq!(
6085            safe_verbs.len(),
6086            safe.len(),
6087            "ADMISSION_DEGRADE_SAFE_VERBS names the same verb under two different packs; a verb \
6088             belongs to exactly one pack"
6089        );
6090        for &(pack, verb) in &safe {
6091            let live_owner = live_assertive
6092                .get(verb)
6093                .map(|(owning_pack, _)| owning_pack.as_str());
6094            assert_eq!(
6095                live_owner,
6096                Some(pack),
6097                "ADMISSION_DEGRADE_SAFE_VERBS claims {verb:?} is owned by pack {pack:?}, but its \
6098                 live declaration says otherwise (found: {live_owner:?})"
6099            );
6100        }
6101        let incidental: BTreeSet<&str> = KNOWN_ADMISSION_UNSAFE_VERBS.iter().copied().collect();
6102        assert!(
6103            safe_verbs.is_disjoint(&incidental),
6104            "a public Assertive verb cannot be both admission-degrade-safe and admission-unsafe: {:?}",
6105            safe_verbs.intersection(&incidental).collect::<Vec<_>>()
6106        );
6107
6108        let classified: BTreeSet<&str> = safe_verbs.union(&incidental).copied().collect();
6109        let live: BTreeSet<&str> = live_assertive.keys().map(String::as_str).collect();
6110        assert_eq!(
6111            classified, live,
6112            "every public Assertive handler must be classified exactly once after a live-source \
6113             effect review; live declarations: {live_assertive:#?}"
6114        );
6115    }
6116
6117    /// A pack whose `handlers()` counts every call, so a test can prove a
6118    /// query touches (or does not touch) it after `VerbRegistryBuilder::build`
6119    /// has already run once over every registered pack's handler list.
6120    struct CountingHandlersPack {
6121        name: &'static str,
6122        handlers: &'static [HandlerDef],
6123        calls: Arc<AtomicUsize>,
6124    }
6125
6126    impl Pack for CountingHandlersPack {
6127        const NAME: &'static str = "counting";
6128        const NOTE_KINDS: &'static [&'static str] = &[];
6129        const ENTITY_KINDS: &'static [&'static str] = &[];
6130        const HANDLERS: &'static [HandlerDef] = &[];
6131    }
6132
6133    #[async_trait]
6134    impl PackRuntime for CountingHandlersPack {
6135        fn name(&self) -> &str {
6136            self.name
6137        }
6138        fn note_kinds(&self) -> &'static [&'static str] {
6139            &[]
6140        }
6141        fn entity_kinds(&self) -> &'static [&'static str] {
6142            &[]
6143        }
6144        fn handlers(&self) -> &'static [HandlerDef] {
6145            self.calls.fetch_add(1, Ordering::SeqCst);
6146            self.handlers
6147        }
6148        async fn dispatch(
6149            &self,
6150            verb: &str,
6151            _params: Value,
6152            _registry: &VerbRegistry,
6153            _token: &NamespaceToken,
6154        ) -> Result<Value, RuntimeError> {
6155            Ok(serde_json::json!({ "pack": self.name, "verb": verb }))
6156        }
6157    }
6158
6159    /// khive-oss#2311: before this fix, `admission_degrade_safe` resolved
6160    /// the owning pack by scanning every registered pack's `handlers()` on
6161    /// every audited dispatch (`self.packs.iter().find_map(|pack|
6162    /// pack.handlers().iter().find(...))`). Eligibility is now decided once
6163    /// in `VerbRegistryBuilder::build` into `VerbRegistry::degrade_safe_verbs`,
6164    /// so `admission_degrade_safe` is a hash-set lookup that never touches
6165    /// `handlers()` again. Proves it directly: `handlers()` is called some
6166    /// number of times during `build()` (unique-name validation, the
6167    /// reserved-envelope-arg check, `available_verbs`, and this
6168    /// eligibility precompute all read it), but that count must not move
6169    /// across any number of `admission_degrade_safe_probe` calls afterward
6170    /// — for an allowlisted verb (a hit) and for one that is not (a miss).
6171    #[test]
6172    fn admission_degrade_safe_is_a_build_time_lookup_with_no_per_call_pack_scan() {
6173        static KG_HANDLERS: [HandlerDef; 1] = [HandlerDef {
6174            name: "list",
6175            description: "list widgets",
6176            visibility: Visibility::Verb,
6177            category: VerbCategory::Assertive,
6178            params: &[],
6179        }];
6180
6181        let calls = Arc::new(AtomicUsize::new(0));
6182        let mut builder = VerbRegistryBuilder::new();
6183        builder.register_trusted(CountingHandlersPack {
6184            name: "kg",
6185            handlers: &KG_HANDLERS,
6186            calls: calls.clone(),
6187        });
6188        let registry = builder.build().expect("registry builds");
6189
6190        let after_build = calls.load(Ordering::SeqCst);
6191        assert!(
6192            after_build > 0,
6193            "build() is expected to read handlers() at least once (unique-name validation, \
6194             available_verbs, and the degrade-safe precompute all do); a count of 0 means this \
6195             test's premise (build-time reads happen) is wrong, not that the property under \
6196             test holds"
6197        );
6198
6199        assert!(
6200            registry.admission_degrade_safe_probe("list"),
6201            "\"list\" is Assertive and (\"kg\", \"list\") is allowlisted under trusted \
6202             registration, so this must be a hit"
6203        );
6204        assert_eq!(
6205            calls.load(Ordering::SeqCst),
6206            after_build,
6207            "a hit must not re-scan any pack's handlers() — eligibility was already decided at \
6208             build() time"
6209        );
6210
6211        assert!(
6212            !registry.admission_degrade_safe_probe("not-a-real-verb"),
6213            "an unregistered verb name is never eligible"
6214        );
6215        assert_eq!(
6216            calls.load(Ordering::SeqCst),
6217            after_build,
6218            "a miss must not re-scan any pack's handlers() either"
6219        );
6220    }
6221
6222    #[test]
6223    fn read_replay_requires_trusted_owning_pack_for_every_opted_in_verb() {
6224        static HANDLERS: [HandlerDef; 5] = [
6225            HandlerDef {
6226                name: "stats",
6227                description: "replay eligibility fixture",
6228                visibility: Visibility::Verb,
6229                category: VerbCategory::Assertive,
6230                params: &[],
6231            },
6232            HandlerDef {
6233                name: "comm.thread",
6234                description: "replay eligibility fixture",
6235                visibility: Visibility::Verb,
6236                category: VerbCategory::Assertive,
6237                params: &[],
6238            },
6239            HandlerDef {
6240                name: "comm.inbox",
6241                description: "replay eligibility fixture",
6242                visibility: Visibility::Verb,
6243                category: VerbCategory::Assertive,
6244                params: &[],
6245            },
6246            HandlerDef {
6247                name: "comm.unread",
6248                description: "replay eligibility fixture",
6249                visibility: Visibility::Verb,
6250                category: VerbCategory::Assertive,
6251                params: &[],
6252            },
6253            HandlerDef {
6254                name: "comm.delivered",
6255                description: "replay eligibility fixture",
6256                visibility: Visibility::Verb,
6257                category: VerbCategory::Assertive,
6258                params: &[],
6259            },
6260        ];
6261
6262        for (owner, handlers) in [("kg", &HANDLERS[..1]), ("comm", &HANDLERS[1..])] {
6263            for (name, trusted, expected) in [
6264                (owner, true, true),
6265                (owner, false, false),
6266                ("custom-impostor", true, false),
6267            ] {
6268                let mut builder = VerbRegistryBuilder::new();
6269                let pack = CountingHandlersPack {
6270                    name,
6271                    handlers,
6272                    calls: Arc::new(AtomicUsize::new(0)),
6273                };
6274                if trusted {
6275                    builder.register_trusted(pack);
6276                } else {
6277                    builder.register(pack);
6278                }
6279                let registry = builder.build().expect("replay fixture registry");
6280                for handler in handlers {
6281                    assert_eq!(
6282                        registry.is_read_replay_safe(handler.name),
6283                        expected,
6284                        "verb={}, owner={name}, trusted={trusted}",
6285                        handler.name,
6286                    );
6287                }
6288                assert!(!registry.is_read_replay_safe("unknown.read"));
6289            }
6290        }
6291    }
6292
6293    #[test]
6294    fn read_replay_excludes_reads_with_fresh_persisted_serve_or_search_rows() {
6295        for (owner, verb) in [("memory", "memory.recall"), ("kg", "search")] {
6296            let handler = Box::leak(Box::new([HandlerDef {
6297                name: verb,
6298                description: "side-effecting replay fixture",
6299                visibility: Visibility::Verb,
6300                category: VerbCategory::Assertive,
6301                params: &[],
6302            }]));
6303            let mut builder = VerbRegistryBuilder::new();
6304            builder.register_trusted(CountingHandlersPack {
6305                name: owner,
6306                handlers: handler,
6307                calls: Arc::new(AtomicUsize::new(0)),
6308            });
6309            let registry = builder.build().expect("side-effecting read fixture");
6310            assert!(!registry.is_read_replay_safe(verb), "{verb}");
6311        }
6312    }
6313
6314    #[test]
6315    fn read_replay_uses_effect_classification_instead_of_speech_act_category() {
6316        static HANDLERS: [HandlerDef; 3] = [
6317            HandlerDef {
6318                name: "stats",
6319                description: "category cannot override the reviewed operation effects",
6320                visibility: Visibility::Verb,
6321                category: VerbCategory::Commissive,
6322                params: &[],
6323            },
6324            HandlerDef {
6325                name: "create",
6326                description: "an Assertive category cannot make a Write replayable",
6327                visibility: Visibility::Verb,
6328                category: VerbCategory::Assertive,
6329                params: &[],
6330            },
6331            HandlerDef {
6332                name: "unclassified_read",
6333                description: "an unknown operation remains ineligible",
6334                visibility: Visibility::Verb,
6335                category: VerbCategory::Assertive,
6336                params: &[],
6337            },
6338        ];
6339        let mut builder = VerbRegistryBuilder::new();
6340        builder.register_trusted(CountingHandlersPack {
6341            name: "kg",
6342            handlers: &HANDLERS,
6343            calls: Arc::new(AtomicUsize::new(0)),
6344        });
6345        let registry = builder.build().expect("mutating fixture registry");
6346        assert!(registry.is_read_replay_safe("stats"));
6347        assert!(!registry.is_read_replay_safe("create"));
6348        assert!(!registry.is_read_replay_safe("unclassified_read"));
6349    }
6350
6351    /// Re-derives each [`VerbRegistry::SIDE_EFFECTING_ASSERTIVE_VERBS`] entry's
6352    /// classification from its owning pack's live source, the same
6353    /// fail-closed pattern as `admission_degrade_safe_verbs_are_registered_assertive`
6354    /// above: a category-only census would stay green even if a verb here
6355    /// were quietly dropped to a different category, leaving
6356    /// `is_retry_safe_after_frame_omission`'s exclusion pointed at a name
6357    /// the category check would already exclude on its own — silently
6358    /// removing test coverage for the exclusion list without anyone
6359    /// noticing.
6360    #[test]
6361    fn side_effecting_assertive_verbs_are_registered_assertive() {
6362        let sources: &[(&str, &str)] = &[
6363            ("search", "/../khive-pack-kg/src/handler_defs.rs"),
6364            ("memory.recall", "/../khive-pack-memory/src/pack.rs"),
6365            ("telemetry.emit", "/../khive-pack-telemetry/src/pack.rs"),
6366        ];
6367        assert_eq!(
6368            sources.len(),
6369            VerbRegistry::SIDE_EFFECTING_ASSERTIVE_VERBS.len(),
6370            "every entry in SIDE_EFFECTING_ASSERTIVE_VERBS needs a source-file mapping in \
6371             this census, or a newly added verb would go unchecked"
6372        );
6373        for (verb, rel_path) in sources {
6374            assert!(
6375                VerbRegistry::SIDE_EFFECTING_ASSERTIVE_VERBS.contains(verb),
6376                "census source table lists {verb:?}, which is missing from \
6377                 SIDE_EFFECTING_ASSERTIVE_VERBS; keep the table and the list in sync"
6378            );
6379            let path = format!("{}{rel_path}", env!("CARGO_MANIFEST_DIR"));
6380            let source = std::fs::read_to_string(&path)
6381                .unwrap_or_else(|e| panic!("failed to read {path}: {e}"));
6382            let needle = format!("\n        name: \"{verb}\",");
6383            let name_pos = source.find(&needle).unwrap_or_else(|| {
6384                panic!(
6385                    "side-effecting-assertive verb {verb:?} has no top-level `HandlerDef` \
6386                     in {path}; update SIDE_EFFECTING_ASSERTIVE_VERBS's source table or \
6387                     this census"
6388                )
6389            });
6390            let block_end = source[name_pos..]
6391                .find("HandlerDef {")
6392                .map(|offset| name_pos + offset)
6393                .unwrap_or(source.len());
6394            let block = &source[name_pos..block_end];
6395            assert!(
6396                block.contains("VerbCategory::Assertive"),
6397                "side-effecting-assertive verb {verb:?} is declared in {path} but is not \
6398                 VerbCategory::Assertive; is_retry_safe_after_frame_omission's exclusion \
6399                 list only needs to cover verbs the category check would otherwise wave \
6400                 through"
6401            );
6402        }
6403    }
6404
6405    static COMM_PROBE_GRANTED: std::sync::atomic::AtomicBool =
6406        std::sync::atomic::AtomicBool::new(false);
6407    static OTHER_PROBE_GRANTED: std::sync::atomic::AtomicBool =
6408        std::sync::atomic::AtomicBool::new(false);
6409
6410    /// Probe factories proving the channel-ingest grant is name-bounded.
6411    /// khive-runtime's own test binary links no real pack crates, so the
6412    /// `comm` name is free for the probe here.
6413    struct CommProbeFactory;
6414    struct OtherProbeFactory;
6415    struct AccidentalZeroVerbFactory;
6416
6417    fn probe_pack(
6418        _runtime: KhiveRuntime,
6419        grant_flag: &'static std::sync::atomic::AtomicBool,
6420    ) -> Box<dyn PackRuntime> {
6421        struct ProbePack {
6422            grant_flag: &'static std::sync::atomic::AtomicBool,
6423        }
6424        #[async_trait::async_trait]
6425        impl PackRuntime for ProbePack {
6426            fn name(&self) -> &str {
6427                "probe"
6428            }
6429            fn note_kinds(&self) -> &'static [&'static str] {
6430                &[]
6431            }
6432            fn entity_kinds(&self) -> &'static [&'static str] {
6433                &[]
6434            }
6435            fn handlers(&self) -> &'static [HandlerDef] {
6436                &[]
6437            }
6438            fn accept_channel_ingest_capability(&self, _capability: ChannelIngestCapability) {
6439                self.grant_flag
6440                    .store(true, std::sync::atomic::Ordering::SeqCst);
6441            }
6442            async fn dispatch(
6443                &self,
6444                _verb: &str,
6445                _params: serde_json::Value,
6446                _registry: &VerbRegistry,
6447                _token: &NamespaceToken,
6448            ) -> Result<serde_json::Value, crate::RuntimeError> {
6449                Err(crate::RuntimeError::InvalidInput("probe".into()))
6450            }
6451        }
6452        Box::new(ProbePack { grant_flag })
6453    }
6454
6455    impl PackFactory for CommProbeFactory {
6456        fn name(&self) -> &'static str {
6457            "comm"
6458        }
6459        fn intentionally_verbless(&self) -> bool {
6460            true
6461        }
6462        fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime> {
6463            probe_pack(runtime, &COMM_PROBE_GRANTED)
6464        }
6465    }
6466
6467    impl PackFactory for OtherProbeFactory {
6468        fn name(&self) -> &'static str {
6469            "grant-probe-other"
6470        }
6471        fn intentionally_verbless(&self) -> bool {
6472            true
6473        }
6474        fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime> {
6475            probe_pack(runtime, &OTHER_PROBE_GRANTED)
6476        }
6477    }
6478
6479    impl PackFactory for AccidentalZeroVerbFactory {
6480        fn name(&self) -> &'static str {
6481            "accidental-zero-verb"
6482        }
6483        fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime> {
6484            probe_pack(runtime, &OTHER_PROBE_GRANTED)
6485        }
6486    }
6487
6488    inventory::submit! { PackRegistration(&CommProbeFactory) }
6489    inventory::submit! { PackRegistration(&OtherProbeFactory) }
6490    inventory::submit! { PackRegistration(&AccidentalZeroVerbFactory) }
6491
6492    #[test]
6493    fn channel_ingest_grant_reaches_only_allowlisted_pack_names() {
6494        let runtime = KhiveRuntime::memory().unwrap();
6495        let mut builder = VerbRegistryBuilder::new();
6496        PackRegistry::register_packs(
6497            &["comm".to_string(), "grant-probe-other".to_string()],
6498            runtime,
6499            &mut builder,
6500        )
6501        .expect("probe registration succeeds");
6502        assert!(
6503            COMM_PROBE_GRANTED.load(std::sync::atomic::Ordering::SeqCst),
6504            "the comm-named factory must receive the channel-ingest grant"
6505        );
6506        assert!(
6507            !OTHER_PROBE_GRANTED.load(std::sync::atomic::Ordering::SeqCst),
6508            "a factory outside CHANNEL_INGEST_CAPABLE_PACKS must never be granted"
6509        );
6510    }
6511
6512    #[test]
6513    fn declared_zero_verb_pack_requires_explicit_intent_metadata() {
6514        let runtime = KhiveRuntime::memory().unwrap();
6515        let mut builder = VerbRegistryBuilder::new();
6516        let error = PackRegistry::register_packs(
6517            &["accidental-zero-verb".to_string()],
6518            runtime,
6519            &mut builder,
6520        )
6521        .expect_err("an unmarked zero-verb pack must fail registration");
6522
6523        assert!(matches!(
6524            error,
6525            PackLoadError::NoPublicVerbs { ref pack } if pack == "accidental-zero-verb"
6526        ));
6527        assert!(
6528            error
6529                .to_string()
6530                .contains("intentionally_verbless() = true"),
6531            "operator error must name the explicit exemption: {error}"
6532        );
6533    }
6534
6535    #[test]
6536    fn multi_backend_loader_enforces_zero_verb_intent_metadata() {
6537        let runtime = KhiveRuntime::memory().unwrap();
6538        let mut builder = VerbRegistryBuilder::new();
6539        let error = PackRegistry::register_packs_with_runtimes(
6540            &["accidental-zero-verb".to_string()],
6541            &HashMap::new(),
6542            &runtime,
6543            &mut builder,
6544        )
6545        .expect_err("multi-backend registration must enforce the same invariant");
6546
6547        assert!(matches!(
6548            error,
6549            PackLoadError::NoPublicVerbs { ref pack } if pack == "accidental-zero-verb"
6550        ));
6551    }
6552
6553    #[test]
6554    fn from_token_preserves_process_ref() {
6555        let with_ref = NamespaceToken::mint_authorized(
6556            Namespace::local(),
6557            ActorRef::new("agent", "provenance-carrier"),
6558        )
6559        .with_process_ref(Some("proc:origin-abc123".to_string()));
6560        let identity = RequestIdentity::from_token(&with_ref);
6561        assert_eq!(identity.process_ref.as_deref(), Some("proc:origin-abc123"));
6562
6563        let without_ref = NamespaceToken::mint_authorized(
6564            Namespace::local(),
6565            ActorRef::new("agent", "provenance-absent"),
6566        );
6567        let identity = RequestIdentity::from_token(&without_ref);
6568        assert_eq!(identity.process_ref, None);
6569    }
6570
6571    struct AlphaPack;
6572
6573    impl Pack for AlphaPack {
6574        const NAME: &'static str = "alpha";
6575        const NOTE_KINDS: &'static [&'static str] = &["memo", "log"];
6576        const ENTITY_KINDS: &'static [&'static str] = &["widget"];
6577        const BRAIN_CONSUMER_KINDS: &'static [&'static str] = &["recall", "search"];
6578        const HANDLERS: &'static [HandlerDef] = &[
6579            HandlerDef {
6580                name: "create",
6581                description: "create a widget",
6582                visibility: Visibility::Verb,
6583                category: VerbCategory::Commissive,
6584                params: &[],
6585            },
6586            HandlerDef {
6587                name: "list",
6588                description: "list widgets",
6589                visibility: Visibility::Verb,
6590                category: VerbCategory::Assertive,
6591                params: &[],
6592            },
6593        ];
6594    }
6595
6596    #[async_trait]
6597    impl PackRuntime for AlphaPack {
6598        fn name(&self) -> &str {
6599            AlphaPack::NAME
6600        }
6601        fn note_kinds(&self) -> &'static [&'static str] {
6602            AlphaPack::NOTE_KINDS
6603        }
6604        fn entity_kinds(&self) -> &'static [&'static str] {
6605            AlphaPack::ENTITY_KINDS
6606        }
6607        fn brain_consumer_kinds(&self) -> &'static [&'static str] {
6608            AlphaPack::BRAIN_CONSUMER_KINDS
6609        }
6610        fn handlers(&self) -> &'static [HandlerDef] {
6611            AlphaPack::HANDLERS
6612        }
6613        async fn dispatch(
6614            &self,
6615            verb: &str,
6616            _params: Value,
6617            _registry: &VerbRegistry,
6618            _token: &NamespaceToken,
6619        ) -> Result<Value, RuntimeError> {
6620            Ok(serde_json::json!({ "pack": "alpha", "verb": verb }))
6621        }
6622    }
6623
6624    #[derive(Debug)]
6625    struct GateErrorTrackingPack {
6626        invoked: Arc<AtomicUsize>,
6627    }
6628
6629    impl Pack for GateErrorTrackingPack {
6630        const NAME: &'static str = "gate_error_tracking";
6631        const NOTE_KINDS: &'static [&'static str] = &[];
6632        const ENTITY_KINDS: &'static [&'static str] = &[];
6633        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
6634            name: "guarded",
6635            description: "track whether gate-error dispatch reaches the handler",
6636            visibility: Visibility::Verb,
6637            category: VerbCategory::Assertive,
6638            params: &[],
6639        }];
6640    }
6641
6642    #[async_trait]
6643    impl PackRuntime for GateErrorTrackingPack {
6644        fn name(&self) -> &str {
6645            Self::NAME
6646        }
6647
6648        fn note_kinds(&self) -> &'static [&'static str] {
6649            Self::NOTE_KINDS
6650        }
6651
6652        fn entity_kinds(&self) -> &'static [&'static str] {
6653            Self::ENTITY_KINDS
6654        }
6655
6656        fn handlers(&self) -> &'static [HandlerDef] {
6657            Self::HANDLERS
6658        }
6659
6660        async fn dispatch(
6661            &self,
6662            _verb: &str,
6663            _params: Value,
6664            _registry: &VerbRegistry,
6665            _token: &NamespaceToken,
6666        ) -> Result<Value, RuntimeError> {
6667            self.invoked.fetch_add(1, Ordering::SeqCst);
6668            Ok(serde_json::json!({"invoked": true}))
6669        }
6670    }
6671
6672    /// A pack whose `dispatch` sleeps for a fixed, generous duration so
6673    /// `duration_us` regression tests (ADR-103 Stage 1) have a reliably
6674    /// nonzero, non-flaky measured dispatch time to assert against.
6675    struct SleepingPack;
6676
6677    impl Pack for SleepingPack {
6678        const NAME: &'static str = "sleeping";
6679        const NOTE_KINDS: &'static [&'static str] = &[];
6680        const ENTITY_KINDS: &'static [&'static str] = &[];
6681        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
6682            name: "slow_op",
6683            description: "sleeps before returning",
6684            visibility: Visibility::Verb,
6685            category: VerbCategory::Assertive,
6686            params: &[],
6687        }];
6688    }
6689
6690    #[async_trait]
6691    impl PackRuntime for SleepingPack {
6692        fn name(&self) -> &str {
6693            SleepingPack::NAME
6694        }
6695        fn note_kinds(&self) -> &'static [&'static str] {
6696            SleepingPack::NOTE_KINDS
6697        }
6698        fn entity_kinds(&self) -> &'static [&'static str] {
6699            SleepingPack::ENTITY_KINDS
6700        }
6701        fn handlers(&self) -> &'static [HandlerDef] {
6702            SleepingPack::HANDLERS
6703        }
6704        async fn dispatch(
6705            &self,
6706            verb: &str,
6707            _params: Value,
6708            _registry: &VerbRegistry,
6709            _token: &NamespaceToken,
6710        ) -> Result<Value, RuntimeError> {
6711            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
6712            Ok(serde_json::json!({ "pack": "sleeping", "verb": verb }))
6713        }
6714    }
6715
6716    struct BetaPack;
6717
6718    impl Pack for BetaPack {
6719        const NAME: &'static str = "beta";
6720        const NOTE_KINDS: &'static [&'static str] = &["alert"];
6721        const ENTITY_KINDS: &'static [&'static str] = &["widget", "gadget"];
6722        const BRAIN_CONSUMER_KINDS: &'static [&'static str] = &["search", "knowledge_compose"];
6723        const HANDLERS: &'static [HandlerDef] = &[
6724            HandlerDef {
6725                name: "notify",
6726                description: "send alert",
6727                visibility: Visibility::Verb,
6728                category: VerbCategory::Commissive,
6729                params: &[],
6730            },
6731            // "create" is Subhandler so it does NOT collide with AlphaPack's
6732            // Verb-visibility "create" — subhandlers are pack-internal and
6733            // excluded from cross-pack collision detection.
6734            HandlerDef {
6735                name: "create",
6736                description: "beta internal create (subhandler)",
6737                visibility: Visibility::Subhandler,
6738                category: VerbCategory::Commissive,
6739                params: &[],
6740            },
6741        ];
6742    }
6743
6744    /// Build a registry with AlphaPack + BetaPack.
6745    ///
6746    /// BetaPack's `create` is Subhandler so there is no Verb-visibility
6747    /// collision with AlphaPack's `create` Verb. Tests that need a collision
6748    /// use `build_colliding_registry()` instead.
6749    fn build_registry() -> VerbRegistry {
6750        let mut builder = VerbRegistryBuilder::new();
6751        builder.register(AlphaPack);
6752        builder.register(BetaPack);
6753        builder.build().expect("registry builds without collision")
6754    }
6755
6756    /// Build a registry with two packs that declare the same Verb-visibility
6757    /// handler — used to test that `VerbCollision` is raised at build time.
6758    struct CollidingPack;
6759
6760    impl Pack for CollidingPack {
6761        const NAME: &'static str = "colliding";
6762        const NOTE_KINDS: &'static [&'static str] = &[];
6763        const ENTITY_KINDS: &'static [&'static str] = &[];
6764        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
6765            name: "create",
6766            description: "duplicate Verb-visibility create",
6767            visibility: Visibility::Verb,
6768            category: VerbCategory::Commissive,
6769            params: &[],
6770        }];
6771    }
6772
6773    #[async_trait]
6774    impl PackRuntime for CollidingPack {
6775        fn name(&self) -> &str {
6776            Self::NAME
6777        }
6778        fn note_kinds(&self) -> &'static [&'static str] {
6779            Self::NOTE_KINDS
6780        }
6781        fn entity_kinds(&self) -> &'static [&'static str] {
6782            Self::ENTITY_KINDS
6783        }
6784        fn handlers(&self) -> &'static [HandlerDef] {
6785            Self::HANDLERS
6786        }
6787        async fn dispatch(
6788            &self,
6789            verb: &str,
6790            _params: Value,
6791            _registry: &VerbRegistry,
6792            _token: &NamespaceToken,
6793        ) -> Result<Value, RuntimeError> {
6794            Ok(serde_json::json!({ "pack": "colliding", "verb": verb }))
6795        }
6796    }
6797
6798    struct ReservedEnvelopeParamPack;
6799
6800    impl Pack for ReservedEnvelopeParamPack {
6801        const NAME: &'static str = "reserved-envelope-param";
6802        const NOTE_KINDS: &'static [&'static str] = &[];
6803        const ENTITY_KINDS: &'static [&'static str] = &[];
6804        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
6805            name: "broken.serve",
6806            description: "declares a transport-owned argument",
6807            visibility: Visibility::Verb,
6808            category: VerbCategory::Commissive,
6809            params: &[ParamDef {
6810                name: "presentation",
6811                param_type: "object",
6812                required: false,
6813                description: "invalid collision with the request envelope",
6814                resolution_mode: IdResolutionMode::NotApplicable,
6815            }],
6816        }];
6817    }
6818
6819    #[async_trait]
6820    impl PackRuntime for ReservedEnvelopeParamPack {
6821        fn name(&self) -> &str {
6822            Self::NAME
6823        }
6824        fn note_kinds(&self) -> &'static [&'static str] {
6825            Self::NOTE_KINDS
6826        }
6827        fn entity_kinds(&self) -> &'static [&'static str] {
6828            Self::ENTITY_KINDS
6829        }
6830        fn handlers(&self) -> &'static [HandlerDef] {
6831            Self::HANDLERS
6832        }
6833        async fn dispatch(
6834            &self,
6835            _verb: &str,
6836            _params: Value,
6837            _registry: &VerbRegistry,
6838            _token: &NamespaceToken,
6839        ) -> Result<Value, RuntimeError> {
6840            unreachable!("invalid handler metadata must fail before dispatch")
6841        }
6842    }
6843
6844    #[async_trait]
6845    impl PackRuntime for BetaPack {
6846        fn name(&self) -> &str {
6847            BetaPack::NAME
6848        }
6849        fn note_kinds(&self) -> &'static [&'static str] {
6850            BetaPack::NOTE_KINDS
6851        }
6852        fn entity_kinds(&self) -> &'static [&'static str] {
6853            BetaPack::ENTITY_KINDS
6854        }
6855        fn brain_consumer_kinds(&self) -> &'static [&'static str] {
6856            BetaPack::BRAIN_CONSUMER_KINDS
6857        }
6858        fn handlers(&self) -> &'static [HandlerDef] {
6859            BetaPack::HANDLERS
6860        }
6861        async fn dispatch(
6862            &self,
6863            verb: &str,
6864            _params: Value,
6865            _registry: &VerbRegistry,
6866            _token: &NamespaceToken,
6867        ) -> Result<Value, RuntimeError> {
6868            Ok(serde_json::json!({ "pack": "beta", "verb": verb }))
6869        }
6870    }
6871
6872    #[tokio::test]
6873    async fn dispatch_routes_to_correct_pack() {
6874        let reg = build_registry();
6875
6876        let res = reg.dispatch("list", Value::Null).await.unwrap();
6877        assert_eq!(res["pack"], "alpha");
6878
6879        let res = reg.dispatch("notify", Value::Null).await.unwrap();
6880        assert_eq!(res["pack"], "beta");
6881    }
6882
6883    /// Two packs declaring the same `Visibility::Verb` handler must be
6884    /// rejected at build time — the old "first registered wins" behaviour is
6885    /// replaced by a boot error.
6886    #[test]
6887    fn verb_collision_is_boot_time_error() {
6888        let mut builder = VerbRegistryBuilder::new();
6889        builder.register(AlphaPack);
6890        builder.register(CollidingPack);
6891        let err = builder
6892            .build()
6893            .err()
6894            .expect("duplicate Verb-visibility handler must be rejected at build time");
6895        assert!(
6896            matches!(err, RuntimeError::VerbCollision { ref verb, .. } if verb == "create"),
6897            "expected VerbCollision for 'create', got {err:?}"
6898        );
6899        let msg = err.to_string();
6900        assert!(
6901            msg.contains("create"),
6902            "error must name the colliding verb: {msg}"
6903        );
6904        assert!(
6905            msg.contains("alpha") || msg.contains("colliding"),
6906            "error must name one of the conflicting packs: {msg}"
6907        );
6908    }
6909
6910    #[test]
6911    fn reserved_request_envelope_param_is_boot_time_error() {
6912        let mut builder = VerbRegistryBuilder::new();
6913        builder.register(ReservedEnvelopeParamPack);
6914        let error = builder
6915            .build()
6916            .err()
6917            .expect("transport-owned parameter names must fail registry construction");
6918        assert!(
6919            matches!(
6920                error,
6921                RuntimeError::ReservedEnvelopeParam {
6922                    ref pack,
6923                    ref verb,
6924                    ref param,
6925                } if pack == "reserved-envelope-param"
6926                    && verb == "broken.serve"
6927                    && param == "presentation"
6928            ),
6929            "unexpected error: {error:?}"
6930        );
6931    }
6932
6933    #[test]
6934    fn reserved_request_envelope_param_is_boot_time_error_for_subhandler() {
6935        struct ReservedEnvelopeSubhandlerParamPack;
6936
6937        impl Pack for ReservedEnvelopeSubhandlerParamPack {
6938            const NAME: &'static str = "reserved-envelope-subhandler-param";
6939            const NOTE_KINDS: &'static [&'static str] = &[];
6940            const ENTITY_KINDS: &'static [&'static str] = &[];
6941            const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
6942                name: "broken.internal",
6943                description: "declares a transport-owned argument on an internal handler",
6944                visibility: Visibility::Subhandler,
6945                category: VerbCategory::Assertive,
6946                params: &[ParamDef {
6947                    name: "presentation_per_op",
6948                    param_type: "string",
6949                    required: false,
6950                    description: "invalid collision with the request envelope",
6951                    resolution_mode: IdResolutionMode::NotApplicable,
6952                }],
6953            }];
6954        }
6955
6956        #[async_trait]
6957        impl PackRuntime for ReservedEnvelopeSubhandlerParamPack {
6958            fn name(&self) -> &str {
6959                Self::NAME
6960            }
6961            fn note_kinds(&self) -> &'static [&'static str] {
6962                Self::NOTE_KINDS
6963            }
6964            fn entity_kinds(&self) -> &'static [&'static str] {
6965                Self::ENTITY_KINDS
6966            }
6967            fn handlers(&self) -> &'static [HandlerDef] {
6968                Self::HANDLERS
6969            }
6970            async fn dispatch(
6971                &self,
6972                _verb: &str,
6973                _params: Value,
6974                _registry: &VerbRegistry,
6975                _token: &NamespaceToken,
6976            ) -> Result<Value, RuntimeError> {
6977                unreachable!("invalid handler metadata must fail before dispatch")
6978            }
6979        }
6980
6981        let mut builder = VerbRegistryBuilder::new();
6982        builder.register(ReservedEnvelopeSubhandlerParamPack);
6983        let error = builder
6984            .build()
6985            .err()
6986            .expect("transport-owned parameter names must fail registry construction");
6987        assert!(
6988            matches!(
6989                error,
6990                RuntimeError::ReservedEnvelopeParam {
6991                    ref pack,
6992                    ref verb,
6993                    ref param,
6994                } if pack == "reserved-envelope-subhandler-param"
6995                    && verb == "broken.internal"
6996                    && param == "presentation_per_op"
6997            ),
6998            "unexpected error: {error:?}"
6999        );
7000    }
7001
7002    /// Subhandler-visibility handlers with the same name across packs are NOT
7003    /// a collision — they are pack-internal and excluded from cross-pack
7004    /// collision detection.
7005    #[test]
7006    fn subhandler_same_name_across_packs_is_not_a_collision() {
7007        struct SubhandlerPack;
7008        impl Pack for SubhandlerPack {
7009            const NAME: &'static str = "subhandler_pack";
7010            const NOTE_KINDS: &'static [&'static str] = &[];
7011            const ENTITY_KINDS: &'static [&'static str] = &[];
7012            const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
7013                name: "create",
7014                description: "internal create",
7015                visibility: Visibility::Subhandler,
7016                category: VerbCategory::Commissive,
7017                params: &[],
7018            }];
7019        }
7020        #[async_trait]
7021        impl PackRuntime for SubhandlerPack {
7022            fn name(&self) -> &str {
7023                Self::NAME
7024            }
7025            fn note_kinds(&self) -> &'static [&'static str] {
7026                Self::NOTE_KINDS
7027            }
7028            fn entity_kinds(&self) -> &'static [&'static str] {
7029                Self::ENTITY_KINDS
7030            }
7031            fn handlers(&self) -> &'static [HandlerDef] {
7032                Self::HANDLERS
7033            }
7034            async fn dispatch(
7035                &self,
7036                verb: &str,
7037                _: Value,
7038                _: &VerbRegistry,
7039                _: &NamespaceToken,
7040            ) -> Result<Value, RuntimeError> {
7041                Ok(serde_json::json!({"pack": "subhandler_pack", "verb": verb}))
7042            }
7043        }
7044        let mut builder = VerbRegistryBuilder::new();
7045        builder.register(AlphaPack); // AlphaPack has Verb "create"
7046        builder.register(SubhandlerPack); // SubhandlerPack has Subhandler "create" — no collision
7047        builder
7048            .build()
7049            .expect("subhandler same name must NOT be a collision");
7050    }
7051
7052    #[tokio::test]
7053    async fn dispatch_unknown_verb_returns_error() {
7054        let reg = build_registry();
7055
7056        let err = reg.dispatch("explode", Value::Null).await.unwrap_err();
7057        let msg = err.to_string();
7058        assert!(msg.contains("explode"));
7059        assert!(msg.contains("create"));
7060    }
7061
7062    /// `all_verbs` returns only `Visibility::Verb` entries.
7063    ///
7064    /// BetaPack's `create` is `Visibility::Subhandler` — it must NOT appear
7065    /// in `all_verbs()` even though it has the same name as a Verb in AlphaPack.
7066    #[test]
7067    fn all_verbs_aggregates_across_packs_excludes_subhandlers() {
7068        let reg = build_registry();
7069        let verbs: Vec<&str> = reg.all_verbs().iter().map(|v| v.name).collect();
7070        // BetaPack's "create" (Subhandler) is absent; only Verb-visibility entries appear.
7071        assert_eq!(verbs, vec!["create", "list", "notify"]);
7072    }
7073
7074    #[test]
7075    fn all_verbs_with_names_pairs_pack_name_excludes_subhandlers() {
7076        let reg = build_registry();
7077        let pairs: Vec<(&str, &str)> = reg
7078            .all_verbs_with_names()
7079            .iter()
7080            .map(|(pack, v)| (*pack, v.name))
7081            .collect();
7082        // BetaPack's "create" is Subhandler and must NOT appear here.
7083        assert_eq!(
7084            pairs,
7085            vec![("alpha", "create"), ("alpha", "list"), ("beta", "notify"),]
7086        );
7087    }
7088
7089    #[test]
7090    fn all_handlers_with_names_includes_subhandlers() {
7091        let reg = build_registry();
7092        let pairs: Vec<(&str, &str)> = reg
7093            .all_handlers_with_names()
7094            .iter()
7095            .map(|(pack, v)| (*pack, v.name))
7096            .collect();
7097        // BetaPack's Subhandler "create" IS present in the full handler list.
7098        assert_eq!(
7099            pairs,
7100            vec![
7101                ("alpha", "create"),
7102                ("alpha", "list"),
7103                ("beta", "notify"),
7104                ("beta", "create"),
7105            ]
7106        );
7107    }
7108
7109    #[test]
7110    fn note_kinds_are_ordered() {
7111        let reg = build_registry();
7112        let kinds = reg.all_note_kinds();
7113        assert_eq!(kinds, vec!["memo", "log", "alert"]);
7114    }
7115
7116    #[test]
7117    fn brain_consumer_kinds_are_ordered_and_deduplicated() {
7118        let reg = build_registry();
7119        assert_eq!(
7120            reg.all_brain_consumer_kinds(),
7121            vec!["recall", "search", "knowledge_compose"]
7122        );
7123    }
7124
7125    #[test]
7126    fn brain_consumer_kind_wildcard_is_rejected_at_build_time() {
7127        struct WildcardConsumerPack;
7128
7129        impl khive_types::Pack for WildcardConsumerPack {
7130            const NAME: &'static str = "wildcard-consumer";
7131            const NOTE_KINDS: &'static [&'static str] = &[];
7132            const ENTITY_KINDS: &'static [&'static str] = &[];
7133            const BRAIN_CONSUMER_KINDS: &'static [&'static str] = &["*"];
7134            const HANDLERS: &'static [HandlerDef] = &[];
7135        }
7136
7137        #[async_trait]
7138        impl PackRuntime for WildcardConsumerPack {
7139            fn name(&self) -> &str {
7140                Self::NAME
7141            }
7142            fn note_kinds(&self) -> &'static [&'static str] {
7143                Self::NOTE_KINDS
7144            }
7145            fn entity_kinds(&self) -> &'static [&'static str] {
7146                Self::ENTITY_KINDS
7147            }
7148            fn brain_consumer_kinds(&self) -> &'static [&'static str] {
7149                Self::BRAIN_CONSUMER_KINDS
7150            }
7151            fn handlers(&self) -> &'static [HandlerDef] {
7152                Self::HANDLERS
7153            }
7154            async fn dispatch(
7155                &self,
7156                _verb: &str,
7157                _params: Value,
7158                _registry: &VerbRegistry,
7159                _token: &NamespaceToken,
7160            ) -> Result<Value, RuntimeError> {
7161                Ok(Value::Null)
7162            }
7163        }
7164
7165        let mut builder = VerbRegistryBuilder::new();
7166        builder.register(WildcardConsumerPack);
7167        let Err(RuntimeError::InvalidInput(message)) = builder.build() else {
7168            panic!("registry must reject a pack-declared brain wildcard");
7169        };
7170        assert!(message.contains("wildcard-consumer"), "{message}");
7171        assert!(message.contains("registry-owned"), "{message}");
7172    }
7173
7174    #[test]
7175    fn note_kind_duplicate_rejected_at_build_time() {
7176        struct DupPack;
7177
7178        impl khive_types::Pack for DupPack {
7179            const NAME: &'static str = "dup";
7180            // "memo" is already declared by AlphaPack — must be rejected at build.
7181            const NOTE_KINDS: &'static [&'static str] = &["memo"];
7182            const ENTITY_KINDS: &'static [&'static str] = &[];
7183            const HANDLERS: &'static [HandlerDef] = &[];
7184        }
7185
7186        #[async_trait]
7187        impl PackRuntime for DupPack {
7188            fn name(&self) -> &str {
7189                Self::NAME
7190            }
7191            fn note_kinds(&self) -> &'static [&'static str] {
7192                Self::NOTE_KINDS
7193            }
7194            fn entity_kinds(&self) -> &'static [&'static str] {
7195                Self::ENTITY_KINDS
7196            }
7197            fn handlers(&self) -> &'static [HandlerDef] {
7198                Self::HANDLERS
7199            }
7200            async fn dispatch(
7201                &self,
7202                _verb: &str,
7203                _params: Value,
7204                _registry: &VerbRegistry,
7205                _token: &NamespaceToken,
7206            ) -> Result<Value, RuntimeError> {
7207                Ok(Value::Null)
7208            }
7209        }
7210
7211        let mut builder = VerbRegistryBuilder::new();
7212        builder.register(AlphaPack);
7213        builder.register(DupPack);
7214        let err = builder
7215            .build()
7216            .err()
7217            .expect("duplicate note kind must be rejected");
7218        let msg = err.to_string();
7219        assert!(
7220            msg.contains("memo"),
7221            "error must name the duplicate kind: {msg}"
7222        );
7223        assert!(
7224            msg.contains("alpha") || msg.contains("dup"),
7225            "error must name one of the conflicting packs: {msg}"
7226        );
7227    }
7228
7229    #[test]
7230    fn entity_kinds_are_deduplicated() {
7231        let reg = build_registry();
7232        let kinds = reg.all_entity_kinds();
7233        assert_eq!(kinds, vec!["widget", "gadget"]);
7234    }
7235
7236    // ---- ENTITY_TYPES composition (pack-declared entity-type subtypes) ----
7237
7238    struct GammaPack;
7239
7240    impl Pack for GammaPack {
7241        const NAME: &'static str = "gamma";
7242        const NOTE_KINDS: &'static [&'static str] = &[];
7243        const ENTITY_KINDS: &'static [&'static str] = &[];
7244        const HANDLERS: &'static [HandlerDef] = &[];
7245        const ENTITY_TYPES: &'static [EntityTypeDef] = &[EntityTypeDef {
7246            kind: khive_types::EntityKind::Document,
7247            type_name: "gamma_report",
7248            aliases: &["gamma_rep"],
7249        }];
7250    }
7251
7252    #[async_trait]
7253    impl PackRuntime for GammaPack {
7254        fn name(&self) -> &str {
7255            Self::NAME
7256        }
7257        fn note_kinds(&self) -> &'static [&'static str] {
7258            Self::NOTE_KINDS
7259        }
7260        fn entity_kinds(&self) -> &'static [&'static str] {
7261            Self::ENTITY_KINDS
7262        }
7263        fn handlers(&self) -> &'static [HandlerDef] {
7264            Self::HANDLERS
7265        }
7266        fn entity_types(&self) -> &'static [EntityTypeDef] {
7267            Self::ENTITY_TYPES
7268        }
7269        async fn dispatch(
7270            &self,
7271            verb: &str,
7272            _params: Value,
7273            _registry: &VerbRegistry,
7274            _token: &NamespaceToken,
7275        ) -> Result<Value, RuntimeError> {
7276            Ok(serde_json::json!({ "pack": "gamma", "verb": verb }))
7277        }
7278    }
7279
7280    /// Builtin-only behavior is unchanged when no pack declares extras:
7281    /// `all_entity_types()` is empty, and composing it with the builtin
7282    /// registry resolves exactly like `EntityTypeRegistry::builtin()`.
7283    #[test]
7284    fn all_entity_types_empty_when_no_pack_declares_extras() {
7285        let reg = build_registry(); // AlphaPack + BetaPack — neither declares ENTITY_TYPES.
7286        assert!(reg.all_entity_types().is_empty());
7287        let composed = khive_types::EntityTypeRegistry::with_extra(reg.all_entity_types());
7288        let resolved = composed
7289            .resolve(khive_types::EntityKind::Document, Some("paper"))
7290            .expect("builtin paper subtype must still resolve");
7291        assert_eq!(resolved.entity_type.as_deref(), Some("paper"));
7292    }
7293
7294    /// A pack-declared entity type validates through the composed registry,
7295    /// and builtin subtypes remain resolvable alongside it.
7296    #[test]
7297    fn pack_declared_entity_type_validates_through_composed_registry() {
7298        let mut builder = VerbRegistryBuilder::new();
7299        builder.register(AlphaPack);
7300        builder.register(GammaPack);
7301        let reg = builder.build().expect("registry builds");
7302
7303        let extras = reg.all_entity_types();
7304        assert_eq!(extras.len(), 1);
7305
7306        let composed = khive_types::EntityTypeRegistry::with_extra(extras);
7307        let resolved = composed
7308            .resolve(khive_types::EntityKind::Document, Some("gamma_rep"))
7309            .expect("pack-declared alias must resolve through the composed registry");
7310        assert_eq!(resolved.entity_type.as_deref(), Some("gamma_report"));
7311
7312        let builtin_resolved = composed
7313            .resolve(khive_types::EntityKind::Document, Some("paper"))
7314            .expect("builtin subtype must remain resolvable when a pack adds extras");
7315        assert_eq!(builtin_resolved.entity_type.as_deref(), Some("paper"));
7316
7317        composed
7318            .resolve(khive_types::EntityKind::Document, Some("nonexistent_type"))
7319            .expect_err("undeclared entity_type must still be rejected");
7320    }
7321
7322    /// Two packs declaring the exact same `(kind, type_name)` subtype are
7323    /// rejected at `build()` — ADR-001's registry-ownership collision rule
7324    /// ("same `(base_kind, canonical_name)` from two different packs = boot
7325    /// error") — instead of silently resolving via registration order the
7326    /// way `EntityTypeRegistry::with_extra`'s hard-`insert` semantics would.
7327    #[test]
7328    fn overlapping_pack_declared_entity_types_reject_at_boot() {
7329        struct DeltaPack;
7330        impl Pack for DeltaPack {
7331            const NAME: &'static str = "delta";
7332            const NOTE_KINDS: &'static [&'static str] = &[];
7333            const ENTITY_KINDS: &'static [&'static str] = &[];
7334            const HANDLERS: &'static [HandlerDef] = &[];
7335            const ENTITY_TYPES: &'static [EntityTypeDef] = &[EntityTypeDef {
7336                kind: khive_types::EntityKind::Document,
7337                type_name: "gamma_report",
7338                aliases: &["gamma_rep"],
7339            }];
7340        }
7341        #[async_trait]
7342        impl PackRuntime for DeltaPack {
7343            fn name(&self) -> &str {
7344                Self::NAME
7345            }
7346            fn note_kinds(&self) -> &'static [&'static str] {
7347                Self::NOTE_KINDS
7348            }
7349            fn entity_kinds(&self) -> &'static [&'static str] {
7350                Self::ENTITY_KINDS
7351            }
7352            fn handlers(&self) -> &'static [HandlerDef] {
7353                Self::HANDLERS
7354            }
7355            fn entity_types(&self) -> &'static [EntityTypeDef] {
7356                Self::ENTITY_TYPES
7357            }
7358            async fn dispatch(
7359                &self,
7360                verb: &str,
7361                _params: Value,
7362                _registry: &VerbRegistry,
7363                _token: &NamespaceToken,
7364            ) -> Result<Value, RuntimeError> {
7365                Ok(serde_json::json!({ "pack": "delta", "verb": verb }))
7366            }
7367        }
7368
7369        let mut builder = VerbRegistryBuilder::new();
7370        builder.register(GammaPack);
7371        builder.register(DeltaPack);
7372        let err = builder.build().err().expect(
7373            "overlapping ENTITY_TYPES declarations must fail at build, not silently compose",
7374        );
7375
7376        let msg = err.to_string();
7377        assert!(
7378            msg.contains("gamma") && msg.contains("delta"),
7379            "collision error must name both contributing packs: {msg}"
7380        );
7381        assert!(
7382            msg.contains("gamma_report"),
7383            "collision error must name the colliding entity_type key: {msg}"
7384        );
7385    }
7386
7387    // ---- Gate wiring ----
7388
7389    use khive_gate::{Gate, GateError};
7390    use std::sync::atomic::{AtomicUsize, Ordering};
7391    use std::sync::Arc;
7392
7393    #[derive(Default, Debug)]
7394    struct CountingGate {
7395        calls: AtomicUsize,
7396        deny_verb: Option<&'static str>,
7397    }
7398
7399    impl Gate for CountingGate {
7400        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
7401            self.calls.fetch_add(1, Ordering::SeqCst);
7402            if Some(req.verb.as_str()) == self.deny_verb {
7403                Ok(GateDecision::deny(format!("test deny for {}", req.verb)))
7404            } else {
7405                Ok(GateDecision::allow())
7406            }
7407        }
7408    }
7409
7410    #[tokio::test]
7411    async fn dispatch_consults_the_gate() {
7412        let gate = Arc::new(CountingGate::default());
7413        let mut builder = VerbRegistryBuilder::new();
7414        builder.register(AlphaPack);
7415        builder.with_gate(gate.clone());
7416        let reg = builder.build().expect("registry builds");
7417
7418        reg.dispatch("list", Value::Null).await.unwrap();
7419        reg.dispatch("create", Value::Null).await.unwrap();
7420        assert_eq!(
7421            gate.calls.load(Ordering::SeqCst),
7422            2,
7423            "gate should be consulted once per dispatch"
7424        );
7425    }
7426
7427    #[tokio::test]
7428    async fn dispatch_returns_permission_denied_on_deny_v03() {
7429        let gate = Arc::new(CountingGate {
7430            calls: AtomicUsize::new(0),
7431            deny_verb: Some("create"),
7432        });
7433        let mut builder = VerbRegistryBuilder::new();
7434        builder.register(AlphaPack);
7435        builder.with_gate(gate.clone());
7436        let reg = builder.build().expect("registry builds");
7437
7438        // Gate denies — dispatch now returns PermissionDenied (hard enforcement).
7439        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
7440        assert!(
7441            matches!(err, RuntimeError::PermissionDenied { ref verb, .. } if verb == "create"),
7442            "expected PermissionDenied, got {err:?}"
7443        );
7444        let msg = err.to_string();
7445        assert!(
7446            msg.contains("create"),
7447            "error message must name the verb: {msg}"
7448        );
7449        assert!(
7450            msg.contains("test deny for create"),
7451            "error message must carry the deny reason: {msg}"
7452        );
7453        assert_eq!(gate.calls.load(Ordering::SeqCst), 1);
7454    }
7455
7456    #[tokio::test]
7457    #[serial_test::serial(config_ledger)]
7458    async fn denied_dispatch_returns_the_id_of_its_committed_gate_denied_row() {
7459        let gate = Arc::new(CountingGate {
7460            calls: AtomicUsize::new(0),
7461            deny_verb: Some("create"),
7462        });
7463        let store = Arc::new(MemoryEventStore::default());
7464        let mut builder = VerbRegistryBuilder::new();
7465        builder.register(AlphaPack);
7466        builder.with_gate(gate);
7467        builder.with_event_store(store.clone());
7468        let reg = builder.build().expect("registry builds");
7469
7470        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
7471        let RuntimeError::PermissionDenied { receipt, .. } = err else {
7472            panic!("expected PermissionDenied, got {err:?}");
7473        };
7474        assert_eq!(
7475            receipt.audit_outcome,
7476            crate::error::DenialAuditOutcome::Committed
7477        );
7478        let audit_event_id = receipt
7479            .audit_event_id
7480            .expect("a committed row carries its id");
7481        let events = store.events.lock().unwrap();
7482        let row = events
7483            .iter()
7484            .find(|e| e.id == audit_event_id)
7485            .expect("the receipt names a row the store holds");
7486        assert_eq!(row.outcome, EventOutcome::Denied);
7487        assert_eq!(row.kind, EventKind::Audit);
7488        assert_eq!(row.verb, "create");
7489    }
7490
7491    #[tokio::test]
7492    #[serial_test::serial(config_ledger)]
7493    async fn denied_dispatch_masks_secret_shaped_deny_reason_in_stored_event() {
7494        // Falsifiable arm for the audit-masking fix (khive#2944): this deny
7495        // reason embeds a fake credential in a shape the write-time secret
7496        // gate recognizes (`scheme://user:pass@host`). Deleting the masking
7497        // call at this call site — `dispatch_with_disposition`'s own
7498        // `AuditEvent` construction — turns this test red: the raw
7499        // credential would reach the stored row unmasked.
7500        #[derive(Debug)]
7501        struct SecretDenyGate;
7502        impl Gate for SecretDenyGate {
7503            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
7504                let reason = "postgres://svc:not-a-real-secret@internal-host in denied request"; // gitleaks:allow
7505                Ok(GateDecision::deny(reason))
7506            }
7507        }
7508
7509        let store = Arc::new(MemoryEventStore::default());
7510        let mut builder = VerbRegistryBuilder::new();
7511        builder.register(AlphaPack);
7512        builder.with_gate(Arc::new(SecretDenyGate));
7513        builder.with_event_store(store.clone());
7514        let reg = builder.build().expect("registry builds");
7515
7516        let _ = reg.dispatch("list", Value::Null).await.unwrap_err();
7517
7518        let events = store.events.lock().unwrap();
7519        assert_eq!(events.len(), 1, "exactly one denial row must commit");
7520        let stored_reason = events[0].payload["deny_reason"]
7521            .as_str()
7522            .expect("deny_reason must be a string on the stored row");
7523        // Non-vacuity: the row actually captured content, so the negative
7524        // assertion below cannot pass merely because nothing was read.
7525        assert!(!stored_reason.is_empty());
7526        assert!(
7527            stored_reason.contains("in denied request"),
7528            "non-secret prose must survive masking: {stored_reason:?}"
7529        );
7530        assert!(
7531            !stored_reason.contains("not-a-real-secret"),
7532            "the durable row must never carry the raw credential: {stored_reason:?}"
7533        );
7534        assert!(
7535            stored_reason.contains("***MASKED***"),
7536            "the durable row must record that a credential was redacted: {stored_reason:?}"
7537        );
7538    }
7539
7540    #[tokio::test]
7541    #[serial_test::serial(config_ledger)]
7542    async fn denied_dispatch_without_an_event_store_reports_no_store() {
7543        let gate = Arc::new(CountingGate {
7544            calls: AtomicUsize::new(0),
7545            deny_verb: Some("create"),
7546        });
7547        let mut builder = VerbRegistryBuilder::new();
7548        builder.register(AlphaPack);
7549        builder.with_gate(gate);
7550        let reg = builder.build().expect("registry builds");
7551
7552        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
7553        assert!(
7554            matches!(
7555                err,
7556                RuntimeError::PermissionDenied { ref receipt, .. }
7557                    if **receipt == crate::error::DenialReceipt::no_store()
7558            ),
7559            "expected a no-store receipt, got {err:?}"
7560        );
7561    }
7562
7563    #[tokio::test]
7564    #[serial_test::serial(config_ledger)]
7565    #[serial_test::serial(audit_append_failures)]
7566    #[serial_test::serial(audit_obligation_append_failures)]
7567    async fn denied_dispatch_whose_row_fails_to_commit_still_refuses_and_names_no_row() {
7568        let gate = Arc::new(CountingGate {
7569            calls: AtomicUsize::new(0),
7570            deny_verb: Some("create"),
7571        });
7572        let store = Arc::new(MemoryEventStore {
7573            fail_appends: true,
7574            ..MemoryEventStore::default()
7575        });
7576        let mut builder = VerbRegistryBuilder::new();
7577        builder.register(AlphaPack);
7578        builder.with_gate(gate);
7579        builder.with_event_store(store.clone());
7580        let reg = builder.build().expect("registry builds");
7581
7582        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
7583        let RuntimeError::PermissionDenied { verb, receipt, .. } = err else {
7584            panic!("expected PermissionDenied, got {err:?}");
7585        };
7586        assert_eq!(verb, "create");
7587        assert_eq!(
7588            receipt.audit_event_id, None,
7589            "a row that did not commit is not cited"
7590        );
7591        assert_eq!(
7592            receipt.audit_outcome,
7593            crate::error::DenialAuditOutcome::NotCommitted("store_failure")
7594        );
7595        assert!(store.events.lock().unwrap().is_empty());
7596    }
7597
7598    #[tokio::test]
7599    async fn dispatch_allow_verb_succeeds_even_with_deny_gate_for_other_verb() {
7600        // Deny only "create" — "list" must still work.
7601        let gate = Arc::new(CountingGate {
7602            calls: AtomicUsize::new(0),
7603            deny_verb: Some("create"),
7604        });
7605        let mut builder = VerbRegistryBuilder::new();
7606        builder.register(AlphaPack);
7607        builder.with_gate(gate.clone());
7608        let reg = builder.build().expect("registry builds");
7609
7610        let res = reg.dispatch("list", Value::Null).await.unwrap();
7611        assert_eq!(res["pack"], "alpha");
7612    }
7613
7614    #[tokio::test]
7615    async fn dispatch_uses_allow_all_gate_by_default() {
7616        // No `with_gate` call — builder should use `AllowAllGate` so dispatch works.
7617        let reg = build_registry();
7618        let res = reg.dispatch("list", Value::Null).await.unwrap();
7619        assert_eq!(res["pack"], "alpha");
7620    }
7621
7622    // Captures the namespace each call sees so we can assert what the gate
7623    // actually receives, rather than assuming a hard-wired `default_ns()`.
7624    #[derive(Default, Debug)]
7625    struct NamespaceCapturingGate {
7626        seen: std::sync::Mutex<Vec<String>>,
7627    }
7628
7629    impl Gate for NamespaceCapturingGate {
7630        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
7631            self.seen
7632                .lock()
7633                .unwrap()
7634                .push(req.namespace.as_str().to_string());
7635            Ok(GateDecision::allow())
7636        }
7637    }
7638
7639    #[tokio::test]
7640    async fn dispatch_propagates_params_namespace_to_gate() {
7641        let gate = Arc::new(NamespaceCapturingGate::default());
7642        let mut builder = VerbRegistryBuilder::new();
7643        builder.register(AlphaPack);
7644        builder.with_gate(gate.clone());
7645        builder.with_default_namespace("tenant-x");
7646        let reg = builder.build().expect("registry builds");
7647
7648        // Explicit namespace in params wins.
7649        reg.dispatch("list", serde_json::json!({"namespace": "tenant-y"}))
7650            .await
7651            .unwrap();
7652        // Missing namespace → registry default.
7653        reg.dispatch("list", Value::Null).await.unwrap();
7654        // Empty string is rejected: Namespace::parse("") fails → InvalidInput error.
7655        let err = reg
7656            .dispatch("list", serde_json::json!({"namespace": ""}))
7657            .await
7658            .unwrap_err();
7659        assert!(
7660            matches!(err, RuntimeError::InvalidInput(_)),
7661            "empty namespace must return InvalidInput, got {err:?}"
7662        );
7663
7664        let seen = gate.seen.lock().unwrap().clone();
7665        assert_eq!(seen, vec!["tenant-y", "tenant-x"]);
7666    }
7667
7668    #[tokio::test]
7669    async fn dispatch_falls_back_to_local_when_no_default_set() {
7670        // Builder default mirrors `Namespace::default_ns()`.
7671        let gate = Arc::new(NamespaceCapturingGate::default());
7672        let mut builder = VerbRegistryBuilder::new();
7673        builder.register(AlphaPack);
7674        builder.with_gate(gate.clone());
7675        let reg = builder.build().expect("registry builds");
7676
7677        reg.dispatch("list", Value::Null).await.unwrap();
7678        let seen = gate.seen.lock().unwrap().clone();
7679        assert_eq!(seen, vec!["local"]);
7680    }
7681
7682    /// A present-but-malformed `namespace` value must never reach the gate as
7683    /// the default namespace. Table-driven over every
7684    /// non-string JSON type; the gate-spy proves no call is ever recorded (the
7685    /// dispatch must short-circuit with `InvalidInput` before `GateRequest` is
7686    /// built), so the default namespace can never appear as a coerced stand-in.
7687    #[tokio::test]
7688    async fn namespace_null_rejected_not_coerced() {
7689        let cases: Vec<(&str, Value)> = vec![
7690            ("null", Value::Null),
7691            ("number", serde_json::json!(42)),
7692            ("boolean", serde_json::json!(true)),
7693            ("array", serde_json::json!(["local"])),
7694            ("object", serde_json::json!({"ns": "local"})),
7695        ];
7696
7697        for (label, ns_value) in cases {
7698            let gate = Arc::new(NamespaceCapturingGate::default());
7699            let mut builder = VerbRegistryBuilder::new();
7700            builder.register(AlphaPack);
7701            builder.with_gate(gate.clone());
7702            builder.with_default_namespace("tenant-x");
7703            let reg = builder.build().expect("registry builds");
7704
7705            let err = reg
7706                .dispatch("list", serde_json::json!({"namespace": ns_value}))
7707                .await
7708                .unwrap_err();
7709            assert!(
7710                matches!(err, RuntimeError::InvalidInput(_)),
7711                "case {label}: expected InvalidInput, got {err:?}"
7712            );
7713
7714            // The gate must never have been consulted for this malformed input —
7715            // proves no Allow decision (and therefore no default-namespace write)
7716            // can ever be reached for it.
7717            let seen = gate.seen.lock().unwrap().clone();
7718            assert!(
7719                seen.is_empty(),
7720                "case {label}: gate must not be consulted for malformed namespace, saw {seen:?}"
7721            );
7722        }
7723    }
7724
7725    // ---- Audit event emission ----
7726
7727    use khive_gate::{AuditDecision, AuditEvent, Obligation};
7728
7729    /// A gate that records every audit event emitted via from_check.
7730    #[derive(Default, Debug)]
7731    struct AuditCapturingGate {
7732        events: std::sync::Mutex<Vec<AuditEvent>>,
7733        deny_verb: Option<&'static str>,
7734    }
7735
7736    impl Gate for AuditCapturingGate {
7737        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
7738            let decision = if Some(req.verb.as_str()) == self.deny_verb {
7739                GateDecision::deny("test deny")
7740            } else {
7741                GateDecision::allow_with(vec![Obligation::Audit {
7742                    tag: format!("{}.check", req.verb),
7743                }])
7744            };
7745            // Capture what dispatch will also emit.
7746            let ev = AuditEvent::from_check(req, &decision, self.impl_name());
7747            self.events.lock().unwrap().push(ev);
7748            Ok(decision)
7749        }
7750
7751        fn impl_name(&self) -> &'static str {
7752            "AuditCapturingGate"
7753        }
7754    }
7755
7756    #[tokio::test]
7757    async fn dispatch_emits_one_audit_event_per_call() {
7758        let gate = Arc::new(AuditCapturingGate::default());
7759        let mut builder = VerbRegistryBuilder::new();
7760        builder.register(AlphaPack);
7761        builder.with_gate(gate.clone());
7762        let reg = builder.build().expect("registry builds");
7763
7764        reg.dispatch("list", Value::Null).await.unwrap();
7765        reg.dispatch("create", Value::Null).await.unwrap();
7766
7767        let evs = gate.events.lock().unwrap();
7768        assert_eq!(evs.len(), 2, "exactly one audit event per dispatch call");
7769    }
7770
7771    #[tokio::test]
7772    async fn dispatch_audit_event_allow_carries_obligations() {
7773        let gate = Arc::new(AuditCapturingGate::default());
7774        let mut builder = VerbRegistryBuilder::new();
7775        builder.register(AlphaPack);
7776        builder.with_gate(gate.clone());
7777        let reg = builder.build().expect("registry builds");
7778
7779        reg.dispatch("list", Value::Null).await.unwrap();
7780
7781        let evs = gate.events.lock().unwrap();
7782        let ev = &evs[0];
7783        assert_eq!(ev.verb, "list");
7784        assert_eq!(ev.decision, AuditDecision::Allow);
7785        assert!(ev.deny_reason.is_none());
7786        assert_eq!(ev.obligations.len(), 1);
7787        assert_eq!(ev.gate_impl, "AuditCapturingGate");
7788    }
7789
7790    #[tokio::test]
7791    async fn dispatch_audit_event_deny_carries_reason() {
7792        let gate = Arc::new(AuditCapturingGate {
7793            events: Default::default(),
7794            deny_verb: Some("create"),
7795        });
7796        let mut builder = VerbRegistryBuilder::new();
7797        builder.register(AlphaPack);
7798        builder.with_gate(gate.clone());
7799        let reg = builder.build().expect("registry builds");
7800
7801        // Gate denies — dispatch returns PermissionDenied (hard enforcement).
7802        // The audit event is still recorded (captured inside the gate impl).
7803        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
7804        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
7805
7806        let evs = gate.events.lock().unwrap();
7807        let ev = &evs[0];
7808        assert_eq!(ev.verb, "create");
7809        assert_eq!(ev.decision, AuditDecision::Deny);
7810        assert_eq!(ev.deny_reason.as_deref(), Some("test deny"));
7811        assert!(ev.obligations.is_empty());
7812    }
7813
7814    #[tokio::test]
7815    async fn dispatch_audit_event_fields_match_gate_request() {
7816        let gate = Arc::new(AuditCapturingGate::default());
7817        let mut builder = VerbRegistryBuilder::new();
7818        builder.register(AlphaPack);
7819        builder.with_gate(gate.clone());
7820        builder.with_default_namespace("tenant-z");
7821        let reg = builder.build().expect("registry builds");
7822
7823        reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
7824            .await
7825            .unwrap();
7826
7827        let evs = gate.events.lock().unwrap();
7828        let ev = &evs[0];
7829        // Namespace from params wins.
7830        assert_eq!(ev.namespace, "tenant-q");
7831        assert_eq!(ev.verb, "list");
7832        assert_eq!(ev.actor.kind, "anonymous");
7833    }
7834
7835    // ---- Actor attribution threading into gate request (ADR-057) ----
7836
7837    /// A gate spy that captures the raw `GateRequest` it receives.
7838    #[derive(Default, Debug)]
7839    struct ActorCapturingGate {
7840        requests: std::sync::Mutex<Vec<GateRequest>>,
7841    }
7842
7843    impl Gate for ActorCapturingGate {
7844        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
7845            self.requests.lock().unwrap().push(req.clone());
7846            Ok(GateDecision::allow())
7847        }
7848    }
7849
7850    /// When `actor_id` is configured, the gate request carries that actor, not
7851    /// anonymous. This exercises the ADR-057 attribution fix: the gate can
7852    /// distinguish an agent caller from an unauthenticated caller.
7853    #[tokio::test]
7854    async fn gate_request_carries_configured_actor_when_actor_id_is_set() {
7855        let gate = Arc::new(ActorCapturingGate::default());
7856        let mut builder = VerbRegistryBuilder::new();
7857        builder.register(AlphaPack);
7858        builder.with_gate(gate.clone());
7859        builder.with_actor_id(Some("team-abc:implementer".to_string()));
7860        let reg = builder.build().expect("registry builds");
7861
7862        reg.dispatch("list", Value::Null).await.unwrap();
7863
7864        let reqs = gate.requests.lock().unwrap();
7865        assert_eq!(reqs.len(), 1);
7866        let req = &reqs[0];
7867        assert_eq!(
7868            req.actor.kind, "actor",
7869            "gate request must carry kind='actor' when actor_id is configured"
7870        );
7871        assert_eq!(
7872            req.actor.id, "team-abc:implementer",
7873            "gate request must carry the configured actor id"
7874        );
7875    }
7876
7877    /// When no `actor_id` is configured, the gate request still receives the
7878    /// anonymous actor (no regression to the party-line default).
7879    #[tokio::test]
7880    async fn gate_request_carries_anonymous_when_no_actor_id_configured() {
7881        let gate = Arc::new(ActorCapturingGate::default());
7882        let mut builder = VerbRegistryBuilder::new();
7883        builder.register(AlphaPack);
7884        builder.with_gate(gate.clone());
7885        // actor_id left at default (None).
7886        let reg = builder.build().expect("registry builds");
7887
7888        reg.dispatch("list", Value::Null).await.unwrap();
7889
7890        let reqs = gate.requests.lock().unwrap();
7891        assert_eq!(reqs.len(), 1);
7892        let req = &reqs[0];
7893        assert_eq!(
7894            req.actor.kind, "anonymous",
7895            "gate request must carry anonymous actor when no actor_id is configured"
7896        );
7897        assert_eq!(req.actor.id, "local");
7898    }
7899
7900    /// A pack that records the `ActorRef` carried by the `NamespaceToken` it
7901    /// is dispatched with, so tests can compare it against the gate's actor.
7902    struct TokenCapturingPack {
7903        actors: Arc<std::sync::Mutex<Vec<khive_gate::ActorRef>>>,
7904    }
7905
7906    impl Pack for TokenCapturingPack {
7907        const NAME: &'static str = "alpha";
7908        const NOTE_KINDS: &'static [&'static str] = &[];
7909        const ENTITY_KINDS: &'static [&'static str] = &[];
7910        const HANDLERS: &'static [HandlerDef] = AlphaPack::HANDLERS;
7911    }
7912
7913    #[async_trait]
7914    impl PackRuntime for TokenCapturingPack {
7915        fn name(&self) -> &str {
7916            Self::NAME
7917        }
7918        fn note_kinds(&self) -> &'static [&'static str] {
7919            Self::NOTE_KINDS
7920        }
7921        fn entity_kinds(&self) -> &'static [&'static str] {
7922            Self::ENTITY_KINDS
7923        }
7924        fn handlers(&self) -> &'static [HandlerDef] {
7925            Self::HANDLERS
7926        }
7927        async fn dispatch(
7928            &self,
7929            verb: &str,
7930            _params: Value,
7931            _registry: &VerbRegistry,
7932            token: &NamespaceToken,
7933        ) -> Result<Value, RuntimeError> {
7934            self.actors.lock().unwrap().push(token.actor().clone());
7935            Ok(serde_json::json!({ "pack": "alpha", "verb": verb }))
7936        }
7937    }
7938
7939    /// The gate's actor and the storage token's actor must be the exact same
7940    /// resolved value: both come from one `resolve_actor` call
7941    /// (`resolved_actor`) instead of two independently hand-synchronized
7942    /// `match` expressions, so a future edit to one copy but not the other
7943    /// cannot silently desynchronize "who the gate thinks the caller is" from
7944    /// "who the storage layer thinks the caller is". Reintroducing a second
7945    /// independent actor-resolution copy for the token would regress this and
7946    /// this test would catch it.
7947    #[tokio::test]
7948    async fn gate_actor_and_token_actor_are_identical_when_actor_id_is_set() {
7949        let gate = Arc::new(ActorCapturingGate::default());
7950        let actors = Arc::new(std::sync::Mutex::new(Vec::new()));
7951        let pack = TokenCapturingPack {
7952            actors: actors.clone(),
7953        };
7954        let mut builder = VerbRegistryBuilder::new();
7955        builder.register(pack);
7956        builder.with_gate(gate.clone());
7957        builder.with_actor_id(Some("actor-alpha".to_string()));
7958        let reg = builder.build().expect("registry builds");
7959
7960        reg.dispatch("list", Value::Null).await.unwrap();
7961
7962        let reqs = gate.requests.lock().unwrap();
7963        let gate_actor = reqs[0].actor.clone();
7964        drop(reqs);
7965
7966        let captured = actors.lock().unwrap();
7967        let token_actor = captured[0].clone();
7968
7969        assert_eq!(
7970            gate_actor.kind, token_actor.kind,
7971            "gate request actor and storage token actor must carry the same kind"
7972        );
7973        assert_eq!(
7974            gate_actor.id, token_actor.id,
7975            "gate request actor and storage token actor must carry the same id"
7976        );
7977        assert_eq!(gate_actor.id, "actor-alpha");
7978    }
7979
7980    struct VisibilityCapturingPack {
7981        visible: Arc<std::sync::Mutex<Vec<Vec<String>>>>,
7982    }
7983
7984    impl Pack for VisibilityCapturingPack {
7985        const NAME: &'static str = "alpha";
7986        const NOTE_KINDS: &'static [&'static str] = &[];
7987        const ENTITY_KINDS: &'static [&'static str] = &[];
7988        const HANDLERS: &'static [HandlerDef] = AlphaPack::HANDLERS;
7989    }
7990
7991    #[async_trait]
7992    impl PackRuntime for VisibilityCapturingPack {
7993        fn name(&self) -> &str {
7994            Self::NAME
7995        }
7996        fn note_kinds(&self) -> &'static [&'static str] {
7997            Self::NOTE_KINDS
7998        }
7999        fn entity_kinds(&self) -> &'static [&'static str] {
8000            Self::ENTITY_KINDS
8001        }
8002        fn handlers(&self) -> &'static [HandlerDef] {
8003            Self::HANDLERS
8004        }
8005        async fn dispatch(
8006            &self,
8007            verb: &str,
8008            _params: Value,
8009            _registry: &VerbRegistry,
8010            token: &NamespaceToken,
8011        ) -> Result<Value, RuntimeError> {
8012            self.visible.lock().unwrap().push(
8013                token
8014                    .visible_namespace_strs()
8015                    .into_iter()
8016                    .map(str::to_string)
8017                    .collect(),
8018            );
8019            Ok(serde_json::json!({ "pack": "alpha", "verb": verb }))
8020        }
8021    }
8022
8023    /// ADR-007 Rev 4 Rule 3b at the token seam: a per-request identity that
8024    /// names a non-`local` actor reads that actor's namespace by default even
8025    /// when its `visible_namespaces` list is empty, the actor appears once when
8026    /// the list already names it, an anonymous identity keeps exactly `local`,
8027    /// and an explicit `namespace=` stays a precise single-namespace scope.
8028    #[tokio::test]
8029    async fn dispatch_with_identity_folds_the_actor_namespace_into_default_reads() {
8030        let visible = Arc::new(std::sync::Mutex::new(Vec::new()));
8031        let mut builder = VerbRegistryBuilder::new();
8032        builder.register(VisibilityCapturingPack {
8033            visible: visible.clone(),
8034        });
8035        let reg = builder.build().expect("registry builds");
8036        let identity = |actor: Option<&str>, listed: &[&str]| RequestIdentity {
8037            namespace: "local".to_string(),
8038            actor_id: actor.map(str::to_string),
8039            visible_namespaces: listed.iter().map(|ns| ns.to_string()).collect(),
8040            ..Default::default()
8041        };
8042
8043        reg.dispatch_with_identity(
8044            "list",
8045            Value::Null,
8046            Some(identity(Some("lambda:probe"), &[])),
8047        )
8048        .await
8049        .unwrap();
8050        reg.dispatch_with_identity(
8051            "list",
8052            Value::Null,
8053            Some(identity(Some("lambda:probe"), &["lambda:probe"])),
8054        )
8055        .await
8056        .unwrap();
8057        reg.dispatch_with_identity("list", Value::Null, Some(identity(None, &[])))
8058            .await
8059            .unwrap();
8060        reg.dispatch_with_identity(
8061            "list",
8062            serde_json::json!({"namespace": "lambda:probe"}),
8063            Some(identity(Some("lambda:probe"), &[])),
8064        )
8065        .await
8066        .unwrap();
8067
8068        let captured = visible.lock().unwrap();
8069        let count = |set: &Vec<String>, ns: &str| set.iter().filter(|s| s.as_str() == ns).count();
8070        assert_eq!(
8071            count(&captured[0], "lambda:probe"),
8072            1,
8073            "empty list: {:?}",
8074            captured[0]
8075        );
8076        assert_eq!(
8077            count(&captured[0], "local"),
8078            1,
8079            "empty list: {:?}",
8080            captured[0]
8081        );
8082        assert_eq!(
8083            count(&captured[1], "lambda:probe"),
8084            1,
8085            "listed once: {:?}",
8086            captured[1]
8087        );
8088        assert_eq!(
8089            captured[2],
8090            vec!["local".to_string()],
8091            "anonymous keeps exactly local"
8092        );
8093        assert_eq!(
8094            captured[3],
8095            vec!["lambda:probe".to_string()],
8096            "explicit namespace is a precise scope, never widened"
8097        );
8098    }
8099
8100    /// Same identity check with no configured `actor_id`: both the gate and
8101    /// the storage token must independently land on `ActorRef::anonymous()`.
8102    #[tokio::test]
8103    async fn gate_actor_and_token_actor_are_identical_when_anonymous() {
8104        let gate = Arc::new(ActorCapturingGate::default());
8105        let actors = Arc::new(std::sync::Mutex::new(Vec::new()));
8106        let pack = TokenCapturingPack {
8107            actors: actors.clone(),
8108        };
8109        let mut builder = VerbRegistryBuilder::new();
8110        builder.register(pack);
8111        builder.with_gate(gate.clone());
8112        let reg = builder.build().expect("registry builds");
8113
8114        reg.dispatch("list", Value::Null).await.unwrap();
8115
8116        let reqs = gate.requests.lock().unwrap();
8117        let gate_actor = reqs[0].actor.clone();
8118        drop(reqs);
8119
8120        let captured = actors.lock().unwrap();
8121        let token_actor = captured[0].clone();
8122
8123        assert_eq!(gate_actor.kind, token_actor.kind);
8124        assert_eq!(gate_actor.id, token_actor.id);
8125        assert_eq!(gate_actor.id, "local");
8126    }
8127
8128    // ---- dispatch_as: verified-actor dispatch for embedding hosts ----
8129
8130    /// `dispatch_as` must thread the caller-supplied verified actor through
8131    /// to the pack handler's `NamespaceToken`, exactly as `dispatch_with_identity`
8132    /// does with a `RequestIdentity.actor_id` — this is the observable
8133    /// contract embedding hosts rely on.
8134    #[tokio::test]
8135    async fn dispatch_as_threads_verified_actor_into_token() {
8136        let gate = Arc::new(ActorCapturingGate::default());
8137        let actors = Arc::new(std::sync::Mutex::new(Vec::new()));
8138        let pack = TokenCapturingPack {
8139            actors: actors.clone(),
8140        };
8141        let mut builder = VerbRegistryBuilder::new();
8142        builder.register(pack);
8143        builder.with_gate(gate.clone());
8144        let reg = builder.build().expect("registry builds");
8145
8146        reg.dispatch_as(
8147            "list",
8148            Value::Null,
8149            VerifiedActor::new("gateway:principal-42").unwrap(),
8150        )
8151        .await
8152        .unwrap();
8153
8154        let reqs = gate.requests.lock().unwrap();
8155        assert_eq!(reqs[0].actor.kind, "actor");
8156        assert_eq!(reqs[0].actor.id, "gateway:principal-42");
8157        drop(reqs);
8158
8159        let captured = actors.lock().unwrap();
8160        assert_eq!(captured[0].kind, "actor");
8161        assert_eq!(
8162            captured[0].id, "gateway:principal-42",
8163            "the storage token actor must be the verified_actor supplied to dispatch_as, \
8164             matching exactly what pack handlers read as the acting principal"
8165        );
8166    }
8167
8168    /// `dispatch_as` is purely additive: a registry with a baked `actor_id`
8169    /// must still serve plain `dispatch()` calls under its own baked actor,
8170    /// unaffected by any `dispatch_as` call made on the same (cheaply
8171    /// cloneable) registry. No shared mutable state links the two calls.
8172    #[tokio::test]
8173    async fn dispatch_as_does_not_change_plain_dispatch_behavior() {
8174        let gate = Arc::new(ActorCapturingGate::default());
8175        let actors = Arc::new(std::sync::Mutex::new(Vec::new()));
8176        let pack = TokenCapturingPack {
8177            actors: actors.clone(),
8178        };
8179        let mut builder = VerbRegistryBuilder::new();
8180        builder.register(pack);
8181        builder.with_gate(gate.clone());
8182        builder.with_actor_id(Some("baked-actor".to_string()));
8183        let reg = builder.build().expect("registry builds");
8184
8185        reg.dispatch_as(
8186            "list",
8187            Value::Null,
8188            VerifiedActor::new("verified-actor").unwrap(),
8189        )
8190        .await
8191        .unwrap();
8192        reg.dispatch("list", Value::Null).await.unwrap();
8193
8194        let captured = actors.lock().unwrap();
8195        assert_eq!(captured.len(), 2);
8196        assert_eq!(captured[0].id, "verified-actor", "dispatch_as call");
8197        assert_eq!(
8198            captured[1].id, "baked-actor",
8199            "a later plain dispatch() call must still use the registry's baked \
8200             actor_id, unaffected by the prior dispatch_as call"
8201        );
8202    }
8203
8204    /// A request `params` payload cannot inject or override the actor:
8205    /// `dispatch_as` resolves the acting principal solely from its Rust-side
8206    /// `verified_actor` argument, never from `params`. An `actor` key placed
8207    /// in `params` passes through untouched to the pack handler like any
8208    /// other unrecognized field — the dispatch boundary itself never reads
8209    /// `params["actor"]`.
8210    #[tokio::test]
8211    async fn dispatch_as_ignores_actor_key_in_params() {
8212        let gate = Arc::new(ActorCapturingGate::default());
8213        let actors = Arc::new(std::sync::Mutex::new(Vec::new()));
8214        let pack = TokenCapturingPack {
8215            actors: actors.clone(),
8216        };
8217        let mut builder = VerbRegistryBuilder::new();
8218        builder.register(pack);
8219        builder.with_gate(gate.clone());
8220        let reg = builder.build().expect("registry builds");
8221
8222        reg.dispatch_as(
8223            "list",
8224            serde_json::json!({"actor": "spoofed-actor"}),
8225            VerifiedActor::new("verified-actor").unwrap(),
8226        )
8227        .await
8228        .unwrap();
8229
8230        let captured = actors.lock().unwrap();
8231        assert_eq!(
8232            captured[0].id, "verified-actor",
8233            "an 'actor' key inside params must never override the verified_actor \
8234             argument threaded through dispatch_as"
8235        );
8236    }
8237
8238    /// `VerifiedActor::new` must reject an empty identifier rather than
8239    /// letting it reach dispatch and silently resolve to the anonymous actor.
8240    #[test]
8241    fn verified_actor_rejects_empty_identifier() {
8242        let err = VerifiedActor::new("").unwrap_err();
8243        assert!(
8244            matches!(err, RuntimeError::InvalidInput(_)),
8245            "expected InvalidInput, got {err:?}"
8246        );
8247    }
8248
8249    /// `VerifiedActor::new` must reject a whitespace-only identifier for the
8250    /// same reason: it must never launder into `ActorRef::anonymous()`.
8251    #[test]
8252    fn verified_actor_rejects_whitespace_only_identifier() {
8253        let err = VerifiedActor::new("   ").unwrap_err();
8254        assert!(
8255            matches!(err, RuntimeError::InvalidInput(_)),
8256            "expected InvalidInput, got {err:?}"
8257        );
8258    }
8259
8260    // ---- Rego gate: fail-closed end-to-end ----
8261
8262    /// A `RegoGate` whose policy lacks the named entrypoint rule must cause
8263    /// `VerbRegistry::dispatch` to return `RuntimeError::PermissionDenied` —
8264    /// never to proceed to the pack handler.
8265    ///
8266    /// This is the runtime-level assertion that a gate evaluation failure
8267    /// fails closed rather than opening a security hole. `RegoGate::check`
8268    /// converts all evaluation failures (missing rule, undefined result,
8269    /// serialization error, poisoned engine) to `Ok(GateDecision::Deny)` with
8270    /// a static classified reason, so dispatch is blocked as a policy
8271    /// refusal. Infrastructure faults from other `Gate` implementations
8272    /// remain distinguishable as `RuntimeError::GateUnavailable`.
8273    #[tokio::test]
8274    async fn rego_gate_missing_entrypoint_returns_permission_denied() {
8275        use khive_gate_rego::RegoGate;
8276
8277        // Policy defines `verdict` but NOT `data.khive.gate.decision` (the
8278        // default entrypoint).  Construction succeeds — from_policy_str does
8279        // not validate the default entrypoint.  check() must convert the
8280        // missing-rule evaluation error to Ok(Deny) with a static classified
8281        // reason so the runtime reports a policy refusal rather than a gate
8282        // infrastructure outage.
8283        let policy = r#"
8284            package khive.gate
8285            import rego.v1
8286            verdict := "allow"
8287        "#;
8288        let gate = Arc::new(RegoGate::from_policy_str(policy).expect("policy compiles"));
8289
8290        let mut builder = VerbRegistryBuilder::new();
8291        builder.register(AlphaPack);
8292        builder.with_gate(gate);
8293        let reg = builder.build().expect("registry builds");
8294
8295        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
8296        assert!(
8297            matches!(err, RuntimeError::PermissionDenied { ref verb, ref reason, .. }
8298                if verb == "create" && reason == "policy evaluation failed"),
8299            "expected PermissionDenied with the static classified reason for a missing rego entrypoint, got {err:?}"
8300        );
8301    }
8302
8303    // ---- Audit tracing emission ----
8304    //
8305    // The AuditCapturingGate tests above prove that AuditEvent::from_check is
8306    // called with the right inputs, but they observe the event *inside* the
8307    // gate impl — they would still pass if dispatch's
8308    // `tracing::info!(audit_event = ..., "gate.check")` were deleted or
8309    // renamed. The tests below install a capture Layer and assert on the
8310    // actual tracing event surfaced from dispatch. This locks the public
8311    // observability contract: one `gate.check` info event per dispatch,
8312    // carrying an `audit_event` field that round-trips back to an `AuditEvent`.
8313
8314    use std::sync::{Mutex as StdMutex, Once, OnceLock};
8315
8316    use serial_test::serial;
8317    use tracing::field::{Field, Visit};
8318
8319    #[derive(Clone, Debug, Default)]
8320    struct CapturedEvent {
8321        message: Option<String>,
8322        audit_event: Option<String>,
8323        into_id: Option<String>,
8324        budget_rows: Option<u64>,
8325    }
8326
8327    #[derive(Default)]
8328    struct CapturedEventVisitor(CapturedEvent);
8329
8330    impl Visit for CapturedEventVisitor {
8331        fn record_str(&mut self, field: &Field, value: &str) {
8332            match field.name() {
8333                "message" => self.0.message = Some(value.to_string()),
8334                "audit_event" => self.0.audit_event = Some(value.to_string()),
8335                _ => {}
8336            }
8337        }
8338
8339        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
8340            // `tracing::info!(audit_event = %expr, "msg")` records via the
8341            // Display-wrapped Debug path, so we receive the JSON string here.
8342            // `"msg"` literal records as a `message` field via `record_debug`
8343            // with a quoted Debug representation; strip the surrounding quotes
8344            // so the captured message matches the source.
8345            let formatted = format!("{value:?}");
8346            let cleaned = formatted
8347                .trim_start_matches('"')
8348                .trim_end_matches('"')
8349                .to_string();
8350            match field.name() {
8351                "message" => self.0.message = Some(cleaned),
8352                "audit_event" => self.0.audit_event = Some(cleaned),
8353                "into_id" => self.0.into_id = Some(cleaned),
8354                _ => {}
8355            }
8356        }
8357
8358        fn record_u64(&mut self, field: &Field, value: u64) {
8359            if field.name() == "budget_rows" {
8360                self.0.budget_rows = Some(value);
8361            }
8362        }
8363    }
8364
8365    /// Minimal `tracing::Subscriber` that captures events into a shared vec.
8366    ///
8367    /// Implemented directly (without `tracing_subscriber::registry()` layering)
8368    /// to avoid the layer machinery that can cause thread-local dispatch to be
8369    /// bypassed when the registry's internal global state is initialised by
8370    /// another subscriber in the same test binary.
8371    ///
8372    /// Isolation across concurrent tests is handled at the dispatcher level by
8373    /// `tracing::dispatcher::with_default`, which installs this subscriber
8374    /// as the thread-local default for the duration of the test closure.
8375    /// Other threads (e.g. `#[tokio::test]` pool workers) emit through their
8376    /// own (typically NoSubscriber) dispatchers and never reach this instance.
8377    struct CaptureSubscriber {
8378        events: Arc<StdMutex<Vec<CapturedEvent>>>,
8379    }
8380
8381    impl CaptureSubscriber {
8382        fn new(events: Arc<StdMutex<Vec<CapturedEvent>>>) -> Self {
8383            Self { events }
8384        }
8385    }
8386
8387    impl tracing::Subscriber for CaptureSubscriber {
8388        fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
8389            true
8390        }
8391        fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
8392            tracing::span::Id::from_u64(1)
8393        }
8394        fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
8395        fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
8396        fn event(&self, event: &tracing::Event<'_>) {
8397            let mut visitor = CapturedEventVisitor::default();
8398            event.record(&mut visitor);
8399            let captured = visitor.0;
8400            // Tee the post-commit budget logs into their own append-only sink:
8401            // `capture_dispatch_events` clears the main buffer, so a reader of
8402            // budget events sharing that buffer would race the clear.
8403            if let (Some(message), Some(into_id)) = (&captured.message, &captured.into_id) {
8404                if message.ends_with("transaction materialization budget") {
8405                    budget_events_sink()
8406                        .lock()
8407                        .unwrap()
8408                        .push(CapturedBudgetLog {
8409                            message: message.clone(),
8410                            into_id: into_id.clone(),
8411                            budget_rows: captured.budget_rows.unwrap_or(0),
8412                        });
8413                }
8414            }
8415            self.events.lock().unwrap().push(captured);
8416        }
8417        fn enter(&self, _: &tracing::span::Id) {}
8418        fn exit(&self, _: &tracing::span::Id) {}
8419    }
8420
8421    /// Global capture buffer for the tracing tests.
8422    ///
8423    /// The subscriber is installed exactly once via `set_global_default`
8424    /// (thread-local dispatchers via `with_default` proved unreliable when
8425    /// other tests in the binary configure their own dispatchers in parallel —
8426    /// the global state interacted unpredictably and events were lost).
8427    ///
8428    /// Each test that uses this buffer is `#[serial]`, so only one
8429    /// runs at a time. The buffer is cleared at the start of each capture call.
8430    static GLOBAL_CAPTURE: OnceLock<Arc<StdMutex<Vec<CapturedEvent>>>> = OnceLock::new();
8431    static GLOBAL_INIT: Once = Once::new();
8432
8433    /// One captured post-commit budget log (curation merge tests).
8434    #[derive(Clone)]
8435    pub(crate) struct CapturedBudgetLog {
8436        pub(crate) message: String,
8437        pub(crate) into_id: String,
8438        pub(crate) budget_rows: u64,
8439    }
8440
8441    /// Append-only sink the subscriber tees budget logs into. Never cleared:
8442    /// curation tests select their own rows by `into_id`, so stale rows from
8443    /// other tests are inert rather than a pollution hazard.
8444    static BUDGET_EVENTS: OnceLock<Arc<StdMutex<Vec<CapturedBudgetLog>>>> = OnceLock::new();
8445
8446    fn budget_events_sink() -> Arc<StdMutex<Vec<CapturedBudgetLog>>> {
8447        Arc::clone(BUDGET_EVENTS.get_or_init(|| Arc::new(StdMutex::new(Vec::new()))))
8448    }
8449
8450    /// Entry point for the curation merge tests: installs the process-global
8451    /// capture subscriber (once for the whole test binary — a second
8452    /// `set_global_default` elsewhere would starve one of the captures) and
8453    /// returns the budget-log sink it tees into.
8454    pub(crate) fn budget_log_events() -> Arc<StdMutex<Vec<CapturedBudgetLog>>> {
8455        let _ = global_capture();
8456        budget_events_sink()
8457    }
8458
8459    fn global_capture() -> Arc<StdMutex<Vec<CapturedEvent>>> {
8460        GLOBAL_INIT.call_once(|| {
8461            let buffer = Arc::new(StdMutex::new(Vec::new()));
8462            let subscriber = CaptureSubscriber::new(Arc::clone(&buffer));
8463            // Ignore error: if another subscriber is already set globally, our
8464            // subscriber installation fails, but the buffer will simply stay
8465            // empty and tests will fail with a clear "got 0 events" message
8466            // rather than a silent corruption.
8467            let _ = tracing::subscriber::set_global_default(subscriber);
8468            let _ = GLOBAL_CAPTURE.set(buffer);
8469        });
8470        Arc::clone(GLOBAL_CAPTURE.get().expect("global capture initialized"))
8471    }
8472
8473    /// Run an async block under the global capture subscriber and return
8474    /// the events emitted during the run. Clears the buffer at the start.
8475    ///
8476    /// Callers MUST be `#[serial]` to prevent concurrent buffer pollution.
8477    fn capture_dispatch_events<Fut>(future: Fut) -> Vec<CapturedEvent>
8478    where
8479        Fut: std::future::Future<Output = ()>,
8480    {
8481        let buffer = global_capture();
8482        buffer.lock().unwrap().clear();
8483
8484        let rt = tokio::runtime::Builder::new_current_thread()
8485            .enable_all()
8486            .build()
8487            .expect("build current-thread tokio runtime");
8488        rt.block_on(future);
8489
8490        let result = buffer.lock().unwrap().clone();
8491        result
8492    }
8493
8494    /// Pull every captured event whose `message` matches `"gate.check"` AND
8495    /// whose audit_event JSON declares the expected `gate_impl` name.
8496    ///
8497    /// Filtering by `gate_impl` lets concurrent tests in the same binary
8498    /// emit their own gate.check events into the global capture buffer
8499    /// without polluting each others' counts.
8500    fn gate_check_events_for(events: &[CapturedEvent], gate_impl: &str) -> Vec<CapturedEvent> {
8501        events
8502            .iter()
8503            .filter(|e| e.message.as_deref() == Some("gate.check"))
8504            .filter(|e| {
8505                e.audit_event
8506                    .as_deref()
8507                    .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
8508                    .and_then(|v| {
8509                        v.get("gate_impl")
8510                            .and_then(|g| g.as_str().map(|s| s.to_string()))
8511                    })
8512                    .as_deref()
8513                    == Some(gate_impl)
8514            })
8515            .cloned()
8516            .collect()
8517    }
8518
8519    #[test]
8520    #[serial]
8521    fn dispatch_tracing_emits_one_gate_check_event_on_allow() {
8522        #[derive(Debug)]
8523        struct TracingAllowGate;
8524        impl Gate for TracingAllowGate {
8525            fn check(&self, _: &GateRequest) -> Result<GateDecision, GateError> {
8526                Ok(GateDecision::allow())
8527            }
8528            fn impl_name(&self) -> &'static str {
8529                "TracingAllowGate"
8530            }
8531        }
8532
8533        let events = capture_dispatch_events(async {
8534            let mut builder = VerbRegistryBuilder::new();
8535            builder.register(AlphaPack);
8536            builder.with_gate(Arc::new(TracingAllowGate));
8537            builder.with_default_namespace("tenant-default");
8538            let reg = builder.build().expect("registry builds");
8539            reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
8540                .await
8541                .unwrap();
8542        });
8543
8544        let gate_events = gate_check_events_for(&events, "TracingAllowGate");
8545        assert_eq!(
8546            gate_events.len(),
8547            1,
8548            "exactly one gate.check tracing event per dispatch (allow); got {gate_events:?}"
8549        );
8550        let payload = gate_events[0]
8551            .audit_event
8552            .as_ref()
8553            .expect("gate.check event must carry an audit_event field");
8554        let audit: khive_gate::AuditEvent =
8555            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
8556        assert_eq!(audit.decision, AuditDecision::Allow);
8557        assert_eq!(audit.verb, "list");
8558        assert_eq!(audit.namespace, "tenant-q");
8559        assert_eq!(audit.gate_impl, "TracingAllowGate");
8560        assert!(
8561            audit.deny_reason.is_none(),
8562            "deny_reason must be None on Allow"
8563        );
8564    }
8565
8566    #[test]
8567    #[serial]
8568    fn dispatch_tracing_emits_one_gate_check_event_when_gate_is_unavailable() {
8569        #[derive(Debug)]
8570        struct TracingUnavailableGate;
8571        impl Gate for TracingUnavailableGate {
8572            fn check(&self, _: &GateRequest) -> Result<GateDecision, GateError> {
8573                Err(GateError::Internal("tracing gate broken".into()))
8574            }
8575
8576            fn impl_name(&self) -> &'static str {
8577                "TracingUnavailableGate"
8578            }
8579        }
8580
8581        let events = capture_dispatch_events(async {
8582            let mut builder = VerbRegistryBuilder::new();
8583            builder.register(AlphaPack);
8584            builder.with_gate(Arc::new(TracingUnavailableGate));
8585            let reg = builder.build().expect("registry builds");
8586            let error = reg
8587                .dispatch("list", Value::Null)
8588                .await
8589                .expect_err("gate outage must refuse dispatch");
8590            assert!(matches!(error, RuntimeError::GateUnavailable { .. }));
8591        });
8592
8593        let gate_events = gate_check_events_for(&events, "TracingUnavailableGate");
8594        assert_eq!(
8595            gate_events.len(),
8596            1,
8597            "exactly one gate.check tracing event per gate outage; got {gate_events:?}"
8598        );
8599        let payload = gate_events[0]
8600            .audit_event
8601            .as_ref()
8602            .expect("gate outage trace must carry an audit_event field");
8603        let audit: AuditEvent =
8604            serde_json::from_str(payload).expect("audit_event payload must decode");
8605        assert_eq!(audit.decision, AuditDecision::GateUnavailable);
8606        assert!(audit.deny_reason.is_none());
8607        assert!(audit.obligations.is_empty());
8608        assert_eq!(audit.gate_impl, "TracingUnavailableGate");
8609    }
8610
8611    // ---- Hard enforcement + EventStore persistence ----
8612
8613    use crate::runtime::NamespaceToken;
8614    use async_trait::async_trait;
8615    use khive_storage::{
8616        BatchWriteSummary, Event, EventFilter, EventStore, Page, PageRequest, SubstrateKind,
8617    };
8618    use khive_types::EventOutcome;
8619
8620    /// Minimal stand-in for the Git pack: the receipt contract belongs to the
8621    /// runtime dispatch seam, so these tests do not need a git repository or
8622    /// any Git-pack dependency.
8623    struct GitDigestResultPack {
8624        project_id: uuid::Uuid,
8625    }
8626
8627    impl Pack for GitDigestResultPack {
8628        const NAME: &'static str = "git";
8629        const NOTE_KINDS: &'static [&'static str] = &[];
8630        const ENTITY_KINDS: &'static [&'static str] = &[];
8631        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
8632            name: "git.digest",
8633            description: "return a deterministic digest report",
8634            visibility: Visibility::Verb,
8635            category: VerbCategory::Assertive,
8636            params: &[],
8637        }];
8638    }
8639
8640    #[async_trait]
8641    impl PackRuntime for GitDigestResultPack {
8642        fn name(&self) -> &str {
8643            Self::NAME
8644        }
8645        fn note_kinds(&self) -> &'static [&'static str] {
8646            Self::NOTE_KINDS
8647        }
8648        fn entity_kinds(&self) -> &'static [&'static str] {
8649            Self::ENTITY_KINDS
8650        }
8651        fn handlers(&self) -> &'static [HandlerDef] {
8652            Self::HANDLERS
8653        }
8654        async fn dispatch(
8655            &self,
8656            _verb: &str,
8657            _params: Value,
8658            _registry: &VerbRegistry,
8659            _token: &NamespaceToken,
8660        ) -> Result<Value, RuntimeError> {
8661            Ok(serde_json::json!({
8662                "project_id": self.project_id,
8663                "project_created": false,
8664                "commits_ingested": 2,
8665                "commits_skipped_existing": 1,
8666                "issues_ingested": 3,
8667                "issues_skipped_existing": 4,
8668                "prs_ingested": 5,
8669                "prs_skipped_existing": 6,
8670                "done": true,
8671                "history_exhausted": true,
8672                "sources": {
8673                    "commits": {"state": "completed"},
8674                    "issues": {"state": "completed"},
8675                    "pull_requests": {"state": "completed"}
8676                },
8677                "warnings": []
8678            }))
8679        }
8680    }
8681
8682    /// A nominally successful handler with an invalid receipt identity. The
8683    /// runtime must turn this into an error without consuming its generic
8684    /// audit fallback.
8685    struct MalformedGitDigestResultPack;
8686
8687    impl Pack for MalformedGitDigestResultPack {
8688        const NAME: &'static str = "malformed-git";
8689        const NOTE_KINDS: &'static [&'static str] = &[];
8690        const ENTITY_KINDS: &'static [&'static str] = &[];
8691        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
8692            name: "git.digest",
8693            description: "return a malformed digest report",
8694            visibility: Visibility::Verb,
8695            category: VerbCategory::Assertive,
8696            params: &[],
8697        }];
8698    }
8699
8700    #[async_trait]
8701    impl PackRuntime for MalformedGitDigestResultPack {
8702        fn name(&self) -> &str {
8703            Self::NAME
8704        }
8705        fn note_kinds(&self) -> &'static [&'static str] {
8706            Self::NOTE_KINDS
8707        }
8708        fn entity_kinds(&self) -> &'static [&'static str] {
8709            Self::ENTITY_KINDS
8710        }
8711        fn handlers(&self) -> &'static [HandlerDef] {
8712            Self::HANDLERS
8713        }
8714        async fn dispatch(
8715            &self,
8716            _verb: &str,
8717            _params: Value,
8718            _registry: &VerbRegistry,
8719            _token: &NamespaceToken,
8720        ) -> Result<Value, RuntimeError> {
8721            Ok(serde_json::json!({
8722                "project_id": "not-a-uuid",
8723                "done": true,
8724            }))
8725        }
8726    }
8727
8728    /// One entry in the interleaved submission trace. Typed so assertions
8729    /// match on fields instead of parsing a formatted string; both sides of
8730    /// the ordering land on ONE vector so their relative order is observable
8731    /// rather than assumed.
8732    #[derive(Debug, Clone, PartialEq)]
8733    enum TraceEntry {
8734        /// A handler effect that has committed (nothing downstream undoes it).
8735        Effect { name: String },
8736        /// An audit row submitted to the event store, carrying exactly the
8737        /// fields the obligation test discriminates on.
8738        Audit {
8739            kind: EventKind,
8740            outcome: EventOutcome,
8741            verb: String,
8742            /// The row's `resource.cost_unit`, `None` when the payload omits
8743            /// it (the error path's `base_resource_payload` does).
8744            cost_unit: Option<Value>,
8745        },
8746    }
8747
8748    /// In-memory EventStore for unit tests — avoids file-backed SQLite.
8749    #[derive(Default, Debug)]
8750    struct MemoryEventStore {
8751        events: std::sync::Mutex<Vec<Event>>,
8752        fail_appends: bool,
8753        /// Fail only a generation whose batch contains an event of this
8754        /// kind, leaving every other generation (e.g. the deferred
8755        /// obligation row committed after dispatch resolves) to commit
8756        /// normally. Lets a test fail a pure-observability row without
8757        /// also failing the obligation row that shares the same store.
8758        fail_kind: Option<EventKind>,
8759        /// Append-ordered record of what was SUBMITTED to this store,
8760        /// written before the injected-failure check so a submission this
8761        /// store then rejects is still visible.
8762        ///
8763        /// `events` alone cannot show that: a rejected append returns before
8764        /// the store records anything, so a test reading `events` cannot
8765        /// tell a row that failed to commit from a row that was never built.
8766        /// Those are different production behaviours and only one of them is
8767        /// the audit contract. A test that hands the same vector to its
8768        /// handler also gets the ordering between the handler's effect and
8769        /// the audit submission, which is the only way to observe that the
8770        /// audit row is written after the handler rather than before it.
8771        trace: Option<Arc<std::sync::Mutex<Vec<TraceEntry>>>>,
8772        /// Hold a real batch append across the caller's audit deadline.
8773        append_started: Option<Arc<tokio::sync::Notify>>,
8774        append_release: Option<Arc<tokio::sync::Notify>>,
8775    }
8776
8777    impl MemoryEventStore {
8778        /// Record a submission attempt. Call before any failure check.
8779        ///
8780        /// The projection carries the verb and the row's `resource.cost_unit`
8781        /// value, not just kind and outcome, and not merely whether the key is
8782        /// present.
8783        ///
8784        /// Presence alone is too weak to pin what it looks like it pins.
8785        /// `cost_unit::resource_payload` inserts the key unconditionally, and
8786        /// for most verbs `item_count` returns a constant `1` regardless of the
8787        /// handler's return value, so a submission built from a static or null
8788        /// `ok_val` still carries the key. Presence separates `resource_payload`
8789        /// from the error path's `base_resource_payload`, which is a real
8790        /// property but a different one.
8791        ///
8792        /// The value is what sources the return value, and only for a verb whose
8793        /// `item_count` reads it. `knowledge.index` is that verb: `item_count`
8794        /// takes `result["total"]`, so `cost_unit` is `total + 1` and moves with
8795        /// what the handler returned. The fixture below uses that verb for
8796        /// exactly this reason.
8797        fn trace_submission(&self, events: &[Event]) {
8798            if let Some(trace) = &self.trace {
8799                let mut trace = trace.lock().expect("trace lock");
8800                for event in events {
8801                    let cost_unit = event
8802                        .payload
8803                        .get("resource")
8804                        .and_then(|resource| resource.get("cost_unit"))
8805                        .cloned();
8806                    trace.push(TraceEntry::Audit {
8807                        kind: event.kind,
8808                        outcome: event.outcome,
8809                        verb: event.verb.clone(),
8810                        cost_unit,
8811                    });
8812                }
8813            }
8814        }
8815    }
8816
8817    #[async_trait]
8818    impl EventStore for MemoryEventStore {
8819        async fn append_event(&self, event: Event) -> khive_storage::StorageResult<()> {
8820            self.trace_submission(std::slice::from_ref(&event));
8821            if self.fail_appends || self.fail_kind == Some(event.kind) {
8822                return Err(khive_storage::StorageError::Internal(
8823                    "injected audit append failure".to_string(),
8824                ));
8825            }
8826            self.events.lock().unwrap().push(event);
8827            Ok(())
8828        }
8829        async fn append_events(
8830            &self,
8831            events: Vec<Event>,
8832        ) -> khive_storage::StorageResult<BatchWriteSummary> {
8833            self.trace_submission(&events);
8834            let attempted = events.len() as u64;
8835            let affected = attempted;
8836            self.events.lock().unwrap().extend(events);
8837            Ok(BatchWriteSummary {
8838                attempted,
8839                affected,
8840                ..BatchWriteSummary::default()
8841            })
8842        }
8843        async fn get_event(&self, id: uuid::Uuid) -> khive_storage::StorageResult<Option<Event>> {
8844            Ok(self
8845                .events
8846                .lock()
8847                .unwrap()
8848                .iter()
8849                .find(|e| e.id == id)
8850                .cloned())
8851        }
8852        async fn query_events(
8853            &self,
8854            _filter: EventFilter,
8855            _page: PageRequest,
8856        ) -> khive_storage::StorageResult<Page<Event>> {
8857            let items = self.events.lock().unwrap().clone();
8858            let total = items.len() as u64;
8859            Ok(Page {
8860                items,
8861                total: Some(total),
8862            })
8863        }
8864        async fn count_events(&self, _filter: EventFilter) -> khive_storage::StorageResult<u64> {
8865            Ok(self.events.lock().unwrap().len() as u64)
8866        }
8867
8868        fn preflight_event(&self, _event: &Event) -> khive_storage::StorageResult<()> {
8869            Ok(())
8870        }
8871
8872        async fn append_events_idempotent(
8873            &self,
8874            events: Vec<Event>,
8875        ) -> khive_storage::StorageResult<khive_storage::event::IdempotentEventBatchResult>
8876        {
8877            if let Some(started) = &self.append_started {
8878                started.notify_one();
8879            }
8880            if let Some(release) = &self.append_release {
8881                release.notified().await;
8882            }
8883            self.trace_submission(&events);
8884            if self.fail_appends
8885                || self
8886                    .fail_kind
8887                    .is_some_and(|kind| events.iter().any(|e| e.kind == kind))
8888            {
8889                return Err(khive_storage::StorageError::Internal(
8890                    "injected audit append failure".to_string(),
8891                ));
8892            }
8893            let mut store = self.events.lock().unwrap();
8894            let mut rows = Vec::with_capacity(events.len());
8895            for event in events {
8896                if let Some(existing) = store.iter().find(|e| e.id == event.id) {
8897                    if *existing == event {
8898                        rows.push(
8899                            khive_storage::event::EventAppendDisposition::AlreadyPresentIdentical,
8900                        );
8901                    } else {
8902                        rows.push(khive_storage::event::EventAppendDisposition::IdentityConflict);
8903                    }
8904                } else {
8905                    store.push(event);
8906                    rows.push(khive_storage::event::EventAppendDisposition::Inserted);
8907                }
8908            }
8909            Ok(khive_storage::event::IdempotentEventBatchResult { rows })
8910        }
8911
8912        fn supports_idempotent_audit_batch(&self) -> bool {
8913            true
8914        }
8915    }
8916
8917    /// Recursively collect every `.rs` file under `dir` into `out`.
8918    fn collect_rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
8919        let Ok(entries) = std::fs::read_dir(dir) else {
8920            return;
8921        };
8922        for entry in entries.filter_map(Result::ok) {
8923            let path = entry.path();
8924            if path.is_dir() {
8925                collect_rust_files(&path, out);
8926            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
8927                out.push(path);
8928            }
8929        }
8930    }
8931
8932    /// Every `crates/<crate>/src/**/*.rs` and `crates/<crate>/tests/**/*.rs`
8933    /// file in the workspace, read to a `String` alongside its path.
8934    ///
8935    /// This is the compiled-test population an event-backed registry
8936    /// constructor can actually be reached from: unit tests live under
8937    /// `src/`, integration tests live under `tests/`. Anything outside those
8938    /// two directories per crate (benches, examples) never runs as `cargo
8939    /// test` and is out of scope for this census.
8940    /// SQL text belongs in `sql/<name>.sql`, reached through each crate's `sql!`
8941    /// macro, not in a Rust string literal. This is the burn-down instrument for that
8942    /// move: a crate is added to `CONVERTED` by the pull request that extracts it, and
8943    /// from then on the workspace refuses to take a statement back into Rust.
8944    ///
8945    /// What it can and cannot see, said plainly because the answer is load-bearing.
8946    /// It selects by SPELLING: a string literal whose first word is a SQL verb. It
8947    /// therefore cannot see a statement assembled from fragments, one returned by a
8948    /// helper, or one built at runtime. Its population is code that COMPILES into the
8949    /// crate, which means `#[cfg(test)]` module bodies are stripped along with
8950    /// `tests/` and `benches/` — a test that stands a fixture table up inline is out
8951    /// of scope for this program. The must-match control below is what keeps those
8952    /// limits honest: an unconverted crate has to trip the same predicate in the same
8953    /// pass, or the detector is broken rather than the tree clean.
8954    #[test]
8955    fn converted_crates_keep_their_sql_out_of_rust() {
8956        /// Crates whose statements live in `sql/`. One pull request adds one name.
8957        const CONVERTED: &[&str] = &[
8958            "khive-pack-brain",
8959            "khive-pack-git",
8960            "khive-pack-kg",
8961            "kkernel",
8962        ];
8963        /// A crate known to still hold SQL in Rust, used only to prove the detector
8964        /// fires. When this one is converted, move the control to another unconverted
8965        /// crate rather than deleting it.
8966        const STILL_INLINE: &str = "khive-db";
8967
8968        fn strip_test_modules(text: &str) -> String {
8969            let bytes = text.as_bytes();
8970            let mut out = String::with_capacity(text.len());
8971            let mut cursor = 0usize;
8972            while let Some(found) = text[cursor..].find("#[cfg(test)]") {
8973                let start = cursor + found;
8974                // Only a `mod` item is stripped; `#[cfg(test)]` on a `use` or a `fn`
8975                // leaves nothing to brace-match.
8976                let after = &text[start..];
8977                let Some(brace_rel) = after.find('{') else {
8978                    out.push_str(&text[cursor..]);
8979                    return out;
8980                };
8981                if !after[..brace_rel].contains("mod ") {
8982                    out.push_str(&text[cursor..start + brace_rel]);
8983                    cursor = start + brace_rel;
8984                    continue;
8985                }
8986                out.push_str(&text[cursor..start]);
8987                let mut depth = 0usize;
8988                let mut i = start + brace_rel;
8989                while i < bytes.len() {
8990                    match bytes[i] {
8991                        b'{' => depth += 1,
8992                        b'}' => {
8993                            depth -= 1;
8994                            if depth == 0 {
8995                                i += 1;
8996                                break;
8997                            }
8998                        }
8999                        _ => {}
9000                    }
9001                    i += 1;
9002                }
9003                cursor = i;
9004            }
9005            out.push_str(&text[cursor..]);
9006            out
9007        }
9008
9009        /// The body of the Rust string literal whose opening quote is at `open`, or
9010        /// `None` if it does not terminate. Escapes are skipped rather than decoded:
9011        /// this only has to find the end and hand back text to match against.
9012        fn literal_body(text: &str, open: usize) -> Option<&str> {
9013            let bytes = text.as_bytes();
9014            let mut i = open + 1;
9015            while i < bytes.len() {
9016                match bytes[i] {
9017                    b'\\' => i += 2,
9018                    b'"' => return text.get(open + 1..i),
9019                    _ => i += 1,
9020                }
9021            }
9022            None
9023        }
9024
9025        /// One line, single-spaced. A statement in Rust wears its line breaks three
9026        /// ways — a real newline in a raw string, a `\n` escape, or a backslash line
9027        /// continuation — and this scan reads source text, so all three have to read as
9028        /// one space before any keyword after the first can be matched. `\n` is two
9029        /// characters here, and dropping only the backslash would leave `nFROM`, which
9030        /// is exactly how this check first failed its own must-fail control.
9031        fn flatten(body: &str) -> String {
9032            let mut out = String::with_capacity(body.len());
9033            let mut chars = body.chars();
9034            while let Some(c) = chars.next() {
9035                if c != '\\' {
9036                    out.push(c);
9037                    continue;
9038                }
9039                match chars.clone().next() {
9040                    // An escape that stands for whitespace: consume both characters.
9041                    Some('n' | 't' | 'r') => {
9042                        chars.next();
9043                        out.push(' ');
9044                    }
9045                    // A line continuation, or any other escape: the backslash goes,
9046                    // what follows is kept and judged on its own.
9047                    _ => out.push(' '),
9048                }
9049            }
9050            out.split_whitespace().collect::<Vec<_>>().join(" ")
9051        }
9052
9053        fn sql_literals(text: &str) -> Vec<String> {
9054            // A leading verb alone is a heuristic, and it is wrong often enough to
9055            // matter: "insert serve batch" is an error label and "Create a new brain
9056            // profile with given name" is a verb description, and both start with a
9057            // SQL verb. So a literal has to carry a second structural keyword too, and
9058            // both are matched CASE-SENSITIVELY, because every statement in this tree
9059            // writes its keywords in upper case and English prose does not.
9060            const SHAPES: [(&str, &[&str]); 10] = [
9061                // A statement need not start with a verb at all. A common table
9062                // expression starts with WITH, and there are a dozen of them in this
9063                // workspace, so a census that only knows verbs reads a crate clean while
9064                // its largest queries sit in Rust. The second keyword here is the CTE's
9065                // own binding, which prose does not write.
9066                ("WITH ", &[" AS ("]),
9067                ("SELECT ", &[" FROM "]),
9068                ("INSERT ", &["INSERT INTO ", "INSERT OR "]),
9069                ("UPDATE ", &[" SET "]),
9070                ("DELETE ", &["DELETE FROM "]),
9071                (
9072                    "CREATE ",
9073                    &[
9074                        "CREATE TABLE",
9075                        "CREATE INDEX",
9076                        "CREATE UNIQUE",
9077                        "CREATE VIEW",
9078                        "CREATE VIRTUAL",
9079                        "CREATE TRIGGER",
9080                    ],
9081                ),
9082                (
9083                    "DROP ",
9084                    &["DROP TABLE", "DROP INDEX", "DROP VIEW", "DROP TRIGGER"],
9085                ),
9086                ("ALTER ", &["ALTER TABLE"]),
9087                ("PRAGMA ", &["PRAGMA "]),
9088                ("REPLACE ", &["REPLACE INTO "]),
9089            ];
9090            let mut found = Vec::new();
9091            for (index, _) in text.match_indices('"') {
9092                let Some(body) = literal_body(text, index) else {
9093                    continue;
9094                };
9095                let flat = flatten(body);
9096                let Some((verb, seconds)) = SHAPES.iter().find(|(v, _)| flat.starts_with(*v))
9097                else {
9098                    continue;
9099                };
9100                if !seconds.iter().any(|second| flat.contains(second)) {
9101                    continue;
9102                }
9103                let snippet: String = flat.chars().take(70).collect();
9104                found.push(format!("{verb}… {snippet}"));
9105            }
9106            found
9107        }
9108
9109        let sources = workspace_rust_sources();
9110        assert!(
9111            !sources.is_empty(),
9112            "the workspace source walk returned nothing, so this census read no code"
9113        );
9114
9115        let mut scanned_files = 0usize;
9116        let mut offenders: Vec<String> = Vec::new();
9117        let mut control_hits = 0usize;
9118        for (path, text) in &sources {
9119            let display = path.display().to_string();
9120            if display.contains("/tests/") || display.contains("/benches/") {
9121                continue;
9122            }
9123            let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
9124            if file_name == "tests.rs" || file_name.ends_with("_tests.rs") {
9125                continue;
9126            }
9127            let production = strip_test_modules(text);
9128            if display.contains(&format!("/{STILL_INLINE}/")) {
9129                control_hits += sql_literals(&production).len();
9130                continue;
9131            }
9132            let Some(crate_name) = CONVERTED
9133                .iter()
9134                .find(|name| display.contains(&format!("/{name}/")))
9135            else {
9136                continue;
9137            };
9138            scanned_files += 1;
9139            for literal in sql_literals(&production) {
9140                offenders.push(format!("{crate_name} {}: {literal}", path.display()));
9141            }
9142        }
9143
9144        // Must-fail control: take every statement this program has already extracted,
9145        // write it back into a Rust literal in each of the three shapes a statement can
9146        // take in Rust source, and require the predicate to catch each one. The
9147        // must-match control below proves the predicate fires SOMEWHERE; this proves it
9148        // fires on exactly the regression the census exists to stop, which is a
9149        // converted statement coming home. The escaped-newline shape is not decoration:
9150        // an earlier version of `flatten` dropped the backslash and left `nFROM`, and
9151        // six of eleven statements would have come back unseen.
9152        let mut round_tripped = 0usize;
9153        let crates_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
9154            .parent()
9155            .expect("khive-runtime's Cargo.toml lives directly under crates/")
9156            .to_path_buf();
9157        for crate_name in CONVERTED {
9158            let sql_dir = crates_root.join(crate_name).join("sql");
9159            let entries = std::fs::read_dir(&sql_dir).unwrap_or_else(|e| {
9160                panic!("{crate_name} is on the converted list but {sql_dir:?} is unreadable: {e}")
9161            });
9162            for entry in entries.filter_map(Result::ok) {
9163                let file = entry.path();
9164                if file.extension().and_then(|e| e.to_str()) != Some("sql") {
9165                    continue;
9166                }
9167                let statement =
9168                    std::fs::read_to_string(&file).unwrap_or_else(|e| panic!("read {file:?}: {e}"));
9169                // A file may open with a header comment saying what it is and where its
9170                // authoritative definition lives. A Rust literal carries no such header,
9171                // so the round trip has to drop it: otherwise the rendered literal opens
9172                // with `--` and the predicate correctly sees no statement, which reads as
9173                // a broken census rather than as a file with a preamble.
9174                let body = statement
9175                    .lines()
9176                    .skip_while(|line| {
9177                        let start = line.trim_start();
9178                        start.is_empty() || start.starts_with("--")
9179                    })
9180                    .collect::<Vec<_>>()
9181                    .join("\n");
9182                assert!(
9183                    !body.trim().is_empty(),
9184                    "{file:?} holds nothing but comments, so it declares no statement for \
9185                     the census to protect"
9186                );
9187                // A quoted identifier would otherwise close the synthetic literal early
9188                // and fail this control for a reason that has nothing to do with it.
9189                let statement = body.trim().replace('"', "\\\"");
9190                let shapes = [
9191                    (
9192                        "one line",
9193                        statement.split_whitespace().collect::<Vec<_>>().join(" "),
9194                    ),
9195                    ("escaped newlines", statement.replace('\n', "\\n")),
9196                    (
9197                        "line continuations",
9198                        statement.replace('\n', " \\\n            "),
9199                    ),
9200                ];
9201                for (shape, rendered) in shapes {
9202                    let snippet = format!("let statement = \"{rendered}\";");
9203                    let seen = sql_literals(&snippet);
9204                    assert_eq!(
9205                        seen.len(),
9206                        1,
9207                        "must-fail control: {file:?} written back into Rust as {shape} was \
9208                         seen {} time(s), so the census would not notice this statement \
9209                         moving home",
9210                        seen.len()
9211                    );
9212                    round_tripped += 1;
9213                }
9214            }
9215        }
9216        assert!(
9217            round_tripped > 0,
9218            "must-fail control ran on nothing: {CONVERTED:?} contributed no .sql files, so \
9219             its passing says only that the loop body never executed"
9220        );
9221
9222        assert!(
9223            control_hits > 0,
9224            "must-match control: {STILL_INLINE} still holds SQL in Rust, so a detector \
9225             finding none there is broken and its clean reading of {CONVERTED:?} means nothing"
9226        );
9227        assert!(
9228            scanned_files > 0,
9229            "no source file matched {CONVERTED:?}; the crate names in that list are how this \
9230             census finds its population, so an empty match reads clean for the wrong reason"
9231        );
9232        assert!(
9233            offenders.is_empty(),
9234            "SQL text belongs in sql/<name>.sql behind that crate's sql! macro; \
9235             {} offender(s) across {scanned_files} file(s): {offenders:#?}",
9236            offenders.len()
9237        );
9238    }
9239
9240    fn workspace_rust_sources() -> Vec<(std::path::PathBuf, String)> {
9241        let crates_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
9242            .parent()
9243            .expect("khive-runtime's Cargo.toml lives directly under crates/")
9244            .to_path_buf();
9245        let mut files = Vec::new();
9246        let Ok(crate_dirs) = std::fs::read_dir(&crates_root) else {
9247            return Vec::new();
9248        };
9249        for crate_dir in crate_dirs.filter_map(Result::ok) {
9250            let crate_dir = crate_dir.path();
9251            if !crate_dir.is_dir() {
9252                continue;
9253            }
9254            for sub in ["src", "tests"] {
9255                let sub_dir = crate_dir.join(sub);
9256                if sub_dir.is_dir() {
9257                    collect_rust_files(&sub_dir, &mut files);
9258                }
9259            }
9260        }
9261        files
9262            .into_iter()
9263            .filter_map(|path| {
9264                let text = std::fs::read_to_string(&path).ok()?;
9265                Some((path, text))
9266            })
9267            .collect()
9268    }
9269
9270    /// The name of the function whose signature starts at `sig_line`
9271    /// (already stripped of leading whitespace), if any.
9272    fn fn_name_from_signature(sig_line: &str) -> Option<&str> {
9273        let mut rest = sig_line;
9274        for prefix in ["pub(crate) ", "pub(super) ", "pub "] {
9275            if let Some(stripped) = rest.strip_prefix(prefix) {
9276                rest = stripped;
9277            }
9278        }
9279        let rest = rest
9280            .strip_prefix("async fn ")
9281            .or_else(|| rest.strip_prefix("fn "))?;
9282        Some(rest.split(['(', '<', ' ']).next().unwrap_or(rest))
9283    }
9284
9285    /// `true` if `line`, once whitespace is trimmed, is a top-level `fn`
9286    /// signature start (covering the `pub`/`pub(crate)`/`pub(super)` and
9287    /// `async` modifiers actually used across this workspace).
9288    fn is_fn_signature_line(trimmed: &str) -> bool {
9289        fn_name_from_signature(trimmed).is_some()
9290    }
9291
9292    /// Blank out the contents of every `"..."` string literal in `text`
9293    /// (escapes included), line by line, so a seam name mentioned in an
9294    /// error message or `.expect(...)` string — e.g. `pack.rs`'s own
9295    /// `"...do not call with_event_store() for this backend."` — can never
9296    /// read as a call. This only needs to handle ordinary quoted strings:
9297    /// nothing in this workspace's actual seam-adjacent code uses raw
9298    /// strings or multi-line string literals for text that could collide
9299    /// with a seam or helper name.
9300    fn strip_string_literals(text: &str) -> String {
9301        let mut out = String::with_capacity(text.len());
9302        // Persists across lines on purpose: this workspace's longer error
9303        // and `.expect(...)` messages routinely use backslash-newline
9304        // string continuations (see `pack.rs`'s own
9305        // `IncompatibleEventStore` message), so a literal spanning several
9306        // source lines must stay "in string" across all of them.
9307        let mut in_string = false;
9308        for line in text.lines() {
9309            let mut chars = line.chars();
9310            while let Some(c) = chars.next() {
9311                if in_string {
9312                    if c == '\\' {
9313                        out.push(' ');
9314                        if chars.next().is_some() {
9315                            out.push(' ');
9316                        }
9317                        continue;
9318                    }
9319                    if c == '"' {
9320                        in_string = false;
9321                        out.push('"');
9322                    } else {
9323                        out.push(' ');
9324                    }
9325                } else {
9326                    if c == '"' {
9327                        in_string = true;
9328                    }
9329                    out.push(c);
9330                }
9331            }
9332            out.push('\n');
9333        }
9334        out
9335    }
9336
9337    /// `true` if `text` contains a call to `name` — `name` immediately
9338    /// followed by `(` (optional whitespace between, including newlines —
9339    /// `rustfmt` is free to break a long call onto its own line, and a call
9340    /// site that happens to fit on one line today is not a
9341    /// property this scan may rely on), with a non-identifier character (or
9342    /// start of text) before it, not immediately preceded by `fn ` (which
9343    /// would make this the definition, not a call), and not inside a string
9344    /// literal (which would make this prose, not a call).
9345    ///
9346    /// The identifier-boundary check is load-bearing: a naive
9347    /// `text.contains(format!("{name}("))` matches `fixture(` inside
9348    /// `daemon_script_fixture(`, which is a different, unrelated function —
9349    /// this is the difference between a real population scan and one that
9350    /// explodes into every helper in the workspace that happens to share a
9351    /// suffix.
9352    fn calls_name(text: &str, name: &str) -> bool {
9353        fn is_ident_byte(b: u8) -> bool {
9354            b.is_ascii_alphanumeric() || b == b'_'
9355        }
9356        let text = strip_string_literals(text);
9357        let bytes = text.as_bytes();
9358        let mut search_from = 0usize;
9359        while let Some(rel) = text[search_from..].find(name) {
9360            let idx = search_from + rel;
9361            let before_ok = idx == 0 || !is_ident_byte(bytes[idx - 1]);
9362            let after = idx + name.len();
9363            let mut j = after;
9364            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
9365                j += 1;
9366            }
9367            let after_ok = j < bytes.len() && bytes[j] == b'(';
9368            let is_definition = text[..idx].ends_with("fn ");
9369            if before_ok && after_ok && !is_definition {
9370                return true;
9371            }
9372            search_from = idx + 1;
9373        }
9374        false
9375    }
9376
9377    /// Regression for a scanner that only tolerated a space/tab between a
9378    /// seam name and its `(` — `rustfmt` can and does break a call onto its
9379    /// own line, and a scanner that only sees same-line whitespace would
9380    /// silently stop finding calls the moment one gets formatted that way.
9381    #[test]
9382    fn calls_name_matches_across_a_newline_before_the_parenthesis() {
9383        let text = "async fn wraps_it() {\n    with_event_store\n        (store)\n}";
9384        assert!(calls_name(text, "with_event_store"));
9385    }
9386
9387    /// `(name, body)` for every function defined in `text`, where `body`
9388    /// spans from the function's signature line to the matching close of
9389    /// its opening brace, found by [`brace_bounded_fn_end`]. The line
9390    /// before the *next* function signature (or EOF) is passed to
9391    /// `brace_bounded_fn_end` only as its own fallback bound — used when
9392    /// brace counting never returns to depth zero, e.g. a signature shape
9393    /// this scan doesn't recognize, or an actual brace imbalance — never as
9394    /// this function's primary way of finding where a body ends.
9395    fn fn_bodies(text: &str) -> Vec<(String, String)> {
9396        let lines: Vec<&str> = text.lines().collect();
9397        let starts: Vec<usize> = lines
9398            .iter()
9399            .enumerate()
9400            .filter(|(_, line)| is_fn_signature_line(line.trim_start()))
9401            .map(|(index, _)| index)
9402            .collect();
9403        starts
9404            .iter()
9405            .enumerate()
9406            .filter_map(|(index, &start)| {
9407                let hard_limit = starts.get(index + 1).copied().unwrap_or(lines.len());
9408                let end = brace_bounded_fn_end(&lines, start, hard_limit);
9409                let name = fn_name_from_signature(lines[start].trim_start())?;
9410                Some((name.to_string(), lines[start..end].join("\n")))
9411            })
9412            .collect()
9413    }
9414
9415    /// The exclusive end index (within `lines`) of the function whose
9416    /// signature line is `lines[sig_start]`, found by counting brace depth
9417    /// from that line until it returns to zero, bounded by `hard_limit` (a
9418    /// caller-supplied fallback — the next known function signature, or
9419    /// EOF) if brace counting never finds a close.
9420    ///
9421    /// Bounding by "next signature" alone (the original design of
9422    /// `fn_bodies`) reads past a function's real end whenever anything
9423    /// between its `{` and the *next* recognized signature is not itself
9424    /// matched as a signature — a nested nested `fn` with an unrecognized
9425    /// visibility spelling, a closure, or simply a long function with a lot
9426    /// of code after its logical end — and keeps scanning into whatever
9427    /// comes next, which can misattribute an unrelated later call as this
9428    /// function's own. Brace counting fixes that; `hard_limit` stays as a
9429    /// safety net, never a primary bound, for the rare text this scan
9430    /// cannot fully make sense of (a signature line this scan doesn't
9431    /// recognize, or an actual brace imbalance).
9432    fn brace_bounded_fn_end(lines: &[&str], sig_start: usize, hard_limit: usize) -> usize {
9433        let joined = lines[sig_start..hard_limit].join("\n");
9434        let stripped = strip_string_literals(&joined);
9435        let mut depth = 0i32;
9436        let mut opened = false;
9437        for (line_index, line) in stripped.lines().enumerate() {
9438            let code = match line.find("//") {
9439                Some(comment_at) => &line[..comment_at],
9440                None => line,
9441            };
9442            for ch in code.chars() {
9443                match ch {
9444                    '{' => {
9445                        depth += 1;
9446                        opened = true;
9447                    }
9448                    '}' => depth -= 1,
9449                    _ => {}
9450                }
9451            }
9452            if opened && depth <= 0 {
9453                return (sig_start + line_index + 1).min(hard_limit);
9454            }
9455        }
9456        hard_limit
9457    }
9458
9459    /// `seed`, plus the name of every function *defined in this same text*
9460    /// that transitively calls one of the `seed` names through a chain of
9461    /// unambiguous same-text helpers — e.g. a local test-fixture helper
9462    /// (`pack_with_events()`, `fixture()`, ...) that itself constructs an
9463    /// event-backed registry, or a verb handler that calls a
9464    /// `record_config_locked`-wrapping config reader through one or more
9465    /// intermediate helpers (`handle_context` → `context_profile_enabled`
9466    /// → `record_config_locked`).
9467    ///
9468    /// Two deliberate boundaries keep this from over-matching:
9469    ///
9470    /// - **Per input text, not per workspace.** A private helper named
9471    ///   `fixture()` in one crate's test binary has nothing to do with an
9472    ///   unrelated `fixture()` in another crate's — they're different
9473    ///   functions in different compiled binaries. Resolving against
9474    ///   exactly the text handed in (one file, or one crate's concatenated
9475    ///   `src/` tree — the caller decides which) matches a real visibility
9476    ///   boundary instead of conflating same-named helpers across the whole
9477    ///   tree.
9478    /// - **Closure gated by per-step uniqueness, not free transitive
9479    ///   chasing.** Growing the known set one full pass at a time, and only
9480    ///   ever promoting a name that is unambiguous (defined exactly once in
9481    ///   the text) at the moment it is promoted, is what keeps a generic
9482    ///   name like `new` or `build` — reused by dozens of unrelated types —
9483    ///   from becoming a global false-positive match. Each pass reuses the
9484    ///   exact single-hop check the uniqueness gate already relied on; only
9485    ///   the number of passes changed; a wrapper that itself wraps a
9486    ///   wrapper is still only promoted once every name on its path to
9487    ///   `seed` has independently cleared that gate.
9488    fn file_seam_names(text: &str, seed: &[&str]) -> Vec<String> {
9489        let bodies = fn_bodies(text);
9490        let mut name_counts: std::collections::HashMap<&str, usize> =
9491            std::collections::HashMap::new();
9492        for (name, _) in &bodies {
9493            *name_counts.entry(name.as_str()).or_insert(0) += 1;
9494        }
9495
9496        let mut known: Vec<String> = seed.iter().map(|s| s.to_string()).collect();
9497        loop {
9498            let mut grew = false;
9499            for (name, body) in &bodies {
9500                if known.iter().any(|k| k == name) {
9501                    continue;
9502                }
9503                if name_counts.get(name.as_str()).copied().unwrap_or(0) != 1 {
9504                    continue;
9505                }
9506                if known.iter().any(|seam| calls_name(body, seam)) {
9507                    known.push(name.clone());
9508                    grew = true;
9509                }
9510            }
9511            if !grew {
9512                break;
9513            }
9514        }
9515        known
9516    }
9517
9518    /// [`file_seam_names`]'s closure, widened to resolve an ordinary
9519    /// function-call chain that crosses source files within one crate —
9520    /// e.g. a coordinator method defined in one file calling a config
9521    /// reader defined in another — while still rejecting a name this scan
9522    /// cannot safely resolve.
9523    ///
9524    /// `bodies_by_file` is one [`fn_bodies`] list per file; each entry
9525    /// keeps the same per-file uniqueness gate `file_seam_names` applies
9526    /// (a name only counts as *that file's* definition when it is the only
9527    /// one *in that file's own text*), but growth is shared across every
9528    /// file, so a name promoted from one file's chain is immediately
9529    /// available to every other file's bodies on the next pass.
9530    ///
9531    /// A name defined identically in more than one file of the crate (the
9532    /// same function name reused by two unrelated types — this workspace
9533    /// has a real instance: a coordinator's own search method and an
9534    /// unrelated service wrapper by the same name, in different files) is
9535    /// promoted only when *every* one of its per-file-unique definitions
9536    /// independently reaches the known set. Requiring the concatenated
9537    /// text's exact-one-definition count instead would block such a name
9538    /// forever — even though each definition, read in its own file, is
9539    /// unambiguous — so this loosens the count check exactly as far as
9540    /// keeping every resolution provably seam-reaching allows, and no
9541    /// further: a name with even one non-reaching definition among its
9542    /// per-file-unique occurrences is never promoted, which is what keeps
9543    /// a generic name like `new` from becoming a crate-wide false match
9544    /// the moment any single type's constructor happens to reach a seam.
9545    fn crate_seam_names(bodies_by_file: &[Vec<(String, String)>], seed: &[&str]) -> Vec<String> {
9546        let per_file_unique: Vec<Vec<(&str, &str)>> = bodies_by_file
9547            .iter()
9548            .map(|bodies| {
9549                let mut counts: std::collections::HashMap<&str, usize> =
9550                    std::collections::HashMap::new();
9551                for (name, _) in bodies {
9552                    *counts.entry(name.as_str()).or_insert(0) += 1;
9553                }
9554                bodies
9555                    .iter()
9556                    .filter(|(name, _)| counts.get(name.as_str()).copied() == Some(1))
9557                    .map(|(name, body)| (name.as_str(), body.as_str()))
9558                    .collect()
9559            })
9560            .collect();
9561
9562        let mut occurrences: std::collections::HashMap<&str, Vec<&str>> =
9563            std::collections::HashMap::new();
9564        for unique_in_file in &per_file_unique {
9565            for (name, body) in unique_in_file {
9566                occurrences.entry(name).or_default().push(body);
9567            }
9568        }
9569
9570        let mut known: Vec<String> = seed.iter().map(|s| s.to_string()).collect();
9571        loop {
9572            let mut grew = false;
9573            for (name, bodies) in &occurrences {
9574                if known.iter().any(|k| k == name) {
9575                    continue;
9576                }
9577                if bodies
9578                    .iter()
9579                    .all(|body| known.iter().any(|seam| calls_name(body, seam)))
9580                {
9581                    known.push((*name).to_string());
9582                    grew = true;
9583                }
9584            }
9585            if !grew {
9586                break;
9587            }
9588        }
9589        known
9590    }
9591
9592    /// The `crates/<name>` crate this source `path` belongs to, or `None`
9593    /// if `path` is not under a `crates/<name>/...` layout.
9594    fn crate_key(path: &std::path::Path) -> Option<String> {
9595        let mut components = path.components();
9596        while let Some(component) = components.next() {
9597            if component.as_os_str() == "crates" {
9598                return components
9599                    .next()
9600                    .map(|c| c.as_os_str().to_string_lossy().into_owned());
9601            }
9602        }
9603        None
9604    }
9605
9606    /// The first `handle_*` call found in `text`, if any — used to read off
9607    /// the handler a dispatch match arm routes to.
9608    fn find_handle_call(text: &str) -> Option<String> {
9609        fn is_ident_byte(b: u8) -> bool {
9610            b.is_ascii_alphanumeric() || b == b'_'
9611        }
9612        let bytes = text.as_bytes();
9613        let mut search_from = 0usize;
9614        while let Some(rel) = text[search_from..].find("handle_") {
9615            let start = search_from + rel;
9616            if start > 0 && is_ident_byte(bytes[start - 1]) {
9617                search_from = start + 1;
9618                continue;
9619            }
9620            let mut end = start + "handle_".len();
9621            while end < bytes.len() && is_ident_byte(bytes[end]) {
9622                end += 1;
9623            }
9624            let mut j = end;
9625            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
9626                j += 1;
9627            }
9628            if j < bytes.len() && bytes[j] == b'(' {
9629                return Some(text[start..end].to_string());
9630            }
9631            search_from = end.max(start + 1);
9632        }
9633        None
9634    }
9635
9636    /// `(verb, handler)` for every `"verb" => ... handle_name(` dispatch
9637    /// match arm found in `text` — the pattern every pack's `dispatch`
9638    /// (`crates/khive-pack-*/src/{dispatch,pack}.rs`) uses to route a verb
9639    /// string to its handler method.
9640    ///
9641    /// This workspace's dispatch tables write one verb per arm ending in a
9642    /// `self.handle_*(...)` call, occasionally wrapped in a short `{ }`
9643    /// block (`"memory.recall" => { self.handle_recall_with_deadline(...)
9644    /// .await }`), so the handler is looked up in a bounded window after
9645    /// the arm's `=>` rather than requiring it on the same line. A combined
9646    /// arm that dispatches on a second, nested `match` (`"create" | "list"
9647    /// | "search" => { match verb { "create" => self.handle_create(...),
9648    /// ... } }`) can misattribute the outer alias to the first inner
9649    /// handler call instead of its real one; that under-attributes rather
9650    /// than over-attributes a verb as ledger-reaching (a missed producer
9651    /// verb is a false negative here, not a false positive), and none of
9652    /// this workspace's combined arms currently route to a ledger
9653    /// producer.
9654    fn dispatch_verb_handlers(text: &str) -> Vec<(String, String)> {
9655        let bytes = text.as_bytes();
9656        let mut arms: Vec<(String, usize, usize)> = Vec::new();
9657        let mut i = 0usize;
9658        while i < bytes.len() {
9659            if bytes[i] != b'"' {
9660                i += 1;
9661                continue;
9662            }
9663            let start = i + 1;
9664            let mut j = start;
9665            while j < bytes.len() && bytes[j] != b'"' && bytes[j] != b'\n' {
9666                j += 1;
9667            }
9668            if j >= bytes.len() || bytes[j] != b'"' {
9669                i += 1;
9670                continue;
9671            }
9672            let literal = &text[start..j];
9673            let verb_like = !literal.is_empty()
9674                && literal
9675                    .chars()
9676                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.');
9677            let mut k = j + 1;
9678            while k < bytes.len() && bytes[k].is_ascii_whitespace() {
9679                k += 1;
9680            }
9681            if verb_like && k + 1 < bytes.len() && bytes[k] == b'=' && bytes[k + 1] == b'>' {
9682                arms.push((literal.to_string(), start - 1, k + 2));
9683            }
9684            i = j + 1;
9685        }
9686
9687        let mut out = Vec::new();
9688        for (index, (verb, _quote_start, arrow_end)) in arms.iter().enumerate() {
9689            let next_arm_start = arms.get(index + 1).map(|arm| arm.1).unwrap_or(bytes.len());
9690            let window_end = next_arm_start.min(arrow_end + 400).min(bytes.len());
9691            if window_end <= *arrow_end {
9692                continue;
9693            }
9694            if let Some(handler) = find_handle_call(&text[*arrow_end..window_end]) {
9695                out.push((verb.clone(), handler));
9696            }
9697        }
9698        out
9699    }
9700
9701    #[derive(Debug)]
9702    struct CensusTest {
9703        name: String,
9704        calls: std::collections::BTreeSet<String>,
9705        dispatch_verbs: std::collections::BTreeSet<String>,
9706        serial_keys: Vec<String>,
9707    }
9708
9709    fn census_call_sites(
9710        tokens: proc_macro2::TokenStream,
9711        calls: &mut std::collections::BTreeSet<String>,
9712        dispatch_verbs: &mut std::collections::BTreeSet<String>,
9713    ) {
9714        use proc_macro2::{Delimiter, TokenTree};
9715
9716        let tokens: Vec<_> = tokens.into_iter().collect();
9717        for (index, token) in tokens.iter().enumerate() {
9718            if let TokenTree::Ident(name) = token {
9719                let is_definition = index > 0
9720                    && matches!(&tokens[index - 1], TokenTree::Ident(previous) if previous == "fn");
9721                if let Some(TokenTree::Group(args)) = tokens.get(index + 1) {
9722                    if !is_definition && args.delimiter() == Delimiter::Parenthesis {
9723                        calls.insert(name.to_string());
9724                        if name == "dispatch" {
9725                            if let Some(TokenTree::Literal(literal)) =
9726                                args.stream().into_iter().next()
9727                            {
9728                                let literal =
9729                                    std::iter::once(TokenTree::Literal(literal)).collect();
9730                                if let Ok(verb) = syn::parse2::<syn::LitStr>(literal) {
9731                                    dispatch_verbs.insert(verb.value());
9732                                }
9733                            }
9734                        }
9735                    }
9736                }
9737            }
9738            // Macro arguments remain token groups even when syn cannot parse
9739            // their syntax as expressions. Literal contents stay opaque.
9740            if let TokenTree::Group(group) = token {
9741                census_call_sites(group.stream(), calls, dispatch_verbs);
9742            }
9743        }
9744    }
9745
9746    fn attribute_path_matches(path: &syn::Path, expected: &[&str]) -> bool {
9747        path.segments.len() == expected.len()
9748            && path
9749                .segments
9750                .iter()
9751                .zip(expected)
9752                .all(|(segment, expected)| segment.ident == *expected)
9753    }
9754
9755    fn serial_attribute_keys(attrs: &[syn::Attribute]) -> syn::Result<Vec<String>> {
9756        fn parse_keys(
9757            input: syn::parse::ParseStream<'_>,
9758        ) -> syn::Result<syn::punctuated::Punctuated<syn::Ident, syn::Token![,]>> {
9759            use syn::ext::IdentExt;
9760
9761            syn::punctuated::Punctuated::parse_terminated_with(input, syn::Ident::parse_any)
9762        }
9763
9764        let mut acquired = Vec::new();
9765        // Later serial attributes wrap the function produced by earlier ones,
9766        // so they acquire first. Only the keys inside one attribute are sorted.
9767        for attr in attrs.iter().rev() {
9768            if !attribute_path_matches(attr.path(), &["serial"])
9769                && !attribute_path_matches(attr.path(), &["serial_test", "serial"])
9770            {
9771                continue;
9772            }
9773            let mut keys = match &attr.meta {
9774                syn::Meta::Path(_) => Vec::new(),
9775                syn::Meta::List(_) => attr
9776                    .parse_args_with(parse_keys)
9777                    .map_err(|error| {
9778                        syn::Error::new_spanned(
9779                            attr,
9780                            format!("unsupported serial attribute arguments in census: {error}"),
9781                        )
9782                    })?
9783                    .into_iter()
9784                    .map(|key| key.to_string())
9785                    .collect(),
9786                syn::Meta::NameValue(_) => {
9787                    return Err(syn::Error::new_spanned(
9788                        attr,
9789                        "unsupported serial attribute arguments in census",
9790                    ));
9791                }
9792            };
9793            // serial_test 3.5 sorts only within an attribute and uses "" for
9794            // an unkeyed lock. Reentrant acquisitions add no new order edge.
9795            if keys.is_empty() {
9796                keys.push(String::new());
9797            }
9798            keys.sort();
9799            for key in keys {
9800                if !acquired.contains(&key) {
9801                    acquired.push(key);
9802                }
9803            }
9804        }
9805        Ok(acquired)
9806    }
9807
9808    fn census_tests(text: &str) -> syn::Result<Vec<CensusTest>> {
9809        use quote::ToTokens;
9810        use syn::visit::Visit;
9811
9812        #[derive(Default)]
9813        struct Collector {
9814            scope: Vec<String>,
9815            tests: Vec<syn::Result<CensusTest>>,
9816        }
9817        impl<'ast> Visit<'ast> for Collector {
9818            fn visit_item_mod(&mut self, item: &'ast syn::ItemMod) {
9819                self.scope.push(item.ident.to_string());
9820                syn::visit::visit_item_mod(self, item);
9821                self.scope.pop();
9822            }
9823
9824            fn visit_item_fn(&mut self, item: &'ast syn::ItemFn) {
9825                self.scope.push(item.sig.ident.to_string());
9826                if item.attrs.iter().any(|attr| {
9827                    attribute_path_matches(attr.path(), &["test"])
9828                        || attribute_path_matches(attr.path(), &["tokio", "test"])
9829                }) {
9830                    self.tests
9831                        .push(serial_attribute_keys(&item.attrs).map(|serial_keys| {
9832                            let mut calls = std::collections::BTreeSet::new();
9833                            let mut dispatch_verbs = std::collections::BTreeSet::new();
9834                            census_call_sites(
9835                                item.block.to_token_stream(),
9836                                &mut calls,
9837                                &mut dispatch_verbs,
9838                            );
9839                            CensusTest {
9840                                name: self.scope.join("::"),
9841                                calls,
9842                                dispatch_verbs,
9843                                serial_keys,
9844                            }
9845                        }));
9846                }
9847                syn::visit::visit_item_fn(self, item);
9848                self.scope.pop();
9849            }
9850        }
9851
9852        let file = syn::parse_file(text)?;
9853        let mut collector = Collector::default();
9854        collector.visit_file(&file);
9855        collector.tests.into_iter().collect()
9856    }
9857
9858    #[derive(Default)]
9859    struct SerialLockOrders {
9860        pairs: std::collections::BTreeMap<(String, String), (bool, String)>,
9861        conflicts: Vec<String>,
9862    }
9863
9864    impl SerialLockOrders {
9865        fn record(&mut self, name: &str, keys: &[String]) {
9866            for (index, first) in keys.iter().enumerate() {
9867                for second in &keys[index + 1..] {
9868                    let forward = first < second;
9869                    let pair = if forward {
9870                        (first.clone(), second.clone())
9871                    } else {
9872                        (second.clone(), first.clone())
9873                    };
9874                    if let Some((prior_forward, prior_name)) = self.pairs.get(&pair) {
9875                        if *prior_forward != forward {
9876                            self.conflicts.push(format!(
9877                                "{name} acquires {first:?} before {second:?}, opposite to {prior_name}"
9878                            ));
9879                        }
9880                    } else {
9881                        self.pairs.insert(pair, (forward, name.to_owned()));
9882                    }
9883                }
9884            }
9885        }
9886    }
9887
9888    fn serial_fixture_conflicts(source: &str) -> Vec<String> {
9889        let mut orders = SerialLockOrders::default();
9890        for test in census_tests(source).expect("valid fixture source") {
9891            orders.record(&test.name, &test.serial_keys);
9892        }
9893        orders.conflicts
9894    }
9895
9896    #[test]
9897    fn serial_census_accepts_complete_attributes_and_per_attribute_sorting() {
9898        let source = r#"
9899            #[serial]
9900            #[cfg(unix)]
9901            #[serial_test::serial(
9902                config_ledger,
9903                other,
9904            )]
9905            #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9906            async fn first() { with_event_store(store); }
9907
9908            #[test]
9909            #[serial_test::serial()]
9910            #[serial(other, config_ledger)]
9911            fn second() { registry.dispatch("serial_fixture_verb", params); }
9912
9913            mod nested {
9914                #[serial_test::serial]
9915                #[test]
9916                #[serial(other)]
9917                #[serial(config_ledger)]
9918                fn third() {}
9919            }
9920        "#;
9921        let tests = census_tests(source).unwrap();
9922        assert_eq!(tests.len(), 3);
9923        for test in &tests {
9924            assert_eq!(test.serial_keys, ["config_ledger", "other", ""]);
9925        }
9926        assert_eq!(tests[2].name, "nested::third");
9927        assert!(tests[0].calls.contains("with_event_store"));
9928        assert!(tests[1].dispatch_verbs.contains("serial_fixture_verb"));
9929        assert!(serial_fixture_conflicts(source).is_empty());
9930        let keyword_keys =
9931            census_tests("#[test] #[serial(type, r#match)] fn keywords() {}").unwrap();
9932        assert_eq!(keyword_keys[0].serial_keys, ["r#match", "type"]);
9933    }
9934
9935    #[test]
9936    fn serial_census_rejects_each_hidden_stacked_order_reversal() {
9937        for (label, reverse_attrs) in [
9938            ("one-line", "#[serial(config_ledger)] #[serial]"),
9939            ("multi-key", "#[serial(config_ledger, other)] #[serial]"),
9940            (
9941                "multiline",
9942                "#[serial_test::serial(\nconfig_ledger,\n)]\n#[serial_test::serial]",
9943            ),
9944            (
9945                "before-test",
9946                "#[serial(config_ledger)] #[serial] #[cfg(unix)]",
9947            ),
9948        ] {
9949            let source = format!(
9950                "#[test] #[serial] #[serial(config_ledger)] fn first() {{}}\n\
9951                 {reverse_attrs} #[test] fn reversed() {{}}"
9952            );
9953            let conflicts = serial_fixture_conflicts(&source);
9954            assert_eq!(conflicts.len(), 1, "{label}: {conflicts:?}");
9955            assert!(conflicts[0].contains("first"), "{label}: {conflicts:?}");
9956            assert!(conflicts[0].contains("reversed"), "{label}: {conflicts:?}");
9957        }
9958    }
9959
9960    #[test]
9961    fn serial_census_compares_third_keys_and_multi_key_lock_order() {
9962        let source = r#"
9963            #[test]
9964            #[serial(config_ledger)]
9965            #[serial(audit_append_failures)]
9966            #[serial(audit_obligation_append_failures)]
9967            fn first() {}
9968            #[test]
9969            #[serial(config_ledger)]
9970            #[serial(audit_obligation_append_failures)]
9971            #[serial(audit_append_failures)]
9972            fn reversed() {}
9973        "#;
9974        let conflicts = serial_fixture_conflicts(source);
9975        assert_eq!(conflicts.len(), 1, "{conflicts:?}");
9976        assert!(conflicts[0].contains("audit_append_failures"));
9977        assert!(conflicts[0].contains("audit_obligation_append_failures"));
9978        assert_eq!(
9979            serial_fixture_conflicts(
9980                "#[test] #[serial(beta, alpha)] fn first() {}\n\
9981                 #[test] #[serial(alpha)] #[serial(beta)] fn reversed() {}"
9982            )
9983            .len(),
9984            1
9985        );
9986    }
9987
9988    #[test]
9989    fn serial_census_ignores_attribute_text_and_does_not_absorb_sibling_helpers() {
9990        let source = r##"
9991            // #[test] #[serial(config_ledger)] #[serial] fn comment() {}
9992            const TEXT: &str = r#"#[test] #[serial(config_ledger)] #[serial] fn string() {}"#;
9993            #[test] #[serial] #[serial(config_ledger)] fn actual() {}
9994            fn helper() { with_event_store(store); }
9995        "##;
9996        let tests = census_tests(source).unwrap();
9997        assert_eq!(tests.len(), 1);
9998        assert_eq!(tests[0].name, "actual");
9999        assert!(!tests[0].calls.contains("with_event_store"));
10000        assert!(serial_fixture_conflicts(source).is_empty());
10001    }
10002
10003    #[test]
10004    fn serial_census_body_calls_ignore_literals_and_preserve_macro_tokens() {
10005        let source = r###"
10006            #[test]
10007            fn literals() {
10008                let ordinary = "with_event_store(fake); registry.dispatch(\"context\", fake)";
10009                let raw = r#"with_event_store(fake); registry.dispatch("context", fake)"#;
10010            }
10011            #[test]
10012            fn actual() {
10013                assert!(with_event_store(store).is_ok());
10014                assert_eq!(registry.dispatch("context", params).await.unwrap(), expected);
10015                custom! { branch => registry.dispatch("memory.recall", params); with_event_store(store) }
10016            }
10017        "###;
10018        let tests = census_tests(source).unwrap();
10019        assert_eq!(tests.len(), 2);
10020        assert!(!tests[0].calls.contains("with_event_store"));
10021        assert!(!tests[0].calls.contains("dispatch"));
10022        assert!(tests[0].dispatch_verbs.is_empty());
10023        assert!(tests[1].calls.contains("with_event_store"));
10024        assert!(tests[1].calls.contains("dispatch"));
10025        assert_eq!(
10026            tests[1].dispatch_verbs,
10027            std::collections::BTreeSet::from(["context".into(), "memory.recall".into()])
10028        );
10029    }
10030
10031    #[test]
10032    fn serial_census_uses_first_acquisitions_for_reentrant_keys() {
10033        let source = "#[test] #[serial(alpha, beta)] #[serial(alpha)] fn first() {}\n\
10034                      #[test] #[serial(beta)] #[serial(alpha)] fn second() {}";
10035        let tests = census_tests(source).unwrap();
10036        assert_eq!(tests[0].serial_keys, ["alpha", "beta"]);
10037        assert!(serial_fixture_conflicts(source).is_empty());
10038    }
10039
10040    #[test]
10041    fn serial_census_surfaces_source_and_serial_argument_parse_failures() {
10042        assert!(census_tests("#[test] fn broken(").is_err());
10043        let error = census_tests("#[test] #[serial(config_ledger, crate = wrapper)] fn test() {}")
10044            .unwrap_err();
10045        assert!(error
10046            .to_string()
10047            .contains("unsupported serial attribute arguments"));
10048    }
10049
10050    /// An event-backed registry can drain the process-wide config ledger at
10051    /// dispatch, and `record_config_locked` (and every `OnceLock` reader
10052    /// that wraps it — `context_profile_enabled`, `recall_profile_enabled`,
10053    /// `ann_overfetch_max_rounds`, `ann_ready_timeout_ms`,
10054    /// `recall_deadline_ms`, `request_read_timeout`,
10055    /// `backend_search_timeout_ms`, ... — enumerated here only as the
10056    /// evidence that motivated widening the seed, never as the source of
10057    /// truth for who counts) writes to it, so every compiled test that
10058    /// reaches either — directly, through a same-text wrapper, or by
10059    /// dispatching a verb whose handler reaches one — must join the
10060    /// ledger's serial group even when its own assertion is about another
10061    /// audit field.
10062    ///
10063    /// The seed is deliberately just the two true seams
10064    /// (`with_event_store`, `record_config_locked`) rather than a
10065    /// hand-maintained list of every wrapper: `file_seam_names` grows the
10066    /// known set to a fixed point, so a future `OnceLock` reader that wraps
10067    /// `record_config_locked` is picked up the moment it exists, without
10068    /// anyone remembering to add it here.
10069    #[test]
10070    fn event_store_test_fixtures_are_config_ledger_serialized() {
10071        let sources = workspace_rust_sources();
10072        assert!(
10073            !sources.is_empty(),
10074            "the workspace source scan found no .rs files under crates/*/src or \
10075             crates/*/tests; the census's own file walk is broken, not the population \
10076             it walks"
10077        );
10078
10079        let base_seed = ["with_event_store", "record_config_locked"];
10080
10081        // A dispatch match arm (`"context" => self.handle_context(...)`)
10082        // and the handler it names are frequently split across files
10083        // within one crate (`khive-pack-kg`'s `dispatch.rs` vs.
10084        // `handlers/context.rs`), so resolving "does verb X's handler reach
10085        // a ledger producer" needs a wider-than-one-file view. Per crate is
10086        // still a real visibility boundary — a pack's dispatch table only
10087        // ever calls its own handlers — unlike a workspace-wide view,
10088        // which would risk resolving a handler name that happens to
10089        // collide across unrelated crates. Built from `src/` text only:
10090        // `tests/` helpers of the same name must never leak into what
10091        // counts as "the crate's own handler".
10092        let mut crate_src_blobs: std::collections::HashMap<String, String> =
10093            std::collections::HashMap::new();
10094        let mut crate_src_bodies: std::collections::HashMap<String, Vec<Vec<(String, String)>>> =
10095            std::collections::HashMap::new();
10096        for (path, text) in &sources {
10097            if !path.components().any(|c| c.as_os_str() == "src") {
10098                continue;
10099            }
10100            let Some(key) = crate_key(path) else {
10101                continue;
10102            };
10103            let blob = crate_src_blobs.entry(key.clone()).or_default();
10104            blob.push_str(text);
10105            blob.push('\n');
10106            crate_src_bodies
10107                .entry(key)
10108                .or_default()
10109                .push(fn_bodies(text));
10110        }
10111        let mut crate_ledger_verbs: std::collections::HashMap<String, Vec<String>> =
10112            std::collections::HashMap::new();
10113        for (crate_name, blob) in &crate_src_blobs {
10114            let empty = Vec::new();
10115            let bodies_by_file = crate_src_bodies.get(crate_name).unwrap_or(&empty);
10116            let producers = crate_seam_names(bodies_by_file, &base_seed);
10117            let verbs: Vec<String> = dispatch_verb_handlers(blob)
10118                .into_iter()
10119                .filter(|(_, handler)| producers.iter().any(|p| p == handler))
10120                .map(|(verb, _)| verb)
10121                .collect();
10122            crate_ledger_verbs.insert(crate_name.clone(), verbs);
10123        }
10124
10125        // Every source file in the crate (`src/` and `tests/` alike) —
10126        // resolves an ordinary same-crate function-call chain that crosses
10127        // files (a coordinator method in `dispatch.rs` calling a config
10128        // reader that lands in `dispatch.rs` too, reached from a test in
10129        // `tests.rs`) for the direct-call match below. Kept separate from
10130        // `crate_src_bodies` above: that population stays `src/`-only so a
10131        // `tests/`-only helper can never be misread as a pack's own
10132        // dispatch handler.
10133        //
10134        // A pack's own verb-dispatch table (`dispatch_verb_handlers`'s own
10135        // target shape — one function whose body is a `"verb" => ...
10136        // handle_x(...)` match with many arms) is excluded from this
10137        // population: "body contains a call to a known name" is sound only
10138        // for a body that always makes that call, and a dispatch table's
10139        // body contains a call to nearly every handler in the pack while
10140        // any one invocation only ever takes one arm. Leaving it in would
10141        // promote the dispatch function itself the moment *any* single
10142        // verb's handler reaches a seam, which reads as "every verb reaches
10143        // the ledger" — the false-positive an ordinary wrapper closure
10144        // cannot produce, verb-routing is already resolved precisely by the
10145        // separate `crate_ledger_verbs`/`CensusTest::dispatch_verbs` path,
10146        // keyed by which verb string was actually invoked.
10147        let mut crate_all_bodies: std::collections::HashMap<String, Vec<Vec<(String, String)>>> =
10148            std::collections::HashMap::new();
10149        for (path, text) in &sources {
10150            let Some(key) = crate_key(path) else {
10151                continue;
10152            };
10153            let bodies: Vec<(String, String)> = fn_bodies(text)
10154                .into_iter()
10155                .filter(|(_, body)| dispatch_verb_handlers(body).len() <= 1)
10156                .collect();
10157            crate_all_bodies.entry(key).or_default().push(bodies);
10158        }
10159        let mut crate_direct_seams: std::collections::HashMap<String, Vec<String>> =
10160            std::collections::HashMap::new();
10161        for (crate_name, bodies_by_file) in &crate_all_bodies {
10162            crate_direct_seams.insert(
10163                crate_name.clone(),
10164                crate_seam_names(bodies_by_file, &base_seed),
10165            );
10166        }
10167
10168        let mut candidate_count = 0usize;
10169        let mut offenders = Vec::new();
10170        let mut serial_orders = SerialLockOrders::default();
10171
10172        for (path, text) in &sources {
10173            let seam_names = file_seam_names(text, &base_seed);
10174            let crate_key_for_path = crate_key(path);
10175            let crate_verbs = crate_key_for_path
10176                .as_deref()
10177                .and_then(|key| crate_ledger_verbs.get(key));
10178            let crate_seams = crate_key_for_path
10179                .as_deref()
10180                .and_then(|key| crate_direct_seams.get(key));
10181            let tests = census_tests(text)
10182                .unwrap_or_else(|error| panic!("{}: census parse failed: {error}", path.display()));
10183            for test in tests {
10184                let name = format!("{}:{}", path.display(), test.name);
10185                serial_orders.record(&name, &test.serial_keys);
10186
10187                let matched_direct = seam_names
10188                    .iter()
10189                    .chain(crate_seams.into_iter().flatten())
10190                    .find(|seam| test.calls.contains(*seam));
10191                let matched: Option<String> = matched_direct.cloned().or_else(|| {
10192                    crate_verbs.and_then(|verbs| {
10193                        verbs
10194                            .iter()
10195                            .find(|verb| test.dispatch_verbs.contains(*verb))
10196                            .map(|verb| format!("dispatch(\"{verb}\")"))
10197                    })
10198                });
10199                let Some(matched) = matched else {
10200                    continue;
10201                };
10202                candidate_count += 1;
10203
10204                let has_group = test.serial_keys.iter().any(|key| key == "config_ledger");
10205                if !has_group {
10206                    offenders.push(format!(
10207                        "{name} (reaches config-ledger seam via `{matched}`)"
10208                    ));
10209                }
10210            }
10211        }
10212
10213        assert!(
10214            candidate_count > 0,
10215            "census found zero config-ledger-reaching test candidates across the whole \
10216             workspace scan ({} source files) — the scan is broken, not the \
10217             population it should have found (khive-runtime's own config-ledger \
10218             tests alone are known callers)",
10219            sources.len()
10220        );
10221        assert!(
10222            offenders.is_empty(),
10223            "config-ledger-reaching pack tests must use #[serial(config_ledger)]; \
10224             offenders: {offenders:?}"
10225        );
10226        assert!(
10227            serial_orders.conflicts.is_empty(),
10228            "serial_test lock pairs must have consistent acquisition order across tests; \
10229             keys sort within each attribute, while later attributes acquire first: {:?}",
10230            serial_orders.conflicts
10231        );
10232    }
10233
10234    fn only_git_digest_event(store: &MemoryEventStore) -> Event {
10235        let events: Vec<Event> = store
10236            .events
10237            .lock()
10238            .unwrap()
10239            .iter()
10240            .filter(|event| event.verb == "git.digest")
10241            .cloned()
10242            .collect();
10243        assert_eq!(
10244            events.len(),
10245            1,
10246            "expected exactly one git.digest receipt event"
10247        );
10248        events[0].clone()
10249    }
10250
10251    #[tokio::test]
10252    #[serial(config_ledger)]
10253    async fn git_digest_success_returns_complete_durable_receipt() {
10254        let project_id = uuid::Uuid::new_v4();
10255        let store = Arc::new(MemoryEventStore::default());
10256        let mut builder = VerbRegistryBuilder::new();
10257        builder.register(GitDigestResultPack { project_id });
10258        builder.with_event_store(store.clone());
10259        let registry = builder.build().expect("registry builds");
10260
10261        let result = registry
10262            .dispatch_with_identity(
10263                "git.digest",
10264                serde_json::json!({
10265                    "source": "https://user:SECRET@example.invalid/org/repo",
10266                }),
10267                Some(RequestIdentity {
10268                    namespace: Namespace::local().as_str().to_string(),
10269                    request_id: Some(1_510),
10270                    ..Default::default()
10271                }),
10272            )
10273            .await
10274            .expect("durably receipted digest succeeds");
10275        let receipt_id = result["receipt_id"]
10276            .as_str()
10277            .and_then(|raw| raw.parse::<uuid::Uuid>().ok())
10278            .expect("response has UUID receipt_id");
10279
10280        let event = only_git_digest_event(&store);
10281        assert_eq!(event.id, receipt_id);
10282        assert_eq!(event.target_id, Some(project_id));
10283        assert_eq!(event.verb, "git.digest");
10284        assert_eq!(event.outcome, EventOutcome::Success);
10285        assert_eq!(event.payload_schema_version, 2);
10286        assert_eq!(event.payload["result"], result);
10287        assert_eq!(event.payload["resource"]["request_id"], 1_510);
10288        assert_eq!(event.payload["result"]["commits_ingested"], 2);
10289        assert_eq!(event.payload["result"]["issues_ingested"], 3);
10290        assert_eq!(event.payload["result"]["prs_ingested"], 5);
10291        assert!(
10292            !event.payload.to_string().contains("SECRET"),
10293            "receipt must not persist the caller's source URL or credentials"
10294        );
10295    }
10296
10297    #[tokio::test]
10298    #[serial(config_ledger)]
10299    async fn malformed_git_digest_report_appends_one_generic_error_audit() {
10300        let store = Arc::new(MemoryEventStore::default());
10301        let mut builder = VerbRegistryBuilder::new();
10302        builder.register(MalformedGitDigestResultPack);
10303        builder.with_event_store(store.clone());
10304        let registry = builder.build().expect("registry builds");
10305
10306        let err = registry
10307            .dispatch("git.digest", serde_json::json!({}))
10308            .await
10309            .expect_err("malformed receipt identity must fail the response");
10310        assert!(matches!(
10311            err,
10312            RuntimeError::AuditObligation { ref failure, .. }
10313                if failure.message.starts_with("git_digest_receipt_persist_failed:")
10314        ));
10315
10316        let event = only_git_digest_event(&store);
10317        assert_eq!(event.outcome, EventOutcome::Error);
10318        assert_eq!(event.payload_schema_version, 1);
10319        assert!(
10320            event.payload.get("result").is_none(),
10321            "a generic Error audit must not masquerade as a success receipt"
10322        );
10323        let audit: AuditEvent =
10324            serde_json::from_value(event.payload).expect("generic payload remains an AuditEvent");
10325        assert_eq!(audit.verb, "git.digest");
10326        assert_eq!(audit.decision, AuditDecision::Allow);
10327    }
10328
10329    #[tokio::test]
10330    #[serial(config_ledger)]
10331    #[serial(audit_append_failures)]
10332    #[serial(audit_obligation_append_failures)]
10333    async fn git_digest_receipt_append_failure_never_returns_unqualified_success() {
10334        let before = audit_append_failure_count();
10335        let before_obligation = audit_obligation_append_failure_count();
10336        let store = Arc::new(MemoryEventStore {
10337            fail_appends: true,
10338            ..MemoryEventStore::default()
10339        });
10340        let mut builder = VerbRegistryBuilder::new();
10341        builder.register(GitDigestResultPack {
10342            project_id: uuid::Uuid::new_v4(),
10343        });
10344        builder.with_event_store(store);
10345        let registry = builder.build().expect("registry builds");
10346
10347        let err = registry
10348            .dispatch("git.digest", serde_json::json!({}))
10349            .await
10350            .expect_err("receipt persistence failure must fail the response");
10351        assert!(
10352            matches!(&err, RuntimeError::AuditObligation { failure, .. }
10353                if failure.message.starts_with("git_digest_receipt_persist_failed:")
10354                    && failure.message.contains("writes may have committed")),
10355            "error is stable, safe, and retry-aware: {err}"
10356        );
10357        // The git.digest receipt is obligation-bearing (`GitDigestReceipt`
10358        // classifies as `DispatchObligation`) and this failure propagated
10359        // into the dispatch's own error above, so it counts on the
10360        // obligation counter, not the swallowed-failures one.
10361        assert_eq!(audit_append_failure_count(), before);
10362        assert_eq!(
10363            audit_obligation_append_failure_count(),
10364            before_obligation + 1
10365        );
10366        // #2784: the same real sink failure must be discoverable through the
10367        // public diagnostics report, not only this private counter accessor.
10368        let runtime = crate::KhiveRuntime::memory().expect("diagnostics runtime");
10369        let report = runtime
10370            .db_diagnostics()
10371            .await
10372            .expect("diagnostics after audit failure");
10373        assert_eq!(
10374            report.writer_contention.audit_obligation_append_failures,
10375            Some(before_obligation + 1)
10376        );
10377        assert_eq!(report.writer_contention.audit_append_failures, Some(before));
10378        assert!(report
10379            .writer_contention
10380            .audit_obligation_append_failures_unavailable_reason
10381            .is_none());
10382        let json = serde_json::to_value(report).expect("serialized diagnostics");
10383        assert_eq!(
10384            json["writer_contention"]["audit_obligation_append_failures"],
10385            before_obligation + 1
10386        );
10387    }
10388
10389    #[tokio::test]
10390    async fn git_digest_without_event_store_fails_safe_after_handler_success() {
10391        let mut builder = VerbRegistryBuilder::new();
10392        builder.register(GitDigestResultPack {
10393            project_id: uuid::Uuid::new_v4(),
10394        });
10395        let registry = builder.build().expect("registry builds");
10396
10397        let err = registry
10398            .dispatch("git.digest", serde_json::json!({}))
10399            .await
10400            .expect_err("a successful digest needs a durable store");
10401        assert!(matches!(
10402            err,
10403            RuntimeError::AuditObligation { ref failure, .. }
10404                if failure.message.starts_with("git_digest_receipt_persist_failed:")
10405        ));
10406    }
10407
10408    #[tokio::test]
10409    #[serial(config_ledger)]
10410    async fn git_digest_gate_unavailable_precedes_the_receipt_contract() {
10411        #[derive(Debug)]
10412        struct FailingGate;
10413        impl Gate for FailingGate {
10414            fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
10415                Err(khive_gate::GateError::Internal(
10416                    "injected gate failure".into(),
10417                ))
10418            }
10419        }
10420
10421        let store = Arc::new(MemoryEventStore::default());
10422        let mut builder = VerbRegistryBuilder::new();
10423        builder.register(GitDigestResultPack {
10424            project_id: uuid::Uuid::new_v4(),
10425        });
10426        builder.with_gate(Arc::new(FailingGate));
10427        builder.with_event_store(store.clone());
10428        let registry = builder.build().expect("registry builds");
10429
10430        let err = registry
10431            .dispatch("git.digest", serde_json::json!({}))
10432            .await
10433            .expect_err("gate unavailability must refuse before the handler or receipt path");
10434        assert!(matches!(
10435            err,
10436            RuntimeError::GateUnavailable { ref verb, ref reason }
10437                if verb == "git.digest"
10438                    && reason == "gate backend unavailable"
10439                    && !reason.contains("injected gate failure")
10440        ));
10441        let event = only_git_digest_event(&store);
10442        assert_eq!(event.outcome, EventOutcome::Error);
10443        assert_eq!(event.payload["decision"], "gate_unavailable");
10444        assert!(event.payload.get("result").is_none());
10445    }
10446
10447    #[tokio::test]
10448    #[serial(config_ledger)]
10449    async fn intercepted_gate_error_returns_typed_refusal_without_invoking_operation() {
10450        #[derive(Debug)]
10451        struct FailingGate;
10452        impl Gate for FailingGate {
10453            fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
10454                Err(khive_gate::GateError::Internal(
10455                    "intercepted gate broken".into(),
10456                ))
10457            }
10458        }
10459
10460        // Entry reset, not exit: a `config_ledger`-grouped test that panics
10461        // after queueing a row (elsewhere in this group) would otherwise
10462        // leave it for whichever test the serial lock hands off to next;
10463        // this test's exact `events.len() == 1` assertion below has no
10464        // tolerance for an inherited row.
10465        let _ = crate::config_ledger::drain_config_locked();
10466
10467        let invoked = Arc::new(AtomicUsize::new(0));
10468        let invoked_by_operation = Arc::clone(&invoked);
10469        let store = Arc::new(MemoryEventStore::default());
10470        let mut builder = VerbRegistryBuilder::new();
10471        builder.with_gate(Arc::new(FailingGate));
10472        builder.with_event_store(store.clone());
10473        let registry = builder.build().expect("registry builds");
10474        let identity = RequestIdentity {
10475            namespace: "identity-default".to_string(),
10476            request_id: Some(1_600),
10477            ..Default::default()
10478        };
10479
10480        let err = registry
10481            .dispatch_intercepted_with_identity(
10482                "list",
10483                &serde_json::json!({"namespace": "test-ns"}),
10484                Some(&identity),
10485                move |_namespace| {
10486                    invoked_by_operation.fetch_add(1, Ordering::SeqCst);
10487                    async move { Ok(serde_json::json!({"invoked": true})) }
10488                },
10489            )
10490            .await
10491            .expect_err("gate unavailability must refuse intercepted dispatch");
10492
10493        assert!(matches!(
10494            err,
10495            RuntimeError::GateUnavailable { ref verb, ref reason }
10496                if verb == "list"
10497                    && reason == "gate backend unavailable"
10498                    && !reason.contains("intercepted gate broken")
10499        ));
10500        assert_eq!(
10501            invoked.load(Ordering::SeqCst),
10502            0,
10503            "intercepted operation must not run after a gate infrastructure error"
10504        );
10505
10506        let events = store.events.lock().unwrap();
10507        assert_eq!(events.len(), 1);
10508        assert_eq!(events[0].verb, "list");
10509        assert_eq!(events[0].namespace, "test-ns");
10510        assert_eq!(events[0].outcome, EventOutcome::Error);
10511        assert_eq!(events[0].payload["decision"], "gate_unavailable");
10512        assert!(events[0].payload.get("deny_reason").is_none());
10513        assert_eq!(events[0].payload["resource"]["work_class"], "interactive");
10514        assert_eq!(events[0].payload["resource"]["request_id"], 1_600);
10515        assert!(events[0].payload["resource"].get("cost_unit").is_none());
10516    }
10517
10518    #[tokio::test]
10519    #[serial(config_ledger)]
10520    async fn intercepted_deny_remains_distinct_and_does_not_invoke_operation() {
10521        #[derive(Debug)]
10522        struct DenyingGate;
10523        impl Gate for DenyingGate {
10524            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
10525                Ok(GateDecision::deny("intercepted policy denied"))
10526            }
10527        }
10528
10529        // Entry reset, not exit — see the sibling test above for why an
10530        // exact `events.len() == 1` assertion needs a clean ledger.
10531        let _ = crate::config_ledger::drain_config_locked();
10532
10533        let invoked = Arc::new(AtomicUsize::new(0));
10534        let invoked_by_operation = Arc::clone(&invoked);
10535        let store = Arc::new(MemoryEventStore::default());
10536        let mut builder = VerbRegistryBuilder::new();
10537        builder.with_gate(Arc::new(DenyingGate));
10538        builder.with_event_store(store.clone());
10539        let registry = builder.build().expect("registry builds");
10540
10541        let err = registry
10542            .dispatch_intercepted_with_identity("list", &Value::Null, None, move |_namespace| {
10543                invoked_by_operation.fetch_add(1, Ordering::SeqCst);
10544                async move { Ok(serde_json::json!({"invoked": true})) }
10545            })
10546            .await
10547            .expect_err("explicit gate denial must refuse intercepted dispatch");
10548
10549        let RuntimeError::PermissionDenied {
10550            verb,
10551            reason,
10552            receipt,
10553        } = err
10554        else {
10555            panic!("expected PermissionDenied, got {err:?}");
10556        };
10557        assert_eq!(verb, "list");
10558        assert_eq!(reason, "intercepted policy denied");
10559        assert_eq!(
10560            receipt.audit_outcome,
10561            crate::error::DenialAuditOutcome::Committed,
10562            "the intercepted path commits its denial row before refusing"
10563        );
10564        assert_eq!(invoked.load(Ordering::SeqCst), 0);
10565
10566        let events = store.events.lock().unwrap();
10567        assert_eq!(events.len(), 1);
10568        assert_eq!(
10569            Some(events[0].id),
10570            receipt.audit_event_id,
10571            "the receipt names the committed row"
10572        );
10573        assert_eq!(events[0].outcome, EventOutcome::Denied);
10574        assert_eq!(events[0].payload["decision"], "deny");
10575        assert_eq!(
10576            events[0].payload["deny_reason"],
10577            "intercepted policy denied"
10578        );
10579    }
10580
10581    #[tokio::test]
10582    #[serial(config_ledger)]
10583    async fn intercepted_denied_dispatch_masks_secret_shaped_deny_reason_in_stored_event() {
10584        // Falsifiable arm for the audit-masking fix (khive#2944), exercised
10585        // through the intercepted dispatch path's OWN `AuditEvent`
10586        // construction site
10587        // (`dispatch_intercepted_with_metadata_and_disposition`), distinct
10588        // from the plain-dispatch site covered by
10589        // `denied_dispatch_masks_secret_shaped_deny_reason_in_stored_event`.
10590        // Masking only the plain-dispatch site would leave this call site's
10591        // stored row carrying the raw credential and this test red.
10592        #[derive(Debug)]
10593        struct InterceptedSecretDenyGate;
10594        impl Gate for InterceptedSecretDenyGate {
10595            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
10596                let reason = "postgres://svc:not-a-real-secret@internal-host in denied request"; // gitleaks:allow
10597                Ok(GateDecision::deny(reason))
10598            }
10599        }
10600
10601        let store = Arc::new(MemoryEventStore::default());
10602        let mut builder = VerbRegistryBuilder::new();
10603        builder.with_gate(Arc::new(InterceptedSecretDenyGate));
10604        builder.with_event_store(store.clone());
10605        let registry = builder.build().expect("registry builds");
10606
10607        let _ = registry
10608            .dispatch_intercepted_with_identity(
10609                "list",
10610                &Value::Null,
10611                None,
10612                move |_namespace| async move { Ok(serde_json::json!({"invoked": true})) },
10613            )
10614            .await
10615            .expect_err("explicit gate denial must refuse intercepted dispatch");
10616
10617        let events = store.events.lock().unwrap();
10618        assert_eq!(events.len(), 1, "exactly one denial row must commit");
10619        let stored_reason = events[0].payload["deny_reason"]
10620            .as_str()
10621            .expect("deny_reason must be a string on the stored row");
10622        assert!(!stored_reason.is_empty());
10623        assert!(
10624            stored_reason.contains("in denied request"),
10625            "non-secret prose must survive masking: {stored_reason:?}"
10626        );
10627        assert!(
10628            !stored_reason.contains("not-a-real-secret"),
10629            "the durable row must never carry the raw credential: {stored_reason:?}"
10630        );
10631        assert!(
10632            stored_reason.contains("***MASKED***"),
10633            "the durable row must record that a credential was redacted: {stored_reason:?}"
10634        );
10635    }
10636
10637    #[tokio::test]
10638    #[serial(config_ledger)]
10639    async fn intercepted_git_digest_uses_the_same_receipt_contract() {
10640        let project_id = uuid::Uuid::new_v4();
10641        let store = Arc::new(MemoryEventStore::default());
10642        let mut builder = VerbRegistryBuilder::new();
10643        builder.with_event_store(store.clone());
10644        let registry = builder.build().expect("registry builds");
10645
10646        let result = registry
10647            .dispatch_intercepted_with_identity(
10648                "git.digest",
10649                &serde_json::json!({}),
10650                Some(&RequestIdentity {
10651                    namespace: Namespace::local().as_str().to_string(),
10652                    request_id: Some(1_647),
10653                    ..Default::default()
10654                }),
10655                |_namespace| async move {
10656                    Ok(serde_json::json!({
10657                        "project_id": project_id,
10658                        "commits_ingested": 7,
10659                        "done": true,
10660                    }))
10661                },
10662            )
10663            .await
10664            .expect("intercepted digest is durably receipted");
10665
10666        let event = only_git_digest_event(&store);
10667        assert_eq!(result["receipt_id"], serde_json::json!(event.id));
10668        assert_eq!(event.payload["result"], result);
10669        assert_eq!(event.payload["resource"]["request_id"], 1_647);
10670    }
10671
10672    #[tokio::test]
10673    #[serial(config_ledger)]
10674    async fn intercepted_git_digest_receipt_preserves_typed_metadata() {
10675        let project_id = uuid::Uuid::new_v4();
10676        let store = Arc::new(MemoryEventStore::default());
10677        let mut builder = VerbRegistryBuilder::new();
10678        builder.with_event_store(store.clone());
10679        let registry = builder.build().expect("registry builds");
10680
10681        let outcome = registry
10682            .dispatch_intercepted_with_metadata_with_identity(
10683                "git.digest",
10684                &serde_json::json!({}),
10685                None,
10686                |_namespace| async move {
10687                    Ok(InterceptedDispatchResult::new(
10688                        serde_json::json!({
10689                            "project_id": project_id,
10690                            "commits_ingested": 3,
10691                            "done": true,
10692                        }),
10693                        vec!["backend-a".to_string(), "backend-b".to_string()],
10694                    ))
10695                },
10696            )
10697            .await
10698            .expect("metadata-bearing digest is durably receipted");
10699
10700        let event = only_git_digest_event(&store);
10701        assert_eq!(outcome.result["receipt_id"], serde_json::json!(event.id));
10702        assert_eq!(event.payload["result"], outcome.result);
10703        assert_eq!(outcome.metadata, ["backend-a", "backend-b"]);
10704    }
10705
10706    #[tokio::test]
10707    #[serial(config_ledger)]
10708    async fn intercepted_malformed_git_digest_appends_one_generic_error_audit() {
10709        let store = Arc::new(MemoryEventStore::default());
10710        let mut builder = VerbRegistryBuilder::new();
10711        builder.with_event_store(store.clone());
10712        let registry = builder.build().expect("registry builds");
10713
10714        let err = registry
10715            .dispatch_intercepted_with_identity(
10716                "git.digest",
10717                &serde_json::json!({}),
10718                None,
10719                |_namespace| async {
10720                    Ok(serde_json::json!({
10721                        "project_id": "not-a-uuid",
10722                        "done": true,
10723                    }))
10724                },
10725            )
10726            .await
10727            .expect_err("malformed intercepted receipt must fail the response");
10728        assert!(matches!(
10729            err,
10730            RuntimeError::AuditObligation { ref failure, .. }
10731                if failure.message.starts_with("git_digest_receipt_persist_failed:")
10732        ));
10733
10734        let event = only_git_digest_event(&store);
10735        assert_eq!(event.outcome, EventOutcome::Error);
10736        assert_eq!(event.payload_schema_version, 1);
10737        assert!(event.payload.get("result").is_none());
10738        let audit: AuditEvent =
10739            serde_json::from_value(event.payload).expect("generic payload remains an AuditEvent");
10740        assert_eq!(audit.verb, "git.digest");
10741        assert_eq!(audit.decision, AuditDecision::Allow);
10742    }
10743
10744    #[tokio::test]
10745    async fn allow_all_gate_default_remains_backward_compatible() {
10746        // No gate set — AllowAllGate is the default. Dispatch must succeed.
10747        let mut builder = VerbRegistryBuilder::new();
10748        builder.register(AlphaPack);
10749        let reg = builder.build().expect("registry builds");
10750
10751        let res = reg.dispatch("list", Value::Null).await.unwrap();
10752        assert_eq!(
10753            res["pack"], "alpha",
10754            "AllowAllGate must allow every verb — backward compat guarantee"
10755        );
10756        let res = reg.dispatch("create", Value::Null).await.unwrap();
10757        assert_eq!(res["pack"], "alpha");
10758    }
10759
10760    #[tokio::test]
10761    async fn deny_gate_returns_permission_denied_pack_never_invoked() {
10762        #[derive(Debug)]
10763        struct AlwaysDenyGate;
10764        impl Gate for AlwaysDenyGate {
10765            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
10766                Ok(GateDecision::deny("test: always deny"))
10767            }
10768        }
10769
10770        // Track whether dispatch was ever invoked on the pack.
10771        #[derive(Debug)]
10772        struct TrackedPack {
10773            invoked: Arc<AtomicUsize>,
10774        }
10775
10776        impl khive_types::Pack for TrackedPack {
10777            const NAME: &'static str = "tracked";
10778            const NOTE_KINDS: &'static [&'static str] = &[];
10779            const ENTITY_KINDS: &'static [&'static str] = &[];
10780            const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
10781                name: "guarded",
10782                description: "a guarded verb",
10783                visibility: Visibility::Verb,
10784                category: VerbCategory::Assertive,
10785                params: &[],
10786            }];
10787        }
10788
10789        #[async_trait]
10790        impl PackRuntime for TrackedPack {
10791            fn name(&self) -> &str {
10792                Self::NAME
10793            }
10794            fn note_kinds(&self) -> &'static [&'static str] {
10795                Self::NOTE_KINDS
10796            }
10797            fn entity_kinds(&self) -> &'static [&'static str] {
10798                Self::ENTITY_KINDS
10799            }
10800            fn handlers(&self) -> &'static [HandlerDef] {
10801                Self::HANDLERS
10802            }
10803            async fn dispatch(
10804                &self,
10805                _verb: &str,
10806                _params: Value,
10807                _registry: &VerbRegistry,
10808                _token: &NamespaceToken,
10809            ) -> Result<Value, RuntimeError> {
10810                self.invoked.fetch_add(1, Ordering::SeqCst);
10811                Ok(serde_json::json!({"invoked": true}))
10812            }
10813        }
10814
10815        let invoked = Arc::new(AtomicUsize::new(0));
10816        let mut builder = VerbRegistryBuilder::new();
10817        builder.register(TrackedPack {
10818            invoked: invoked.clone(),
10819        });
10820        builder.with_gate(Arc::new(AlwaysDenyGate));
10821        let reg = builder.build().expect("registry builds");
10822
10823        let err = reg.dispatch("guarded", Value::Null).await.unwrap_err();
10824        assert!(
10825            matches!(err, RuntimeError::PermissionDenied { ref verb, ref reason, .. } if verb == "guarded" && reason.contains("always deny")),
10826            "expected PermissionDenied with verb=guarded and reason, got: {err:?}"
10827        );
10828        assert_eq!(
10829            invoked.load(Ordering::SeqCst),
10830            0,
10831            "pack dispatch MUST NOT be invoked when gate denies"
10832        );
10833    }
10834
10835    #[tokio::test]
10836    async fn update_denial_precedes_id_existence_resolution() {
10837        #[derive(Debug)]
10838        struct AlwaysDenyUpdateGate {
10839            checked: Arc<AtomicUsize>,
10840        }
10841        impl Gate for AlwaysDenyUpdateGate {
10842            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
10843                self.checked.fetch_add(1, Ordering::SeqCst);
10844                Ok(GateDecision::deny("caller has no update capability"))
10845            }
10846        }
10847
10848        #[derive(Debug)]
10849        struct ExistenceOracleUpdatePack {
10850            existing_id: String,
10851            invoked: Arc<AtomicUsize>,
10852        }
10853
10854        impl khive_types::Pack for ExistenceOracleUpdatePack {
10855            const NAME: &'static str = "existence_oracle";
10856            const NOTE_KINDS: &'static [&'static str] = &[];
10857            const ENTITY_KINDS: &'static [&'static str] = &[];
10858            const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
10859                name: "update",
10860                description: "distinguish a present id from an absent id",
10861                visibility: Visibility::Verb,
10862                category: VerbCategory::Declaration,
10863                params: &[],
10864            }];
10865        }
10866
10867        #[async_trait]
10868        impl PackRuntime for ExistenceOracleUpdatePack {
10869            fn name(&self) -> &str {
10870                Self::NAME
10871            }
10872            fn note_kinds(&self) -> &'static [&'static str] {
10873                Self::NOTE_KINDS
10874            }
10875            fn entity_kinds(&self) -> &'static [&'static str] {
10876                Self::ENTITY_KINDS
10877            }
10878            fn handlers(&self) -> &'static [HandlerDef] {
10879                Self::HANDLERS
10880            }
10881            async fn dispatch(
10882                &self,
10883                _verb: &str,
10884                params: Value,
10885                _registry: &VerbRegistry,
10886                _token: &NamespaceToken,
10887            ) -> Result<Value, RuntimeError> {
10888                self.invoked.fetch_add(1, Ordering::SeqCst);
10889                match params.get("id").and_then(Value::as_str) {
10890                    Some(id) if id == self.existing_id => Ok(serde_json::json!({"updated": id})),
10891                    _ => Err(RuntimeError::NotFound("record".to_string())),
10892                }
10893            }
10894        }
10895
10896        let existing_id = uuid::Uuid::new_v4().to_string();
10897        let absent_id = uuid::Uuid::new_v4().to_string();
10898        let invoked = Arc::new(AtomicUsize::new(0));
10899        let checked = Arc::new(AtomicUsize::new(0));
10900
10901        let pack = || ExistenceOracleUpdatePack {
10902            existing_id: existing_id.clone(),
10903            invoked: Arc::clone(&invoked),
10904        };
10905
10906        let mut control_builder = VerbRegistryBuilder::new();
10907        control_builder.register(pack());
10908        let control = control_builder.build().expect("control registry builds");
10909        control
10910            .dispatch("update", serde_json::json!({"id": existing_id.clone()}))
10911            .await
10912            .expect("positive control resolves the present id");
10913        assert!(matches!(
10914            control
10915                .dispatch("update", serde_json::json!({"id": absent_id.clone()}))
10916                .await,
10917            Err(RuntimeError::NotFound(_))
10918        ));
10919        assert_eq!(invoked.load(Ordering::SeqCst), 2);
10920
10921        let mut denied_builder = VerbRegistryBuilder::new();
10922        denied_builder.register(pack());
10923        denied_builder.with_gate(Arc::new(AlwaysDenyUpdateGate {
10924            checked: Arc::clone(&checked),
10925        }));
10926        let denied = denied_builder.build().expect("denied registry builds");
10927
10928        let present_error = denied
10929            .dispatch("update", serde_json::json!({"id": existing_id.clone()}))
10930            .await
10931            .expect_err("denied present-id update must not resolve the id");
10932        let absent_error = denied
10933            .dispatch("update", serde_json::json!({"id": absent_id.clone()}))
10934            .await
10935            .expect_err("denied absent-id update must not resolve the id");
10936
10937        let denial = |error: RuntimeError| match error {
10938            RuntimeError::PermissionDenied { verb, reason, .. } => (verb, reason),
10939            other => panic!("expected gate refusal, got {other:?}"),
10940        };
10941        let present_denial = denial(present_error);
10942        let absent_denial = denial(absent_error);
10943        assert_eq!(present_denial.0, "update");
10944        assert_eq!(present_denial.1, "caller has no update capability");
10945        assert_eq!(present_denial, absent_denial);
10946        assert_eq!(
10947            checked.load(Ordering::SeqCst),
10948            2,
10949            "both denied requests must consult the configured gate"
10950        );
10951        assert_eq!(
10952            invoked.load(Ordering::SeqCst),
10953            2,
10954            "neither denied request may reach the existence oracle"
10955        );
10956    }
10957
10958    #[tokio::test]
10959    #[serial(config_ledger)]
10960    async fn runtime_audit_sink_uses_final_namespace_in_both_builder_orders() {
10961        for namespace_first in [true, false] {
10962            let runtime = KhiveRuntime::memory().expect("memory runtime");
10963            runtime
10964                .raw_events_for_namespace("local")
10965                .expect("local sink")
10966                .append_event(Event::new(
10967                    "local",
10968                    "list",
10969                    EventKind::Audit,
10970                    SubstrateKind::Event,
10971                    "actor:unrelated",
10972                ))
10973                .await
10974                .expect("local control event");
10975
10976            let mut builder = VerbRegistryBuilder::new();
10977            builder.register(AlphaPack);
10978            builder.with_actor_id(Some("lambda:dispatcher".to_string()));
10979            if namespace_first {
10980                builder.with_default_namespace("audit-tenant");
10981            }
10982            builder
10983                .with_runtime_event_store(&runtime)
10984                .expect("configure runtime sink");
10985            if !namespace_first {
10986                builder.with_default_namespace("audit-tenant");
10987            }
10988            let registry = builder.build().expect("registry builds");
10989            registry
10990                .dispatch("list", serde_json::json!({}))
10991                .await
10992                .expect("dispatch persists its audit");
10993
10994            // Raw writes retain their supplied namespace even when the sink's
10995            // read scope is stale, so a separate runtime accessor hides the bug.
10996            let page = registry
10997                .event_store()
10998                .expect("registry retains the sink")
10999                .query_events(
11000                    EventFilter {
11001                        verbs: vec!["list".to_string()],
11002                        ..EventFilter::default()
11003                    },
11004                    PageRequest {
11005                        limit: 10,
11006                        offset: 0,
11007                    },
11008                )
11009                .await
11010                .expect("query the registry's sink");
11011            assert_eq!(page.items.len(), 1, "namespace_first={namespace_first}");
11012            let event = &page.items[0];
11013            assert_eq!(event.namespace, "audit-tenant");
11014            assert_eq!(event.actor, "actor:lambda:dispatcher");
11015            assert_eq!(event.outcome, EventOutcome::Success);
11016        }
11017    }
11018
11019    #[tokio::test]
11020    #[serial(config_ledger)]
11021    async fn runtime_audit_sink_configuration_preserves_last_setter() {
11022        #[derive(Clone, Copy, Debug)]
11023        enum Sink {
11024            Runtime,
11025            Custom,
11026            ReadOnly,
11027        }
11028
11029        let runtime = KhiveRuntime::memory().expect("memory runtime");
11030        runtime
11031            .raw_events_for_namespace("audit-tenant")
11032            .expect("runtime sink")
11033            .append_event(Event::new(
11034                "audit-tenant",
11035                "audit.fixture",
11036                EventKind::Audit,
11037                SubstrateKind::Event,
11038                "actor:creator",
11039            ))
11040            .await
11041            .expect("runtime control event");
11042        let custom: Arc<dyn EventStore> = Arc::new(MemoryEventStore::default());
11043
11044        for (first, last) in [
11045            (Sink::Runtime, Sink::Custom),
11046            (Sink::Runtime, Sink::ReadOnly),
11047            (Sink::Custom, Sink::Runtime),
11048            (Sink::Custom, Sink::ReadOnly),
11049            (Sink::ReadOnly, Sink::Runtime),
11050            (Sink::ReadOnly, Sink::Custom),
11051        ] {
11052            let mut builder = VerbRegistryBuilder::new();
11053            builder.with_default_namespace("audit-tenant");
11054            for sink in [first, last] {
11055                match sink {
11056                    Sink::Runtime => {
11057                        builder
11058                            .with_runtime_event_store(&runtime)
11059                            .expect("configure runtime sink");
11060                    }
11061                    Sink::Custom => {
11062                        builder.with_event_store(custom.clone());
11063                    }
11064                    Sink::ReadOnly => {
11065                        builder.with_read_only_audit_store();
11066                    }
11067                }
11068            }
11069            let registry = builder.build().expect("registry builds");
11070            match last {
11071                Sink::Runtime => {
11072                    let store = registry.event_store().expect("runtime sink wins");
11073                    assert!(!Arc::ptr_eq(&store, &custom));
11074                    assert_eq!(
11075                        store.count_events(EventFilter::default()).await.unwrap(),
11076                        1,
11077                        "runtime event remains readable after {first:?}"
11078                    );
11079                    assert!(registry.audit_persistence_advisory().is_none());
11080                    assert!(registry.audit_batch_metrics().is_some());
11081                }
11082                Sink::Custom => {
11083                    assert!(Arc::ptr_eq(
11084                        &registry.event_store().expect("custom sink wins"),
11085                        &custom
11086                    ));
11087                    assert!(registry.audit_persistence_advisory().is_none());
11088                    assert!(registry.audit_batch_metrics().is_some());
11089                }
11090                Sink::ReadOnly => {
11091                    assert!(registry.event_store().is_none());
11092                    assert!(registry.audit_persistence_advisory().is_some());
11093                    assert!(registry.audit_batch_metrics().is_none());
11094                }
11095            }
11096        }
11097    }
11098
11099    #[test]
11100    #[serial(config_ledger)]
11101    fn runtime_audit_sink_is_not_bound_when_replaced_or_building_metadata() {
11102        let directory = tempfile::tempdir().expect("temporary directory");
11103        let runtime = KhiveRuntime::new(crate::runtime::RuntimeConfig {
11104            db_path: None,
11105            packs: vec![],
11106            brain_profile: None,
11107            actor_id: None,
11108            events_split: Some(crate::events_split::EventsSplitConfig {
11109                db_path: directory.path().to_path_buf(),
11110                socket_path: None,
11111            }),
11112            ..crate::runtime::RuntimeConfig::no_embeddings()
11113        })
11114        .expect("runtime creation does not open the events sink");
11115
11116        let mut metadata = VerbRegistryBuilder::new();
11117        metadata.register(AlphaPack);
11118        metadata
11119            .with_runtime_event_store(&runtime)
11120            .expect("configuration defers the invalid sink");
11121        let metadata = metadata.build_metadata().expect("metadata needs no sink");
11122        assert!(metadata.has_verb("list"));
11123        assert!(metadata.registry.event_store().is_none());
11124        assert!(metadata.registry.audit_batch_metrics().is_none());
11125
11126        let mut custom = VerbRegistryBuilder::new();
11127        custom.with_runtime_event_store(&runtime).unwrap();
11128        custom.with_event_store(Arc::new(MemoryEventStore::default()));
11129        assert!(custom
11130            .build()
11131            .expect("custom replaces runtime")
11132            .event_store()
11133            .is_some());
11134
11135        let mut read_only = VerbRegistryBuilder::new();
11136        read_only.with_runtime_event_store(&runtime).unwrap();
11137        read_only.with_read_only_audit_store();
11138        assert!(read_only
11139            .build()
11140            .expect("read-only replaces runtime")
11141            .event_store()
11142            .is_none());
11143
11144        let mut serving = VerbRegistryBuilder::new();
11145        serving.with_runtime_event_store(&runtime).unwrap();
11146        assert!(
11147            serving.build().is_err(),
11148            "serving build must surface the error opening a directory as an events database"
11149        );
11150    }
11151
11152    #[tokio::test]
11153    #[serial(config_ledger)]
11154    async fn audit_event_persists_to_event_store_on_allow() {
11155        let store = Arc::new(MemoryEventStore::default());
11156        let mut builder = VerbRegistryBuilder::new();
11157        builder.register(AlphaPack);
11158        builder.with_event_store(store.clone());
11159        let reg = builder.build().expect("registry builds");
11160
11161        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
11162            .await
11163            .unwrap();
11164
11165        let count = store.count_events(EventFilter::default()).await.unwrap();
11166        assert_eq!(count, 1, "one audit event persisted to EventStore on allow");
11167
11168        let page = store
11169            .query_events(
11170                EventFilter::default(),
11171                PageRequest {
11172                    limit: 10,
11173                    offset: 0,
11174                },
11175            )
11176            .await
11177            .unwrap();
11178        let ev = &page.items[0];
11179        assert_eq!(ev.verb, "list");
11180        assert_eq!(ev.namespace, "test-ns");
11181        assert_eq!(ev.substrate, SubstrateKind::Event);
11182        assert_eq!(ev.outcome, EventOutcome::Success);
11183    }
11184
11185    #[tokio::test]
11186    #[serial(config_ledger)]
11187    #[serial(audit_append_failures)]
11188    #[serial(audit_obligation_append_failures)]
11189    async fn audit_append_failure_fails_an_obligation_bearing_dispatch() {
11190        let before = audit_append_failure_count();
11191        let before_obligation = audit_obligation_append_failure_count();
11192
11193        let successful_store = Arc::new(MemoryEventStore::default());
11194        let mut successful_builder = VerbRegistryBuilder::new();
11195        successful_builder.register(AlphaPack);
11196        successful_builder.with_event_store(successful_store);
11197        let successful_registry = successful_builder.build().expect("registry builds");
11198        successful_registry
11199            .dispatch("list", Value::Null)
11200            .await
11201            .expect("successful audit append must not affect dispatch");
11202        assert_eq!(
11203            audit_append_failure_count(),
11204            before,
11205            "successful audit appends must not increment the swallowed-failure counter"
11206        );
11207        assert_eq!(
11208            audit_obligation_append_failure_count(),
11209            before_obligation,
11210            "successful audit appends must not increment the obligation-failure counter"
11211        );
11212
11213        // ADR-133 D2/D3/D4: `list`'s deferred audit row is a
11214        // `DispatchSucceeded` obligation. A dispatch must not report success
11215        // when the row that accounts for it did not commit, so a persistent
11216        // commit failure here must fail the dispatch that would otherwise
11217        // have reported success.
11218        let failing_store = Arc::new(MemoryEventStore {
11219            fail_appends: true,
11220            ..MemoryEventStore::default()
11221        });
11222        let mut failing_builder = VerbRegistryBuilder::new();
11223        failing_builder.register(AlphaPack);
11224        failing_builder.with_event_store(failing_store);
11225        let failing_registry = failing_builder.build().expect("registry builds");
11226        let err = failing_registry
11227            .dispatch("list", Value::Null)
11228            .await
11229            .expect_err(
11230                "a persistent obligation-bearing audit commit failure must fail the dispatch",
11231            );
11232        assert!(
11233            matches!(&err, RuntimeError::AuditObligation { failure, .. }
11234                if failure.message.contains("audit obligation commit failed")),
11235            "error names the obligation failure so it is distinguishable from a handler error: {err}"
11236        );
11237
11238        // `list`'s deferred audit row is `DispatchSucceeded`, an obligation
11239        // producer, so this propagated failure belongs on the obligation
11240        // counter — the swallowed-failure counter must not move for it.
11241        assert_eq!(
11242            audit_append_failure_count(),
11243            before,
11244            "an obligation failure must never inflate the swallowed-failure counter"
11245        );
11246        assert_eq!(
11247            audit_obligation_append_failure_count(),
11248            before_obligation + 1,
11249            "the failed obligation append must remain visible to diagnostics"
11250        );
11251    }
11252
11253    /// An obligation-bearing audit row is written AFTER the handler returns and FROM the
11254    /// handler's own return value. So when that row fails to commit,
11255    /// `fold_audit_obligation` turns a would-be success into an error for a dispatch whose
11256    /// effect has ALREADY happened and cannot be rolled back by it.
11257    ///
11258    /// The existing obligation test above proves the flip using `list`, a read, where the
11259    /// distinction does not matter. This one pins the part that decides caller behaviour:
11260    /// the write landed, and the caller was told it failed. A caller that treats this error
11261    /// as "it did not run" and retries therefore applies the effect twice, which is what the
11262    /// last assertion covers.
11263    ///
11264    /// The handler and the event store share one trace vector, so the ordering claim is
11265    /// observed rather than assumed. That matters more than it looks: asserting only the
11266    /// caller-visible error and the handler's effect would leave this test green against an
11267    /// implementation that submits no audit row at all, or that submits one built from the
11268    /// error path — both of which contradict the contract while producing exactly the same
11269    /// error string.
11270    ///
11271    /// The outcome alone does not separate those. A row can carry `Success` and still have
11272    /// been built without the handler's return value, which is a third implementation and
11273    /// also wrong. So the trace records whether the row's resource carries `cost_unit`:
11274    /// `resource_payload` derives that key from `ok_val`, and `base_resource_payload`
11275    /// documents that it omits it. Asserting the key is what pins result-sourcing; the
11276    /// verb is asserted alongside it so a fabricated row for some other verb cannot satisfy
11277    /// the same check.
11278    ///
11279    /// Not pinned here: that the row commits on a SEPARATE writer acquisition from the
11280    /// handler's. The store double has no writer to observe, so that half of the mechanism
11281    /// needs a different fixture than this one.
11282    // The config ledger is process-global and an event-store dispatch drains its
11283    // queue before invoking the pack, so a concurrent config_ledger test can land
11284    // a submission ahead of this handler's effect and break the first-entry
11285    // assertion below. That group is held for the position assertion, not for the
11286    // audit counters the other two groups cover.
11287    #[tokio::test]
11288    #[serial(config_ledger)]
11289    #[serial(audit_append_failures)]
11290    #[serial(audit_obligation_append_failures)]
11291    async fn obligation_failure_reports_a_write_that_already_committed() {
11292        /// `total` is what `cost_unit` is computed from, so this number is the
11293        /// test's handle on whether the audit row was built from the return
11294        /// value. 41 is arbitrary but distinctive: it makes the expected
11295        /// `cost_unit` 42 (`base_weight` 1 + `item_count` 41 * `model_count`
11296        /// 1), a value no default path produces.
11297        const RETURNED_TOTAL: u64 = 41;
11298
11299        #[derive(Debug)]
11300        struct RecordingWritePack {
11301            committed: Arc<std::sync::Mutex<Vec<TraceEntry>>>,
11302        }
11303
11304        impl Pack for RecordingWritePack {
11305            const NAME: &'static str = "recording_write";
11306            const NOTE_KINDS: &'static [&'static str] = &[];
11307            const ENTITY_KINDS: &'static [&'static str] = &[];
11308            // `knowledge.index` rather than `create`, and the choice is
11309            // load-bearing rather than incidental: it is the one verb whose
11310            // `cost_unit` reads the handler's return value (`item_count` takes
11311            // `result["total"]`). Under any other verb `item_count` is the
11312            // constant `1`, so the recorded `cost_unit` would be the same
11313            // whether the row was built from the result or from a static value,
11314            // and the assertion below could not tell those apart.
11315            const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
11316                name: "knowledge.index",
11317                description: "record one committed write",
11318                visibility: Visibility::Verb,
11319                category: VerbCategory::Commissive,
11320                params: &[],
11321            }];
11322        }
11323
11324        #[async_trait]
11325        impl PackRuntime for RecordingWritePack {
11326            fn name(&self) -> &str {
11327                Self::NAME
11328            }
11329            fn note_kinds(&self) -> &'static [&'static str] {
11330                Self::NOTE_KINDS
11331            }
11332            fn entity_kinds(&self) -> &'static [&'static str] {
11333                Self::ENTITY_KINDS
11334            }
11335            fn handlers(&self) -> &'static [HandlerDef] {
11336                Self::HANDLERS
11337            }
11338            async fn dispatch(
11339                &self,
11340                _verb: &str,
11341                params: Value,
11342                _registry: &VerbRegistry,
11343                _token: &NamespaceToken,
11344            ) -> Result<Value, RuntimeError> {
11345                // Stands in for a committed effect: by the time this returns, the write
11346                // is done and nothing downstream can undo it. Pushed onto the SAME
11347                // vector the event store traces into, so the relative order of the
11348                // effect and the audit submission is observable rather than assumed.
11349                let name = params
11350                    .get("name")
11351                    .and_then(Value::as_str)
11352                    .unwrap_or("unnamed")
11353                    .to_string();
11354                self.committed
11355                    .lock()
11356                    .expect("committed lock")
11357                    .push(TraceEntry::Effect { name: name.clone() });
11358                Ok(serde_json::json!({ "created": name, "total": RETURNED_TOTAL }))
11359            }
11360        }
11361
11362        let committed = Arc::new(std::sync::Mutex::new(Vec::new()));
11363        let failing_store = Arc::new(MemoryEventStore {
11364            fail_appends: true,
11365            trace: Some(Arc::clone(&committed)),
11366            ..MemoryEventStore::default()
11367        });
11368        let mut builder = VerbRegistryBuilder::new();
11369        builder.register(RecordingWritePack {
11370            committed: Arc::clone(&committed),
11371        });
11372        builder.with_event_store(failing_store);
11373        let registry = builder.build().expect("registry builds");
11374
11375        let err = registry
11376            .dispatch("knowledge.index", serde_json::json!({"name": "first"}))
11377            .await
11378            .expect_err("an obligation commit failure must fail the dispatch");
11379        assert!(
11380            matches!(&err, RuntimeError::AuditObligation { failure, .. }
11381                if failure.message.contains("audit obligation commit failed")),
11382            "the error must name the obligation failure, since that string is what tells a \
11383             caller the effect landed: {err}"
11384        );
11385
11386        // The trace carries both sides, so each of the three claims above is an
11387        // assertion rather than a comment. Read as a sequence it says: the handler's
11388        // effect committed, and only THEN was an audit row submitted -- a row built
11389        // from the successful result, which is what makes it the obligation row and
11390        // not an error row.
11391        let first_pass = committed.lock().expect("committed lock").clone();
11392        let first_effect = TraceEntry::Effect {
11393            name: "first".to_string(),
11394        };
11395        assert_eq!(
11396            first_pass.first(),
11397            Some(&first_effect),
11398            "the handler's effect must land FIRST: an implementation that submitted the \
11399             audit row before dispatching would satisfy every other assertion here"
11400        );
11401        let audit_rows: Vec<&TraceEntry> = first_pass
11402            .iter()
11403            .filter(|entry| matches!(entry, TraceEntry::Audit { .. }))
11404            .collect();
11405        assert!(
11406            !audit_rows.is_empty(),
11407            "an audit row must actually be SUBMITTED; without this assertion the test \
11408             passes against an implementation that returns the same error and never \
11409             builds a row at all, which is a different defect wearing the same error \
11410             string. Trace was {first_pass:?}"
11411        );
11412        let expected_cost_unit = serde_json::json!(RETURNED_TOTAL + 1);
11413        assert!(
11414            audit_rows.iter().any(|entry| matches!(
11415                entry,
11416                TraceEntry::Audit {
11417                    outcome: EventOutcome::Success,
11418                    verb,
11419                    cost_unit: Some(cost_unit),
11420                    ..
11421                } if verb.as_str() == "knowledge.index" && *cost_unit == expected_cost_unit
11422            )),
11423            "the submitted row must be the SUCCESS row for THIS verb, carrying a resource \
11424             computed FROM the handler's return value. The outcome alone is not enough: an \
11425             implementation that stamps Success on a row built without `ok_val` would \
11426             satisfy an outcome-only assertion while breaking the contract this test \
11427             exists for. The exact value is the discriminator, not the key's presence: \
11428             `resource_payload` inserts `cost_unit` unconditionally, so presence survives a \
11429             static `ok_val`, while {expected_cost_unit} is reachable only from the \
11430             returned `total` of {RETURNED_TOTAL} (`base_weight` 1 + `item_count` \
11431             {RETURNED_TOTAL} * `model_count` 1). Substituting a null or static result \
11432             collapses it to 1, and the error path's `base_resource_payload` omits the key \
11433             entirely (`cost_unit: None` here), so all three implementations are \
11434             distinguishable. Trace was {first_pass:?}"
11435        );
11436        assert_eq!(
11437            first_pass
11438                .iter()
11439                .filter(|entry| **entry == first_effect)
11440                .count(),
11441            1,
11442            "exactly one effect on the first pass"
11443        );
11444
11445        // What a caller does on a failure it believes means "did not run".
11446        let _ = registry
11447            .dispatch("knowledge.index", serde_json::json!({"name": "first"}))
11448            .await
11449            .expect_err("the retry fails the same way");
11450        assert_eq!(
11451            committed
11452                .lock()
11453                .expect("committed lock")
11454                .iter()
11455                .filter(|entry| **entry == first_effect)
11456                .count(),
11457            2,
11458            "retrying this error double-writes; a caller must re-derive state instead of \
11459             resubmitting"
11460        );
11461    }
11462
11463    #[tokio::test]
11464    #[serial(config_ledger)]
11465    #[serial(audit_append_failures)]
11466    #[serial(audit_obligation_append_failures)]
11467    async fn config_locked_row_degrades_without_failing_the_dispatch_that_observed_it() {
11468        // Deny-gate a dispatch so the only append this call makes is the
11469        // immediate `ConfigLocked` drain in the gate-check block: the
11470        // `GateDenied` row and the eventual `PermissionDenied` return are
11471        // unaffected by the store either way (see the two `let _ =` sites
11472        // above), so any failure this test observes is isolated to the
11473        // pure-observability `ConfigLocked` row. The `fail_appends: true`
11474        // store also fails the fire-and-forget `GateDenied` append, which
11475        // now counts on the obligation counter (`#[serial(...)]` above
11476        // keeps that from racing this file's exact-delta assertions on it).
11477        #[derive(Debug)]
11478        struct DenyGate;
11479        impl Gate for DenyGate {
11480            fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
11481                Ok(GateDecision::Deny {
11482                    reason: "denied for test".to_string(),
11483                })
11484            }
11485        }
11486
11487        crate::config_ledger::record_config_locked("adr133_test_key", "adr133_test_value");
11488
11489        let store = Arc::new(MemoryEventStore {
11490            fail_appends: true,
11491            ..MemoryEventStore::default()
11492        });
11493        let mut builder = VerbRegistryBuilder::new();
11494        builder.register(AlphaPack);
11495        builder.with_gate(Arc::new(DenyGate));
11496        builder.with_event_store(store);
11497        let registry = builder.build().expect("registry builds");
11498
11499        let err = registry
11500            .dispatch("list", Value::Null)
11501            .await
11502            .expect_err("the gate denies every request");
11503        assert!(
11504            matches!(err, RuntimeError::PermissionDenied { .. }),
11505            "a pure-observability row's failure must never surface as the dispatch error: {err}"
11506        );
11507
11508        let metrics = registry
11509            .audit_batch_metrics()
11510            .expect("with_event_store configures the ADR-133 seam");
11511        assert!(
11512            metrics.degraded,
11513            "the config-locked row's commit failure must be visible as degradation"
11514        );
11515        assert!(metrics.degraded_rows >= 1);
11516    }
11517
11518    #[tokio::test]
11519    #[serial(config_ledger)]
11520    #[serial(audit_append_failures)]
11521    async fn config_locked_row_failure_never_fails_a_dispatch_that_would_otherwise_succeed() {
11522        // ADR-133 criterion 4's success half: a pure-observability row's
11523        // commit failure must degrade gracefully without touching the
11524        // caller-visible outcome of a dispatch that has nothing to do with
11525        // it. Only the `ConfigLocked` generation fails here — the gate
11526        // allows the call, so `list`'s own `DispatchSucceeded` obligation
11527        // row commits in a later, unaffected generation.
11528        crate::config_ledger::record_config_locked(
11529            "adr133_success_path_key",
11530            "adr133_success_path_value",
11531        );
11532
11533        let store = Arc::new(MemoryEventStore {
11534            fail_kind: Some(EventKind::ConfigLocked),
11535            ..MemoryEventStore::default()
11536        });
11537        let mut builder = VerbRegistryBuilder::new();
11538        builder.register(AlphaPack);
11539        builder.with_event_store(store);
11540        let registry = builder.build().expect("registry builds");
11541
11542        let result = registry
11543            .dispatch("list", Value::Null)
11544            .await
11545            .expect("a config-locked row's commit failure must never fail an unrelated dispatch");
11546        assert_eq!(
11547            result,
11548            serde_json::json!({ "pack": "alpha", "verb": "list" })
11549        );
11550
11551        let metrics = registry
11552            .audit_batch_metrics()
11553            .expect("with_event_store configures the ADR-133 seam");
11554        assert!(
11555            metrics.degraded_rows >= 1,
11556            "the config-locked row's failure must remain visible as degradation"
11557        );
11558    }
11559
11560    /// An `EventStore` that only implements the base trait — the
11561    /// unmodified pre-ADR-133 shape. `preflight_event`/
11562    /// `append_events_idempotent`/`supports_idempotent_audit_batch` are all
11563    /// inherited defaults.
11564    #[derive(Default)]
11565    struct LegacyEventStore {
11566        events: std::sync::Mutex<Vec<Event>>,
11567    }
11568
11569    #[async_trait]
11570    impl EventStore for LegacyEventStore {
11571        async fn append_event(&self, event: Event) -> khive_storage::StorageResult<()> {
11572            self.events.lock().unwrap().push(event);
11573            Ok(())
11574        }
11575        async fn append_events(
11576            &self,
11577            events: Vec<Event>,
11578        ) -> khive_storage::StorageResult<BatchWriteSummary> {
11579            let attempted = events.len() as u64;
11580            self.events.lock().unwrap().extend(events);
11581            Ok(BatchWriteSummary {
11582                attempted,
11583                affected: attempted,
11584                ..BatchWriteSummary::default()
11585            })
11586        }
11587        async fn get_event(&self, id: uuid::Uuid) -> khive_storage::StorageResult<Option<Event>> {
11588            Ok(self
11589                .events
11590                .lock()
11591                .unwrap()
11592                .iter()
11593                .find(|e| e.id == id)
11594                .cloned())
11595        }
11596        async fn query_events(
11597            &self,
11598            _filter: EventFilter,
11599            _page: PageRequest,
11600        ) -> khive_storage::StorageResult<Page<Event>> {
11601            let items = self.events.lock().unwrap().clone();
11602            let total = items.len() as u64;
11603            Ok(Page {
11604                items,
11605                total: Some(total),
11606            })
11607        }
11608        async fn count_events(&self, _filter: EventFilter) -> khive_storage::StorageResult<u64> {
11609            Ok(self.events.lock().unwrap().len() as u64)
11610        }
11611    }
11612
11613    #[test]
11614    #[serial(config_ledger)]
11615    fn build_rejects_a_configured_event_store_incompatible_with_the_audit_batch_seam() {
11616        let mut builder = VerbRegistryBuilder::new();
11617        builder.register(AlphaPack);
11618        builder.with_event_store(Arc::new(LegacyEventStore::default()));
11619        let err = match builder.build() {
11620            Ok(_) => {
11621                panic!("a store that cannot implement the seam must not build a healthy registry")
11622            }
11623            Err(err) => err,
11624        };
11625        assert!(
11626            matches!(&err, RuntimeError::IncompatibleEventStore(message)
11627                if message.contains("supports_idempotent_audit_batch")),
11628            "error names the missing capability so an operator can act on it: {err}"
11629        );
11630    }
11631
11632    #[tokio::test]
11633    #[serial(config_ledger)]
11634    #[serial(audit_append_failures)]
11635    #[serial(audit_obligation_append_failures)]
11636    async fn db_diagnostics_with_audit_metrics_reports_batch_failure_and_degradation() {
11637        // One dispatch call exercises both halves of the classifier through
11638        // the registry it actually owns the seam on: the queued
11639        // `ConfigLocked` (pure-observability) row drains during the gate
11640        // check regardless of allow/deny, and `list`'s deferred
11641        // `DispatchSucceeded` (obligation) row is appended once dispatch
11642        // resolves — both against the same persistently failing store.
11643        crate::config_ledger::record_config_locked(
11644            "adr133_diag_test_key",
11645            "adr133_diag_test_value",
11646        );
11647        let store = Arc::new(MemoryEventStore {
11648            fail_appends: true,
11649            ..MemoryEventStore::default()
11650        });
11651        let mut builder = VerbRegistryBuilder::new();
11652        builder.register(AlphaPack);
11653        builder.with_event_store(store);
11654        let registry = builder.build().expect("registry builds");
11655        let _ = registry.dispatch("list", Value::Null).await;
11656
11657        let metrics = registry
11658            .audit_batch_metrics()
11659            .expect("with_event_store configures the ADR-133 seam");
11660        assert!(metrics.degraded, "the config-locked row must have degraded");
11661        assert!(metrics.degraded_rows >= 1);
11662        assert!(
11663            metrics.flush_failures >= 1,
11664            "the list dispatch's obligation row must count as a flush failure"
11665        );
11666
11667        let rt = KhiveRuntime::memory().expect("memory runtime should create");
11668        let report = rt
11669            .db_diagnostics_with_audit_metrics(Some(metrics))
11670            .await
11671            .expect("diagnostics succeed");
11672        assert_eq!(report.writer_contention.audit_degraded, Some(true));
11673        assert!(report.writer_contention.audit_degraded_rows.unwrap_or(0) >= 1);
11674        assert!(
11675            report
11676                .writer_contention
11677                .audit_batch_flush_failures
11678                .unwrap_or(0)
11679                >= 1
11680        );
11681        assert!(report
11682            .writer_contention
11683            .audit_batch_flush_failures_unavailable_reason
11684            .is_none());
11685        assert!(report
11686            .writer_contention
11687            .audit_degraded_unavailable_reason
11688            .is_none());
11689
11690        // The no-metrics path (a bare `KhiveRuntime::db_diagnostics`, or the
11691        // `db_diagnostics_with_audit_metrics(None)` it delegates to) must
11692        // still report the batch-health fields as explicitly unavailable
11693        // rather than silently zero, so an operator cannot mistake "no
11694        // registry wired in" for "no failures occurred".
11695        let bare_report = rt.db_diagnostics().await.expect("diagnostics succeed");
11696        assert!(bare_report.writer_contention.audit_degraded.is_none());
11697        assert!(bare_report
11698            .writer_contention
11699            .audit_degraded_unavailable_reason
11700            .is_some());
11701        assert!(bare_report
11702            .writer_contention
11703            .audit_admission_refused_obligations
11704            .is_none());
11705        assert!(bare_report
11706            .writer_contention
11707            .audit_admission_refused_obligations_unavailable_reason
11708            .is_some());
11709        assert!(bare_report
11710            .writer_contention
11711            .audit_admission_unresolved_obligations
11712            .is_none());
11713        assert!(bare_report
11714            .writer_contention
11715            .audit_admission_unresolved_obligations_unavailable_reason
11716            .is_some());
11717    }
11718
11719    #[tokio::test]
11720    #[serial(config_ledger)]
11721    async fn audit_event_duration_us_reflects_measured_dispatch_time() {
11722        // The persisted audit row's `duration_us` must carry the measured
11723        // pack-dispatch time, not the `Event::new` default of 0 (persisting
11724        // the row before dispatch ran always yielded 0). `SleepingPack`
11725        // sleeps 20ms so the assertion has a wide, non-flaky margin over
11726        // scheduling jitter.
11727        let store = Arc::new(MemoryEventStore::default());
11728        let mut builder = VerbRegistryBuilder::new();
11729        builder.register(SleepingPack);
11730        builder.with_event_store(store.clone());
11731        let reg = builder.build().expect("registry builds");
11732
11733        reg.dispatch("slow_op", serde_json::json!({}))
11734            .await
11735            .unwrap();
11736
11737        let page = store
11738            .query_events(
11739                EventFilter::default(),
11740                PageRequest {
11741                    limit: 10,
11742                    offset: 0,
11743                },
11744            )
11745            .await
11746            .unwrap();
11747        assert_eq!(page.items.len(), 1);
11748        let ev = &page.items[0];
11749        assert!(
11750            ev.duration_us >= 10_000,
11751            "duration_us must reflect the ~20ms measured dispatch time, got {}",
11752            ev.duration_us
11753        );
11754    }
11755
11756    #[tokio::test]
11757    #[serial(config_ledger)]
11758    async fn dispatch_unknown_verb_allowed_by_gate_still_persists_audit_row() {
11759        // Generalizing audit-row deferral to every Allow-outcome verb (not
11760        // just singleton `link`) must not silently drop the audit row for a
11761        // verb the gate allows but no pack owns. `duration_us` stays at the
11762        // `Event::new` default of 0 here since no dispatch ever ran to measure.
11763        let store = Arc::new(MemoryEventStore::default());
11764        let mut builder = VerbRegistryBuilder::new();
11765        builder.register(AlphaPack);
11766        builder.with_event_store(store.clone());
11767        let reg = builder.build().expect("registry builds");
11768
11769        let result = reg.dispatch("no_such_verb", serde_json::json!({})).await;
11770        assert!(result.is_err(), "unknown verb must still return an error");
11771
11772        let count = store.count_events(EventFilter::default()).await.unwrap();
11773        assert_eq!(
11774            count, 1,
11775            "an allowed-but-unknown verb must still persist one audit row"
11776        );
11777        let page = store
11778            .query_events(
11779                EventFilter::default(),
11780                PageRequest {
11781                    limit: 10,
11782                    offset: 0,
11783                },
11784            )
11785            .await
11786            .unwrap();
11787        assert_eq!(page.items[0].duration_us, 0);
11788        // Dispatch returns UnknownVerb for an unknown verb, so the
11789        // persisted outcome must be Error, not the previously-hardcoded
11790        // Success.
11791        assert_eq!(page.items[0].outcome, EventOutcome::Error);
11792    }
11793
11794    #[tokio::test]
11795    #[serial(config_ledger)]
11796    async fn audit_event_persists_to_event_store_on_deny() {
11797        #[derive(Debug)]
11798        struct AlwaysDenyGate;
11799        impl Gate for AlwaysDenyGate {
11800            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
11801                Ok(GateDecision::deny("denied by test"))
11802            }
11803        }
11804
11805        let store = Arc::new(MemoryEventStore::default());
11806        let mut builder = VerbRegistryBuilder::new();
11807        builder.register(AlphaPack);
11808        builder.with_gate(Arc::new(AlwaysDenyGate));
11809        builder.with_event_store(store.clone());
11810        let reg = builder.build().expect("registry builds");
11811
11812        // Hard enforce → PermissionDenied returned.
11813        let err = reg
11814            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
11815            .await
11816            .unwrap_err();
11817        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
11818
11819        let count = store.count_events(EventFilter::default()).await.unwrap();
11820        assert_eq!(count, 1, "one audit event persisted to EventStore on deny");
11821
11822        let page = store
11823            .query_events(
11824                EventFilter::default(),
11825                PageRequest {
11826                    limit: 10,
11827                    offset: 0,
11828                },
11829            )
11830            .await
11831            .unwrap();
11832        let ev = &page.items[0];
11833        assert_eq!(ev.verb, "list");
11834        assert_eq!(ev.outcome, EventOutcome::Denied);
11835    }
11836
11837    #[derive(Debug)]
11838    struct MailboxTrackingPack {
11839        invoked: Arc<AtomicUsize>,
11840    }
11841
11842    impl Pack for MailboxTrackingPack {
11843        const NAME: &'static str = "mailbox_tracking";
11844        const NOTE_KINDS: &'static [&'static str] = &[];
11845        const ENTITY_KINDS: &'static [&'static str] = &[];
11846        const HANDLERS: &'static [HandlerDef] = &[
11847            HandlerDef {
11848                name: "comm.inbox",
11849                description: "mailbox admission probe",
11850                visibility: Visibility::Verb,
11851                category: VerbCategory::Assertive,
11852                params: &[],
11853            },
11854            HandlerDef {
11855                name: "comm.thread",
11856                description: "mailbox admission probe",
11857                visibility: Visibility::Verb,
11858                category: VerbCategory::Assertive,
11859                params: &[],
11860            },
11861        ];
11862    }
11863
11864    #[async_trait]
11865    impl PackRuntime for MailboxTrackingPack {
11866        fn name(&self) -> &str {
11867            "mailbox_tracking"
11868        }
11869        fn note_kinds(&self) -> &'static [&'static str] {
11870            &[]
11871        }
11872        fn entity_kinds(&self) -> &'static [&'static str] {
11873            &[]
11874        }
11875        fn handlers(&self) -> &'static [HandlerDef] {
11876            Self::HANDLERS
11877        }
11878        async fn dispatch(
11879            &self,
11880            _verb: &str,
11881            _params: Value,
11882            _registry: &VerbRegistry,
11883            token: &NamespaceToken,
11884        ) -> Result<Value, RuntimeError> {
11885            self.invoked.fetch_add(1, Ordering::SeqCst);
11886            Ok(serde_json::json!({"actor":token.actor().id}))
11887        }
11888    }
11889
11890    #[tokio::test]
11891    #[serial(config_ledger)]
11892    async fn gate_mailbox_normal_and_intercepted_default_deny_audit_real_caller() {
11893        let store = Arc::new(MemoryEventStore::default());
11894        let invoked = Arc::new(AtomicUsize::new(0));
11895        let mut builder = VerbRegistryBuilder::new();
11896        builder.register(MailboxTrackingPack {
11897            invoked: invoked.clone(),
11898        });
11899        builder.with_event_store(store.clone());
11900        builder.with_actor_id(Some("lambda:owner".into()));
11901        let registry = builder.build().unwrap();
11902        let identity = RequestIdentity {
11903            actor_id: Some("lambda:reader".into()),
11904            namespace: "local".into(),
11905            ..Default::default()
11906        };
11907        for verb in ["comm.inbox", "comm.thread"] {
11908            for namespace in ["local", "lambda:owner"] {
11909                let args = serde_json::json!({"mailbox_actor":"lambda:owner", "namespace":namespace, "actor":"lambda:owner"});
11910                let error = registry
11911                    .dispatch_with_identity(verb, args.clone(), Some(identity.clone()))
11912                    .await
11913                    .unwrap_err();
11914                assert!(
11915                    matches!(error, RuntimeError::PermissionDenied { reason, .. } if reason == "mailbox_read_not_granted")
11916                );
11917                let error = registry
11918                    .dispatch_intercepted_with_metadata_and_disposition(
11919                        verb,
11920                        &args,
11921                        Some(&identity),
11922                        |_| async {
11923                            invoked.fetch_add(1, Ordering::SeqCst);
11924                            Ok(InterceptedDispatchResult::new(Value::Null, ()))
11925                        },
11926                    )
11927                    .await
11928                    .unwrap_err()
11929                    .into_source();
11930                assert!(
11931                    matches!(error, RuntimeError::PermissionDenied { reason, .. } if reason == "mailbox_read_not_granted")
11932                );
11933            }
11934        }
11935        assert_eq!(invoked.load(Ordering::SeqCst), 0);
11936        let events = store
11937            .query_events(
11938                EventFilter::default(),
11939                PageRequest {
11940                    limit: 20,
11941                    offset: 0,
11942                },
11943            )
11944            .await
11945            .unwrap()
11946            .items;
11947        assert_eq!(events.len(), 8);
11948        for event in events {
11949            assert_eq!(event.actor, "actor:lambda:reader");
11950            assert_eq!(event.outcome, EventOutcome::Denied);
11951            assert_eq!(event.payload["actor"]["id"], "lambda:reader");
11952            assert_eq!(event.payload["deny_reason"], "mailbox_read_not_granted");
11953        }
11954        // A positive own-view control proves the ordinary handler is present.
11955        let value = registry
11956            .dispatch_with_identity("comm.inbox", serde_json::json!({}), Some(identity.clone()))
11957            .await
11958            .unwrap();
11959        assert_eq!(value["actor"], "lambda:reader");
11960        assert_eq!(invoked.load(Ordering::SeqCst), 1);
11961        for args in [
11962            serde_json::json!({"mailbox_actor":null}),
11963            serde_json::json!({"mailbox_actor":"local"}),
11964        ] {
11965            assert!(matches!(
11966                registry
11967                    .dispatch_with_identity("comm.inbox", args.clone(), Some(identity.clone()))
11968                    .await,
11969                Err(RuntimeError::InvalidInput(_))
11970            ));
11971            let error = registry
11972                .dispatch_intercepted_with_metadata_and_disposition(
11973                    "comm.inbox",
11974                    &args,
11975                    Some(&identity),
11976                    |_| async {
11977                        invoked.fetch_add(1, Ordering::SeqCst);
11978                        Ok(InterceptedDispatchResult::new(Value::Null, ()))
11979                    },
11980                )
11981                .await
11982                .unwrap_err()
11983                .into_source();
11984            assert!(matches!(error, RuntimeError::InvalidInput(_)));
11985        }
11986        assert_eq!(invoked.load(Ordering::SeqCst), 1);
11987    }
11988
11989    #[tokio::test]
11990    #[serial(config_ledger)]
11991    async fn gate_mailbox_granted_dispatch_keeps_caller_and_backend_error_fails_closed() {
11992        #[derive(Debug)]
11993        struct BrokenMailboxGate;
11994        impl Gate for BrokenMailboxGate {
11995            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
11996                Ok(GateDecision::allow())
11997            }
11998            fn check_mailbox_read(
11999                &self,
12000                _req: &GateRequest,
12001                _owner: &khive_gate::ActorRef,
12002            ) -> Result<GateDecision, GateError> {
12003                Err(GateError::Internal("private policy outage".into()))
12004            }
12005        }
12006        let identity = RequestIdentity {
12007            actor_id: Some("lambda:reader".into()),
12008            namespace: "local".into(),
12009            ..Default::default()
12010        };
12011        let args = serde_json::json!({"mailbox_actor":"lambda:owner"});
12012        let gate: GateRef = Arc::new(
12013            khive_gate::MailboxReadGate::new(
12014                Arc::new(AllowAllGate),
12015                khive_gate::ActorRef::new("actor", "lambda:owner"),
12016                vec![khive_gate::ActorRef::new("actor", "lambda:reader")],
12017            )
12018            .unwrap(),
12019        );
12020        for (gate, allowed) in [
12021            (gate, true),
12022            (Arc::new(BrokenMailboxGate) as GateRef, false),
12023        ] {
12024            let store = Arc::new(MemoryEventStore::default());
12025            let invoked = Arc::new(AtomicUsize::new(0));
12026            let mut builder = VerbRegistryBuilder::new();
12027            builder.register(MailboxTrackingPack {
12028                invoked: invoked.clone(),
12029            });
12030            builder.with_actor_id(Some("lambda:owner".into()));
12031            builder.with_gate(gate);
12032            builder.with_event_store(store.clone());
12033            let registry = builder.build().unwrap();
12034            let normal = registry
12035                .dispatch_with_identity("comm.inbox", args.clone(), Some(identity.clone()))
12036                .await;
12037            let intercepted = registry
12038                .dispatch_intercepted_with_metadata_and_disposition(
12039                    "comm.thread",
12040                    &args,
12041                    Some(&identity),
12042                    |_| async {
12043                        invoked.fetch_add(1, Ordering::SeqCst);
12044                        Ok(InterceptedDispatchResult::new(Value::Null, ()))
12045                    },
12046                )
12047                .await
12048                .map_err(DispatchError::into_source);
12049            if allowed {
12050                assert_eq!(normal.unwrap()["actor"], "lambda:reader");
12051                intercepted.unwrap();
12052                assert_eq!(invoked.load(Ordering::SeqCst), 2);
12053            } else {
12054                assert!(
12055                    matches!(normal.unwrap_err(), RuntimeError::GateUnavailable { reason, .. } if reason == "gate backend unavailable")
12056                );
12057                assert!(
12058                    matches!(intercepted.unwrap_err(), RuntimeError::GateUnavailable { reason, .. } if reason == "gate backend unavailable")
12059                );
12060                assert_eq!(invoked.load(Ordering::SeqCst), 0);
12061            }
12062            let events = store
12063                .query_events(
12064                    EventFilter::default(),
12065                    PageRequest {
12066                        limit: 10,
12067                        offset: 0,
12068                    },
12069                )
12070                .await
12071                .unwrap()
12072                .items;
12073            assert_eq!(events.len(), 2);
12074            for event in events {
12075                assert_eq!(event.actor, "actor:lambda:reader");
12076                assert_eq!(
12077                    event.outcome,
12078                    if allowed {
12079                        EventOutcome::Success
12080                    } else {
12081                        EventOutcome::Error
12082                    }
12083                );
12084            }
12085        }
12086    }
12087
12088    #[tokio::test]
12089    #[serial(config_ledger)]
12090    async fn gate_error_returns_typed_refusal_without_invoking_pack() {
12091        #[derive(Debug)]
12092        struct FailingGate;
12093        impl Gate for FailingGate {
12094            fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
12095                Err(khive_gate::GateError::Internal("gate broken".into()))
12096            }
12097        }
12098
12099        let store = Arc::new(MemoryEventStore::default());
12100        let invoked = Arc::new(AtomicUsize::new(0));
12101        let mut builder = VerbRegistryBuilder::new();
12102        builder.register(GateErrorTrackingPack {
12103            invoked: Arc::clone(&invoked),
12104        });
12105        builder.with_gate(Arc::new(FailingGate));
12106        builder.with_event_store(store.clone());
12107        let reg = builder.build().expect("registry builds");
12108
12109        let err = reg
12110            .dispatch("guarded", Value::Null)
12111            .await
12112            .expect_err("gate unavailability must refuse normal dispatch");
12113        assert!(matches!(
12114            err,
12115            RuntimeError::GateUnavailable { ref verb, ref reason }
12116                if verb == "guarded"
12117                    && reason == "gate backend unavailable"
12118                    && !reason.contains("gate broken")
12119        ));
12120        assert_eq!(
12121            invoked.load(Ordering::SeqCst),
12122            0,
12123            "pack handler must not run after a gate infrastructure error"
12124        );
12125
12126        let count = store.count_events(EventFilter::default()).await.unwrap();
12127        assert_eq!(count, 1, "gate infrastructure error must be audited");
12128        let page = store
12129            .query_events(
12130                EventFilter::default(),
12131                PageRequest {
12132                    limit: 10,
12133                    offset: 0,
12134                },
12135            )
12136            .await
12137            .unwrap();
12138        let event = &page.items[0];
12139        assert_eq!(event.verb, "guarded");
12140        assert_eq!(event.outcome, EventOutcome::Error);
12141        assert_eq!(event.payload["decision"], "gate_unavailable");
12142        assert!(event.payload.get("deny_reason").is_none());
12143        assert_eq!(event.payload["resource"]["work_class"], "interactive");
12144        assert!(event.payload["resource"].get("cost_unit").is_none());
12145    }
12146
12147    #[tokio::test]
12148    #[serial(config_ledger)]
12149    #[serial(audit_append_failures)]
12150    async fn gate_error_audit_failure_cannot_reopen_dispatch_or_replace_typed_error() {
12151        #[derive(Debug)]
12152        struct FailingGate;
12153        impl Gate for FailingGate {
12154            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12155                Err(GateError::Internal("gate still broken".into()))
12156            }
12157        }
12158
12159        let before = audit_append_failure_count();
12160        let invoked = Arc::new(AtomicUsize::new(0));
12161        let store = Arc::new(MemoryEventStore {
12162            fail_appends: true,
12163            ..MemoryEventStore::default()
12164        });
12165        let mut builder = VerbRegistryBuilder::new();
12166        builder.register(GateErrorTrackingPack {
12167            invoked: Arc::clone(&invoked),
12168        });
12169        builder.with_gate(Arc::new(FailingGate));
12170        builder.with_event_store(store);
12171        let registry = builder.build().expect("registry builds");
12172
12173        let error = registry
12174            .dispatch("guarded", Value::Null)
12175            .await
12176            .expect_err("audit persistence failure must not reopen dispatch");
12177
12178        assert!(matches!(
12179            error,
12180            RuntimeError::GateUnavailable { ref verb, ref reason }
12181                if verb == "guarded"
12182                    && reason == "gate backend unavailable"
12183                    && !reason.contains("gate still broken")
12184        ));
12185        assert_eq!(invoked.load(Ordering::SeqCst), 0);
12186        assert_eq!(
12187            audit_append_failure_count(),
12188            before + 1,
12189            "best-effort audit failure remains diagnostic without changing the refusal"
12190        );
12191    }
12192
12193    /// Regression for a credential-disclosure path: a gate backend's error
12194    /// `Display` text can embed connection details (URLs, addresses, auth
12195    /// material). That text must never reach `RuntimeError::GateUnavailable`
12196    /// as observed by a dispatch caller — only the stable classified
12197    /// `wire_reason()` may cross that boundary. A bounded, masked rendering
12198    /// of the error is logged server-side via `tracing::warn!` in
12199    /// `gate_unavailable_error`.
12200    #[tokio::test]
12201    async fn gate_unavailable_reason_never_carries_backend_error_text() {
12202        const CANARY: &str = "postgres://svc:not-a-real-secret@internal-host";
12203
12204        #[derive(Debug)]
12205        struct FailingGate;
12206        impl Gate for FailingGate {
12207            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12208                Err(GateError::Internal(CANARY.to_string()))
12209            }
12210        }
12211
12212        let mut builder = VerbRegistryBuilder::new();
12213        builder.register(GateErrorTrackingPack {
12214            invoked: Arc::new(AtomicUsize::new(0)),
12215        });
12216        builder.with_gate(Arc::new(FailingGate));
12217        let registry = builder.build().expect("registry builds");
12218
12219        let err = registry
12220            .dispatch("guarded", Value::Null)
12221            .await
12222            .expect_err("gate unavailability must refuse dispatch");
12223
12224        let RuntimeError::GateUnavailable { reason, .. } = &err else {
12225            panic!("expected GateUnavailable, got {err:?}");
12226        };
12227        assert!(
12228            !reason.contains(CANARY),
12229            "caller-visible reason must not embed backend error text: {reason:?}"
12230        );
12231        assert!(
12232            !reason.contains("svc") && !reason.contains("internal-host"),
12233            "caller-visible reason must not embed backend error fragments: {reason:?}"
12234        );
12235        assert_eq!(reason, "gate backend unavailable");
12236
12237        // The full error, canary included, still reaches the server-side log.
12238        let rendered = err.to_string();
12239        assert!(
12240            !rendered.contains(CANARY),
12241            "top-level Display must not embed backend error text either: {rendered:?}"
12242        );
12243    }
12244
12245    /// Task 2 (ADR-129 gate-error classification): a `GateError::Policy`
12246    /// failure — the gate backend is reachable but its configured policy
12247    /// could not be evaluated — is a distinct, non-transient class from a
12248    /// `GateError::Internal` backend-availability failure, and gets its own
12249    /// stable reason text at the dispatch boundary.
12250    #[tokio::test]
12251    async fn gate_policy_error_classifies_distinctly_from_backend_unavailable() {
12252        #[derive(Debug)]
12253        struct PolicyBrokenGate;
12254        impl Gate for PolicyBrokenGate {
12255            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12256                Err(GateError::Policy(
12257                    "rule set has no allow clause for this namespace".to_string(),
12258                ))
12259            }
12260        }
12261
12262        let mut builder = VerbRegistryBuilder::new();
12263        builder.register(GateErrorTrackingPack {
12264            invoked: Arc::new(AtomicUsize::new(0)),
12265        });
12266        builder.with_gate(Arc::new(PolicyBrokenGate));
12267        let registry = builder.build().expect("registry builds");
12268
12269        let err = registry
12270            .dispatch("guarded", Value::Null)
12271            .await
12272            .expect_err("gate unavailability must refuse dispatch");
12273
12274        assert!(matches!(
12275            err,
12276            RuntimeError::GateUnavailable { ref verb, ref reason }
12277                if verb == "guarded"
12278                    && reason == "gate policy evaluation failed"
12279                    && !reason.contains("rule set has no allow clause")
12280        ));
12281    }
12282
12283    #[tokio::test]
12284    async fn no_event_store_configured_tracing_only() {
12285        // Ordinary verbs remain tracing-only without an event store. The
12286        // strict git.digest receipt exception is covered separately above.
12287        let mut builder = VerbRegistryBuilder::new();
12288        builder.register(AlphaPack);
12289        let reg = builder.build().expect("registry builds");
12290
12291        let res = reg.dispatch("list", Value::Null).await.unwrap();
12292        assert_eq!(res["pack"], "alpha");
12293    }
12294
12295    #[test]
12296    #[serial]
12297    fn dispatch_tracing_emits_gate_check_event_with_deny_payload() {
12298        #[derive(Debug)]
12299        struct TracingDenyGate;
12300        impl Gate for TracingDenyGate {
12301            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12302                Ok(GateDecision::deny("denied by test gate"))
12303            }
12304            fn impl_name(&self) -> &'static str {
12305                "TracingDenyGate"
12306            }
12307        }
12308
12309        let events = capture_dispatch_events(async {
12310            let mut builder = VerbRegistryBuilder::new();
12311            builder.register(AlphaPack);
12312            builder.with_gate(Arc::new(TracingDenyGate));
12313            let reg = builder.build().expect("registry builds");
12314            // Hard enforcement — dispatch returns PermissionDenied on Deny.
12315            // The tracing audit event is still emitted before the error is returned.
12316            let _ = reg.dispatch("create", serde_json::Value::Null).await;
12317        });
12318
12319        let gate_events = gate_check_events_for(&events, "TracingDenyGate");
12320        assert_eq!(
12321            gate_events.len(),
12322            1,
12323            "exactly one gate.check tracing event per dispatch (deny); got {gate_events:?}"
12324        );
12325        let payload = gate_events[0]
12326            .audit_event
12327            .as_ref()
12328            .expect("gate.check event must carry an audit_event field on Deny");
12329        let audit: khive_gate::AuditEvent =
12330            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
12331        assert_eq!(audit.decision, AuditDecision::Deny);
12332        assert_eq!(audit.deny_reason.as_deref(), Some("denied by test gate"));
12333        assert_eq!(audit.gate_impl, "TracingDenyGate");
12334        // Wire-shape rule: obligations is always serialized as an array, empty
12335        // on Deny. Round-trip back through serde_json::Value to confirm the
12336        // field exists on the wire and is `[]`, not missing.
12337        let payload_json: serde_json::Value =
12338            serde_json::from_str(payload).expect("payload must be valid JSON");
12339        assert_eq!(
12340            payload_json["obligations"],
12341            serde_json::Value::Array(Vec::new()),
12342            "obligations must be `[]` on Deny on the tracing payload, not omitted"
12343        );
12344    }
12345
12346    #[test]
12347    #[serial]
12348    fn dispatch_tracing_emits_gate_check_event_with_masked_deny_payload() {
12349        // Falsifiable arm for the audit-masking fix (khive#2944): this deny
12350        // reason embeds a fake credential in a shape the write-time secret
12351        // gate recognizes (`scheme://user:pass@host`). Deleting the masking
12352        // call at the production call site — or masking only the durable
12353        // sink and not this tracing line — turns this test red.
12354        #[derive(Debug)]
12355        struct TracingSecretDenyGate;
12356        impl Gate for TracingSecretDenyGate {
12357            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12358                let reason = "postgres://svc:not-a-real-secret@internal-host in denied request"; // gitleaks:allow
12359                Ok(GateDecision::deny(reason))
12360            }
12361            fn impl_name(&self) -> &'static str {
12362                "TracingSecretDenyGate"
12363            }
12364        }
12365
12366        let events = capture_dispatch_events(async {
12367            let mut builder = VerbRegistryBuilder::new();
12368            builder.register(AlphaPack);
12369            builder.with_gate(Arc::new(TracingSecretDenyGate));
12370            let reg = builder.build().expect("registry builds");
12371            let _ = reg.dispatch("create", serde_json::Value::Null).await;
12372        });
12373
12374        let gate_events = gate_check_events_for(&events, "TracingSecretDenyGate");
12375        assert_eq!(
12376            gate_events.len(),
12377            1,
12378            "exactly one gate.check tracing event per dispatch (deny); got {gate_events:?}"
12379        );
12380        let payload = gate_events[0]
12381            .audit_event
12382            .as_ref()
12383            .expect("gate.check event must carry an audit_event field on Deny");
12384        // Non-vacuity: the capture actually produced content, so the
12385        // negative assertion below cannot pass merely because nothing was
12386        // read.
12387        assert!(!payload.is_empty());
12388        let audit: khive_gate::AuditEvent =
12389            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
12390        let masked_reason = audit
12391            .deny_reason
12392            .as_deref()
12393            .expect("deny_reason must be present on a Deny audit event");
12394        assert!(
12395            masked_reason.contains("in denied request"),
12396            "non-secret prose must survive masking: {masked_reason:?}"
12397        );
12398        assert!(
12399            !masked_reason.contains("not-a-real-secret"),
12400            "the process log must never carry the raw credential: {masked_reason:?}"
12401        );
12402        assert!(
12403            masked_reason.contains("***MASKED***"),
12404            "the log must record that a credential was redacted: {masked_reason:?}"
12405        );
12406    }
12407
12408    #[test]
12409    #[serial]
12410    fn intercepted_dispatch_tracing_emits_gate_check_event_with_masked_deny_payload() {
12411        // Same falsifiable arm as
12412        // `dispatch_tracing_emits_gate_check_event_with_masked_deny_payload`,
12413        // exercised through the intercepted dispatch path
12414        // (`dispatch_intercepted_with_metadata_and_disposition`), the
12415        // audit-emission code's second production `AuditEvent` construction
12416        // site. Masking only the plain-dispatch call site would leave this
12417        // one red.
12418        #[derive(Debug)]
12419        struct InterceptedTracingSecretDenyGate;
12420        impl Gate for InterceptedTracingSecretDenyGate {
12421            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12422                let reason = "postgres://svc:not-a-real-secret@internal-host in denied request"; // gitleaks:allow
12423                Ok(GateDecision::deny(reason))
12424            }
12425            fn impl_name(&self) -> &'static str {
12426                "InterceptedTracingSecretDenyGate"
12427            }
12428        }
12429
12430        let events = capture_dispatch_events(async {
12431            let mut builder = VerbRegistryBuilder::new();
12432            builder.with_gate(Arc::new(InterceptedTracingSecretDenyGate));
12433            let reg = builder.build().expect("registry builds");
12434            let _ = reg
12435                .dispatch_intercepted_with_identity(
12436                    "list",
12437                    &Value::Null,
12438                    None,
12439                    move |_namespace| async move { Ok(serde_json::json!({"invoked": true})) },
12440                )
12441                .await;
12442        });
12443
12444        let gate_events = gate_check_events_for(&events, "InterceptedTracingSecretDenyGate");
12445        assert_eq!(
12446            gate_events.len(),
12447            1,
12448            "exactly one gate.check tracing event per intercepted dispatch (deny); got {gate_events:?}"
12449        );
12450        let payload = gate_events[0]
12451            .audit_event
12452            .as_ref()
12453            .expect("gate.check event must carry an audit_event field on Deny");
12454        assert!(!payload.is_empty());
12455        let audit: khive_gate::AuditEvent =
12456            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
12457        let masked_reason = audit
12458            .deny_reason
12459            .as_deref()
12460            .expect("deny_reason must be present on a Deny audit event");
12461        assert!(
12462            masked_reason.contains("in denied request"),
12463            "non-secret prose must survive masking: {masked_reason:?}"
12464        );
12465        assert!(
12466            !masked_reason.contains("not-a-real-secret"),
12467            "the process log must never carry the raw credential: {masked_reason:?}"
12468        );
12469        assert!(
12470            masked_reason.contains("***MASKED***"),
12471            "the log must record that a credential was redacted: {masked_reason:?}"
12472        );
12473    }
12474
12475    // ---- EventStore audit envelope round-trip ----
12476    //
12477    // EventStore must not persist a summary Event without the full
12478    // AuditEvent fields (deny_reason, gate_impl, obligations). This test
12479    // verifies the complete envelope survives append_event → query_events.
12480
12481    #[tokio::test]
12482    #[serial(config_ledger)]
12483    async fn audit_envelope_round_trips_deny_reason_and_gate_impl_through_event_store() {
12484        #[derive(Debug)]
12485        struct DenyGateWithName;
12486        impl Gate for DenyGateWithName {
12487            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12488                Ok(GateDecision::deny("policy: write forbidden for anon"))
12489            }
12490            fn impl_name(&self) -> &'static str {
12491                "DenyGateWithName"
12492            }
12493        }
12494
12495        let store = Arc::new(MemoryEventStore::default());
12496        let mut builder = VerbRegistryBuilder::new();
12497        builder.register(AlphaPack);
12498        builder.with_gate(Arc::new(DenyGateWithName));
12499        builder.with_event_store(store.clone());
12500        let reg = builder.build().expect("registry builds");
12501
12502        // Dispatch is denied — PermissionDenied returned.
12503        let err = reg
12504            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
12505            .await
12506            .unwrap_err();
12507        assert!(
12508            matches!(err, RuntimeError::PermissionDenied { .. }),
12509            "expected PermissionDenied, got {err:?}"
12510        );
12511
12512        // Exactly one event in the store.
12513        let page = store
12514            .query_events(
12515                EventFilter::default(),
12516                PageRequest {
12517                    limit: 10,
12518                    offset: 0,
12519                },
12520            )
12521            .await
12522            .unwrap();
12523        assert_eq!(
12524            page.items.len(),
12525            1,
12526            "one audit event must be persisted on deny"
12527        );
12528
12529        let ev = &page.items[0];
12530        assert_eq!(ev.outcome, EventOutcome::Denied);
12531
12532        // The payload field must hold the full AuditEvent envelope.
12533        let data = &ev.payload;
12534
12535        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
12536            .expect("Event.payload must deserialize to AuditEvent");
12537
12538        assert_eq!(
12539            audit.deny_reason.as_deref(),
12540            Some("policy: write forbidden for anon"),
12541            "deny_reason must be preserved through EventStore"
12542        );
12543        assert_eq!(
12544            audit.gate_impl, "DenyGateWithName",
12545            "gate_impl must be preserved through EventStore"
12546        );
12547        assert_eq!(
12548            audit.decision,
12549            khive_gate::AuditDecision::Deny,
12550            "decision field must be preserved through EventStore"
12551        );
12552    }
12553
12554    #[tokio::test]
12555    #[serial(config_ledger)]
12556    async fn audit_envelope_round_trips_obligations_through_event_store() {
12557        use khive_gate::Obligation;
12558
12559        #[derive(Debug)]
12560        struct ObligationGate;
12561        impl Gate for ObligationGate {
12562            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12563                Ok(GateDecision::allow_with(vec![Obligation::Audit {
12564                    tag: "billing.meter".into(),
12565                }]))
12566            }
12567            fn impl_name(&self) -> &'static str {
12568                "ObligationGate"
12569            }
12570        }
12571
12572        let store = Arc::new(MemoryEventStore::default());
12573        let mut builder = VerbRegistryBuilder::new();
12574        builder.register(AlphaPack);
12575        builder.with_gate(Arc::new(ObligationGate));
12576        builder.with_event_store(store.clone());
12577        let reg = builder.build().expect("registry builds");
12578
12579        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
12580            .await
12581            .unwrap();
12582
12583        let page = store
12584            .query_events(
12585                EventFilter::default(),
12586                PageRequest {
12587                    limit: 10,
12588                    offset: 0,
12589                },
12590            )
12591            .await
12592            .unwrap();
12593        assert_eq!(page.items.len(), 1);
12594
12595        let ev = &page.items[0];
12596        assert_eq!(ev.outcome, EventOutcome::Success);
12597
12598        let data = &ev.payload;
12599
12600        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
12601            .expect("Event.payload must deserialize to AuditEvent");
12602
12603        assert_eq!(audit.gate_impl, "ObligationGate");
12604        assert_eq!(
12605            audit.obligations.len(),
12606            1,
12607            "obligations must be preserved through EventStore"
12608        );
12609        match &audit.obligations[0] {
12610            Obligation::Audit { tag } => assert_eq!(tag, "billing.meter"),
12611            other => panic!("expected Audit obligation, got {other:?}"),
12612        }
12613    }
12614
12615    // ---- SQL-backed audit envelope round-trip ----
12616    //
12617    // The two tests above use MemoryEventStore (no serialization). This test
12618    // wires the production SqlEventStore via KhiveRuntime::memory() to verify
12619    // that the full AuditEvent envelope survives the SQL text→parse round-trip
12620    // (Event.data is stored as TEXT and parsed back on read).
12621
12622    #[tokio::test]
12623    #[serial(config_ledger)]
12624    async fn sql_backed_audit_envelope_round_trips_deny_reason_gate_impl_and_obligations() {
12625        #[derive(Debug)]
12626        struct SqlTestDenyGate;
12627        impl Gate for SqlTestDenyGate {
12628            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12629                Ok(GateDecision::deny("sql-path: write denied"))
12630            }
12631            fn impl_name(&self) -> &'static str {
12632                "SqlTestDenyGate"
12633            }
12634        }
12635
12636        // KhiveRuntime::memory() creates an in-memory SQLite pool (is_file_backed=false).
12637        // events_for_namespace ensures the events schema and returns a SqlEventStore
12638        // scoped to "test-ns". The pool is shared so reads and writes see the same data.
12639        let rt = KhiveRuntime::memory().expect("in-memory runtime");
12640        let test_tok = NamespaceToken::for_namespace(Namespace::parse("test-ns").unwrap());
12641        let sql_store = rt
12642            .events(&test_tok)
12643            .expect("events_for_namespace must succeed");
12644
12645        let mut builder = VerbRegistryBuilder::new();
12646        builder.register(AlphaPack);
12647        builder.with_gate(Arc::new(SqlTestDenyGate));
12648        builder.with_event_store(sql_store.clone());
12649        let reg = builder.build().expect("registry builds");
12650
12651        // Dispatch is denied — PermissionDenied returned.
12652        let err = reg
12653            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
12654            .await
12655            .unwrap_err();
12656        assert!(
12657            matches!(err, RuntimeError::PermissionDenied { .. }),
12658            "expected PermissionDenied, got {err:?}"
12659        );
12660
12661        // Query via the same SqlEventStore — this is the SQL read path.
12662        let page = sql_store
12663            .query_events(
12664                EventFilter::default(),
12665                PageRequest {
12666                    limit: 10,
12667                    offset: 0,
12668                },
12669            )
12670            .await
12671            .unwrap();
12672        assert_eq!(
12673            page.items.len(),
12674            1,
12675            "one audit event must be persisted on deny through SqlEventStore"
12676        );
12677
12678        let ev = &page.items[0];
12679        assert_eq!(ev.outcome, EventOutcome::Denied);
12680
12681        // Event.payload must hold the full AuditEvent serialized as JSON text and
12682        // parsed back. If the SQL path was lossy, this deserialization would fail
12683        // or the field assertions below would fail.
12684        let data = &ev.payload;
12685
12686        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
12687            .expect("Event.payload must deserialize to AuditEvent after SQL round-trip");
12688
12689        assert_eq!(
12690            audit.deny_reason.as_deref(),
12691            Some("sql-path: write denied"),
12692            "deny_reason must survive the SQL text round-trip"
12693        );
12694        assert_eq!(
12695            audit.gate_impl, "SqlTestDenyGate",
12696            "gate_impl must survive the SQL text round-trip"
12697        );
12698        assert_eq!(
12699            audit.decision,
12700            khive_gate::AuditDecision::Deny,
12701            "decision field must survive the SQL text round-trip"
12702        );
12703        // obligations is [] on a Deny gate (no obligations returned).
12704        // Verify the field is present and empty after SQL round-trip.
12705        assert!(
12706            audit.obligations.is_empty(),
12707            "obligations must be preserved as empty [] through SQL round-trip"
12708        );
12709    }
12710
12711    // ---- SQL-backed audit envelope: non-empty obligations survive round-trip ----
12712    //
12713    // Blind spot: the deny-path SQL test above only
12714    // asserts obligations == [], which passes even if the SQL path drops the
12715    // field entirely (AuditEvent.obligations has #[serde(default)]).
12716    //
12717    // This test installs an allow-path gate that returns a non-empty obligations
12718    // vec. After dispatch, the same SqlEventStore is queried and both layers are
12719    // checked:
12720    //   1. Raw Event.data["obligations"] is a non-empty JSON array.
12721    //   2. Deserialized AuditEvent.obligations[0] matches the expected variant.
12722    #[tokio::test]
12723    #[serial(config_ledger)]
12724    async fn sql_backed_audit_envelope_round_trips_non_empty_obligations() {
12725        use khive_gate::Obligation;
12726
12727        #[derive(Debug)]
12728        struct SqlTestAllowWithObligationGate;
12729        impl Gate for SqlTestAllowWithObligationGate {
12730            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
12731                Ok(GateDecision::allow_with(vec![Obligation::Audit {
12732                    tag: "sql-path-billing.meter".into(),
12733                }]))
12734            }
12735            fn impl_name(&self) -> &'static str {
12736                "SqlTestAllowWithObligationGate"
12737            }
12738        }
12739
12740        let rt = KhiveRuntime::memory().expect("in-memory runtime");
12741        let test_tok = NamespaceToken::for_namespace(Namespace::parse("test-ns").unwrap());
12742        let sql_store = rt
12743            .events(&test_tok)
12744            .expect("events_for_namespace must succeed");
12745
12746        let mut builder = VerbRegistryBuilder::new();
12747        builder.register(AlphaPack);
12748        builder.with_gate(Arc::new(SqlTestAllowWithObligationGate));
12749        builder.with_event_store(sql_store.clone());
12750        let reg = builder.build().expect("registry builds");
12751
12752        // Dispatch succeeds — the gate allows with obligations.
12753        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
12754            .await
12755            .expect("dispatch must succeed when gate allows");
12756
12757        // Query via the same SqlEventStore — this is the SQL read path.
12758        let page = sql_store
12759            .query_events(
12760                EventFilter::default(),
12761                PageRequest {
12762                    limit: 10,
12763                    offset: 0,
12764                },
12765            )
12766            .await
12767            .unwrap();
12768        assert_eq!(
12769            page.items.len(),
12770            1,
12771            "one audit event must be persisted on allow through SqlEventStore"
12772        );
12773
12774        let ev = &page.items[0];
12775        assert_eq!(ev.outcome, EventOutcome::Success);
12776
12777        let data = &ev.payload;
12778
12779        // Layer 1: raw JSON check — obligations must be a non-empty array in
12780        // the persisted TEXT. If the SQL path dropped the field, the default
12781        // #[serde(default)] would silently deserialize it to [], so we verify
12782        // the raw JSON before deserializing.
12783        let obligations_raw = data
12784            .get("obligations")
12785            .expect("Event.data JSON must contain 'obligations' key");
12786        let obligations_arr = obligations_raw
12787            .as_array()
12788            .expect("'obligations' must be a JSON array");
12789        assert!(
12790            !obligations_arr.is_empty(),
12791            "raw Event.data['obligations'] must be non-empty after SQL round-trip"
12792        );
12793
12794        // Layer 2: deserialized AuditEvent check — the obligation variant and
12795        // payload must survive the text round-trip faithfully.
12796        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
12797            .expect("Event.data must deserialize to AuditEvent after SQL round-trip");
12798
12799        assert_eq!(
12800            audit.gate_impl, "SqlTestAllowWithObligationGate",
12801            "gate_impl must survive the SQL text round-trip"
12802        );
12803        assert_eq!(
12804            audit.decision,
12805            khive_gate::AuditDecision::Allow,
12806            "decision field must survive the SQL text round-trip"
12807        );
12808        assert_eq!(
12809            audit.obligations.len(),
12810            1,
12811            "obligations must be non-empty after SQL round-trip (not silently defaulted to [])"
12812        );
12813        match &audit.obligations[0] {
12814            Obligation::Audit { tag } => assert_eq!(
12815                tag, "sql-path-billing.meter",
12816                "Audit obligation tag must survive the SQL text round-trip"
12817            ),
12818            other => panic!("expected Audit obligation, got {other:?}"),
12819        }
12820    }
12821
12822    // ---- Audit payload shape for 'create' verb dispatch ----
12823    //
12824    // The previous audit tests verify the envelope shape for the 'list' verb.
12825    // This test dispatches 'create' (matching the create_note + annotates path)
12826    // and verifies that ev.verb, ev.outcome, and ev.data all round-trip correctly
12827    // through the EventStore. Ensures the wire shape is independent of which verb
12828    // triggers the gate check.
12829    #[tokio::test]
12830    #[serial(config_ledger)]
12831    async fn audit_event_payload_shape_for_create_verb() {
12832        let store = Arc::new(MemoryEventStore::default());
12833        let mut builder = VerbRegistryBuilder::new();
12834        builder.register(AlphaPack);
12835        builder.with_event_store(store.clone());
12836        builder.with_default_namespace("test-ns");
12837        let reg = builder.build().expect("registry builds");
12838
12839        // Dispatch 'create' — AlphaPack returns a stub value; what matters is
12840        // the EventStore entry emitted by the registry's gate-check path.
12841        reg.dispatch("create", serde_json::json!({"namespace": "test-ns"}))
12842            .await
12843            .unwrap();
12844
12845        let count = store.count_events(EventFilter::default()).await.unwrap();
12846        assert_eq!(count, 1, "exactly one audit event for one dispatch");
12847
12848        let page = store
12849            .query_events(
12850                EventFilter::default(),
12851                PageRequest {
12852                    limit: 10,
12853                    offset: 0,
12854                },
12855            )
12856            .await
12857            .unwrap();
12858        let ev = &page.items[0];
12859
12860        // Top-level Event fields.
12861        assert_eq!(ev.verb, "create", "ev.verb must be the dispatched verb");
12862        assert_eq!(
12863            ev.outcome,
12864            EventOutcome::Success,
12865            "ev.outcome must be Success on allow"
12866        );
12867        assert_eq!(
12868            ev.namespace, "test-ns",
12869            "ev.namespace must match the dispatch namespace"
12870        );
12871
12872        // ev.payload must hold the full AuditEvent envelope.
12873        let data = &ev.payload;
12874
12875        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
12876            .expect("ev.payload must deserialize to AuditEvent");
12877
12878        assert_eq!(
12879            audit.decision,
12880            khive_gate::AuditDecision::Allow,
12881            "AuditEvent.decision must be Allow"
12882        );
12883        assert_eq!(audit.verb, "create", "AuditEvent.verb must be 'create'");
12884        assert_eq!(
12885            audit.namespace, "test-ns",
12886            "AuditEvent.namespace must be preserved"
12887        );
12888        assert_eq!(
12889            audit.gate_impl, "AllowAllGate",
12890            "AuditEvent.gate_impl must name the gate implementation"
12891        );
12892        assert!(
12893            audit.deny_reason.is_none(),
12894            "AuditEvent.deny_reason must be None on Allow"
12895        );
12896        // Wire-shape check: obligations serializes as [] on AllowAllGate.
12897        let payload_json: serde_json::Value =
12898            serde_json::from_value(data.clone()).expect("data must be valid JSON");
12899        assert_eq!(
12900            payload_json["obligations"],
12901            serde_json::Value::Array(Vec::new()),
12902            "obligations must be [] on AllowAllGate"
12903        );
12904    }
12905
12906    // ---- ADR-103 Amendment 1: resource.cost_unit emission ----
12907
12908    /// Test pack whose `create` handler is a stub (mirrors `AlphaPack`) but
12909    /// overrides `registered_embedding_model_names` to a configurable set,
12910    /// exercising ADR-103 Amendment 1's `model_count` computation for
12911    /// singleton `create` at the dispatch audit-row emission seam.
12912    struct EmbeddingAwarePack {
12913        models: Vec<String>,
12914    }
12915
12916    impl khive_types::Pack for EmbeddingAwarePack {
12917        const NAME: &'static str = "embedding_aware";
12918        const NOTE_KINDS: &'static [&'static str] = &[];
12919        const ENTITY_KINDS: &'static [&'static str] = &["widget"];
12920        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
12921            name: "create",
12922            description: "create a widget (embedding-aware stub)",
12923            visibility: Visibility::Verb,
12924            category: VerbCategory::Commissive,
12925            params: &[],
12926        }];
12927    }
12928
12929    #[async_trait]
12930    impl PackRuntime for EmbeddingAwarePack {
12931        fn name(&self) -> &str {
12932            Self::NAME
12933        }
12934        fn note_kinds(&self) -> &'static [&'static str] {
12935            Self::NOTE_KINDS
12936        }
12937        fn entity_kinds(&self) -> &'static [&'static str] {
12938            Self::ENTITY_KINDS
12939        }
12940        fn handlers(&self) -> &'static [HandlerDef] {
12941            Self::HANDLERS
12942        }
12943        fn registered_embedding_model_names(&self) -> Vec<String> {
12944            self.models.clone()
12945        }
12946        async fn dispatch(
12947            &self,
12948            verb: &str,
12949            _params: Value,
12950            _registry: &VerbRegistry,
12951            _token: &NamespaceToken,
12952        ) -> Result<Value, RuntimeError> {
12953            Ok(serde_json::json!({ "pack": "embedding_aware", "verb": verb }))
12954        }
12955    }
12956
12957    /// Test pack whose one verb, `probe`, always fails — used to drive the
12958    /// general (non-link) deferred-audit Err arm without a real backend.
12959    struct FailingProbePack;
12960
12961    impl khive_types::Pack for FailingProbePack {
12962        const NAME: &'static str = "failing_probe";
12963        const NOTE_KINDS: &'static [&'static str] = &[];
12964        const ENTITY_KINDS: &'static [&'static str] = &[];
12965        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
12966            name: "probe",
12967            description: "always fails",
12968            visibility: Visibility::Verb,
12969            category: VerbCategory::Assertive,
12970            params: &[],
12971        }];
12972    }
12973
12974    #[async_trait]
12975    impl PackRuntime for FailingProbePack {
12976        fn name(&self) -> &str {
12977            Self::NAME
12978        }
12979        fn note_kinds(&self) -> &'static [&'static str] {
12980            Self::NOTE_KINDS
12981        }
12982        fn entity_kinds(&self) -> &'static [&'static str] {
12983            Self::ENTITY_KINDS
12984        }
12985        fn handlers(&self) -> &'static [HandlerDef] {
12986            Self::HANDLERS
12987        }
12988        async fn dispatch(
12989            &self,
12990            _verb: &str,
12991            _params: Value,
12992            _registry: &VerbRegistry,
12993            _token: &NamespaceToken,
12994        ) -> Result<Value, RuntimeError> {
12995            Err(RuntimeError::InvalidInput("boom".into()))
12996        }
12997    }
12998
12999    #[tokio::test]
13000    #[serial(config_ledger)]
13001    async fn resource_cost_unit_present_on_non_embedding_successful_dispatch() {
13002        let store = Arc::new(MemoryEventStore::default());
13003        let mut builder = VerbRegistryBuilder::new();
13004        builder.register(AlphaPack);
13005        builder.with_event_store(store.clone());
13006        let reg = builder.build().expect("registry builds");
13007
13008        reg.dispatch("list", serde_json::json!({})).await.unwrap();
13009
13010        let page = store
13011            .query_events(
13012                EventFilter::default(),
13013                PageRequest {
13014                    limit: 10,
13015                    offset: 0,
13016                },
13017            )
13018            .await
13019            .unwrap();
13020        assert_eq!(page.items.len(), 1);
13021        assert_eq!(
13022            page.items[0].payload["resource"],
13023            serde_json::json!({"work_class": "interactive", "cost_unit": 1}),
13024            "non-embedding-bearing verb's resource.cost_unit must be base_weight(verb) alone"
13025        );
13026    }
13027
13028    #[tokio::test]
13029    #[serial(config_ledger)]
13030    async fn resource_cost_unit_scales_with_registered_model_count_for_create() {
13031        let store = Arc::new(MemoryEventStore::default());
13032        let mut builder = VerbRegistryBuilder::new();
13033        builder.register(EmbeddingAwarePack {
13034            models: vec!["all-minilm-l6-v2".into(), "paraphrase".into()],
13035        });
13036        builder.with_event_store(store.clone());
13037        let reg = builder.build().expect("registry builds");
13038
13039        reg.dispatch("create", serde_json::json!({"kind": "widget"}))
13040            .await
13041            .unwrap();
13042
13043        let page = store
13044            .query_events(
13045                EventFilter::default(),
13046                PageRequest {
13047                    limit: 10,
13048                    offset: 0,
13049                },
13050            )
13051            .await
13052            .unwrap();
13053        // base_weight(1) + per_item_weight(1) * item_count(1) * model_count(2)
13054        assert_eq!(
13055            page.items[0].payload["resource"],
13056            serde_json::json!({"work_class": "interactive", "cost_unit": 3}),
13057        );
13058    }
13059
13060    #[tokio::test]
13061    #[serial(config_ledger)]
13062    async fn resource_cost_unit_zero_registered_models_is_base_weight_only() {
13063        let store = Arc::new(MemoryEventStore::default());
13064        let mut builder = VerbRegistryBuilder::new();
13065        builder.register(EmbeddingAwarePack { models: vec![] });
13066        builder.with_event_store(store.clone());
13067        let reg = builder.build().expect("registry builds");
13068
13069        reg.dispatch("create", serde_json::json!({"kind": "widget"}))
13070            .await
13071            .unwrap();
13072
13073        let page = store
13074            .query_events(
13075                EventFilter::default(),
13076                PageRequest {
13077                    limit: 10,
13078                    offset: 0,
13079                },
13080            )
13081            .await
13082            .unwrap();
13083        assert_eq!(
13084            page.items[0].payload["resource"]["cost_unit"], 1,
13085            "zero registered embedding models must vanish the term, not error or omit"
13086        );
13087    }
13088
13089    #[tokio::test]
13090    #[serial(config_ledger)]
13091    async fn resource_work_class_present_cost_unit_absent_when_dispatch_returns_error() {
13092        let store = Arc::new(MemoryEventStore::default());
13093        let mut builder = VerbRegistryBuilder::new();
13094        builder.register(FailingProbePack);
13095        builder.with_event_store(store.clone());
13096        let reg = builder.build().expect("registry builds");
13097
13098        let err = reg
13099            .dispatch("probe", serde_json::json!({}))
13100            .await
13101            .unwrap_err();
13102        assert!(matches!(err, RuntimeError::InvalidInput(_)));
13103
13104        let page = store
13105            .query_events(
13106                EventFilter::default(),
13107                PageRequest {
13108                    limit: 10,
13109                    offset: 0,
13110                },
13111            )
13112            .await
13113            .unwrap();
13114        assert_eq!(page.items.len(), 1);
13115        assert_eq!(page.items[0].outcome, EventOutcome::Error);
13116        // ADR-103 Decision (a): work_class is stamped on EVERY event, denial
13117        // and error included -- only Amendment 1's cost_unit field is scoped
13118        // to a successful dispatch. An errored dispatch keeps
13119        // resource.work_class and omits only resource.cost_unit, never 0.
13120        assert_eq!(
13121            page.items[0].payload["resource"],
13122            serde_json::json!({"work_class": "interactive"}),
13123            "resource must carry work_class with cost_unit OMITTED (never 0) on an \
13124             errored dispatch: {:?}",
13125            page.items[0].payload
13126        );
13127    }
13128
13129    #[tokio::test]
13130    #[serial(config_ledger)]
13131    async fn resource_work_class_present_cost_unit_absent_when_no_pack_owns_the_verb() {
13132        let store = Arc::new(MemoryEventStore::default());
13133        let mut builder = VerbRegistryBuilder::new();
13134        builder.register(AlphaPack);
13135        builder.with_event_store(store.clone());
13136        let reg = builder.build().expect("registry builds");
13137
13138        let _ = reg
13139            .dispatch("no_such_verb_resource_test", serde_json::json!({}))
13140            .await;
13141
13142        let page = store
13143            .query_events(
13144                EventFilter::default(),
13145                PageRequest {
13146                    limit: 10,
13147                    offset: 0,
13148                },
13149            )
13150            .await
13151            .unwrap();
13152        assert_eq!(page.items.len(), 1);
13153        assert_eq!(
13154            page.items[0].payload["resource"],
13155            serde_json::json!({"work_class": "interactive"})
13156        );
13157    }
13158
13159    #[tokio::test]
13160    #[serial(config_ledger)]
13161    async fn resource_work_class_present_cost_unit_absent_on_denied_dispatch() {
13162        #[derive(Debug)]
13163        struct AlwaysDenyGate;
13164        impl Gate for AlwaysDenyGate {
13165            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
13166                Ok(GateDecision::deny("test: always deny"))
13167            }
13168        }
13169        let store = Arc::new(MemoryEventStore::default());
13170        let mut builder = VerbRegistryBuilder::new();
13171        builder.register(AlphaPack);
13172        builder.with_gate(Arc::new(AlwaysDenyGate));
13173        builder.with_event_store(store.clone());
13174        let reg = builder.build().expect("registry builds");
13175
13176        let _ = reg.dispatch("list", serde_json::json!({})).await;
13177
13178        let page = store
13179            .query_events(
13180                EventFilter::default(),
13181                PageRequest {
13182                    limit: 10,
13183                    offset: 0,
13184                },
13185            )
13186            .await
13187            .unwrap();
13188        assert_eq!(page.items.len(), 1);
13189        assert_eq!(page.items[0].outcome, EventOutcome::Denied);
13190        assert_eq!(
13191            page.items[0].payload["resource"],
13192            serde_json::json!({"work_class": "interactive"})
13193        );
13194    }
13195
13196    #[tokio::test]
13197    #[serial(config_ledger)]
13198    async fn resource_cost_unit_present_on_link_singleton_success() {
13199        let store = Arc::new(MemoryEventStore::default());
13200        let edge_id = uuid::Uuid::new_v4();
13201        let source_id = uuid::Uuid::new_v4();
13202        let target_id = uuid::Uuid::new_v4();
13203        let edge_json = serde_json::json!({
13204            "id": edge_id,
13205            "namespace": "local",
13206            "source_id": source_id,
13207            "target_id": target_id,
13208            "relation": "depends_on",
13209            "weight": 1.0,
13210        });
13211        let mut builder = VerbRegistryBuilder::new();
13212        builder.register(LinkResultPack::ok(edge_json));
13213        builder.with_event_store(store.clone());
13214        let reg = builder.build().expect("registry builds");
13215
13216        reg.dispatch(
13217            "link",
13218            serde_json::json!({
13219                "source_id": source_id,
13220                "target_id": target_id,
13221                "relation": "depends_on",
13222            }),
13223        )
13224        .await
13225        .unwrap();
13226
13227        let page = store
13228            .query_events(
13229                EventFilter::default(),
13230                PageRequest {
13231                    limit: 10,
13232                    offset: 0,
13233                },
13234            )
13235            .await
13236            .unwrap();
13237        assert_eq!(
13238            page.items[0].payload["resource"],
13239            serde_json::json!({"work_class": "interactive", "cost_unit": 1}),
13240            "link has no embedding-bearing path -> base_weight(link) alone, even on the v2-enriched singleton path"
13241        );
13242    }
13243
13244    #[tokio::test]
13245    #[serial(config_ledger)]
13246    async fn resource_work_class_present_cost_unit_absent_on_link_dispatch_failure() {
13247        let store = Arc::new(MemoryEventStore::default());
13248        let mut builder = VerbRegistryBuilder::new();
13249        builder.register(LinkResultPack::err("target endpoint not found"));
13250        builder.with_event_store(store.clone());
13251        let reg = builder.build().expect("registry builds");
13252
13253        let _ = reg
13254            .dispatch(
13255                "link",
13256                serde_json::json!({
13257                    "source_id": "note:alpha",
13258                    "target_id": "note:missing",
13259                    "relation": "depends_on",
13260                }),
13261            )
13262            .await;
13263
13264        let page = store
13265            .query_events(
13266                EventFilter::default(),
13267                PageRequest {
13268                    limit: 10,
13269                    offset: 0,
13270                },
13271            )
13272            .await
13273            .unwrap();
13274        assert_eq!(page.items.len(), 1);
13275        assert_eq!(
13276            page.items[0].payload["resource"],
13277            serde_json::json!({"work_class": "interactive"})
13278        );
13279    }
13280
13281    // Registry audit event must carry target_id when dispatch params include it.
13282    #[tokio::test]
13283    #[serial(config_ledger)]
13284    async fn audit_event_threads_target_id_from_dispatch_args() {
13285        let store = Arc::new(MemoryEventStore::default());
13286        let target = uuid::Uuid::new_v4();
13287        let mut builder = VerbRegistryBuilder::new();
13288        builder.register(AlphaPack);
13289        builder.with_event_store(store.clone());
13290        builder.with_default_namespace("test-ns");
13291        let reg = builder.build().expect("registry builds");
13292
13293        reg.dispatch(
13294            "create",
13295            serde_json::json!({"namespace": "test-ns", "target_id": target}),
13296        )
13297        .await
13298        .unwrap();
13299
13300        let page = store
13301            .query_events(
13302                EventFilter::default(),
13303                PageRequest {
13304                    offset: 0,
13305                    limit: 10,
13306                },
13307            )
13308            .await
13309            .unwrap();
13310        assert_eq!(
13311            page.items[0].target_id,
13312            Some(target),
13313            "#282: audit event must carry target_id from dispatch params"
13314        );
13315    }
13316
13317    // ---- Link-verb audit enrichment ----
13318
13319    /// Test pack exposing a single `link` verb whose one-shot result is
13320    /// configured up front — lets tests drive both the success and failure
13321    /// legs of the deferred link-audit path without a real KG backend.
13322    struct LinkResultPack {
13323        result: std::sync::Mutex<Option<Result<Value, RuntimeError>>>,
13324    }
13325
13326    impl LinkResultPack {
13327        fn ok(value: Value) -> Self {
13328            Self {
13329                result: std::sync::Mutex::new(Some(Ok(value))),
13330            }
13331        }
13332        fn err(message: &str) -> Self {
13333            Self {
13334                result: std::sync::Mutex::new(Some(Err(RuntimeError::InvalidInput(
13335                    message.to_string(),
13336                )))),
13337            }
13338        }
13339    }
13340
13341    impl khive_types::Pack for LinkResultPack {
13342        const NAME: &'static str = "kg";
13343        const NOTE_KINDS: &'static [&'static str] = &[];
13344        const ENTITY_KINDS: &'static [&'static str] = &[];
13345        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
13346            name: "link",
13347            description: "test link handler",
13348            visibility: Visibility::Verb,
13349            category: VerbCategory::Commissive,
13350            params: &[],
13351        }];
13352    }
13353
13354    #[async_trait]
13355    impl PackRuntime for LinkResultPack {
13356        fn name(&self) -> &str {
13357            Self::NAME
13358        }
13359        fn note_kinds(&self) -> &'static [&'static str] {
13360            Self::NOTE_KINDS
13361        }
13362        fn entity_kinds(&self) -> &'static [&'static str] {
13363            Self::ENTITY_KINDS
13364        }
13365        fn handlers(&self) -> &'static [HandlerDef] {
13366            Self::HANDLERS
13367        }
13368        async fn dispatch(
13369            &self,
13370            _verb: &str,
13371            _params: Value,
13372            _registry: &VerbRegistry,
13373            _token: &NamespaceToken,
13374        ) -> Result<Value, RuntimeError> {
13375            self.result
13376                .lock()
13377                .unwrap()
13378                .take()
13379                .expect("LinkResultPack dispatch called more than once in a test")
13380        }
13381    }
13382
13383    #[tokio::test]
13384    #[serial(config_ledger)]
13385    async fn link_audit_enriches_successful_singleton_with_edge_v2() {
13386        let store = Arc::new(MemoryEventStore::default());
13387        let edge_id = uuid::Uuid::new_v4();
13388        let source_id = uuid::Uuid::new_v4();
13389        let target_id = uuid::Uuid::new_v4();
13390        let edge_json = serde_json::json!({
13391            "id": edge_id,
13392            "namespace": "local",
13393            "source_id": source_id,
13394            "target_id": target_id,
13395            "relation": "depends_on",
13396            "weight": 1.0,
13397        });
13398        let mut builder = VerbRegistryBuilder::new();
13399        builder.register(LinkResultPack::ok(edge_json));
13400        builder.with_event_store(store.clone());
13401        builder.with_default_namespace("test-ns");
13402        let reg = builder.build().expect("registry builds");
13403
13404        reg.dispatch(
13405            "link",
13406            serde_json::json!({
13407                "source_id": source_id,
13408                "target_id": target_id,
13409                "relation": "depends_on",
13410            }),
13411        )
13412        .await
13413        .unwrap();
13414
13415        let count = store.count_events(EventFilter::default()).await.unwrap();
13416        assert_eq!(
13417            count, 1,
13418            "exactly one deferred audit row must be persisted for a successful singleton link"
13419        );
13420        let page = store
13421            .query_events(
13422                EventFilter::default(),
13423                PageRequest {
13424                    limit: 10,
13425                    offset: 0,
13426                },
13427            )
13428            .await
13429            .unwrap();
13430        let ev = &page.items[0];
13431        assert_eq!(ev.verb, "link");
13432        assert_eq!(ev.outcome, EventOutcome::Success);
13433        assert_eq!(
13434            ev.payload_schema_version, 2,
13435            "successful singleton link uses audit schema v2"
13436        );
13437        assert_eq!(
13438            ev.target_id,
13439            Some(edge_id),
13440            "target_id must be the created/resolved edge id, not a raw caller arg"
13441        );
13442        assert_eq!(ev.payload["edge_id"], serde_json::json!(edge_id));
13443        assert_eq!(ev.payload["source_id"], serde_json::json!(source_id));
13444        assert_eq!(ev.payload["target_id"], serde_json::json!(target_id));
13445        assert_eq!(ev.payload["relation"], "depends_on");
13446        assert_eq!(ev.payload["weight"], 1.0);
13447        // v1 AuditEvent fields remain present via #[serde(flatten)].
13448        assert_eq!(ev.payload["verb"], "link");
13449        assert_eq!(ev.payload["decision"], "allow");
13450        assert!(ev.payload.get("gate_impl").is_some());
13451    }
13452
13453    #[tokio::test]
13454    #[serial(config_ledger)]
13455    async fn link_audit_falls_back_to_v1_when_dispatch_fails() {
13456        let store = Arc::new(MemoryEventStore::default());
13457        let mut builder = VerbRegistryBuilder::new();
13458        builder.register(LinkResultPack::err("target endpoint not found"));
13459        builder.with_event_store(store.clone());
13460        builder.with_default_namespace("test-ns");
13461        let reg = builder.build().expect("registry builds");
13462
13463        let err = reg
13464            .dispatch(
13465                "link",
13466                serde_json::json!({
13467                    "source_id": "note:alpha",
13468                    "target_id": "note:missing",
13469                    "relation": "depends_on",
13470                }),
13471            )
13472            .await
13473            .unwrap_err();
13474        assert!(
13475            matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("not found")),
13476            "the original dispatch error must be returned unchanged"
13477        );
13478
13479        let page = store
13480            .query_events(
13481                EventFilter::default(),
13482                PageRequest {
13483                    limit: 10,
13484                    offset: 0,
13485                },
13486            )
13487            .await
13488            .unwrap();
13489        assert_eq!(
13490            page.items.len(),
13491            1,
13492            "a v1 fallback audit row must still be persisted on dispatch failure"
13493        );
13494        let ev = &page.items[0];
13495        assert_eq!(
13496            ev.payload_schema_version, 1,
13497            "failed link keeps the v1 audit shape"
13498        );
13499        // The persisted outcome must reflect the dispatch result (Err →
13500        // Error), not be hardcoded to Success from the gate's Allow decision.
13501        assert_eq!(
13502            ev.outcome,
13503            EventOutcome::Error,
13504            "outcome reflects the dispatch result (Err), not the gate decision (Allow)"
13505        );
13506        assert!(
13507            ev.duration_us >= 0,
13508            "duration_us must still be populated (measured, not the Event::new \
13509             default sentinel) on a failed dispatch"
13510        );
13511        assert!(
13512            ev.target_id.is_none(),
13513            "non-UUID caller-supplied ids do not spuriously populate target_id"
13514        );
13515        assert!(
13516            ev.payload.get("edge_id").is_none(),
13517            "v1 fallback must not carry edge enrichment fields"
13518        );
13519        let _: khive_gate::AuditEvent = serde_json::from_value(ev.payload.clone())
13520            .expect("v1 fallback payload must deserialize as AuditEvent");
13521    }
13522
13523    #[tokio::test]
13524    #[serial(config_ledger)]
13525    async fn link_audit_falls_back_to_v1_when_result_missing_edge_fields() {
13526        let store = Arc::new(MemoryEventStore::default());
13527        let target_arg = uuid::Uuid::new_v4();
13528        let mut builder = VerbRegistryBuilder::new();
13529        builder.register(LinkResultPack::ok(serde_json::json!({"ok": true})));
13530        builder.with_event_store(store.clone());
13531        builder.with_default_namespace("test-ns");
13532        let reg = builder.build().expect("registry builds");
13533
13534        reg.dispatch(
13535            "link",
13536            serde_json::json!({
13537                "source_id": uuid::Uuid::new_v4(),
13538                "target_id": target_arg,
13539                "relation": "depends_on",
13540            }),
13541        )
13542        .await
13543        .unwrap();
13544
13545        let page = store
13546            .query_events(
13547                EventFilter::default(),
13548                PageRequest {
13549                    limit: 10,
13550                    offset: 0,
13551                },
13552            )
13553            .await
13554            .unwrap();
13555        assert_eq!(page.items.len(), 1);
13556        let ev = &page.items[0];
13557        assert_eq!(
13558            ev.payload_schema_version, 1,
13559            "an unparsable success result falls back to v1 rather than dropping the audit row"
13560        );
13561        assert_eq!(ev.outcome, EventOutcome::Success);
13562        assert_eq!(
13563            ev.target_id,
13564            Some(target_arg),
13565            "v1 fallback still extracts target_id from the raw dispatch args"
13566        );
13567        assert!(ev.payload.get("edge_id").is_none());
13568    }
13569
13570    #[tokio::test]
13571    #[serial(config_ledger)]
13572    async fn link_audit_bulk_links_get_no_enrichment() {
13573        let store = Arc::new(MemoryEventStore::default());
13574        let mut builder = VerbRegistryBuilder::new();
13575        builder.register(LinkResultPack::ok(serde_json::json!({
13576            "attempted": 2, "created": 2, "skipped": 0, "failed": 0
13577        })));
13578        builder.with_event_store(store.clone());
13579        builder.with_default_namespace("test-ns");
13580        let reg = builder.build().expect("registry builds");
13581
13582        reg.dispatch(
13583            "link",
13584            serde_json::json!({
13585                "links": [
13586                    {"source_id": "a", "target_id": "b", "relation": "depends_on"},
13587                    {"source_id": "c", "target_id": "d", "relation": "depends_on"},
13588                ],
13589            }),
13590        )
13591        .await
13592        .unwrap();
13593
13594        let count = store.count_events(EventFilter::default()).await.unwrap();
13595        assert_eq!(
13596            count, 1,
13597            "bulk `links` gets exactly one v1 audit row (deferred until dispatch \
13598             resolves like every other Allow-outcome row since ADR-103 Stage 1, \
13599             but never v2-enriched — enrichment is singleton-`link`-only)"
13600        );
13601        let page = store
13602            .query_events(
13603                EventFilter::default(),
13604                PageRequest {
13605                    limit: 10,
13606                    offset: 0,
13607                },
13608            )
13609            .await
13610            .unwrap();
13611        let ev = &page.items[0];
13612        assert_eq!(
13613            ev.payload_schema_version, 1,
13614            "bulk link mode is out of scope for #676's events.target_id enrichment"
13615        );
13616        assert!(ev.target_id.is_none());
13617    }
13618
13619    #[test]
13620    fn link_audit_success_from_result_extracts_edge_fields() {
13621        let gate_req = GateRequest::new(
13622            ActorRef::anonymous(),
13623            Namespace::local(),
13624            "link",
13625            serde_json::json!({}),
13626        );
13627        let decision = GateDecision::Allow {
13628            obligations: vec![],
13629        };
13630        let audit = AuditEvent::from_check(&gate_req, &decision, "AllowAllGate");
13631
13632        let edge_id = uuid::Uuid::new_v4();
13633        let source_id = uuid::Uuid::new_v4();
13634        let target_id = uuid::Uuid::new_v4();
13635        let result = serde_json::json!({
13636            "id": edge_id,
13637            "source_id": source_id,
13638            "target_id": target_id,
13639            "relation": "depends_on",
13640            "weight": 0.5,
13641        });
13642
13643        let (returned_id, payload) = link_audit_success_from_result(audit, &result)
13644            .expect("well-formed edge JSON must produce an enriched payload");
13645        assert_eq!(returned_id, edge_id);
13646        assert_eq!(payload["edge_id"], serde_json::json!(edge_id));
13647        assert_eq!(payload["relation"], "depends_on");
13648        assert_eq!(payload["weight"], 0.5);
13649        assert_eq!(
13650            payload["verb"], "link",
13651            "v1 AuditEvent fields must flatten into the v2 payload"
13652        );
13653    }
13654
13655    #[test]
13656    fn link_audit_success_from_result_rejects_incomplete_or_malformed_result() {
13657        let gate_req = GateRequest::new(
13658            ActorRef::anonymous(),
13659            Namespace::local(),
13660            "link",
13661            serde_json::json!({}),
13662        );
13663        let decision = GateDecision::Allow {
13664            obligations: vec![],
13665        };
13666        let audit = AuditEvent::from_check(&gate_req, &decision, "AllowAllGate");
13667
13668        assert!(
13669            link_audit_success_from_result(
13670                audit.clone(),
13671                &serde_json::json!({"id": uuid::Uuid::new_v4()}),
13672            )
13673            .is_none(),
13674            "missing source_id/target_id/relation/weight must not enrich"
13675        );
13676        assert!(
13677            link_audit_success_from_result(audit, &serde_json::json!({"id": "not-a-uuid"}))
13678                .is_none(),
13679            "a non-UUID id must not enrich"
13680        );
13681    }
13682
13683    // ---- khive#948: request_id survives to the persisted audit event ----
13684    //
13685    // The pure `resource_payload`/`base_resource_payload` helpers are unit
13686    // tested in `cost_unit.rs`; these tests prove the id actually reaches
13687    // `resource.request_id` on a persisted `Event` through every one of
13688    // `dispatch_with_identity`'s four audit-append sites (denied, ordinary
13689    // success/error, singleton-link v2 success and its v1 fallback, and the
13690    // unknown-verb error path), plus the "no id supplied" omission case.
13691
13692    async fn first_event(store: &Arc<MemoryEventStore>) -> Event {
13693        let page = store
13694            .query_events(
13695                EventFilter::default(),
13696                PageRequest {
13697                    limit: 10,
13698                    offset: 0,
13699                },
13700            )
13701            .await
13702            .unwrap();
13703        assert_eq!(
13704            page.items.len(),
13705            1,
13706            "expected exactly one persisted audit event"
13707        );
13708        page.items[0].clone()
13709    }
13710
13711    /// A fresh `MemoryEventStore` for a test that will assert `first_event`'s
13712    /// exact one-event count, with the process-wide config ledger drained
13713    /// first.
13714    ///
13715    /// `first_event` itself runs *after* dispatch, so it cannot fix this: a
13716    /// `config_ledger`-grouped test that panics after queueing a row (a
13717    /// direct `record_config_locked` call, or a `OnceLock` reader it
13718    /// invoked) but before its own event-backed dispatch never drains that
13719    /// row itself, leaving it queued for whichever test the serial lock
13720    /// hands off to next. If that next test builds its store with plain
13721    /// `Arc::new(MemoryEventStore::default())`, its own dispatch call
13722    /// drains the inherited row as an extra `ConfigLocked` event alongside
13723    /// the one it expects, and `first_event`'s exact `page.items.len() ==
13724    /// 1` assertion sees two. The fix has to run before dispatch, so it
13725    /// lives in the store constructor every exact-one-event test calls, not
13726    /// in the post-dispatch helper that reads the count.
13727    fn clean_ledger_event_store() -> Arc<MemoryEventStore> {
13728        let _ = crate::config_ledger::drain_config_locked();
13729        Arc::new(MemoryEventStore::default())
13730    }
13731
13732    /// Regression: a preceding
13733    /// `config_ledger`-grouped test that queues a row (directly, or via a
13734    /// `OnceLock` reader it invoked) and then panics before its own
13735    /// event-backed dispatch drains it leaves that row queued for whichever
13736    /// test the serial lock hands to next. Simulate exactly that leaked row
13737    /// here and prove `clean_ledger_event_store` — not `first_event` itself,
13738    /// which only runs after dispatch and so cannot fix a pre-dispatch race
13739    /// — is what keeps `first_event`'s exact one-event assertion honest.
13740    #[tokio::test]
13741    #[serial(config_ledger)]
13742    async fn first_event_is_immune_to_a_ledger_row_a_prior_test_never_drained() {
13743        let _ = crate::config_ledger::drain_config_locked();
13744        crate::config_ledger::record_config_locked("simulated_leaked_key", "leaked_value");
13745
13746        let store = clean_ledger_event_store();
13747        let mut builder = VerbRegistryBuilder::new();
13748        builder.register(AlphaPack);
13749        builder.with_event_store(store.clone());
13750        let reg = builder.build().expect("registry builds");
13751
13752        reg.dispatch_with_identity(
13753            "list",
13754            serde_json::json!({"namespace": "test-ns"}),
13755            Some(RequestIdentity {
13756                request_id: Some(9_001),
13757                ..Default::default()
13758            }),
13759        )
13760        .await
13761        .unwrap();
13762
13763        let ev = first_event(&store).await;
13764        assert_eq!(ev.outcome, EventOutcome::Success);
13765    }
13766
13767    #[tokio::test]
13768    #[serial(config_ledger)]
13769    async fn dispatch_with_identity_stamps_request_id_on_success() {
13770        let store = clean_ledger_event_store();
13771        let mut builder = VerbRegistryBuilder::new();
13772        builder.register(AlphaPack);
13773        builder.with_event_store(store.clone());
13774        let reg = builder.build().expect("registry builds");
13775
13776        reg.dispatch_with_identity(
13777            "list",
13778            serde_json::json!({"namespace": "test-ns"}),
13779            Some(RequestIdentity {
13780                request_id: Some(101),
13781                ..Default::default()
13782            }),
13783        )
13784        .await
13785        .unwrap();
13786
13787        let ev = first_event(&store).await;
13788        assert_eq!(ev.outcome, EventOutcome::Success);
13789        assert_eq!(ev.payload["resource"]["request_id"], serde_json::json!(101));
13790    }
13791
13792    #[tokio::test]
13793    #[serial(config_ledger)]
13794    async fn dispatch_with_identity_stamps_request_id_on_dispatch_error() {
13795        let store = clean_ledger_event_store();
13796        let mut builder = VerbRegistryBuilder::new();
13797        builder.register(FailingProbePack);
13798        builder.with_event_store(store.clone());
13799        let reg = builder.build().expect("registry builds");
13800
13801        let err = reg
13802            .dispatch_with_identity(
13803                "probe",
13804                serde_json::json!({"namespace": "test-ns"}),
13805                Some(RequestIdentity {
13806                    request_id: Some(102),
13807                    ..Default::default()
13808                }),
13809            )
13810            .await
13811            .unwrap_err();
13812        assert!(matches!(err, RuntimeError::InvalidInput(_)));
13813
13814        let ev = first_event(&store).await;
13815        assert_eq!(ev.outcome, EventOutcome::Error);
13816        assert_eq!(ev.payload["resource"]["request_id"], serde_json::json!(102));
13817    }
13818
13819    #[tokio::test]
13820    #[serial(config_ledger)]
13821    async fn dispatch_with_identity_stamps_request_id_on_denied() {
13822        #[derive(Debug)]
13823        struct AlwaysDenyGate;
13824        impl Gate for AlwaysDenyGate {
13825            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
13826                Ok(GateDecision::deny("denied by test"))
13827            }
13828        }
13829
13830        let store = clean_ledger_event_store();
13831        let mut builder = VerbRegistryBuilder::new();
13832        builder.register(AlphaPack);
13833        builder.with_gate(Arc::new(AlwaysDenyGate));
13834        builder.with_event_store(store.clone());
13835        let reg = builder.build().expect("registry builds");
13836
13837        let err = reg
13838            .dispatch_with_identity(
13839                "list",
13840                serde_json::json!({"namespace": "test-ns"}),
13841                Some(RequestIdentity {
13842                    request_id: Some(103),
13843                    ..Default::default()
13844                }),
13845            )
13846            .await
13847            .unwrap_err();
13848        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
13849
13850        let ev = first_event(&store).await;
13851        assert_eq!(ev.payload["resource"]["request_id"], serde_json::json!(103));
13852    }
13853
13854    #[tokio::test]
13855    #[serial(config_ledger)]
13856    async fn dispatch_with_identity_stamps_request_id_on_link_v2_success() {
13857        let store = clean_ledger_event_store();
13858        let edge_id = uuid::Uuid::new_v4();
13859        let source_id = uuid::Uuid::new_v4();
13860        let target_id = uuid::Uuid::new_v4();
13861        let edge_json = serde_json::json!({
13862            "id": edge_id,
13863            "namespace": "local",
13864            "source_id": source_id,
13865            "target_id": target_id,
13866            "relation": "depends_on",
13867            "weight": 1.0,
13868        });
13869        let mut builder = VerbRegistryBuilder::new();
13870        builder.register(LinkResultPack::ok(edge_json));
13871        builder.with_event_store(store.clone());
13872        builder.with_default_namespace("test-ns");
13873        let reg = builder.build().expect("registry builds");
13874
13875        reg.dispatch_with_identity(
13876            "link",
13877            serde_json::json!({
13878                "source_id": source_id,
13879                "target_id": target_id,
13880                "relation": "depends_on",
13881            }),
13882            Some(RequestIdentity {
13883                namespace: "test-ns".to_string(),
13884                request_id: Some(104),
13885                ..Default::default()
13886            }),
13887        )
13888        .await
13889        .unwrap();
13890
13891        let ev = first_event(&store).await;
13892        assert_eq!(
13893            ev.payload_schema_version, 2,
13894            "successful singleton link uses audit schema v2"
13895        );
13896        assert_eq!(ev.payload["resource"]["request_id"], serde_json::json!(104));
13897    }
13898
13899    #[tokio::test]
13900    #[serial(config_ledger)]
13901    async fn dispatch_with_identity_stamps_request_id_on_link_v1_fallback() {
13902        let store = clean_ledger_event_store();
13903        let mut builder = VerbRegistryBuilder::new();
13904        builder.register(LinkResultPack::err("target endpoint not found"));
13905        builder.with_event_store(store.clone());
13906        builder.with_default_namespace("test-ns");
13907        let reg = builder.build().expect("registry builds");
13908
13909        let err = reg
13910            .dispatch_with_identity(
13911                "link",
13912                serde_json::json!({
13913                    "source_id": "note:alpha",
13914                    "target_id": "note:missing",
13915                    "relation": "depends_on",
13916                }),
13917                Some(RequestIdentity {
13918                    namespace: "test-ns".to_string(),
13919                    request_id: Some(105),
13920                    ..Default::default()
13921                }),
13922            )
13923            .await
13924            .unwrap_err();
13925        assert!(matches!(err, RuntimeError::InvalidInput(_)));
13926
13927        let ev = first_event(&store).await;
13928        assert_eq!(
13929            ev.payload_schema_version, 1,
13930            "failed link keeps the v1 audit shape"
13931        );
13932        assert_eq!(ev.payload["resource"]["request_id"], serde_json::json!(105));
13933    }
13934
13935    #[tokio::test]
13936    #[serial(config_ledger)]
13937    async fn dispatch_with_identity_stamps_request_id_on_unknown_verb() {
13938        let store = clean_ledger_event_store();
13939        let mut builder = VerbRegistryBuilder::new();
13940        builder.register(AlphaPack);
13941        builder.with_event_store(store.clone());
13942        let reg = builder.build().expect("registry builds");
13943
13944        let err = reg
13945            .dispatch_with_identity(
13946                "no_such_verb",
13947                serde_json::json!({}),
13948                Some(RequestIdentity {
13949                    namespace: Namespace::local().as_str().to_string(),
13950                    request_id: Some(106),
13951                    ..Default::default()
13952                }),
13953            )
13954            .await
13955            .unwrap_err();
13956        assert!(matches!(err, RuntimeError::UnknownVerb(_)));
13957
13958        let ev = first_event(&store).await;
13959        assert_eq!(ev.outcome, EventOutcome::Error);
13960        assert_eq!(ev.payload["resource"]["request_id"], serde_json::json!(106));
13961    }
13962
13963    #[tokio::test]
13964    #[serial(config_ledger)]
13965    async fn dispatch_with_identity_omits_request_id_key_when_absent() {
13966        let store = clean_ledger_event_store();
13967        let mut builder = VerbRegistryBuilder::new();
13968        builder.register(AlphaPack);
13969        builder.with_event_store(store.clone());
13970        let reg = builder.build().expect("registry builds");
13971
13972        // No identity at all — the pre-#948 call shape.
13973        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
13974            .await
13975            .unwrap();
13976
13977        let ev = first_event(&store).await;
13978        let resource = ev.payload["resource"]
13979            .as_object()
13980            .expect("resource must be an object");
13981        assert!(
13982            !resource.contains_key("request_id"),
13983            "request_id key must be entirely absent when no id is supplied, \
13984             not present as null or 0: got {resource:?}"
13985        );
13986    }
13987}
13988
13989// ---- Inter-pack dependency checking ----
13990
13991#[cfg(test)]
13992mod dep_tests {
13993    use super::*;
13994    use async_trait::async_trait;
13995    use khive_types::Pack;
13996    use serde_json::Value;
13997
13998    struct KgDepPack;
13999    struct MemoryDepPack;
14000    struct ADepPack;
14001    struct BDepPack;
14002
14003    impl Pack for KgDepPack {
14004        const NAME: &'static str = "kg_dep";
14005        const NOTE_KINDS: &'static [&'static str] = &["observation"];
14006        const ENTITY_KINDS: &'static [&'static str] = &["concept"];
14007        const HANDLERS: &'static [HandlerDef] = &[];
14008    }
14009
14010    impl Pack for MemoryDepPack {
14011        const NAME: &'static str = "memory_dep";
14012        const NOTE_KINDS: &'static [&'static str] = &["memory"];
14013        const ENTITY_KINDS: &'static [&'static str] = &[];
14014        const HANDLERS: &'static [HandlerDef] = &[];
14015        const REQUIRES: &'static [&'static str] = &["kg_dep"];
14016    }
14017
14018    impl Pack for ADepPack {
14019        const NAME: &'static str = "pack_a";
14020        const NOTE_KINDS: &'static [&'static str] = &[];
14021        const ENTITY_KINDS: &'static [&'static str] = &[];
14022        const HANDLERS: &'static [HandlerDef] = &[];
14023        const REQUIRES: &'static [&'static str] = &["pack_b"];
14024    }
14025
14026    impl Pack for BDepPack {
14027        const NAME: &'static str = "pack_b";
14028        const NOTE_KINDS: &'static [&'static str] = &[];
14029        const ENTITY_KINDS: &'static [&'static str] = &[];
14030        const HANDLERS: &'static [HandlerDef] = &[];
14031        const REQUIRES: &'static [&'static str] = &["pack_a"];
14032    }
14033
14034    #[async_trait]
14035    impl PackRuntime for KgDepPack {
14036        fn name(&self) -> &str {
14037            Self::NAME
14038        }
14039        fn note_kinds(&self) -> &'static [&'static str] {
14040            Self::NOTE_KINDS
14041        }
14042        fn entity_kinds(&self) -> &'static [&'static str] {
14043            Self::ENTITY_KINDS
14044        }
14045        fn handlers(&self) -> &'static [HandlerDef] {
14046            Self::HANDLERS
14047        }
14048        async fn dispatch(
14049            &self,
14050            verb: &str,
14051            _: Value,
14052            _: &VerbRegistry,
14053            _: &NamespaceToken,
14054        ) -> Result<Value, RuntimeError> {
14055            Err(RuntimeError::InvalidInput(format!(
14056                "KgDepPack has no verbs: {verb}"
14057            )))
14058        }
14059    }
14060
14061    #[async_trait]
14062    impl PackRuntime for MemoryDepPack {
14063        fn name(&self) -> &str {
14064            Self::NAME
14065        }
14066        fn note_kinds(&self) -> &'static [&'static str] {
14067            Self::NOTE_KINDS
14068        }
14069        fn entity_kinds(&self) -> &'static [&'static str] {
14070            Self::ENTITY_KINDS
14071        }
14072        fn handlers(&self) -> &'static [HandlerDef] {
14073            Self::HANDLERS
14074        }
14075        fn requires(&self) -> &'static [&'static str] {
14076            Self::REQUIRES
14077        }
14078        async fn dispatch(
14079            &self,
14080            verb: &str,
14081            _: Value,
14082            _: &VerbRegistry,
14083            _: &NamespaceToken,
14084        ) -> Result<Value, RuntimeError> {
14085            Err(RuntimeError::InvalidInput(format!(
14086                "MemoryDepPack has no verbs: {verb}"
14087            )))
14088        }
14089    }
14090
14091    #[async_trait]
14092    impl PackRuntime for ADepPack {
14093        fn name(&self) -> &str {
14094            Self::NAME
14095        }
14096        fn note_kinds(&self) -> &'static [&'static str] {
14097            Self::NOTE_KINDS
14098        }
14099        fn entity_kinds(&self) -> &'static [&'static str] {
14100            Self::ENTITY_KINDS
14101        }
14102        fn handlers(&self) -> &'static [HandlerDef] {
14103            Self::HANDLERS
14104        }
14105        fn requires(&self) -> &'static [&'static str] {
14106            Self::REQUIRES
14107        }
14108        async fn dispatch(
14109            &self,
14110            verb: &str,
14111            _: Value,
14112            _: &VerbRegistry,
14113            _: &NamespaceToken,
14114        ) -> Result<Value, RuntimeError> {
14115            Err(RuntimeError::InvalidInput(format!(
14116                "ADepPack has no verbs: {verb}"
14117            )))
14118        }
14119    }
14120
14121    #[async_trait]
14122    impl PackRuntime for BDepPack {
14123        fn name(&self) -> &str {
14124            Self::NAME
14125        }
14126        fn note_kinds(&self) -> &'static [&'static str] {
14127            Self::NOTE_KINDS
14128        }
14129        fn entity_kinds(&self) -> &'static [&'static str] {
14130            Self::ENTITY_KINDS
14131        }
14132        fn handlers(&self) -> &'static [HandlerDef] {
14133            Self::HANDLERS
14134        }
14135        fn requires(&self) -> &'static [&'static str] {
14136            Self::REQUIRES
14137        }
14138        async fn dispatch(
14139            &self,
14140            verb: &str,
14141            _: Value,
14142            _: &VerbRegistry,
14143            _: &NamespaceToken,
14144        ) -> Result<Value, RuntimeError> {
14145            Err(RuntimeError::InvalidInput(format!(
14146                "BDepPack has no verbs: {verb}"
14147            )))
14148        }
14149    }
14150
14151    #[test]
14152    fn test_pack_deps_happy_path() {
14153        let mut builder = VerbRegistryBuilder::new();
14154        builder.register(MemoryDepPack);
14155        builder.register(KgDepPack);
14156        let reg = builder
14157            .build()
14158            .expect("kg_dep satisfies memory_dep dependency");
14159        assert_eq!(reg.pack_requires("memory_dep").unwrap(), &["kg_dep"]);
14160        let names = reg.pack_names();
14161        let kg_pos = names.iter().position(|&n| n == "kg_dep").unwrap();
14162        let mem_pos = names.iter().position(|&n| n == "memory_dep").unwrap();
14163        assert!(
14164            kg_pos < mem_pos,
14165            "kg_dep must be loaded before memory_dep; order: {names:?}"
14166        );
14167    }
14168
14169    #[test]
14170    fn test_pack_deps_missing() {
14171        let mut builder = VerbRegistryBuilder::new();
14172        builder.register(MemoryDepPack);
14173        let err = match builder.build() {
14174            Ok(_) => panic!("expected Err, got Ok"),
14175            Err(e) => e,
14176        };
14177        assert!(
14178            matches!(err, RuntimeError::MissingPackDependency(_)),
14179            "expected MissingPackDependency, got {err:?}"
14180        );
14181        let msg = err.to_string();
14182        assert!(
14183            msg.contains("memory_dep"),
14184            "error must name the dependent pack: {msg}"
14185        );
14186        assert!(
14187            msg.contains("kg_dep"),
14188            "error must name the missing dep: {msg}"
14189        );
14190    }
14191
14192    #[test]
14193    fn test_pack_deps_circular() {
14194        let mut builder = VerbRegistryBuilder::new();
14195        builder.register(ADepPack);
14196        builder.register(BDepPack);
14197        let err = match builder.build() {
14198            Ok(_) => panic!("expected Err, got Ok"),
14199            Err(e) => e,
14200        };
14201        assert!(
14202            matches!(err, RuntimeError::CircularPackDependency(_)),
14203            "expected CircularPackDependency, got {err:?}"
14204        );
14205        let msg = err.to_string();
14206        assert!(msg.contains("pack_a"), "error must name pack_a: {msg}");
14207        assert!(msg.contains("pack_b"), "error must name pack_b: {msg}");
14208    }
14209
14210    #[test]
14211    fn test_pack_deps_no_deps() {
14212        struct NoDepsA;
14213        struct NoDepsB;
14214
14215        impl Pack for NoDepsA {
14216            const NAME: &'static str = "no_deps_a";
14217            const NOTE_KINDS: &'static [&'static str] = &[];
14218            const ENTITY_KINDS: &'static [&'static str] = &[];
14219            const HANDLERS: &'static [HandlerDef] = &[];
14220        }
14221
14222        impl Pack for NoDepsB {
14223            const NAME: &'static str = "no_deps_b";
14224            const NOTE_KINDS: &'static [&'static str] = &[];
14225            const ENTITY_KINDS: &'static [&'static str] = &[];
14226            const HANDLERS: &'static [HandlerDef] = &[];
14227        }
14228
14229        #[async_trait]
14230        impl PackRuntime for NoDepsA {
14231            fn name(&self) -> &str {
14232                Self::NAME
14233            }
14234            fn note_kinds(&self) -> &'static [&'static str] {
14235                Self::NOTE_KINDS
14236            }
14237            fn entity_kinds(&self) -> &'static [&'static str] {
14238                Self::ENTITY_KINDS
14239            }
14240            fn handlers(&self) -> &'static [HandlerDef] {
14241                Self::HANDLERS
14242            }
14243            async fn dispatch(
14244                &self,
14245                verb: &str,
14246                _: Value,
14247                _: &VerbRegistry,
14248                _: &NamespaceToken,
14249            ) -> Result<Value, RuntimeError> {
14250                Err(RuntimeError::InvalidInput(format!("NoDepsA: {verb}")))
14251            }
14252        }
14253
14254        #[async_trait]
14255        impl PackRuntime for NoDepsB {
14256            fn name(&self) -> &str {
14257                Self::NAME
14258            }
14259            fn note_kinds(&self) -> &'static [&'static str] {
14260                Self::NOTE_KINDS
14261            }
14262            fn entity_kinds(&self) -> &'static [&'static str] {
14263                Self::ENTITY_KINDS
14264            }
14265            fn handlers(&self) -> &'static [HandlerDef] {
14266                Self::HANDLERS
14267            }
14268            async fn dispatch(
14269                &self,
14270                verb: &str,
14271                _: Value,
14272                _: &VerbRegistry,
14273                _: &NamespaceToken,
14274            ) -> Result<Value, RuntimeError> {
14275                Err(RuntimeError::InvalidInput(format!("NoDepsB: {verb}")))
14276            }
14277        }
14278
14279        let mut builder = VerbRegistryBuilder::new();
14280        builder.register(NoDepsA);
14281        builder.register(NoDepsB);
14282        let reg = builder.build().expect("packs with REQUIRES=&[] build");
14283        assert_eq!(reg.pack_requires("no_deps_a").unwrap(), &[] as &[&str]);
14284        assert_eq!(reg.pack_requires("no_deps_b").unwrap(), &[] as &[&str]);
14285    }
14286}
14287
14288// ── Note-update hook sequencing tests ───────────────────────────
14289//
14290// These tests exercise the DISPATCHER (`VerbRegistry::prepare_note_update_hook`),
14291// not any one pack's hook. The probe below overrides `normalize_note_update`
14292// and `validate_note_update`, which since #2956 are the only two halves a pack
14293// can implement — there is no sequencing method on the trait — so the only way
14294// both can run, in order, is through the registry's own sequencing.
14295
14296#[cfg(test)]
14297mod note_update_sequencing_tests {
14298    use super::*;
14299    use khive_types::Pack;
14300    use std::sync::atomic::{AtomicUsize, Ordering};
14301    use std::sync::Mutex as StdMutex;
14302
14303    /// A probe hook shaped like a real kind-owning pack: `normalize_note_update`
14304    /// moves a caller-supplied top-level field into `properties`, and
14305    /// `validate_note_update` refuses based on what it finds there. The value
14306    /// `validate_note_update` inspects does not exist in `properties` until
14307    /// `normalize_note_update` puts it there, so a passing refusal assertion
14308    /// proves both the ordering and that normalize's mutation reached validate.
14309    #[derive(Debug, Default)]
14310    struct SequencerProbeHook {
14311        normalize_calls: AtomicUsize,
14312        validate_calls: AtomicUsize,
14313        validate_saw_marker: StdMutex<Option<bool>>,
14314        effects_calls: AtomicUsize,
14315        effects: StdMutex<Vec<NoteUpdateEffect>>,
14316    }
14317
14318    #[async_trait]
14319    impl KindHook for SequencerProbeHook {
14320        async fn prepare_create(
14321            &self,
14322            _runtime: &KhiveRuntime,
14323            _args: &mut Value,
14324        ) -> Result<(), RuntimeError> {
14325            Ok(())
14326        }
14327
14328        async fn after_create(
14329            &self,
14330            _runtime: &KhiveRuntime,
14331            _id: uuid::Uuid,
14332            _args: &Value,
14333        ) -> Result<(), RuntimeError> {
14334            Ok(())
14335        }
14336
14337        async fn normalize_note_update(
14338            &self,
14339            _runtime: &KhiveRuntime,
14340            _token: &NamespaceToken,
14341            _note: &khive_storage::Note,
14342            args: &mut Value,
14343        ) -> Result<(), RuntimeError> {
14344            self.normalize_calls.fetch_add(1, Ordering::SeqCst);
14345            let Some(raw) = args.get("raw_marker").and_then(Value::as_bool) else {
14346                return Ok(());
14347            };
14348            let root = args.as_object_mut().expect("probe test args are an object");
14349            root.remove("raw_marker");
14350            let mut properties = serde_json::Map::new();
14351            properties.insert("marker".into(), Value::Bool(raw));
14352            root.insert("properties".into(), Value::Object(properties));
14353            Ok(())
14354        }
14355
14356        async fn validate_note_update(
14357            &self,
14358            _runtime: &KhiveRuntime,
14359            _token: &NamespaceToken,
14360            _note: &khive_storage::Note,
14361            properties: Option<&Value>,
14362        ) -> Result<(), RuntimeError> {
14363            self.validate_calls.fetch_add(1, Ordering::SeqCst);
14364            let marker = properties
14365                .and_then(|value| value.get("marker"))
14366                .and_then(Value::as_bool);
14367            *self.validate_saw_marker.lock().unwrap() = marker;
14368            if marker == Some(true) {
14369                return Err(RuntimeError::InvalidInput(
14370                    "probe validator refuses marker=true".into(),
14371                ));
14372            }
14373            Ok(())
14374        }
14375        async fn note_update_effects(
14376            &self,
14377            _runtime: &KhiveRuntime,
14378            _token: &NamespaceToken,
14379            _note: &khive_storage::Note,
14380            _patch: &crate::NotePatch,
14381        ) -> Result<Vec<NoteUpdateEffect>, RuntimeError> {
14382            self.effects_calls.fetch_add(1, Ordering::SeqCst);
14383            Ok(self.effects.lock().unwrap().clone())
14384        }
14385    }
14386
14387    struct ProbePack(Arc<SequencerProbeHook>);
14388
14389    impl Pack for ProbePack {
14390        const NAME: &'static str = "sequencer-probe";
14391        const NOTE_KINDS: &'static [&'static str] = &["probe-note"];
14392        const ENTITY_KINDS: &'static [&'static str] = &[];
14393        const HANDLERS: &'static [HandlerDef] = &[];
14394    }
14395
14396    #[async_trait]
14397    impl PackRuntime for ProbePack {
14398        fn name(&self) -> &str {
14399            Self::NAME
14400        }
14401        fn note_kinds(&self) -> &'static [&'static str] {
14402            Self::NOTE_KINDS
14403        }
14404        fn entity_kinds(&self) -> &'static [&'static str] {
14405            Self::ENTITY_KINDS
14406        }
14407        fn handlers(&self) -> &'static [HandlerDef] {
14408            Self::HANDLERS
14409        }
14410        fn kind_hook(&self, kind: &str) -> Option<Arc<dyn KindHook>> {
14411            (kind == "probe-note").then(|| self.0.clone() as Arc<dyn KindHook>)
14412        }
14413        async fn dispatch(
14414            &self,
14415            verb: &str,
14416            _params: Value,
14417            _registry: &VerbRegistry,
14418            _token: &NamespaceToken,
14419        ) -> Result<Value, RuntimeError> {
14420            Err(RuntimeError::InvalidInput(format!(
14421                "ProbePack has no verbs: {verb}"
14422            )))
14423        }
14424    }
14425
14426    /// The arm the issue reported: a normalizer that moves a caller field into
14427    /// `properties` and a validator that refuses it must both run by the time
14428    /// `prepare_note_update_hook` returns its error.
14429    ///
14430    /// This arm was confirmed to be load-bearing by a mutation run before the
14431    /// change landed, recorded here as a result rather than as a procedure. With
14432    /// the sequencing reduced to a single `validate_note_update` call — the
14433    /// pre-fix shape, and what a caller that skips the normalizer reproduces —
14434    /// `normalize_note_update` did not run, `marker` did not reach `properties`,
14435    /// `validate_note_update` observed `None` where it expects `Some(true)`, and
14436    /// the call returned `Ok` instead of the expected `Err`, reddening the first
14437    /// assertion below.
14438    #[tokio::test]
14439    async fn prepare_note_update_hook_runs_normalize_before_validate_and_validate_can_refuse() {
14440        let runtime = KhiveRuntime::memory().expect("memory runtime");
14441        let token = runtime
14442            .authorize(Namespace::local())
14443            .expect("authorize local namespace");
14444        let hook = Arc::new(SequencerProbeHook::default());
14445        let mut builder = VerbRegistryBuilder::new();
14446        builder.register(ProbePack(hook.clone()));
14447        let registry = builder.build().expect("registry builds");
14448
14449        let note = khive_storage::Note::new("local", "probe-note", "body");
14450        let mut args = serde_json::json!({"raw_marker": true});
14451
14452        let result = registry
14453            .prepare_note_update_hook(&runtime, &token, &note, &mut args)
14454            .await;
14455
14456        let error = result.expect_err("the refusal must fire");
14457        assert!(
14458            error.to_string().contains("marker=true"),
14459            "the error must be the probe validator's own refusal: {error}"
14460        );
14461        assert_eq!(
14462            args["properties"]["marker"],
14463            serde_json::json!(true),
14464            "normalization must land in args even on the arm that ends in refusal"
14465        );
14466        assert_eq!(
14467            hook.normalize_calls.load(Ordering::SeqCst),
14468            1,
14469            "normalize must run even though validate goes on to refuse"
14470        );
14471        assert_eq!(
14472            hook.validate_calls.load(Ordering::SeqCst),
14473            1,
14474            "validate must run exactly once"
14475        );
14476        assert_eq!(
14477            *hook.validate_saw_marker.lock().unwrap(),
14478            Some(true),
14479            "validate must see the property normalize just wrote, not the caller's raw field"
14480        );
14481    }
14482
14483    /// Mirror of the arm above: the same probe, with input that makes the
14484    /// validator accept. `prepare_note_update_hook` must still return `Ok`
14485    /// AND normalization must still have landed in `args` — an accepting
14486    /// validator is not a reason to have skipped normalization.
14487    ///
14488    /// Under the same mutation described on the arm above,
14489    /// `args["properties"]["marker"]` was left unset — `properties` did not
14490    /// exist at all — reddening the first assertion below.
14491    #[tokio::test]
14492    async fn prepare_note_update_hook_runs_normalize_before_validate_and_validate_can_accept() {
14493        let runtime = KhiveRuntime::memory().expect("memory runtime");
14494        let token = runtime
14495            .authorize(Namespace::local())
14496            .expect("authorize local namespace");
14497        let hook = Arc::new(SequencerProbeHook::default());
14498        let mut builder = VerbRegistryBuilder::new();
14499        builder.register(ProbePack(hook.clone()));
14500        let registry = builder.build().expect("registry builds");
14501
14502        let note = khive_storage::Note::new("local", "probe-note", "body");
14503        let mut args = serde_json::json!({"raw_marker": false});
14504
14505        registry
14506            .prepare_note_update_hook(&runtime, &token, &note, &mut args)
14507            .await
14508            .expect("an accepting validator must not refuse");
14509
14510        assert_eq!(
14511            args["properties"]["marker"],
14512            serde_json::json!(false),
14513            "normalization must land in args even when validation accepts"
14514        );
14515        assert_eq!(hook.normalize_calls.load(Ordering::SeqCst), 1);
14516        assert_eq!(hook.validate_calls.load(Ordering::SeqCst), 1);
14517        assert_eq!(*hook.validate_saw_marker.lock().unwrap(), Some(false));
14518    }
14519    async fn effects_fixture() -> (
14520        KhiveRuntime,
14521        NamespaceToken,
14522        VerbRegistry,
14523        Arc<SequencerProbeHook>,
14524        khive_storage::Note,
14525    ) {
14526        let runtime = KhiveRuntime::memory().unwrap();
14527        let token = runtime.authorize(Namespace::local()).unwrap();
14528        let hook = Arc::new(SequencerProbeHook::default());
14529        let mut builder = VerbRegistryBuilder::new();
14530        builder.register(ProbePack(hook.clone()));
14531        let registry = builder.build().unwrap();
14532        let note = khive_storage::Note::new("local", "probe-note", "before");
14533        let id = note.id;
14534        runtime
14535            .notes(&token)
14536            .unwrap()
14537            .upsert_note(note)
14538            .await
14539            .unwrap();
14540        let note = runtime
14541            .notes(&token)
14542            .unwrap()
14543            .get_note(id)
14544            .await
14545            .unwrap()
14546            .unwrap();
14547        (runtime, token, registry, hook, note)
14548    }
14549
14550    #[tokio::test]
14551    async fn note_update_effects_are_not_computed_for_invalid_or_known_stale_patches() {
14552        use crate::atomic_prepare::prepare_update_from_note_snapshot;
14553        use serde_json::json;
14554        let (runtime, token, registry, hook, snapshot) = effects_fixture().await;
14555        let mut invalid = json!({"id": snapshot.id, "raw_marker": true});
14556        registry
14557            .prepare_note_update_hook(&runtime, &token, &snapshot, &mut invalid)
14558            .await
14559            .expect_err("kind validator refuses");
14560        assert_eq!(hook.effects_calls.load(Ordering::SeqCst), 0);
14561        for extra in [
14562            json!({"salience": 2.0}),
14563            json!({"expected_version": snapshot.version + 1}),
14564        ] {
14565            let mut args = json!({"id": snapshot.id, "raw_marker": false});
14566            args.as_object_mut()
14567                .unwrap()
14568                .extend(extra.as_object().unwrap().clone());
14569            let policy = registry
14570                .prepare_note_update_policy(&runtime, &token, &snapshot, &mut args)
14571                .await
14572                .unwrap();
14573            prepare_update_from_note_snapshot(
14574                &runtime,
14575                &token,
14576                &args,
14577                None,
14578                snapshot.clone(),
14579                policy,
14580                &registry,
14581            )
14582            .await
14583            .expect_err("invalid/stale update must be refused before effects");
14584            assert_eq!(hook.effects_calls.load(Ordering::SeqCst), 0);
14585            assert_eq!(
14586                runtime
14587                    .notes(&token)
14588                    .unwrap()
14589                    .get_note(snapshot.id)
14590                    .await
14591                    .unwrap()
14592                    .unwrap(),
14593                snapshot
14594            );
14595        }
14596        runtime
14597            .update_note(
14598                &token,
14599                snapshot.id,
14600                crate::NotePatch::new(None, Some("winner".into()), None, None, None),
14601            )
14602            .await
14603            .unwrap();
14604        let current = runtime
14605            .notes(&token)
14606            .unwrap()
14607            .get_note(snapshot.id)
14608            .await
14609            .unwrap()
14610            .unwrap();
14611        let mut args = json!({"id": snapshot.id, "raw_marker": false});
14612        let policy = registry
14613            .prepare_note_update_policy(&runtime, &token, &snapshot, &mut args)
14614            .await
14615            .unwrap();
14616        prepare_update_from_note_snapshot(
14617            &runtime,
14618            &token,
14619            &args,
14620            None,
14621            snapshot.clone(),
14622            policy,
14623            &registry,
14624        )
14625        .await
14626        .expect_err("known stale snapshot");
14627        assert_eq!(hook.effects_calls.load(Ordering::SeqCst), 0);
14628        assert_eq!(
14629            runtime
14630                .notes(&token)
14631                .unwrap()
14632                .get_note(snapshot.id)
14633                .await
14634                .unwrap()
14635                .unwrap(),
14636            current
14637        );
14638    }
14639
14640    #[tokio::test]
14641    async fn note_update_effects_missing_target_must_fail_without_note_changes() {
14642        use serde_json::json;
14643        let (runtime, token, registry, hook, snapshot) = effects_fixture().await;
14644        *hook.effects.lock().unwrap() = vec![NoteUpdateEffect::Link(LinkSpec {
14645            namespace: None,
14646            source_id: snapshot.id,
14647            target_id: uuid::Uuid::new_v4(),
14648            relation: khive_storage::EdgeRelation::Annotates,
14649            weight: 1.0,
14650            metadata: None,
14651            resurrect: false,
14652        })];
14653        let mut args = json!({"id": snapshot.id, "raw_marker": false});
14654        let policy = registry
14655            .prepare_note_update_policy(&runtime, &token, &snapshot, &mut args)
14656            .await
14657            .unwrap();
14658        let error = runtime
14659            .update_note_from_snapshot_with_kind_effects(
14660                &token,
14661                snapshot.clone(),
14662                &args,
14663                policy,
14664                &registry,
14665            )
14666            .await
14667            .expect_err("missing link target must not become a successful note update");
14668        assert!(matches!(error, RuntimeError::NotFound(_)), "{error}");
14669        assert_eq!(hook.effects_calls.load(Ordering::SeqCst), 1);
14670        assert_eq!(
14671            runtime
14672                .notes(&token)
14673                .unwrap()
14674                .get_note(snapshot.id)
14675                .await
14676                .unwrap()
14677                .unwrap(),
14678            snapshot
14679        );
14680    }
14681
14682    #[tokio::test]
14683    async fn note_update_effects_do_not_replace_a_live_annotation_that_appeared_during_prepare() {
14684        use khive_storage::EdgeRelation;
14685        use serde_json::json;
14686        let (runtime, token, registry, hook, snapshot) = effects_fixture().await;
14687        let target = runtime
14688            .create_entity(&token, "concept", None, "new target", None, None, vec![])
14689            .await
14690            .unwrap();
14691        runtime
14692            .link(
14693                &token,
14694                snapshot.id,
14695                target.id,
14696                EdgeRelation::Annotates,
14697                0.3,
14698                Some(json!({"keep": true})),
14699            )
14700            .await
14701            .unwrap();
14702        let before = runtime
14703            .get_edge_by_natural_key_including_deleted(
14704                &token,
14705                "local",
14706                snapshot.id,
14707                target.id,
14708                EdgeRelation::Annotates,
14709            )
14710            .await
14711            .unwrap()
14712            .unwrap();
14713        // Models an owner's absent-edge observation followed by another writer
14714        // creating the natural key before the runtime prepares the Link request.
14715        *hook.effects.lock().unwrap() = vec![NoteUpdateEffect::Link(LinkSpec {
14716            namespace: None,
14717            source_id: snapshot.id,
14718            target_id: target.id,
14719            relation: EdgeRelation::Annotates,
14720            weight: 1.0,
14721            metadata: None,
14722            resurrect: true,
14723        })];
14724        let mut args = json!({"id": snapshot.id, "raw_marker": false});
14725        let policy = registry
14726            .prepare_note_update_policy(&runtime, &token, &snapshot, &mut args)
14727            .await
14728            .unwrap();
14729        let error = runtime
14730            .update_note_from_snapshot_with_kind_effects(
14731                &token,
14732                snapshot.clone(),
14733                &args,
14734                policy,
14735                &registry,
14736            )
14737            .await
14738            .expect_err("typed create must not replace an intervening live edge");
14739        assert!(error.to_string().contains("live edge appeared"), "{error}");
14740        assert_eq!(
14741            runtime
14742                .notes(&token)
14743                .unwrap()
14744                .get_note(snapshot.id)
14745                .await
14746                .unwrap()
14747                .unwrap(),
14748            snapshot
14749        );
14750        let after = runtime
14751            .get_edge_by_natural_key_including_deleted(
14752                &token,
14753                "local",
14754                snapshot.id,
14755                target.id,
14756                EdgeRelation::Annotates,
14757            )
14758            .await
14759            .unwrap()
14760            .unwrap();
14761        assert_eq!(
14762            serde_json::to_value(after).unwrap(),
14763            serde_json::to_value(before).unwrap()
14764        );
14765    }
14766
14767    #[tokio::test]
14768    async fn note_update_effects_commit_guards_roll_back_note_and_prior_edge_effects() {
14769        use crate::atomic_prepare::prepare_update_from_note_snapshot;
14770        use crate::{run_atomic_unit, AtomicRunOutcome, EdgeListFilter};
14771        use khive_storage::EdgeRelation;
14772        use serde_json::json;
14773        for stale_note in [false, true] {
14774            let (runtime, token, registry, hook, snapshot) = effects_fixture().await;
14775            let a = runtime
14776                .create_entity(&token, "concept", None, "A", None, None, vec![])
14777                .await
14778                .unwrap();
14779            let b = runtime
14780                .create_entity(&token, "concept", None, "B", None, None, vec![])
14781                .await
14782                .unwrap();
14783            let a_id = a.id;
14784            let b_id = b.id;
14785            runtime
14786                .link(
14787                    &token,
14788                    snapshot.id,
14789                    a_id,
14790                    EdgeRelation::Annotates,
14791                    1.0,
14792                    None,
14793                )
14794                .await
14795                .unwrap();
14796            let old = runtime
14797                .get_edge_by_natural_key_including_deleted(
14798                    &token,
14799                    "local",
14800                    snapshot.id,
14801                    a_id,
14802                    EdgeRelation::Annotates,
14803                )
14804                .await
14805                .unwrap()
14806                .unwrap();
14807            *hook.effects.lock().unwrap() = vec![
14808                NoteUpdateEffect::DeleteEdge(old.clone()),
14809                NoteUpdateEffect::Link(LinkSpec {
14810                    namespace: None,
14811                    source_id: snapshot.id,
14812                    target_id: b_id,
14813                    relation: EdgeRelation::Annotates,
14814                    weight: 1.0,
14815                    metadata: None,
14816                    resurrect: false,
14817                }),
14818            ];
14819            let mut args = json!({"id": snapshot.id, "raw_marker": false});
14820            let policy = registry
14821                .prepare_note_update_policy(&runtime, &token, &snapshot, &mut args)
14822                .await
14823                .unwrap();
14824            let (_, plan) = prepare_update_from_note_snapshot(
14825                &runtime,
14826                &token,
14827                &args,
14828                None,
14829                snapshot.clone(),
14830                policy,
14831                &registry,
14832            )
14833            .await
14834            .unwrap();
14835            let expected = if stale_note {
14836                runtime
14837                    .update_note(
14838                        &token,
14839                        snapshot.id,
14840                        crate::NotePatch::new(
14841                            None,
14842                            Some("concurrent winner".into()),
14843                            None,
14844                            None,
14845                            None,
14846                        ),
14847                    )
14848                    .await
14849                    .unwrap()
14850            } else {
14851                runtime.delete_entity(&token, b_id, false).await.unwrap();
14852                snapshot.clone()
14853            };
14854            let outcome = run_atomic_unit(runtime.sql().as_ref(), vec![plan])
14855                .await
14856                .unwrap();
14857            assert!(
14858                matches!(
14859                    outcome,
14860                    AtomicRunOutcome::RolledBack {
14861                        failed_op_index: 0,
14862                        ..
14863                    }
14864                ),
14865                "missing endpoint / stale note MUST refuse: {outcome:?}"
14866            );
14867            assert_eq!(
14868                runtime
14869                    .notes(&token)
14870                    .unwrap()
14871                    .get_note(snapshot.id)
14872                    .await
14873                    .unwrap()
14874                    .unwrap(),
14875                expected
14876            );
14877            let edges = runtime
14878                .list_edges(
14879                    &token,
14880                    EdgeListFilter {
14881                        source_id: Some(snapshot.id),
14882                        ..Default::default()
14883                    },
14884                    10,
14885                    0,
14886                )
14887                .await
14888                .unwrap();
14889            assert_eq!(serde_json::to_value(edges).unwrap(), json!([old]));
14890            assert!(runtime
14891                .get_edge_by_natural_key_including_deleted(
14892                    &token,
14893                    "local",
14894                    snapshot.id,
14895                    b_id,
14896                    EdgeRelation::Annotates,
14897                )
14898                .await
14899                .unwrap()
14900                .is_none());
14901        }
14902    }
14903}
14904
14905// ── Dispatch hook tests ─────────────────────────────────────────
14906
14907#[cfg(test)]
14908mod hook_tests {
14909    use super::*;
14910    use async_trait::async_trait;
14911    use khive_types::Pack;
14912    use std::sync::atomic::{AtomicUsize, Ordering};
14913    use std::sync::Mutex as StdMutex;
14914
14915    struct SimplePack;
14916
14917    impl Pack for SimplePack {
14918        const NAME: &'static str = "simple";
14919        const NOTE_KINDS: &'static [&'static str] = &[];
14920        const ENTITY_KINDS: &'static [&'static str] = &[];
14921        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
14922            name: "ping",
14923            description: "ping",
14924            visibility: Visibility::Verb,
14925            category: VerbCategory::Assertive,
14926            params: &[],
14927        }];
14928    }
14929
14930    #[async_trait]
14931    impl PackRuntime for SimplePack {
14932        fn name(&self) -> &str {
14933            SimplePack::NAME
14934        }
14935        fn note_kinds(&self) -> &'static [&'static str] {
14936            SimplePack::NOTE_KINDS
14937        }
14938        fn entity_kinds(&self) -> &'static [&'static str] {
14939            SimplePack::ENTITY_KINDS
14940        }
14941        fn handlers(&self) -> &'static [HandlerDef] {
14942            SimplePack::HANDLERS
14943        }
14944        async fn dispatch(
14945            &self,
14946            verb: &str,
14947            _params: Value,
14948            _registry: &VerbRegistry,
14949            _token: &NamespaceToken,
14950        ) -> Result<Value, RuntimeError> {
14951            Ok(serde_json::json!({ "verb": verb }))
14952        }
14953    }
14954
14955    struct RecallPack;
14956
14957    impl Pack for RecallPack {
14958        const NAME: &'static str = "memory";
14959        const NOTE_KINDS: &'static [&'static str] = &[];
14960        const ENTITY_KINDS: &'static [&'static str] = &[];
14961        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
14962            name: "memory.recall",
14963            description: "test recall",
14964            visibility: Visibility::Verb,
14965            category: VerbCategory::Assertive,
14966            params: &[],
14967        }];
14968    }
14969
14970    #[async_trait]
14971    impl PackRuntime for RecallPack {
14972        fn name(&self) -> &str {
14973            RecallPack::NAME
14974        }
14975        fn note_kinds(&self) -> &'static [&'static str] {
14976            RecallPack::NOTE_KINDS
14977        }
14978        fn entity_kinds(&self) -> &'static [&'static str] {
14979            RecallPack::ENTITY_KINDS
14980        }
14981        fn handlers(&self) -> &'static [HandlerDef] {
14982            RecallPack::HANDLERS
14983        }
14984        async fn dispatch(
14985            &self,
14986            _verb: &str,
14987            params: Value,
14988            _registry: &VerbRegistry,
14989            _token: &NamespaceToken,
14990        ) -> Result<Value, RuntimeError> {
14991            let hit = serde_json::json!({
14992                "id": uuid::Uuid::new_v4().to_string(),
14993                "served_by_profile_id": "custom-recall-v1",
14994                "serve_attribution": "profile",
14995            });
14996            if params.get("verbose").and_then(Value::as_bool) == Some(true) {
14997                Ok(serde_json::json!({"results": [hit]}))
14998            } else {
14999                Ok(serde_json::json!([hit]))
15000            }
15001        }
15002    }
15003
15004    #[derive(Default)]
15005    struct EventCapturingHook {
15006        event: StdMutex<Option<Event>>,
15007    }
15008
15009    #[async_trait]
15010    impl DispatchHook for EventCapturingHook {
15011        async fn on_dispatch(&self, view: &EventView) {
15012            *self.event.lock().unwrap() = Some(view.event.clone());
15013        }
15014    }
15015
15016    /// Hook that counts calls and records the last verb seen.
15017    #[derive(Default)]
15018    struct CountingHook {
15019        calls: AtomicUsize,
15020        last_verb: StdMutex<String>,
15021    }
15022
15023    #[async_trait]
15024    impl DispatchHook for CountingHook {
15025        async fn on_dispatch(&self, view: &EventView) {
15026            self.calls.fetch_add(1, Ordering::SeqCst);
15027            *self.last_verb.lock().unwrap() = view.event.verb.clone();
15028        }
15029    }
15030
15031    #[tokio::test]
15032    async fn dispatch_hook_fires_on_successful_dispatch() {
15033        let hook = Arc::new(CountingHook::default());
15034        let mut builder = VerbRegistryBuilder::new();
15035        builder.register(SimplePack);
15036        builder.with_dispatch_hook(hook.clone());
15037        let reg = builder.build().expect("registry builds");
15038
15039        reg.dispatch("ping", Value::Null).await.unwrap();
15040
15041        assert_eq!(
15042            hook.calls.load(Ordering::SeqCst),
15043            1,
15044            "hook must fire once per successful dispatch"
15045        );
15046        assert_eq!(
15047            hook.last_verb.lock().unwrap().as_str(),
15048            "ping",
15049            "hook event must carry the dispatched verb"
15050        );
15051    }
15052
15053    #[tokio::test]
15054    async fn dispatch_hook_fires_multiple_times() {
15055        let hook = Arc::new(CountingHook::default());
15056        let mut builder = VerbRegistryBuilder::new();
15057        builder.register(SimplePack);
15058        builder.with_dispatch_hook(hook.clone());
15059        let reg = builder.build().expect("registry builds");
15060
15061        reg.dispatch("ping", Value::Null).await.unwrap();
15062        reg.dispatch("ping", Value::Null).await.unwrap();
15063        reg.dispatch("ping", Value::Null).await.unwrap();
15064
15065        assert_eq!(
15066            hook.calls.load(Ordering::SeqCst),
15067            3,
15068            "hook must fire once per successful dispatch"
15069        );
15070    }
15071
15072    #[tokio::test]
15073    async fn recall_hook_copies_serve_attribution_from_bare_and_verbose_results() {
15074        let hook = Arc::new(EventCapturingHook::default());
15075        let mut builder = VerbRegistryBuilder::new();
15076        builder.register(RecallPack);
15077        builder.with_dispatch_hook(hook.clone());
15078        let reg = builder.build().expect("registry builds");
15079
15080        for params in [serde_json::json!({}), serde_json::json!({"verbose": true})] {
15081            reg.dispatch("memory.recall", params)
15082                .await
15083                .expect("recall dispatch");
15084            let event = hook.event.lock().unwrap().clone().expect("hook event");
15085            assert!(
15086                event.target_id.is_some(),
15087                "first recall id must become target"
15088            );
15089            assert_eq!(
15090                event.payload["served_by_profile_id"],
15091                serde_json::json!("custom-recall-v1")
15092            );
15093            assert_eq!(
15094                event.payload["serve_attribution"],
15095                serde_json::json!("profile")
15096            );
15097        }
15098    }
15099
15100    #[tokio::test]
15101    async fn dispatch_hook_does_not_fire_on_unknown_verb() {
15102        let hook = Arc::new(CountingHook::default());
15103        let mut builder = VerbRegistryBuilder::new();
15104        builder.register(SimplePack);
15105        builder.with_dispatch_hook(hook.clone());
15106        let reg = builder.build().expect("registry builds");
15107
15108        let _ = reg.dispatch("nonexistent", Value::Null).await;
15109
15110        assert_eq!(
15111            hook.calls.load(Ordering::SeqCst),
15112            0,
15113            "hook must NOT fire for unknown verb (dispatch returns error)"
15114        );
15115    }
15116
15117    #[tokio::test]
15118    async fn dispatch_hook_does_not_fire_on_gate_deny() {
15119        use khive_gate::{Gate, GateDecision, GateError};
15120
15121        #[derive(Debug)]
15122        struct AlwaysDenyGate;
15123        impl Gate for AlwaysDenyGate {
15124            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
15125                Ok(GateDecision::deny("test deny"))
15126            }
15127        }
15128
15129        let hook = Arc::new(CountingHook::default());
15130        let mut builder = VerbRegistryBuilder::new();
15131        builder.register(SimplePack);
15132        builder.with_gate(Arc::new(AlwaysDenyGate));
15133        builder.with_dispatch_hook(hook.clone());
15134        let reg = builder.build().expect("registry builds");
15135
15136        let err = reg.dispatch("ping", Value::Null).await.unwrap_err();
15137        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
15138
15139        assert_eq!(
15140            hook.calls.load(Ordering::SeqCst),
15141            0,
15142            "hook must NOT fire when gate denies dispatch"
15143        );
15144    }
15145
15146    #[tokio::test]
15147    async fn dispatch_hook_event_carries_namespace_from_params() {
15148        let hook = Arc::new(CountingHook::default());
15149
15150        #[derive(Default)]
15151        struct NsCapturingHook {
15152            ns: StdMutex<String>,
15153        }
15154
15155        #[async_trait]
15156        impl DispatchHook for NsCapturingHook {
15157            async fn on_dispatch(&self, view: &EventView) {
15158                *self.ns.lock().unwrap() = view.event.namespace.clone();
15159            }
15160        }
15161
15162        let ns_hook = Arc::new(NsCapturingHook::default());
15163        let mut builder = VerbRegistryBuilder::new();
15164        builder.register(SimplePack);
15165        builder.with_dispatch_hook(ns_hook.clone());
15166        let reg = builder.build().expect("registry builds");
15167
15168        reg.dispatch("ping", serde_json::json!({"namespace": "tenant-abc"}))
15169            .await
15170            .unwrap();
15171
15172        assert_eq!(
15173            ns_hook.ns.lock().unwrap().as_str(),
15174            "tenant-abc",
15175            "dispatch hook event must carry the resolved namespace"
15176        );
15177
15178        // Suppress unused-variable warning from the outer hook.
15179        drop(hook);
15180    }
15181
15182    #[tokio::test]
15183    async fn no_dispatch_hook_configured_dispatch_succeeds() {
15184        // Regression: registries without a hook must still work.
15185        let mut builder = VerbRegistryBuilder::new();
15186        builder.register(SimplePack);
15187        // No with_dispatch_hook call.
15188        let reg = builder.build().expect("registry builds");
15189
15190        let res = reg.dispatch("ping", Value::Null).await.unwrap();
15191        assert_eq!(res["verb"], "ping");
15192    }
15193}
15194
15195// ── help=true tests ──────────────────────────────────────────────
15196
15197#[cfg(test)]
15198mod help_tests {
15199    use super::*;
15200    use async_trait::async_trait;
15201    use khive_types::Pack;
15202    use std::sync::{
15203        atomic::{AtomicUsize, Ordering},
15204        Arc,
15205    };
15206
15207    // ── HelpPack: a minimal pack with one handler that records invocation count.
15208    //
15209    // Used to verify that help=true never reaches the pack's dispatch method.
15210
15211    static CREATE_PARAMS: [ParamDef; 2] = [
15212        ParamDef {
15213            name: "kind",
15214            param_type: "string",
15215            required: true,
15216            description: "Granular kind (concept | document | ...).",
15217            resolution_mode: IdResolutionMode::NotApplicable,
15218        },
15219        ParamDef {
15220            name: "name",
15221            param_type: "string",
15222            required: false,
15223            description: "Human-readable name.",
15224            resolution_mode: IdResolutionMode::NotApplicable,
15225        },
15226    ];
15227
15228    static RECALL_PARAMS: [ParamDef; 2] = [
15229        ParamDef {
15230            name: "query",
15231            param_type: "string",
15232            required: true,
15233            description: "Semantic recall query.",
15234            resolution_mode: IdResolutionMode::NotApplicable,
15235        },
15236        ParamDef {
15237            name: "limit",
15238            param_type: "integer",
15239            required: false,
15240            description: "Maximum memories to return.",
15241            resolution_mode: IdResolutionMode::NotApplicable,
15242        },
15243    ];
15244
15245    // A subhandler with no params — mirrors recall.embed / brain.emit / etc.
15246    // Used to test that help=true on a Subhandler returns callable_via_mcp: false.
15247    static EMBED_PARAMS: [ParamDef; 0] = [];
15248
15249    // A uuid-typed param declaring IdResolutionMode::UnscopedById, used to
15250    // verify `describe_verb` appends `resolution_mode_contract`'s rendering
15251    // to every uuid-typed description instead of requiring each `HandlerDef`
15252    // to paste the contract in by hand.
15253    static GET_PARAMS: [ParamDef; 1] = [ParamDef {
15254        name: "id",
15255        param_type: "uuid",
15256        required: true,
15257        description: "UUID of the record to fetch.",
15258        resolution_mode: IdResolutionMode::UnscopedById,
15259    }];
15260
15261    // Mirrors link's real source_id/target_id params (both `param_type:
15262    // "uuid"`, `IdResolutionMode::UnscopedById`) — used to verify the shared
15263    // id contract is appended to link's endpoint params too, matching the
15264    // enumeration in `resolution_mode_contract`'s `UnscopedById` text.
15265    static LINK_PARAMS: [ParamDef; 2] = [
15266        ParamDef {
15267            name: "source_id",
15268            param_type: "uuid",
15269            required: true,
15270            description: "Source node UUID.",
15271            resolution_mode: IdResolutionMode::UnscopedById,
15272        },
15273        ParamDef {
15274            name: "target_id",
15275            param_type: "uuid",
15276            required: true,
15277            description: "Target node UUID.",
15278            resolution_mode: IdResolutionMode::UnscopedById,
15279        },
15280    ];
15281
15282    struct HelpPack {
15283        invocations: Arc<AtomicUsize>,
15284    }
15285
15286    impl Pack for HelpPack {
15287        const NAME: &'static str = "helptest";
15288        const NOTE_KINDS: &'static [&'static str] = &[];
15289        const ENTITY_KINDS: &'static [&'static str] = &[];
15290        const HANDLERS: &'static [HandlerDef] = &[
15291            HandlerDef {
15292                name: "create",
15293                description: "Create an entity or note",
15294                visibility: Visibility::Verb,
15295                category: VerbCategory::Commissive,
15296                params: &CREATE_PARAMS,
15297            },
15298            HandlerDef {
15299                name: "recall",
15300                description: "Recall memory notes with decay-aware hybrid ranking",
15301                visibility: Visibility::Verb,
15302                category: VerbCategory::Assertive,
15303                params: &RECALL_PARAMS,
15304            },
15305            // A Subhandler used to test that help=true returns
15306            // callable_via_mcp: false for internal verbs.
15307            HandlerDef {
15308                name: "recall.embed",
15309                description: "Return the embedding vector used by memory recall",
15310                visibility: Visibility::Subhandler,
15311                category: VerbCategory::Assertive,
15312                params: &EMBED_PARAMS,
15313            },
15314            HandlerDef {
15315                name: "link",
15316                description: "Create a typed directed edge",
15317                visibility: Visibility::Verb,
15318                category: VerbCategory::Commissive,
15319                params: &LINK_PARAMS,
15320            },
15321            HandlerDef {
15322                name: "get",
15323                description: "Fetch a record by id",
15324                visibility: Visibility::Verb,
15325                category: VerbCategory::Assertive,
15326                params: &GET_PARAMS,
15327            },
15328        ];
15329    }
15330
15331    // A pack-declared additive edge rule (mirrors the GTD pack's real
15332    // task-to-task `depends_on` rule), used to verify `link(help=true)`
15333    // surfaces pack-composed rules alongside the base entity table. The
15334    // second entry declares a rule for a special relation
15335    // (`supersedes`) that the validator's dedicated special-relation
15336    // branch never consults `pack_rule_allows` for — it must NOT be
15337    // advertised (see `test_link_help_true_matches_special_relation_validator_set`).
15338    static HELP_EDGE_RULES: [EdgeEndpointRule; 2] = [
15339        EdgeEndpointRule {
15340            relation: khive_types::EdgeRelation::DependsOn,
15341            source: EndpointKind::NoteOfKind("task"),
15342            target: EndpointKind::NoteOfKind("task"),
15343        },
15344        EdgeEndpointRule {
15345            relation: khive_types::EdgeRelation::Supersedes,
15346            source: EndpointKind::NoteOfKind("task"),
15347            target: EndpointKind::NoteOfKind("task"),
15348        },
15349    ];
15350
15351    #[async_trait]
15352    impl PackRuntime for HelpPack {
15353        fn name(&self) -> &str {
15354            HelpPack::NAME
15355        }
15356        fn note_kinds(&self) -> &'static [&'static str] {
15357            HelpPack::NOTE_KINDS
15358        }
15359        fn entity_kinds(&self) -> &'static [&'static str] {
15360            HelpPack::ENTITY_KINDS
15361        }
15362        fn handlers(&self) -> &'static [HandlerDef] {
15363            HelpPack::HANDLERS
15364        }
15365        fn edge_rules(&self) -> &'static [EdgeEndpointRule] {
15366            &HELP_EDGE_RULES
15367        }
15368        async fn dispatch(
15369            &self,
15370            verb: &str,
15371            _params: Value,
15372            _registry: &VerbRegistry,
15373            _token: &NamespaceToken,
15374        ) -> Result<Value, RuntimeError> {
15375            self.invocations.fetch_add(1, Ordering::SeqCst);
15376            Ok(serde_json::json!({ "pack": "helptest", "verb": verb }))
15377        }
15378    }
15379
15380    fn build_help_registry(invocations: Arc<AtomicUsize>) -> VerbRegistry {
15381        let mut builder = VerbRegistryBuilder::new();
15382        builder.register(HelpPack { invocations });
15383        builder.build().expect("help registry builds")
15384    }
15385
15386    /// help=true on `create` returns a schema envelope with the correct verb name,
15387    /// pack name, description, and at least the required `kind` parameter.
15388    #[tokio::test]
15389    async fn test_help_true_returns_schema_for_kg_create() {
15390        let invocations = Arc::new(AtomicUsize::new(0));
15391        let reg = build_help_registry(invocations.clone());
15392
15393        let result = reg
15394            .dispatch("create", serde_json::json!({ "help": true }))
15395            .await
15396            .expect("help=true must succeed for a known verb");
15397
15398        // Shape checks.
15399        assert_eq!(result["verb"], "create", "envelope must name the verb");
15400        assert_eq!(
15401            result["pack"], "helptest",
15402            "envelope must name the owning pack"
15403        );
15404        assert!(
15405            result["description"].as_str().is_some(),
15406            "description must be a string"
15407        );
15408
15409        // Params array must be present and non-empty.
15410        let params = result["params"]
15411            .as_array()
15412            .expect("params must be a JSON array");
15413        assert!(!params.is_empty(), "params array must not be empty");
15414
15415        // The required `kind` param must appear.
15416        let kind_param = params.iter().find(|p| p["name"] == "kind");
15417        assert!(
15418            kind_param.is_some(),
15419            "params array must include the 'kind' parameter"
15420        );
15421        let kind_param = kind_param.unwrap();
15422        assert_eq!(
15423            kind_param["required"],
15424            serde_json::json!(true),
15425            "'kind' must be required"
15426        );
15427        assert_eq!(kind_param["type"], "string", "'kind' type must be 'string'");
15428
15429        let identifier_help = result["identifier_resolution"]
15430            .as_object()
15431            .expect("help=true must include the shared identifier contract");
15432        assert!(identifier_help["full_uuid"]
15433            .as_str()
15434            .is_some_and(|text| text.contains("globally unique")));
15435        assert!(identifier_help["short_prefix"]
15436            .as_str()
15437            .is_some_and(|text| text.contains("lookup scope belongs to the consuming parameter")));
15438        assert!(identifier_help["parameter_rule"]
15439            .as_str()
15440            .is_some_and(|text| text.contains("submitted again")));
15441    }
15442
15443    #[test]
15444    fn event_target_resolution_metadata_has_a_conditional_contract() {
15445        let mode = IdResolutionMode::EdgeOrEventTarget;
15446        let text = resolution_mode_contract(mode).unwrap();
15447        assert!(text.contains("kind=event accepts only a full subject UUID"));
15448        assert!(text.contains("prefixes and names are rejected without graph resolution"));
15449        assert!(text.contains("For kind=edge"));
15450        assert!(text.contains("prefix or entity name resolves in the primary namespace"));
15451        assert_eq!(resolution_mode_key(mode), "edge_or_event_target");
15452        assert_eq!(
15453            identifier_resolution_help()["resolution_modes"]["edge_or_event_target"],
15454            text
15455        );
15456    }
15457
15458    /// `describe_verb` appends `resolution_mode_contract(p.resolution_mode)`
15459    /// to every uuid-typed parameter's description, so the full-UUID-vs-
15460    /// short-prefix rule is stated once per mode (in
15461    /// `resolution_mode_contract`) and inherited by every verb declaring
15462    /// that mode, rather than requiring each `HandlerDef` to paste its own
15463    /// explanation — or, worse, one blanket explanation getting appended to
15464    /// parameters whose actual resolver behaves differently (see
15465    /// `IdResolutionMode`'s doc comment: this is exactly the bug the mode
15466    /// field replaced — a single shared string asserted namespace-agnostic
15467    /// full-UUID acceptance on every uuid param, which was false for
15468    /// primary-scoped and namespace-filtered resolvers).
15469    /// This test covers `IdResolutionMode::UnscopedById`; the full mode
15470    /// enumeration is exercised against real pack definitions by
15471    /// `kkernel::pack_introspect::tests::
15472    /// every_uuid_param_across_every_registered_pack_declares_a_resolution_mode`
15473    /// and its `describe_verb_renders_mode_specific_contract_for_real_handlers`
15474    /// companion (khive-runtime cannot depend on the pack crates itself
15475    /// without a circular dependency).
15476    ///
15477    /// The namespace assertion below is deliberately specific. It first
15478    /// asserted only `description.contains("namespace")`, which passes
15479    /// identically whether the contract says prefix resolution *is* or *is
15480    /// not* namespace-scoped — so it passed while the contract stated the
15481    /// opposite of what the by-ID verbs do. A test that cannot separate the
15482    /// two readings protects neither.
15483    #[tokio::test]
15484    async fn test_help_true_uuid_param_carries_shared_id_contract() {
15485        let invocations = Arc::new(AtomicUsize::new(0));
15486        let reg = build_help_registry(invocations.clone());
15487
15488        let result = reg
15489            .dispatch("get", serde_json::json!({ "help": true }))
15490            .await
15491            .expect("help=true must succeed for a known verb");
15492
15493        let params = result["params"]
15494            .as_array()
15495            .expect("params must be a JSON array");
15496        let id_param = params
15497            .iter()
15498            .find(|p| p["name"] == "id")
15499            .expect("params array must include the 'id' parameter");
15500        let description = id_param["description"]
15501            .as_str()
15502            .expect("description must be a string");
15503
15504        // The verb-specific text must still be present...
15505        assert!(
15506            description.contains("UUID of the record to fetch"),
15507            "shared contract must be appended, not replace, the verb-specific text; got: {description}"
15508        );
15509        // ...and the shared contract must state the ACTUAL by-ID rule, in a
15510        // form that separates it from its negation. Must-match and
15511        // must-not-match together: either arm alone still admits a contract
15512        // that merely mentions namespaces without committing to a rule.
15513        assert!(
15514            description.contains("no namespace filter"),
15515            "id contract must state that by-ID prefix resolution applies NO namespace filter \
15516             (ADR-007 Rev 6, `resolve_prefix_unfiltered`); got: {description}"
15517        );
15518        assert!(
15519            !description.contains("namespace-scoped resolution"),
15520            "id contract must not claim prefix resolution is namespace-scoped — get/update/\
15521             delete/merge resolve prefixes unfiltered; got: {description}"
15522        );
15523        assert!(
15524            description.to_ascii_lowercase().contains("prefix"),
15525            "id contract must describe short-prefix semantics; got: {description}"
15526        );
15527
15528        // `link`'s source_id/target_id are `param_type: "uuid"` too (they
15529        // resolve through the same unfiltered path as get/update/delete/
15530        // merge — see `crates/khive-pack-kg/src/handlers/link.rs`), so the
15531        // contract's enumerated verb list must name `link` explicitly, not
15532        // just the four record-level by-ID verbs.
15533        let link_result = reg
15534            .dispatch("link", serde_json::json!({ "help": true }))
15535            .await
15536            .expect("help=true must succeed for link");
15537        let link_params = link_result["params"]
15538            .as_array()
15539            .expect("link params must be a JSON array");
15540        let source_id_param = link_params
15541            .iter()
15542            .find(|p| p["name"] == "source_id")
15543            .expect("link params must include 'source_id'");
15544        let source_id_description = source_id_param["description"]
15545            .as_str()
15546            .expect("description must be a string");
15547        assert!(
15548            source_id_description.contains("get/update/delete/merge/link"),
15549            "id contract's by-ID verb enumeration must include 'link' alongside get/update/\
15550             delete/merge, since link's source_id/target_id resolve through the same \
15551             unfiltered path; got: {source_id_description}"
15552        );
15553    }
15554
15555    /// help=true on `recall` returns a schema envelope including the `query` param.
15556    #[tokio::test]
15557    async fn test_help_true_returns_schema_for_recall() {
15558        let invocations = Arc::new(AtomicUsize::new(0));
15559        let reg = build_help_registry(invocations.clone());
15560
15561        let result = reg
15562            .dispatch("recall", serde_json::json!({ "help": true }))
15563            .await
15564            .expect("help=true must succeed for recall");
15565
15566        assert_eq!(result["verb"], "recall");
15567        assert_eq!(result["pack"], "helptest");
15568
15569        let params = result["params"]
15570            .as_array()
15571            .expect("params must be a JSON array");
15572
15573        // `query` must be present and required.
15574        let query_param = params.iter().find(|p| p["name"] == "query");
15575        assert!(query_param.is_some(), "params must include 'query'");
15576        let query_param = query_param.unwrap();
15577        assert_eq!(
15578            query_param["required"],
15579            serde_json::json!(true),
15580            "'query' must be required"
15581        );
15582
15583        // `limit` must be present and optional.
15584        let limit_param = params.iter().find(|p| p["name"] == "limit");
15585        assert!(limit_param.is_some(), "params must include 'limit'");
15586        let limit_param = limit_param.unwrap();
15587        assert_eq!(
15588            limit_param["required"],
15589            serde_json::json!(false),
15590            "'limit' must be optional"
15591        );
15592    }
15593
15594    /// `link(help=true)` (issue #964) surfaces the composed per-relation
15595    /// endpoint allowlist: the base entity-to-entity table, every loaded
15596    /// pack's additive `EDGE_RULES`, and the `annotates` note-to-any rule —
15597    /// so a batch caller can defer to the kernel's own table instead of
15598    /// re-implementing it.
15599    #[tokio::test]
15600    async fn test_link_help_true_exposes_endpoint_rules() {
15601        let invocations = Arc::new(AtomicUsize::new(0));
15602        let reg = build_help_registry(invocations.clone());
15603
15604        let result = reg
15605            .dispatch("link", serde_json::json!({ "help": true }))
15606            .await
15607            .expect("help=true must succeed for link");
15608
15609        assert_eq!(result["verb"], "link");
15610        let rules = result["endpoint_rules"]
15611            .as_array()
15612            .expect("link help must include an endpoint_rules array");
15613        assert!(!rules.is_empty(), "endpoint_rules must not be empty");
15614
15615        // A base entity-to-entity rule (khive-runtime's own table) must appear.
15616        assert!(
15617            rules.iter().any(|r| r["relation"] == "contains"
15618                && r["source"] == "entity:concept"
15619                && r["target"] == "entity:concept"),
15620            "endpoint_rules must include the base 'contains' entity rule; got {rules:#?}"
15621        );
15622
15623        // The pack-declared additive rule (HelpPack's task->task depends_on) must appear.
15624        assert!(
15625            rules.iter().any(|r| r["relation"] == "depends_on"
15626                && r["source"] == "note:task"
15627                && r["target"] == "note:task"),
15628            "endpoint_rules must include the pack-declared depends_on rule; got {rules:#?}"
15629        );
15630
15631        // The annotates note-to-any special case must appear.
15632        assert!(
15633            rules
15634                .iter()
15635                .any(|r| r["relation"] == "annotates" && r["source"] == "note:*"),
15636            "endpoint_rules must document the annotates note-to-any rule; got {rules:#?}"
15637        );
15638
15639        // help=true must remain side-effect-free.
15640        assert_eq!(
15641            invocations.load(Ordering::SeqCst),
15642            0,
15643            "link(help=true) must not invoke pack dispatch"
15644        );
15645    }
15646
15647    /// `link(help=true)`'s `endpoint_rules` must match, set-for-set, every
15648    /// endpoint pair `validate_edge_relation_endpoints`
15649    /// (`crates/khive-runtime/src/operations.rs`) actually accepts for the
15650    /// three special relations (`supersedes` / `supports` / `refutes`):
15651    ///
15652    /// - a `note -> note` row for each of the three relations (the
15653    ///   validator's dedicated special-relation branch accepts any
15654    ///   `Resolved::Note(_), Resolved::Note(_)` pair unconditionally,
15655    ///   `operations.rs:1338` / `:1527` — before `pack_rule_allows` is ever
15656    ///   reached);
15657    /// - the base entity->entity rows for the three relations
15658    ///   (`base_entity_endpoint_rules`, e.g. `concept -[supersedes]-> concept`);
15659    /// - and, critically, NOT a row for `HelpPack`'s pack-declared
15660    ///   `supersedes` rule on `note:task -> note:task`
15661    ///   (`HELP_EDGE_RULES[1]`) — because the validator's special-relation
15662    ///   branch returns before `pack_rule_allows` is consulted, that pack
15663    ///   rule is never actually enforced, so advertising it would be a false
15664    ///   promise (the exact defect this test guards against, issue #991).
15665    #[tokio::test]
15666    async fn test_link_help_true_matches_special_relation_validator_set() {
15667        let invocations = Arc::new(AtomicUsize::new(0));
15668        let reg = build_help_registry(invocations.clone());
15669
15670        let result = reg
15671            .dispatch("link", serde_json::json!({ "help": true }))
15672            .await
15673            .expect("help=true must succeed for link");
15674
15675        let rules = result["endpoint_rules"]
15676            .as_array()
15677            .expect("link help must include an endpoint_rules array");
15678
15679        for relation in ["supersedes", "supports", "refutes"] {
15680            // The unconditional note -> note row must appear.
15681            assert!(
15682                rules.iter().any(|r| r["relation"] == relation
15683                    && r["source"] == "note:*"
15684                    && r["target"] == "note:*"),
15685                "endpoint_rules must include the note:*->note:* row for '{relation}' \
15686                 (validator accepts any note->note pair unconditionally); got {rules:#?}"
15687            );
15688
15689            // HelpPack's pack-declared rule for this relation on note:task->note:task
15690            // (only Supersedes is declared in HELP_EDGE_RULES) must NOT be advertised
15691            // as a distinct entity — the validator never reaches pack_rule_allows for
15692            // special relations, so no note:task->note:task row should exist for it.
15693            assert!(
15694                !rules.iter().any(|r| r["relation"] == relation
15695                    && r["source"] == "note:task"
15696                    && r["target"] == "note:task"),
15697                "endpoint_rules must NOT advertise a pack EDGE_RULES row for special \
15698                 relation '{relation}' — validate_edge_relation_endpoints never consults \
15699                 pack_rule_allows for supersedes/supports/refutes; got {rules:#?}"
15700            );
15701        }
15702
15703        // Base entity->entity rows for the three relations (from
15704        // base_entity_endpoint_rules) must still appear alongside the note rows.
15705        for (relation, kind) in [
15706            ("supersedes", "concept"),
15707            ("supports", "concept"),
15708            ("refutes", "concept"),
15709        ] {
15710            assert!(
15711                rules.iter().any(|r| r["relation"] == relation
15712                    && r["source"] == format!("entity:{kind}")
15713                    && r["target"] == "entity:concept"),
15714                "endpoint_rules must include the base entity:{kind}->entity:concept row \
15715                 for '{relation}'; got {rules:#?}"
15716            );
15717        }
15718    }
15719
15720    #[test]
15721    fn special_relation_predicate_matches_the_dedicated_validator_set() {
15722        for relation in khive_types::EdgeRelation::ALL {
15723            assert_eq!(
15724                is_special_relation(relation),
15725                matches!(
15726                    relation,
15727                    khive_types::EdgeRelation::Supersedes
15728                        | khive_types::EdgeRelation::Supports
15729                        | khive_types::EdgeRelation::Refutes
15730                ),
15731                "unexpected special-relation classification for {relation}"
15732            );
15733        }
15734    }
15735
15736    /// help=true is intercepted before pack dispatch — the pack's dispatch method
15737    /// must never be invoked when help=true is in the params.
15738    #[tokio::test]
15739    async fn test_help_true_does_not_execute_the_verb() {
15740        let invocations = Arc::new(AtomicUsize::new(0));
15741        let reg = build_help_registry(invocations.clone());
15742
15743        // Call both verbs with help=true.
15744        reg.dispatch("create", serde_json::json!({ "help": true }))
15745            .await
15746            .expect("help=true must succeed");
15747        reg.dispatch("recall", serde_json::json!({ "help": true }))
15748            .await
15749            .expect("help=true must succeed");
15750
15751        assert_eq!(
15752            invocations.load(Ordering::SeqCst),
15753            0,
15754            "pack dispatch MUST NOT be invoked when help=true; \
15755             got {} invocation(s)",
15756            invocations.load(Ordering::SeqCst)
15757        );
15758
15759        // Confirm that a normal call (without help=true) DOES invoke dispatch.
15760        reg.dispatch("create", serde_json::json!({}))
15761            .await
15762            .expect("normal dispatch must succeed");
15763        assert_eq!(
15764            invocations.load(Ordering::SeqCst),
15765            1,
15766            "pack dispatch must fire exactly once for a normal call"
15767        );
15768    }
15769
15770    // ── Subhandler help-schema regressions ─────────────────────────────────
15771    //
15772    // Subhandler verbs must return `callable_via_mcp: false` in their help
15773    // schema so agents who read help=true before probing see accurate
15774    // availability — not a "looks callable" schema followed by permission denied.
15775
15776    /// help=true on a `Visibility::Subhandler` verb returns `callable_via_mcp: false`
15777    /// and `visibility: "internal"` rather than a plain callable-looking envelope.
15778    #[tokio::test]
15779    async fn help_true_on_subhandler_returns_callable_via_mcp_false() {
15780        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
15781
15782        let result = reg
15783            .dispatch("recall.embed", serde_json::json!({ "help": true }))
15784            .await
15785            .expect("help=true on subhandler must succeed (no permission check on help path)");
15786
15787        assert_eq!(
15788            result["callable_via_mcp"],
15789            serde_json::json!(false),
15790            "subhandler help must carry callable_via_mcp: false"
15791        );
15792        assert_eq!(
15793            result["visibility"], "internal",
15794            "subhandler help must carry visibility: internal"
15795        );
15796        // The verb and pack fields must still be present so the caller knows
15797        // what the schema belongs to.
15798        assert_eq!(result["verb"], "recall.embed");
15799        assert_eq!(result["pack"], "helptest");
15800    }
15801
15802    /// Public Verb-visibility handlers must NOT have `callable_via_mcp: false`.
15803    #[tokio::test]
15804    async fn help_true_on_public_verb_does_not_have_callable_via_mcp_false() {
15805        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
15806
15807        let result = reg
15808            .dispatch("create", serde_json::json!({ "help": true }))
15809            .await
15810            .expect("help=true on public verb must succeed");
15811
15812        // callable_via_mcp must be absent or true for public verbs.
15813        assert_ne!(
15814            result.get("callable_via_mcp"),
15815            Some(&serde_json::json!(false)),
15816            "public verb help must NOT carry callable_via_mcp: false"
15817        );
15818        // visibility must be absent or 'public' (never 'internal') for public verbs.
15819        assert_ne!(
15820            result.get("visibility"),
15821            Some(&serde_json::json!("internal")),
15822            "public verb help must NOT carry visibility: internal"
15823        );
15824    }
15825
15826    /// help=true on an unknown verb returns an error (same behavior as normal dispatch).
15827    #[tokio::test]
15828    async fn help_true_on_unknown_verb_returns_error() {
15829        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
15830
15831        let err = reg
15832            .dispatch("nonexistent_verb", serde_json::json!({ "help": true }))
15833            .await
15834            .unwrap_err();
15835
15836        assert!(
15837            matches!(err, RuntimeError::UnknownVerb(_)),
15838            "help=true on unknown verb must return UnknownVerb, got {err:?}"
15839        );
15840        let msg = err.to_string();
15841        assert!(
15842            msg.contains("nonexistent_verb"),
15843            "error must name the unknown verb: {msg}"
15844        );
15845    }
15846
15847    /// Subhandler help must include params: [] even when the verb has no params.
15848    #[tokio::test]
15849    async fn help_true_on_subhandler_includes_params_field() {
15850        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
15851
15852        let result = reg
15853            .dispatch("recall.embed", serde_json::json!({ "help": true }))
15854            .await
15855            .expect("help=true on subhandler must succeed");
15856
15857        // params must always be present (consistent shape).
15858        let params = result
15859            .get("params")
15860            .expect("subhandler help must include 'params' field");
15861        assert!(
15862            params.is_array(),
15863            "subhandler help params must be a JSON array"
15864        );
15865    }
15866
15867    // ── Unknown-verb error must not leak subhandler names ─────────
15868
15869    /// `describe_verb` on an unknown verb must list only Verb-visibility names
15870    /// in the "available" list: never subhandler names like `recall.embed`.
15871    #[tokio::test]
15872    async fn help_true_unknown_verb_available_list_excludes_subhandlers() {
15873        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
15874
15875        let err = reg
15876            .dispatch("not_a_verb", serde_json::json!({ "help": true }))
15877            .await
15878            .unwrap_err();
15879
15880        let msg = err.to_string();
15881        // `recall.embed` is a Subhandler in HelpPack — must NOT appear in the
15882        // "available" list of an unknown-verb error.
15883        assert!(
15884            !msg.contains("recall.embed"),
15885            "unknown-verb help error must not advertise subhandler recall.embed: {msg}"
15886        );
15887        // Public verbs must still appear so the agent knows what to call.
15888        assert!(
15889            msg.contains("create"),
15890            "unknown-verb help error must still list public verb 'create': {msg}"
15891        );
15892        assert!(
15893            msg.contains("recall"),
15894            "unknown-verb help error must still list public verb 'recall': {msg}"
15895        );
15896    }
15897
15898    /// Normal dispatch on an unknown verb must also not leak subhandler names.
15899    #[tokio::test]
15900    async fn dispatch_unknown_verb_available_list_excludes_subhandlers() {
15901        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
15902
15903        let err = reg
15904            .dispatch("not_a_verb", serde_json::json!({}))
15905            .await
15906            .unwrap_err();
15907
15908        let msg = err.to_string();
15909        // `recall.embed` is a Subhandler in HelpPack — must NOT appear in the
15910        // "available" list of an unknown-verb dispatch error.
15911        assert!(
15912            !msg.contains("recall.embed"),
15913            "dispatch unknown-verb error must not advertise subhandler recall.embed: {msg}"
15914        );
15915        // Public verbs must still appear so the agent knows what to call.
15916        assert!(
15917            msg.contains("create"),
15918            "dispatch unknown-verb error must still list public verb 'create': {msg}"
15919        );
15920        assert!(
15921            msg.contains("recall"),
15922            "dispatch unknown-verb error must still list public verb 'recall': {msg}"
15923        );
15924    }
15925
15926    // ── ADR-028 multi-backend schema routing tests ───────────────────────────
15927
15928    /// A test pack that returns a real SchemaPlan so we can assert routing.
15929    struct SchemaPack {
15930        pack_name: &'static str,
15931        statements: &'static [&'static str],
15932        column_additions: &'static [PackColumnAddition],
15933    }
15934
15935    impl Pack for SchemaPack {
15936        const NAME: &'static str = "schema-pack";
15937        const NOTE_KINDS: &'static [&'static str] = &[];
15938        const ENTITY_KINDS: &'static [&'static str] = &[];
15939        const HANDLERS: &'static [HandlerDef] = &[];
15940    }
15941
15942    #[async_trait]
15943    impl PackRuntime for SchemaPack {
15944        fn name(&self) -> &str {
15945            self.pack_name
15946        }
15947        fn note_kinds(&self) -> &'static [&'static str] {
15948            &[]
15949        }
15950        fn entity_kinds(&self) -> &'static [&'static str] {
15951            &[]
15952        }
15953        fn handlers(&self) -> &'static [HandlerDef] {
15954            &[]
15955        }
15956        fn schema_plan(&self) -> SchemaPlan {
15957            SchemaPlan {
15958                pack: self.pack_name,
15959                statements: self.statements,
15960            }
15961        }
15962        fn schema_column_additions(&self) -> &'static [PackColumnAddition] {
15963            self.column_additions
15964        }
15965        async fn dispatch(
15966            &self,
15967            verb: &str,
15968            _params: Value,
15969            _registry: &VerbRegistry,
15970            _token: &NamespaceToken,
15971        ) -> Result<Value, RuntimeError> {
15972            Ok(serde_json::json!({ "pack": self.pack_name, "verb": verb }))
15973        }
15974    }
15975
15976    // ADR-028: all_schema_plans_named returns (pack_name, SchemaPlan) pairs
15977    // where pack_name comes from SchemaPlan::pack (always &'static str).
15978    #[test]
15979    fn all_schema_plans_named_returns_correct_pairs() {
15980        let mut builder = VerbRegistryBuilder::new();
15981        builder.register_boxed(Box::new(SchemaPack {
15982            pack_name: "alpha",
15983            statements: &["CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER PRIMARY KEY)"],
15984            column_additions: &[],
15985        }));
15986        builder.register_boxed(Box::new(SchemaPack {
15987            pack_name: "beta",
15988            statements: &[],
15989            column_additions: &[],
15990        }));
15991        let reg = builder.build().expect("registry builds");
15992
15993        let named = reg.all_schema_plans_named();
15994        assert_eq!(named.len(), 2);
15995
15996        let alpha_entry = named.iter().find(|(n, _)| *n == "alpha");
15997        let beta_entry = named.iter().find(|(n, _)| *n == "beta");
15998
15999        assert!(alpha_entry.is_some(), "alpha must appear in named plans");
16000        assert!(beta_entry.is_some(), "beta must appear in named plans");
16001
16002        let (_, alpha_plan) = alpha_entry.unwrap();
16003        assert_eq!(alpha_plan.statements.len(), 1);
16004        assert!(!alpha_plan.is_empty());
16005
16006        let (_, beta_plan) = beta_entry.unwrap();
16007        assert!(beta_plan.is_empty());
16008    }
16009
16010    // ADR-028: apply_schema_plans_with_map routes non-empty plans to the
16011    // correct per-pack backend instead of the default.
16012    //
16013    // Verification: apply DDL to routed backend, then confirm the table is
16014    // present on pack_backend and absent on default_backend by attempting to
16015    // apply the same DDL again — if the table already exists on pack_backend
16016    // the idempotent CREATE IF NOT EXISTS succeeds; applying to default_backend
16017    // would only matter if the table were routed there.  We verify isolation
16018    // by applying the plan and then running a targeted DDL on each backend
16019    // that would fail if the table did not already exist (CREATE without
16020    // IF NOT EXISTS on a duplicate raises an error), combined with a no-error
16021    // path on the correct backend.
16022    //
16023    // Simpler approach: confirm the plan applies without error (routing is
16024    // correct) and that the opposite backend returns an error when we try to
16025    // INSERT into the routed table (table-not-found = SQLITE_ERROR).
16026    #[tokio::test]
16027    async fn apply_schema_plans_with_map_routes_to_correct_backend() {
16028        use khive_storage::types::{SqlStatement, SqlValue};
16029
16030        let default_backend = khive_db::StorageBackend::memory().expect("default memory backend");
16031        let pack_backend =
16032            khive_db::StorageBackend::memory().expect("pack-specific memory backend");
16033
16034        let mut builder = VerbRegistryBuilder::new();
16035        builder.register_boxed(Box::new(SchemaPack {
16036            pack_name: "routed",
16037            statements: &["CREATE TABLE IF NOT EXISTS t_routed (id INTEGER PRIMARY KEY)"],
16038            column_additions: &[],
16039        }));
16040        let reg = builder.build().expect("registry builds");
16041
16042        let mut backend_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
16043        backend_map.insert("routed", &pack_backend);
16044
16045        reg.apply_schema_plans_with_map(&backend_map, &default_backend)
16046            .expect("schema application must not collide");
16047
16048        // On pack_backend: INSERT must succeed (table exists).
16049        let mut writer = pack_backend.sql().writer().await.expect("writer");
16050        let result = writer
16051            .execute(SqlStatement {
16052                sql: "INSERT INTO t_routed (id) VALUES (?1)".into(),
16053                params: vec![SqlValue::Integer(1)],
16054                label: None,
16055            })
16056            .await;
16057        assert!(
16058            result.is_ok(),
16059            "t_routed must exist on pack_backend after routing: {result:?}"
16060        );
16061
16062        // On default_backend: INSERT must fail (table not there).
16063        let mut default_writer = default_backend.sql().writer().await.expect("writer");
16064        let default_result = default_writer
16065            .execute(SqlStatement {
16066                sql: "INSERT INTO t_routed (id) VALUES (?1)".into(),
16067                params: vec![SqlValue::Integer(2)],
16068                label: None,
16069            })
16070            .await;
16071        assert!(
16072            default_result.is_err(),
16073            "t_routed must NOT exist on default_backend (table should not be there)"
16074        );
16075    }
16076
16077    // ADR-028: apply_schema_plans_with_map uses default backend for packs
16078    // absent from the map.
16079    #[tokio::test]
16080    async fn apply_schema_plans_with_map_falls_back_to_default_for_unmapped_packs() {
16081        use khive_storage::types::{SqlStatement, SqlValue};
16082
16083        let default_backend = khive_db::StorageBackend::memory().expect("default memory backend");
16084
16085        let mut builder = VerbRegistryBuilder::new();
16086        builder.register_boxed(Box::new(SchemaPack {
16087            pack_name: "unmapped",
16088            statements: &["CREATE TABLE IF NOT EXISTS t_unmapped (id INTEGER PRIMARY KEY)"],
16089            column_additions: &[],
16090        }));
16091        let reg = builder.build().expect("registry builds");
16092
16093        let backend_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
16094        reg.apply_schema_plans_with_map(&backend_map, &default_backend)
16095            .expect("schema application must not collide");
16096
16097        // On default_backend: INSERT must succeed (table fell back here).
16098        let mut writer = default_backend.sql().writer().await.expect("writer");
16099        let result = writer
16100            .execute(SqlStatement {
16101                sql: "INSERT INTO t_unmapped (id) VALUES (?1)".into(),
16102                params: vec![SqlValue::Integer(1)],
16103                label: None,
16104            })
16105            .await;
16106        assert!(
16107            result.is_ok(),
16108            "t_unmapped must exist on default_backend for unmapped pack: {result:?}"
16109        );
16110    }
16111
16112    // ADR-028: two packs declaring the same auxiliary table on the same
16113    // backend must cause apply_schema_plans_with_map to return an error that
16114    // names both packs and the table: it is a boot-time failure, not a
16115    // silent DDL race.
16116    #[test]
16117    fn apply_schema_plans_with_map_collision_is_an_error() {
16118        let backend = khive_db::StorageBackend::memory().expect("memory backend");
16119        let empty_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
16120
16121        let mut builder = VerbRegistryBuilder::new();
16122        builder.register_boxed(Box::new(SchemaPack {
16123            pack_name: "pack_alpha",
16124            statements: &["CREATE TABLE IF NOT EXISTS collision_table (id INTEGER PRIMARY KEY)"],
16125            column_additions: &[],
16126        }));
16127        builder.register_boxed(Box::new(SchemaPack {
16128            pack_name: "pack_beta",
16129            statements: &["CREATE TABLE IF NOT EXISTS collision_table (id INTEGER PRIMARY KEY)"],
16130            column_additions: &[],
16131        }));
16132        let registry = builder.build().expect("registry builds");
16133
16134        let result = registry.apply_schema_plans_with_map(&empty_map, &backend);
16135        assert!(
16136            result.is_err(),
16137            "two packs declaring the same table on the same backend must produce a collision error"
16138        );
16139        let err = result.unwrap_err();
16140        let msg = err.to_string();
16141        assert!(
16142            msg.contains("pack_alpha"),
16143            "collision error must name first pack; got: {msg}"
16144        );
16145        assert!(
16146            msg.contains("pack_beta"),
16147            "collision error must name second pack; got: {msg}"
16148        );
16149        assert!(
16150            msg.contains("collision_table"),
16151            "collision error must name the table; got: {msg}"
16152        );
16153    }
16154
16155    #[test]
16156    fn schema_collision_normalizes_sql_identifiers_before_any_ddl() {
16157        let spellings: [&'static [&'static str]; 7] = [
16158            &["CREATE TABLE IF NOT EXISTS \"shared\"(id INTEGER)"],
16159            &["-- pack table\nCREATE TABLE IF NOT EXISTS shared(id INTEGER)"],
16160            &["CREATE TABLE IF NOT EXISTS main.shared(id INTEGER)"],
16161            &["CREATE TEMP TABLE IF NOT EXISTS shared(id INTEGER)"],
16162            &["CREATE/**/TABLE IF NOT EXISTS shared(id INTEGER)"],
16163            &["CREATE TABLE IF NOT EXISTS/**/shared(id INTEGER)"],
16164            &["CREATE TABLE IF NOT EXISTS shared/**/(id INTEGER)"],
16165        ];
16166        for statements in spellings {
16167            let backend = khive_db::StorageBackend::memory().expect("memory backend");
16168            let mut builder = VerbRegistryBuilder::new();
16169            builder.register_boxed(Box::new(SchemaPack {
16170                pack_name: "pack_alpha",
16171                statements: &["CREATE TABLE IF NOT EXISTS shared (id INTEGER)"],
16172                column_additions: &[],
16173            }));
16174            builder.register_boxed(Box::new(SchemaPack {
16175                pack_name: "pack_beta",
16176                statements,
16177                column_additions: &[],
16178            }));
16179            let registry = builder.build().expect("registry builds");
16180            let error = registry
16181                .apply_schema_plans_with_map(&HashMap::new(), &backend)
16182                .expect_err("spelling must not evade table ownership");
16183            let message = error.to_string();
16184            assert!(message.contains("pack_alpha") && message.contains("pack_beta"));
16185            assert!(message.contains("shared"));
16186            let table_count: i64 = backend
16187                .pool()
16188                .reader()
16189                .expect("reader")
16190                .query_row(
16191                    "SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = 'shared'",
16192                    [],
16193                    |row| row.get(0),
16194                )
16195                .expect("schema count");
16196            assert_eq!(table_count, 0, "collision must precede all pack DDL");
16197        }
16198    }
16199
16200    #[test]
16201    fn quoted_punctuation_table_names_cannot_evade_ownership() {
16202        let cases: [(&'static str, &'static [&'static str]); 3] = [
16203            (".", &[r#"CREATE TABLE IF NOT EXISTS "." (id INTEGER)"#]),
16204            ("(", &[r#"CREATE TABLE IF NOT EXISTS "(" (id INTEGER)"#]),
16205            (";", &[r#"CREATE TABLE IF NOT EXISTS ";" (id INTEGER)"#]),
16206        ];
16207        for (table, statements) in cases {
16208            // Control: the quoted-punctuation identifier is valid SQLite on its
16209            // own, so a refusal below must be an ownership-collision refusal,
16210            // not a generic SQL-syntax rejection.
16211            let control = khive_db::StorageBackend::memory().expect("control backend");
16212            control
16213                .apply_pack_ddl_statements(statements)
16214                .expect("quoted punctuation is valid SQLite");
16215
16216            let backend = khive_db::StorageBackend::memory().expect("memory backend");
16217            let mut builder = VerbRegistryBuilder::new();
16218            for pack_name in ["pack_alpha", "pack_beta"] {
16219                builder.register_boxed(Box::new(SchemaPack {
16220                    pack_name,
16221                    statements,
16222                    column_additions: &[],
16223                }));
16224            }
16225            let registry = builder.build().expect("registry builds");
16226            let error = registry
16227                .apply_schema_plans_with_map(&HashMap::new(), &backend)
16228                .expect_err("quoted punctuation must remain an owned table");
16229            let message = error.to_string();
16230            assert!(message.contains("pack_alpha") && message.contains("pack_beta"));
16231            let table_count: i64 = backend
16232                .pool()
16233                .reader()
16234                .expect("reader")
16235                .query_row(
16236                    "SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = ?1",
16237                    [table],
16238                    |row| row.get(0),
16239                )
16240                .expect("schema count");
16241            assert_eq!(table_count, 0, "collision must precede all pack DDL");
16242        }
16243    }
16244
16245    #[test]
16246    fn apply_schema_plans_with_map_read_only_collision_is_an_error_without_writes() {
16247        let dir = tempfile::tempdir().expect("tempdir");
16248        let path = dir.path().join("read_only_schema_collision.db");
16249        {
16250            let writable =
16251                khive_db::StorageBackend::sqlite_for_test(&path).expect("writable backend");
16252            writable.prepare_core_schema().expect("current schema");
16253        }
16254        #[cfg(unix)]
16255        khive_storage::test_support::freeze_snapshot_sidecars(&path);
16256        let backend =
16257            khive_db::StorageBackend::sqlite_read_only_for_test(&path).expect("read-only backend");
16258        let empty_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
16259
16260        let mut builder = VerbRegistryBuilder::new();
16261        builder.register_boxed(Box::new(SchemaPack {
16262            pack_name: "pack_alpha",
16263            statements: &["CREATE TABLE IF NOT EXISTS collision_table (id INTEGER PRIMARY KEY)"],
16264            column_additions: &[],
16265        }));
16266        builder.register_boxed(Box::new(SchemaPack {
16267            pack_name: "pack_beta",
16268            statements: &["CREATE TABLE IF NOT EXISTS collision_table (id INTEGER PRIMARY KEY)"],
16269            column_additions: &[],
16270        }));
16271        let registry = builder.build().expect("registry builds");
16272        let writes_before = backend.pool().writer_acquisition_snapshot();
16273
16274        let result = registry.apply_schema_plans_with_map(&empty_map, &backend);
16275
16276        let err = result.expect_err(
16277            "read-only topology must reject the same cross-pack collision as writable topology",
16278        );
16279        let msg = err.to_string();
16280        assert!(
16281            msg.contains("pack_alpha"),
16282            "collision error must name first pack; got: {msg}"
16283        );
16284        assert!(
16285            msg.contains("pack_beta"),
16286            "collision error must name second pack; got: {msg}"
16287        );
16288        assert!(
16289            msg.contains("collision_table"),
16290            "collision error must name the table; got: {msg}"
16291        );
16292        assert_eq!(
16293            backend.pool().writer_acquisition_snapshot(),
16294            writes_before,
16295            "read-only collision validation must not acquire a writer"
16296        );
16297    }
16298
16299    fn column_schema_registry() -> VerbRegistry {
16300        let mut builder = VerbRegistryBuilder::new();
16301        builder.register_boxed(Box::new(SchemaPack {
16302            pack_name: "alpha",
16303            statements: &["CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER, revision TEXT)"],
16304            column_additions: &[PackColumnAddition {
16305                table: "t_alpha",
16306                column: "revision",
16307                affinity: PackColumnAffinity::Text,
16308            }],
16309        }));
16310        builder.register_boxed(Box::new(SchemaPack {
16311            pack_name: "beta",
16312            statements: &["CREATE TABLE IF NOT EXISTS t_beta (id INTEGER, epoch INTEGER)"],
16313            column_additions: &[PackColumnAddition {
16314                table: "t_beta",
16315                column: "epoch",
16316                affinity: PackColumnAffinity::Integer,
16317            }],
16318        }));
16319        builder.build().expect("registry builds")
16320    }
16321
16322    fn seed_column_schema(backend: &khive_db::StorageBackend) {
16323        backend
16324            .apply_pack_ddl_statements(&[
16325                "CREATE TABLE t_alpha (id INTEGER)",
16326                "CREATE TABLE t_beta (id INTEGER)",
16327            ])
16328            .expect("legacy schemas");
16329    }
16330
16331    fn column_schema_count(backend: &khive_db::StorageBackend, table: &str, column: &str) -> i64 {
16332        backend
16333            .pool()
16334            .reader()
16335            .unwrap()
16336            .query_row(
16337                "SELECT count(*) FROM pragma_table_xinfo(?1, 'main') WHERE name = ?2",
16338                [table, column],
16339                |row| row.get(0),
16340            )
16341            .unwrap()
16342    }
16343
16344    #[test]
16345    fn pack_column_upgrades_preserve_owner_metadata_and_apply_on_shared_backend() {
16346        let backend = khive_db::StorageBackend::memory().unwrap();
16347        seed_column_schema(&backend);
16348        let registry = column_schema_registry();
16349        let plans = registry.all_schema_plans_with_columns();
16350        assert_eq!(plans.len(), 2);
16351        let (_, alpha_columns) = plans.iter().find(|(plan, _)| plan.pack == "alpha").unwrap();
16352        assert_eq!(alpha_columns[0].table, "t_alpha");
16353        assert_eq!(alpha_columns[0].affinity, PackColumnAffinity::Text);
16354        let (_, beta_columns) = plans.iter().find(|(plan, _)| plan.pack == "beta").unwrap();
16355        assert_eq!(beta_columns[0].table, "t_beta");
16356        assert_eq!(beta_columns[0].affinity, PackColumnAffinity::Integer);
16357
16358        registry.apply_schema_plans(&backend);
16359        registry.apply_schema_plans(&backend);
16360        assert_eq!(column_schema_count(&backend, "t_alpha", "revision"), 1);
16361        assert_eq!(column_schema_count(&backend, "t_beta", "epoch"), 1);
16362    }
16363
16364    #[test]
16365    fn pack_column_upgrades_follow_assigned_backend_and_default_fallback() {
16366        let default_backend = khive_db::StorageBackend::memory().unwrap();
16367        let alpha_backend = khive_db::StorageBackend::memory().unwrap();
16368        seed_column_schema(&default_backend);
16369        seed_column_schema(&alpha_backend);
16370        let registry = column_schema_registry();
16371        let backend_map = HashMap::from([("alpha", &alpha_backend)]);
16372
16373        registry
16374            .apply_schema_plans_with_map(&backend_map, &default_backend)
16375            .unwrap();
16376        registry
16377            .apply_schema_plans_with_map(&backend_map, &default_backend)
16378            .unwrap();
16379        assert_eq!(
16380            column_schema_count(&alpha_backend, "t_alpha", "revision"),
16381            1
16382        );
16383        assert_eq!(
16384            column_schema_count(&default_backend, "t_alpha", "revision"),
16385            0
16386        );
16387        assert_eq!(column_schema_count(&default_backend, "t_beta", "epoch"), 1);
16388        assert_eq!(column_schema_count(&alpha_backend, "t_beta", "epoch"), 0);
16389    }
16390
16391    #[test]
16392    fn issue2768_pack_column_upgrades_refuse_read_only_old_schema_without_acquiring_writer() {
16393        let dir = tempfile::tempdir().unwrap();
16394        let path = dir.path().join("read_only_column_schema.db");
16395        {
16396            let writable = khive_db::StorageBackend::sqlite_for_test(&path).unwrap();
16397            writable.prepare_core_schema().unwrap();
16398            seed_column_schema(&writable);
16399        }
16400        #[cfg(unix)]
16401        khive_storage::test_support::freeze_snapshot_sidecars(&path);
16402        let backend = khive_db::StorageBackend::sqlite_read_only_for_test(&path).unwrap();
16403        let registry = column_schema_registry();
16404        let writes_before = backend.pool().writer_acquisition_snapshot();
16405
16406        let error = registry
16407            .apply_schema_plans_with_map(&HashMap::new(), &backend)
16408            .unwrap_err()
16409            .to_string();
16410        assert!(error.contains("alpha"), "{error}");
16411        assert!(error.contains("t_alpha.revision"), "{error}");
16412        assert!(
16413            error.contains("read-only schema validation failed"),
16414            "{error}"
16415        );
16416
16417        assert_eq!(backend.pool().writer_acquisition_snapshot(), writes_before);
16418        assert_eq!(column_schema_count(&backend, "t_alpha", "revision"), 0);
16419        assert_eq!(column_schema_count(&backend, "t_beta", "epoch"), 0);
16420    }
16421
16422    #[test]
16423    fn pack_column_upgrades_reject_cross_pack_addition_ownership_collision() {
16424        let backend = khive_db::StorageBackend::memory().unwrap();
16425        let mut builder = VerbRegistryBuilder::new();
16426        builder.register_boxed(Box::new(SchemaPack {
16427            pack_name: "alpha",
16428            statements: &["CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER)"],
16429            column_additions: &[],
16430        }));
16431        builder.register_boxed(Box::new(SchemaPack {
16432            pack_name: "beta",
16433            statements: &[],
16434            column_additions: &[PackColumnAddition {
16435                table: "t_alpha",
16436                column: "revision",
16437                affinity: PackColumnAffinity::Text,
16438            }],
16439        }));
16440        let registry = builder.build().unwrap();
16441        let error = registry
16442            .apply_schema_plans_with_map(&HashMap::new(), &backend)
16443            .unwrap_err();
16444        assert_eq!(error.pack_a, "alpha");
16445        assert_eq!(error.pack_b, "beta");
16446        assert_eq!(error.table, "t_alpha");
16447        assert_eq!(column_schema_count(&backend, "t_alpha", "revision"), 0);
16448    }
16449
16450    #[test]
16451    fn pack_column_upgrades_do_not_hide_duplicate_create_claims() {
16452        let backend = khive_db::StorageBackend::memory().unwrap();
16453        let mut builder = VerbRegistryBuilder::new();
16454        builder.register_boxed(Box::new(SchemaPack {
16455            pack_name: "alpha",
16456            statements: &[
16457                "CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER, revision TEXT)",
16458                "CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER, revision TEXT)",
16459            ],
16460            column_additions: &[PackColumnAddition {
16461                table: "t_alpha",
16462                column: "revision",
16463                affinity: PackColumnAffinity::Text,
16464            }],
16465        }));
16466        let registry = builder.build().unwrap();
16467        let error = registry
16468            .apply_schema_plans_with_map(&HashMap::new(), &backend)
16469            .unwrap_err();
16470        assert_eq!(error.pack_a, "alpha");
16471        assert_eq!(error.pack_b, "alpha");
16472        assert_eq!(error.table, "t_alpha");
16473        assert_eq!(column_schema_count(&backend, "t_alpha", "revision"), 0);
16474    }
16475}
16476
16477#[cfg(test)]
16478#[path = "gate_argument_contract_tests.rs"]
16479mod gate_argument_contract_tests;