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::collections::{HashMap, HashSet, VecDeque};
14use std::sync::Arc;
15use std::time::Instant;
16
17use crate::runtime::NamespaceToken;
18use async_trait::async_trait;
19use khive_gate::{AllowAllGate, AuditEvent, GateDecision, GateRef, GateRequest};
20use khive_storage::{Event, EventStore, EventView, SubstrateKind};
21use khive_types::{EventKind, EventOutcome, Namespace};
22use serde_json::Value;
23
24pub use khive_types::{
25    EdgeEndpointRule, EndpointKind, HandlerDef, NoteKindSpec, NoteLifecycleSpec, PackSchemaPlan,
26    ParamDef, VerbCategory, VerbPresentationPolicy, Visibility,
27};
28// Backward-compat re-export.
29#[allow(deprecated)]
30pub use khive_types::VerbDef;
31
32use crate::validation::ValidationRule;
33
34/// Pack-auxiliary schema plan.
35///
36/// Declares `CREATE TABLE IF NOT EXISTS` statements for pack-owned tables that
37/// are NOT part of the core substrate schema (entities, notes, edges, events).
38/// Applied at boot via `StorageBackend::apply_schema` / `apply_pack_schema_plan`.
39///
40/// Core substrate tables evolve through versioned migrations. Pack schema is
41/// strictly for pack-auxiliary tables (e.g. GTD lifecycle audit, memory index).
42/// v1 pack schemas are non-versioned.
43#[derive(Debug, Default, Clone)]
44pub struct SchemaPlan {
45    /// Owning pack name.
46    pub pack: &'static str,
47    /// DDL statements applied idempotently at boot.
48    /// Each entry must be a self-contained `CREATE TABLE IF NOT EXISTS` or
49    /// similar idempotent statement.
50    pub statements: &'static [&'static str],
51}
52
53impl SchemaPlan {
54    /// Construct a `SchemaPlan` with no statements.
55    ///
56    /// Packs whose state lives entirely in the core substrate tables (entities,
57    /// notes, edges) use this as their `schema_plan()` return value.
58    pub const fn empty() -> Self {
59        Self {
60            pack: "",
61            statements: &[],
62        }
63    }
64
65    /// Returns `true` when the plan contains no DDL statements.
66    pub fn is_empty(&self) -> bool {
67        self.statements.is_empty()
68    }
69}
70
71/// Hook called after every successful verb dispatch.
72///
73/// Packs observe enriched event views so provenance-aware consumers can use
74/// `view.observations` while legacy folds can still consume `view.event`.
75#[async_trait]
76pub trait DispatchHook: Send + Sync {
77    /// Called with the dispatch-outcome event view after a successful pack dispatch.
78    ///
79    /// Errors are logged via `tracing::warn!` and never propagated to the
80    /// caller; the dispatch has already succeeded.
81    async fn on_dispatch(&self, view: &EventView);
82}
83
84use crate::error::{
85    CircularPackDependency, MissingPackDependencies, MissingPackDependency, RuntimeError,
86};
87use crate::KhiveRuntime;
88
89/// Async dispatch trait for packs.
90///
91/// This is the object-safe behavioral counterpart to `khive_types::Pack`.
92/// `Pack` uses const associated items (not object-safe in Rust); this trait
93/// mirrors that metadata as methods and adds async dispatch.
94///
95/// Registration requires `P: Pack + PackRuntime` — the compiler enforces
96/// that every runtime pack also declares its vocabulary via `Pack`.
97#[async_trait]
98pub trait PackRuntime: Send + Sync {
99    /// Pack name — must equal `<Self as Pack>::NAME`.
100    fn name(&self) -> &str;
101
102    /// Note kinds this pack owns — must equal `<Self as Pack>::NOTE_KINDS`.
103    fn note_kinds(&self) -> &'static [&'static str];
104
105    /// Entity kinds this pack owns — must equal `<Self as Pack>::ENTITY_KINDS`.
106    fn entity_kinds(&self) -> &'static [&'static str];
107
108    /// Handlers this pack registers — must equal `<Self as Pack>::HANDLERS`.
109    fn handlers(&self) -> &'static [HandlerDef];
110
111    /// Pack-extensible edge endpoint rules — must equal `<Self as Pack>::EDGE_RULES`.
112    /// Defaults to empty so existing packs that don't extend the edge contract
113    /// can ignore it.
114    fn edge_rules(&self) -> &'static [EdgeEndpointRule] {
115        &[]
116    }
117
118    /// Pack names whose vocabulary this pack references.
119    /// Defaults to empty so existing packs compile without changes.
120    fn requires(&self) -> &'static [&'static str] {
121        &[]
122    }
123
124    /// NoteKindSpec declarations for note kinds this pack owns.
125    ///
126    /// Packs that introduce note kinds with explicit lifecycle semantics
127    /// declare the spec here.  The runtime collects these for introspection
128    /// and future enforcement.  Defaults to empty so existing packs compile
129    /// without changes.
130    fn note_kind_specs(&self) -> &'static [NoteKindSpec] {
131        &[]
132    }
133
134    /// Optional per-kind hook for shared CRUD specialization.
135    ///
136    /// When a kind is owned by this pack (declared in `note_kinds()` or
137    /// `entity_kinds()`), returning `Some(hook)` opts that kind into
138    /// pack-specific behavior — defaults, derived properties, side-effect
139    /// edges — through the shared `create` path. Returning `None` keeps
140    /// the kind as plain storage with no specialization.
141    fn kind_hook(&self, _kind: &str) -> Option<Arc<dyn KindHook>> {
142        None
143    }
144
145    /// Pack-auxiliary schema.
146    ///
147    /// Returns DDL statements for pack-owned tables that are NOT part of the
148    /// core substrate schema. Statements are idempotent (`CREATE TABLE IF NOT
149    /// EXISTS`) so callers can apply them safely on every registration. Core
150    /// substrate tables evolve through versioned migrations; pack schema is
151    /// strictly pack-auxiliary.
152    ///
153    /// Defaults to an empty plan — packs that store everything in the core
154    /// substrate tables (entities, notes, edges, events) return this default.
155    ///
156    /// Plans are aggregated via [`VerbRegistry::all_schema_plans`] and applied
157    /// at startup via `KhiveMcpServer::with_packs`. Packs that need their
158    /// schema present (e.g. GTD) also self-bootstrap lazily on first call for
159    /// robustness in test contexts that create fresh in-memory databases.
160    fn schema_plan(&self) -> SchemaPlan {
161        SchemaPlan::empty()
162    }
163
164    /// Domain-specific validation rules contributed by this pack.
165    ///
166    /// Rule IDs MUST follow the `<pack>/<rule-id>` namespace convention.
167    /// Built-in rules (no pack prefix) are reserved for the `khive-runtime`
168    /// validation infrastructure.
169    ///
170    /// Defaults to empty — packs with no domain-specific rules return `&[]`.
171    fn validation_rules(&self) -> &'static [ValidationRule] {
172        &[]
173    }
174
175    /// Register custom embedding providers with the runtime.
176    ///
177    /// Called by the transport during pack initialisation, before the first verb
178    /// dispatch, so that `KhiveRuntime::embedder(name)` resolves provider names
179    /// declared here.
180    ///
181    /// Implement this method to contribute non-lattice embedding backends:
182    ///
183    /// ```ignore
184    /// fn register_embedders(&self, runtime: &KhiveRuntime) {
185    ///     runtime.register_embedder(MyCustomProvider::new());
186    /// }
187    /// ```
188    ///
189    /// The default no-op preserves backwards compatibility — packs that only
190    /// use built-in lattice models do not need to override this method.
191    fn register_embedders(&self, _runtime: &KhiveRuntime) {}
192
193    /// Install a pack-owned entity-type validator on the runtime.
194    ///
195    /// Called by the transport during pack initialisation, after the registry
196    /// is built and before the first verb dispatch, so that `create_many` and
197    /// `create_entity` reject unregistered `entity_type` values at the runtime
198    /// layer in addition to the handler layer.
199    ///
200    /// Packs that own `EntityTypeRegistry` vocabularies (e.g. `KgPack`) should
201    /// override this to install their registry's `resolve` function.  The
202    /// default no-op leaves the runtime validator absent (skip-when-None), which
203    /// is the correct behaviour for bare runtimes without packs.
204    fn register_entity_type_validator(&self, _runtime: &KhiveRuntime) {}
205
206    /// Install a pack-owned note-mutation hook on the runtime.
207    ///
208    /// Called by the transport during pack initialisation, after the registry
209    /// is built and before the first verb dispatch — same timing as
210    /// `register_entity_type_validator`. Packs that cache derived state keyed
211    /// by note content (e.g. `khive-pack-memory`'s warm ANN index) should
212    /// override this to install a hook via `KhiveRuntime::install_note_mutation_hook`,
213    /// so `update_note`/`delete_note` notify them even when the mutation
214    /// arrived through a different pack's verb that has no dependency on the
215    /// reacting pack (e.g. KG's `update`/`delete` on a `kind="memory"` note).
216    ///
217    /// The default no-op leaves the runtime hook absent (skip-when-None),
218    /// which is the correct behaviour for packs that don't cache note-derived
219    /// state and for bare runtimes without packs.
220    fn register_note_mutation_hook(&self, _runtime: &KhiveRuntime) {}
221
222    /// Warm up any in-memory state from persisted snapshots (optional).
223    ///
224    /// Called by the transport after all packs are registered but before
225    /// serving the first request, giving packs a chance to pre-load expensive
226    /// in-memory structures (e.g. ANN indexes) so that the first query does
227    /// not incur rebuild latency.
228    ///
229    /// The default no-op is correct for all packs that have no warm-start
230    /// state. Packs that override this must make it idempotent and infallible:
231    /// any errors are logged internally, not propagated to the caller.
232    async fn warm(&self) {}
233
234    /// Dispatch a verb call. Returns serialized JSON response.
235    ///
236    /// The `registry` parameter gives the handler access to the merged
237    /// vocabulary and kind hooks across all loaded packs.
238    /// The `token` is an authorized namespace token minted by the dispatch
239    /// boundary after gate authorization — handlers must use it directly.
240    async fn dispatch(
241        &self,
242        verb: &str,
243        params: Value,
244        registry: &VerbRegistry,
245        token: &NamespaceToken,
246    ) -> Result<Value, RuntimeError>;
247}
248
249/// Per-kind specialization for shared CRUD.
250///
251/// Packs implement `KindHook` for kinds they own that need:
252/// - **Defaults** filled into create args (e.g. `status="inbox"` for tasks)
253/// - **Derived properties** computed from args (e.g. salience from priority)
254/// - **Side-effect writes** after the storage commit (e.g. `depends_on` edges)
255///
256/// Hooks are stateless from the framework's perspective — they receive the
257/// runtime as a method parameter and operate on the args `Value` directly.
258/// The pack registers them via [`PackRuntime::kind_hook`].
259///
260/// Lifecycle verbs (e.g. gtd's `complete`, `transition`) remain pack-owned
261/// verbs and do not flow through this trait — only the create path does.
262#[async_trait]
263pub trait KindHook: Send + Sync + std::fmt::Debug {
264    /// Mutate args before the storage write. Fill defaults, normalize values,
265    /// rearrange user-facing fields into the storage shape expected by the
266    /// shared CRUD handler.
267    ///
268    /// Returning an error aborts the create call (no storage write happens).
269    async fn prepare_create(
270        &self,
271        runtime: &KhiveRuntime,
272        args: &mut Value,
273    ) -> Result<(), RuntimeError>;
274
275    /// Fire side effects after a successful storage write — graph edges,
276    /// derived observations, etc. The newly created record's UUID is passed
277    /// so the hook can attach metadata referencing it.
278    ///
279    /// Errors here are **logged but not propagated** — the storage write has
280    /// already succeeded; failing the call would mislead the caller.
281    /// Implementations should `tracing::warn!` and return `Ok(())` for
282    /// best-effort side effects.
283    async fn after_create(
284        &self,
285        runtime: &KhiveRuntime,
286        id: uuid::Uuid,
287        args: &Value,
288    ) -> Result<(), RuntimeError>;
289}
290
291/// Optional sub-trait for packs that own private SQL tables and issue UUIDs
292/// that must be reachable through the generic `get(id)` and `delete(id)` verbs.
293///
294/// Implementing both methods is required — the sub-trait bundles them atomically
295/// so partial implementation is a compile-time error, not a runtime surprise.
296/// Packs whose records live in the shared entity/note substrate (gtd, memory)
297/// do not implement this sub-trait.
298#[async_trait]
299pub trait PackByIdResolver: Send + Sync {
300    /// Attempt to resolve a live (non-deleted) UUID owned by this pack's private tables.
301    ///
302    /// Returns `Some(Resolved::PackRecord { ... })` if this pack owns the UUID,
303    /// `None` if it does not (the caller continues to the next resolver),
304    /// or `Err(...)` on a storage error.
305    ///
306    /// Must query domain-authoritative tables before mirror tables.
307    /// Must NOT filter by namespace. UUID v4 is globally unique; by-ID
308    /// resolution is namespace-blind per ADR-007.
309    async fn resolve_by_id(
310        &self,
311        id: uuid::Uuid,
312    ) -> Result<Option<crate::Resolved>, crate::RuntimeError>;
313
314    /// Attempt to resolve a UUID including already-soft-deleted records.
315    ///
316    /// Used by the hard-delete path. Default delegates to `resolve_by_id`;
317    /// packs with `deleted_at` columns override this to query without the filter.
318    async fn resolve_by_id_including_deleted(
319        &self,
320        id: uuid::Uuid,
321    ) -> Result<Option<crate::Resolved>, crate::RuntimeError> {
322        self.resolve_by_id(id).await
323    }
324
325    /// Delete a record owned by this pack's private tables.
326    ///
327    /// `hard` mirrors the `delete` verb's `hard?` argument.
328    /// Default behavior for packs with a `deleted_at` column MUST be soft-delete;
329    /// `hard=true` performs permanent removal.
330    ///
331    /// Returns `Ok(Value)` with a `{ deleted: true, id, kind, hard }` body on success.
332    /// Returns `Err(RuntimeError::NotFound(...))` if the record does not exist.
333    async fn delete_by_id(
334        &self,
335        id: uuid::Uuid,
336        hard: bool,
337    ) -> Result<serde_json::Value, crate::RuntimeError>;
338}
339
340/// Builder for constructing a `VerbRegistry`.
341///
342/// Packs are registered here; once `.build()` is called the registry is
343/// immutable and cheaply cloneable.
344pub struct VerbRegistryBuilder {
345    packs: Vec<Box<dyn PackRuntime>>,
346    resolvers: Vec<(String, Box<dyn PackByIdResolver>)>,
347    gate: GateRef,
348    default_namespace: String,
349    /// Operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
350    ///
351    /// Threads into `VerbRegistry::visible_namespaces` and is consumed by the
352    /// default dispatch path to widen read scope to `['local'] ∪ visible_namespaces`.
353    /// Writes remain pinned to `'local'`. An explicit `namespace=` request param
354    /// is a precise escape and is not widened by this set. A cloud gate may also
355    /// consult the list as policy input at its own layer.
356    visible_namespaces: Vec<Namespace>,
357    /// Configured actor identity label (ADR-057). When set, dispatch mints tokens
358    /// carrying this actor so that `comm.inbox` filters by `to_actor`.
359    actor_id: Option<String>,
360    /// Optional audit event sink.
361    ///
362    /// When set, every gate check writes a storage `Event` in addition to the
363    /// `tracing::info!` emission. The store is `Arc<dyn EventStore>` so the
364    /// registry does not depend on the full `KhiveRuntime` surface — only the
365    /// audit-persistence capability is needed here.
366    event_store: Option<Arc<dyn EventStore>>,
367    /// Optional post-dispatch hook.
368    ///
369    /// When set, every successful pack dispatch calls `hook.on_dispatch(event)`
370    /// with a synthesized Event describing the outcome. Opt-in: when None,
371    /// no overhead is incurred.
372    dispatch_hook: Option<Arc<dyn DispatchHook>>,
373}
374
375impl VerbRegistryBuilder {
376    /// Create a builder with no packs, `AllowAllGate`, and the local namespace as default.
377    pub fn new() -> Self {
378        Self {
379            packs: Vec::new(),
380            resolvers: Vec::new(),
381            gate: std::sync::Arc::new(AllowAllGate),
382            default_namespace: Namespace::local().as_str().to_string(),
383            visible_namespaces: vec![],
384            actor_id: None,
385            event_store: None,
386            dispatch_hook: None,
387        }
388    }
389
390    /// Set the operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
391    ///
392    /// On the default (no explicit `namespace=` param) dispatch path, reads fan
393    /// out over `['local'] ∪ ns`. Writes remain pinned to `'local'`. An explicit
394    /// `namespace=` request parameter is a precise single-namespace escape and
395    /// is not widened by this set. A cloud gate may also consult the list as
396    /// policy input at its own layer.
397    pub fn with_visible_namespaces(&mut self, ns: Vec<Namespace>) -> &mut Self {
398        self.visible_namespaces = ns;
399        self
400    }
401
402    /// Set the configured actor identity label (ADR-057).
403    ///
404    /// When set, the dispatch path mints tokens carrying this actor so that
405    /// `comm.inbox` applies the `to_actor` filter for directed delivery.
406    /// When `None` (default), tokens carry `ActorRef::anonymous()` and inbox
407    /// falls back to party-line behavior.
408    pub fn with_actor_id(&mut self, actor_id: Option<String>) -> &mut Self {
409        self.actor_id = actor_id;
410        self
411    }
412
413    /// Register a pack. The bound `P: Pack + PackRuntime` ensures the pack
414    /// declares vocabulary via `Pack` consts alongside runtime dispatch.
415    pub fn register<P: khive_types::Pack + PackRuntime + 'static>(&mut self, pack: P) -> &mut Self {
416        self.packs.push(Box::new(pack));
417        self
418    }
419
420    /// Register a boxed pack directly.
421    ///
422    /// Crate-private: only [`PackRegistry::register_packs`] should call this.
423    /// External callers must use the typed [`Self::register`] which enforces the
424    /// `Pack + PackRuntime` dual-impl contract at the call site.  Here the
425    /// contract is satisfied upstream at the [`PackFactory::create`] site.
426    pub(crate) fn register_boxed(&mut self, pack: Box<dyn PackRuntime>) -> &mut Self {
427        self.packs.push(pack);
428        self
429    }
430
431    /// Register a by-ID resolver for a pack that owns private SQL tables.
432    ///
433    /// Packs that implement `PackByIdResolver` call this during their boot path
434    /// so that `get(id)` and `delete(id)` can reach their records.
435    pub fn register_resolver(
436        &mut self,
437        name: impl Into<String>,
438        resolver: Box<dyn PackByIdResolver>,
439    ) -> &mut Self {
440        self.resolvers.push((name.into(), resolver));
441        self
442    }
443
444    /// Set the authorization gate consulted on every dispatch.
445    ///
446    /// Defaults to `AllowAllGate` if not set. `Deny` is authoritative — a deny
447    /// decision aborts dispatch with `RuntimeError::PermissionDenied`. Gate
448    /// infrastructure errors fail open (logged via `tracing::warn!`, dispatch
449    /// proceeds).
450    pub fn with_gate(&mut self, gate: GateRef) -> &mut Self {
451        self.gate = gate;
452        self
453    }
454
455    /// Set the namespace surfaced to the gate when a verb does not carry an
456    /// explicit `namespace` argument. Transports should plumb the runtime's
457    /// `default_namespace` so the gate's `input.namespace` always reflects
458    /// the operation's true tenant.
459    pub fn with_default_namespace(&mut self, ns: impl Into<String>) -> &mut Self {
460        self.default_namespace = ns.into();
461        self
462    }
463
464    /// Set the `EventStore` used to persist audit events.
465    ///
466    /// When configured, every gate check appends one `Event` (substrate =
467    /// `Event`, outcome = `Success` on allow, `Denied` on deny) in addition to
468    /// the `tracing::info!` emission that was already present in v0.2.
469    ///
470    /// Callers that do not set this field continue to use tracing-only emission
471    /// (the v0.2 default). There is no behavior change for them.
472    pub fn with_event_store(&mut self, store: Arc<dyn EventStore>) -> &mut Self {
473        self.event_store = Some(store);
474        self
475    }
476
477    /// Register a post-dispatch hook.
478    ///
479    /// When set, every successful pack dispatch calls `hook.on_dispatch(event)`
480    /// with a synthesized [`Event`] describing the verb outcome. The hook is
481    /// opt-in: registries without a hook incur zero overhead on the dispatch
482    /// hot path.
483    ///
484    /// Brain pack uses this to update its posteriors in real time without
485    /// polling the EventStore. Errors from `on_dispatch` are logged via
486    /// `tracing::warn!` and never propagated.
487    pub fn with_dispatch_hook(&mut self, hook: Arc<dyn DispatchHook>) -> &mut Self {
488        self.dispatch_hook = Some(hook);
489        self
490    }
491
492    /// Consume the builder and produce an immutable, cloneable registry.
493    ///
494    /// Performs a topological sort of packs using Kahn's algorithm.
495    /// Returns an error if any declared dependency is missing from the loaded
496    /// pack set, or if a circular dependency is detected.
497    pub fn build(self) -> Result<VerbRegistry, RuntimeError> {
498        let packs = self.packs;
499        let mut name_to_idx: HashMap<&str, usize> = HashMap::with_capacity(packs.len());
500        for (idx, pack) in packs.iter().enumerate() {
501            if let Some(prev_idx) = name_to_idx.insert(pack.name(), idx) {
502                return Err(RuntimeError::PackRedeclared {
503                    name: pack.name().to_string(),
504                    first_idx: prev_idx,
505                    second_idx: idx,
506                });
507            }
508        }
509
510        let mut missing: Vec<MissingPackDependency> = Vec::new();
511        let mut indegree = vec![0usize; packs.len()];
512        let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); packs.len()];
513
514        for (idx, pack) in packs.iter().enumerate() {
515            for &requires in pack.requires() {
516                match name_to_idx.get(requires).copied() {
517                    Some(dep_idx) => {
518                        dependents[dep_idx].push(idx);
519                        indegree[idx] += 1;
520                    }
521                    None => missing.push(MissingPackDependency {
522                        from: pack.name().to_string(),
523                        requires: requires.to_string(),
524                    }),
525                }
526            }
527        }
528
529        if !missing.is_empty() {
530            return if missing.len() == 1 {
531                Err(RuntimeError::MissingPackDependency(missing.remove(0)))
532            } else {
533                Err(RuntimeError::MissingPackDependencies(
534                    MissingPackDependencies { missing },
535                ))
536            };
537        }
538
539        let mut ready: VecDeque<usize> = indegree
540            .iter()
541            .enumerate()
542            .filter_map(|(idx, degree)| (*degree == 0).then_some(idx))
543            .collect();
544        let mut ordered_indices = Vec::with_capacity(packs.len());
545
546        while let Some(idx) = ready.pop_front() {
547            ordered_indices.push(idx);
548            for &dep_idx in &dependents[idx] {
549                indegree[dep_idx] -= 1;
550                if indegree[dep_idx] == 0 {
551                    ready.push_back(dep_idx);
552                }
553            }
554        }
555
556        if ordered_indices.len() != packs.len() {
557            let cycle_nodes: HashSet<usize> = indegree
558                .iter()
559                .enumerate()
560                .filter_map(|(idx, degree)| (*degree > 0).then_some(idx))
561                .collect();
562            let cycle = find_pack_dependency_cycle(&packs, &name_to_idx, &cycle_nodes);
563            return Err(RuntimeError::CircularPackDependency(
564                CircularPackDependency { cycle },
565            ));
566        }
567
568        let mut slots: Vec<Option<Box<dyn PackRuntime>>> = packs.into_iter().map(Some).collect();
569        let ordered_packs: Vec<Box<dyn PackRuntime>> = ordered_indices
570            .into_iter()
571            .map(|idx| slots[idx].take().expect("topological index must exist"))
572            .collect();
573
574        validate_unique_note_kinds(&ordered_packs)?;
575        validate_unique_verb_names(&ordered_packs)?;
576
577        let available_verbs: Vec<&'static str> = ordered_packs
578            .iter()
579            .flat_map(|p| p.handlers().iter())
580            .filter(|h| matches!(h.visibility, Visibility::Verb))
581            .map(|h| h.name)
582            .collect();
583
584        Ok(VerbRegistry {
585            packs: Arc::new(ordered_packs),
586            resolvers: Arc::new(self.resolvers),
587            gate: self.gate,
588            default_namespace: self.default_namespace,
589            visible_namespaces: self.visible_namespaces,
590            actor_id: self.actor_id,
591            event_store: self.event_store,
592            dispatch_hook: self.dispatch_hook,
593            available_verbs: Arc::new(available_verbs),
594            reference_ring: Arc::new(crate::reference_ring::ReferenceRing::new()),
595        })
596    }
597}
598
599/// Validate that no two packs declare the same note kind.
600///
601/// Boot-time duplicate detection prevents pack configuration errors from
602/// silently corrupting note kind routing. Returns an error naming the
603/// duplicate kind and the two packs that claim it.
604fn validate_unique_note_kinds(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
605    let mut seen: HashMap<&str, &str> = HashMap::new();
606    for pack in packs {
607        for &kind in pack.note_kinds() {
608            if let Some(first_pack) = seen.insert(kind, pack.name()) {
609                return Err(RuntimeError::InvalidInput(format!(
610                    "duplicate note kind {kind:?}: claimed by both {first_pack:?} and {:?}",
611                    pack.name()
612                )));
613            }
614        }
615    }
616    Ok(())
617}
618
619/// Validate that no two packs declare the same `Visibility::Verb` handler name.
620///
621/// `Visibility::Subhandler` entries are pack-prefixed by convention and excluded
622/// from cross-pack collision detection. Two packs declaring the same subhandler
623/// name prefix (e.g. `recall.embed`) would be a pack-authoring error but does not
624/// produce a cross-pack routing conflict since only the owning pack dispatches them.
625fn validate_unique_verb_names(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
626    let mut seen: HashMap<&str, &str> = HashMap::new();
627    for pack in packs {
628        for handler in pack.handlers() {
629            if !matches!(handler.visibility, Visibility::Verb) {
630                continue;
631            }
632            if let Some(first_pack) = seen.insert(handler.name, pack.name()) {
633                return Err(RuntimeError::VerbCollision {
634                    verb: handler.name.to_string(),
635                    first_pack: first_pack.to_string(),
636                    second_pack: pack.name().to_string(),
637                });
638            }
639        }
640    }
641    Ok(())
642}
643
644fn find_pack_dependency_cycle(
645    packs: &[Box<dyn PackRuntime>],
646    name_to_idx: &HashMap<&str, usize>,
647    cycle_nodes: &HashSet<usize>,
648) -> Vec<String> {
649    fn visit(
650        idx: usize,
651        packs: &[Box<dyn PackRuntime>],
652        name_to_idx: &HashMap<&str, usize>,
653        cycle_nodes: &HashSet<usize>,
654        visiting: &mut Vec<usize>,
655        visited: &mut HashSet<usize>,
656    ) -> Option<Vec<String>> {
657        if let Some(pos) = visiting.iter().position(|&seen| seen == idx) {
658            let mut cycle: Vec<String> = visiting[pos..]
659                .iter()
660                .map(|&i| packs[i].name().to_string())
661                .collect();
662            cycle.push(packs[idx].name().to_string());
663            return Some(cycle);
664        }
665        if !visited.insert(idx) {
666            return None;
667        }
668        visiting.push(idx);
669        for &req in packs[idx].requires() {
670            let Some(&dep_idx) = name_to_idx.get(req) else {
671                continue;
672            };
673            if cycle_nodes.contains(&dep_idx) {
674                if let Some(cycle) =
675                    visit(dep_idx, packs, name_to_idx, cycle_nodes, visiting, visited)
676                {
677                    return Some(cycle);
678                }
679            }
680        }
681        visiting.pop();
682        None
683    }
684
685    let mut visited = HashSet::new();
686    for &idx in cycle_nodes {
687        let mut visiting = Vec::new();
688        if let Some(cycle) = visit(
689            idx,
690            packs,
691            name_to_idx,
692            cycle_nodes,
693            &mut visiting,
694            &mut visited,
695        ) {
696            return cycle;
697        }
698    }
699    cycle_nodes
700        .iter()
701        .map(|&idx| packs[idx].name().to_string())
702        .collect()
703}
704
705impl Default for VerbRegistryBuilder {
706    fn default() -> Self {
707        Self::new()
708    }
709}
710
711/// Immutable registry that dispatches verb calls to registered packs.
712///
713/// Clone is cheap (Arc-wrapped). Constructed via `VerbRegistryBuilder`.
714#[derive(Clone)]
715pub struct VerbRegistry {
716    packs: std::sync::Arc<Vec<Box<dyn PackRuntime>>>,
717    /// Pack-level by-ID resolvers, in registration order.
718    resolvers: std::sync::Arc<Vec<(String, Box<dyn PackByIdResolver>)>>,
719    gate: GateRef,
720    default_namespace: String,
721    /// Operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
722    ///
723    /// On the default (no explicit `namespace=` param) dispatch path, reads fan
724    /// out over `['local'] ∪ visible_namespaces`. Writes are unaffected — they
725    /// still pin to `'local'`. An explicit `namespace=` request param is a
726    /// precise single-namespace escape and is not widened by this set.
727    visible_namespaces: Vec<Namespace>,
728    /// Configured actor identity label (ADR-057). When `Some`, dispatch mints
729    /// tokens carrying this actor so that `comm.inbox` applies the `to_actor`
730    /// filter. When `None`, tokens carry `ActorRef::anonymous()` (party-line).
731    actor_id: Option<String>,
732    /// Audit event sink — `None` means tracing-only (v0.2 default).
733    event_store: Option<Arc<dyn EventStore>>,
734    /// Post-dispatch hook: `None` means no real-time observation.
735    dispatch_hook: Option<Arc<dyn DispatchHook>>,
736    /// Names of all `Visibility::Verb` handlers across all packs, precomputed
737    /// once at `build()` time. Used only to render the unknown-verb error
738    /// message — the pack set is fixed after construction, so there is no
739    /// need to re-scan every pack's handlers on every miss.
740    available_verbs: Arc<Vec<&'static str>>,
741    /// Recently-referenced ring (unified-verb draft ADR, Slice 1). Daemon-warm,
742    /// actor-scoped, never persisted — see `crate::reference_ring`. Shared
743    /// across every clone of this registry via the `Arc`, so admissions made
744    /// by one dispatch are visible to the next on the same warm daemon.
745    reference_ring: Arc<crate::reference_ring::ReferenceRing>,
746}
747
748/// Per-request identity context that overrides a [`VerbRegistry`]'s
749/// construction-baked `default_namespace` / `actor_id` / `visible_namespaces`
750/// for exactly one [`VerbRegistry::dispatch_with_identity`] call (ADR-096
751/// Fork 1 — warm-daemon per-request identity).
752///
753/// A single warm registry is built once with a baked identity, but must be
754/// able to serve requests whose caller resolved a *different* attribution
755/// identity (e.g. a different project-local `[actor]`) without a cold
756/// fallback and without mis-stamping writes under the registry's own baked
757/// actor. Supplying `Some(RequestIdentity { .. })` threads the caller's
758/// identity through token minting for that one call; the registry's fields
759/// (and every other in-flight call) are untouched. `None` is exactly
760/// [`VerbRegistry::dispatch`] — the baked scalars apply, unchanged from
761/// before this type existed.
762#[derive(Debug, Clone, Default)]
763pub struct RequestIdentity {
764    /// Storage/gate default namespace for this request (used when the verb's
765    /// own params carry no explicit `namespace` field). Overrides
766    /// `VerbRegistry::default_namespace`.
767    pub namespace: String,
768    /// Write-stamp / gate actor label for this request (ADR-057). Overrides
769    /// `VerbRegistry::actor_id`. `None` mints `ActorRef::anonymous()`, same
770    /// as an unconfigured baked `actor_id`.
771    pub actor_id: Option<String>,
772    /// Extra read-visibility namespaces for this request (ADR-007 Rev 4 Rule
773    /// 3b). Overrides `VerbRegistry::visible_namespaces`. Entries that fail
774    /// `Namespace::parse` are skipped with a `tracing::warn!` rather than
775    /// failing the whole request — a single malformed visibility entry from a
776    /// caller-supplied frame must not block dispatch.
777    pub visible_namespaces: Vec<String>,
778}
779
780/// Error returned by [`VerbRegistry::apply_schema_plans_with_map`] when two
781/// packs on the same backend declare the same auxiliary table (ADR-028 §7).
782#[derive(Debug)]
783pub struct PackSchemaCollisionError {
784    /// First pack to declare the table.
785    pub pack_a: &'static str,
786    /// Second pack that collides with `pack_a`.
787    pub pack_b: &'static str,
788    /// Table name or DDL error description.
789    pub table: String,
790}
791
792impl std::fmt::Display for PackSchemaCollisionError {
793    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794        if self.pack_a == self.pack_b {
795            write!(
796                f,
797                "pack schema boot failure for pack {:?}: {}",
798                self.pack_a, self.table
799            )
800        } else {
801            write!(
802                f,
803                "pack schema collision: packs {:?} and {:?} both declare table {:?} \
804                 on the same backend — move one pack to a separate backend or rename the table",
805                self.pack_a, self.pack_b, self.table
806            )
807        }
808    }
809}
810
811impl std::error::Error for PackSchemaCollisionError {}
812
813/// Extract table names from a single DDL statement.
814///
815/// Handles `CREATE TABLE IF NOT EXISTS`, `CREATE TABLE`, and
816/// `CREATE VIRTUAL TABLE IF NOT EXISTS`, `CREATE VIRTUAL TABLE`.
817/// Returns an empty Vec when no table name is found (e.g. index DDL).
818fn extract_table_names(stmt: &str) -> Vec<String> {
819    let normalized = stmt.split_whitespace().collect::<Vec<_>>().join(" ");
820    let upper = normalized.to_ascii_uppercase();
821    let table_name = if let Some(rest) = upper.strip_prefix("CREATE VIRTUAL TABLE IF NOT EXISTS ") {
822        rest.split_whitespace().next()
823    } else if let Some(rest) = upper.strip_prefix("CREATE VIRTUAL TABLE ") {
824        rest.split_whitespace().next()
825    } else if let Some(rest) = upper.strip_prefix("CREATE TABLE IF NOT EXISTS ") {
826        rest.split_whitespace().next()
827    } else if let Some(rest) = upper.strip_prefix("CREATE TABLE ") {
828        rest.split_whitespace().next()
829    } else {
830        None
831    };
832    match table_name {
833        Some(name) => {
834            let clean = name.trim_matches(|c: char| c == '(' || c == ';');
835            if clean.is_empty() {
836                vec![]
837            } else {
838                vec![clean.to_ascii_lowercase()]
839            }
840        }
841        None => vec![],
842    }
843}
844
845impl VerbRegistry {
846    /// This registry's construction-baked default namespace.
847    ///
848    /// Used as the fallback when a request carries no [`RequestIdentity`]
849    /// override (ADR-096 Fork 1) and by transports that need to advertise
850    /// their own resolved identity when forwarding to a warm daemon.
851    pub fn default_namespace(&self) -> &str {
852        &self.default_namespace
853    }
854
855    /// This registry's construction-baked actor identity label, if configured
856    /// (ADR-057). `None` means dispatch mints `ActorRef::anonymous()` absent a
857    /// per-request [`RequestIdentity`] override (ADR-096 Fork 1).
858    pub fn actor_id(&self) -> Option<&str> {
859        self.actor_id.as_deref()
860    }
861
862    /// This registry's construction-baked extra read-visibility namespaces
863    /// (ADR-007 Rev 4 Rule 3b), used absent a per-request [`RequestIdentity`]
864    /// override (ADR-096 Fork 1).
865    pub fn visible_namespaces(&self) -> &[Namespace] {
866        &self.visible_namespaces
867    }
868
869    /// This registry's configured audit `EventStore`, if any (ADR-094).
870    ///
871    /// Lets background tasks that hold a `VerbRegistry` but do not go through
872    /// `dispatch` (e.g. the email channel poll loop) append best-effort
873    /// lifecycle events to the same sink gate-check audit rows use, without
874    /// threading a second `Option<Arc<dyn EventStore>>` field through every
875    /// caller. `None` means tracing-only, matching the registry's own
876    /// audit-persistence default.
877    pub fn event_store(&self) -> Option<Arc<dyn EventStore>> {
878        self.event_store.clone()
879    }
880
881    /// Return the help schema envelope for a verb.
882    ///
883    /// Walks registered packs for the first matching `HandlerDef` and returns a
884    /// structured JSON envelope. Subhandlers carry `callable_via_mcp: false`.
885    /// Unknown verbs return `RuntimeError::InvalidInput`. Full shape documented
886    /// in `docs/protocol.md` §Request Schema.
887    pub fn describe_verb(&self, verb: &str) -> Result<Value, RuntimeError> {
888        for pack in self.packs.iter() {
889            for handler in pack.handlers().iter() {
890                if handler.name == verb {
891                    let category = format!("{:?}", handler.category);
892                    let params_arr: Vec<Value> = handler
893                        .params
894                        .iter()
895                        .map(|p| {
896                            serde_json::json!({
897                                "name": p.name,
898                                "type": p.param_type,
899                                "required": p.required,
900                                "description": p.description,
901                            })
902                        })
903                        .collect();
904                    // Subhandlers are not callable via the MCP request surface;
905                    // the help payload must match the behaviour the dispatch
906                    // path enforces so callers reading `help=true` before
907                    // probing see accurate availability.
908                    if matches!(handler.visibility, Visibility::Subhandler) {
909                        return Ok(serde_json::json!({
910                            "verb": verb,
911                            "pack": pack.name(),
912                            "description": handler.description,
913                            "category": category,
914                            "params": params_arr,
915                            "visibility": "internal",
916                            "callable_via_mcp": false,
917                            "note": "This is an internal subhandler. Calling it via the MCP \
918                                     request surface returns permission denied. It can only be \
919                                     invoked by internal runtime callers.",
920                        }));
921                    }
922                    return Ok(serde_json::json!({
923                        "verb": verb,
924                        "pack": pack.name(),
925                        "description": handler.description,
926                        "category": category,
927                        "params": params_arr,
928                    }));
929                }
930            }
931        }
932        // Verb-visibility handler names, precomputed at build() time (internal
933        // subhandlers are excluded so they are not advertised in the
934        // unknown-verb error).
935        Err(RuntimeError::InvalidInput(format!(
936            "unknown verb {verb:?}; available: {}",
937            self.available_verbs.join(", ")
938        )))
939    }
940
941    /// Check whether the gate permits writes into `ns`.
942    ///
943    /// Performs a gate evaluation with verb `"authorize"` before any background
944    /// loop is spawned (ADR-056 §6).  Returns `Ok(())` when the gate allows the
945    /// namespace, or `Err(RuntimeError::PermissionDenied{..})` when denied.
946    /// Gate errors (implementation failures) are surfaced as
947    /// `RuntimeError::Internal`.
948    pub fn authorize_namespace(&self, ns: Namespace) -> Result<(), RuntimeError> {
949        let actor = crate::actor_identity::resolve_actor(self.actor_id.as_deref());
950        let req = GateRequest::new(actor, ns, "authorize", serde_json::Value::Null);
951        match self.gate.check(&req) {
952            Ok(decision) if decision.is_allow() => Ok(()),
953            Ok(GateDecision::Deny { reason }) => Err(RuntimeError::PermissionDenied {
954                verb: "authorize".to_string(),
955                reason,
956            }),
957            Ok(_) => Err(RuntimeError::PermissionDenied {
958                verb: "authorize".to_string(),
959                reason: "gate denied".to_string(),
960            }),
961            Err(e) => Err(RuntimeError::Internal(format!("gate error: {e}"))),
962        }
963    }
964
965    /// Dispatch a verb to the first pack that handles it.
966    ///
967    /// Routes through the gate, then invokes the matching pack handler. When
968    /// `params["help"] == true`, short-circuits to `describe_verb` with no side effects.
969    /// Gate errors are fail-open. Full dispatch flow documented in `docs/protocol.md`.
970    ///
971    /// Equivalent to `self.dispatch_with_identity(verb, params, None)` — uses
972    /// this registry's construction-baked `default_namespace` / `actor_id` /
973    /// `visible_namespaces`.
974    pub async fn dispatch(&self, verb: &str, params: Value) -> Result<Value, RuntimeError> {
975        self.dispatch_with_identity(verb, params, None).await
976    }
977
978    /// Dispatch a verb, optionally overriding this registry's baked identity
979    /// scalars for exactly this call (ADR-096 Fork 1).
980    ///
981    /// `identity = None` behaves exactly like [`Self::dispatch`]. `identity =
982    /// Some(id)` uses `id.namespace` / `id.actor_id` / `id.visible_namespaces`
983    /// in place of `self.default_namespace` / `self.actor_id` /
984    /// `self.visible_namespaces` for this call's namespace resolution, gate
985    /// request, and token minting — the registry's own fields are never
986    /// mutated, so concurrent calls with different (or no) identity are
987    /// independent. This is what lets one warm registry correctly serve
988    /// requests from many attribution identities over the same shared
989    /// backend (same db, same warm ANN indexes) instead of rejecting or
990    /// silently dispatching under its own baked identity.
991    pub async fn dispatch_with_identity(
992        &self,
993        verb: &str,
994        params: Value,
995        identity: Option<RequestIdentity>,
996    ) -> Result<Value, RuntimeError> {
997        // help=true interception: short-circuit before gate/pack.
998        if params.get("help").and_then(Value::as_bool) == Some(true) {
999            return self.describe_verb(verb);
1000        }
1001        // Resolve namespace before `params` is moved into pack.dispatch, so the
1002        // post-dispatch hook can reference it.
1003        //
1004        // Absent `namespace` and a present-but-malformed `namespace` are
1005        // different cases. A present non-string value (null, number, bool,
1006        // array, object) is explicit caller input that failed to parse and
1007        // must fail closed, not silently coerce to the default namespace.
1008        // Only a genuinely absent key defaults. Shared with the multi-backend
1009        // coordinator intercept via `resolve_explicit_namespace` so every MCP
1010        // ingress path applies the same fail-closed rule.
1011        let explicit_namespace = params.get("namespace").is_some_and(Value::is_string);
1012        // A supplied per-request identity overrides the baked
1013        // default_namespace/actor_id/visible_namespaces for this call only.
1014        let default_namespace_str: &str = identity
1015            .as_ref()
1016            .map(|id| id.namespace.as_str())
1017            .unwrap_or(self.default_namespace.as_str());
1018        let ns = resolve_explicit_namespace(&params, default_namespace_str)?;
1019        let actor_id_str: Option<&str> = match identity.as_ref() {
1020            Some(id) => id.actor_id.as_deref(),
1021            None => self.actor_id.as_deref(),
1022        };
1023        // Thread the configured actor identity into the gate request so the
1024        // gate can distinguish human vs agent callers at the dispatch seam.
1025        // Resolved once via the shared actor-identity policy and reused for
1026        // token minting below, so the gate's notion of "who is the caller"
1027        // and the storage token's notion can never drift apart.
1028        let resolved_actor = crate::actor_identity::resolve_actor(actor_id_str);
1029        let gate_req = GateRequest::new(resolved_actor.clone(), ns.clone(), verb, params.clone());
1030
1031        // Consult the gate.
1032        //
1033        // - Ok(Allow) → proceed to pack dispatch (tracing + optional EventStore).
1034        // - Ok(Deny) → emit audit, persist if store configured, return PermissionDenied.
1035        // - Err(_) → warn via tracing, fail-open (no audit persisted).
1036        let (gate_blocked, mut deferred_audit) = match self.gate.check(&gate_req) {
1037            Ok(decision) => {
1038                let is_deny = matches!(decision, GateDecision::Deny { .. });
1039
1040                // Emit audit event via tracing.
1041                let audit = AuditEvent::from_check(&gate_req, &decision, self.gate.impl_name());
1042                tracing::info!(
1043                    audit_event = %serde_json::to_string(&audit)
1044                        .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
1045                    "gate.check"
1046                );
1047
1048                // Drain any process-lifetime `OnceLock` config locks queued
1049                // since the last dispatch and persist them as `ConfigLocked`
1050                // events, riding this same audit-persistence gate. The
1051                // namespace/actor stamped on these rows are whichever
1052                // dispatch happens to observe the queue non-empty first:
1053                // an accepted provenance quirk, preferred over threading an
1054                // `EventStore` handle into every synchronous
1055                // `OnceLock::get_or_init` call site.
1056                if let Some(store) = &self.event_store {
1057                    if crate::config_ledger::PENDING
1058                        .swap(false, std::sync::atomic::Ordering::AcqRel)
1059                    {
1060                        for (key, value) in crate::config_ledger::drain_config_locked() {
1061                            let payload = serde_json::json!({ "key": key, "value": value });
1062                            let storage_event = Event::new(
1063                                gate_req.namespace.as_str(),
1064                                verb,
1065                                EventKind::ConfigLocked,
1066                                SubstrateKind::Event,
1067                                format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
1068                            )
1069                            .with_payload(payload);
1070                            append_audit_event_best_effort(store, storage_event, verb).await;
1071                        }
1072                    }
1073                }
1074
1075                // Every Allow-outcome audit row defers its append until pack
1076                // dispatch returns, so the row can carry the measured
1077                // dispatch time in `duration_us` (persisting before dispatch
1078                // ran always recorded the `Event::new` default of 0). A
1079                // singleton `link` call (no `links` bulk array) additionally
1080                // enriches the deferred row with the created/resolved edge
1081                // fields (schema v2) once dispatch resolves. Denied calls
1082                // have no dispatch to wait for and keep the immediate v1
1083                // append below.
1084                //
1085                // Accepted trade-off: a crash between this Allow decision and
1086                // the deferred append below (post-dispatch, further down this
1087                // function) loses that dispatch's audit row entirely: a
1088                // deliberate choice, not an oversight.
1089                let defer_audit = !is_deny;
1090
1091                // Persist to EventStore immediately only for denied calls.
1092                if !defer_audit {
1093                    if let Some(store) = &self.event_store {
1094                        let storage_event =
1095                            build_audit_storage_event(&gate_req, &audit, EventOutcome::Denied);
1096                        append_audit_event_best_effort(store, storage_event, verb).await;
1097                    }
1098                }
1099
1100                let reason = if is_deny {
1101                    let reason = match decision {
1102                        GateDecision::Deny { reason } => reason,
1103                        _ => String::new(),
1104                    };
1105                    Some(reason)
1106                } else {
1107                    None
1108                };
1109                let deferred = if defer_audit { Some(audit) } else { None };
1110                (reason, deferred)
1111            }
1112            Err(err) => {
1113                // Gate infrastructure failure — fail-open.
1114                // No decision was produced; no audit event is persisted.
1115                tracing::warn!(verb, error = %err, "gate check failed (fail-open)");
1116                (None, None)
1117            }
1118        };
1119
1120        // Hard enforcement: Deny is authoritative.
1121        if let Some(reason) = gate_blocked {
1122            return Err(RuntimeError::PermissionDenied {
1123                verb: verb.to_string(),
1124                reason,
1125            });
1126        }
1127
1128        // Mint the authorized storage token at the dispatch boundary.
1129        //
1130        // Writes pin to `local` by default. Actor identity and config
1131        // `[actor] id` are attribution and gate-context inputs only: they
1132        // never route storage. The explicit `namespace=` request param is a
1133        // precise single-namespace escape: the caller deliberately
1134        // reads/writes exactly that one set; it is NOT widened by `visible_namespaces`.
1135        //
1136        // When actor_id is configured, mint a token carrying that actor
1137        // label so that comm.inbox applies the to_actor filter for directed delivery.
1138        // Otherwise, use ActorRef::anonymous() and inbox falls back to party-line.
1139        // `actor_id_str` already reflects the per-request identity override
1140        // when supplied (resolved above into `resolved_actor`, mirrored into
1141        // the gate request). Reusing the same value here guarantees the
1142        // gate's actor and the storage token's actor can never diverge.
1143        //
1144        // On the default (no explicit `namespace=`) path, the read scope
1145        // widens to `['local'] ∪ visible_namespaces` (baked, or the
1146        // per-request override). `'local'` is always included
1147        // (mint_with_visibility deduplicates). Writes remain pinned to
1148        // `'local'`. Per-actor distinctions use view-layer tag filters
1149        // (assignee, actor_id, from/to), not namespace partitions. `ns`/
1150        // `explicit_namespace` were already validated above: reuse them
1151        // instead of re-reading `params["namespace"]` with `as_str()`, which
1152        // would silently drop malformed non-string values again.
1153        let token = if explicit_namespace {
1154            // Explicit escape: precise single-namespace scope, read+write. NOT widened.
1155            NamespaceToken::mint_with_visibility(ns.clone(), vec![], resolved_actor)
1156        } else {
1157            // Default path: write namespace = local; read scope = ['local'] ∪ visible_namespaces.
1158            let primary = Namespace::local();
1159            let mut extra_visible: Vec<Namespace> = match identity.as_ref() {
1160                Some(id) => id
1161                    .visible_namespaces
1162                    .iter()
1163                    .filter_map(|s| match Namespace::parse(s) {
1164                        Ok(parsed) => Some(parsed),
1165                        Err(e) => {
1166                            tracing::warn!(
1167                                namespace = %s,
1168                                error = %e,
1169                                "dispatch_with_identity: skipping invalid visible_namespace \
1170                                 entry from per-request identity"
1171                            );
1172                            None
1173                        }
1174                    })
1175                    .collect(),
1176                None => self.visible_namespaces.clone(),
1177            };
1178            extra_visible.push(Namespace::local()); // 'local' always readable; mint dedups
1179            NamespaceToken::mint_with_visibility(primary, extra_visible, resolved_actor)
1180        };
1181
1182        for pack in self.packs.iter() {
1183            if let Some(handler_def) = pack.handlers().iter().find(|v| v.name == verb) {
1184                // Strip `namespace` from params before forwarding to packs.
1185                // The registry has already consumed it to mint the NamespaceToken.
1186                //
1187                // Exception: if the handler's own `params` schema declares
1188                // `"namespace"` as a valid field (e.g. brain.bind, brain.unbind,
1189                // brain.bindings, brain.resolve), the field is a *business* argument
1190                // — not a transport routing key — and must be passed through
1191                // unchanged. Stripping it would silently default the binding to the
1192                // "*" wildcard, broadening profile scope across namespaces.
1193                let handler_accepts_namespace =
1194                    handler_def.params.iter().any(|p| p.name == "namespace");
1195                let params = if !handler_accepts_namespace {
1196                    if let Value::Object(mut map) = params {
1197                        map.remove("namespace");
1198                        Value::Object(map)
1199                    } else {
1200                        params
1201                    }
1202                } else {
1203                    params
1204                };
1205                let dispatch_start = Instant::now();
1206                let result = pack.dispatch(verb, params, self, &token).await;
1207                let dispatch_us = dispatch_start.elapsed().as_micros() as i64;
1208
1209                // Append the deferred Allow-outcome audit row now that
1210                // dispatch has resolved, so `duration_us` carries the
1211                // measured `dispatch_us` instead of the `Event::new` default
1212                // of 0. A successful singleton `link` call enriches the row
1213                // with the created/resolved edge (schema v2); anything that
1214                // cannot be enriched, or is not a singleton `link` call,
1215                // falls back to the generic v1 audit shape so no audit row
1216                // is ever dropped for the deferred path.
1217                if let Some(audit) = deferred_audit.take() {
1218                    if let Some(store) = &self.event_store {
1219                        let is_link_singleton =
1220                            verb == "link" && gate_req.args.get("links").is_none();
1221                        match &result {
1222                            Ok(ok_val) if is_link_singleton => {
1223                                match link_audit_success_from_result(audit.clone(), ok_val) {
1224                                    Some((edge_id, payload)) => {
1225                                        let storage_event = Event::new(
1226                                            gate_req.namespace.as_str(),
1227                                            gate_req.verb.as_str(),
1228                                            EventKind::Audit,
1229                                            SubstrateKind::Event,
1230                                            format!(
1231                                                "{}:{}",
1232                                                gate_req.actor.kind, gate_req.actor.id
1233                                            ),
1234                                        )
1235                                        .with_outcome(EventOutcome::Success)
1236                                        .with_target(edge_id)
1237                                        .with_payload(payload)
1238                                        .with_payload_schema_version(2)
1239                                        .with_duration_us(dispatch_us);
1240                                        append_audit_event_best_effort(store, storage_event, verb)
1241                                            .await;
1242                                    }
1243                                    None => {
1244                                        tracing::warn!(
1245                                            verb,
1246                                            "link audit v2 enrichment parse failed; \
1247                                             falling back to v1 audit shape"
1248                                        );
1249                                        let storage_event = build_audit_storage_event(
1250                                            &gate_req,
1251                                            &audit,
1252                                            EventOutcome::Success,
1253                                        )
1254                                        .with_duration_us(dispatch_us);
1255                                        append_audit_event_best_effort(store, storage_event, verb)
1256                                            .await;
1257                                    }
1258                                }
1259                            }
1260                            _ => {
1261                                // The persisted audit outcome must reflect
1262                                // the dispatch result, not be hardcoded to
1263                                // Success — otherwise a failed dispatch is
1264                                // recorded as successful work and disappears
1265                                // from `outcome=error` queries.
1266                                let outcome = if result.is_ok() {
1267                                    EventOutcome::Success
1268                                } else {
1269                                    EventOutcome::Error
1270                                };
1271                                let storage_event =
1272                                    build_audit_storage_event(&gate_req, &audit, outcome)
1273                                        .with_duration_us(dispatch_us);
1274                                append_audit_event_best_effort(store, storage_event, verb).await;
1275                            }
1276                        }
1277                    }
1278                }
1279
1280                // Post-dispatch hook: fires on success, opt-in.
1281                if let (Ok(ref ok_val), Some(hook)) = (&result, &self.dispatch_hook) {
1282                    let mut dispatch_event = Event::new(
1283                        ns.as_str(),
1284                        verb,
1285                        EventKind::Audit,
1286                        SubstrateKind::Event,
1287                        pack.name(),
1288                    )
1289                    .with_outcome(EventOutcome::Success)
1290                    .with_duration_us(dispatch_us);
1291
1292                    // For recall verbs: extract the first result's id as
1293                    // target_id so the brain temporal posterior can observe
1294                    // real hit/miss and latency.
1295                    if verb == "memory.recall" {
1296                        let first_note_id = ok_val
1297                            .as_array()
1298                            .and_then(|arr| arr.first())
1299                            .and_then(|v| v.get("id"))
1300                            .and_then(|v| v.as_str())
1301                            .and_then(|s| s.parse::<uuid::Uuid>().ok());
1302                        if let Some(note_id) = first_note_id {
1303                            dispatch_event = dispatch_event.with_target(note_id);
1304                        }
1305                        // No first result → target_id stays None (RecallMiss
1306                        // in brain's event interpreter).
1307                    }
1308
1309                    let dispatch_view = EventView {
1310                        event: dispatch_event,
1311                        observations: Vec::new(),
1312                    };
1313                    let hook = Arc::clone(hook);
1314                    hook.on_dispatch(&dispatch_view).await;
1315                }
1316
1317                // Recently-referenced ring admission: only by-id touches admit
1318                // an id. Runs unconditionally (not gated on `dispatch_hook`,
1319                // which is opt-in) because the ring is a core
1320                // dispatch-boundary capability, not an observer.
1321                //
1322                // Keyed on `token.namespace()`, NOT `ns`: `ns` is the
1323                // gate-resolved namespace, which on the default
1324                // (non-explicit) dispatch path can be a non-local
1325                // `default_namespace` (e.g. "foreign") while the storage
1326                // token that actually created/touched the record is pinned
1327                // to `local`. The ring must be keyed on the namespace the
1328                // record actually lives in: the same namespace
1329                // `resolve_reference`'s ring lookup uses: or admission and
1330                // lookup silently diverge on any non-local `default_namespace`
1331                // config.
1332                if let Ok(ref ok_val) = result {
1333                    let admissions = crate::reference_ring::ring_admissions_for(verb, ok_val);
1334                    if !admissions.is_empty() {
1335                        let actor_key = format!("{}:{}", gate_req.actor.kind, gate_req.actor.id);
1336                        for (id, name) in admissions {
1337                            self.reference_ring.admit(
1338                                token.namespace().as_str(),
1339                                &actor_key,
1340                                id,
1341                                name,
1342                            );
1343                        }
1344                    }
1345                }
1346
1347                return result;
1348            }
1349        }
1350
1351        // No pack owns this verb: the gate allowed it, but no dispatch runs.
1352        // Persist the deferred audit row now (duration stays at the
1353        // `Event::new` default of 0 — no dispatch occurred to measure) so an
1354        // allowed-but-unknown verb is never silently dropped from the audit
1355        // trail (matches the "no audit row is ever dropped" contract above).
1356        if let Some(audit) = deferred_audit.take() {
1357            if let Some(store) = &self.event_store {
1358                // Dispatch is about to return `InvalidInput` below (no pack
1359                // owns this verb), so the persisted outcome must be `Error`,
1360                // not `Success`.
1361                let storage_event =
1362                    build_audit_storage_event(&gate_req, &audit, EventOutcome::Error);
1363                append_audit_event_best_effort(store, storage_event, verb).await;
1364            }
1365        }
1366
1367        // Verb-visibility handler names, precomputed at build() time (internal
1368        // subhandlers are excluded so they are not advertised in the
1369        // unknown-verb error).
1370        Err(RuntimeError::InvalidInput(format!(
1371            "unknown verb {verb:?}; available: {}",
1372            self.available_verbs.join(", ")
1373        )))
1374    }
1375
1376    /// Registered pack-level by-ID resolvers, in registration order.
1377    ///
1378    /// Each element is `(pack_name, resolver)`. The kg `get` and `delete` handlers
1379    /// iterate this slice to probe pack-private tables when the standard KG
1380    /// substrates (entity/note/edge/event) return `None` for a given UUID.
1381    pub fn resolvers(&self) -> &[(String, Box<dyn PackByIdResolver>)] {
1382        &self.resolvers
1383    }
1384
1385    /// The daemon-warm recently-referenced ring (unified-verb draft ADR,
1386    /// Slice 1). Consumed by `resolve_reference` (Layer 0 stage 2) and by the
1387    /// `resolve` verb handler; admitted-to by every successful by-id
1388    /// dispatch (see the admission block in `dispatch_with_identity`).
1389    pub fn reference_ring(&self) -> &Arc<crate::reference_ring::ReferenceRing> {
1390        &self.reference_ring
1391    }
1392
1393    /// Find a kind hook among the registered packs.
1394    ///
1395    /// Walks packs in registration order; the first pack that both owns the
1396    /// kind (declares it in `note_kinds()` or `entity_kinds()`) and returns
1397    /// a hook from `kind_hook(kind)` wins. Returns `None` if the kind is
1398    /// unknown to all packs or no owning pack registered a hook.
1399    pub fn find_kind_hook(&self, kind: &str) -> Option<Arc<dyn KindHook>> {
1400        for pack in self.packs.iter() {
1401            let owns = pack.note_kinds().contains(&kind) || pack.entity_kinds().contains(&kind);
1402            if owns {
1403                if let Some(hook) = pack.kind_hook(kind) {
1404                    return Some(hook);
1405                }
1406            }
1407        }
1408        None
1409    }
1410
1411    /// All MCP-exposed handlers across all registered packs (`Visibility::Verb` only).
1412    ///
1413    /// Subhandlers (`Visibility::Subhandler`) are excluded — they are internal
1414    /// pipeline steps not surfaced on the MCP wire. Returned with `'static`
1415    /// lifetime since pack handlers are `&'static [HandlerDef]` constants.
1416    pub fn all_verbs(&self) -> Vec<&'static HandlerDef> {
1417        self.packs
1418            .iter()
1419            .flat_map(|p| p.handlers().iter())
1420            .filter(|h| matches!(h.visibility, Visibility::Verb))
1421            .collect()
1422    }
1423
1424    /// All MCP-exposed handlers paired with the name of the pack that owns them
1425    /// (`Visibility::Verb` only).
1426    ///
1427    /// Subhandlers (`Visibility::Subhandler`) are excluded from the MCP catalog
1428    /// Use `all_handlers_with_names` when internal handlers must
1429    /// also be enumerated (e.g. runtime introspection).
1430    pub fn all_verbs_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
1431        self.packs
1432            .iter()
1433            .flat_map(|p| p.handlers().iter().map(move |v| (p.name(), v)))
1434            .filter(|(_, h)| matches!(h.visibility, Visibility::Verb))
1435            .collect()
1436    }
1437
1438    /// All handler definitions across all registered packs, including subhandlers.
1439    ///
1440    /// Unlike `all_verbs`, this includes `Visibility::Subhandler` entries. Useful
1441    /// for runtime introspection (e.g. `list_handlers`) and tooling that needs
1442    /// the complete handler surface.
1443    pub fn all_handlers_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
1444        self.packs
1445            .iter()
1446            .flat_map(|p| p.handlers().iter().map(move |v| (p.name(), v)))
1447            .collect()
1448    }
1449
1450    /// Merged set of note kinds across all registered packs (deduplicated,
1451    /// first-seen order preserved).
1452    pub fn all_note_kinds(&self) -> Vec<&'static str> {
1453        let mut seen = std::collections::HashSet::new();
1454        self.packs
1455            .iter()
1456            .flat_map(|p| p.note_kinds().iter().copied())
1457            .filter(|k| seen.insert(*k))
1458            .collect()
1459    }
1460
1461    /// Merged set of entity kinds across all registered packs (deduplicated,
1462    /// first-seen order preserved).
1463    pub fn all_entity_kinds(&self) -> Vec<&'static str> {
1464        let mut seen = std::collections::HashSet::new();
1465        self.packs
1466            .iter()
1467            .flat_map(|p| p.entity_kinds().iter().copied())
1468            .filter(|k| seen.insert(*k))
1469            .collect()
1470    }
1471
1472    /// Names of packs in topological load order.
1473    pub fn pack_names(&self) -> Vec<&str> {
1474        self.packs.iter().map(|p| p.name()).collect()
1475    }
1476
1477    /// Declared dependencies for a registered pack.
1478    pub fn pack_requires(&self, name: &str) -> Option<&'static [&'static str]> {
1479        self.packs
1480            .iter()
1481            .find(|p| p.name() == name)
1482            .map(|p| p.requires())
1483    }
1484
1485    /// Note kinds owned by a specific registered pack.
1486    ///
1487    /// Returns `None` if no pack with `name` is registered. The slice is
1488    /// the pack's `NOTE_KINDS` constant — `'static` lifetime, no allocation.
1489    pub fn pack_note_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
1490        self.packs
1491            .iter()
1492            .find(|p| p.name() == name)
1493            .map(|p| p.note_kinds())
1494    }
1495
1496    /// Entity kinds owned by a specific registered pack.
1497    ///
1498    /// Returns `None` if no pack with `name` is registered. The slice is
1499    /// the pack's `ENTITY_KINDS` constant — `'static` lifetime, no allocation.
1500    pub fn pack_entity_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
1501        self.packs
1502            .iter()
1503            .find(|p| p.name() == name)
1504            .map(|p| p.entity_kinds())
1505    }
1506
1507    /// Handlers declared by a specific registered pack.
1508    ///
1509    /// Returns `None` if no pack with `name` is registered. Each `HandlerDef`
1510    /// carries name + description + visibility — sufficient for introspection clients.
1511    pub fn pack_verbs(&self, name: &str) -> Option<&'static [HandlerDef]> {
1512        self.packs
1513            .iter()
1514            .find(|p| p.name() == name)
1515            .map(|p| p.handlers())
1516    }
1517
1518    /// All pack-declared edge endpoint rules across registered packs.
1519    ///
1520    /// Order follows topological pack registration; duplicates are *not* deduplicated —
1521    /// validation only checks membership, and an exact-duplicate rule is a
1522    /// harmless restatement.
1523    pub fn all_edge_rules(&self) -> Vec<EdgeEndpointRule> {
1524        self.packs
1525            .iter()
1526            .flat_map(|p| p.edge_rules().iter().copied())
1527            .collect()
1528    }
1529
1530    /// Collect all `NoteKindSpec` declarations from every loaded pack.
1531    ///
1532    /// Used by the runtime for lifecycle introspection and future enforcement.
1533    pub fn all_note_kind_specs(&self) -> Vec<&'static NoteKindSpec> {
1534        self.packs
1535            .iter()
1536            .flat_map(|p| p.note_kind_specs().iter())
1537            .collect()
1538    }
1539
1540    /// All pack-contributed validation rules across registered packs.
1541    ///
1542    /// Returns references into the pack-owned `'static` slices — no allocation
1543    /// beyond the outer `Vec`. Rule IDs are namespaced by pack; callers can
1544    /// group by `rule.id.split_once('/')` to attribute rules to their packs.
1545    pub fn all_validation_rules(&self) -> Vec<&'static ValidationRule> {
1546        self.packs
1547            .iter()
1548            .flat_map(|p| p.validation_rules().iter())
1549            .collect()
1550    }
1551
1552    /// Pack-auxiliary schema plans for all registered packs.
1553    ///
1554    /// Returns one `SchemaPlan` per pack. Callers (typically the runtime
1555    /// bootstrap) apply each plan to the pack's assigned backend. Empty plans
1556    /// are included so the caller can iterate uniformly; callers that want to
1557    /// skip empty plans should check `plan.is_empty()`.
1558    pub fn all_schema_plans(&self) -> Vec<SchemaPlan> {
1559        self.packs.iter().map(|p| p.schema_plan()).collect()
1560    }
1561
1562    /// Invoke `PackRuntime::register_embedders` on every registered pack.
1563    ///
1564    /// Called by the transport during startup, after the registry is built and
1565    /// before the first verb dispatch, so that custom embedding providers
1566    /// contributed by packs are reachable via `KhiveRuntime::embedder(name)`.
1567    ///
1568    /// Packs whose `register_embedders` is the default no-op pay no overhead.
1569    /// The method is idempotent when the underlying registry uses last-wins
1570    /// semantics for duplicate provider names.
1571    pub fn call_register_embedders(&self, runtime: &KhiveRuntime) {
1572        for pack in self.packs.iter() {
1573            pack.register_embedders(runtime);
1574        }
1575    }
1576
1577    /// Invoke `PackRuntime::register_entity_type_validator` on every registered pack.
1578    ///
1579    /// Called by the transport during startup, after the registry is built and
1580    /// before the first verb dispatch, so that entity-type validation at the
1581    /// runtime layer is active for all write paths including direct `create_many`
1582    /// callers that bypass the handler layer.
1583    ///
1584    /// Packs whose `register_entity_type_validator` is the default no-op pay
1585    /// no overhead.
1586    pub fn call_register_entity_type_validators(&self, runtime: &KhiveRuntime) {
1587        for pack in self.packs.iter() {
1588            pack.register_entity_type_validator(runtime);
1589        }
1590    }
1591
1592    /// Invoke `PackRuntime::register_note_mutation_hook` on every registered pack.
1593    ///
1594    /// Called by the transport during startup, after the registry is built and
1595    /// before the first verb dispatch, so that note-mutation notifications at
1596    /// the runtime layer are active for all write paths — including KG's
1597    /// `update`/`delete` verbs reaching a `kind="memory"` note, which have no
1598    /// crate-level dependency on `khive-pack-memory`.
1599    ///
1600    /// Packs whose `register_note_mutation_hook` is the default no-op pay no
1601    /// overhead.
1602    pub fn call_register_note_mutation_hooks(&self, runtime: &KhiveRuntime) {
1603        for pack in self.packs.iter() {
1604            pack.register_note_mutation_hook(runtime);
1605        }
1606    }
1607
1608    /// Invoke `PackRuntime::warm` on every registered pack.
1609    /// Called by the daemon at boot (in a background task) so expensive in-memory
1610    /// state (ANN indexes) is pre-loaded without blocking request serving.
1611    pub async fn call_warm_all(&self) {
1612        for pack in self.packs.iter() {
1613            pack.warm().await;
1614        }
1615    }
1616
1617    /// Resolve the presentation policy for a verb name.
1618    ///
1619    /// Walks all registered handlers (including subhandlers) for the first
1620    /// matching name and returns its declared [`VerbPresentationPolicy`].
1621    /// Returns `Standard` for unknown verbs — unknown verbs will fail at
1622    /// dispatch anyway, so the fallback here is safe.
1623    pub fn presentation_policy_for(&self, verb: &str) -> khive_types::VerbPresentationPolicy {
1624        for pack in self.packs.iter() {
1625            if let Some(handler) = pack.handlers().iter().find(|h| h.name == verb) {
1626                return handler.presentation_policy();
1627            }
1628        }
1629        khive_types::VerbPresentationPolicy::Standard
1630    }
1631
1632    /// Returns `true` if the named verb exists and is tagged
1633    /// `Visibility::Subhandler` (internal / operator-only).
1634    ///
1635    /// Used by the MCP server to gate subhandler invocation at the wire
1636    /// boundary without blocking internal callers that invoke the same verbs
1637    /// through the runtime directly.
1638    pub fn is_subhandler_verb(&self, verb: &str) -> bool {
1639        for pack in self.packs.iter() {
1640            if let Some(handler) = pack.handlers().iter().find(|h| h.name == verb) {
1641                return matches!(handler.visibility, Visibility::Subhandler);
1642            }
1643        }
1644        false
1645    }
1646
1647    /// Apply all non-empty pack-auxiliary schema plans to the given backend.
1648    ///
1649    /// This is the centralized startup hook that replaced the previous lazy
1650    /// per-pack self-bootstrap pattern. Each pack's `SchemaPlan` carries
1651    /// idempotent `CREATE TABLE IF NOT EXISTS` DDL; calling this more than once
1652    /// is safe. Empty plans are skipped.
1653    ///
1654    /// Errors from individual plans are logged via `tracing::warn!` and not
1655    /// propagated so that a single pack's schema failure does not prevent the
1656    /// rest from loading. Callers that need hard-failure semantics should call
1657    /// `all_schema_plans()` and apply each plan individually.
1658    pub fn apply_schema_plans(&self, backend: &khive_db::StorageBackend) {
1659        for plan in self.all_schema_plans() {
1660            if plan.is_empty() {
1661                continue;
1662            }
1663            if let Err(e) = backend.apply_pack_ddl_statements(plan.statements) {
1664                tracing::warn!(
1665                    pack = plan.pack,
1666                    error = %e,
1667                    "failed to apply pack schema plan at startup (non-fatal)"
1668                );
1669            }
1670        }
1671    }
1672
1673    /// Pack-auxiliary schema plans with their owning pack names.
1674    ///
1675    /// Returns `(pack_name, SchemaPlan)` pairs for every registered pack.
1676    /// Used by the multi-backend boot path to apply each plan to the pack's
1677    /// assigned backend rather than a single shared backend.
1678    pub fn all_schema_plans_named(&self) -> Vec<(&'static str, SchemaPlan)> {
1679        self.packs
1680            .iter()
1681            .map(|p| {
1682                let plan = p.schema_plan();
1683                (plan.pack, plan)
1684            })
1685            .collect()
1686    }
1687
1688    /// Apply pack-auxiliary schema plans using a per-pack backend map.
1689    ///
1690    /// For each `(pack_name, plan)` returned by `all_schema_plans_named()`,
1691    /// applies the plan to `backend_for_pack[pack_name]` when present,
1692    /// falling back to `default_backend` for any pack not in the map.
1693    ///
1694    /// Returns an error when two packs on the same backend declare the same
1695    /// auxiliary table (ADR-028 §7 collision policy: boot failure naming both
1696    /// packs and the conflicting table).
1697    ///
1698    /// This is the multi-backend boot path (ADR-028). Single-backend callers
1699    /// should continue using [`Self::apply_schema_plans`].
1700    pub fn apply_schema_plans_with_map(
1701        &self,
1702        backend_for_pack: &HashMap<&str, &khive_db::StorageBackend>,
1703        default_backend: &khive_db::StorageBackend,
1704    ) -> Result<(), crate::PackSchemaCollisionError> {
1705        // Track which pack first claimed each table on each backend.
1706        // Backend identity is the raw pointer of the underlying connection pool Arc.
1707        let mut claimed: HashMap<(*const (), String), &'static str> = HashMap::new();
1708
1709        for (pack_name, plan) in self.all_schema_plans_named() {
1710            if plan.is_empty() {
1711                continue;
1712            }
1713            let backend = backend_for_pack
1714                .get(pack_name)
1715                .copied()
1716                .unwrap_or(default_backend);
1717            let backend_ptr = std::sync::Arc::as_ptr(&backend.pool_arc()) as *const ();
1718
1719            // Pre-scan DDL for table names and detect collisions before applying.
1720            for stmt in plan.statements {
1721                for table_name in extract_table_names(stmt) {
1722                    let key = (backend_ptr, table_name.clone());
1723                    match claimed.entry(key) {
1724                        std::collections::hash_map::Entry::Vacant(e) => {
1725                            e.insert(pack_name);
1726                        }
1727                        std::collections::hash_map::Entry::Occupied(e) => {
1728                            let prior_pack = *e.get();
1729                            return Err(crate::PackSchemaCollisionError {
1730                                pack_a: prior_pack,
1731                                pack_b: pack_name,
1732                                table: table_name,
1733                            });
1734                        }
1735                    }
1736                }
1737            }
1738
1739            backend
1740                .apply_pack_ddl_statements(plan.statements)
1741                .map_err(|e| crate::PackSchemaCollisionError {
1742                    pack_a: pack_name,
1743                    pack_b: pack_name,
1744                    table: format!("DDL error: {e}"),
1745                })?;
1746        }
1747        Ok(())
1748    }
1749}
1750
1751// ── Inventory-based dynamic pack loading ────────────────────────────────────
1752
1753/// Output of [`PackFactory::create_install`] — bundles the pack runtime with
1754/// its optional by-ID resolver and dispatch hook so a factory can hand back
1755/// all three built from one shared instance (see `BrainPackFactory` for why
1756/// this matters: the dispatch hook must observe the same state the runtime
1757/// mutates, not a second unrelated instance).
1758pub struct PackInstall {
1759    /// The pack runtime, registered into the builder's pack list.
1760    pub runtime: Box<dyn PackRuntime>,
1761    /// Optional by-ID resolver, registered when present.
1762    pub resolver: Option<Box<dyn PackByIdResolver>>,
1763    /// Optional post-dispatch observer, wired via `VerbRegistryBuilder::with_dispatch_hook`.
1764    pub dispatch_hook: Option<Arc<dyn DispatchHook>>,
1765}
1766
1767/// Factory for creating pack instances registered via `inventory` at link time.
1768/// Each pack crate submits a `&'static dyn PackFactory` wrapped in a
1769/// [`PackRegistration`]; the binary's linker collects them all into a single
1770/// slice iterable at runtime.
1771///
1772/// Implementors must be `Send + Sync + 'static` because the registry is built
1773/// once and shared across async tasks.
1774pub trait PackFactory: Send + Sync + 'static {
1775    /// Canonical lowercase name for this pack (e.g. `"kg"`, `"gtd"`).
1776    fn name(&self) -> &'static str;
1777
1778    /// Names of packs that must be loaded before this one.
1779    ///
1780    /// Defaults to empty so pack crates that have no dependencies compile
1781    /// without changes. [`PackRegistry::register_packs`] validates that every
1782    /// name listed here is present in the caller's explicit pack list — absent
1783    /// dependencies are a boot error, not silently auto-added.
1784    fn requires(&self) -> &'static [&'static str] {
1785        &[]
1786    }
1787
1788    /// Create a new pack instance for the given runtime.
1789    fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime>;
1790
1791    /// Build the full installation bundle for this pack: runtime, optional
1792    /// resolver, optional dispatch hook.
1793    ///
1794    /// Defaults to composing `create` and `create_resolver` with no dispatch
1795    /// hook, so existing factories compile unchanged. Packs whose dispatch
1796    /// hook must observe the same instance as the runtime (e.g. `brain`)
1797    /// override this method instead of `create`, since the default would
1798    /// otherwise require two independent instances to share state.
1799    fn create_install(&self, runtime: KhiveRuntime) -> PackInstall {
1800        let resolver = self.create_resolver(runtime.clone());
1801        PackInstall {
1802            runtime: self.create(runtime),
1803            resolver,
1804            dispatch_hook: None,
1805        }
1806    }
1807
1808    /// Optionally create a `PackByIdResolver` for this pack.
1809    ///
1810    /// Packs that own private SQL tables implement this to hook into
1811    /// `get(id)` and `delete(id)`. Defaults to `None` so existing packs
1812    /// compile without changes.
1813    fn create_resolver(&self, _runtime: KhiveRuntime) -> Option<Box<dyn PackByIdResolver>> {
1814        None
1815    }
1816}
1817
1818/// Newtype wrapper collected by `inventory` so pack crates can submit
1819/// `&'static dyn PackFactory` references without the type-ascription syntax
1820/// that `inventory::submit!` does not support for bare trait-object references.
1821pub struct PackRegistration(pub &'static dyn PackFactory);
1822
1823inventory::collect!(PackRegistration);
1824
1825/// Error returned by [`PackRegistry::register_packs`] when boot validation fails.
1826#[derive(Debug)]
1827pub enum PackLoadError {
1828    /// The requested pack name was not found in the inventory.
1829    UnknownPack(String),
1830    /// A pack was requested but a declared dependency is absent from the list.
1831    MissingDependency {
1832        /// The pack that declared the dependency.
1833        pack: String,
1834        /// The dependency that is missing from the requested pack list.
1835        dep: String,
1836    },
1837}
1838
1839impl std::fmt::Display for PackLoadError {
1840    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1841        match self {
1842            PackLoadError::UnknownPack(name) => write!(f, "unknown pack {name:?}"),
1843            PackLoadError::MissingDependency { pack, dep } => write!(
1844                f,
1845                "pack {pack:?} requires {dep:?}, which is not in the requested pack list; \
1846                 add --pack {dep} before --pack {pack}"
1847            ),
1848        }
1849    }
1850}
1851
1852impl std::error::Error for PackLoadError {}
1853
1854/// Registry of pack factories discovered via `inventory` at link time.
1855///
1856/// No instance is needed — all methods are associated functions that walk the
1857/// globally-collected [`PackRegistration`] slice.
1858pub struct PackRegistry;
1859
1860impl PackRegistry {
1861    /// Names of all pack factories discovered via `inventory`.
1862    pub fn discovered_names() -> Vec<&'static str> {
1863        inventory::iter::<PackRegistration>
1864            .into_iter()
1865            .map(|r| r.0.name())
1866            .collect()
1867    }
1868
1869    /// Register the named packs into `builder` using the supplied `runtime`.
1870    ///
1871    /// Validates the explicit pack list against `PackFactory::requires()` —
1872    /// if any requested pack declares a dependency that is absent from `names`,
1873    /// registration fails (missing dependency is a boot error, not silently
1874    /// auto-added). Callers must include all required packs explicitly.
1875    ///
1876    /// The [`VerbRegistryBuilder::build`] topo-sort enforces correct load order.
1877    ///
1878    /// Returns `Ok(())` when all names are recognised and all declared
1879    /// dependencies are satisfied; returns `Err(PackLoadError)` with a
1880    /// distinct variant for unknown pack vs missing dependency.
1881    pub fn register_packs(
1882        names: &[String],
1883        runtime: KhiveRuntime,
1884        builder: &mut VerbRegistryBuilder,
1885    ) -> Result<(), PackLoadError> {
1886        // Build a name→factory index once.
1887        let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
1888            .into_iter()
1889            .map(|r| r.0)
1890            .collect();
1891        let factory_for = |name: &str| -> Option<&'static dyn PackFactory> {
1892            all.iter().copied().find(|f| f.name() == name)
1893        };
1894
1895        // Validate that every requested name is a known factory.
1896        let requested: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
1897        for name in names {
1898            factory_for(name.as_str()).ok_or_else(|| PackLoadError::UnknownPack(name.clone()))?;
1899        }
1900
1901        // Validate that all requires() dependencies are explicitly present in
1902        // the requested set. Missing dep → boot error, not auto-add.
1903        for name in names {
1904            let factory = factory_for(name.as_str()).unwrap(); // validated above
1905            for &dep in factory.requires() {
1906                if !requested.contains(dep) {
1907                    return Err(PackLoadError::MissingDependency {
1908                        pack: name.clone(),
1909                        dep: dep.to_string(),
1910                    });
1911                }
1912            }
1913        }
1914
1915        // Register every requested pack; VerbRegistryBuilder::build()
1916        // performs the topo-sort, so insertion order here does not matter.
1917        for name in names {
1918            let factory = factory_for(name.as_str()).unwrap(); // validated above
1919            let install = factory.create_install(runtime.clone());
1920            builder.register_boxed(install.runtime);
1921            if let Some(resolver) = install.resolver {
1922                builder.register_resolver(name.clone(), resolver);
1923            }
1924            if let Some(hook) = install.dispatch_hook {
1925                builder.with_dispatch_hook(hook);
1926            }
1927        }
1928
1929        Ok(())
1930    }
1931
1932    /// Register the named packs into `builder`, routing each pack to its own runtime.
1933    ///
1934    /// `runtimes` maps pack name → `KhiveRuntime` (one per backend assignment).
1935    /// `default_runtime` is used for any pack whose name is not in `runtimes`.
1936    /// The validation logic (unknown pack, missing dependency) is identical to
1937    /// [`PackRegistry::register_packs`].
1938    ///
1939    /// This is the multi-backend boot path (ADR-028). Single-backend callers
1940    /// should continue using [`PackRegistry::register_packs`].
1941    pub fn register_packs_with_runtimes(
1942        names: &[String],
1943        runtimes: &HashMap<String, KhiveRuntime>,
1944        default_runtime: &KhiveRuntime,
1945        builder: &mut VerbRegistryBuilder,
1946    ) -> Result<(), PackLoadError> {
1947        let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
1948            .into_iter()
1949            .map(|r| r.0)
1950            .collect();
1951        let factory_for = |name: &str| -> Option<&'static dyn PackFactory> {
1952            all.iter().copied().find(|f| f.name() == name)
1953        };
1954
1955        let requested: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
1956        for name in names {
1957            factory_for(name.as_str()).ok_or_else(|| PackLoadError::UnknownPack(name.clone()))?;
1958        }
1959
1960        for name in names {
1961            let factory = factory_for(name.as_str()).unwrap();
1962            for &dep in factory.requires() {
1963                if !requested.contains(dep) {
1964                    return Err(PackLoadError::MissingDependency {
1965                        pack: name.clone(),
1966                        dep: dep.to_string(),
1967                    });
1968                }
1969            }
1970        }
1971
1972        for name in names {
1973            let factory = factory_for(name.as_str()).unwrap();
1974            let runtime = runtimes
1975                .get(name.as_str())
1976                .cloned()
1977                .unwrap_or_else(|| default_runtime.clone());
1978            let install = factory.create_install(runtime);
1979            builder.register_boxed(install.runtime);
1980            if let Some(resolver) = install.resolver {
1981                builder.register_resolver(name.clone(), resolver);
1982            }
1983            if let Some(hook) = install.dispatch_hook {
1984                builder.with_dispatch_hook(hook);
1985            }
1986        }
1987
1988        Ok(())
1989    }
1990}
1991
1992fn target_id_from_args(args: &serde_json::Value) -> Option<uuid::Uuid> {
1993    args.get("target_id")
1994        .and_then(serde_json::Value::as_str)
1995        .and_then(|s| s.parse::<uuid::Uuid>().ok())
1996}
1997
1998/// Build a v1-shape audit storage event from a gate check outcome.
1999///
2000/// Shared by the immediate-append path (all verbs, denied calls, bulk
2001/// `links`) and the deferred singleton-`link` fallback so both audit
2002/// shapes are produced by one code path.
2003fn build_audit_storage_event(
2004    gate_req: &GateRequest,
2005    audit: &AuditEvent,
2006    outcome: EventOutcome,
2007) -> Event {
2008    let audit_data = serde_json::to_value(audit).unwrap_or_else(|e| {
2009        tracing::warn!(error = %e, "failed to serialize AuditEvent for EventStore");
2010        serde_json::Value::Null
2011    });
2012    let mut storage_event = Event::new(
2013        gate_req.namespace.as_str(),
2014        gate_req.verb.as_str(),
2015        EventKind::Audit,
2016        SubstrateKind::Event,
2017        format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
2018    )
2019    .with_outcome(outcome)
2020    .with_payload(audit_data);
2021    if let Some(target_id) = target_id_from_args(&gate_req.args) {
2022        storage_event = storage_event.with_target(target_id);
2023    }
2024    storage_event
2025}
2026
2027/// Append an audit event, logging and swallowing store failures.
2028///
2029/// Audit persistence is best-effort everywhere in the dispatch path: a store
2030/// write failure must never fail the verb call it is auditing.
2031async fn append_audit_event_best_effort(store: &Arc<dyn EventStore>, event: Event, verb: &str) {
2032    if let Err(store_err) = store.append_event(event).await {
2033        tracing::warn!(
2034            verb,
2035            error = %store_err,
2036            "audit event store write failed (non-fatal)"
2037        );
2038    }
2039}
2040
2041/// Schema v2 audit payload for a successful singleton `link` call.
2042///
2043/// Additive over the v1 `AuditEvent` shape: every v1 field is preserved via
2044/// `#[serde(flatten)]`, and the edge identity/relation/weight the caller
2045/// created or resolved are added at the top level.
2046#[derive(Debug, Clone, serde::Serialize)]
2047struct LinkAuditSuccessV2 {
2048    #[serde(flatten)]
2049    audit: AuditEvent,
2050    edge_id: uuid::Uuid,
2051    source_id: uuid::Uuid,
2052    target_id: uuid::Uuid,
2053    relation: String,
2054    weight: f64,
2055}
2056
2057/// Extract the edge fields needed to enrich a successful singleton `link`
2058/// audit row from the handler's returned JSON.
2059///
2060/// Returns `None` (rather than a `Result`) on any missing/malformed field —
2061/// the caller treats that as "cannot enrich" and falls back to the v1 audit
2062/// shape instead of failing the already-succeeded `link` call.
2063fn link_audit_success_from_result(
2064    audit: AuditEvent,
2065    result: &serde_json::Value,
2066) -> Option<(uuid::Uuid, serde_json::Value)> {
2067    let edge_id = result.get("id")?.as_str()?.parse::<uuid::Uuid>().ok()?;
2068    let source_id = result
2069        .get("source_id")?
2070        .as_str()?
2071        .parse::<uuid::Uuid>()
2072        .ok()?;
2073    let target_id = result
2074        .get("target_id")?
2075        .as_str()?
2076        .parse::<uuid::Uuid>()
2077        .ok()?;
2078    let relation = result.get("relation")?.as_str()?.to_string();
2079    let weight = result.get("weight")?.as_f64()?;
2080    let enriched = LinkAuditSuccessV2 {
2081        audit,
2082        edge_id,
2083        source_id,
2084        target_id,
2085        relation,
2086        weight,
2087    };
2088    let payload = serde_json::to_value(&enriched).ok()?;
2089    Some((edge_id, payload))
2090}
2091
2092/// Resolve and validate a caller-supplied `namespace` argument the same way
2093/// on every MCP ingress path.
2094///
2095/// - Absent `namespace` key → parse `default_namespace`.
2096/// - Present `namespace: "<string>"` → parse the caller's value.
2097/// - Present non-string `namespace` (null, number, bool, array, object) →
2098///   fail closed with `RuntimeError::InvalidInput`. ADR-018 requires this:
2099///   a malformed explicit value must never be silently coerced to the
2100///   default namespace.
2101///
2102/// This is the single chokepoint both `VerbRegistry::dispatch` (single-backend
2103/// and JSON-form ingress) and the multi-backend coordinator intercept
2104/// (`dispatch_via_coordinator_inner` in `khive-mcp`) call into, so no ingress
2105/// path can bypass the fail-closed rule by routing around `dispatch`.
2106pub fn resolve_explicit_namespace(
2107    params: &Value,
2108    default_namespace: &str,
2109) -> Result<Namespace, RuntimeError> {
2110    match params.get("namespace") {
2111        None => Namespace::parse(default_namespace)
2112            .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}"))),
2113        Some(Value::String(ns_str)) => Namespace::parse(ns_str)
2114            .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace {ns_str:?}: {e}"))),
2115        Some(other) => Err(RuntimeError::InvalidInput(format!(
2116            "invalid namespace: expected string when present, got {}",
2117            json_type_name(other),
2118        ))),
2119    }
2120}
2121
2122/// JSON type name for error messages: describes a present-but-malformed
2123/// `namespace` value without echoing its contents.
2124pub fn json_type_name(v: &Value) -> &'static str {
2125    match v {
2126        Value::Null => "null",
2127        Value::Bool(_) => "boolean",
2128        Value::Number(_) => "number",
2129        Value::String(_) => "string",
2130        Value::Array(_) => "array",
2131        Value::Object(_) => "object",
2132    }
2133}
2134
2135// INLINE TEST JUSTIFICATION: tests here exercise VerbRegistry collision detection,
2136// gate enforcement, and dispatch ordering that depend on direct access to the
2137// registry's private `packs` Vec and gate field. Moving them to tests/ would
2138// require pub-exporting registry internals. Broad behavioral dispatch tests
2139// live in tests/integration.rs.
2140#[cfg(test)]
2141mod tests {
2142    use super::*;
2143    use crate::ActorRef;
2144    use khive_types::Pack;
2145
2146    struct AlphaPack;
2147
2148    impl Pack for AlphaPack {
2149        const NAME: &'static str = "alpha";
2150        const NOTE_KINDS: &'static [&'static str] = &["memo", "log"];
2151        const ENTITY_KINDS: &'static [&'static str] = &["widget"];
2152        const HANDLERS: &'static [HandlerDef] = &[
2153            HandlerDef {
2154                name: "create",
2155                description: "create a widget",
2156                visibility: Visibility::Verb,
2157                category: VerbCategory::Commissive,
2158                params: &[],
2159            },
2160            HandlerDef {
2161                name: "list",
2162                description: "list widgets",
2163                visibility: Visibility::Verb,
2164                category: VerbCategory::Assertive,
2165                params: &[],
2166            },
2167        ];
2168    }
2169
2170    #[async_trait]
2171    impl PackRuntime for AlphaPack {
2172        fn name(&self) -> &str {
2173            AlphaPack::NAME
2174        }
2175        fn note_kinds(&self) -> &'static [&'static str] {
2176            AlphaPack::NOTE_KINDS
2177        }
2178        fn entity_kinds(&self) -> &'static [&'static str] {
2179            AlphaPack::ENTITY_KINDS
2180        }
2181        fn handlers(&self) -> &'static [HandlerDef] {
2182            AlphaPack::HANDLERS
2183        }
2184        async fn dispatch(
2185            &self,
2186            verb: &str,
2187            _params: Value,
2188            _registry: &VerbRegistry,
2189            _token: &NamespaceToken,
2190        ) -> Result<Value, RuntimeError> {
2191            Ok(serde_json::json!({ "pack": "alpha", "verb": verb }))
2192        }
2193    }
2194
2195    /// A pack whose `dispatch` sleeps for a fixed, generous duration so
2196    /// `duration_us` regression tests (ADR-103 Stage 1) have a reliably
2197    /// nonzero, non-flaky measured dispatch time to assert against.
2198    struct SleepingPack;
2199
2200    impl Pack for SleepingPack {
2201        const NAME: &'static str = "sleeping";
2202        const NOTE_KINDS: &'static [&'static str] = &[];
2203        const ENTITY_KINDS: &'static [&'static str] = &[];
2204        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
2205            name: "slow_op",
2206            description: "sleeps before returning",
2207            visibility: Visibility::Verb,
2208            category: VerbCategory::Assertive,
2209            params: &[],
2210        }];
2211    }
2212
2213    #[async_trait]
2214    impl PackRuntime for SleepingPack {
2215        fn name(&self) -> &str {
2216            SleepingPack::NAME
2217        }
2218        fn note_kinds(&self) -> &'static [&'static str] {
2219            SleepingPack::NOTE_KINDS
2220        }
2221        fn entity_kinds(&self) -> &'static [&'static str] {
2222            SleepingPack::ENTITY_KINDS
2223        }
2224        fn handlers(&self) -> &'static [HandlerDef] {
2225            SleepingPack::HANDLERS
2226        }
2227        async fn dispatch(
2228            &self,
2229            verb: &str,
2230            _params: Value,
2231            _registry: &VerbRegistry,
2232            _token: &NamespaceToken,
2233        ) -> Result<Value, RuntimeError> {
2234            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2235            Ok(serde_json::json!({ "pack": "sleeping", "verb": verb }))
2236        }
2237    }
2238
2239    struct BetaPack;
2240
2241    impl Pack for BetaPack {
2242        const NAME: &'static str = "beta";
2243        const NOTE_KINDS: &'static [&'static str] = &["alert"];
2244        const ENTITY_KINDS: &'static [&'static str] = &["widget", "gadget"];
2245        const HANDLERS: &'static [HandlerDef] = &[
2246            HandlerDef {
2247                name: "notify",
2248                description: "send alert",
2249                visibility: Visibility::Verb,
2250                category: VerbCategory::Commissive,
2251                params: &[],
2252            },
2253            // "create" is Subhandler so it does NOT collide with AlphaPack's
2254            // Verb-visibility "create" — subhandlers are pack-internal and
2255            // excluded from cross-pack collision detection.
2256            HandlerDef {
2257                name: "create",
2258                description: "beta internal create (subhandler)",
2259                visibility: Visibility::Subhandler,
2260                category: VerbCategory::Commissive,
2261                params: &[],
2262            },
2263        ];
2264    }
2265
2266    /// Build a registry with AlphaPack + BetaPack.
2267    ///
2268    /// BetaPack's `create` is Subhandler so there is no Verb-visibility
2269    /// collision with AlphaPack's `create` Verb. Tests that need a collision
2270    /// use `build_colliding_registry()` instead.
2271    fn build_registry() -> VerbRegistry {
2272        let mut builder = VerbRegistryBuilder::new();
2273        builder.register(AlphaPack);
2274        builder.register(BetaPack);
2275        builder.build().expect("registry builds without collision")
2276    }
2277
2278    /// Build a registry with two packs that declare the same Verb-visibility
2279    /// handler — used to test that `VerbCollision` is raised at build time.
2280    struct CollidingPack;
2281
2282    impl Pack for CollidingPack {
2283        const NAME: &'static str = "colliding";
2284        const NOTE_KINDS: &'static [&'static str] = &[];
2285        const ENTITY_KINDS: &'static [&'static str] = &[];
2286        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
2287            name: "create",
2288            description: "duplicate Verb-visibility create",
2289            visibility: Visibility::Verb,
2290            category: VerbCategory::Commissive,
2291            params: &[],
2292        }];
2293    }
2294
2295    #[async_trait]
2296    impl PackRuntime for CollidingPack {
2297        fn name(&self) -> &str {
2298            Self::NAME
2299        }
2300        fn note_kinds(&self) -> &'static [&'static str] {
2301            Self::NOTE_KINDS
2302        }
2303        fn entity_kinds(&self) -> &'static [&'static str] {
2304            Self::ENTITY_KINDS
2305        }
2306        fn handlers(&self) -> &'static [HandlerDef] {
2307            Self::HANDLERS
2308        }
2309        async fn dispatch(
2310            &self,
2311            verb: &str,
2312            _params: Value,
2313            _registry: &VerbRegistry,
2314            _token: &NamespaceToken,
2315        ) -> Result<Value, RuntimeError> {
2316            Ok(serde_json::json!({ "pack": "colliding", "verb": verb }))
2317        }
2318    }
2319
2320    #[async_trait]
2321    impl PackRuntime for BetaPack {
2322        fn name(&self) -> &str {
2323            BetaPack::NAME
2324        }
2325        fn note_kinds(&self) -> &'static [&'static str] {
2326            BetaPack::NOTE_KINDS
2327        }
2328        fn entity_kinds(&self) -> &'static [&'static str] {
2329            BetaPack::ENTITY_KINDS
2330        }
2331        fn handlers(&self) -> &'static [HandlerDef] {
2332            BetaPack::HANDLERS
2333        }
2334        async fn dispatch(
2335            &self,
2336            verb: &str,
2337            _params: Value,
2338            _registry: &VerbRegistry,
2339            _token: &NamespaceToken,
2340        ) -> Result<Value, RuntimeError> {
2341            Ok(serde_json::json!({ "pack": "beta", "verb": verb }))
2342        }
2343    }
2344
2345    #[tokio::test]
2346    async fn dispatch_routes_to_correct_pack() {
2347        let reg = build_registry();
2348
2349        let res = reg.dispatch("list", Value::Null).await.unwrap();
2350        assert_eq!(res["pack"], "alpha");
2351
2352        let res = reg.dispatch("notify", Value::Null).await.unwrap();
2353        assert_eq!(res["pack"], "beta");
2354    }
2355
2356    /// Two packs declaring the same `Visibility::Verb` handler must be
2357    /// rejected at build time — the old "first registered wins" behaviour is
2358    /// replaced by a boot error.
2359    #[test]
2360    fn verb_collision_is_boot_time_error() {
2361        let mut builder = VerbRegistryBuilder::new();
2362        builder.register(AlphaPack);
2363        builder.register(CollidingPack);
2364        let err = builder
2365            .build()
2366            .err()
2367            .expect("duplicate Verb-visibility handler must be rejected at build time");
2368        assert!(
2369            matches!(err, RuntimeError::VerbCollision { ref verb, .. } if verb == "create"),
2370            "expected VerbCollision for 'create', got {err:?}"
2371        );
2372        let msg = err.to_string();
2373        assert!(
2374            msg.contains("create"),
2375            "error must name the colliding verb: {msg}"
2376        );
2377        assert!(
2378            msg.contains("alpha") || msg.contains("colliding"),
2379            "error must name one of the conflicting packs: {msg}"
2380        );
2381    }
2382
2383    /// Subhandler-visibility handlers with the same name across packs are NOT
2384    /// a collision — they are pack-internal and excluded from cross-pack
2385    /// collision detection.
2386    #[test]
2387    fn subhandler_same_name_across_packs_is_not_a_collision() {
2388        struct SubhandlerPack;
2389        impl Pack for SubhandlerPack {
2390            const NAME: &'static str = "subhandler_pack";
2391            const NOTE_KINDS: &'static [&'static str] = &[];
2392            const ENTITY_KINDS: &'static [&'static str] = &[];
2393            const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
2394                name: "create",
2395                description: "internal create",
2396                visibility: Visibility::Subhandler,
2397                category: VerbCategory::Commissive,
2398                params: &[],
2399            }];
2400        }
2401        #[async_trait]
2402        impl PackRuntime for SubhandlerPack {
2403            fn name(&self) -> &str {
2404                Self::NAME
2405            }
2406            fn note_kinds(&self) -> &'static [&'static str] {
2407                Self::NOTE_KINDS
2408            }
2409            fn entity_kinds(&self) -> &'static [&'static str] {
2410                Self::ENTITY_KINDS
2411            }
2412            fn handlers(&self) -> &'static [HandlerDef] {
2413                Self::HANDLERS
2414            }
2415            async fn dispatch(
2416                &self,
2417                verb: &str,
2418                _: Value,
2419                _: &VerbRegistry,
2420                _: &NamespaceToken,
2421            ) -> Result<Value, RuntimeError> {
2422                Ok(serde_json::json!({"pack": "subhandler_pack", "verb": verb}))
2423            }
2424        }
2425        let mut builder = VerbRegistryBuilder::new();
2426        builder.register(AlphaPack); // AlphaPack has Verb "create"
2427        builder.register(SubhandlerPack); // SubhandlerPack has Subhandler "create" — no collision
2428        builder
2429            .build()
2430            .expect("subhandler same name must NOT be a collision");
2431    }
2432
2433    #[tokio::test]
2434    async fn dispatch_unknown_verb_returns_error() {
2435        let reg = build_registry();
2436
2437        let err = reg.dispatch("explode", Value::Null).await.unwrap_err();
2438        let msg = err.to_string();
2439        assert!(msg.contains("explode"));
2440        assert!(msg.contains("create"));
2441    }
2442
2443    /// `all_verbs` returns only `Visibility::Verb` entries.
2444    ///
2445    /// BetaPack's `create` is `Visibility::Subhandler` — it must NOT appear
2446    /// in `all_verbs()` even though it has the same name as a Verb in AlphaPack.
2447    #[test]
2448    fn all_verbs_aggregates_across_packs_excludes_subhandlers() {
2449        let reg = build_registry();
2450        let verbs: Vec<&str> = reg.all_verbs().iter().map(|v| v.name).collect();
2451        // BetaPack's "create" (Subhandler) is absent; only Verb-visibility entries appear.
2452        assert_eq!(verbs, vec!["create", "list", "notify"]);
2453    }
2454
2455    #[test]
2456    fn all_verbs_with_names_pairs_pack_name_excludes_subhandlers() {
2457        let reg = build_registry();
2458        let pairs: Vec<(&str, &str)> = reg
2459            .all_verbs_with_names()
2460            .iter()
2461            .map(|(pack, v)| (*pack, v.name))
2462            .collect();
2463        // BetaPack's "create" is Subhandler and must NOT appear here.
2464        assert_eq!(
2465            pairs,
2466            vec![("alpha", "create"), ("alpha", "list"), ("beta", "notify"),]
2467        );
2468    }
2469
2470    #[test]
2471    fn all_handlers_with_names_includes_subhandlers() {
2472        let reg = build_registry();
2473        let pairs: Vec<(&str, &str)> = reg
2474            .all_handlers_with_names()
2475            .iter()
2476            .map(|(pack, v)| (*pack, v.name))
2477            .collect();
2478        // BetaPack's Subhandler "create" IS present in the full handler list.
2479        assert_eq!(
2480            pairs,
2481            vec![
2482                ("alpha", "create"),
2483                ("alpha", "list"),
2484                ("beta", "notify"),
2485                ("beta", "create"),
2486            ]
2487        );
2488    }
2489
2490    #[test]
2491    fn note_kinds_are_ordered() {
2492        let reg = build_registry();
2493        let kinds = reg.all_note_kinds();
2494        assert_eq!(kinds, vec!["memo", "log", "alert"]);
2495    }
2496
2497    #[test]
2498    fn note_kind_duplicate_rejected_at_build_time() {
2499        struct DupPack;
2500
2501        impl khive_types::Pack for DupPack {
2502            const NAME: &'static str = "dup";
2503            // "memo" is already declared by AlphaPack — must be rejected at build.
2504            const NOTE_KINDS: &'static [&'static str] = &["memo"];
2505            const ENTITY_KINDS: &'static [&'static str] = &[];
2506            const HANDLERS: &'static [HandlerDef] = &[];
2507        }
2508
2509        #[async_trait]
2510        impl PackRuntime for DupPack {
2511            fn name(&self) -> &str {
2512                Self::NAME
2513            }
2514            fn note_kinds(&self) -> &'static [&'static str] {
2515                Self::NOTE_KINDS
2516            }
2517            fn entity_kinds(&self) -> &'static [&'static str] {
2518                Self::ENTITY_KINDS
2519            }
2520            fn handlers(&self) -> &'static [HandlerDef] {
2521                Self::HANDLERS
2522            }
2523            async fn dispatch(
2524                &self,
2525                _verb: &str,
2526                _params: Value,
2527                _registry: &VerbRegistry,
2528                _token: &NamespaceToken,
2529            ) -> Result<Value, RuntimeError> {
2530                Ok(Value::Null)
2531            }
2532        }
2533
2534        let mut builder = VerbRegistryBuilder::new();
2535        builder.register(AlphaPack);
2536        builder.register(DupPack);
2537        let err = builder
2538            .build()
2539            .err()
2540            .expect("duplicate note kind must be rejected");
2541        let msg = err.to_string();
2542        assert!(
2543            msg.contains("memo"),
2544            "error must name the duplicate kind: {msg}"
2545        );
2546        assert!(
2547            msg.contains("alpha") || msg.contains("dup"),
2548            "error must name one of the conflicting packs: {msg}"
2549        );
2550    }
2551
2552    #[test]
2553    fn entity_kinds_are_deduplicated() {
2554        let reg = build_registry();
2555        let kinds = reg.all_entity_kinds();
2556        assert_eq!(kinds, vec!["widget", "gadget"]);
2557    }
2558
2559    // ---- Gate wiring ----
2560
2561    use khive_gate::{Gate, GateError};
2562    use std::sync::atomic::{AtomicUsize, Ordering};
2563    use std::sync::Arc;
2564
2565    #[derive(Default, Debug)]
2566    struct CountingGate {
2567        calls: AtomicUsize,
2568        deny_verb: Option<&'static str>,
2569    }
2570
2571    impl Gate for CountingGate {
2572        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
2573            self.calls.fetch_add(1, Ordering::SeqCst);
2574            if Some(req.verb.as_str()) == self.deny_verb {
2575                Ok(GateDecision::deny(format!("test deny for {}", req.verb)))
2576            } else {
2577                Ok(GateDecision::allow())
2578            }
2579        }
2580    }
2581
2582    #[tokio::test]
2583    async fn dispatch_consults_the_gate() {
2584        let gate = Arc::new(CountingGate::default());
2585        let mut builder = VerbRegistryBuilder::new();
2586        builder.register(AlphaPack);
2587        builder.with_gate(gate.clone());
2588        let reg = builder.build().expect("registry builds");
2589
2590        reg.dispatch("list", Value::Null).await.unwrap();
2591        reg.dispatch("create", Value::Null).await.unwrap();
2592        assert_eq!(
2593            gate.calls.load(Ordering::SeqCst),
2594            2,
2595            "gate should be consulted once per dispatch"
2596        );
2597    }
2598
2599    #[tokio::test]
2600    async fn dispatch_returns_permission_denied_on_deny_v03() {
2601        let gate = Arc::new(CountingGate {
2602            calls: AtomicUsize::new(0),
2603            deny_verb: Some("create"),
2604        });
2605        let mut builder = VerbRegistryBuilder::new();
2606        builder.register(AlphaPack);
2607        builder.with_gate(gate.clone());
2608        let reg = builder.build().expect("registry builds");
2609
2610        // Gate denies — dispatch now returns PermissionDenied (hard enforcement).
2611        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
2612        assert!(
2613            matches!(err, RuntimeError::PermissionDenied { ref verb, .. } if verb == "create"),
2614            "expected PermissionDenied, got {err:?}"
2615        );
2616        let msg = err.to_string();
2617        assert!(
2618            msg.contains("create"),
2619            "error message must name the verb: {msg}"
2620        );
2621        assert!(
2622            msg.contains("test deny for create"),
2623            "error message must carry the deny reason: {msg}"
2624        );
2625        assert_eq!(gate.calls.load(Ordering::SeqCst), 1);
2626    }
2627
2628    #[tokio::test]
2629    async fn dispatch_allow_verb_succeeds_even_with_deny_gate_for_other_verb() {
2630        // Deny only "create" — "list" must still work.
2631        let gate = Arc::new(CountingGate {
2632            calls: AtomicUsize::new(0),
2633            deny_verb: Some("create"),
2634        });
2635        let mut builder = VerbRegistryBuilder::new();
2636        builder.register(AlphaPack);
2637        builder.with_gate(gate.clone());
2638        let reg = builder.build().expect("registry builds");
2639
2640        let res = reg.dispatch("list", Value::Null).await.unwrap();
2641        assert_eq!(res["pack"], "alpha");
2642    }
2643
2644    #[tokio::test]
2645    async fn dispatch_uses_allow_all_gate_by_default() {
2646        // No `with_gate` call — builder should use `AllowAllGate` so dispatch works.
2647        let reg = build_registry();
2648        let res = reg.dispatch("list", Value::Null).await.unwrap();
2649        assert_eq!(res["pack"], "alpha");
2650    }
2651
2652    // Captures the namespace each call sees so we can assert what the gate
2653    // actually receives, rather than assuming a hard-wired `default_ns()`.
2654    #[derive(Default, Debug)]
2655    struct NamespaceCapturingGate {
2656        seen: std::sync::Mutex<Vec<String>>,
2657    }
2658
2659    impl Gate for NamespaceCapturingGate {
2660        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
2661            self.seen
2662                .lock()
2663                .unwrap()
2664                .push(req.namespace.as_str().to_string());
2665            Ok(GateDecision::allow())
2666        }
2667    }
2668
2669    #[tokio::test]
2670    async fn dispatch_propagates_params_namespace_to_gate() {
2671        let gate = Arc::new(NamespaceCapturingGate::default());
2672        let mut builder = VerbRegistryBuilder::new();
2673        builder.register(AlphaPack);
2674        builder.with_gate(gate.clone());
2675        builder.with_default_namespace("tenant-x");
2676        let reg = builder.build().expect("registry builds");
2677
2678        // Explicit namespace in params wins.
2679        reg.dispatch("list", serde_json::json!({"namespace": "tenant-y"}))
2680            .await
2681            .unwrap();
2682        // Missing namespace → registry default.
2683        reg.dispatch("list", Value::Null).await.unwrap();
2684        // Empty string is rejected: Namespace::parse("") fails → InvalidInput error.
2685        let err = reg
2686            .dispatch("list", serde_json::json!({"namespace": ""}))
2687            .await
2688            .unwrap_err();
2689        assert!(
2690            matches!(err, RuntimeError::InvalidInput(_)),
2691            "empty namespace must return InvalidInput, got {err:?}"
2692        );
2693
2694        let seen = gate.seen.lock().unwrap().clone();
2695        assert_eq!(seen, vec!["tenant-y", "tenant-x"]);
2696    }
2697
2698    #[tokio::test]
2699    async fn dispatch_falls_back_to_local_when_no_default_set() {
2700        // Builder default mirrors `Namespace::default_ns()`.
2701        let gate = Arc::new(NamespaceCapturingGate::default());
2702        let mut builder = VerbRegistryBuilder::new();
2703        builder.register(AlphaPack);
2704        builder.with_gate(gate.clone());
2705        let reg = builder.build().expect("registry builds");
2706
2707        reg.dispatch("list", Value::Null).await.unwrap();
2708        let seen = gate.seen.lock().unwrap().clone();
2709        assert_eq!(seen, vec!["local"]);
2710    }
2711
2712    /// A present-but-malformed `namespace` value must never reach the gate as
2713    /// the default namespace. Table-driven over every
2714    /// non-string JSON type; the gate-spy proves no call is ever recorded (the
2715    /// dispatch must short-circuit with `InvalidInput` before `GateRequest` is
2716    /// built), so the default namespace can never appear as a coerced stand-in.
2717    #[tokio::test]
2718    async fn namespace_null_rejected_not_coerced() {
2719        let cases: Vec<(&str, Value)> = vec![
2720            ("null", Value::Null),
2721            ("number", serde_json::json!(42)),
2722            ("boolean", serde_json::json!(true)),
2723            ("array", serde_json::json!(["local"])),
2724            ("object", serde_json::json!({"ns": "local"})),
2725        ];
2726
2727        for (label, ns_value) in cases {
2728            let gate = Arc::new(NamespaceCapturingGate::default());
2729            let mut builder = VerbRegistryBuilder::new();
2730            builder.register(AlphaPack);
2731            builder.with_gate(gate.clone());
2732            builder.with_default_namespace("tenant-x");
2733            let reg = builder.build().expect("registry builds");
2734
2735            let err = reg
2736                .dispatch("list", serde_json::json!({"namespace": ns_value}))
2737                .await
2738                .unwrap_err();
2739            assert!(
2740                matches!(err, RuntimeError::InvalidInput(_)),
2741                "case {label}: expected InvalidInput, got {err:?}"
2742            );
2743
2744            // The gate must never have been consulted for this malformed input —
2745            // proves no Allow decision (and therefore no default-namespace write)
2746            // can ever be reached for it.
2747            let seen = gate.seen.lock().unwrap().clone();
2748            assert!(
2749                seen.is_empty(),
2750                "case {label}: gate must not be consulted for malformed namespace, saw {seen:?}"
2751            );
2752        }
2753    }
2754
2755    // ---- Audit event emission ----
2756
2757    use khive_gate::{AuditDecision, AuditEvent, Obligation};
2758
2759    /// A gate that records every audit event emitted via from_check.
2760    #[derive(Default, Debug)]
2761    struct AuditCapturingGate {
2762        events: std::sync::Mutex<Vec<AuditEvent>>,
2763        deny_verb: Option<&'static str>,
2764    }
2765
2766    impl Gate for AuditCapturingGate {
2767        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
2768            let decision = if Some(req.verb.as_str()) == self.deny_verb {
2769                GateDecision::deny("test deny")
2770            } else {
2771                GateDecision::allow_with(vec![Obligation::Audit {
2772                    tag: format!("{}.check", req.verb),
2773                }])
2774            };
2775            // Capture what dispatch will also emit.
2776            let ev = AuditEvent::from_check(req, &decision, self.impl_name());
2777            self.events.lock().unwrap().push(ev);
2778            Ok(decision)
2779        }
2780
2781        fn impl_name(&self) -> &'static str {
2782            "AuditCapturingGate"
2783        }
2784    }
2785
2786    #[tokio::test]
2787    async fn dispatch_emits_one_audit_event_per_call() {
2788        let gate = Arc::new(AuditCapturingGate::default());
2789        let mut builder = VerbRegistryBuilder::new();
2790        builder.register(AlphaPack);
2791        builder.with_gate(gate.clone());
2792        let reg = builder.build().expect("registry builds");
2793
2794        reg.dispatch("list", Value::Null).await.unwrap();
2795        reg.dispatch("create", Value::Null).await.unwrap();
2796
2797        let evs = gate.events.lock().unwrap();
2798        assert_eq!(evs.len(), 2, "exactly one audit event per dispatch call");
2799    }
2800
2801    #[tokio::test]
2802    async fn dispatch_audit_event_allow_carries_obligations() {
2803        let gate = Arc::new(AuditCapturingGate::default());
2804        let mut builder = VerbRegistryBuilder::new();
2805        builder.register(AlphaPack);
2806        builder.with_gate(gate.clone());
2807        let reg = builder.build().expect("registry builds");
2808
2809        reg.dispatch("list", Value::Null).await.unwrap();
2810
2811        let evs = gate.events.lock().unwrap();
2812        let ev = &evs[0];
2813        assert_eq!(ev.verb, "list");
2814        assert_eq!(ev.decision, AuditDecision::Allow);
2815        assert!(ev.deny_reason.is_none());
2816        assert_eq!(ev.obligations.len(), 1);
2817        assert_eq!(ev.gate_impl, "AuditCapturingGate");
2818    }
2819
2820    #[tokio::test]
2821    async fn dispatch_audit_event_deny_carries_reason() {
2822        let gate = Arc::new(AuditCapturingGate {
2823            events: Default::default(),
2824            deny_verb: Some("create"),
2825        });
2826        let mut builder = VerbRegistryBuilder::new();
2827        builder.register(AlphaPack);
2828        builder.with_gate(gate.clone());
2829        let reg = builder.build().expect("registry builds");
2830
2831        // Gate denies — dispatch returns PermissionDenied (hard enforcement).
2832        // The audit event is still recorded (captured inside the gate impl).
2833        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
2834        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
2835
2836        let evs = gate.events.lock().unwrap();
2837        let ev = &evs[0];
2838        assert_eq!(ev.verb, "create");
2839        assert_eq!(ev.decision, AuditDecision::Deny);
2840        assert_eq!(ev.deny_reason.as_deref(), Some("test deny"));
2841        assert!(ev.obligations.is_empty());
2842    }
2843
2844    #[tokio::test]
2845    async fn dispatch_audit_event_fields_match_gate_request() {
2846        let gate = Arc::new(AuditCapturingGate::default());
2847        let mut builder = VerbRegistryBuilder::new();
2848        builder.register(AlphaPack);
2849        builder.with_gate(gate.clone());
2850        builder.with_default_namespace("tenant-z");
2851        let reg = builder.build().expect("registry builds");
2852
2853        reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
2854            .await
2855            .unwrap();
2856
2857        let evs = gate.events.lock().unwrap();
2858        let ev = &evs[0];
2859        // Namespace from params wins.
2860        assert_eq!(ev.namespace, "tenant-q");
2861        assert_eq!(ev.verb, "list");
2862        assert_eq!(ev.actor.kind, "anonymous");
2863    }
2864
2865    // ---- Actor attribution threading into gate request (ADR-057) ----
2866
2867    /// A gate spy that captures the raw `GateRequest` it receives.
2868    #[derive(Default, Debug)]
2869    struct ActorCapturingGate {
2870        requests: std::sync::Mutex<Vec<GateRequest>>,
2871    }
2872
2873    impl Gate for ActorCapturingGate {
2874        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
2875            self.requests.lock().unwrap().push(req.clone());
2876            Ok(GateDecision::allow())
2877        }
2878    }
2879
2880    /// When `actor_id` is configured, the gate request carries that actor, not
2881    /// anonymous. This exercises the ADR-057 attribution fix: the gate can
2882    /// distinguish an agent caller from an unauthenticated caller.
2883    #[tokio::test]
2884    async fn gate_request_carries_configured_actor_when_actor_id_is_set() {
2885        let gate = Arc::new(ActorCapturingGate::default());
2886        let mut builder = VerbRegistryBuilder::new();
2887        builder.register(AlphaPack);
2888        builder.with_gate(gate.clone());
2889        builder.with_actor_id(Some("team-abc:implementer".to_string()));
2890        let reg = builder.build().expect("registry builds");
2891
2892        reg.dispatch("list", Value::Null).await.unwrap();
2893
2894        let reqs = gate.requests.lock().unwrap();
2895        assert_eq!(reqs.len(), 1);
2896        let req = &reqs[0];
2897        assert_eq!(
2898            req.actor.kind, "actor",
2899            "gate request must carry kind='actor' when actor_id is configured"
2900        );
2901        assert_eq!(
2902            req.actor.id, "team-abc:implementer",
2903            "gate request must carry the configured actor id"
2904        );
2905    }
2906
2907    /// When no `actor_id` is configured, the gate request still receives the
2908    /// anonymous actor (no regression to the party-line default).
2909    #[tokio::test]
2910    async fn gate_request_carries_anonymous_when_no_actor_id_configured() {
2911        let gate = Arc::new(ActorCapturingGate::default());
2912        let mut builder = VerbRegistryBuilder::new();
2913        builder.register(AlphaPack);
2914        builder.with_gate(gate.clone());
2915        // actor_id left at default (None).
2916        let reg = builder.build().expect("registry builds");
2917
2918        reg.dispatch("list", Value::Null).await.unwrap();
2919
2920        let reqs = gate.requests.lock().unwrap();
2921        assert_eq!(reqs.len(), 1);
2922        let req = &reqs[0];
2923        assert_eq!(
2924            req.actor.kind, "anonymous",
2925            "gate request must carry anonymous actor when no actor_id is configured"
2926        );
2927        assert_eq!(req.actor.id, "local");
2928    }
2929
2930    /// A pack that records the `ActorRef` carried by the `NamespaceToken` it
2931    /// is dispatched with, so tests can compare it against the gate's actor.
2932    struct TokenCapturingPack {
2933        actors: Arc<std::sync::Mutex<Vec<khive_gate::ActorRef>>>,
2934    }
2935
2936    impl Pack for TokenCapturingPack {
2937        const NAME: &'static str = "alpha";
2938        const NOTE_KINDS: &'static [&'static str] = &[];
2939        const ENTITY_KINDS: &'static [&'static str] = &[];
2940        const HANDLERS: &'static [HandlerDef] = AlphaPack::HANDLERS;
2941    }
2942
2943    #[async_trait]
2944    impl PackRuntime for TokenCapturingPack {
2945        fn name(&self) -> &str {
2946            Self::NAME
2947        }
2948        fn note_kinds(&self) -> &'static [&'static str] {
2949            Self::NOTE_KINDS
2950        }
2951        fn entity_kinds(&self) -> &'static [&'static str] {
2952            Self::ENTITY_KINDS
2953        }
2954        fn handlers(&self) -> &'static [HandlerDef] {
2955            Self::HANDLERS
2956        }
2957        async fn dispatch(
2958            &self,
2959            verb: &str,
2960            _params: Value,
2961            _registry: &VerbRegistry,
2962            token: &NamespaceToken,
2963        ) -> Result<Value, RuntimeError> {
2964            self.actors.lock().unwrap().push(token.actor().clone());
2965            Ok(serde_json::json!({ "pack": "alpha", "verb": verb }))
2966        }
2967    }
2968
2969    /// The gate's actor and the storage token's actor must be the exact same
2970    /// resolved value: both come from one `resolve_actor` call
2971    /// (`resolved_actor`) instead of two independently hand-synchronized
2972    /// `match` expressions, so a future edit to one copy but not the other
2973    /// cannot silently desynchronize "who the gate thinks the caller is" from
2974    /// "who the storage layer thinks the caller is". Reintroducing a second
2975    /// independent actor-resolution copy for the token would regress this and
2976    /// this test would catch it.
2977    #[tokio::test]
2978    async fn gate_actor_and_token_actor_are_identical_when_actor_id_is_set() {
2979        let gate = Arc::new(ActorCapturingGate::default());
2980        let actors = Arc::new(std::sync::Mutex::new(Vec::new()));
2981        let pack = TokenCapturingPack {
2982            actors: actors.clone(),
2983        };
2984        let mut builder = VerbRegistryBuilder::new();
2985        builder.register(pack);
2986        builder.with_gate(gate.clone());
2987        builder.with_actor_id(Some("actor-alpha".to_string()));
2988        let reg = builder.build().expect("registry builds");
2989
2990        reg.dispatch("list", Value::Null).await.unwrap();
2991
2992        let reqs = gate.requests.lock().unwrap();
2993        let gate_actor = reqs[0].actor.clone();
2994        drop(reqs);
2995
2996        let captured = actors.lock().unwrap();
2997        let token_actor = captured[0].clone();
2998
2999        assert_eq!(
3000            gate_actor.kind, token_actor.kind,
3001            "gate request actor and storage token actor must carry the same kind"
3002        );
3003        assert_eq!(
3004            gate_actor.id, token_actor.id,
3005            "gate request actor and storage token actor must carry the same id"
3006        );
3007        assert_eq!(gate_actor.id, "actor-alpha");
3008    }
3009
3010    /// Same identity check with no configured `actor_id`: both the gate and
3011    /// the storage token must independently land on `ActorRef::anonymous()`.
3012    #[tokio::test]
3013    async fn gate_actor_and_token_actor_are_identical_when_anonymous() {
3014        let gate = Arc::new(ActorCapturingGate::default());
3015        let actors = Arc::new(std::sync::Mutex::new(Vec::new()));
3016        let pack = TokenCapturingPack {
3017            actors: actors.clone(),
3018        };
3019        let mut builder = VerbRegistryBuilder::new();
3020        builder.register(pack);
3021        builder.with_gate(gate.clone());
3022        let reg = builder.build().expect("registry builds");
3023
3024        reg.dispatch("list", Value::Null).await.unwrap();
3025
3026        let reqs = gate.requests.lock().unwrap();
3027        let gate_actor = reqs[0].actor.clone();
3028        drop(reqs);
3029
3030        let captured = actors.lock().unwrap();
3031        let token_actor = captured[0].clone();
3032
3033        assert_eq!(gate_actor.kind, token_actor.kind);
3034        assert_eq!(gate_actor.id, token_actor.id);
3035        assert_eq!(gate_actor.id, "local");
3036    }
3037
3038    // ---- Rego gate: fail-closed end-to-end ----
3039
3040    /// A `RegoGate` whose policy lacks the named entrypoint rule must cause
3041    /// `VerbRegistry::dispatch` to return `RuntimeError::PermissionDenied` —
3042    /// never to proceed to the pack handler.
3043    ///
3044    /// This is the runtime-level assertion that a gate evaluation failure
3045    /// fails closed rather than opening a security hole.
3046    /// `RegoGate::check` converts all evaluation failures (missing rule,
3047    /// undefined result, serialization error, poisoned engine) to
3048    /// `Ok(GateDecision::Deny)`, so dispatch is blocked. The runtime's
3049    /// fail-open `Err(_)` branch remains for non-evaluation gate errors
3050    /// (e.g. infrastructure faults from other `Gate` implementations).
3051    #[tokio::test]
3052    async fn rego_gate_missing_entrypoint_returns_permission_denied() {
3053        use khive_gate_rego::RegoGate;
3054
3055        // Policy defines `verdict` but NOT `data.khive.gate.decision` (the
3056        // default entrypoint).  Construction succeeds — from_policy_str does
3057        // not validate the default entrypoint.  check() must convert the
3058        // missing-rule evaluation error to Ok(Deny) so the runtime denies
3059        // the request rather than treating the Err as a fail-open signal.
3060        let policy = r#"
3061            package khive.gate
3062            import rego.v1
3063            verdict := "allow"
3064        "#;
3065        let gate = Arc::new(RegoGate::from_policy_str(policy).expect("policy compiles"));
3066
3067        let mut builder = VerbRegistryBuilder::new();
3068        builder.register(AlphaPack);
3069        builder.with_gate(gate);
3070        let reg = builder.build().expect("registry builds");
3071
3072        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
3073        assert!(
3074            matches!(err, RuntimeError::PermissionDenied { ref verb, .. } if verb == "create"),
3075            "expected PermissionDenied for missing rego entrypoint, got {err:?}"
3076        );
3077    }
3078
3079    // ---- Audit tracing emission ----
3080    //
3081    // The AuditCapturingGate tests above prove that AuditEvent::from_check is
3082    // called with the right inputs, but they observe the event *inside* the
3083    // gate impl — they would still pass if dispatch's
3084    // `tracing::info!(audit_event = ..., "gate.check")` were deleted or
3085    // renamed. The tests below install a capture Layer and assert on the
3086    // actual tracing event surfaced from dispatch. This locks the public
3087    // observability contract: one `gate.check` info event per dispatch,
3088    // carrying an `audit_event` field that round-trips back to an `AuditEvent`.
3089
3090    use std::sync::{Mutex as StdMutex, Once, OnceLock};
3091
3092    use serial_test::serial;
3093    use tracing::field::{Field, Visit};
3094
3095    #[derive(Clone, Debug, Default)]
3096    struct CapturedEvent {
3097        message: Option<String>,
3098        audit_event: Option<String>,
3099    }
3100
3101    #[derive(Default)]
3102    struct CapturedEventVisitor(CapturedEvent);
3103
3104    impl Visit for CapturedEventVisitor {
3105        fn record_str(&mut self, field: &Field, value: &str) {
3106            match field.name() {
3107                "message" => self.0.message = Some(value.to_string()),
3108                "audit_event" => self.0.audit_event = Some(value.to_string()),
3109                _ => {}
3110            }
3111        }
3112
3113        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
3114            // `tracing::info!(audit_event = %expr, "msg")` records via the
3115            // Display-wrapped Debug path, so we receive the JSON string here.
3116            // `"msg"` literal records as a `message` field via `record_debug`
3117            // with a quoted Debug representation; strip the surrounding quotes
3118            // so the captured message matches the source.
3119            let formatted = format!("{value:?}");
3120            let cleaned = formatted
3121                .trim_start_matches('"')
3122                .trim_end_matches('"')
3123                .to_string();
3124            match field.name() {
3125                "message" => self.0.message = Some(cleaned),
3126                "audit_event" => self.0.audit_event = Some(cleaned),
3127                _ => {}
3128            }
3129        }
3130    }
3131
3132    /// Minimal `tracing::Subscriber` that captures events into a shared vec.
3133    ///
3134    /// Implemented directly (without `tracing_subscriber::registry()` layering)
3135    /// to avoid the layer machinery that can cause thread-local dispatch to be
3136    /// bypassed when the registry's internal global state is initialised by
3137    /// another subscriber in the same test binary.
3138    ///
3139    /// Isolation across concurrent tests is handled at the dispatcher level by
3140    /// `tracing::dispatcher::with_default`, which installs this subscriber
3141    /// as the thread-local default for the duration of the test closure.
3142    /// Other threads (e.g. `#[tokio::test]` pool workers) emit through their
3143    /// own (typically NoSubscriber) dispatchers and never reach this instance.
3144    struct CaptureSubscriber {
3145        events: Arc<StdMutex<Vec<CapturedEvent>>>,
3146    }
3147
3148    impl CaptureSubscriber {
3149        fn new(events: Arc<StdMutex<Vec<CapturedEvent>>>) -> Self {
3150            Self { events }
3151        }
3152    }
3153
3154    impl tracing::Subscriber for CaptureSubscriber {
3155        fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
3156            true
3157        }
3158        fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
3159            tracing::span::Id::from_u64(1)
3160        }
3161        fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
3162        fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
3163        fn event(&self, event: &tracing::Event<'_>) {
3164            let mut visitor = CapturedEventVisitor::default();
3165            event.record(&mut visitor);
3166            self.events.lock().unwrap().push(visitor.0);
3167        }
3168        fn enter(&self, _: &tracing::span::Id) {}
3169        fn exit(&self, _: &tracing::span::Id) {}
3170    }
3171
3172    /// Global capture buffer for the tracing tests.
3173    ///
3174    /// The subscriber is installed exactly once via `set_global_default`
3175    /// (thread-local dispatchers via `with_default` proved unreliable when
3176    /// other tests in the binary configure their own dispatchers in parallel —
3177    /// the global state interacted unpredictably and events were lost).
3178    ///
3179    /// Each test that uses this buffer is `#[serial]`, so only one
3180    /// runs at a time. The buffer is cleared at the start of each capture call.
3181    static GLOBAL_CAPTURE: OnceLock<Arc<StdMutex<Vec<CapturedEvent>>>> = OnceLock::new();
3182    static GLOBAL_INIT: Once = Once::new();
3183
3184    fn global_capture() -> Arc<StdMutex<Vec<CapturedEvent>>> {
3185        GLOBAL_INIT.call_once(|| {
3186            let buffer = Arc::new(StdMutex::new(Vec::new()));
3187            let subscriber = CaptureSubscriber::new(Arc::clone(&buffer));
3188            // Ignore error: if another subscriber is already set globally, our
3189            // subscriber installation fails, but the buffer will simply stay
3190            // empty and tests will fail with a clear "got 0 events" message
3191            // rather than a silent corruption.
3192            let _ = tracing::subscriber::set_global_default(subscriber);
3193            let _ = GLOBAL_CAPTURE.set(buffer);
3194        });
3195        Arc::clone(GLOBAL_CAPTURE.get().expect("global capture initialized"))
3196    }
3197
3198    /// Run an async block under the global capture subscriber and return
3199    /// the events emitted during the run. Clears the buffer at the start.
3200    ///
3201    /// Callers MUST be `#[serial]` to prevent concurrent buffer pollution.
3202    fn capture_dispatch_events<Fut>(future: Fut) -> Vec<CapturedEvent>
3203    where
3204        Fut: std::future::Future<Output = ()>,
3205    {
3206        let buffer = global_capture();
3207        buffer.lock().unwrap().clear();
3208
3209        let rt = tokio::runtime::Builder::new_current_thread()
3210            .enable_all()
3211            .build()
3212            .expect("build current-thread tokio runtime");
3213        rt.block_on(future);
3214
3215        let result = buffer.lock().unwrap().clone();
3216        result
3217    }
3218
3219    /// Pull every captured event whose `message` matches `"gate.check"` AND
3220    /// whose audit_event JSON declares the expected `gate_impl` name.
3221    ///
3222    /// Filtering by `gate_impl` lets concurrent tests in the same binary
3223    /// emit their own gate.check events into the global capture buffer
3224    /// without polluting each others' counts.
3225    fn gate_check_events_for(events: &[CapturedEvent], gate_impl: &str) -> Vec<CapturedEvent> {
3226        events
3227            .iter()
3228            .filter(|e| e.message.as_deref() == Some("gate.check"))
3229            .filter(|e| {
3230                e.audit_event
3231                    .as_deref()
3232                    .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
3233                    .and_then(|v| {
3234                        v.get("gate_impl")
3235                            .and_then(|g| g.as_str().map(|s| s.to_string()))
3236                    })
3237                    .as_deref()
3238                    == Some(gate_impl)
3239            })
3240            .cloned()
3241            .collect()
3242    }
3243
3244    #[test]
3245    #[serial]
3246    fn dispatch_tracing_emits_one_gate_check_event_on_allow() {
3247        #[derive(Debug)]
3248        struct TracingAllowGate;
3249        impl Gate for TracingAllowGate {
3250            fn check(&self, _: &GateRequest) -> Result<GateDecision, GateError> {
3251                Ok(GateDecision::allow())
3252            }
3253            fn impl_name(&self) -> &'static str {
3254                "TracingAllowGate"
3255            }
3256        }
3257
3258        let events = capture_dispatch_events(async {
3259            let mut builder = VerbRegistryBuilder::new();
3260            builder.register(AlphaPack);
3261            builder.with_gate(Arc::new(TracingAllowGate));
3262            builder.with_default_namespace("tenant-default");
3263            let reg = builder.build().expect("registry builds");
3264            reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
3265                .await
3266                .unwrap();
3267        });
3268
3269        let gate_events = gate_check_events_for(&events, "TracingAllowGate");
3270        assert_eq!(
3271            gate_events.len(),
3272            1,
3273            "exactly one gate.check tracing event per dispatch (allow); got {gate_events:?}"
3274        );
3275        let payload = gate_events[0]
3276            .audit_event
3277            .as_ref()
3278            .expect("gate.check event must carry an audit_event field");
3279        let audit: khive_gate::AuditEvent =
3280            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
3281        assert_eq!(audit.decision, AuditDecision::Allow);
3282        assert_eq!(audit.verb, "list");
3283        assert_eq!(audit.namespace, "tenant-q");
3284        assert_eq!(audit.gate_impl, "TracingAllowGate");
3285        assert!(
3286            audit.deny_reason.is_none(),
3287            "deny_reason must be None on Allow"
3288        );
3289    }
3290
3291    // ---- Hard enforcement + EventStore persistence ----
3292
3293    use crate::runtime::NamespaceToken;
3294    use async_trait::async_trait;
3295    use khive_storage::{
3296        BatchWriteSummary, Event, EventFilter, EventStore, Page, PageRequest, SubstrateKind,
3297    };
3298    use khive_types::EventOutcome;
3299
3300    /// In-memory EventStore for unit tests — avoids file-backed SQLite.
3301    #[derive(Default, Debug)]
3302    struct MemoryEventStore {
3303        events: std::sync::Mutex<Vec<Event>>,
3304    }
3305
3306    #[async_trait]
3307    impl EventStore for MemoryEventStore {
3308        async fn append_event(&self, event: Event) -> khive_storage::StorageResult<()> {
3309            self.events.lock().unwrap().push(event);
3310            Ok(())
3311        }
3312        async fn append_events(
3313            &self,
3314            events: Vec<Event>,
3315        ) -> khive_storage::StorageResult<BatchWriteSummary> {
3316            let attempted = events.len() as u64;
3317            let affected = attempted;
3318            self.events.lock().unwrap().extend(events);
3319            Ok(BatchWriteSummary {
3320                attempted,
3321                affected,
3322                failed: 0,
3323                first_error: String::new(),
3324            })
3325        }
3326        async fn get_event(&self, id: uuid::Uuid) -> khive_storage::StorageResult<Option<Event>> {
3327            Ok(self
3328                .events
3329                .lock()
3330                .unwrap()
3331                .iter()
3332                .find(|e| e.id == id)
3333                .cloned())
3334        }
3335        async fn query_events(
3336            &self,
3337            _filter: EventFilter,
3338            _page: PageRequest,
3339        ) -> khive_storage::StorageResult<Page<Event>> {
3340            let items = self.events.lock().unwrap().clone();
3341            let total = items.len() as u64;
3342            Ok(Page {
3343                items,
3344                total: Some(total),
3345            })
3346        }
3347        async fn count_events(&self, _filter: EventFilter) -> khive_storage::StorageResult<u64> {
3348            Ok(self.events.lock().unwrap().len() as u64)
3349        }
3350    }
3351
3352    #[tokio::test]
3353    async fn allow_all_gate_default_remains_backward_compatible() {
3354        // No gate set — AllowAllGate is the default. Dispatch must succeed.
3355        let mut builder = VerbRegistryBuilder::new();
3356        builder.register(AlphaPack);
3357        let reg = builder.build().expect("registry builds");
3358
3359        let res = reg.dispatch("list", Value::Null).await.unwrap();
3360        assert_eq!(
3361            res["pack"], "alpha",
3362            "AllowAllGate must allow every verb — backward compat guarantee"
3363        );
3364        let res = reg.dispatch("create", Value::Null).await.unwrap();
3365        assert_eq!(res["pack"], "alpha");
3366    }
3367
3368    #[tokio::test]
3369    async fn deny_gate_returns_permission_denied_pack_never_invoked() {
3370        #[derive(Debug)]
3371        struct AlwaysDenyGate;
3372        impl Gate for AlwaysDenyGate {
3373            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
3374                Ok(GateDecision::deny("test: always deny"))
3375            }
3376        }
3377
3378        // Track whether dispatch was ever invoked on the pack.
3379        #[derive(Debug)]
3380        struct TrackedPack {
3381            invoked: Arc<AtomicUsize>,
3382        }
3383
3384        impl khive_types::Pack for TrackedPack {
3385            const NAME: &'static str = "tracked";
3386            const NOTE_KINDS: &'static [&'static str] = &[];
3387            const ENTITY_KINDS: &'static [&'static str] = &[];
3388            const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
3389                name: "guarded",
3390                description: "a guarded verb",
3391                visibility: Visibility::Verb,
3392                category: VerbCategory::Assertive,
3393                params: &[],
3394            }];
3395        }
3396
3397        #[async_trait]
3398        impl PackRuntime for TrackedPack {
3399            fn name(&self) -> &str {
3400                Self::NAME
3401            }
3402            fn note_kinds(&self) -> &'static [&'static str] {
3403                Self::NOTE_KINDS
3404            }
3405            fn entity_kinds(&self) -> &'static [&'static str] {
3406                Self::ENTITY_KINDS
3407            }
3408            fn handlers(&self) -> &'static [HandlerDef] {
3409                Self::HANDLERS
3410            }
3411            async fn dispatch(
3412                &self,
3413                _verb: &str,
3414                _params: Value,
3415                _registry: &VerbRegistry,
3416                _token: &NamespaceToken,
3417            ) -> Result<Value, RuntimeError> {
3418                self.invoked.fetch_add(1, Ordering::SeqCst);
3419                Ok(serde_json::json!({"invoked": true}))
3420            }
3421        }
3422
3423        let invoked = Arc::new(AtomicUsize::new(0));
3424        let mut builder = VerbRegistryBuilder::new();
3425        builder.register(TrackedPack {
3426            invoked: invoked.clone(),
3427        });
3428        builder.with_gate(Arc::new(AlwaysDenyGate));
3429        let reg = builder.build().expect("registry builds");
3430
3431        let err = reg.dispatch("guarded", Value::Null).await.unwrap_err();
3432        assert!(
3433            matches!(err, RuntimeError::PermissionDenied { ref verb, ref reason } if verb == "guarded" && reason.contains("always deny")),
3434            "expected PermissionDenied with verb=guarded and reason, got: {err:?}"
3435        );
3436        assert_eq!(
3437            invoked.load(Ordering::SeqCst),
3438            0,
3439            "pack dispatch MUST NOT be invoked when gate denies"
3440        );
3441    }
3442
3443    #[tokio::test]
3444    async fn audit_event_persists_to_event_store_on_allow() {
3445        let store = Arc::new(MemoryEventStore::default());
3446        let mut builder = VerbRegistryBuilder::new();
3447        builder.register(AlphaPack);
3448        builder.with_event_store(store.clone());
3449        let reg = builder.build().expect("registry builds");
3450
3451        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
3452            .await
3453            .unwrap();
3454
3455        let count = store.count_events(EventFilter::default()).await.unwrap();
3456        assert_eq!(count, 1, "one audit event persisted to EventStore on allow");
3457
3458        let page = store
3459            .query_events(
3460                EventFilter::default(),
3461                PageRequest {
3462                    limit: 10,
3463                    offset: 0,
3464                },
3465            )
3466            .await
3467            .unwrap();
3468        let ev = &page.items[0];
3469        assert_eq!(ev.verb, "list");
3470        assert_eq!(ev.namespace, "test-ns");
3471        assert_eq!(ev.substrate, SubstrateKind::Event);
3472        assert_eq!(ev.outcome, EventOutcome::Success);
3473    }
3474
3475    #[tokio::test]
3476    async fn audit_event_duration_us_reflects_measured_dispatch_time() {
3477        // The persisted audit row's `duration_us` must carry the measured
3478        // pack-dispatch time, not the `Event::new` default of 0 (persisting
3479        // the row before dispatch ran always yielded 0). `SleepingPack`
3480        // sleeps 20ms so the assertion has a wide, non-flaky margin over
3481        // scheduling jitter.
3482        let store = Arc::new(MemoryEventStore::default());
3483        let mut builder = VerbRegistryBuilder::new();
3484        builder.register(SleepingPack);
3485        builder.with_event_store(store.clone());
3486        let reg = builder.build().expect("registry builds");
3487
3488        reg.dispatch("slow_op", serde_json::json!({}))
3489            .await
3490            .unwrap();
3491
3492        let page = store
3493            .query_events(
3494                EventFilter::default(),
3495                PageRequest {
3496                    limit: 10,
3497                    offset: 0,
3498                },
3499            )
3500            .await
3501            .unwrap();
3502        assert_eq!(page.items.len(), 1);
3503        let ev = &page.items[0];
3504        assert!(
3505            ev.duration_us >= 10_000,
3506            "duration_us must reflect the ~20ms measured dispatch time, got {}",
3507            ev.duration_us
3508        );
3509    }
3510
3511    #[tokio::test]
3512    async fn dispatch_unknown_verb_allowed_by_gate_still_persists_audit_row() {
3513        // Generalizing audit-row deferral to every Allow-outcome verb (not
3514        // just singleton `link`) must not silently drop the audit row for a
3515        // verb the gate allows but no pack owns. `duration_us` stays at the
3516        // `Event::new` default of 0 here since no dispatch ever ran to measure.
3517        let store = Arc::new(MemoryEventStore::default());
3518        let mut builder = VerbRegistryBuilder::new();
3519        builder.register(AlphaPack);
3520        builder.with_event_store(store.clone());
3521        let reg = builder.build().expect("registry builds");
3522
3523        let result = reg.dispatch("no_such_verb", serde_json::json!({})).await;
3524        assert!(result.is_err(), "unknown verb must still return an error");
3525
3526        let count = store.count_events(EventFilter::default()).await.unwrap();
3527        assert_eq!(
3528            count, 1,
3529            "an allowed-but-unknown verb must still persist one audit row"
3530        );
3531        let page = store
3532            .query_events(
3533                EventFilter::default(),
3534                PageRequest {
3535                    limit: 10,
3536                    offset: 0,
3537                },
3538            )
3539            .await
3540            .unwrap();
3541        assert_eq!(page.items[0].duration_us, 0);
3542        // Dispatch returns InvalidInput for an unknown verb, so the
3543        // persisted outcome must be Error, not the previously-hardcoded
3544        // Success.
3545        assert_eq!(page.items[0].outcome, EventOutcome::Error);
3546    }
3547
3548    #[tokio::test]
3549    async fn audit_event_persists_to_event_store_on_deny() {
3550        #[derive(Debug)]
3551        struct AlwaysDenyGate;
3552        impl Gate for AlwaysDenyGate {
3553            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
3554                Ok(GateDecision::deny("denied by test"))
3555            }
3556        }
3557
3558        let store = Arc::new(MemoryEventStore::default());
3559        let mut builder = VerbRegistryBuilder::new();
3560        builder.register(AlphaPack);
3561        builder.with_gate(Arc::new(AlwaysDenyGate));
3562        builder.with_event_store(store.clone());
3563        let reg = builder.build().expect("registry builds");
3564
3565        // Hard enforce → PermissionDenied returned.
3566        let err = reg
3567            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
3568            .await
3569            .unwrap_err();
3570        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
3571
3572        let count = store.count_events(EventFilter::default()).await.unwrap();
3573        assert_eq!(count, 1, "one audit event persisted to EventStore on deny");
3574
3575        let page = store
3576            .query_events(
3577                EventFilter::default(),
3578                PageRequest {
3579                    limit: 10,
3580                    offset: 0,
3581                },
3582            )
3583            .await
3584            .unwrap();
3585        let ev = &page.items[0];
3586        assert_eq!(ev.verb, "list");
3587        assert_eq!(ev.outcome, EventOutcome::Denied);
3588    }
3589
3590    #[tokio::test]
3591    async fn gate_error_does_not_persist_to_event_store() {
3592        #[derive(Debug)]
3593        struct FailingGate;
3594        impl Gate for FailingGate {
3595            fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
3596                Err(khive_gate::GateError::Internal("gate broken".into()))
3597            }
3598        }
3599
3600        let store = Arc::new(MemoryEventStore::default());
3601        let mut builder = VerbRegistryBuilder::new();
3602        builder.register(AlphaPack);
3603        builder.with_gate(Arc::new(FailingGate));
3604        builder.with_event_store(store.clone());
3605        let reg = builder.build().expect("registry builds");
3606
3607        // Gate Err → fail-open, dispatch proceeds.
3608        let res = reg.dispatch("list", Value::Null).await.unwrap();
3609        assert_eq!(
3610            res["pack"], "alpha",
3611            "gate error must fail-open, not block dispatch"
3612        );
3613
3614        let count = store.count_events(EventFilter::default()).await.unwrap();
3615        assert_eq!(
3616            count, 0,
3617            "gate infrastructure error must NOT produce an audit event in EventStore"
3618        );
3619    }
3620
3621    #[tokio::test]
3622    async fn no_event_store_configured_tracing_only() {
3623        // When no event_store is configured, dispatch must succeed without error.
3624        // (The tracing path is exercised in the tracing tests above; here we just
3625        // verify the absence of event_store does not break dispatch.)
3626        let mut builder = VerbRegistryBuilder::new();
3627        builder.register(AlphaPack);
3628        let reg = builder.build().expect("registry builds");
3629
3630        let res = reg.dispatch("list", Value::Null).await.unwrap();
3631        assert_eq!(res["pack"], "alpha");
3632    }
3633
3634    #[test]
3635    #[serial]
3636    fn dispatch_tracing_emits_gate_check_event_with_deny_payload() {
3637        #[derive(Debug)]
3638        struct TracingDenyGate;
3639        impl Gate for TracingDenyGate {
3640            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
3641                Ok(GateDecision::deny("denied by test gate"))
3642            }
3643            fn impl_name(&self) -> &'static str {
3644                "TracingDenyGate"
3645            }
3646        }
3647
3648        let events = capture_dispatch_events(async {
3649            let mut builder = VerbRegistryBuilder::new();
3650            builder.register(AlphaPack);
3651            builder.with_gate(Arc::new(TracingDenyGate));
3652            let reg = builder.build().expect("registry builds");
3653            // Hard enforcement — dispatch returns PermissionDenied on Deny.
3654            // The tracing audit event is still emitted before the error is returned.
3655            let _ = reg.dispatch("create", serde_json::Value::Null).await;
3656        });
3657
3658        let gate_events = gate_check_events_for(&events, "TracingDenyGate");
3659        assert_eq!(
3660            gate_events.len(),
3661            1,
3662            "exactly one gate.check tracing event per dispatch (deny); got {gate_events:?}"
3663        );
3664        let payload = gate_events[0]
3665            .audit_event
3666            .as_ref()
3667            .expect("gate.check event must carry an audit_event field on Deny");
3668        let audit: khive_gate::AuditEvent =
3669            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
3670        assert_eq!(audit.decision, AuditDecision::Deny);
3671        assert_eq!(audit.deny_reason.as_deref(), Some("denied by test gate"));
3672        assert_eq!(audit.gate_impl, "TracingDenyGate");
3673        // Wire-shape rule: obligations is always serialized as an array, empty
3674        // on Deny. Round-trip back through serde_json::Value to confirm the
3675        // field exists on the wire and is `[]`, not missing.
3676        let payload_json: serde_json::Value =
3677            serde_json::from_str(payload).expect("payload must be valid JSON");
3678        assert_eq!(
3679            payload_json["obligations"],
3680            serde_json::Value::Array(Vec::new()),
3681            "obligations must be `[]` on Deny on the tracing payload, not omitted"
3682        );
3683    }
3684
3685    // ---- EventStore audit envelope round-trip ----
3686    //
3687    // EventStore must not persist a summary Event without the full
3688    // AuditEvent fields (deny_reason, gate_impl, obligations). This test
3689    // verifies the complete envelope survives append_event → query_events.
3690
3691    #[tokio::test]
3692    async fn audit_envelope_round_trips_deny_reason_and_gate_impl_through_event_store() {
3693        #[derive(Debug)]
3694        struct DenyGateWithName;
3695        impl Gate for DenyGateWithName {
3696            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
3697                Ok(GateDecision::deny("policy: write forbidden for anon"))
3698            }
3699            fn impl_name(&self) -> &'static str {
3700                "DenyGateWithName"
3701            }
3702        }
3703
3704        let store = Arc::new(MemoryEventStore::default());
3705        let mut builder = VerbRegistryBuilder::new();
3706        builder.register(AlphaPack);
3707        builder.with_gate(Arc::new(DenyGateWithName));
3708        builder.with_event_store(store.clone());
3709        let reg = builder.build().expect("registry builds");
3710
3711        // Dispatch is denied — PermissionDenied returned.
3712        let err = reg
3713            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
3714            .await
3715            .unwrap_err();
3716        assert!(
3717            matches!(err, RuntimeError::PermissionDenied { .. }),
3718            "expected PermissionDenied, got {err:?}"
3719        );
3720
3721        // Exactly one event in the store.
3722        let page = store
3723            .query_events(
3724                EventFilter::default(),
3725                PageRequest {
3726                    limit: 10,
3727                    offset: 0,
3728                },
3729            )
3730            .await
3731            .unwrap();
3732        assert_eq!(
3733            page.items.len(),
3734            1,
3735            "one audit event must be persisted on deny"
3736        );
3737
3738        let ev = &page.items[0];
3739        assert_eq!(ev.outcome, EventOutcome::Denied);
3740
3741        // The payload field must hold the full AuditEvent envelope.
3742        let data = &ev.payload;
3743
3744        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
3745            .expect("Event.payload must deserialize to AuditEvent");
3746
3747        assert_eq!(
3748            audit.deny_reason.as_deref(),
3749            Some("policy: write forbidden for anon"),
3750            "deny_reason must be preserved through EventStore"
3751        );
3752        assert_eq!(
3753            audit.gate_impl, "DenyGateWithName",
3754            "gate_impl must be preserved through EventStore"
3755        );
3756        assert_eq!(
3757            audit.decision,
3758            khive_gate::AuditDecision::Deny,
3759            "decision field must be preserved through EventStore"
3760        );
3761    }
3762
3763    #[tokio::test]
3764    async fn audit_envelope_round_trips_obligations_through_event_store() {
3765        use khive_gate::Obligation;
3766
3767        #[derive(Debug)]
3768        struct ObligationGate;
3769        impl Gate for ObligationGate {
3770            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
3771                Ok(GateDecision::allow_with(vec![Obligation::Audit {
3772                    tag: "billing.meter".into(),
3773                }]))
3774            }
3775            fn impl_name(&self) -> &'static str {
3776                "ObligationGate"
3777            }
3778        }
3779
3780        let store = Arc::new(MemoryEventStore::default());
3781        let mut builder = VerbRegistryBuilder::new();
3782        builder.register(AlphaPack);
3783        builder.with_gate(Arc::new(ObligationGate));
3784        builder.with_event_store(store.clone());
3785        let reg = builder.build().expect("registry builds");
3786
3787        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
3788            .await
3789            .unwrap();
3790
3791        let page = store
3792            .query_events(
3793                EventFilter::default(),
3794                PageRequest {
3795                    limit: 10,
3796                    offset: 0,
3797                },
3798            )
3799            .await
3800            .unwrap();
3801        assert_eq!(page.items.len(), 1);
3802
3803        let ev = &page.items[0];
3804        assert_eq!(ev.outcome, EventOutcome::Success);
3805
3806        let data = &ev.payload;
3807
3808        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
3809            .expect("Event.payload must deserialize to AuditEvent");
3810
3811        assert_eq!(audit.gate_impl, "ObligationGate");
3812        assert_eq!(
3813            audit.obligations.len(),
3814            1,
3815            "obligations must be preserved through EventStore"
3816        );
3817        match &audit.obligations[0] {
3818            Obligation::Audit { tag } => assert_eq!(tag, "billing.meter"),
3819            other => panic!("expected Audit obligation, got {other:?}"),
3820        }
3821    }
3822
3823    // ---- SQL-backed audit envelope round-trip ----
3824    //
3825    // The two tests above use MemoryEventStore (no serialization). This test
3826    // wires the production SqlEventStore via KhiveRuntime::memory() to verify
3827    // that the full AuditEvent envelope survives the SQL text→parse round-trip
3828    // (Event.data is stored as TEXT and parsed back on read).
3829
3830    #[tokio::test]
3831    async fn sql_backed_audit_envelope_round_trips_deny_reason_gate_impl_and_obligations() {
3832        #[derive(Debug)]
3833        struct SqlTestDenyGate;
3834        impl Gate for SqlTestDenyGate {
3835            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
3836                Ok(GateDecision::deny("sql-path: write denied"))
3837            }
3838            fn impl_name(&self) -> &'static str {
3839                "SqlTestDenyGate"
3840            }
3841        }
3842
3843        // KhiveRuntime::memory() creates an in-memory SQLite pool (is_file_backed=false).
3844        // events_for_namespace ensures the events schema and returns a SqlEventStore
3845        // scoped to "test-ns". The pool is shared so reads and writes see the same data.
3846        let rt = KhiveRuntime::memory().expect("in-memory runtime");
3847        let test_tok = NamespaceToken::for_namespace(Namespace::parse("test-ns").unwrap());
3848        let sql_store = rt
3849            .events(&test_tok)
3850            .expect("events_for_namespace must succeed");
3851
3852        let mut builder = VerbRegistryBuilder::new();
3853        builder.register(AlphaPack);
3854        builder.with_gate(Arc::new(SqlTestDenyGate));
3855        builder.with_event_store(sql_store.clone());
3856        let reg = builder.build().expect("registry builds");
3857
3858        // Dispatch is denied — PermissionDenied returned.
3859        let err = reg
3860            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
3861            .await
3862            .unwrap_err();
3863        assert!(
3864            matches!(err, RuntimeError::PermissionDenied { .. }),
3865            "expected PermissionDenied, got {err:?}"
3866        );
3867
3868        // Query via the same SqlEventStore — this is the SQL read path.
3869        let page = sql_store
3870            .query_events(
3871                EventFilter::default(),
3872                PageRequest {
3873                    limit: 10,
3874                    offset: 0,
3875                },
3876            )
3877            .await
3878            .unwrap();
3879        assert_eq!(
3880            page.items.len(),
3881            1,
3882            "one audit event must be persisted on deny through SqlEventStore"
3883        );
3884
3885        let ev = &page.items[0];
3886        assert_eq!(ev.outcome, EventOutcome::Denied);
3887
3888        // Event.payload must hold the full AuditEvent serialized as JSON text and
3889        // parsed back. If the SQL path was lossy, this deserialization would fail
3890        // or the field assertions below would fail.
3891        let data = &ev.payload;
3892
3893        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
3894            .expect("Event.payload must deserialize to AuditEvent after SQL round-trip");
3895
3896        assert_eq!(
3897            audit.deny_reason.as_deref(),
3898            Some("sql-path: write denied"),
3899            "deny_reason must survive the SQL text round-trip"
3900        );
3901        assert_eq!(
3902            audit.gate_impl, "SqlTestDenyGate",
3903            "gate_impl must survive the SQL text round-trip"
3904        );
3905        assert_eq!(
3906            audit.decision,
3907            khive_gate::AuditDecision::Deny,
3908            "decision field must survive the SQL text round-trip"
3909        );
3910        // obligations is [] on a Deny gate (no obligations returned).
3911        // Verify the field is present and empty after SQL round-trip.
3912        assert!(
3913            audit.obligations.is_empty(),
3914            "obligations must be preserved as empty [] through SQL round-trip"
3915        );
3916    }
3917
3918    // ---- SQL-backed audit envelope: non-empty obligations survive round-trip ----
3919    //
3920    // Blind spot: the deny-path SQL test above only
3921    // asserts obligations == [], which passes even if the SQL path drops the
3922    // field entirely (AuditEvent.obligations has #[serde(default)]).
3923    //
3924    // This test installs an allow-path gate that returns a non-empty obligations
3925    // vec. After dispatch, the same SqlEventStore is queried and both layers are
3926    // checked:
3927    //   1. Raw Event.data["obligations"] is a non-empty JSON array.
3928    //   2. Deserialized AuditEvent.obligations[0] matches the expected variant.
3929    #[tokio::test]
3930    async fn sql_backed_audit_envelope_round_trips_non_empty_obligations() {
3931        use khive_gate::Obligation;
3932
3933        #[derive(Debug)]
3934        struct SqlTestAllowWithObligationGate;
3935        impl Gate for SqlTestAllowWithObligationGate {
3936            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
3937                Ok(GateDecision::allow_with(vec![Obligation::Audit {
3938                    tag: "sql-path-billing.meter".into(),
3939                }]))
3940            }
3941            fn impl_name(&self) -> &'static str {
3942                "SqlTestAllowWithObligationGate"
3943            }
3944        }
3945
3946        let rt = KhiveRuntime::memory().expect("in-memory runtime");
3947        let test_tok = NamespaceToken::for_namespace(Namespace::parse("test-ns").unwrap());
3948        let sql_store = rt
3949            .events(&test_tok)
3950            .expect("events_for_namespace must succeed");
3951
3952        let mut builder = VerbRegistryBuilder::new();
3953        builder.register(AlphaPack);
3954        builder.with_gate(Arc::new(SqlTestAllowWithObligationGate));
3955        builder.with_event_store(sql_store.clone());
3956        let reg = builder.build().expect("registry builds");
3957
3958        // Dispatch succeeds — the gate allows with obligations.
3959        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
3960            .await
3961            .expect("dispatch must succeed when gate allows");
3962
3963        // Query via the same SqlEventStore — this is the SQL read path.
3964        let page = sql_store
3965            .query_events(
3966                EventFilter::default(),
3967                PageRequest {
3968                    limit: 10,
3969                    offset: 0,
3970                },
3971            )
3972            .await
3973            .unwrap();
3974        assert_eq!(
3975            page.items.len(),
3976            1,
3977            "one audit event must be persisted on allow through SqlEventStore"
3978        );
3979
3980        let ev = &page.items[0];
3981        assert_eq!(ev.outcome, EventOutcome::Success);
3982
3983        let data = &ev.payload;
3984
3985        // Layer 1: raw JSON check — obligations must be a non-empty array in
3986        // the persisted TEXT. If the SQL path dropped the field, the default
3987        // #[serde(default)] would silently deserialize it to [], so we verify
3988        // the raw JSON before deserializing.
3989        let obligations_raw = data
3990            .get("obligations")
3991            .expect("Event.data JSON must contain 'obligations' key");
3992        let obligations_arr = obligations_raw
3993            .as_array()
3994            .expect("'obligations' must be a JSON array");
3995        assert!(
3996            !obligations_arr.is_empty(),
3997            "raw Event.data['obligations'] must be non-empty after SQL round-trip"
3998        );
3999
4000        // Layer 2: deserialized AuditEvent check — the obligation variant and
4001        // payload must survive the text round-trip faithfully.
4002        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
4003            .expect("Event.data must deserialize to AuditEvent after SQL round-trip");
4004
4005        assert_eq!(
4006            audit.gate_impl, "SqlTestAllowWithObligationGate",
4007            "gate_impl must survive the SQL text round-trip"
4008        );
4009        assert_eq!(
4010            audit.decision,
4011            khive_gate::AuditDecision::Allow,
4012            "decision field must survive the SQL text round-trip"
4013        );
4014        assert_eq!(
4015            audit.obligations.len(),
4016            1,
4017            "obligations must be non-empty after SQL round-trip (not silently defaulted to [])"
4018        );
4019        match &audit.obligations[0] {
4020            Obligation::Audit { tag } => assert_eq!(
4021                tag, "sql-path-billing.meter",
4022                "Audit obligation tag must survive the SQL text round-trip"
4023            ),
4024            other => panic!("expected Audit obligation, got {other:?}"),
4025        }
4026    }
4027
4028    // ---- Audit payload shape for 'create' verb dispatch ----
4029    //
4030    // The previous audit tests verify the envelope shape for the 'list' verb.
4031    // This test dispatches 'create' (matching the create_note + annotates path)
4032    // and verifies that ev.verb, ev.outcome, and ev.data all round-trip correctly
4033    // through the EventStore. Ensures the wire shape is independent of which verb
4034    // triggers the gate check.
4035    #[tokio::test]
4036    async fn audit_event_payload_shape_for_create_verb() {
4037        let store = Arc::new(MemoryEventStore::default());
4038        let mut builder = VerbRegistryBuilder::new();
4039        builder.register(AlphaPack);
4040        builder.with_event_store(store.clone());
4041        builder.with_default_namespace("test-ns");
4042        let reg = builder.build().expect("registry builds");
4043
4044        // Dispatch 'create' — AlphaPack returns a stub value; what matters is
4045        // the EventStore entry emitted by the registry's gate-check path.
4046        reg.dispatch("create", serde_json::json!({"namespace": "test-ns"}))
4047            .await
4048            .unwrap();
4049
4050        let count = store.count_events(EventFilter::default()).await.unwrap();
4051        assert_eq!(count, 1, "exactly one audit event for one dispatch");
4052
4053        let page = store
4054            .query_events(
4055                EventFilter::default(),
4056                PageRequest {
4057                    limit: 10,
4058                    offset: 0,
4059                },
4060            )
4061            .await
4062            .unwrap();
4063        let ev = &page.items[0];
4064
4065        // Top-level Event fields.
4066        assert_eq!(ev.verb, "create", "ev.verb must be the dispatched verb");
4067        assert_eq!(
4068            ev.outcome,
4069            EventOutcome::Success,
4070            "ev.outcome must be Success on allow"
4071        );
4072        assert_eq!(
4073            ev.namespace, "test-ns",
4074            "ev.namespace must match the dispatch namespace"
4075        );
4076
4077        // ev.payload must hold the full AuditEvent envelope.
4078        let data = &ev.payload;
4079
4080        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
4081            .expect("ev.payload must deserialize to AuditEvent");
4082
4083        assert_eq!(
4084            audit.decision,
4085            khive_gate::AuditDecision::Allow,
4086            "AuditEvent.decision must be Allow"
4087        );
4088        assert_eq!(audit.verb, "create", "AuditEvent.verb must be 'create'");
4089        assert_eq!(
4090            audit.namespace, "test-ns",
4091            "AuditEvent.namespace must be preserved"
4092        );
4093        assert_eq!(
4094            audit.gate_impl, "AllowAllGate",
4095            "AuditEvent.gate_impl must name the gate implementation"
4096        );
4097        assert!(
4098            audit.deny_reason.is_none(),
4099            "AuditEvent.deny_reason must be None on Allow"
4100        );
4101        // Wire-shape check: obligations serializes as [] on AllowAllGate.
4102        let payload_json: serde_json::Value =
4103            serde_json::from_value(data.clone()).expect("data must be valid JSON");
4104        assert_eq!(
4105            payload_json["obligations"],
4106            serde_json::Value::Array(Vec::new()),
4107            "obligations must be [] on AllowAllGate"
4108        );
4109    }
4110
4111    // Registry audit event must carry target_id when dispatch params include it.
4112    #[tokio::test]
4113    async fn audit_event_threads_target_id_from_dispatch_args() {
4114        let store = Arc::new(MemoryEventStore::default());
4115        let target = uuid::Uuid::new_v4();
4116        let mut builder = VerbRegistryBuilder::new();
4117        builder.register(AlphaPack);
4118        builder.with_event_store(store.clone());
4119        builder.with_default_namespace("test-ns");
4120        let reg = builder.build().expect("registry builds");
4121
4122        reg.dispatch(
4123            "create",
4124            serde_json::json!({"namespace": "test-ns", "target_id": target}),
4125        )
4126        .await
4127        .unwrap();
4128
4129        let page = store
4130            .query_events(
4131                EventFilter::default(),
4132                PageRequest {
4133                    offset: 0,
4134                    limit: 10,
4135                },
4136            )
4137            .await
4138            .unwrap();
4139        assert_eq!(
4140            page.items[0].target_id,
4141            Some(target),
4142            "#282: audit event must carry target_id from dispatch params"
4143        );
4144    }
4145
4146    // ---- Link-verb audit enrichment ----
4147
4148    /// Test pack exposing a single `link` verb whose one-shot result is
4149    /// configured up front — lets tests drive both the success and failure
4150    /// legs of the deferred link-audit path without a real KG backend.
4151    struct LinkResultPack {
4152        result: std::sync::Mutex<Option<Result<Value, RuntimeError>>>,
4153    }
4154
4155    impl LinkResultPack {
4156        fn ok(value: Value) -> Self {
4157            Self {
4158                result: std::sync::Mutex::new(Some(Ok(value))),
4159            }
4160        }
4161        fn err(message: &str) -> Self {
4162            Self {
4163                result: std::sync::Mutex::new(Some(Err(RuntimeError::InvalidInput(
4164                    message.to_string(),
4165                )))),
4166            }
4167        }
4168    }
4169
4170    impl khive_types::Pack for LinkResultPack {
4171        const NAME: &'static str = "kg";
4172        const NOTE_KINDS: &'static [&'static str] = &[];
4173        const ENTITY_KINDS: &'static [&'static str] = &[];
4174        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
4175            name: "link",
4176            description: "test link handler",
4177            visibility: Visibility::Verb,
4178            category: VerbCategory::Commissive,
4179            params: &[],
4180        }];
4181    }
4182
4183    #[async_trait]
4184    impl PackRuntime for LinkResultPack {
4185        fn name(&self) -> &str {
4186            Self::NAME
4187        }
4188        fn note_kinds(&self) -> &'static [&'static str] {
4189            Self::NOTE_KINDS
4190        }
4191        fn entity_kinds(&self) -> &'static [&'static str] {
4192            Self::ENTITY_KINDS
4193        }
4194        fn handlers(&self) -> &'static [HandlerDef] {
4195            Self::HANDLERS
4196        }
4197        async fn dispatch(
4198            &self,
4199            _verb: &str,
4200            _params: Value,
4201            _registry: &VerbRegistry,
4202            _token: &NamespaceToken,
4203        ) -> Result<Value, RuntimeError> {
4204            self.result
4205                .lock()
4206                .unwrap()
4207                .take()
4208                .expect("LinkResultPack dispatch called more than once in a test")
4209        }
4210    }
4211
4212    #[tokio::test]
4213    async fn link_audit_enriches_successful_singleton_with_edge_v2() {
4214        let store = Arc::new(MemoryEventStore::default());
4215        let edge_id = uuid::Uuid::new_v4();
4216        let source_id = uuid::Uuid::new_v4();
4217        let target_id = uuid::Uuid::new_v4();
4218        let edge_json = serde_json::json!({
4219            "id": edge_id,
4220            "namespace": "local",
4221            "source_id": source_id,
4222            "target_id": target_id,
4223            "relation": "depends_on",
4224            "weight": 1.0,
4225        });
4226        let mut builder = VerbRegistryBuilder::new();
4227        builder.register(LinkResultPack::ok(edge_json));
4228        builder.with_event_store(store.clone());
4229        builder.with_default_namespace("test-ns");
4230        let reg = builder.build().expect("registry builds");
4231
4232        reg.dispatch(
4233            "link",
4234            serde_json::json!({
4235                "source_id": source_id,
4236                "target_id": target_id,
4237                "relation": "depends_on",
4238            }),
4239        )
4240        .await
4241        .unwrap();
4242
4243        let count = store.count_events(EventFilter::default()).await.unwrap();
4244        assert_eq!(
4245            count, 1,
4246            "exactly one deferred audit row must be persisted for a successful singleton link"
4247        );
4248        let page = store
4249            .query_events(
4250                EventFilter::default(),
4251                PageRequest {
4252                    limit: 10,
4253                    offset: 0,
4254                },
4255            )
4256            .await
4257            .unwrap();
4258        let ev = &page.items[0];
4259        assert_eq!(ev.verb, "link");
4260        assert_eq!(ev.outcome, EventOutcome::Success);
4261        assert_eq!(
4262            ev.payload_schema_version, 2,
4263            "successful singleton link uses audit schema v2"
4264        );
4265        assert_eq!(
4266            ev.target_id,
4267            Some(edge_id),
4268            "target_id must be the created/resolved edge id, not a raw caller arg"
4269        );
4270        assert_eq!(ev.payload["edge_id"], serde_json::json!(edge_id));
4271        assert_eq!(ev.payload["source_id"], serde_json::json!(source_id));
4272        assert_eq!(ev.payload["target_id"], serde_json::json!(target_id));
4273        assert_eq!(ev.payload["relation"], "depends_on");
4274        assert_eq!(ev.payload["weight"], 1.0);
4275        // v1 AuditEvent fields remain present via #[serde(flatten)].
4276        assert_eq!(ev.payload["verb"], "link");
4277        assert_eq!(ev.payload["decision"], "allow");
4278        assert!(ev.payload.get("gate_impl").is_some());
4279    }
4280
4281    #[tokio::test]
4282    async fn link_audit_falls_back_to_v1_when_dispatch_fails() {
4283        let store = Arc::new(MemoryEventStore::default());
4284        let mut builder = VerbRegistryBuilder::new();
4285        builder.register(LinkResultPack::err("target endpoint not found"));
4286        builder.with_event_store(store.clone());
4287        builder.with_default_namespace("test-ns");
4288        let reg = builder.build().expect("registry builds");
4289
4290        let err = reg
4291            .dispatch(
4292                "link",
4293                serde_json::json!({
4294                    "source_id": "note:alpha",
4295                    "target_id": "note:missing",
4296                    "relation": "depends_on",
4297                }),
4298            )
4299            .await
4300            .unwrap_err();
4301        assert!(
4302            matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("not found")),
4303            "the original dispatch error must be returned unchanged"
4304        );
4305
4306        let page = store
4307            .query_events(
4308                EventFilter::default(),
4309                PageRequest {
4310                    limit: 10,
4311                    offset: 0,
4312                },
4313            )
4314            .await
4315            .unwrap();
4316        assert_eq!(
4317            page.items.len(),
4318            1,
4319            "a v1 fallback audit row must still be persisted on dispatch failure"
4320        );
4321        let ev = &page.items[0];
4322        assert_eq!(
4323            ev.payload_schema_version, 1,
4324            "failed link keeps the v1 audit shape"
4325        );
4326        // The persisted outcome must reflect the dispatch result (Err →
4327        // Error), not be hardcoded to Success from the gate's Allow decision.
4328        assert_eq!(
4329            ev.outcome,
4330            EventOutcome::Error,
4331            "outcome reflects the dispatch result (Err), not the gate decision (Allow)"
4332        );
4333        assert!(
4334            ev.duration_us >= 0,
4335            "duration_us must still be populated (measured, not the Event::new \
4336             default sentinel) on a failed dispatch"
4337        );
4338        assert!(
4339            ev.target_id.is_none(),
4340            "non-UUID caller-supplied ids do not spuriously populate target_id"
4341        );
4342        assert!(
4343            ev.payload.get("edge_id").is_none(),
4344            "v1 fallback must not carry edge enrichment fields"
4345        );
4346        let _: khive_gate::AuditEvent = serde_json::from_value(ev.payload.clone())
4347            .expect("v1 fallback payload must deserialize as AuditEvent");
4348    }
4349
4350    #[tokio::test]
4351    async fn link_audit_falls_back_to_v1_when_result_missing_edge_fields() {
4352        let store = Arc::new(MemoryEventStore::default());
4353        let target_arg = uuid::Uuid::new_v4();
4354        let mut builder = VerbRegistryBuilder::new();
4355        builder.register(LinkResultPack::ok(serde_json::json!({"ok": true})));
4356        builder.with_event_store(store.clone());
4357        builder.with_default_namespace("test-ns");
4358        let reg = builder.build().expect("registry builds");
4359
4360        reg.dispatch(
4361            "link",
4362            serde_json::json!({
4363                "source_id": uuid::Uuid::new_v4(),
4364                "target_id": target_arg,
4365                "relation": "depends_on",
4366            }),
4367        )
4368        .await
4369        .unwrap();
4370
4371        let page = store
4372            .query_events(
4373                EventFilter::default(),
4374                PageRequest {
4375                    limit: 10,
4376                    offset: 0,
4377                },
4378            )
4379            .await
4380            .unwrap();
4381        assert_eq!(page.items.len(), 1);
4382        let ev = &page.items[0];
4383        assert_eq!(
4384            ev.payload_schema_version, 1,
4385            "an unparsable success result falls back to v1 rather than dropping the audit row"
4386        );
4387        assert_eq!(ev.outcome, EventOutcome::Success);
4388        assert_eq!(
4389            ev.target_id,
4390            Some(target_arg),
4391            "v1 fallback still extracts target_id from the raw dispatch args"
4392        );
4393        assert!(ev.payload.get("edge_id").is_none());
4394    }
4395
4396    #[tokio::test]
4397    async fn link_audit_bulk_links_get_no_enrichment() {
4398        let store = Arc::new(MemoryEventStore::default());
4399        let mut builder = VerbRegistryBuilder::new();
4400        builder.register(LinkResultPack::ok(serde_json::json!({
4401            "attempted": 2, "created": 2, "skipped": 0, "failed": 0
4402        })));
4403        builder.with_event_store(store.clone());
4404        builder.with_default_namespace("test-ns");
4405        let reg = builder.build().expect("registry builds");
4406
4407        reg.dispatch(
4408            "link",
4409            serde_json::json!({
4410                "links": [
4411                    {"source_id": "a", "target_id": "b", "relation": "depends_on"},
4412                    {"source_id": "c", "target_id": "d", "relation": "depends_on"},
4413                ],
4414            }),
4415        )
4416        .await
4417        .unwrap();
4418
4419        let count = store.count_events(EventFilter::default()).await.unwrap();
4420        assert_eq!(
4421            count, 1,
4422            "bulk `links` gets exactly one v1 audit row (deferred until dispatch \
4423             resolves like every other Allow-outcome row since ADR-103 Stage 1, \
4424             but never v2-enriched — enrichment is singleton-`link`-only)"
4425        );
4426        let page = store
4427            .query_events(
4428                EventFilter::default(),
4429                PageRequest {
4430                    limit: 10,
4431                    offset: 0,
4432                },
4433            )
4434            .await
4435            .unwrap();
4436        let ev = &page.items[0];
4437        assert_eq!(
4438            ev.payload_schema_version, 1,
4439            "bulk link mode is out of scope for #676's events.target_id enrichment"
4440        );
4441        assert!(ev.target_id.is_none());
4442    }
4443
4444    #[test]
4445    fn link_audit_success_from_result_extracts_edge_fields() {
4446        let gate_req = GateRequest::new(
4447            ActorRef::anonymous(),
4448            Namespace::local(),
4449            "link",
4450            serde_json::json!({}),
4451        );
4452        let decision = GateDecision::Allow {
4453            obligations: vec![],
4454        };
4455        let audit = AuditEvent::from_check(&gate_req, &decision, "AllowAllGate");
4456
4457        let edge_id = uuid::Uuid::new_v4();
4458        let source_id = uuid::Uuid::new_v4();
4459        let target_id = uuid::Uuid::new_v4();
4460        let result = serde_json::json!({
4461            "id": edge_id,
4462            "source_id": source_id,
4463            "target_id": target_id,
4464            "relation": "depends_on",
4465            "weight": 0.5,
4466        });
4467
4468        let (returned_id, payload) = link_audit_success_from_result(audit, &result)
4469            .expect("well-formed edge JSON must produce an enriched payload");
4470        assert_eq!(returned_id, edge_id);
4471        assert_eq!(payload["edge_id"], serde_json::json!(edge_id));
4472        assert_eq!(payload["relation"], "depends_on");
4473        assert_eq!(payload["weight"], 0.5);
4474        assert_eq!(
4475            payload["verb"], "link",
4476            "v1 AuditEvent fields must flatten into the v2 payload"
4477        );
4478    }
4479
4480    #[test]
4481    fn link_audit_success_from_result_rejects_incomplete_or_malformed_result() {
4482        let gate_req = GateRequest::new(
4483            ActorRef::anonymous(),
4484            Namespace::local(),
4485            "link",
4486            serde_json::json!({}),
4487        );
4488        let decision = GateDecision::Allow {
4489            obligations: vec![],
4490        };
4491        let audit = AuditEvent::from_check(&gate_req, &decision, "AllowAllGate");
4492
4493        assert!(
4494            link_audit_success_from_result(
4495                audit.clone(),
4496                &serde_json::json!({"id": uuid::Uuid::new_v4()}),
4497            )
4498            .is_none(),
4499            "missing source_id/target_id/relation/weight must not enrich"
4500        );
4501        assert!(
4502            link_audit_success_from_result(audit, &serde_json::json!({"id": "not-a-uuid"}))
4503                .is_none(),
4504            "a non-UUID id must not enrich"
4505        );
4506    }
4507}
4508
4509// ---- Inter-pack dependency checking ----
4510
4511#[cfg(test)]
4512mod dep_tests {
4513    use super::*;
4514    use async_trait::async_trait;
4515    use khive_types::Pack;
4516    use serde_json::Value;
4517
4518    struct KgDepPack;
4519    struct MemoryDepPack;
4520    struct ADepPack;
4521    struct BDepPack;
4522
4523    impl Pack for KgDepPack {
4524        const NAME: &'static str = "kg_dep";
4525        const NOTE_KINDS: &'static [&'static str] = &["observation"];
4526        const ENTITY_KINDS: &'static [&'static str] = &["concept"];
4527        const HANDLERS: &'static [HandlerDef] = &[];
4528    }
4529
4530    impl Pack for MemoryDepPack {
4531        const NAME: &'static str = "memory_dep";
4532        const NOTE_KINDS: &'static [&'static str] = &["memory"];
4533        const ENTITY_KINDS: &'static [&'static str] = &[];
4534        const HANDLERS: &'static [HandlerDef] = &[];
4535        const REQUIRES: &'static [&'static str] = &["kg_dep"];
4536    }
4537
4538    impl Pack for ADepPack {
4539        const NAME: &'static str = "pack_a";
4540        const NOTE_KINDS: &'static [&'static str] = &[];
4541        const ENTITY_KINDS: &'static [&'static str] = &[];
4542        const HANDLERS: &'static [HandlerDef] = &[];
4543        const REQUIRES: &'static [&'static str] = &["pack_b"];
4544    }
4545
4546    impl Pack for BDepPack {
4547        const NAME: &'static str = "pack_b";
4548        const NOTE_KINDS: &'static [&'static str] = &[];
4549        const ENTITY_KINDS: &'static [&'static str] = &[];
4550        const HANDLERS: &'static [HandlerDef] = &[];
4551        const REQUIRES: &'static [&'static str] = &["pack_a"];
4552    }
4553
4554    #[async_trait]
4555    impl PackRuntime for KgDepPack {
4556        fn name(&self) -> &str {
4557            Self::NAME
4558        }
4559        fn note_kinds(&self) -> &'static [&'static str] {
4560            Self::NOTE_KINDS
4561        }
4562        fn entity_kinds(&self) -> &'static [&'static str] {
4563            Self::ENTITY_KINDS
4564        }
4565        fn handlers(&self) -> &'static [HandlerDef] {
4566            Self::HANDLERS
4567        }
4568        async fn dispatch(
4569            &self,
4570            verb: &str,
4571            _: Value,
4572            _: &VerbRegistry,
4573            _: &NamespaceToken,
4574        ) -> Result<Value, RuntimeError> {
4575            Err(RuntimeError::InvalidInput(format!(
4576                "KgDepPack has no verbs: {verb}"
4577            )))
4578        }
4579    }
4580
4581    #[async_trait]
4582    impl PackRuntime for MemoryDepPack {
4583        fn name(&self) -> &str {
4584            Self::NAME
4585        }
4586        fn note_kinds(&self) -> &'static [&'static str] {
4587            Self::NOTE_KINDS
4588        }
4589        fn entity_kinds(&self) -> &'static [&'static str] {
4590            Self::ENTITY_KINDS
4591        }
4592        fn handlers(&self) -> &'static [HandlerDef] {
4593            Self::HANDLERS
4594        }
4595        fn requires(&self) -> &'static [&'static str] {
4596            Self::REQUIRES
4597        }
4598        async fn dispatch(
4599            &self,
4600            verb: &str,
4601            _: Value,
4602            _: &VerbRegistry,
4603            _: &NamespaceToken,
4604        ) -> Result<Value, RuntimeError> {
4605            Err(RuntimeError::InvalidInput(format!(
4606                "MemoryDepPack has no verbs: {verb}"
4607            )))
4608        }
4609    }
4610
4611    #[async_trait]
4612    impl PackRuntime for ADepPack {
4613        fn name(&self) -> &str {
4614            Self::NAME
4615        }
4616        fn note_kinds(&self) -> &'static [&'static str] {
4617            Self::NOTE_KINDS
4618        }
4619        fn entity_kinds(&self) -> &'static [&'static str] {
4620            Self::ENTITY_KINDS
4621        }
4622        fn handlers(&self) -> &'static [HandlerDef] {
4623            Self::HANDLERS
4624        }
4625        fn requires(&self) -> &'static [&'static str] {
4626            Self::REQUIRES
4627        }
4628        async fn dispatch(
4629            &self,
4630            verb: &str,
4631            _: Value,
4632            _: &VerbRegistry,
4633            _: &NamespaceToken,
4634        ) -> Result<Value, RuntimeError> {
4635            Err(RuntimeError::InvalidInput(format!(
4636                "ADepPack has no verbs: {verb}"
4637            )))
4638        }
4639    }
4640
4641    #[async_trait]
4642    impl PackRuntime for BDepPack {
4643        fn name(&self) -> &str {
4644            Self::NAME
4645        }
4646        fn note_kinds(&self) -> &'static [&'static str] {
4647            Self::NOTE_KINDS
4648        }
4649        fn entity_kinds(&self) -> &'static [&'static str] {
4650            Self::ENTITY_KINDS
4651        }
4652        fn handlers(&self) -> &'static [HandlerDef] {
4653            Self::HANDLERS
4654        }
4655        fn requires(&self) -> &'static [&'static str] {
4656            Self::REQUIRES
4657        }
4658        async fn dispatch(
4659            &self,
4660            verb: &str,
4661            _: Value,
4662            _: &VerbRegistry,
4663            _: &NamespaceToken,
4664        ) -> Result<Value, RuntimeError> {
4665            Err(RuntimeError::InvalidInput(format!(
4666                "BDepPack has no verbs: {verb}"
4667            )))
4668        }
4669    }
4670
4671    #[test]
4672    fn test_pack_deps_happy_path() {
4673        let mut builder = VerbRegistryBuilder::new();
4674        builder.register(MemoryDepPack);
4675        builder.register(KgDepPack);
4676        let reg = builder
4677            .build()
4678            .expect("kg_dep satisfies memory_dep dependency");
4679        assert_eq!(reg.pack_requires("memory_dep").unwrap(), &["kg_dep"]);
4680        let names = reg.pack_names();
4681        let kg_pos = names.iter().position(|&n| n == "kg_dep").unwrap();
4682        let mem_pos = names.iter().position(|&n| n == "memory_dep").unwrap();
4683        assert!(
4684            kg_pos < mem_pos,
4685            "kg_dep must be loaded before memory_dep; order: {names:?}"
4686        );
4687    }
4688
4689    #[test]
4690    fn test_pack_deps_missing() {
4691        let mut builder = VerbRegistryBuilder::new();
4692        builder.register(MemoryDepPack);
4693        let err = match builder.build() {
4694            Ok(_) => panic!("expected Err, got Ok"),
4695            Err(e) => e,
4696        };
4697        assert!(
4698            matches!(err, RuntimeError::MissingPackDependency(_)),
4699            "expected MissingPackDependency, got {err:?}"
4700        );
4701        let msg = err.to_string();
4702        assert!(
4703            msg.contains("memory_dep"),
4704            "error must name the dependent pack: {msg}"
4705        );
4706        assert!(
4707            msg.contains("kg_dep"),
4708            "error must name the missing dep: {msg}"
4709        );
4710    }
4711
4712    #[test]
4713    fn test_pack_deps_circular() {
4714        let mut builder = VerbRegistryBuilder::new();
4715        builder.register(ADepPack);
4716        builder.register(BDepPack);
4717        let err = match builder.build() {
4718            Ok(_) => panic!("expected Err, got Ok"),
4719            Err(e) => e,
4720        };
4721        assert!(
4722            matches!(err, RuntimeError::CircularPackDependency(_)),
4723            "expected CircularPackDependency, got {err:?}"
4724        );
4725        let msg = err.to_string();
4726        assert!(msg.contains("pack_a"), "error must name pack_a: {msg}");
4727        assert!(msg.contains("pack_b"), "error must name pack_b: {msg}");
4728    }
4729
4730    #[test]
4731    fn test_pack_deps_no_deps() {
4732        struct NoDepsA;
4733        struct NoDepsB;
4734
4735        impl Pack for NoDepsA {
4736            const NAME: &'static str = "no_deps_a";
4737            const NOTE_KINDS: &'static [&'static str] = &[];
4738            const ENTITY_KINDS: &'static [&'static str] = &[];
4739            const HANDLERS: &'static [HandlerDef] = &[];
4740        }
4741
4742        impl Pack for NoDepsB {
4743            const NAME: &'static str = "no_deps_b";
4744            const NOTE_KINDS: &'static [&'static str] = &[];
4745            const ENTITY_KINDS: &'static [&'static str] = &[];
4746            const HANDLERS: &'static [HandlerDef] = &[];
4747        }
4748
4749        #[async_trait]
4750        impl PackRuntime for NoDepsA {
4751            fn name(&self) -> &str {
4752                Self::NAME
4753            }
4754            fn note_kinds(&self) -> &'static [&'static str] {
4755                Self::NOTE_KINDS
4756            }
4757            fn entity_kinds(&self) -> &'static [&'static str] {
4758                Self::ENTITY_KINDS
4759            }
4760            fn handlers(&self) -> &'static [HandlerDef] {
4761                Self::HANDLERS
4762            }
4763            async fn dispatch(
4764                &self,
4765                verb: &str,
4766                _: Value,
4767                _: &VerbRegistry,
4768                _: &NamespaceToken,
4769            ) -> Result<Value, RuntimeError> {
4770                Err(RuntimeError::InvalidInput(format!("NoDepsA: {verb}")))
4771            }
4772        }
4773
4774        #[async_trait]
4775        impl PackRuntime for NoDepsB {
4776            fn name(&self) -> &str {
4777                Self::NAME
4778            }
4779            fn note_kinds(&self) -> &'static [&'static str] {
4780                Self::NOTE_KINDS
4781            }
4782            fn entity_kinds(&self) -> &'static [&'static str] {
4783                Self::ENTITY_KINDS
4784            }
4785            fn handlers(&self) -> &'static [HandlerDef] {
4786                Self::HANDLERS
4787            }
4788            async fn dispatch(
4789                &self,
4790                verb: &str,
4791                _: Value,
4792                _: &VerbRegistry,
4793                _: &NamespaceToken,
4794            ) -> Result<Value, RuntimeError> {
4795                Err(RuntimeError::InvalidInput(format!("NoDepsB: {verb}")))
4796            }
4797        }
4798
4799        let mut builder = VerbRegistryBuilder::new();
4800        builder.register(NoDepsA);
4801        builder.register(NoDepsB);
4802        let reg = builder.build().expect("packs with REQUIRES=&[] build");
4803        assert_eq!(reg.pack_requires("no_deps_a").unwrap(), &[] as &[&str]);
4804        assert_eq!(reg.pack_requires("no_deps_b").unwrap(), &[] as &[&str]);
4805    }
4806}
4807
4808// ── Dispatch hook tests ─────────────────────────────────────────
4809
4810#[cfg(test)]
4811mod hook_tests {
4812    use super::*;
4813    use async_trait::async_trait;
4814    use khive_types::Pack;
4815    use std::sync::atomic::{AtomicUsize, Ordering};
4816    use std::sync::Mutex as StdMutex;
4817
4818    struct SimplePack;
4819
4820    impl Pack for SimplePack {
4821        const NAME: &'static str = "simple";
4822        const NOTE_KINDS: &'static [&'static str] = &[];
4823        const ENTITY_KINDS: &'static [&'static str] = &[];
4824        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
4825            name: "ping",
4826            description: "ping",
4827            visibility: Visibility::Verb,
4828            category: VerbCategory::Assertive,
4829            params: &[],
4830        }];
4831    }
4832
4833    #[async_trait]
4834    impl PackRuntime for SimplePack {
4835        fn name(&self) -> &str {
4836            SimplePack::NAME
4837        }
4838        fn note_kinds(&self) -> &'static [&'static str] {
4839            SimplePack::NOTE_KINDS
4840        }
4841        fn entity_kinds(&self) -> &'static [&'static str] {
4842            SimplePack::ENTITY_KINDS
4843        }
4844        fn handlers(&self) -> &'static [HandlerDef] {
4845            SimplePack::HANDLERS
4846        }
4847        async fn dispatch(
4848            &self,
4849            verb: &str,
4850            _params: Value,
4851            _registry: &VerbRegistry,
4852            _token: &NamespaceToken,
4853        ) -> Result<Value, RuntimeError> {
4854            Ok(serde_json::json!({ "verb": verb }))
4855        }
4856    }
4857
4858    /// Hook that counts calls and records the last verb seen.
4859    #[derive(Default)]
4860    struct CountingHook {
4861        calls: AtomicUsize,
4862        last_verb: StdMutex<String>,
4863    }
4864
4865    #[async_trait]
4866    impl DispatchHook for CountingHook {
4867        async fn on_dispatch(&self, view: &EventView) {
4868            self.calls.fetch_add(1, Ordering::SeqCst);
4869            *self.last_verb.lock().unwrap() = view.event.verb.clone();
4870        }
4871    }
4872
4873    #[tokio::test]
4874    async fn dispatch_hook_fires_on_successful_dispatch() {
4875        let hook = Arc::new(CountingHook::default());
4876        let mut builder = VerbRegistryBuilder::new();
4877        builder.register(SimplePack);
4878        builder.with_dispatch_hook(hook.clone());
4879        let reg = builder.build().expect("registry builds");
4880
4881        reg.dispatch("ping", Value::Null).await.unwrap();
4882
4883        assert_eq!(
4884            hook.calls.load(Ordering::SeqCst),
4885            1,
4886            "hook must fire once per successful dispatch"
4887        );
4888        assert_eq!(
4889            hook.last_verb.lock().unwrap().as_str(),
4890            "ping",
4891            "hook event must carry the dispatched verb"
4892        );
4893    }
4894
4895    #[tokio::test]
4896    async fn dispatch_hook_fires_multiple_times() {
4897        let hook = Arc::new(CountingHook::default());
4898        let mut builder = VerbRegistryBuilder::new();
4899        builder.register(SimplePack);
4900        builder.with_dispatch_hook(hook.clone());
4901        let reg = builder.build().expect("registry builds");
4902
4903        reg.dispatch("ping", Value::Null).await.unwrap();
4904        reg.dispatch("ping", Value::Null).await.unwrap();
4905        reg.dispatch("ping", Value::Null).await.unwrap();
4906
4907        assert_eq!(
4908            hook.calls.load(Ordering::SeqCst),
4909            3,
4910            "hook must fire once per successful dispatch"
4911        );
4912    }
4913
4914    #[tokio::test]
4915    async fn dispatch_hook_does_not_fire_on_unknown_verb() {
4916        let hook = Arc::new(CountingHook::default());
4917        let mut builder = VerbRegistryBuilder::new();
4918        builder.register(SimplePack);
4919        builder.with_dispatch_hook(hook.clone());
4920        let reg = builder.build().expect("registry builds");
4921
4922        let _ = reg.dispatch("nonexistent", Value::Null).await;
4923
4924        assert_eq!(
4925            hook.calls.load(Ordering::SeqCst),
4926            0,
4927            "hook must NOT fire for unknown verb (dispatch returns error)"
4928        );
4929    }
4930
4931    #[tokio::test]
4932    async fn dispatch_hook_does_not_fire_on_gate_deny() {
4933        use khive_gate::{Gate, GateDecision, GateError};
4934
4935        #[derive(Debug)]
4936        struct AlwaysDenyGate;
4937        impl Gate for AlwaysDenyGate {
4938            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
4939                Ok(GateDecision::deny("test deny"))
4940            }
4941        }
4942
4943        let hook = Arc::new(CountingHook::default());
4944        let mut builder = VerbRegistryBuilder::new();
4945        builder.register(SimplePack);
4946        builder.with_gate(Arc::new(AlwaysDenyGate));
4947        builder.with_dispatch_hook(hook.clone());
4948        let reg = builder.build().expect("registry builds");
4949
4950        let err = reg.dispatch("ping", Value::Null).await.unwrap_err();
4951        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
4952
4953        assert_eq!(
4954            hook.calls.load(Ordering::SeqCst),
4955            0,
4956            "hook must NOT fire when gate denies dispatch"
4957        );
4958    }
4959
4960    #[tokio::test]
4961    async fn dispatch_hook_event_carries_namespace_from_params() {
4962        let hook = Arc::new(CountingHook::default());
4963
4964        #[derive(Default)]
4965        struct NsCapturingHook {
4966            ns: StdMutex<String>,
4967        }
4968
4969        #[async_trait]
4970        impl DispatchHook for NsCapturingHook {
4971            async fn on_dispatch(&self, view: &EventView) {
4972                *self.ns.lock().unwrap() = view.event.namespace.clone();
4973            }
4974        }
4975
4976        let ns_hook = Arc::new(NsCapturingHook::default());
4977        let mut builder = VerbRegistryBuilder::new();
4978        builder.register(SimplePack);
4979        builder.with_dispatch_hook(ns_hook.clone());
4980        let reg = builder.build().expect("registry builds");
4981
4982        reg.dispatch("ping", serde_json::json!({"namespace": "tenant-abc"}))
4983            .await
4984            .unwrap();
4985
4986        assert_eq!(
4987            ns_hook.ns.lock().unwrap().as_str(),
4988            "tenant-abc",
4989            "dispatch hook event must carry the resolved namespace"
4990        );
4991
4992        // Suppress unused-variable warning from the outer hook.
4993        drop(hook);
4994    }
4995
4996    #[tokio::test]
4997    async fn no_dispatch_hook_configured_dispatch_succeeds() {
4998        // Regression: registries without a hook must still work.
4999        let mut builder = VerbRegistryBuilder::new();
5000        builder.register(SimplePack);
5001        // No with_dispatch_hook call.
5002        let reg = builder.build().expect("registry builds");
5003
5004        let res = reg.dispatch("ping", Value::Null).await.unwrap();
5005        assert_eq!(res["verb"], "ping");
5006    }
5007}
5008
5009// ── help=true tests ──────────────────────────────────────────────
5010
5011#[cfg(test)]
5012mod help_tests {
5013    use super::*;
5014    use async_trait::async_trait;
5015    use khive_types::Pack;
5016    use std::sync::{
5017        atomic::{AtomicUsize, Ordering},
5018        Arc,
5019    };
5020
5021    // ── HelpPack: a minimal pack with one handler that records invocation count.
5022    //
5023    // Used to verify that help=true never reaches the pack's dispatch method.
5024
5025    static CREATE_PARAMS: [ParamDef; 2] = [
5026        ParamDef {
5027            name: "kind",
5028            param_type: "string",
5029            required: true,
5030            description: "Granular kind (concept | document | ...).",
5031        },
5032        ParamDef {
5033            name: "name",
5034            param_type: "string",
5035            required: false,
5036            description: "Human-readable name.",
5037        },
5038    ];
5039
5040    static RECALL_PARAMS: [ParamDef; 2] = [
5041        ParamDef {
5042            name: "query",
5043            param_type: "string",
5044            required: true,
5045            description: "Semantic recall query.",
5046        },
5047        ParamDef {
5048            name: "limit",
5049            param_type: "integer",
5050            required: false,
5051            description: "Maximum memories to return.",
5052        },
5053    ];
5054
5055    // A subhandler with no params — mirrors recall.embed / brain.emit / etc.
5056    // Used to test that help=true on a Subhandler returns callable_via_mcp: false.
5057    static EMBED_PARAMS: [ParamDef; 0] = [];
5058
5059    struct HelpPack {
5060        invocations: Arc<AtomicUsize>,
5061    }
5062
5063    impl Pack for HelpPack {
5064        const NAME: &'static str = "helptest";
5065        const NOTE_KINDS: &'static [&'static str] = &[];
5066        const ENTITY_KINDS: &'static [&'static str] = &[];
5067        const HANDLERS: &'static [HandlerDef] = &[
5068            HandlerDef {
5069                name: "create",
5070                description: "Create an entity or note",
5071                visibility: Visibility::Verb,
5072                category: VerbCategory::Commissive,
5073                params: &CREATE_PARAMS,
5074            },
5075            HandlerDef {
5076                name: "recall",
5077                description: "Recall memory notes with decay-aware hybrid ranking",
5078                visibility: Visibility::Verb,
5079                category: VerbCategory::Assertive,
5080                params: &RECALL_PARAMS,
5081            },
5082            // A Subhandler used to test that help=true returns
5083            // callable_via_mcp: false for internal verbs.
5084            HandlerDef {
5085                name: "recall.embed",
5086                description: "Return the embedding vector used by memory recall",
5087                visibility: Visibility::Subhandler,
5088                category: VerbCategory::Assertive,
5089                params: &EMBED_PARAMS,
5090            },
5091        ];
5092    }
5093
5094    #[async_trait]
5095    impl PackRuntime for HelpPack {
5096        fn name(&self) -> &str {
5097            HelpPack::NAME
5098        }
5099        fn note_kinds(&self) -> &'static [&'static str] {
5100            HelpPack::NOTE_KINDS
5101        }
5102        fn entity_kinds(&self) -> &'static [&'static str] {
5103            HelpPack::ENTITY_KINDS
5104        }
5105        fn handlers(&self) -> &'static [HandlerDef] {
5106            HelpPack::HANDLERS
5107        }
5108        async fn dispatch(
5109            &self,
5110            verb: &str,
5111            _params: Value,
5112            _registry: &VerbRegistry,
5113            _token: &NamespaceToken,
5114        ) -> Result<Value, RuntimeError> {
5115            self.invocations.fetch_add(1, Ordering::SeqCst);
5116            Ok(serde_json::json!({ "pack": "helptest", "verb": verb }))
5117        }
5118    }
5119
5120    fn build_help_registry(invocations: Arc<AtomicUsize>) -> VerbRegistry {
5121        let mut builder = VerbRegistryBuilder::new();
5122        builder.register(HelpPack { invocations });
5123        builder.build().expect("help registry builds")
5124    }
5125
5126    /// help=true on `create` returns a schema envelope with the correct verb name,
5127    /// pack name, description, and at least the required `kind` parameter.
5128    #[tokio::test]
5129    async fn test_help_true_returns_schema_for_kg_create() {
5130        let invocations = Arc::new(AtomicUsize::new(0));
5131        let reg = build_help_registry(invocations.clone());
5132
5133        let result = reg
5134            .dispatch("create", serde_json::json!({ "help": true }))
5135            .await
5136            .expect("help=true must succeed for a known verb");
5137
5138        // Shape checks.
5139        assert_eq!(result["verb"], "create", "envelope must name the verb");
5140        assert_eq!(
5141            result["pack"], "helptest",
5142            "envelope must name the owning pack"
5143        );
5144        assert!(
5145            result["description"].as_str().is_some(),
5146            "description must be a string"
5147        );
5148
5149        // Params array must be present and non-empty.
5150        let params = result["params"]
5151            .as_array()
5152            .expect("params must be a JSON array");
5153        assert!(!params.is_empty(), "params array must not be empty");
5154
5155        // The required `kind` param must appear.
5156        let kind_param = params.iter().find(|p| p["name"] == "kind");
5157        assert!(
5158            kind_param.is_some(),
5159            "params array must include the 'kind' parameter"
5160        );
5161        let kind_param = kind_param.unwrap();
5162        assert_eq!(
5163            kind_param["required"],
5164            serde_json::json!(true),
5165            "'kind' must be required"
5166        );
5167        assert_eq!(kind_param["type"], "string", "'kind' type must be 'string'");
5168    }
5169
5170    /// help=true on `recall` returns a schema envelope including the `query` param.
5171    #[tokio::test]
5172    async fn test_help_true_returns_schema_for_recall() {
5173        let invocations = Arc::new(AtomicUsize::new(0));
5174        let reg = build_help_registry(invocations.clone());
5175
5176        let result = reg
5177            .dispatch("recall", serde_json::json!({ "help": true }))
5178            .await
5179            .expect("help=true must succeed for recall");
5180
5181        assert_eq!(result["verb"], "recall");
5182        assert_eq!(result["pack"], "helptest");
5183
5184        let params = result["params"]
5185            .as_array()
5186            .expect("params must be a JSON array");
5187
5188        // `query` must be present and required.
5189        let query_param = params.iter().find(|p| p["name"] == "query");
5190        assert!(query_param.is_some(), "params must include 'query'");
5191        let query_param = query_param.unwrap();
5192        assert_eq!(
5193            query_param["required"],
5194            serde_json::json!(true),
5195            "'query' must be required"
5196        );
5197
5198        // `limit` must be present and optional.
5199        let limit_param = params.iter().find(|p| p["name"] == "limit");
5200        assert!(limit_param.is_some(), "params must include 'limit'");
5201        let limit_param = limit_param.unwrap();
5202        assert_eq!(
5203            limit_param["required"],
5204            serde_json::json!(false),
5205            "'limit' must be optional"
5206        );
5207    }
5208
5209    /// help=true is intercepted before pack dispatch — the pack's dispatch method
5210    /// must never be invoked when help=true is in the params.
5211    #[tokio::test]
5212    async fn test_help_true_does_not_execute_the_verb() {
5213        let invocations = Arc::new(AtomicUsize::new(0));
5214        let reg = build_help_registry(invocations.clone());
5215
5216        // Call both verbs with help=true.
5217        reg.dispatch("create", serde_json::json!({ "help": true }))
5218            .await
5219            .expect("help=true must succeed");
5220        reg.dispatch("recall", serde_json::json!({ "help": true }))
5221            .await
5222            .expect("help=true must succeed");
5223
5224        assert_eq!(
5225            invocations.load(Ordering::SeqCst),
5226            0,
5227            "pack dispatch MUST NOT be invoked when help=true; \
5228             got {} invocation(s)",
5229            invocations.load(Ordering::SeqCst)
5230        );
5231
5232        // Confirm that a normal call (without help=true) DOES invoke dispatch.
5233        reg.dispatch("create", serde_json::json!({}))
5234            .await
5235            .expect("normal dispatch must succeed");
5236        assert_eq!(
5237            invocations.load(Ordering::SeqCst),
5238            1,
5239            "pack dispatch must fire exactly once for a normal call"
5240        );
5241    }
5242
5243    // ── Subhandler help-schema regressions ─────────────────────────────────
5244    //
5245    // Subhandler verbs must return `callable_via_mcp: false` in their help
5246    // schema so agents who read help=true before probing see accurate
5247    // availability — not a "looks callable" schema followed by permission denied.
5248
5249    /// help=true on a `Visibility::Subhandler` verb returns `callable_via_mcp: false`
5250    /// and `visibility: "internal"` rather than a plain callable-looking envelope.
5251    #[tokio::test]
5252    async fn help_true_on_subhandler_returns_callable_via_mcp_false() {
5253        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
5254
5255        let result = reg
5256            .dispatch("recall.embed", serde_json::json!({ "help": true }))
5257            .await
5258            .expect("help=true on subhandler must succeed (no permission check on help path)");
5259
5260        assert_eq!(
5261            result["callable_via_mcp"],
5262            serde_json::json!(false),
5263            "subhandler help must carry callable_via_mcp: false"
5264        );
5265        assert_eq!(
5266            result["visibility"], "internal",
5267            "subhandler help must carry visibility: internal"
5268        );
5269        // The verb and pack fields must still be present so the caller knows
5270        // what the schema belongs to.
5271        assert_eq!(result["verb"], "recall.embed");
5272        assert_eq!(result["pack"], "helptest");
5273    }
5274
5275    /// Public Verb-visibility handlers must NOT have `callable_via_mcp: false`.
5276    #[tokio::test]
5277    async fn help_true_on_public_verb_does_not_have_callable_via_mcp_false() {
5278        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
5279
5280        let result = reg
5281            .dispatch("create", serde_json::json!({ "help": true }))
5282            .await
5283            .expect("help=true on public verb must succeed");
5284
5285        // callable_via_mcp must be absent or true for public verbs.
5286        assert_ne!(
5287            result.get("callable_via_mcp"),
5288            Some(&serde_json::json!(false)),
5289            "public verb help must NOT carry callable_via_mcp: false"
5290        );
5291        // visibility must be absent or 'public' (never 'internal') for public verbs.
5292        assert_ne!(
5293            result.get("visibility"),
5294            Some(&serde_json::json!("internal")),
5295            "public verb help must NOT carry visibility: internal"
5296        );
5297    }
5298
5299    /// help=true on an unknown verb returns an error (same behavior as normal dispatch).
5300    #[tokio::test]
5301    async fn help_true_on_unknown_verb_returns_error() {
5302        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
5303
5304        let err = reg
5305            .dispatch("nonexistent_verb", serde_json::json!({ "help": true }))
5306            .await
5307            .unwrap_err();
5308
5309        assert!(
5310            matches!(err, RuntimeError::InvalidInput(_)),
5311            "help=true on unknown verb must return InvalidInput, got {err:?}"
5312        );
5313        let msg = err.to_string();
5314        assert!(
5315            msg.contains("nonexistent_verb"),
5316            "error must name the unknown verb: {msg}"
5317        );
5318    }
5319
5320    /// Subhandler help must include params: [] even when the verb has no params.
5321    #[tokio::test]
5322    async fn help_true_on_subhandler_includes_params_field() {
5323        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
5324
5325        let result = reg
5326            .dispatch("recall.embed", serde_json::json!({ "help": true }))
5327            .await
5328            .expect("help=true on subhandler must succeed");
5329
5330        // params must always be present (consistent shape).
5331        let params = result
5332            .get("params")
5333            .expect("subhandler help must include 'params' field");
5334        assert!(
5335            params.is_array(),
5336            "subhandler help params must be a JSON array"
5337        );
5338    }
5339
5340    // ── Unknown-verb error must not leak subhandler names ─────────
5341
5342    /// `describe_verb` on an unknown verb must list only Verb-visibility names
5343    /// in the "available" list: never subhandler names like `recall.embed`.
5344    #[tokio::test]
5345    async fn help_true_unknown_verb_available_list_excludes_subhandlers() {
5346        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
5347
5348        let err = reg
5349            .dispatch("not_a_verb", serde_json::json!({ "help": true }))
5350            .await
5351            .unwrap_err();
5352
5353        let msg = err.to_string();
5354        // `recall.embed` is a Subhandler in HelpPack — must NOT appear in the
5355        // "available" list of an unknown-verb error.
5356        assert!(
5357            !msg.contains("recall.embed"),
5358            "unknown-verb help error must not advertise subhandler recall.embed: {msg}"
5359        );
5360        // Public verbs must still appear so the agent knows what to call.
5361        assert!(
5362            msg.contains("create"),
5363            "unknown-verb help error must still list public verb 'create': {msg}"
5364        );
5365        assert!(
5366            msg.contains("recall"),
5367            "unknown-verb help error must still list public verb 'recall': {msg}"
5368        );
5369    }
5370
5371    /// Normal dispatch on an unknown verb must also not leak subhandler names.
5372    #[tokio::test]
5373    async fn dispatch_unknown_verb_available_list_excludes_subhandlers() {
5374        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
5375
5376        let err = reg
5377            .dispatch("not_a_verb", serde_json::json!({}))
5378            .await
5379            .unwrap_err();
5380
5381        let msg = err.to_string();
5382        // `recall.embed` is a Subhandler in HelpPack — must NOT appear in the
5383        // "available" list of an unknown-verb dispatch error.
5384        assert!(
5385            !msg.contains("recall.embed"),
5386            "dispatch unknown-verb error must not advertise subhandler recall.embed: {msg}"
5387        );
5388        // Public verbs must still appear so the agent knows what to call.
5389        assert!(
5390            msg.contains("create"),
5391            "dispatch unknown-verb error must still list public verb 'create': {msg}"
5392        );
5393        assert!(
5394            msg.contains("recall"),
5395            "dispatch unknown-verb error must still list public verb 'recall': {msg}"
5396        );
5397    }
5398
5399    // ── ADR-028 multi-backend schema routing tests ───────────────────────────
5400
5401    /// A test pack that returns a real SchemaPlan so we can assert routing.
5402    struct SchemaPack {
5403        pack_name: &'static str,
5404        statements: &'static [&'static str],
5405    }
5406
5407    impl Pack for SchemaPack {
5408        const NAME: &'static str = "schema-pack";
5409        const NOTE_KINDS: &'static [&'static str] = &[];
5410        const ENTITY_KINDS: &'static [&'static str] = &[];
5411        const HANDLERS: &'static [HandlerDef] = &[];
5412    }
5413
5414    #[async_trait]
5415    impl PackRuntime for SchemaPack {
5416        fn name(&self) -> &str {
5417            self.pack_name
5418        }
5419        fn note_kinds(&self) -> &'static [&'static str] {
5420            &[]
5421        }
5422        fn entity_kinds(&self) -> &'static [&'static str] {
5423            &[]
5424        }
5425        fn handlers(&self) -> &'static [HandlerDef] {
5426            &[]
5427        }
5428        fn schema_plan(&self) -> SchemaPlan {
5429            SchemaPlan {
5430                pack: self.pack_name,
5431                statements: self.statements,
5432            }
5433        }
5434        async fn dispatch(
5435            &self,
5436            verb: &str,
5437            _params: Value,
5438            _registry: &VerbRegistry,
5439            _token: &NamespaceToken,
5440        ) -> Result<Value, RuntimeError> {
5441            Ok(serde_json::json!({ "pack": self.pack_name, "verb": verb }))
5442        }
5443    }
5444
5445    // ADR-028: all_schema_plans_named returns (pack_name, SchemaPlan) pairs
5446    // where pack_name comes from SchemaPlan::pack (always &'static str).
5447    #[test]
5448    fn all_schema_plans_named_returns_correct_pairs() {
5449        let mut builder = VerbRegistryBuilder::new();
5450        builder.register_boxed(Box::new(SchemaPack {
5451            pack_name: "alpha",
5452            statements: &["CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER PRIMARY KEY)"],
5453        }));
5454        builder.register_boxed(Box::new(SchemaPack {
5455            pack_name: "beta",
5456            statements: &[],
5457        }));
5458        let reg = builder.build().expect("registry builds");
5459
5460        let named = reg.all_schema_plans_named();
5461        assert_eq!(named.len(), 2);
5462
5463        let alpha_entry = named.iter().find(|(n, _)| *n == "alpha");
5464        let beta_entry = named.iter().find(|(n, _)| *n == "beta");
5465
5466        assert!(alpha_entry.is_some(), "alpha must appear in named plans");
5467        assert!(beta_entry.is_some(), "beta must appear in named plans");
5468
5469        let (_, alpha_plan) = alpha_entry.unwrap();
5470        assert_eq!(alpha_plan.statements.len(), 1);
5471        assert!(!alpha_plan.is_empty());
5472
5473        let (_, beta_plan) = beta_entry.unwrap();
5474        assert!(beta_plan.is_empty());
5475    }
5476
5477    // ADR-028: apply_schema_plans_with_map routes non-empty plans to the
5478    // correct per-pack backend instead of the default.
5479    //
5480    // Verification: apply DDL to routed backend, then confirm the table is
5481    // present on pack_backend and absent on default_backend by attempting to
5482    // apply the same DDL again — if the table already exists on pack_backend
5483    // the idempotent CREATE IF NOT EXISTS succeeds; applying to default_backend
5484    // would only matter if the table were routed there.  We verify isolation
5485    // by applying the plan and then running a targeted DDL on each backend
5486    // that would fail if the table did not already exist (CREATE without
5487    // IF NOT EXISTS on a duplicate raises an error), combined with a no-error
5488    // path on the correct backend.
5489    //
5490    // Simpler approach: confirm the plan applies without error (routing is
5491    // correct) and that the opposite backend returns an error when we try to
5492    // INSERT into the routed table (table-not-found = SQLITE_ERROR).
5493    #[tokio::test]
5494    async fn apply_schema_plans_with_map_routes_to_correct_backend() {
5495        use khive_storage::types::{SqlStatement, SqlValue};
5496
5497        let default_backend = khive_db::StorageBackend::memory().expect("default memory backend");
5498        let pack_backend =
5499            khive_db::StorageBackend::memory().expect("pack-specific memory backend");
5500
5501        let mut builder = VerbRegistryBuilder::new();
5502        builder.register_boxed(Box::new(SchemaPack {
5503            pack_name: "routed",
5504            statements: &["CREATE TABLE IF NOT EXISTS t_routed (id INTEGER PRIMARY KEY)"],
5505        }));
5506        let reg = builder.build().expect("registry builds");
5507
5508        let mut backend_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
5509        backend_map.insert("routed", &pack_backend);
5510
5511        reg.apply_schema_plans_with_map(&backend_map, &default_backend)
5512            .expect("schema application must not collide");
5513
5514        // On pack_backend: INSERT must succeed (table exists).
5515        let mut writer = pack_backend.sql().writer().await.expect("writer");
5516        let result = writer
5517            .execute(SqlStatement {
5518                sql: "INSERT INTO t_routed (id) VALUES (?1)".into(),
5519                params: vec![SqlValue::Integer(1)],
5520                label: None,
5521            })
5522            .await;
5523        assert!(
5524            result.is_ok(),
5525            "t_routed must exist on pack_backend after routing: {result:?}"
5526        );
5527
5528        // On default_backend: INSERT must fail (table not there).
5529        let mut default_writer = default_backend.sql().writer().await.expect("writer");
5530        let default_result = default_writer
5531            .execute(SqlStatement {
5532                sql: "INSERT INTO t_routed (id) VALUES (?1)".into(),
5533                params: vec![SqlValue::Integer(2)],
5534                label: None,
5535            })
5536            .await;
5537        assert!(
5538            default_result.is_err(),
5539            "t_routed must NOT exist on default_backend (table should not be there)"
5540        );
5541    }
5542
5543    // ADR-028: apply_schema_plans_with_map uses default backend for packs
5544    // absent from the map.
5545    #[tokio::test]
5546    async fn apply_schema_plans_with_map_falls_back_to_default_for_unmapped_packs() {
5547        use khive_storage::types::{SqlStatement, SqlValue};
5548
5549        let default_backend = khive_db::StorageBackend::memory().expect("default memory backend");
5550
5551        let mut builder = VerbRegistryBuilder::new();
5552        builder.register_boxed(Box::new(SchemaPack {
5553            pack_name: "unmapped",
5554            statements: &["CREATE TABLE IF NOT EXISTS t_unmapped (id INTEGER PRIMARY KEY)"],
5555        }));
5556        let reg = builder.build().expect("registry builds");
5557
5558        let backend_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
5559        reg.apply_schema_plans_with_map(&backend_map, &default_backend)
5560            .expect("schema application must not collide");
5561
5562        // On default_backend: INSERT must succeed (table fell back here).
5563        let mut writer = default_backend.sql().writer().await.expect("writer");
5564        let result = writer
5565            .execute(SqlStatement {
5566                sql: "INSERT INTO t_unmapped (id) VALUES (?1)".into(),
5567                params: vec![SqlValue::Integer(1)],
5568                label: None,
5569            })
5570            .await;
5571        assert!(
5572            result.is_ok(),
5573            "t_unmapped must exist on default_backend for unmapped pack: {result:?}"
5574        );
5575    }
5576
5577    // ADR-028: two packs declaring the same auxiliary table on the same
5578    // backend must cause apply_schema_plans_with_map to return an error that
5579    // names both packs and the table: it is a boot-time failure, not a
5580    // silent DDL race.
5581    #[test]
5582    fn apply_schema_plans_with_map_collision_is_an_error() {
5583        let backend = khive_db::StorageBackend::memory().expect("memory backend");
5584        let empty_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
5585
5586        let mut builder = VerbRegistryBuilder::new();
5587        builder.register_boxed(Box::new(SchemaPack {
5588            pack_name: "pack_alpha",
5589            statements: &["CREATE TABLE IF NOT EXISTS collision_table (id INTEGER PRIMARY KEY)"],
5590        }));
5591        builder.register_boxed(Box::new(SchemaPack {
5592            pack_name: "pack_beta",
5593            statements: &["CREATE TABLE IF NOT EXISTS collision_table (id INTEGER PRIMARY KEY)"],
5594        }));
5595        let registry = builder.build().expect("registry builds");
5596
5597        let result = registry.apply_schema_plans_with_map(&empty_map, &backend);
5598        assert!(
5599            result.is_err(),
5600            "two packs declaring the same table on the same backend must produce a collision error"
5601        );
5602        let err = result.unwrap_err();
5603        let msg = err.to_string();
5604        assert!(
5605            msg.contains("pack_alpha"),
5606            "collision error must name first pack; got: {msg}"
5607        );
5608        assert!(
5609            msg.contains("pack_beta"),
5610            "collision error must name second pack; got: {msg}"
5611        );
5612        assert!(
5613            msg.contains("collision_table"),
5614            "collision error must name the table; got: {msg}"
5615        );
5616    }
5617}