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::{ActorRef, 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 (Issue #158).
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` (c12). 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    /// Warm up any in-memory state from persisted snapshots (optional).
207    ///
208    /// Called by the transport after all packs are registered but before
209    /// serving the first request, giving packs a chance to pre-load expensive
210    /// in-memory structures (e.g. ANN indexes) so that the first query does
211    /// not incur rebuild latency.
212    ///
213    /// The default no-op is correct for all packs that have no warm-start
214    /// state. Packs that override this must make it idempotent and infallible:
215    /// any errors are logged internally, not propagated to the caller.
216    async fn warm(&self) {}
217
218    /// Dispatch a verb call. Returns serialized JSON response.
219    ///
220    /// The `registry` parameter gives the handler access to the merged
221    /// vocabulary and kind hooks across all loaded packs.
222    /// The `token` is an authorized namespace token minted by the dispatch
223    /// boundary after gate authorization — handlers must use it directly.
224    async fn dispatch(
225        &self,
226        verb: &str,
227        params: Value,
228        registry: &VerbRegistry,
229        token: &NamespaceToken,
230    ) -> Result<Value, RuntimeError>;
231}
232
233/// Per-kind specialization for shared CRUD.
234///
235/// Packs implement `KindHook` for kinds they own that need:
236/// - **Defaults** filled into create args (e.g. `status="inbox"` for tasks)
237/// - **Derived properties** computed from args (e.g. salience from priority)
238/// - **Side-effect writes** after the storage commit (e.g. `depends_on` edges)
239///
240/// Hooks are stateless from the framework's perspective — they receive the
241/// runtime as a method parameter and operate on the args `Value` directly.
242/// The pack registers them via [`PackRuntime::kind_hook`].
243///
244/// Lifecycle verbs (e.g. gtd's `complete`, `transition`) remain pack-owned
245/// verbs and do not flow through this trait — only the create path does.
246#[async_trait]
247pub trait KindHook: Send + Sync + std::fmt::Debug {
248    /// Mutate args before the storage write. Fill defaults, normalize values,
249    /// rearrange user-facing fields into the storage shape expected by the
250    /// shared CRUD handler.
251    ///
252    /// Returning an error aborts the create call (no storage write happens).
253    async fn prepare_create(
254        &self,
255        runtime: &KhiveRuntime,
256        args: &mut Value,
257    ) -> Result<(), RuntimeError>;
258
259    /// Fire side effects after a successful storage write — graph edges,
260    /// derived observations, etc. The newly created record's UUID is passed
261    /// so the hook can attach metadata referencing it.
262    ///
263    /// Errors here are **logged but not propagated** — the storage write has
264    /// already succeeded; failing the call would mislead the caller.
265    /// Implementations should `tracing::warn!` and return `Ok(())` for
266    /// best-effort side effects.
267    async fn after_create(
268        &self,
269        runtime: &KhiveRuntime,
270        id: uuid::Uuid,
271        args: &Value,
272    ) -> Result<(), RuntimeError>;
273}
274
275/// Optional sub-trait for packs that own private SQL tables and issue UUIDs
276/// that must be reachable through the generic `get(id)` and `delete(id)` verbs.
277///
278/// Implementing both methods is required — the sub-trait bundles them atomically
279/// so partial implementation is a compile-time error, not a runtime surprise.
280/// Packs whose records live in the shared entity/note substrate (gtd, memory)
281/// do not implement this sub-trait.
282#[async_trait]
283pub trait PackByIdResolver: Send + Sync {
284    /// Attempt to resolve a live (non-deleted) UUID owned by this pack's private tables.
285    ///
286    /// Returns `Some(Resolved::PackRecord { ... })` if this pack owns the UUID,
287    /// `None` if it does not (the caller continues to the next resolver),
288    /// or `Err(...)` on a storage error.
289    ///
290    /// Must query domain-authoritative tables before mirror tables.
291    /// Must NOT filter by namespace. UUID v4 is globally unique; by-ID
292    /// resolution is namespace-blind per ADR-007.
293    async fn resolve_by_id(
294        &self,
295        id: uuid::Uuid,
296    ) -> Result<Option<crate::Resolved>, crate::RuntimeError>;
297
298    /// Attempt to resolve a UUID including already-soft-deleted records.
299    ///
300    /// Used by the hard-delete path. Default delegates to `resolve_by_id`;
301    /// packs with `deleted_at` columns override this to query without the filter.
302    async fn resolve_by_id_including_deleted(
303        &self,
304        id: uuid::Uuid,
305    ) -> Result<Option<crate::Resolved>, crate::RuntimeError> {
306        self.resolve_by_id(id).await
307    }
308
309    /// Delete a record owned by this pack's private tables.
310    ///
311    /// `hard` mirrors the `delete` verb's `hard?` argument.
312    /// Default behavior for packs with a `deleted_at` column MUST be soft-delete;
313    /// `hard=true` performs permanent removal.
314    ///
315    /// Returns `Ok(Value)` with a `{ deleted: true, id, kind, hard }` body on success.
316    /// Returns `Err(RuntimeError::NotFound(...))` if the record does not exist.
317    async fn delete_by_id(
318        &self,
319        id: uuid::Uuid,
320        hard: bool,
321    ) -> Result<serde_json::Value, crate::RuntimeError>;
322}
323
324/// Builder for constructing a `VerbRegistry`.
325///
326/// Packs are registered here; once `.build()` is called the registry is
327/// immutable and cheaply cloneable.
328pub struct VerbRegistryBuilder {
329    packs: Vec<Box<dyn PackRuntime>>,
330    resolvers: Vec<(String, Box<dyn PackByIdResolver>)>,
331    gate: GateRef,
332    default_namespace: String,
333    /// Operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
334    ///
335    /// Threads into `VerbRegistry::visible_namespaces` and is consumed by the
336    /// default dispatch path to widen read scope to `['local'] ∪ visible_namespaces`.
337    /// Writes remain pinned to `'local'`. An explicit `namespace=` request param
338    /// is a precise escape and is not widened by this set. A cloud gate may also
339    /// consult the list as policy input at its own layer.
340    visible_namespaces: Vec<Namespace>,
341    /// Configured actor identity label (ADR-057). When set, dispatch mints tokens
342    /// carrying this actor so that `comm.inbox` filters by `to_actor`.
343    actor_id: Option<String>,
344    /// Optional audit event sink.
345    ///
346    /// When set, every gate check writes a storage `Event` in addition to the
347    /// `tracing::info!` emission. The store is `Arc<dyn EventStore>` so the
348    /// registry does not depend on the full `KhiveRuntime` surface — only the
349    /// audit-persistence capability is needed here.
350    event_store: Option<Arc<dyn EventStore>>,
351    /// Optional post-dispatch hook (Issue #158).
352    ///
353    /// When set, every successful pack dispatch calls `hook.on_dispatch(event)`
354    /// with a synthesized Event describing the outcome. Opt-in: when None,
355    /// no overhead is incurred.
356    dispatch_hook: Option<Arc<dyn DispatchHook>>,
357}
358
359impl VerbRegistryBuilder {
360    /// Create a builder with no packs, `AllowAllGate`, and the local namespace as default.
361    pub fn new() -> Self {
362        Self {
363            packs: Vec::new(),
364            resolvers: Vec::new(),
365            gate: std::sync::Arc::new(AllowAllGate),
366            default_namespace: Namespace::local().as_str().to_string(),
367            visible_namespaces: vec![],
368            actor_id: None,
369            event_store: None,
370            dispatch_hook: None,
371        }
372    }
373
374    /// Set the operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
375    ///
376    /// On the default (no explicit `namespace=` param) dispatch path, reads fan
377    /// out over `['local'] ∪ ns`. Writes remain pinned to `'local'`. An explicit
378    /// `namespace=` request parameter is a precise single-namespace escape and
379    /// is not widened by this set. A cloud gate may also consult the list as
380    /// policy input at its own layer.
381    pub fn with_visible_namespaces(&mut self, ns: Vec<Namespace>) -> &mut Self {
382        self.visible_namespaces = ns;
383        self
384    }
385
386    /// Set the configured actor identity label (ADR-057).
387    ///
388    /// When set, the dispatch path mints tokens carrying this actor so that
389    /// `comm.inbox` applies the `to_actor` filter for directed delivery.
390    /// When `None` (default), tokens carry `ActorRef::anonymous()` and inbox
391    /// falls back to party-line behavior.
392    pub fn with_actor_id(&mut self, actor_id: Option<String>) -> &mut Self {
393        self.actor_id = actor_id;
394        self
395    }
396
397    /// Register a pack. The bound `P: Pack + PackRuntime` ensures the pack
398    /// declares vocabulary via `Pack` consts alongside runtime dispatch.
399    pub fn register<P: khive_types::Pack + PackRuntime + 'static>(&mut self, pack: P) -> &mut Self {
400        self.packs.push(Box::new(pack));
401        self
402    }
403
404    /// Register a boxed pack directly.
405    ///
406    /// Crate-private: only [`PackRegistry::register_packs`] should call this.
407    /// External callers must use the typed [`Self::register`] which enforces the
408    /// `Pack + PackRuntime` dual-impl contract at the call site.  Here the
409    /// contract is satisfied upstream at the [`PackFactory::create`] site.
410    pub(crate) fn register_boxed(&mut self, pack: Box<dyn PackRuntime>) -> &mut Self {
411        self.packs.push(pack);
412        self
413    }
414
415    /// Register a by-ID resolver for a pack that owns private SQL tables.
416    ///
417    /// Packs that implement `PackByIdResolver` call this during their boot path
418    /// so that `get(id)` and `delete(id)` can reach their records.
419    pub fn register_resolver(
420        &mut self,
421        name: impl Into<String>,
422        resolver: Box<dyn PackByIdResolver>,
423    ) -> &mut Self {
424        self.resolvers.push((name.into(), resolver));
425        self
426    }
427
428    /// Set the authorization gate consulted on every dispatch.
429    ///
430    /// Defaults to `AllowAllGate` if not set. `Deny` is authoritative — a deny
431    /// decision aborts dispatch with `RuntimeError::PermissionDenied`. Gate
432    /// infrastructure errors fail open (logged via `tracing::warn!`, dispatch
433    /// proceeds).
434    pub fn with_gate(&mut self, gate: GateRef) -> &mut Self {
435        self.gate = gate;
436        self
437    }
438
439    /// Set the namespace surfaced to the gate when a verb does not carry an
440    /// explicit `namespace` argument. Transports should plumb the runtime's
441    /// `default_namespace` so the gate's `input.namespace` always reflects
442    /// the operation's true tenant.
443    pub fn with_default_namespace(&mut self, ns: impl Into<String>) -> &mut Self {
444        self.default_namespace = ns.into();
445        self
446    }
447
448    /// Set the `EventStore` used to persist audit events.
449    ///
450    /// When configured, every gate check appends one `Event` (substrate =
451    /// `Event`, outcome = `Success` on allow, `Denied` on deny) in addition to
452    /// the `tracing::info!` emission that was already present in v0.2.
453    ///
454    /// Callers that do not set this field continue to use tracing-only emission
455    /// (the v0.2 default). There is no behavior change for them.
456    pub fn with_event_store(&mut self, store: Arc<dyn EventStore>) -> &mut Self {
457        self.event_store = Some(store);
458        self
459    }
460
461    /// Register a post-dispatch hook (Issue #158).
462    ///
463    /// When set, every successful pack dispatch calls `hook.on_dispatch(event)`
464    /// with a synthesized [`Event`] describing the verb outcome. The hook is
465    /// opt-in: registries without a hook incur zero overhead on the dispatch
466    /// hot path.
467    ///
468    /// Brain pack uses this to update its posteriors in real time without
469    /// polling the EventStore. Errors from `on_dispatch` are logged via
470    /// `tracing::warn!` and never propagated.
471    pub fn with_dispatch_hook(&mut self, hook: Arc<dyn DispatchHook>) -> &mut Self {
472        self.dispatch_hook = Some(hook);
473        self
474    }
475
476    /// Consume the builder and produce an immutable, cloneable registry.
477    ///
478    /// Performs a topological sort of packs using Kahn's algorithm.
479    /// Returns an error if any declared dependency is missing from the loaded
480    /// pack set, or if a circular dependency is detected.
481    pub fn build(self) -> Result<VerbRegistry, RuntimeError> {
482        let packs = self.packs;
483        let mut name_to_idx: HashMap<&str, usize> = HashMap::with_capacity(packs.len());
484        for (idx, pack) in packs.iter().enumerate() {
485            if let Some(prev_idx) = name_to_idx.insert(pack.name(), idx) {
486                return Err(RuntimeError::PackRedeclared {
487                    name: pack.name().to_string(),
488                    first_idx: prev_idx,
489                    second_idx: idx,
490                });
491            }
492        }
493
494        let mut missing: Vec<MissingPackDependency> = Vec::new();
495        let mut indegree = vec![0usize; packs.len()];
496        let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); packs.len()];
497
498        for (idx, pack) in packs.iter().enumerate() {
499            for &requires in pack.requires() {
500                match name_to_idx.get(requires).copied() {
501                    Some(dep_idx) => {
502                        dependents[dep_idx].push(idx);
503                        indegree[idx] += 1;
504                    }
505                    None => missing.push(MissingPackDependency {
506                        from: pack.name().to_string(),
507                        requires: requires.to_string(),
508                    }),
509                }
510            }
511        }
512
513        if !missing.is_empty() {
514            return if missing.len() == 1 {
515                Err(RuntimeError::MissingPackDependency(missing.remove(0)))
516            } else {
517                Err(RuntimeError::MissingPackDependencies(
518                    MissingPackDependencies { missing },
519                ))
520            };
521        }
522
523        let mut ready: VecDeque<usize> = indegree
524            .iter()
525            .enumerate()
526            .filter_map(|(idx, degree)| (*degree == 0).then_some(idx))
527            .collect();
528        let mut ordered_indices = Vec::with_capacity(packs.len());
529
530        while let Some(idx) = ready.pop_front() {
531            ordered_indices.push(idx);
532            for &dep_idx in &dependents[idx] {
533                indegree[dep_idx] -= 1;
534                if indegree[dep_idx] == 0 {
535                    ready.push_back(dep_idx);
536                }
537            }
538        }
539
540        if ordered_indices.len() != packs.len() {
541            let cycle_nodes: HashSet<usize> = indegree
542                .iter()
543                .enumerate()
544                .filter_map(|(idx, degree)| (*degree > 0).then_some(idx))
545                .collect();
546            let cycle = find_pack_dependency_cycle(&packs, &name_to_idx, &cycle_nodes);
547            return Err(RuntimeError::CircularPackDependency(
548                CircularPackDependency { cycle },
549            ));
550        }
551
552        let mut slots: Vec<Option<Box<dyn PackRuntime>>> = packs.into_iter().map(Some).collect();
553        let ordered_packs: Vec<Box<dyn PackRuntime>> = ordered_indices
554            .into_iter()
555            .map(|idx| slots[idx].take().expect("topological index must exist"))
556            .collect();
557
558        validate_unique_note_kinds(&ordered_packs)?;
559        validate_unique_verb_names(&ordered_packs)?;
560
561        Ok(VerbRegistry {
562            packs: Arc::new(ordered_packs),
563            resolvers: Arc::new(self.resolvers),
564            gate: self.gate,
565            default_namespace: self.default_namespace,
566            visible_namespaces: self.visible_namespaces,
567            actor_id: self.actor_id,
568            event_store: self.event_store,
569            dispatch_hook: self.dispatch_hook,
570        })
571    }
572}
573
574/// Validate that no two packs declare the same note kind (F073).
575///
576/// Boot-time duplicate detection prevents pack configuration errors from
577/// silently corrupting note kind routing. Returns an error naming the
578/// duplicate kind and the two packs that claim it.
579fn validate_unique_note_kinds(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
580    let mut seen: HashMap<&str, &str> = HashMap::new();
581    for pack in packs {
582        for &kind in pack.note_kinds() {
583            if let Some(first_pack) = seen.insert(kind, pack.name()) {
584                return Err(RuntimeError::InvalidInput(format!(
585                    "duplicate note kind {kind:?}: claimed by both {first_pack:?} and {:?}",
586                    pack.name()
587                )));
588            }
589        }
590    }
591    Ok(())
592}
593
594/// Validate that no two packs declare the same `Visibility::Verb` handler name
595/// (F093).
596///
597/// `Visibility::Subhandler` entries are pack-prefixed by convention and excluded
598/// from cross-pack collision detection. Two packs declaring the same subhandler
599/// name prefix (e.g. `recall.embed`) would be a pack-authoring error but does not
600/// produce a cross-pack routing conflict since only the owning pack dispatches them.
601fn validate_unique_verb_names(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
602    let mut seen: HashMap<&str, &str> = HashMap::new();
603    for pack in packs {
604        for handler in pack.handlers() {
605            if !matches!(handler.visibility, Visibility::Verb) {
606                continue;
607            }
608            if let Some(first_pack) = seen.insert(handler.name, pack.name()) {
609                return Err(RuntimeError::VerbCollision {
610                    verb: handler.name.to_string(),
611                    first_pack: first_pack.to_string(),
612                    second_pack: pack.name().to_string(),
613                });
614            }
615        }
616    }
617    Ok(())
618}
619
620fn find_pack_dependency_cycle(
621    packs: &[Box<dyn PackRuntime>],
622    name_to_idx: &HashMap<&str, usize>,
623    cycle_nodes: &HashSet<usize>,
624) -> Vec<String> {
625    fn visit(
626        idx: usize,
627        packs: &[Box<dyn PackRuntime>],
628        name_to_idx: &HashMap<&str, usize>,
629        cycle_nodes: &HashSet<usize>,
630        visiting: &mut Vec<usize>,
631        visited: &mut HashSet<usize>,
632    ) -> Option<Vec<String>> {
633        if let Some(pos) = visiting.iter().position(|&seen| seen == idx) {
634            let mut cycle: Vec<String> = visiting[pos..]
635                .iter()
636                .map(|&i| packs[i].name().to_string())
637                .collect();
638            cycle.push(packs[idx].name().to_string());
639            return Some(cycle);
640        }
641        if !visited.insert(idx) {
642            return None;
643        }
644        visiting.push(idx);
645        for &req in packs[idx].requires() {
646            let Some(&dep_idx) = name_to_idx.get(req) else {
647                continue;
648            };
649            if cycle_nodes.contains(&dep_idx) {
650                if let Some(cycle) =
651                    visit(dep_idx, packs, name_to_idx, cycle_nodes, visiting, visited)
652                {
653                    return Some(cycle);
654                }
655            }
656        }
657        visiting.pop();
658        None
659    }
660
661    let mut visited = HashSet::new();
662    for &idx in cycle_nodes {
663        let mut visiting = Vec::new();
664        if let Some(cycle) = visit(
665            idx,
666            packs,
667            name_to_idx,
668            cycle_nodes,
669            &mut visiting,
670            &mut visited,
671        ) {
672            return cycle;
673        }
674    }
675    cycle_nodes
676        .iter()
677        .map(|&idx| packs[idx].name().to_string())
678        .collect()
679}
680
681impl Default for VerbRegistryBuilder {
682    fn default() -> Self {
683        Self::new()
684    }
685}
686
687/// Immutable registry that dispatches verb calls to registered packs.
688///
689/// Clone is cheap (Arc-wrapped). Constructed via `VerbRegistryBuilder`.
690#[derive(Clone)]
691pub struct VerbRegistry {
692    packs: std::sync::Arc<Vec<Box<dyn PackRuntime>>>,
693    /// Pack-level by-ID resolvers, in registration order.
694    resolvers: std::sync::Arc<Vec<(String, Box<dyn PackByIdResolver>)>>,
695    gate: GateRef,
696    default_namespace: String,
697    /// Operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
698    ///
699    /// On the default (no explicit `namespace=` param) dispatch path, reads fan
700    /// out over `['local'] ∪ visible_namespaces`. Writes are unaffected — they
701    /// still pin to `'local'`. An explicit `namespace=` request param is a
702    /// precise single-namespace escape and is not widened by this set.
703    visible_namespaces: Vec<Namespace>,
704    /// Configured actor identity label (ADR-057). When `Some`, dispatch mints
705    /// tokens carrying this actor so that `comm.inbox` applies the `to_actor`
706    /// filter. When `None`, tokens carry `ActorRef::anonymous()` (party-line).
707    actor_id: Option<String>,
708    /// Audit event sink — `None` means tracing-only (v0.2 default).
709    event_store: Option<Arc<dyn EventStore>>,
710    /// Post-dispatch hook — `None` means no real-time observation (Issue #158).
711    dispatch_hook: Option<Arc<dyn DispatchHook>>,
712}
713
714/// Error returned by [`VerbRegistry::apply_schema_plans_with_map`] when two
715/// packs on the same backend declare the same auxiliary table (ADR-028 §7).
716#[derive(Debug)]
717pub struct PackSchemaCollisionError {
718    /// First pack to declare the table.
719    pub pack_a: &'static str,
720    /// Second pack that collides with `pack_a`.
721    pub pack_b: &'static str,
722    /// Table name or DDL error description.
723    pub table: String,
724}
725
726impl std::fmt::Display for PackSchemaCollisionError {
727    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
728        if self.pack_a == self.pack_b {
729            write!(
730                f,
731                "pack schema boot failure for pack {:?}: {}",
732                self.pack_a, self.table
733            )
734        } else {
735            write!(
736                f,
737                "pack schema collision: packs {:?} and {:?} both declare table {:?} \
738                 on the same backend — move one pack to a separate backend or rename the table",
739                self.pack_a, self.pack_b, self.table
740            )
741        }
742    }
743}
744
745impl std::error::Error for PackSchemaCollisionError {}
746
747/// Extract table names from a single DDL statement.
748///
749/// Handles `CREATE TABLE IF NOT EXISTS`, `CREATE TABLE`, and
750/// `CREATE VIRTUAL TABLE IF NOT EXISTS`, `CREATE VIRTUAL TABLE`.
751/// Returns an empty Vec when no table name is found (e.g. index DDL).
752fn extract_table_names(stmt: &str) -> Vec<String> {
753    let normalized = stmt.split_whitespace().collect::<Vec<_>>().join(" ");
754    let upper = normalized.to_ascii_uppercase();
755    let table_name = if let Some(rest) = upper.strip_prefix("CREATE VIRTUAL TABLE IF NOT EXISTS ") {
756        rest.split_whitespace().next()
757    } else if let Some(rest) = upper.strip_prefix("CREATE VIRTUAL TABLE ") {
758        rest.split_whitespace().next()
759    } else if let Some(rest) = upper.strip_prefix("CREATE TABLE IF NOT EXISTS ") {
760        rest.split_whitespace().next()
761    } else if let Some(rest) = upper.strip_prefix("CREATE TABLE ") {
762        rest.split_whitespace().next()
763    } else {
764        None
765    };
766    match table_name {
767        Some(name) => {
768            let clean = name.trim_matches(|c: char| c == '(' || c == ';');
769            if clean.is_empty() {
770                vec![]
771            } else {
772                vec![clean.to_ascii_lowercase()]
773            }
774        }
775        None => vec![],
776    }
777}
778
779impl VerbRegistry {
780    /// Return the help schema envelope for a verb (issue #287).
781    ///
782    /// Walks registered packs for the first matching `HandlerDef` and returns a
783    /// structured JSON envelope. Subhandlers carry `callable_via_mcp: false`.
784    /// Unknown verbs return `RuntimeError::InvalidInput`. Full shape documented
785    /// in `docs/protocol.md` §Request Schema.
786    pub fn describe_verb(&self, verb: &str) -> Result<Value, RuntimeError> {
787        for pack in self.packs.iter() {
788            for handler in pack.handlers().iter() {
789                if handler.name == verb {
790                    let category = format!("{:?}", handler.category);
791                    let params_arr: Vec<Value> = handler
792                        .params
793                        .iter()
794                        .map(|p| {
795                            serde_json::json!({
796                                "name": p.name,
797                                "type": p.param_type,
798                                "required": p.required,
799                                "description": p.description,
800                            })
801                        })
802                        .collect();
803                    // ue-help-introspection C1: subhandlers are not callable via
804                    // the MCP request surface.  The help payload must match the
805                    // behaviour the dispatch path enforces so that agents who
806                    // read `help=true` before probing see accurate availability.
807                    if matches!(handler.visibility, Visibility::Subhandler) {
808                        return Ok(serde_json::json!({
809                            "verb": verb,
810                            "pack": pack.name(),
811                            "description": handler.description,
812                            "category": category,
813                            "params": params_arr,
814                            "visibility": "internal",
815                            "callable_via_mcp": false,
816                            "note": "This is an internal subhandler. Calling it via the MCP \
817                                     request surface returns permission denied. It can only be \
818                                     invoked by internal runtime callers.",
819                        }));
820                    }
821                    return Ok(serde_json::json!({
822                        "verb": verb,
823                        "pack": pack.name(),
824                        "description": handler.description,
825                        "category": category,
826                        "params": params_arr,
827                    }));
828                }
829            }
830        }
831        // Only list Verb-visibility handlers so internal subhandlers are not
832        // advertised in the unknown-verb error (ue-help-introspection C1 / codex High).
833        let available: Vec<&str> = self
834            .packs
835            .iter()
836            .flat_map(|p| p.handlers().iter())
837            .filter(|h| matches!(h.visibility, Visibility::Verb))
838            .map(|h| h.name)
839            .collect();
840        Err(RuntimeError::InvalidInput(format!(
841            "unknown verb {verb:?}; available: {}",
842            available.join(", ")
843        )))
844    }
845
846    /// Check whether the gate permits writes into `ns`.
847    ///
848    /// Performs a gate evaluation with verb `"authorize"` before any background
849    /// loop is spawned (ADR-056 §6).  Returns `Ok(())` when the gate allows the
850    /// namespace, or `Err(RuntimeError::PermissionDenied{..})` when denied.
851    /// Gate errors (implementation failures) are surfaced as
852    /// `RuntimeError::Internal`.
853    pub fn authorize_namespace(&self, ns: Namespace) -> Result<(), RuntimeError> {
854        let actor = match self.actor_id.as_deref() {
855            Some(id) if !id.trim().is_empty() => ActorRef::new("actor", id),
856            _ => ActorRef::anonymous(),
857        };
858        let req = GateRequest::new(actor, ns, "authorize", serde_json::Value::Null);
859        match self.gate.check(&req) {
860            Ok(decision) if decision.is_allow() => Ok(()),
861            Ok(GateDecision::Deny { reason }) => Err(RuntimeError::PermissionDenied {
862                verb: "authorize".to_string(),
863                reason,
864            }),
865            Ok(_) => Err(RuntimeError::PermissionDenied {
866                verb: "authorize".to_string(),
867                reason: "gate denied".to_string(),
868            }),
869            Err(e) => Err(RuntimeError::Internal(format!("gate error: {e}"))),
870        }
871    }
872
873    /// Dispatch a verb to the first pack that handles it.
874    ///
875    /// Routes through the gate, then invokes the matching pack handler. When
876    /// `params["help"] == true`, short-circuits to `describe_verb` with no side effects.
877    /// Gate errors are fail-open. Full dispatch flow documented in `docs/protocol.md`.
878    pub async fn dispatch(&self, verb: &str, params: Value) -> Result<Value, RuntimeError> {
879        // help=true interception (issue #287) — short-circuit before gate/pack.
880        if params.get("help").and_then(Value::as_bool) == Some(true) {
881            return self.describe_verb(verb);
882        }
883        // Resolve namespace as an owned String before `params` is moved into
884        // pack.dispatch, so the post-dispatch hook can reference it.
885        let ns_str: String = params
886            .get("namespace")
887            .and_then(Value::as_str)
888            .map(str::to_string)
889            .unwrap_or_else(|| self.default_namespace.clone());
890        let ns = Namespace::parse(&ns_str)
891            .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
892        // ADR-057: thread the configured actor identity into the gate request so
893        // the gate can distinguish human vs agent callers at the dispatch seam.
894        // Mirrors the actor resolution used by the token-minting path below.
895        let gate_actor = match self.actor_id.as_deref() {
896            Some(id) if !id.trim().is_empty() => ActorRef::new("actor", id),
897            _ => ActorRef::anonymous(),
898        };
899        let gate_req = GateRequest::new(gate_actor, ns, verb, params.clone());
900
901        // Consult the gate.
902        //
903        // - Ok(Allow) → proceed to pack dispatch (tracing + optional EventStore).
904        // - Ok(Deny) → emit audit, persist if store configured, return PermissionDenied.
905        // - Err(_) → warn via tracing, fail-open (no audit persisted).
906        let gate_blocked = match self.gate.check(&gate_req) {
907            Ok(decision) => {
908                let is_deny = matches!(decision, GateDecision::Deny { .. });
909
910                // Emit audit event via tracing.
911                let audit = AuditEvent::from_check(&gate_req, &decision, self.gate.impl_name());
912                tracing::info!(
913                    audit_event = %serde_json::to_string(&audit)
914                        .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
915                    "gate.check"
916                );
917
918                // Persist to EventStore when configured.
919                if let Some(store) = &self.event_store {
920                    let outcome = if is_deny {
921                        EventOutcome::Denied
922                    } else {
923                        EventOutcome::Success
924                    };
925                    let audit_data = serde_json::to_value(&audit).unwrap_or_else(|e| {
926                        tracing::warn!(error = %e, "failed to serialize AuditEvent for EventStore");
927                        serde_json::Value::Null
928                    });
929                    let mut storage_event = Event::new(
930                        gate_req.namespace.as_str(),
931                        verb,
932                        EventKind::Audit,
933                        SubstrateKind::Event,
934                        format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
935                    )
936                    .with_outcome(outcome)
937                    .with_payload(audit_data);
938                    if let Some(target_id) = target_id_from_args(&gate_req.args) {
939                        storage_event = storage_event.with_target(target_id);
940                    }
941                    if let Err(store_err) = store.append_event(storage_event).await {
942                        tracing::warn!(
943                            verb,
944                            error = %store_err,
945                            "audit event store write failed (non-fatal)"
946                        );
947                    }
948                }
949
950                if is_deny {
951                    let reason = match decision {
952                        GateDecision::Deny { reason } => reason,
953                        _ => String::new(),
954                    };
955                    Some(reason)
956                } else {
957                    None
958                }
959            }
960            Err(err) => {
961                // Gate infrastructure failure — fail-open.
962                // No decision was produced; no audit event is persisted.
963                tracing::warn!(verb, error = %err, "gate check failed (fail-open)");
964                None
965            }
966        };
967
968        // Hard enforcement: Deny is authoritative.
969        if let Some(reason) = gate_blocked {
970            return Err(RuntimeError::PermissionDenied {
971                verb: verb.to_string(),
972                reason,
973            });
974        }
975
976        // Mint the authorized storage token at the dispatch boundary.
977        //
978        // ADR-007 Rev 4 Rule 0/3/3b: writes pin to `local` by default. Actor
979        // identity and config `[actor] id` are attribution and gate-context inputs
980        // only — they never route storage. The explicit `namespace=` request param
981        // is a precise single-namespace escape (Rule 3): the caller deliberately
982        // reads/writes exactly that one set; it is NOT widened by `visible_namespaces`.
983        //
984        // When actor_id is configured (ADR-057), mint a token carrying that actor
985        // label so that comm.inbox applies the to_actor filter for directed delivery.
986        // Otherwise, use ActorRef::anonymous() and inbox falls back to party-line.
987        let configured_actor = match self.actor_id.as_deref() {
988            Some(id) if !id.trim().is_empty() => ActorRef::new("actor", id),
989            _ => ActorRef::anonymous(),
990        };
991
992        // Rule 3b (Rev 4): on the default (no explicit `namespace=`) path, the read
993        // scope widens to `['local'] ∪ self.visible_namespaces`. `'local'` is always
994        // included (mint_with_visibility deduplicates). Writes remain pinned to
995        // `'local'`. Per-actor distinctions use view-layer tag filters
996        // (assignee, actor_id, from/to), not namespace partitions.
997        let token = match params.get("namespace").and_then(Value::as_str) {
998            Some(ns) => {
999                // Rule 3 explicit escape: precise single-namespace scope, read+write. NOT widened.
1000                let primary = Namespace::parse(ns)
1001                    .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
1002                NamespaceToken::mint_with_visibility(primary, vec![], configured_actor)
1003            }
1004            None => {
1005                // Rule 3b: default path. Write namespace = local; read scope = ['local'] ∪ visible_namespaces.
1006                let primary = Namespace::local();
1007                let mut extra_visible = self.visible_namespaces.clone();
1008                extra_visible.push(Namespace::local()); // 'local' always readable; mint dedups
1009                NamespaceToken::mint_with_visibility(primary, extra_visible, configured_actor)
1010            }
1011        };
1012
1013        for pack in self.packs.iter() {
1014            if let Some(handler_def) = pack.handlers().iter().find(|v| v.name == verb) {
1015                // Strip `namespace` from params before forwarding to packs.
1016                // The registry has already consumed it to mint the NamespaceToken.
1017                //
1018                // Exception: if the handler's own `params` schema declares
1019                // `"namespace"` as a valid field (e.g. brain.bind, brain.unbind,
1020                // brain.bindings, brain.resolve), the field is a *business* argument
1021                // — not a transport routing key — and must be passed through
1022                // unchanged. Stripping it would silently default the binding to the
1023                // "*" wildcard, broadening profile scope across namespaces.
1024                let handler_accepts_namespace =
1025                    handler_def.params.iter().any(|p| p.name == "namespace");
1026                let params = if !handler_accepts_namespace {
1027                    if let Value::Object(mut map) = params {
1028                        map.remove("namespace");
1029                        Value::Object(map)
1030                    } else {
1031                        params
1032                    }
1033                } else {
1034                    params
1035                };
1036                let dispatch_start = Instant::now();
1037                let result = pack.dispatch(verb, params, self, &token).await;
1038                let dispatch_us = dispatch_start.elapsed().as_micros() as i64;
1039
1040                // Post-dispatch hook: fires on success, opt-in (Issue #158).
1041                if let (Ok(ref ok_val), Some(hook)) = (&result, &self.dispatch_hook) {
1042                    let mut dispatch_event = Event::new(
1043                        ns_str.as_str(),
1044                        verb,
1045                        EventKind::Audit,
1046                        SubstrateKind::Event,
1047                        pack.name(),
1048                    )
1049                    .with_outcome(EventOutcome::Success)
1050                    .with_duration_us(dispatch_us);
1051
1052                    // For recall verbs: extract the first result's id as
1053                    // target_id so the brain temporal posterior can observe
1054                    // real hit/miss and latency (fix for codex P12 Major).
1055                    if verb == "memory.recall" {
1056                        let first_note_id = ok_val
1057                            .as_array()
1058                            .and_then(|arr| arr.first())
1059                            .and_then(|v| v.get("id"))
1060                            .and_then(|v| v.as_str())
1061                            .and_then(|s| s.parse::<uuid::Uuid>().ok());
1062                        if let Some(note_id) = first_note_id {
1063                            dispatch_event = dispatch_event.with_target(note_id);
1064                        }
1065                        // No first result → target_id stays None (RecallMiss
1066                        // in brain's event interpreter).
1067                    }
1068
1069                    let dispatch_view = EventView {
1070                        event: dispatch_event,
1071                        observations: Vec::new(),
1072                    };
1073                    let hook = Arc::clone(hook);
1074                    hook.on_dispatch(&dispatch_view).await;
1075                }
1076
1077                return result;
1078            }
1079        }
1080        // Only list Verb-visibility handlers so internal subhandlers are not
1081        // advertised in the unknown-verb error (ue-help-introspection C1 / codex High).
1082        let available: Vec<&str> = self
1083            .packs
1084            .iter()
1085            .flat_map(|p| p.handlers().iter())
1086            .filter(|h| matches!(h.visibility, Visibility::Verb))
1087            .map(|h| h.name)
1088            .collect();
1089        Err(RuntimeError::InvalidInput(format!(
1090            "unknown verb {verb:?}; available: {}",
1091            available.join(", ")
1092        )))
1093    }
1094
1095    /// Registered pack-level by-ID resolvers, in registration order.
1096    ///
1097    /// Each element is `(pack_name, resolver)`. The kg `get` and `delete` handlers
1098    /// iterate this slice to probe pack-private tables when the standard KG
1099    /// substrates (entity/note/edge/event) return `None` for a given UUID.
1100    pub fn resolvers(&self) -> &[(String, Box<dyn PackByIdResolver>)] {
1101        &self.resolvers
1102    }
1103
1104    /// Find a kind hook among the registered packs.
1105    ///
1106    /// Walks packs in registration order; the first pack that both owns the
1107    /// kind (declares it in `note_kinds()` or `entity_kinds()`) and returns
1108    /// a hook from `kind_hook(kind)` wins. Returns `None` if the kind is
1109    /// unknown to all packs or no owning pack registered a hook.
1110    pub fn find_kind_hook(&self, kind: &str) -> Option<Arc<dyn KindHook>> {
1111        for pack in self.packs.iter() {
1112            let owns = pack.note_kinds().contains(&kind) || pack.entity_kinds().contains(&kind);
1113            if owns {
1114                if let Some(hook) = pack.kind_hook(kind) {
1115                    return Some(hook);
1116                }
1117            }
1118        }
1119        None
1120    }
1121
1122    /// All MCP-exposed handlers across all registered packs (`Visibility::Verb` only).
1123    ///
1124    /// Subhandlers (`Visibility::Subhandler`) are excluded — they are internal
1125    /// pipeline steps not surfaced on the MCP wire (F118). Returned with `'static`
1126    /// lifetime since pack handlers are `&'static [HandlerDef]` constants.
1127    pub fn all_verbs(&self) -> Vec<&'static HandlerDef> {
1128        self.packs
1129            .iter()
1130            .flat_map(|p| p.handlers().iter())
1131            .filter(|h| matches!(h.visibility, Visibility::Verb))
1132            .collect()
1133    }
1134
1135    /// All MCP-exposed handlers paired with the name of the pack that owns them
1136    /// (`Visibility::Verb` only).
1137    ///
1138    /// Subhandlers (`Visibility::Subhandler`) are excluded from the MCP catalog
1139    /// (F118-F123). Use `all_handlers_with_names` when internal handlers must
1140    /// also be enumerated (e.g. runtime introspection).
1141    pub fn all_verbs_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
1142        self.packs
1143            .iter()
1144            .flat_map(|p| p.handlers().iter().map(move |v| (p.name(), v)))
1145            .filter(|(_, h)| matches!(h.visibility, Visibility::Verb))
1146            .collect()
1147    }
1148
1149    /// All handler definitions across all registered packs, including subhandlers.
1150    ///
1151    /// Unlike `all_verbs`, this includes `Visibility::Subhandler` entries. Useful
1152    /// for runtime introspection (e.g. `list_handlers`) and tooling that needs
1153    /// the complete handler surface.
1154    pub fn all_handlers_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
1155        self.packs
1156            .iter()
1157            .flat_map(|p| p.handlers().iter().map(move |v| (p.name(), v)))
1158            .collect()
1159    }
1160
1161    /// Merged set of note kinds across all registered packs (deduplicated,
1162    /// first-seen order preserved).
1163    pub fn all_note_kinds(&self) -> Vec<&'static str> {
1164        let mut seen = std::collections::HashSet::new();
1165        self.packs
1166            .iter()
1167            .flat_map(|p| p.note_kinds().iter().copied())
1168            .filter(|k| seen.insert(*k))
1169            .collect()
1170    }
1171
1172    /// Merged set of entity kinds across all registered packs (deduplicated,
1173    /// first-seen order preserved).
1174    pub fn all_entity_kinds(&self) -> Vec<&'static str> {
1175        let mut seen = std::collections::HashSet::new();
1176        self.packs
1177            .iter()
1178            .flat_map(|p| p.entity_kinds().iter().copied())
1179            .filter(|k| seen.insert(*k))
1180            .collect()
1181    }
1182
1183    /// Names of packs in topological load order.
1184    pub fn pack_names(&self) -> Vec<&str> {
1185        self.packs.iter().map(|p| p.name()).collect()
1186    }
1187
1188    /// Declared dependencies for a registered pack.
1189    pub fn pack_requires(&self, name: &str) -> Option<&'static [&'static str]> {
1190        self.packs
1191            .iter()
1192            .find(|p| p.name() == name)
1193            .map(|p| p.requires())
1194    }
1195
1196    /// Note kinds owned by a specific registered pack.
1197    ///
1198    /// Returns `None` if no pack with `name` is registered. The slice is
1199    /// the pack's `NOTE_KINDS` constant — `'static` lifetime, no allocation.
1200    pub fn pack_note_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
1201        self.packs
1202            .iter()
1203            .find(|p| p.name() == name)
1204            .map(|p| p.note_kinds())
1205    }
1206
1207    /// Entity kinds owned by a specific registered pack.
1208    ///
1209    /// Returns `None` if no pack with `name` is registered. The slice is
1210    /// the pack's `ENTITY_KINDS` constant — `'static` lifetime, no allocation.
1211    pub fn pack_entity_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
1212        self.packs
1213            .iter()
1214            .find(|p| p.name() == name)
1215            .map(|p| p.entity_kinds())
1216    }
1217
1218    /// Handlers declared by a specific registered pack.
1219    ///
1220    /// Returns `None` if no pack with `name` is registered. Each `HandlerDef`
1221    /// carries name + description + visibility — sufficient for introspection clients.
1222    pub fn pack_verbs(&self, name: &str) -> Option<&'static [HandlerDef]> {
1223        self.packs
1224            .iter()
1225            .find(|p| p.name() == name)
1226            .map(|p| p.handlers())
1227    }
1228
1229    /// All pack-declared edge endpoint rules across registered packs.
1230    ///
1231    /// Order follows topological pack registration; duplicates are *not* deduplicated —
1232    /// validation only checks membership, and an exact-duplicate rule is a
1233    /// harmless restatement.
1234    pub fn all_edge_rules(&self) -> Vec<EdgeEndpointRule> {
1235        self.packs
1236            .iter()
1237            .flat_map(|p| p.edge_rules().iter().copied())
1238            .collect()
1239    }
1240
1241    /// Collect all `NoteKindSpec` declarations from every loaded pack.
1242    ///
1243    /// Used by the runtime for lifecycle introspection and future enforcement.
1244    pub fn all_note_kind_specs(&self) -> Vec<&'static NoteKindSpec> {
1245        self.packs
1246            .iter()
1247            .flat_map(|p| p.note_kind_specs().iter())
1248            .collect()
1249    }
1250
1251    /// All pack-contributed validation rules across registered packs.
1252    ///
1253    /// Returns references into the pack-owned `'static` slices — no allocation
1254    /// beyond the outer `Vec`. Rule IDs are namespaced by pack; callers can
1255    /// group by `rule.id.split_once('/')` to attribute rules to their packs.
1256    pub fn all_validation_rules(&self) -> Vec<&'static ValidationRule> {
1257        self.packs
1258            .iter()
1259            .flat_map(|p| p.validation_rules().iter())
1260            .collect()
1261    }
1262
1263    /// Pack-auxiliary schema plans for all registered packs.
1264    ///
1265    /// Returns one `SchemaPlan` per pack. Callers (typically the runtime
1266    /// bootstrap) apply each plan to the pack's assigned backend. Empty plans
1267    /// are included so the caller can iterate uniformly; callers that want to
1268    /// skip empty plans should check `plan.is_empty()`.
1269    pub fn all_schema_plans(&self) -> Vec<SchemaPlan> {
1270        self.packs.iter().map(|p| p.schema_plan()).collect()
1271    }
1272
1273    /// Invoke `PackRuntime::register_embedders` on every registered pack.
1274    ///
1275    /// Called by the transport during startup, after the registry is built and
1276    /// before the first verb dispatch, so that custom embedding providers
1277    /// contributed by packs are reachable via `KhiveRuntime::embedder(name)`.
1278    ///
1279    /// Packs whose `register_embedders` is the default no-op pay no overhead.
1280    /// The method is idempotent when the underlying registry uses last-wins
1281    /// semantics for duplicate provider names.
1282    pub fn call_register_embedders(&self, runtime: &KhiveRuntime) {
1283        for pack in self.packs.iter() {
1284            pack.register_embedders(runtime);
1285        }
1286    }
1287
1288    /// Invoke `PackRuntime::register_entity_type_validator` on every registered pack.
1289    ///
1290    /// Called by the transport during startup, after the registry is built and
1291    /// before the first verb dispatch, so that entity-type validation at the
1292    /// runtime layer is active for all write paths including direct `create_many`
1293    /// callers that bypass the handler layer.
1294    ///
1295    /// Packs whose `register_entity_type_validator` is the default no-op pay
1296    /// no overhead.
1297    pub fn call_register_entity_type_validators(&self, runtime: &KhiveRuntime) {
1298        for pack in self.packs.iter() {
1299            pack.register_entity_type_validator(runtime);
1300        }
1301    }
1302
1303    /// Invoke `PackRuntime::warm` on every registered pack.
1304    /// Called by the daemon at boot (in a background task) so expensive in-memory
1305    /// state (ANN indexes) is pre-loaded without blocking request serving.
1306    pub async fn call_warm_all(&self) {
1307        for pack in self.packs.iter() {
1308            pack.warm().await;
1309        }
1310    }
1311
1312    /// Resolve the presentation policy for a verb name.
1313    ///
1314    /// Walks all registered handlers (including subhandlers) for the first
1315    /// matching name and returns its declared [`VerbPresentationPolicy`].
1316    /// Returns `Standard` for unknown verbs — unknown verbs will fail at
1317    /// dispatch anyway, so the fallback here is safe.
1318    pub fn presentation_policy_for(&self, verb: &str) -> khive_types::VerbPresentationPolicy {
1319        for pack in self.packs.iter() {
1320            if let Some(handler) = pack.handlers().iter().find(|h| h.name == verb) {
1321                return handler.presentation_policy();
1322            }
1323        }
1324        khive_types::VerbPresentationPolicy::Standard
1325    }
1326
1327    /// Returns `true` if the named verb exists and is tagged
1328    /// `Visibility::Subhandler` (internal / operator-only).
1329    ///
1330    /// Used by the MCP server to gate subhandler invocation at the wire
1331    /// boundary without blocking internal callers that invoke the same verbs
1332    /// through the runtime directly.
1333    pub fn is_subhandler_verb(&self, verb: &str) -> bool {
1334        for pack in self.packs.iter() {
1335            if let Some(handler) = pack.handlers().iter().find(|h| h.name == verb) {
1336                return matches!(handler.visibility, Visibility::Subhandler);
1337            }
1338        }
1339        false
1340    }
1341
1342    /// Apply all non-empty pack-auxiliary schema plans to the given backend
1343    /// (c12 startup application).
1344    ///
1345    /// This is the centralized startup hook that replaced the previous lazy
1346    /// per-pack self-bootstrap pattern. Each pack's `SchemaPlan` carries
1347    /// idempotent `CREATE TABLE IF NOT EXISTS` DDL; calling this more than once
1348    /// is safe. Empty plans are skipped.
1349    ///
1350    /// Errors from individual plans are logged via `tracing::warn!` and not
1351    /// propagated so that a single pack's schema failure does not prevent the
1352    /// rest from loading. Callers that need hard-failure semantics should call
1353    /// `all_schema_plans()` and apply each plan individually.
1354    pub fn apply_schema_plans(&self, backend: &khive_db::StorageBackend) {
1355        for plan in self.all_schema_plans() {
1356            if plan.is_empty() {
1357                continue;
1358            }
1359            if let Err(e) = backend.apply_pack_ddl_statements(plan.statements) {
1360                tracing::warn!(
1361                    pack = plan.pack,
1362                    error = %e,
1363                    "failed to apply pack schema plan at startup (non-fatal)"
1364                );
1365            }
1366        }
1367    }
1368
1369    /// Pack-auxiliary schema plans with their owning pack names.
1370    ///
1371    /// Returns `(pack_name, SchemaPlan)` pairs for every registered pack.
1372    /// Used by the multi-backend boot path to apply each plan to the pack's
1373    /// assigned backend rather than a single shared backend.
1374    pub fn all_schema_plans_named(&self) -> Vec<(&'static str, SchemaPlan)> {
1375        self.packs
1376            .iter()
1377            .map(|p| {
1378                let plan = p.schema_plan();
1379                (plan.pack, plan)
1380            })
1381            .collect()
1382    }
1383
1384    /// Apply pack-auxiliary schema plans using a per-pack backend map.
1385    ///
1386    /// For each `(pack_name, plan)` returned by `all_schema_plans_named()`,
1387    /// applies the plan to `backend_for_pack[pack_name]` when present,
1388    /// falling back to `default_backend` for any pack not in the map.
1389    ///
1390    /// Returns an error when two packs on the same backend declare the same
1391    /// auxiliary table (ADR-028 §7 collision policy: boot failure naming both
1392    /// packs and the conflicting table).
1393    ///
1394    /// This is the multi-backend boot path (ADR-028). Single-backend callers
1395    /// should continue using [`Self::apply_schema_plans`].
1396    pub fn apply_schema_plans_with_map(
1397        &self,
1398        backend_for_pack: &HashMap<&str, &khive_db::StorageBackend>,
1399        default_backend: &khive_db::StorageBackend,
1400    ) -> Result<(), crate::PackSchemaCollisionError> {
1401        // Track which pack first claimed each table on each backend.
1402        // Backend identity is the raw pointer of the underlying connection pool Arc.
1403        let mut claimed: HashMap<(*const (), String), &'static str> = HashMap::new();
1404
1405        for (pack_name, plan) in self.all_schema_plans_named() {
1406            if plan.is_empty() {
1407                continue;
1408            }
1409            let backend = backend_for_pack
1410                .get(pack_name)
1411                .copied()
1412                .unwrap_or(default_backend);
1413            let backend_ptr = std::sync::Arc::as_ptr(&backend.pool_arc()) as *const ();
1414
1415            // Pre-scan DDL for table names and detect collisions before applying.
1416            for stmt in plan.statements {
1417                for table_name in extract_table_names(stmt) {
1418                    let key = (backend_ptr, table_name.clone());
1419                    match claimed.entry(key) {
1420                        std::collections::hash_map::Entry::Vacant(e) => {
1421                            e.insert(pack_name);
1422                        }
1423                        std::collections::hash_map::Entry::Occupied(e) => {
1424                            let prior_pack = *e.get();
1425                            return Err(crate::PackSchemaCollisionError {
1426                                pack_a: prior_pack,
1427                                pack_b: pack_name,
1428                                table: table_name,
1429                            });
1430                        }
1431                    }
1432                }
1433            }
1434
1435            backend
1436                .apply_pack_ddl_statements(plan.statements)
1437                .map_err(|e| crate::PackSchemaCollisionError {
1438                    pack_a: pack_name,
1439                    pack_b: pack_name,
1440                    table: format!("DDL error: {e}"),
1441                })?;
1442        }
1443        Ok(())
1444    }
1445}
1446
1447// ── Inventory-based dynamic pack loading ────────────────────────────────────
1448
1449/// Factory for creating pack instances registered via `inventory` at link time.
1450/// Each pack crate submits a `&'static dyn PackFactory` wrapped in a
1451/// [`PackRegistration`]; the binary's linker collects them all into a single
1452/// slice iterable at runtime.
1453///
1454/// Implementors must be `Send + Sync + 'static` because the registry is built
1455/// once and shared across async tasks.
1456pub trait PackFactory: Send + Sync + 'static {
1457    /// Canonical lowercase name for this pack (e.g. `"kg"`, `"gtd"`).
1458    fn name(&self) -> &'static str;
1459
1460    /// Names of packs that must be loaded before this one.
1461    ///
1462    /// Defaults to empty so pack crates that have no dependencies compile
1463    /// without changes. [`PackRegistry::register_packs`] validates that every
1464    /// name listed here is present in the caller's explicit pack list — absent
1465    /// dependencies are a boot error, not silently auto-added.
1466    fn requires(&self) -> &'static [&'static str] {
1467        &[]
1468    }
1469
1470    /// Create a new pack instance for the given runtime.
1471    fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime>;
1472
1473    /// Optionally create a `PackByIdResolver` for this pack.
1474    ///
1475    /// Packs that own private SQL tables implement this to hook into
1476    /// `get(id)` and `delete(id)`. Defaults to `None` so existing packs
1477    /// compile without changes.
1478    fn create_resolver(&self, _runtime: KhiveRuntime) -> Option<Box<dyn PackByIdResolver>> {
1479        None
1480    }
1481}
1482
1483/// Newtype wrapper collected by `inventory` so pack crates can submit
1484/// `&'static dyn PackFactory` references without the type-ascription syntax
1485/// that `inventory::submit!` does not support for bare trait-object references.
1486pub struct PackRegistration(pub &'static dyn PackFactory);
1487
1488inventory::collect!(PackRegistration);
1489
1490/// Error returned by [`PackRegistry::register_packs`] when boot validation fails.
1491#[derive(Debug)]
1492pub enum PackLoadError {
1493    /// The requested pack name was not found in the inventory.
1494    UnknownPack(String),
1495    /// A pack was requested but a declared dependency is absent from the list.
1496    MissingDependency {
1497        /// The pack that declared the dependency.
1498        pack: String,
1499        /// The dependency that is missing from the requested pack list.
1500        dep: String,
1501    },
1502}
1503
1504impl std::fmt::Display for PackLoadError {
1505    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1506        match self {
1507            PackLoadError::UnknownPack(name) => write!(f, "unknown pack {name:?}"),
1508            PackLoadError::MissingDependency { pack, dep } => write!(
1509                f,
1510                "pack {pack:?} requires {dep:?}, which is not in the requested pack list; \
1511                 add --pack {dep} before --pack {pack}"
1512            ),
1513        }
1514    }
1515}
1516
1517impl std::error::Error for PackLoadError {}
1518
1519/// Registry of pack factories discovered via `inventory` at link time.
1520///
1521/// No instance is needed — all methods are associated functions that walk the
1522/// globally-collected [`PackRegistration`] slice.
1523pub struct PackRegistry;
1524
1525impl PackRegistry {
1526    /// Names of all pack factories discovered via `inventory`.
1527    pub fn discovered_names() -> Vec<&'static str> {
1528        inventory::iter::<PackRegistration>
1529            .into_iter()
1530            .map(|r| r.0.name())
1531            .collect()
1532    }
1533
1534    /// Register the named packs into `builder` using the supplied `runtime`.
1535    ///
1536    /// Validates the explicit pack list against `PackFactory::requires()` —
1537    /// if any requested pack declares a dependency that is absent from `names`,
1538    /// registration fails (missing dependency is a boot error, not silently
1539    /// auto-added). Callers must include all required packs explicitly.
1540    ///
1541    /// The [`VerbRegistryBuilder::build`] topo-sort enforces correct load order.
1542    ///
1543    /// Returns `Ok(())` when all names are recognised and all declared
1544    /// dependencies are satisfied; returns `Err(PackLoadError)` with a
1545    /// distinct variant for unknown pack vs missing dependency.
1546    pub fn register_packs(
1547        names: &[String],
1548        runtime: KhiveRuntime,
1549        builder: &mut VerbRegistryBuilder,
1550    ) -> Result<(), PackLoadError> {
1551        // Build a name→factory index once.
1552        let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
1553            .into_iter()
1554            .map(|r| r.0)
1555            .collect();
1556        let factory_for = |name: &str| -> Option<&'static dyn PackFactory> {
1557            all.iter().copied().find(|f| f.name() == name)
1558        };
1559
1560        // Validate that every requested name is a known factory.
1561        let requested: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
1562        for name in names {
1563            factory_for(name.as_str()).ok_or_else(|| PackLoadError::UnknownPack(name.clone()))?;
1564        }
1565
1566        // Validate that all requires() dependencies are explicitly present in
1567        // the requested set. Missing dep → boot error, not auto-add.
1568        for name in names {
1569            let factory = factory_for(name.as_str()).unwrap(); // validated above
1570            for &dep in factory.requires() {
1571                if !requested.contains(dep) {
1572                    return Err(PackLoadError::MissingDependency {
1573                        pack: name.clone(),
1574                        dep: dep.to_string(),
1575                    });
1576                }
1577            }
1578        }
1579
1580        // Register every requested pack; VerbRegistryBuilder::build()
1581        // performs the topo-sort, so insertion order here does not matter.
1582        for name in names {
1583            let factory = factory_for(name.as_str()).unwrap(); // validated above
1584            builder.register_boxed(factory.create(runtime.clone()));
1585            if let Some(resolver) = factory.create_resolver(runtime.clone()) {
1586                builder.register_resolver(name.clone(), resolver);
1587            }
1588        }
1589
1590        Ok(())
1591    }
1592
1593    /// Register the named packs into `builder`, routing each pack to its own runtime.
1594    ///
1595    /// `runtimes` maps pack name → `KhiveRuntime` (one per backend assignment).
1596    /// `default_runtime` is used for any pack whose name is not in `runtimes`.
1597    /// The validation logic (unknown pack, missing dependency) is identical to
1598    /// [`PackRegistry::register_packs`].
1599    ///
1600    /// This is the multi-backend boot path (ADR-028). Single-backend callers
1601    /// should continue using [`PackRegistry::register_packs`].
1602    pub fn register_packs_with_runtimes(
1603        names: &[String],
1604        runtimes: &HashMap<String, KhiveRuntime>,
1605        default_runtime: &KhiveRuntime,
1606        builder: &mut VerbRegistryBuilder,
1607    ) -> Result<(), PackLoadError> {
1608        let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
1609            .into_iter()
1610            .map(|r| r.0)
1611            .collect();
1612        let factory_for = |name: &str| -> Option<&'static dyn PackFactory> {
1613            all.iter().copied().find(|f| f.name() == name)
1614        };
1615
1616        let requested: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
1617        for name in names {
1618            factory_for(name.as_str()).ok_or_else(|| PackLoadError::UnknownPack(name.clone()))?;
1619        }
1620
1621        for name in names {
1622            let factory = factory_for(name.as_str()).unwrap();
1623            for &dep in factory.requires() {
1624                if !requested.contains(dep) {
1625                    return Err(PackLoadError::MissingDependency {
1626                        pack: name.clone(),
1627                        dep: dep.to_string(),
1628                    });
1629                }
1630            }
1631        }
1632
1633        for name in names {
1634            let factory = factory_for(name.as_str()).unwrap();
1635            let runtime = runtimes
1636                .get(name.as_str())
1637                .cloned()
1638                .unwrap_or_else(|| default_runtime.clone());
1639            builder.register_boxed(factory.create(runtime.clone()));
1640            if let Some(resolver) = factory.create_resolver(runtime) {
1641                builder.register_resolver(name.clone(), resolver);
1642            }
1643        }
1644
1645        Ok(())
1646    }
1647}
1648
1649fn target_id_from_args(args: &serde_json::Value) -> Option<uuid::Uuid> {
1650    args.get("target_id")
1651        .and_then(serde_json::Value::as_str)
1652        .and_then(|s| s.parse::<uuid::Uuid>().ok())
1653}
1654
1655// INLINE TEST JUSTIFICATION: tests here exercise VerbRegistry collision detection,
1656// gate enforcement, and dispatch ordering that depend on direct access to the
1657// registry's private `packs` Vec and gate field. Moving them to tests/ would
1658// require pub-exporting registry internals. Broad behavioral dispatch tests
1659// live in tests/integration.rs.
1660#[cfg(test)]
1661mod tests {
1662    use super::*;
1663    use khive_types::Pack;
1664
1665    struct AlphaPack;
1666
1667    impl Pack for AlphaPack {
1668        const NAME: &'static str = "alpha";
1669        const NOTE_KINDS: &'static [&'static str] = &["memo", "log"];
1670        const ENTITY_KINDS: &'static [&'static str] = &["widget"];
1671        const HANDLERS: &'static [HandlerDef] = &[
1672            HandlerDef {
1673                name: "create",
1674                description: "create a widget",
1675                visibility: Visibility::Verb,
1676                category: VerbCategory::Commissive,
1677                params: &[],
1678            },
1679            HandlerDef {
1680                name: "list",
1681                description: "list widgets",
1682                visibility: Visibility::Verb,
1683                category: VerbCategory::Assertive,
1684                params: &[],
1685            },
1686        ];
1687    }
1688
1689    #[async_trait]
1690    impl PackRuntime for AlphaPack {
1691        fn name(&self) -> &str {
1692            AlphaPack::NAME
1693        }
1694        fn note_kinds(&self) -> &'static [&'static str] {
1695            AlphaPack::NOTE_KINDS
1696        }
1697        fn entity_kinds(&self) -> &'static [&'static str] {
1698            AlphaPack::ENTITY_KINDS
1699        }
1700        fn handlers(&self) -> &'static [HandlerDef] {
1701            AlphaPack::HANDLERS
1702        }
1703        async fn dispatch(
1704            &self,
1705            verb: &str,
1706            _params: Value,
1707            _registry: &VerbRegistry,
1708            _token: &NamespaceToken,
1709        ) -> Result<Value, RuntimeError> {
1710            Ok(serde_json::json!({ "pack": "alpha", "verb": verb }))
1711        }
1712    }
1713
1714    struct BetaPack;
1715
1716    impl Pack for BetaPack {
1717        const NAME: &'static str = "beta";
1718        const NOTE_KINDS: &'static [&'static str] = &["alert"];
1719        const ENTITY_KINDS: &'static [&'static str] = &["widget", "gadget"];
1720        const HANDLERS: &'static [HandlerDef] = &[
1721            HandlerDef {
1722                name: "notify",
1723                description: "send alert",
1724                visibility: Visibility::Verb,
1725                category: VerbCategory::Commissive,
1726                params: &[],
1727            },
1728            // "create" is Subhandler so it does NOT collide with AlphaPack's
1729            // Verb-visibility "create" — subhandlers are pack-internal and
1730            // excluded from cross-pack collision detection.
1731            HandlerDef {
1732                name: "create",
1733                description: "beta internal create (subhandler)",
1734                visibility: Visibility::Subhandler,
1735                category: VerbCategory::Commissive,
1736                params: &[],
1737            },
1738        ];
1739    }
1740
1741    /// Build a registry with AlphaPack + BetaPack.
1742    ///
1743    /// BetaPack's `create` is Subhandler so there is no Verb-visibility
1744    /// collision with AlphaPack's `create` Verb. Tests that need a collision
1745    /// use `build_colliding_registry()` instead.
1746    fn build_registry() -> VerbRegistry {
1747        let mut builder = VerbRegistryBuilder::new();
1748        builder.register(AlphaPack);
1749        builder.register(BetaPack);
1750        builder.build().expect("registry builds without collision")
1751    }
1752
1753    /// Build a registry with two packs that declare the same Verb-visibility
1754    /// handler — used to test that `VerbCollision` is raised at build time.
1755    struct CollidingPack;
1756
1757    impl Pack for CollidingPack {
1758        const NAME: &'static str = "colliding";
1759        const NOTE_KINDS: &'static [&'static str] = &[];
1760        const ENTITY_KINDS: &'static [&'static str] = &[];
1761        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
1762            name: "create",
1763            description: "duplicate Verb-visibility create",
1764            visibility: Visibility::Verb,
1765            category: VerbCategory::Commissive,
1766            params: &[],
1767        }];
1768    }
1769
1770    #[async_trait]
1771    impl PackRuntime for CollidingPack {
1772        fn name(&self) -> &str {
1773            Self::NAME
1774        }
1775        fn note_kinds(&self) -> &'static [&'static str] {
1776            Self::NOTE_KINDS
1777        }
1778        fn entity_kinds(&self) -> &'static [&'static str] {
1779            Self::ENTITY_KINDS
1780        }
1781        fn handlers(&self) -> &'static [HandlerDef] {
1782            Self::HANDLERS
1783        }
1784        async fn dispatch(
1785            &self,
1786            verb: &str,
1787            _params: Value,
1788            _registry: &VerbRegistry,
1789            _token: &NamespaceToken,
1790        ) -> Result<Value, RuntimeError> {
1791            Ok(serde_json::json!({ "pack": "colliding", "verb": verb }))
1792        }
1793    }
1794
1795    #[async_trait]
1796    impl PackRuntime for BetaPack {
1797        fn name(&self) -> &str {
1798            BetaPack::NAME
1799        }
1800        fn note_kinds(&self) -> &'static [&'static str] {
1801            BetaPack::NOTE_KINDS
1802        }
1803        fn entity_kinds(&self) -> &'static [&'static str] {
1804            BetaPack::ENTITY_KINDS
1805        }
1806        fn handlers(&self) -> &'static [HandlerDef] {
1807            BetaPack::HANDLERS
1808        }
1809        async fn dispatch(
1810            &self,
1811            verb: &str,
1812            _params: Value,
1813            _registry: &VerbRegistry,
1814            _token: &NamespaceToken,
1815        ) -> Result<Value, RuntimeError> {
1816            Ok(serde_json::json!({ "pack": "beta", "verb": verb }))
1817        }
1818    }
1819
1820    #[tokio::test]
1821    async fn dispatch_routes_to_correct_pack() {
1822        let reg = build_registry();
1823
1824        let res = reg.dispatch("list", Value::Null).await.unwrap();
1825        assert_eq!(res["pack"], "alpha");
1826
1827        let res = reg.dispatch("notify", Value::Null).await.unwrap();
1828        assert_eq!(res["pack"], "beta");
1829    }
1830
1831    /// F093/F094: two packs declaring the same `Visibility::Verb` handler must be
1832    /// rejected at build time — the old "first registered wins" behaviour is
1833    /// replaced by a boot error.
1834    #[test]
1835    fn verb_collision_is_boot_time_error() {
1836        let mut builder = VerbRegistryBuilder::new();
1837        builder.register(AlphaPack);
1838        builder.register(CollidingPack);
1839        let err = builder
1840            .build()
1841            .err()
1842            .expect("duplicate Verb-visibility handler must be rejected at build time");
1843        assert!(
1844            matches!(err, RuntimeError::VerbCollision { ref verb, .. } if verb == "create"),
1845            "expected VerbCollision for 'create', got {err:?}"
1846        );
1847        let msg = err.to_string();
1848        assert!(
1849            msg.contains("create"),
1850            "error must name the colliding verb: {msg}"
1851        );
1852        assert!(
1853            msg.contains("alpha") || msg.contains("colliding"),
1854            "error must name one of the conflicting packs: {msg}"
1855        );
1856    }
1857
1858    /// Subhandler-visibility handlers with the same name across packs are NOT
1859    /// a collision — they are pack-internal and excluded from cross-pack
1860    /// collision detection.
1861    #[test]
1862    fn subhandler_same_name_across_packs_is_not_a_collision() {
1863        struct SubhandlerPack;
1864        impl Pack for SubhandlerPack {
1865            const NAME: &'static str = "subhandler_pack";
1866            const NOTE_KINDS: &'static [&'static str] = &[];
1867            const ENTITY_KINDS: &'static [&'static str] = &[];
1868            const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
1869                name: "create",
1870                description: "internal create",
1871                visibility: Visibility::Subhandler,
1872                category: VerbCategory::Commissive,
1873                params: &[],
1874            }];
1875        }
1876        #[async_trait]
1877        impl PackRuntime for SubhandlerPack {
1878            fn name(&self) -> &str {
1879                Self::NAME
1880            }
1881            fn note_kinds(&self) -> &'static [&'static str] {
1882                Self::NOTE_KINDS
1883            }
1884            fn entity_kinds(&self) -> &'static [&'static str] {
1885                Self::ENTITY_KINDS
1886            }
1887            fn handlers(&self) -> &'static [HandlerDef] {
1888                Self::HANDLERS
1889            }
1890            async fn dispatch(
1891                &self,
1892                verb: &str,
1893                _: Value,
1894                _: &VerbRegistry,
1895                _: &NamespaceToken,
1896            ) -> Result<Value, RuntimeError> {
1897                Ok(serde_json::json!({"pack": "subhandler_pack", "verb": verb}))
1898            }
1899        }
1900        let mut builder = VerbRegistryBuilder::new();
1901        builder.register(AlphaPack); // AlphaPack has Verb "create"
1902        builder.register(SubhandlerPack); // SubhandlerPack has Subhandler "create" — no collision
1903        builder
1904            .build()
1905            .expect("subhandler same name must NOT be a collision");
1906    }
1907
1908    #[tokio::test]
1909    async fn dispatch_unknown_verb_returns_error() {
1910        let reg = build_registry();
1911
1912        let err = reg.dispatch("explode", Value::Null).await.unwrap_err();
1913        let msg = err.to_string();
1914        assert!(msg.contains("explode"));
1915        assert!(msg.contains("create"));
1916    }
1917
1918    /// `all_verbs` returns only `Visibility::Verb` entries (F118).
1919    ///
1920    /// BetaPack's `create` is `Visibility::Subhandler` — it must NOT appear
1921    /// in `all_verbs()` even though it has the same name as a Verb in AlphaPack.
1922    #[test]
1923    fn all_verbs_aggregates_across_packs_excludes_subhandlers() {
1924        let reg = build_registry();
1925        let verbs: Vec<&str> = reg.all_verbs().iter().map(|v| v.name).collect();
1926        // BetaPack's "create" (Subhandler) is absent; only Verb-visibility entries appear.
1927        assert_eq!(verbs, vec!["create", "list", "notify"]);
1928    }
1929
1930    #[test]
1931    fn all_verbs_with_names_pairs_pack_name_excludes_subhandlers() {
1932        let reg = build_registry();
1933        let pairs: Vec<(&str, &str)> = reg
1934            .all_verbs_with_names()
1935            .iter()
1936            .map(|(pack, v)| (*pack, v.name))
1937            .collect();
1938        // BetaPack's "create" is Subhandler and must NOT appear here.
1939        assert_eq!(
1940            pairs,
1941            vec![("alpha", "create"), ("alpha", "list"), ("beta", "notify"),]
1942        );
1943    }
1944
1945    #[test]
1946    fn all_handlers_with_names_includes_subhandlers() {
1947        let reg = build_registry();
1948        let pairs: Vec<(&str, &str)> = reg
1949            .all_handlers_with_names()
1950            .iter()
1951            .map(|(pack, v)| (*pack, v.name))
1952            .collect();
1953        // BetaPack's Subhandler "create" IS present in the full handler list.
1954        assert_eq!(
1955            pairs,
1956            vec![
1957                ("alpha", "create"),
1958                ("alpha", "list"),
1959                ("beta", "notify"),
1960                ("beta", "create"),
1961            ]
1962        );
1963    }
1964
1965    #[test]
1966    fn note_kinds_are_ordered() {
1967        let reg = build_registry();
1968        let kinds = reg.all_note_kinds();
1969        assert_eq!(kinds, vec!["memo", "log", "alert"]);
1970    }
1971
1972    #[test]
1973    fn note_kind_duplicate_rejected_at_build_time() {
1974        struct DupPack;
1975
1976        impl khive_types::Pack for DupPack {
1977            const NAME: &'static str = "dup";
1978            // "memo" is already declared by AlphaPack — must be rejected at build.
1979            const NOTE_KINDS: &'static [&'static str] = &["memo"];
1980            const ENTITY_KINDS: &'static [&'static str] = &[];
1981            const HANDLERS: &'static [HandlerDef] = &[];
1982        }
1983
1984        #[async_trait]
1985        impl PackRuntime for DupPack {
1986            fn name(&self) -> &str {
1987                Self::NAME
1988            }
1989            fn note_kinds(&self) -> &'static [&'static str] {
1990                Self::NOTE_KINDS
1991            }
1992            fn entity_kinds(&self) -> &'static [&'static str] {
1993                Self::ENTITY_KINDS
1994            }
1995            fn handlers(&self) -> &'static [HandlerDef] {
1996                Self::HANDLERS
1997            }
1998            async fn dispatch(
1999                &self,
2000                _verb: &str,
2001                _params: Value,
2002                _registry: &VerbRegistry,
2003                _token: &NamespaceToken,
2004            ) -> Result<Value, RuntimeError> {
2005                Ok(Value::Null)
2006            }
2007        }
2008
2009        let mut builder = VerbRegistryBuilder::new();
2010        builder.register(AlphaPack);
2011        builder.register(DupPack);
2012        let err = builder
2013            .build()
2014            .err()
2015            .expect("duplicate note kind must be rejected");
2016        let msg = err.to_string();
2017        assert!(
2018            msg.contains("memo"),
2019            "error must name the duplicate kind: {msg}"
2020        );
2021        assert!(
2022            msg.contains("alpha") || msg.contains("dup"),
2023            "error must name one of the conflicting packs: {msg}"
2024        );
2025    }
2026
2027    #[test]
2028    fn entity_kinds_are_deduplicated() {
2029        let reg = build_registry();
2030        let kinds = reg.all_entity_kinds();
2031        assert_eq!(kinds, vec!["widget", "gadget"]);
2032    }
2033
2034    // ---- Gate wiring ----
2035
2036    use khive_gate::{Gate, GateError};
2037    use std::sync::atomic::{AtomicUsize, Ordering};
2038    use std::sync::Arc;
2039
2040    #[derive(Default, Debug)]
2041    struct CountingGate {
2042        calls: AtomicUsize,
2043        deny_verb: Option<&'static str>,
2044    }
2045
2046    impl Gate for CountingGate {
2047        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
2048            self.calls.fetch_add(1, Ordering::SeqCst);
2049            if Some(req.verb.as_str()) == self.deny_verb {
2050                Ok(GateDecision::deny(format!("test deny for {}", req.verb)))
2051            } else {
2052                Ok(GateDecision::allow())
2053            }
2054        }
2055    }
2056
2057    #[tokio::test]
2058    async fn dispatch_consults_the_gate() {
2059        let gate = Arc::new(CountingGate::default());
2060        let mut builder = VerbRegistryBuilder::new();
2061        builder.register(AlphaPack);
2062        builder.with_gate(gate.clone());
2063        let reg = builder.build().expect("registry builds");
2064
2065        reg.dispatch("list", Value::Null).await.unwrap();
2066        reg.dispatch("create", Value::Null).await.unwrap();
2067        assert_eq!(
2068            gate.calls.load(Ordering::SeqCst),
2069            2,
2070            "gate should be consulted once per dispatch"
2071        );
2072    }
2073
2074    #[tokio::test]
2075    async fn dispatch_returns_permission_denied_on_deny_v03() {
2076        let gate = Arc::new(CountingGate {
2077            calls: AtomicUsize::new(0),
2078            deny_verb: Some("create"),
2079        });
2080        let mut builder = VerbRegistryBuilder::new();
2081        builder.register(AlphaPack);
2082        builder.with_gate(gate.clone());
2083        let reg = builder.build().expect("registry builds");
2084
2085        // Gate denies — dispatch now returns PermissionDenied (hard enforcement).
2086        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
2087        assert!(
2088            matches!(err, RuntimeError::PermissionDenied { ref verb, .. } if verb == "create"),
2089            "expected PermissionDenied, got {err:?}"
2090        );
2091        let msg = err.to_string();
2092        assert!(
2093            msg.contains("create"),
2094            "error message must name the verb: {msg}"
2095        );
2096        assert!(
2097            msg.contains("test deny for create"),
2098            "error message must carry the deny reason: {msg}"
2099        );
2100        assert_eq!(gate.calls.load(Ordering::SeqCst), 1);
2101    }
2102
2103    #[tokio::test]
2104    async fn dispatch_allow_verb_succeeds_even_with_deny_gate_for_other_verb() {
2105        // Deny only "create" — "list" must still work.
2106        let gate = Arc::new(CountingGate {
2107            calls: AtomicUsize::new(0),
2108            deny_verb: Some("create"),
2109        });
2110        let mut builder = VerbRegistryBuilder::new();
2111        builder.register(AlphaPack);
2112        builder.with_gate(gate.clone());
2113        let reg = builder.build().expect("registry builds");
2114
2115        let res = reg.dispatch("list", Value::Null).await.unwrap();
2116        assert_eq!(res["pack"], "alpha");
2117    }
2118
2119    #[tokio::test]
2120    async fn dispatch_uses_allow_all_gate_by_default() {
2121        // No `with_gate` call — builder should use `AllowAllGate` so dispatch works.
2122        let reg = build_registry();
2123        let res = reg.dispatch("list", Value::Null).await.unwrap();
2124        assert_eq!(res["pack"], "alpha");
2125    }
2126
2127    // Captures the namespace each call sees so we can assert what the gate
2128    // actually receives — codex round-1 caught us hard-wiring `default_ns()`.
2129    #[derive(Default, Debug)]
2130    struct NamespaceCapturingGate {
2131        seen: std::sync::Mutex<Vec<String>>,
2132    }
2133
2134    impl Gate for NamespaceCapturingGate {
2135        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
2136            self.seen
2137                .lock()
2138                .unwrap()
2139                .push(req.namespace.as_str().to_string());
2140            Ok(GateDecision::allow())
2141        }
2142    }
2143
2144    #[tokio::test]
2145    async fn dispatch_propagates_params_namespace_to_gate() {
2146        let gate = Arc::new(NamespaceCapturingGate::default());
2147        let mut builder = VerbRegistryBuilder::new();
2148        builder.register(AlphaPack);
2149        builder.with_gate(gate.clone());
2150        builder.with_default_namespace("tenant-x");
2151        let reg = builder.build().expect("registry builds");
2152
2153        // Explicit namespace in params wins.
2154        reg.dispatch("list", serde_json::json!({"namespace": "tenant-y"}))
2155            .await
2156            .unwrap();
2157        // Missing namespace → registry default.
2158        reg.dispatch("list", Value::Null).await.unwrap();
2159        // Empty string is rejected: Namespace::parse("") fails → InvalidInput error.
2160        let err = reg
2161            .dispatch("list", serde_json::json!({"namespace": ""}))
2162            .await
2163            .unwrap_err();
2164        assert!(
2165            matches!(err, RuntimeError::InvalidInput(_)),
2166            "empty namespace must return InvalidInput, got {err:?}"
2167        );
2168
2169        let seen = gate.seen.lock().unwrap().clone();
2170        assert_eq!(seen, vec!["tenant-y", "tenant-x"]);
2171    }
2172
2173    #[tokio::test]
2174    async fn dispatch_falls_back_to_local_when_no_default_set() {
2175        // Builder default mirrors `Namespace::default_ns()`.
2176        let gate = Arc::new(NamespaceCapturingGate::default());
2177        let mut builder = VerbRegistryBuilder::new();
2178        builder.register(AlphaPack);
2179        builder.with_gate(gate.clone());
2180        let reg = builder.build().expect("registry builds");
2181
2182        reg.dispatch("list", Value::Null).await.unwrap();
2183        let seen = gate.seen.lock().unwrap().clone();
2184        assert_eq!(seen, vec!["local"]);
2185    }
2186
2187    // ---- Audit event emission ----
2188
2189    use khive_gate::{AuditDecision, AuditEvent, Obligation};
2190
2191    /// A gate that records every audit event emitted via from_check.
2192    #[derive(Default, Debug)]
2193    struct AuditCapturingGate {
2194        events: std::sync::Mutex<Vec<AuditEvent>>,
2195        deny_verb: Option<&'static str>,
2196    }
2197
2198    impl Gate for AuditCapturingGate {
2199        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
2200            let decision = if Some(req.verb.as_str()) == self.deny_verb {
2201                GateDecision::deny("test deny")
2202            } else {
2203                GateDecision::allow_with(vec![Obligation::Audit {
2204                    tag: format!("{}.check", req.verb),
2205                }])
2206            };
2207            // Capture what dispatch will also emit.
2208            let ev = AuditEvent::from_check(req, &decision, self.impl_name());
2209            self.events.lock().unwrap().push(ev);
2210            Ok(decision)
2211        }
2212
2213        fn impl_name(&self) -> &'static str {
2214            "AuditCapturingGate"
2215        }
2216    }
2217
2218    #[tokio::test]
2219    async fn dispatch_emits_one_audit_event_per_call() {
2220        let gate = Arc::new(AuditCapturingGate::default());
2221        let mut builder = VerbRegistryBuilder::new();
2222        builder.register(AlphaPack);
2223        builder.with_gate(gate.clone());
2224        let reg = builder.build().expect("registry builds");
2225
2226        reg.dispatch("list", Value::Null).await.unwrap();
2227        reg.dispatch("create", Value::Null).await.unwrap();
2228
2229        let evs = gate.events.lock().unwrap();
2230        assert_eq!(evs.len(), 2, "exactly one audit event per dispatch call");
2231    }
2232
2233    #[tokio::test]
2234    async fn dispatch_audit_event_allow_carries_obligations() {
2235        let gate = Arc::new(AuditCapturingGate::default());
2236        let mut builder = VerbRegistryBuilder::new();
2237        builder.register(AlphaPack);
2238        builder.with_gate(gate.clone());
2239        let reg = builder.build().expect("registry builds");
2240
2241        reg.dispatch("list", Value::Null).await.unwrap();
2242
2243        let evs = gate.events.lock().unwrap();
2244        let ev = &evs[0];
2245        assert_eq!(ev.verb, "list");
2246        assert_eq!(ev.decision, AuditDecision::Allow);
2247        assert!(ev.deny_reason.is_none());
2248        assert_eq!(ev.obligations.len(), 1);
2249        assert_eq!(ev.gate_impl, "AuditCapturingGate");
2250    }
2251
2252    #[tokio::test]
2253    async fn dispatch_audit_event_deny_carries_reason() {
2254        let gate = Arc::new(AuditCapturingGate {
2255            events: Default::default(),
2256            deny_verb: Some("create"),
2257        });
2258        let mut builder = VerbRegistryBuilder::new();
2259        builder.register(AlphaPack);
2260        builder.with_gate(gate.clone());
2261        let reg = builder.build().expect("registry builds");
2262
2263        // Gate denies — dispatch returns PermissionDenied (hard enforcement).
2264        // The audit event is still recorded (captured inside the gate impl).
2265        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
2266        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
2267
2268        let evs = gate.events.lock().unwrap();
2269        let ev = &evs[0];
2270        assert_eq!(ev.verb, "create");
2271        assert_eq!(ev.decision, AuditDecision::Deny);
2272        assert_eq!(ev.deny_reason.as_deref(), Some("test deny"));
2273        assert!(ev.obligations.is_empty());
2274    }
2275
2276    #[tokio::test]
2277    async fn dispatch_audit_event_fields_match_gate_request() {
2278        let gate = Arc::new(AuditCapturingGate::default());
2279        let mut builder = VerbRegistryBuilder::new();
2280        builder.register(AlphaPack);
2281        builder.with_gate(gate.clone());
2282        builder.with_default_namespace("tenant-z");
2283        let reg = builder.build().expect("registry builds");
2284
2285        reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
2286            .await
2287            .unwrap();
2288
2289        let evs = gate.events.lock().unwrap();
2290        let ev = &evs[0];
2291        // Namespace from params wins.
2292        assert_eq!(ev.namespace, "tenant-q");
2293        assert_eq!(ev.verb, "list");
2294        assert_eq!(ev.actor.kind, "anonymous");
2295    }
2296
2297    // ---- Actor attribution threading into gate request (ADR-057) ----
2298
2299    /// A gate spy that captures the raw `GateRequest` it receives.
2300    #[derive(Default, Debug)]
2301    struct ActorCapturingGate {
2302        requests: std::sync::Mutex<Vec<GateRequest>>,
2303    }
2304
2305    impl Gate for ActorCapturingGate {
2306        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
2307            self.requests.lock().unwrap().push(req.clone());
2308            Ok(GateDecision::allow())
2309        }
2310    }
2311
2312    /// When `actor_id` is configured, the gate request carries that actor, not
2313    /// anonymous. This exercises the ADR-057 attribution fix: the gate can
2314    /// distinguish an agent caller from an unauthenticated caller.
2315    #[tokio::test]
2316    async fn gate_request_carries_configured_actor_when_actor_id_is_set() {
2317        let gate = Arc::new(ActorCapturingGate::default());
2318        let mut builder = VerbRegistryBuilder::new();
2319        builder.register(AlphaPack);
2320        builder.with_gate(gate.clone());
2321        builder.with_actor_id(Some("team-abc:implementer".to_string()));
2322        let reg = builder.build().expect("registry builds");
2323
2324        reg.dispatch("list", Value::Null).await.unwrap();
2325
2326        let reqs = gate.requests.lock().unwrap();
2327        assert_eq!(reqs.len(), 1);
2328        let req = &reqs[0];
2329        assert_eq!(
2330            req.actor.kind, "actor",
2331            "gate request must carry kind='actor' when actor_id is configured"
2332        );
2333        assert_eq!(
2334            req.actor.id, "team-abc:implementer",
2335            "gate request must carry the configured actor id"
2336        );
2337    }
2338
2339    /// When no `actor_id` is configured, the gate request still receives the
2340    /// anonymous actor (no regression to the party-line default).
2341    #[tokio::test]
2342    async fn gate_request_carries_anonymous_when_no_actor_id_configured() {
2343        let gate = Arc::new(ActorCapturingGate::default());
2344        let mut builder = VerbRegistryBuilder::new();
2345        builder.register(AlphaPack);
2346        builder.with_gate(gate.clone());
2347        // actor_id left at default (None).
2348        let reg = builder.build().expect("registry builds");
2349
2350        reg.dispatch("list", Value::Null).await.unwrap();
2351
2352        let reqs = gate.requests.lock().unwrap();
2353        assert_eq!(reqs.len(), 1);
2354        let req = &reqs[0];
2355        assert_eq!(
2356            req.actor.kind, "anonymous",
2357            "gate request must carry anonymous actor when no actor_id is configured"
2358        );
2359        assert_eq!(req.actor.id, "local");
2360    }
2361
2362    // ---- Rego gate: fail-closed end-to-end (issue #30) ----
2363
2364    /// A `RegoGate` whose policy lacks the named entrypoint rule must cause
2365    /// `VerbRegistry::dispatch` to return `RuntimeError::PermissionDenied` —
2366    /// never to proceed to the pack handler.
2367    ///
2368    /// This is the runtime-level assertion for the fix to security issue #30.
2369    /// `RegoGate::check` converts all evaluation failures (missing rule,
2370    /// undefined result, serialization error, poisoned engine) to
2371    /// `Ok(GateDecision::Deny)`, so dispatch is blocked. The runtime's
2372    /// fail-open `Err(_)` branch remains for non-evaluation gate errors
2373    /// (e.g. infrastructure faults from other `Gate` implementations).
2374    #[tokio::test]
2375    async fn rego_gate_missing_entrypoint_returns_permission_denied() {
2376        use khive_gate_rego::RegoGate;
2377
2378        // Policy defines `verdict` but NOT `data.khive.gate.decision` (the
2379        // default entrypoint).  Construction succeeds — from_policy_str does
2380        // not validate the default entrypoint.  check() must convert the
2381        // missing-rule evaluation error to Ok(Deny) so the runtime denies
2382        // the request rather than treating the Err as a fail-open signal.
2383        let policy = r#"
2384            package khive.gate
2385            import rego.v1
2386            verdict := "allow"
2387        "#;
2388        let gate = Arc::new(RegoGate::from_policy_str(policy).expect("policy compiles"));
2389
2390        let mut builder = VerbRegistryBuilder::new();
2391        builder.register(AlphaPack);
2392        builder.with_gate(gate);
2393        let reg = builder.build().expect("registry builds");
2394
2395        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
2396        assert!(
2397            matches!(err, RuntimeError::PermissionDenied { ref verb, .. } if verb == "create"),
2398            "expected PermissionDenied for missing rego entrypoint, got {err:?}"
2399        );
2400    }
2401
2402    // ---- Audit tracing emission ----
2403    //
2404    // The AuditCapturingGate tests above prove that AuditEvent::from_check is
2405    // called with the right inputs, but they observe the event *inside* the
2406    // gate impl — they would still pass if dispatch's
2407    // `tracing::info!(audit_event = ..., "gate.check")` were deleted or
2408    // renamed. The tests below install a capture Layer and assert on the
2409    // actual tracing event surfaced from dispatch. This locks the public
2410    // observability contract: one `gate.check` info event per dispatch,
2411    // carrying an `audit_event` field that round-trips back to an `AuditEvent`.
2412
2413    use std::sync::{Mutex as StdMutex, Once, OnceLock};
2414
2415    use serial_test::serial;
2416    use tracing::field::{Field, Visit};
2417
2418    #[derive(Clone, Debug, Default)]
2419    struct CapturedEvent {
2420        message: Option<String>,
2421        audit_event: Option<String>,
2422    }
2423
2424    #[derive(Default)]
2425    struct CapturedEventVisitor(CapturedEvent);
2426
2427    impl Visit for CapturedEventVisitor {
2428        fn record_str(&mut self, field: &Field, value: &str) {
2429            match field.name() {
2430                "message" => self.0.message = Some(value.to_string()),
2431                "audit_event" => self.0.audit_event = Some(value.to_string()),
2432                _ => {}
2433            }
2434        }
2435
2436        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
2437            // `tracing::info!(audit_event = %expr, "msg")` records via the
2438            // Display-wrapped Debug path, so we receive the JSON string here.
2439            // `"msg"` literal records as a `message` field via `record_debug`
2440            // with a quoted Debug representation; strip the surrounding quotes
2441            // so the captured message matches the source.
2442            let formatted = format!("{value:?}");
2443            let cleaned = formatted
2444                .trim_start_matches('"')
2445                .trim_end_matches('"')
2446                .to_string();
2447            match field.name() {
2448                "message" => self.0.message = Some(cleaned),
2449                "audit_event" => self.0.audit_event = Some(cleaned),
2450                _ => {}
2451            }
2452        }
2453    }
2454
2455    /// Minimal `tracing::Subscriber` that captures events into a shared vec.
2456    ///
2457    /// Implemented directly (without `tracing_subscriber::registry()` layering)
2458    /// to avoid the layer machinery that can cause thread-local dispatch to be
2459    /// bypassed when the registry's internal global state is initialised by
2460    /// another subscriber in the same test binary.
2461    ///
2462    /// Isolation across concurrent tests is handled at the dispatcher level by
2463    /// `tracing::dispatcher::with_default`, which installs this subscriber
2464    /// as the thread-local default for the duration of the test closure.
2465    /// Other threads (e.g. `#[tokio::test]` pool workers) emit through their
2466    /// own (typically NoSubscriber) dispatchers and never reach this instance.
2467    struct CaptureSubscriber {
2468        events: Arc<StdMutex<Vec<CapturedEvent>>>,
2469    }
2470
2471    impl CaptureSubscriber {
2472        fn new(events: Arc<StdMutex<Vec<CapturedEvent>>>) -> Self {
2473            Self { events }
2474        }
2475    }
2476
2477    impl tracing::Subscriber for CaptureSubscriber {
2478        fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
2479            true
2480        }
2481        fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
2482            tracing::span::Id::from_u64(1)
2483        }
2484        fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
2485        fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
2486        fn event(&self, event: &tracing::Event<'_>) {
2487            let mut visitor = CapturedEventVisitor::default();
2488            event.record(&mut visitor);
2489            self.events.lock().unwrap().push(visitor.0);
2490        }
2491        fn enter(&self, _: &tracing::span::Id) {}
2492        fn exit(&self, _: &tracing::span::Id) {}
2493    }
2494
2495    /// Global capture buffer for the tracing tests.
2496    ///
2497    /// The subscriber is installed exactly once via `set_global_default`
2498    /// (thread-local dispatchers via `with_default` proved unreliable when
2499    /// other tests in the binary configure their own dispatchers in parallel —
2500    /// the global state interacted unpredictably and events were lost).
2501    ///
2502    /// Each test that uses this buffer is `#[serial]`, so only one
2503    /// runs at a time. The buffer is cleared at the start of each capture call.
2504    static GLOBAL_CAPTURE: OnceLock<Arc<StdMutex<Vec<CapturedEvent>>>> = OnceLock::new();
2505    static GLOBAL_INIT: Once = Once::new();
2506
2507    fn global_capture() -> Arc<StdMutex<Vec<CapturedEvent>>> {
2508        GLOBAL_INIT.call_once(|| {
2509            let buffer = Arc::new(StdMutex::new(Vec::new()));
2510            let subscriber = CaptureSubscriber::new(Arc::clone(&buffer));
2511            // Ignore error: if another subscriber is already set globally, our
2512            // subscriber installation fails, but the buffer will simply stay
2513            // empty and tests will fail with a clear "got 0 events" message
2514            // rather than a silent corruption.
2515            let _ = tracing::subscriber::set_global_default(subscriber);
2516            let _ = GLOBAL_CAPTURE.set(buffer);
2517        });
2518        Arc::clone(GLOBAL_CAPTURE.get().expect("global capture initialized"))
2519    }
2520
2521    /// Run an async block under the global capture subscriber and return
2522    /// the events emitted during the run. Clears the buffer at the start.
2523    ///
2524    /// Callers MUST be `#[serial]` to prevent concurrent buffer pollution.
2525    fn capture_dispatch_events<Fut>(future: Fut) -> Vec<CapturedEvent>
2526    where
2527        Fut: std::future::Future<Output = ()>,
2528    {
2529        let buffer = global_capture();
2530        buffer.lock().unwrap().clear();
2531
2532        let rt = tokio::runtime::Builder::new_current_thread()
2533            .enable_all()
2534            .build()
2535            .expect("build current-thread tokio runtime");
2536        rt.block_on(future);
2537
2538        let result = buffer.lock().unwrap().clone();
2539        result
2540    }
2541
2542    /// Pull every captured event whose `message` matches `"gate.check"` AND
2543    /// whose audit_event JSON declares the expected `gate_impl` name.
2544    ///
2545    /// Filtering by `gate_impl` lets concurrent tests in the same binary
2546    /// emit their own gate.check events into the global capture buffer
2547    /// without polluting each others' counts.
2548    fn gate_check_events_for(events: &[CapturedEvent], gate_impl: &str) -> Vec<CapturedEvent> {
2549        events
2550            .iter()
2551            .filter(|e| e.message.as_deref() == Some("gate.check"))
2552            .filter(|e| {
2553                e.audit_event
2554                    .as_deref()
2555                    .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
2556                    .and_then(|v| {
2557                        v.get("gate_impl")
2558                            .and_then(|g| g.as_str().map(|s| s.to_string()))
2559                    })
2560                    .as_deref()
2561                    == Some(gate_impl)
2562            })
2563            .cloned()
2564            .collect()
2565    }
2566
2567    #[test]
2568    #[serial]
2569    fn dispatch_tracing_emits_one_gate_check_event_on_allow() {
2570        #[derive(Debug)]
2571        struct TracingAllowGate;
2572        impl Gate for TracingAllowGate {
2573            fn check(&self, _: &GateRequest) -> Result<GateDecision, GateError> {
2574                Ok(GateDecision::allow())
2575            }
2576            fn impl_name(&self) -> &'static str {
2577                "TracingAllowGate"
2578            }
2579        }
2580
2581        let events = capture_dispatch_events(async {
2582            let mut builder = VerbRegistryBuilder::new();
2583            builder.register(AlphaPack);
2584            builder.with_gate(Arc::new(TracingAllowGate));
2585            builder.with_default_namespace("tenant-default");
2586            let reg = builder.build().expect("registry builds");
2587            reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
2588                .await
2589                .unwrap();
2590        });
2591
2592        let gate_events = gate_check_events_for(&events, "TracingAllowGate");
2593        assert_eq!(
2594            gate_events.len(),
2595            1,
2596            "exactly one gate.check tracing event per dispatch (allow); got {gate_events:?}"
2597        );
2598        let payload = gate_events[0]
2599            .audit_event
2600            .as_ref()
2601            .expect("gate.check event must carry an audit_event field");
2602        let audit: khive_gate::AuditEvent =
2603            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
2604        assert_eq!(audit.decision, AuditDecision::Allow);
2605        assert_eq!(audit.verb, "list");
2606        assert_eq!(audit.namespace, "tenant-q");
2607        assert_eq!(audit.gate_impl, "TracingAllowGate");
2608        assert!(
2609            audit.deny_reason.is_none(),
2610            "deny_reason must be None on Allow"
2611        );
2612    }
2613
2614    // ---- Hard enforcement + EventStore persistence ----
2615
2616    use crate::runtime::NamespaceToken;
2617    use async_trait::async_trait;
2618    use khive_storage::{
2619        BatchWriteSummary, Event, EventFilter, EventStore, Page, PageRequest, SubstrateKind,
2620    };
2621    use khive_types::EventOutcome;
2622
2623    /// In-memory EventStore for unit tests — avoids file-backed SQLite.
2624    #[derive(Default, Debug)]
2625    struct MemoryEventStore {
2626        events: std::sync::Mutex<Vec<Event>>,
2627    }
2628
2629    #[async_trait]
2630    impl EventStore for MemoryEventStore {
2631        async fn append_event(&self, event: Event) -> khive_storage::StorageResult<()> {
2632            self.events.lock().unwrap().push(event);
2633            Ok(())
2634        }
2635        async fn append_events(
2636            &self,
2637            events: Vec<Event>,
2638        ) -> khive_storage::StorageResult<BatchWriteSummary> {
2639            let attempted = events.len() as u64;
2640            let affected = attempted;
2641            self.events.lock().unwrap().extend(events);
2642            Ok(BatchWriteSummary {
2643                attempted,
2644                affected,
2645                failed: 0,
2646                first_error: String::new(),
2647            })
2648        }
2649        async fn get_event(&self, id: uuid::Uuid) -> khive_storage::StorageResult<Option<Event>> {
2650            Ok(self
2651                .events
2652                .lock()
2653                .unwrap()
2654                .iter()
2655                .find(|e| e.id == id)
2656                .cloned())
2657        }
2658        async fn query_events(
2659            &self,
2660            _filter: EventFilter,
2661            _page: PageRequest,
2662        ) -> khive_storage::StorageResult<Page<Event>> {
2663            let items = self.events.lock().unwrap().clone();
2664            let total = items.len() as u64;
2665            Ok(Page {
2666                items,
2667                total: Some(total),
2668            })
2669        }
2670        async fn count_events(&self, _filter: EventFilter) -> khive_storage::StorageResult<u64> {
2671            Ok(self.events.lock().unwrap().len() as u64)
2672        }
2673    }
2674
2675    #[tokio::test]
2676    async fn allow_all_gate_default_remains_backward_compatible() {
2677        // No gate set — AllowAllGate is the default. Dispatch must succeed.
2678        let mut builder = VerbRegistryBuilder::new();
2679        builder.register(AlphaPack);
2680        let reg = builder.build().expect("registry builds");
2681
2682        let res = reg.dispatch("list", Value::Null).await.unwrap();
2683        assert_eq!(
2684            res["pack"], "alpha",
2685            "AllowAllGate must allow every verb — backward compat guarantee"
2686        );
2687        let res = reg.dispatch("create", Value::Null).await.unwrap();
2688        assert_eq!(res["pack"], "alpha");
2689    }
2690
2691    #[tokio::test]
2692    async fn deny_gate_returns_permission_denied_pack_never_invoked() {
2693        #[derive(Debug)]
2694        struct AlwaysDenyGate;
2695        impl Gate for AlwaysDenyGate {
2696            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2697                Ok(GateDecision::deny("test: always deny"))
2698            }
2699        }
2700
2701        // Track whether dispatch was ever invoked on the pack.
2702        #[derive(Debug)]
2703        struct TrackedPack {
2704            invoked: Arc<AtomicUsize>,
2705        }
2706
2707        impl khive_types::Pack for TrackedPack {
2708            const NAME: &'static str = "tracked";
2709            const NOTE_KINDS: &'static [&'static str] = &[];
2710            const ENTITY_KINDS: &'static [&'static str] = &[];
2711            const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
2712                name: "guarded",
2713                description: "a guarded verb",
2714                visibility: Visibility::Verb,
2715                category: VerbCategory::Assertive,
2716                params: &[],
2717            }];
2718        }
2719
2720        #[async_trait]
2721        impl PackRuntime for TrackedPack {
2722            fn name(&self) -> &str {
2723                Self::NAME
2724            }
2725            fn note_kinds(&self) -> &'static [&'static str] {
2726                Self::NOTE_KINDS
2727            }
2728            fn entity_kinds(&self) -> &'static [&'static str] {
2729                Self::ENTITY_KINDS
2730            }
2731            fn handlers(&self) -> &'static [HandlerDef] {
2732                Self::HANDLERS
2733            }
2734            async fn dispatch(
2735                &self,
2736                _verb: &str,
2737                _params: Value,
2738                _registry: &VerbRegistry,
2739                _token: &NamespaceToken,
2740            ) -> Result<Value, RuntimeError> {
2741                self.invoked.fetch_add(1, Ordering::SeqCst);
2742                Ok(serde_json::json!({"invoked": true}))
2743            }
2744        }
2745
2746        let invoked = Arc::new(AtomicUsize::new(0));
2747        let mut builder = VerbRegistryBuilder::new();
2748        builder.register(TrackedPack {
2749            invoked: invoked.clone(),
2750        });
2751        builder.with_gate(Arc::new(AlwaysDenyGate));
2752        let reg = builder.build().expect("registry builds");
2753
2754        let err = reg.dispatch("guarded", Value::Null).await.unwrap_err();
2755        assert!(
2756            matches!(err, RuntimeError::PermissionDenied { ref verb, ref reason } if verb == "guarded" && reason.contains("always deny")),
2757            "expected PermissionDenied with verb=guarded and reason, got: {err:?}"
2758        );
2759        assert_eq!(
2760            invoked.load(Ordering::SeqCst),
2761            0,
2762            "pack dispatch MUST NOT be invoked when gate denies"
2763        );
2764    }
2765
2766    #[tokio::test]
2767    async fn audit_event_persists_to_event_store_on_allow() {
2768        let store = Arc::new(MemoryEventStore::default());
2769        let mut builder = VerbRegistryBuilder::new();
2770        builder.register(AlphaPack);
2771        builder.with_event_store(store.clone());
2772        let reg = builder.build().expect("registry builds");
2773
2774        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
2775            .await
2776            .unwrap();
2777
2778        let count = store.count_events(EventFilter::default()).await.unwrap();
2779        assert_eq!(count, 1, "one audit event persisted to EventStore on allow");
2780
2781        let page = store
2782            .query_events(
2783                EventFilter::default(),
2784                PageRequest {
2785                    limit: 10,
2786                    offset: 0,
2787                },
2788            )
2789            .await
2790            .unwrap();
2791        let ev = &page.items[0];
2792        assert_eq!(ev.verb, "list");
2793        assert_eq!(ev.namespace, "test-ns");
2794        assert_eq!(ev.substrate, SubstrateKind::Event);
2795        assert_eq!(ev.outcome, EventOutcome::Success);
2796    }
2797
2798    #[tokio::test]
2799    async fn audit_event_persists_to_event_store_on_deny() {
2800        #[derive(Debug)]
2801        struct AlwaysDenyGate;
2802        impl Gate for AlwaysDenyGate {
2803            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2804                Ok(GateDecision::deny("denied by test"))
2805            }
2806        }
2807
2808        let store = Arc::new(MemoryEventStore::default());
2809        let mut builder = VerbRegistryBuilder::new();
2810        builder.register(AlphaPack);
2811        builder.with_gate(Arc::new(AlwaysDenyGate));
2812        builder.with_event_store(store.clone());
2813        let reg = builder.build().expect("registry builds");
2814
2815        // Hard enforce → PermissionDenied returned.
2816        let err = reg
2817            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
2818            .await
2819            .unwrap_err();
2820        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
2821
2822        let count = store.count_events(EventFilter::default()).await.unwrap();
2823        assert_eq!(count, 1, "one audit event persisted to EventStore on deny");
2824
2825        let page = store
2826            .query_events(
2827                EventFilter::default(),
2828                PageRequest {
2829                    limit: 10,
2830                    offset: 0,
2831                },
2832            )
2833            .await
2834            .unwrap();
2835        let ev = &page.items[0];
2836        assert_eq!(ev.verb, "list");
2837        assert_eq!(ev.outcome, EventOutcome::Denied);
2838    }
2839
2840    #[tokio::test]
2841    async fn gate_error_does_not_persist_to_event_store() {
2842        #[derive(Debug)]
2843        struct FailingGate;
2844        impl Gate for FailingGate {
2845            fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
2846                Err(khive_gate::GateError::Internal("gate broken".into()))
2847            }
2848        }
2849
2850        let store = Arc::new(MemoryEventStore::default());
2851        let mut builder = VerbRegistryBuilder::new();
2852        builder.register(AlphaPack);
2853        builder.with_gate(Arc::new(FailingGate));
2854        builder.with_event_store(store.clone());
2855        let reg = builder.build().expect("registry builds");
2856
2857        // Gate Err → fail-open, dispatch proceeds.
2858        let res = reg.dispatch("list", Value::Null).await.unwrap();
2859        assert_eq!(
2860            res["pack"], "alpha",
2861            "gate error must fail-open, not block dispatch"
2862        );
2863
2864        let count = store.count_events(EventFilter::default()).await.unwrap();
2865        assert_eq!(
2866            count, 0,
2867            "gate infrastructure error must NOT produce an audit event in EventStore"
2868        );
2869    }
2870
2871    #[tokio::test]
2872    async fn no_event_store_configured_tracing_only() {
2873        // When no event_store is configured, dispatch must succeed without error.
2874        // (The tracing path is exercised in the tracing tests above; here we just
2875        // verify the absence of event_store does not break dispatch.)
2876        let mut builder = VerbRegistryBuilder::new();
2877        builder.register(AlphaPack);
2878        let reg = builder.build().expect("registry builds");
2879
2880        let res = reg.dispatch("list", Value::Null).await.unwrap();
2881        assert_eq!(res["pack"], "alpha");
2882    }
2883
2884    #[test]
2885    #[serial]
2886    fn dispatch_tracing_emits_gate_check_event_with_deny_payload() {
2887        #[derive(Debug)]
2888        struct TracingDenyGate;
2889        impl Gate for TracingDenyGate {
2890            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2891                Ok(GateDecision::deny("denied by test gate"))
2892            }
2893            fn impl_name(&self) -> &'static str {
2894                "TracingDenyGate"
2895            }
2896        }
2897
2898        let events = capture_dispatch_events(async {
2899            let mut builder = VerbRegistryBuilder::new();
2900            builder.register(AlphaPack);
2901            builder.with_gate(Arc::new(TracingDenyGate));
2902            let reg = builder.build().expect("registry builds");
2903            // Hard enforcement — dispatch returns PermissionDenied on Deny.
2904            // The tracing audit event is still emitted before the error is returned.
2905            let _ = reg.dispatch("create", serde_json::Value::Null).await;
2906        });
2907
2908        let gate_events = gate_check_events_for(&events, "TracingDenyGate");
2909        assert_eq!(
2910            gate_events.len(),
2911            1,
2912            "exactly one gate.check tracing event per dispatch (deny); got {gate_events:?}"
2913        );
2914        let payload = gate_events[0]
2915            .audit_event
2916            .as_ref()
2917            .expect("gate.check event must carry an audit_event field on Deny");
2918        let audit: khive_gate::AuditEvent =
2919            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
2920        assert_eq!(audit.decision, AuditDecision::Deny);
2921        assert_eq!(audit.deny_reason.as_deref(), Some("denied by test gate"));
2922        assert_eq!(audit.gate_impl, "TracingDenyGate");
2923        // Wire-shape rule: obligations is always serialized as an array, empty
2924        // on Deny. Round-trip back through serde_json::Value to confirm the
2925        // field exists on the wire and is `[]`, not missing.
2926        let payload_json: serde_json::Value =
2927            serde_json::from_str(payload).expect("payload must be valid JSON");
2928        assert_eq!(
2929            payload_json["obligations"],
2930            serde_json::Value::Array(Vec::new()),
2931            "obligations must be `[]` on Deny on the tracing payload, not omitted"
2932        );
2933    }
2934
2935    // ---- EventStore audit envelope round-trip ----
2936    //
2937    // Codex review finding (Major #1): EventStore was persisting a summary
2938    // Event without the full AuditEvent fields (deny_reason, gate_impl,
2939    // obligations). This test verifies the complete envelope survives
2940    // append_event → query_events.
2941
2942    #[tokio::test]
2943    async fn audit_envelope_round_trips_deny_reason_and_gate_impl_through_event_store() {
2944        #[derive(Debug)]
2945        struct DenyGateWithName;
2946        impl Gate for DenyGateWithName {
2947            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2948                Ok(GateDecision::deny("policy: write forbidden for anon"))
2949            }
2950            fn impl_name(&self) -> &'static str {
2951                "DenyGateWithName"
2952            }
2953        }
2954
2955        let store = Arc::new(MemoryEventStore::default());
2956        let mut builder = VerbRegistryBuilder::new();
2957        builder.register(AlphaPack);
2958        builder.with_gate(Arc::new(DenyGateWithName));
2959        builder.with_event_store(store.clone());
2960        let reg = builder.build().expect("registry builds");
2961
2962        // Dispatch is denied — PermissionDenied returned.
2963        let err = reg
2964            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
2965            .await
2966            .unwrap_err();
2967        assert!(
2968            matches!(err, RuntimeError::PermissionDenied { .. }),
2969            "expected PermissionDenied, got {err:?}"
2970        );
2971
2972        // Exactly one event in the store.
2973        let page = store
2974            .query_events(
2975                EventFilter::default(),
2976                PageRequest {
2977                    limit: 10,
2978                    offset: 0,
2979                },
2980            )
2981            .await
2982            .unwrap();
2983        assert_eq!(
2984            page.items.len(),
2985            1,
2986            "one audit event must be persisted on deny"
2987        );
2988
2989        let ev = &page.items[0];
2990        assert_eq!(ev.outcome, EventOutcome::Denied);
2991
2992        // The payload field must hold the full AuditEvent envelope.
2993        let data = &ev.payload;
2994
2995        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
2996            .expect("Event.payload must deserialize to AuditEvent");
2997
2998        assert_eq!(
2999            audit.deny_reason.as_deref(),
3000            Some("policy: write forbidden for anon"),
3001            "deny_reason must be preserved through EventStore"
3002        );
3003        assert_eq!(
3004            audit.gate_impl, "DenyGateWithName",
3005            "gate_impl must be preserved through EventStore"
3006        );
3007        assert_eq!(
3008            audit.decision,
3009            khive_gate::AuditDecision::Deny,
3010            "decision field must be preserved through EventStore"
3011        );
3012    }
3013
3014    #[tokio::test]
3015    async fn audit_envelope_round_trips_obligations_through_event_store() {
3016        use khive_gate::Obligation;
3017
3018        #[derive(Debug)]
3019        struct ObligationGate;
3020        impl Gate for ObligationGate {
3021            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
3022                Ok(GateDecision::allow_with(vec![Obligation::Audit {
3023                    tag: "billing.meter".into(),
3024                }]))
3025            }
3026            fn impl_name(&self) -> &'static str {
3027                "ObligationGate"
3028            }
3029        }
3030
3031        let store = Arc::new(MemoryEventStore::default());
3032        let mut builder = VerbRegistryBuilder::new();
3033        builder.register(AlphaPack);
3034        builder.with_gate(Arc::new(ObligationGate));
3035        builder.with_event_store(store.clone());
3036        let reg = builder.build().expect("registry builds");
3037
3038        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
3039            .await
3040            .unwrap();
3041
3042        let page = store
3043            .query_events(
3044                EventFilter::default(),
3045                PageRequest {
3046                    limit: 10,
3047                    offset: 0,
3048                },
3049            )
3050            .await
3051            .unwrap();
3052        assert_eq!(page.items.len(), 1);
3053
3054        let ev = &page.items[0];
3055        assert_eq!(ev.outcome, EventOutcome::Success);
3056
3057        let data = &ev.payload;
3058
3059        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
3060            .expect("Event.payload must deserialize to AuditEvent");
3061
3062        assert_eq!(audit.gate_impl, "ObligationGate");
3063        assert_eq!(
3064            audit.obligations.len(),
3065            1,
3066            "obligations must be preserved through EventStore"
3067        );
3068        match &audit.obligations[0] {
3069            Obligation::Audit { tag } => assert_eq!(tag, "billing.meter"),
3070            other => panic!("expected Audit obligation, got {other:?}"),
3071        }
3072    }
3073
3074    // ---- SQL-backed audit envelope round-trip (codex r2) ----
3075    //
3076    // The two tests above use MemoryEventStore (no serialization). This test
3077    // wires the production SqlEventStore via KhiveRuntime::memory() to verify
3078    // that the full AuditEvent envelope survives the SQL text→parse round-trip
3079    // (Event.data is stored as TEXT and parsed back on read).
3080
3081    #[tokio::test]
3082    async fn sql_backed_audit_envelope_round_trips_deny_reason_gate_impl_and_obligations() {
3083        #[derive(Debug)]
3084        struct SqlTestDenyGate;
3085        impl Gate for SqlTestDenyGate {
3086            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
3087                Ok(GateDecision::deny("sql-path: write denied"))
3088            }
3089            fn impl_name(&self) -> &'static str {
3090                "SqlTestDenyGate"
3091            }
3092        }
3093
3094        // KhiveRuntime::memory() creates an in-memory SQLite pool (is_file_backed=false).
3095        // events_for_namespace ensures the events schema and returns a SqlEventStore
3096        // scoped to "test-ns". The pool is shared so reads and writes see the same data.
3097        let rt = KhiveRuntime::memory().expect("in-memory runtime");
3098        let test_tok = NamespaceToken::for_namespace(Namespace::parse("test-ns").unwrap());
3099        let sql_store = rt
3100            .events(&test_tok)
3101            .expect("events_for_namespace must succeed");
3102
3103        let mut builder = VerbRegistryBuilder::new();
3104        builder.register(AlphaPack);
3105        builder.with_gate(Arc::new(SqlTestDenyGate));
3106        builder.with_event_store(sql_store.clone());
3107        let reg = builder.build().expect("registry builds");
3108
3109        // Dispatch is denied — PermissionDenied returned.
3110        let err = reg
3111            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
3112            .await
3113            .unwrap_err();
3114        assert!(
3115            matches!(err, RuntimeError::PermissionDenied { .. }),
3116            "expected PermissionDenied, got {err:?}"
3117        );
3118
3119        // Query via the same SqlEventStore — this is the SQL read path.
3120        let page = sql_store
3121            .query_events(
3122                EventFilter::default(),
3123                PageRequest {
3124                    limit: 10,
3125                    offset: 0,
3126                },
3127            )
3128            .await
3129            .unwrap();
3130        assert_eq!(
3131            page.items.len(),
3132            1,
3133            "one audit event must be persisted on deny through SqlEventStore"
3134        );
3135
3136        let ev = &page.items[0];
3137        assert_eq!(ev.outcome, EventOutcome::Denied);
3138
3139        // Event.payload must hold the full AuditEvent serialized as JSON text and
3140        // parsed back. If the SQL path was lossy, this deserialization would fail
3141        // or the field assertions below would fail.
3142        let data = &ev.payload;
3143
3144        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
3145            .expect("Event.payload must deserialize to AuditEvent after SQL round-trip");
3146
3147        assert_eq!(
3148            audit.deny_reason.as_deref(),
3149            Some("sql-path: write denied"),
3150            "deny_reason must survive the SQL text round-trip"
3151        );
3152        assert_eq!(
3153            audit.gate_impl, "SqlTestDenyGate",
3154            "gate_impl must survive the SQL text round-trip"
3155        );
3156        assert_eq!(
3157            audit.decision,
3158            khive_gate::AuditDecision::Deny,
3159            "decision field must survive the SQL text round-trip"
3160        );
3161        // obligations is [] on a Deny gate (no obligations returned).
3162        // Verify the field is present and empty after SQL round-trip.
3163        assert!(
3164            audit.obligations.is_empty(),
3165            "obligations must be preserved as empty [] through SQL round-trip"
3166        );
3167    }
3168
3169    // ---- SQL-backed audit envelope: non-empty obligations survive round-trip ----
3170    //
3171    // Codex r3 identified a blind spot: the deny-path SQL test above only
3172    // asserts obligations == [], which passes even if the SQL path drops the
3173    // field entirely (AuditEvent.obligations has #[serde(default)]).
3174    //
3175    // This test installs an allow-path gate that returns a non-empty obligations
3176    // vec. After dispatch, the same SqlEventStore is queried and both layers are
3177    // checked:
3178    //   1. Raw Event.data["obligations"] is a non-empty JSON array.
3179    //   2. Deserialized AuditEvent.obligations[0] matches the expected variant.
3180    #[tokio::test]
3181    async fn sql_backed_audit_envelope_round_trips_non_empty_obligations() {
3182        use khive_gate::Obligation;
3183
3184        #[derive(Debug)]
3185        struct SqlTestAllowWithObligationGate;
3186        impl Gate for SqlTestAllowWithObligationGate {
3187            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
3188                Ok(GateDecision::allow_with(vec![Obligation::Audit {
3189                    tag: "sql-path-billing.meter".into(),
3190                }]))
3191            }
3192            fn impl_name(&self) -> &'static str {
3193                "SqlTestAllowWithObligationGate"
3194            }
3195        }
3196
3197        let rt = KhiveRuntime::memory().expect("in-memory runtime");
3198        let test_tok = NamespaceToken::for_namespace(Namespace::parse("test-ns").unwrap());
3199        let sql_store = rt
3200            .events(&test_tok)
3201            .expect("events_for_namespace must succeed");
3202
3203        let mut builder = VerbRegistryBuilder::new();
3204        builder.register(AlphaPack);
3205        builder.with_gate(Arc::new(SqlTestAllowWithObligationGate));
3206        builder.with_event_store(sql_store.clone());
3207        let reg = builder.build().expect("registry builds");
3208
3209        // Dispatch succeeds — the gate allows with obligations.
3210        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
3211            .await
3212            .expect("dispatch must succeed when gate allows");
3213
3214        // Query via the same SqlEventStore — this is the SQL read path.
3215        let page = sql_store
3216            .query_events(
3217                EventFilter::default(),
3218                PageRequest {
3219                    limit: 10,
3220                    offset: 0,
3221                },
3222            )
3223            .await
3224            .unwrap();
3225        assert_eq!(
3226            page.items.len(),
3227            1,
3228            "one audit event must be persisted on allow through SqlEventStore"
3229        );
3230
3231        let ev = &page.items[0];
3232        assert_eq!(ev.outcome, EventOutcome::Success);
3233
3234        let data = &ev.payload;
3235
3236        // Layer 1: raw JSON check — obligations must be a non-empty array in
3237        // the persisted TEXT. If the SQL path dropped the field, the default
3238        // #[serde(default)] would silently deserialize it to [], so we verify
3239        // the raw JSON before deserializing.
3240        let obligations_raw = data
3241            .get("obligations")
3242            .expect("Event.data JSON must contain 'obligations' key");
3243        let obligations_arr = obligations_raw
3244            .as_array()
3245            .expect("'obligations' must be a JSON array");
3246        assert!(
3247            !obligations_arr.is_empty(),
3248            "raw Event.data['obligations'] must be non-empty after SQL round-trip"
3249        );
3250
3251        // Layer 2: deserialized AuditEvent check — the obligation variant and
3252        // payload must survive the text round-trip faithfully.
3253        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
3254            .expect("Event.data must deserialize to AuditEvent after SQL round-trip");
3255
3256        assert_eq!(
3257            audit.gate_impl, "SqlTestAllowWithObligationGate",
3258            "gate_impl must survive the SQL text round-trip"
3259        );
3260        assert_eq!(
3261            audit.decision,
3262            khive_gate::AuditDecision::Allow,
3263            "decision field must survive the SQL text round-trip"
3264        );
3265        assert_eq!(
3266            audit.obligations.len(),
3267            1,
3268            "obligations must be non-empty after SQL round-trip (not silently defaulted to [])"
3269        );
3270        match &audit.obligations[0] {
3271            Obligation::Audit { tag } => assert_eq!(
3272                tag, "sql-path-billing.meter",
3273                "Audit obligation tag must survive the SQL text round-trip"
3274            ),
3275            other => panic!("expected Audit obligation, got {other:?}"),
3276        }
3277    }
3278
3279    // ---- Audit payload shape for 'create' verb dispatch ----
3280    //
3281    // The previous audit tests verify the envelope shape for the 'list' verb.
3282    // This test dispatches 'create' (matching the create_note + annotates path)
3283    // and verifies that ev.verb, ev.outcome, and ev.data all round-trip correctly
3284    // through the EventStore. Ensures the wire shape is independent of which verb
3285    // triggers the gate check.
3286    #[tokio::test]
3287    async fn audit_event_payload_shape_for_create_verb() {
3288        let store = Arc::new(MemoryEventStore::default());
3289        let mut builder = VerbRegistryBuilder::new();
3290        builder.register(AlphaPack);
3291        builder.with_event_store(store.clone());
3292        builder.with_default_namespace("test-ns");
3293        let reg = builder.build().expect("registry builds");
3294
3295        // Dispatch 'create' — AlphaPack returns a stub value; what matters is
3296        // the EventStore entry emitted by the registry's gate-check path.
3297        reg.dispatch("create", serde_json::json!({"namespace": "test-ns"}))
3298            .await
3299            .unwrap();
3300
3301        let count = store.count_events(EventFilter::default()).await.unwrap();
3302        assert_eq!(count, 1, "exactly one audit event for one dispatch");
3303
3304        let page = store
3305            .query_events(
3306                EventFilter::default(),
3307                PageRequest {
3308                    limit: 10,
3309                    offset: 0,
3310                },
3311            )
3312            .await
3313            .unwrap();
3314        let ev = &page.items[0];
3315
3316        // Top-level Event fields.
3317        assert_eq!(ev.verb, "create", "ev.verb must be the dispatched verb");
3318        assert_eq!(
3319            ev.outcome,
3320            EventOutcome::Success,
3321            "ev.outcome must be Success on allow"
3322        );
3323        assert_eq!(
3324            ev.namespace, "test-ns",
3325            "ev.namespace must match the dispatch namespace"
3326        );
3327
3328        // ev.payload must hold the full AuditEvent envelope.
3329        let data = &ev.payload;
3330
3331        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
3332            .expect("ev.payload must deserialize to AuditEvent");
3333
3334        assert_eq!(
3335            audit.decision,
3336            khive_gate::AuditDecision::Allow,
3337            "AuditEvent.decision must be Allow"
3338        );
3339        assert_eq!(audit.verb, "create", "AuditEvent.verb must be 'create'");
3340        assert_eq!(
3341            audit.namespace, "test-ns",
3342            "AuditEvent.namespace must be preserved"
3343        );
3344        assert_eq!(
3345            audit.gate_impl, "AllowAllGate",
3346            "AuditEvent.gate_impl must name the gate implementation"
3347        );
3348        assert!(
3349            audit.deny_reason.is_none(),
3350            "AuditEvent.deny_reason must be None on Allow"
3351        );
3352        // Wire-shape check: obligations serializes as [] on AllowAllGate.
3353        let payload_json: serde_json::Value =
3354            serde_json::from_value(data.clone()).expect("data must be valid JSON");
3355        assert_eq!(
3356            payload_json["obligations"],
3357            serde_json::Value::Array(Vec::new()),
3358            "obligations must be [] on AllowAllGate"
3359        );
3360    }
3361
3362    // #282: registry audit event must carry target_id when dispatch params include it.
3363    #[tokio::test]
3364    async fn audit_event_threads_target_id_from_dispatch_args() {
3365        let store = Arc::new(MemoryEventStore::default());
3366        let target = uuid::Uuid::new_v4();
3367        let mut builder = VerbRegistryBuilder::new();
3368        builder.register(AlphaPack);
3369        builder.with_event_store(store.clone());
3370        builder.with_default_namespace("test-ns");
3371        let reg = builder.build().expect("registry builds");
3372
3373        reg.dispatch(
3374            "create",
3375            serde_json::json!({"namespace": "test-ns", "target_id": target}),
3376        )
3377        .await
3378        .unwrap();
3379
3380        let page = store
3381            .query_events(
3382                EventFilter::default(),
3383                PageRequest {
3384                    offset: 0,
3385                    limit: 10,
3386                },
3387            )
3388            .await
3389            .unwrap();
3390        assert_eq!(
3391            page.items[0].target_id,
3392            Some(target),
3393            "#282: audit event must carry target_id from dispatch params"
3394        );
3395    }
3396}
3397
3398// ---- Inter-pack dependency checking ----
3399
3400#[cfg(test)]
3401mod dep_tests {
3402    use super::*;
3403    use async_trait::async_trait;
3404    use khive_types::Pack;
3405    use serde_json::Value;
3406
3407    struct KgDepPack;
3408    struct MemoryDepPack;
3409    struct ADepPack;
3410    struct BDepPack;
3411
3412    impl Pack for KgDepPack {
3413        const NAME: &'static str = "kg_dep";
3414        const NOTE_KINDS: &'static [&'static str] = &["observation"];
3415        const ENTITY_KINDS: &'static [&'static str] = &["concept"];
3416        const HANDLERS: &'static [HandlerDef] = &[];
3417    }
3418
3419    impl Pack for MemoryDepPack {
3420        const NAME: &'static str = "memory_dep";
3421        const NOTE_KINDS: &'static [&'static str] = &["memory"];
3422        const ENTITY_KINDS: &'static [&'static str] = &[];
3423        const HANDLERS: &'static [HandlerDef] = &[];
3424        const REQUIRES: &'static [&'static str] = &["kg_dep"];
3425    }
3426
3427    impl Pack for ADepPack {
3428        const NAME: &'static str = "pack_a";
3429        const NOTE_KINDS: &'static [&'static str] = &[];
3430        const ENTITY_KINDS: &'static [&'static str] = &[];
3431        const HANDLERS: &'static [HandlerDef] = &[];
3432        const REQUIRES: &'static [&'static str] = &["pack_b"];
3433    }
3434
3435    impl Pack for BDepPack {
3436        const NAME: &'static str = "pack_b";
3437        const NOTE_KINDS: &'static [&'static str] = &[];
3438        const ENTITY_KINDS: &'static [&'static str] = &[];
3439        const HANDLERS: &'static [HandlerDef] = &[];
3440        const REQUIRES: &'static [&'static str] = &["pack_a"];
3441    }
3442
3443    #[async_trait]
3444    impl PackRuntime for KgDepPack {
3445        fn name(&self) -> &str {
3446            Self::NAME
3447        }
3448        fn note_kinds(&self) -> &'static [&'static str] {
3449            Self::NOTE_KINDS
3450        }
3451        fn entity_kinds(&self) -> &'static [&'static str] {
3452            Self::ENTITY_KINDS
3453        }
3454        fn handlers(&self) -> &'static [HandlerDef] {
3455            Self::HANDLERS
3456        }
3457        async fn dispatch(
3458            &self,
3459            verb: &str,
3460            _: Value,
3461            _: &VerbRegistry,
3462            _: &NamespaceToken,
3463        ) -> Result<Value, RuntimeError> {
3464            Err(RuntimeError::InvalidInput(format!(
3465                "KgDepPack has no verbs: {verb}"
3466            )))
3467        }
3468    }
3469
3470    #[async_trait]
3471    impl PackRuntime for MemoryDepPack {
3472        fn name(&self) -> &str {
3473            Self::NAME
3474        }
3475        fn note_kinds(&self) -> &'static [&'static str] {
3476            Self::NOTE_KINDS
3477        }
3478        fn entity_kinds(&self) -> &'static [&'static str] {
3479            Self::ENTITY_KINDS
3480        }
3481        fn handlers(&self) -> &'static [HandlerDef] {
3482            Self::HANDLERS
3483        }
3484        fn requires(&self) -> &'static [&'static str] {
3485            Self::REQUIRES
3486        }
3487        async fn dispatch(
3488            &self,
3489            verb: &str,
3490            _: Value,
3491            _: &VerbRegistry,
3492            _: &NamespaceToken,
3493        ) -> Result<Value, RuntimeError> {
3494            Err(RuntimeError::InvalidInput(format!(
3495                "MemoryDepPack has no verbs: {verb}"
3496            )))
3497        }
3498    }
3499
3500    #[async_trait]
3501    impl PackRuntime for ADepPack {
3502        fn name(&self) -> &str {
3503            Self::NAME
3504        }
3505        fn note_kinds(&self) -> &'static [&'static str] {
3506            Self::NOTE_KINDS
3507        }
3508        fn entity_kinds(&self) -> &'static [&'static str] {
3509            Self::ENTITY_KINDS
3510        }
3511        fn handlers(&self) -> &'static [HandlerDef] {
3512            Self::HANDLERS
3513        }
3514        fn requires(&self) -> &'static [&'static str] {
3515            Self::REQUIRES
3516        }
3517        async fn dispatch(
3518            &self,
3519            verb: &str,
3520            _: Value,
3521            _: &VerbRegistry,
3522            _: &NamespaceToken,
3523        ) -> Result<Value, RuntimeError> {
3524            Err(RuntimeError::InvalidInput(format!(
3525                "ADepPack has no verbs: {verb}"
3526            )))
3527        }
3528    }
3529
3530    #[async_trait]
3531    impl PackRuntime for BDepPack {
3532        fn name(&self) -> &str {
3533            Self::NAME
3534        }
3535        fn note_kinds(&self) -> &'static [&'static str] {
3536            Self::NOTE_KINDS
3537        }
3538        fn entity_kinds(&self) -> &'static [&'static str] {
3539            Self::ENTITY_KINDS
3540        }
3541        fn handlers(&self) -> &'static [HandlerDef] {
3542            Self::HANDLERS
3543        }
3544        fn requires(&self) -> &'static [&'static str] {
3545            Self::REQUIRES
3546        }
3547        async fn dispatch(
3548            &self,
3549            verb: &str,
3550            _: Value,
3551            _: &VerbRegistry,
3552            _: &NamespaceToken,
3553        ) -> Result<Value, RuntimeError> {
3554            Err(RuntimeError::InvalidInput(format!(
3555                "BDepPack has no verbs: {verb}"
3556            )))
3557        }
3558    }
3559
3560    #[test]
3561    fn test_pack_deps_happy_path() {
3562        let mut builder = VerbRegistryBuilder::new();
3563        builder.register(MemoryDepPack);
3564        builder.register(KgDepPack);
3565        let reg = builder
3566            .build()
3567            .expect("kg_dep satisfies memory_dep dependency");
3568        assert_eq!(reg.pack_requires("memory_dep").unwrap(), &["kg_dep"]);
3569        let names = reg.pack_names();
3570        let kg_pos = names.iter().position(|&n| n == "kg_dep").unwrap();
3571        let mem_pos = names.iter().position(|&n| n == "memory_dep").unwrap();
3572        assert!(
3573            kg_pos < mem_pos,
3574            "kg_dep must be loaded before memory_dep; order: {names:?}"
3575        );
3576    }
3577
3578    #[test]
3579    fn test_pack_deps_missing() {
3580        let mut builder = VerbRegistryBuilder::new();
3581        builder.register(MemoryDepPack);
3582        let err = match builder.build() {
3583            Ok(_) => panic!("expected Err, got Ok"),
3584            Err(e) => e,
3585        };
3586        assert!(
3587            matches!(err, RuntimeError::MissingPackDependency(_)),
3588            "expected MissingPackDependency, got {err:?}"
3589        );
3590        let msg = err.to_string();
3591        assert!(
3592            msg.contains("memory_dep"),
3593            "error must name the dependent pack: {msg}"
3594        );
3595        assert!(
3596            msg.contains("kg_dep"),
3597            "error must name the missing dep: {msg}"
3598        );
3599    }
3600
3601    #[test]
3602    fn test_pack_deps_circular() {
3603        let mut builder = VerbRegistryBuilder::new();
3604        builder.register(ADepPack);
3605        builder.register(BDepPack);
3606        let err = match builder.build() {
3607            Ok(_) => panic!("expected Err, got Ok"),
3608            Err(e) => e,
3609        };
3610        assert!(
3611            matches!(err, RuntimeError::CircularPackDependency(_)),
3612            "expected CircularPackDependency, got {err:?}"
3613        );
3614        let msg = err.to_string();
3615        assert!(msg.contains("pack_a"), "error must name pack_a: {msg}");
3616        assert!(msg.contains("pack_b"), "error must name pack_b: {msg}");
3617    }
3618
3619    #[test]
3620    fn test_pack_deps_no_deps() {
3621        struct NoDepsA;
3622        struct NoDepsB;
3623
3624        impl Pack for NoDepsA {
3625            const NAME: &'static str = "no_deps_a";
3626            const NOTE_KINDS: &'static [&'static str] = &[];
3627            const ENTITY_KINDS: &'static [&'static str] = &[];
3628            const HANDLERS: &'static [HandlerDef] = &[];
3629        }
3630
3631        impl Pack for NoDepsB {
3632            const NAME: &'static str = "no_deps_b";
3633            const NOTE_KINDS: &'static [&'static str] = &[];
3634            const ENTITY_KINDS: &'static [&'static str] = &[];
3635            const HANDLERS: &'static [HandlerDef] = &[];
3636        }
3637
3638        #[async_trait]
3639        impl PackRuntime for NoDepsA {
3640            fn name(&self) -> &str {
3641                Self::NAME
3642            }
3643            fn note_kinds(&self) -> &'static [&'static str] {
3644                Self::NOTE_KINDS
3645            }
3646            fn entity_kinds(&self) -> &'static [&'static str] {
3647                Self::ENTITY_KINDS
3648            }
3649            fn handlers(&self) -> &'static [HandlerDef] {
3650                Self::HANDLERS
3651            }
3652            async fn dispatch(
3653                &self,
3654                verb: &str,
3655                _: Value,
3656                _: &VerbRegistry,
3657                _: &NamespaceToken,
3658            ) -> Result<Value, RuntimeError> {
3659                Err(RuntimeError::InvalidInput(format!("NoDepsA: {verb}")))
3660            }
3661        }
3662
3663        #[async_trait]
3664        impl PackRuntime for NoDepsB {
3665            fn name(&self) -> &str {
3666                Self::NAME
3667            }
3668            fn note_kinds(&self) -> &'static [&'static str] {
3669                Self::NOTE_KINDS
3670            }
3671            fn entity_kinds(&self) -> &'static [&'static str] {
3672                Self::ENTITY_KINDS
3673            }
3674            fn handlers(&self) -> &'static [HandlerDef] {
3675                Self::HANDLERS
3676            }
3677            async fn dispatch(
3678                &self,
3679                verb: &str,
3680                _: Value,
3681                _: &VerbRegistry,
3682                _: &NamespaceToken,
3683            ) -> Result<Value, RuntimeError> {
3684                Err(RuntimeError::InvalidInput(format!("NoDepsB: {verb}")))
3685            }
3686        }
3687
3688        let mut builder = VerbRegistryBuilder::new();
3689        builder.register(NoDepsA);
3690        builder.register(NoDepsB);
3691        let reg = builder.build().expect("packs with REQUIRES=&[] build");
3692        assert_eq!(reg.pack_requires("no_deps_a").unwrap(), &[] as &[&str]);
3693        assert_eq!(reg.pack_requires("no_deps_b").unwrap(), &[] as &[&str]);
3694    }
3695}
3696
3697// ── Dispatch hook tests (Issue #158) ─────────────────────────────────────────
3698
3699#[cfg(test)]
3700mod hook_tests {
3701    use super::*;
3702    use async_trait::async_trait;
3703    use khive_types::Pack;
3704    use std::sync::atomic::{AtomicUsize, Ordering};
3705    use std::sync::Mutex as StdMutex;
3706
3707    struct SimplePack;
3708
3709    impl Pack for SimplePack {
3710        const NAME: &'static str = "simple";
3711        const NOTE_KINDS: &'static [&'static str] = &[];
3712        const ENTITY_KINDS: &'static [&'static str] = &[];
3713        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
3714            name: "ping",
3715            description: "ping",
3716            visibility: Visibility::Verb,
3717            category: VerbCategory::Assertive,
3718            params: &[],
3719        }];
3720    }
3721
3722    #[async_trait]
3723    impl PackRuntime for SimplePack {
3724        fn name(&self) -> &str {
3725            SimplePack::NAME
3726        }
3727        fn note_kinds(&self) -> &'static [&'static str] {
3728            SimplePack::NOTE_KINDS
3729        }
3730        fn entity_kinds(&self) -> &'static [&'static str] {
3731            SimplePack::ENTITY_KINDS
3732        }
3733        fn handlers(&self) -> &'static [HandlerDef] {
3734            SimplePack::HANDLERS
3735        }
3736        async fn dispatch(
3737            &self,
3738            verb: &str,
3739            _params: Value,
3740            _registry: &VerbRegistry,
3741            _token: &NamespaceToken,
3742        ) -> Result<Value, RuntimeError> {
3743            Ok(serde_json::json!({ "verb": verb }))
3744        }
3745    }
3746
3747    /// Hook that counts calls and records the last verb seen.
3748    #[derive(Default)]
3749    struct CountingHook {
3750        calls: AtomicUsize,
3751        last_verb: StdMutex<String>,
3752    }
3753
3754    #[async_trait]
3755    impl DispatchHook for CountingHook {
3756        async fn on_dispatch(&self, view: &EventView) {
3757            self.calls.fetch_add(1, Ordering::SeqCst);
3758            *self.last_verb.lock().unwrap() = view.event.verb.clone();
3759        }
3760    }
3761
3762    #[tokio::test]
3763    async fn dispatch_hook_fires_on_successful_dispatch() {
3764        let hook = Arc::new(CountingHook::default());
3765        let mut builder = VerbRegistryBuilder::new();
3766        builder.register(SimplePack);
3767        builder.with_dispatch_hook(hook.clone());
3768        let reg = builder.build().expect("registry builds");
3769
3770        reg.dispatch("ping", Value::Null).await.unwrap();
3771
3772        assert_eq!(
3773            hook.calls.load(Ordering::SeqCst),
3774            1,
3775            "hook must fire once per successful dispatch"
3776        );
3777        assert_eq!(
3778            hook.last_verb.lock().unwrap().as_str(),
3779            "ping",
3780            "hook event must carry the dispatched verb"
3781        );
3782    }
3783
3784    #[tokio::test]
3785    async fn dispatch_hook_fires_multiple_times() {
3786        let hook = Arc::new(CountingHook::default());
3787        let mut builder = VerbRegistryBuilder::new();
3788        builder.register(SimplePack);
3789        builder.with_dispatch_hook(hook.clone());
3790        let reg = builder.build().expect("registry builds");
3791
3792        reg.dispatch("ping", Value::Null).await.unwrap();
3793        reg.dispatch("ping", Value::Null).await.unwrap();
3794        reg.dispatch("ping", Value::Null).await.unwrap();
3795
3796        assert_eq!(
3797            hook.calls.load(Ordering::SeqCst),
3798            3,
3799            "hook must fire once per successful dispatch"
3800        );
3801    }
3802
3803    #[tokio::test]
3804    async fn dispatch_hook_does_not_fire_on_unknown_verb() {
3805        let hook = Arc::new(CountingHook::default());
3806        let mut builder = VerbRegistryBuilder::new();
3807        builder.register(SimplePack);
3808        builder.with_dispatch_hook(hook.clone());
3809        let reg = builder.build().expect("registry builds");
3810
3811        let _ = reg.dispatch("nonexistent", Value::Null).await;
3812
3813        assert_eq!(
3814            hook.calls.load(Ordering::SeqCst),
3815            0,
3816            "hook must NOT fire for unknown verb (dispatch returns error)"
3817        );
3818    }
3819
3820    #[tokio::test]
3821    async fn dispatch_hook_does_not_fire_on_gate_deny() {
3822        use khive_gate::{Gate, GateDecision, GateError};
3823
3824        #[derive(Debug)]
3825        struct AlwaysDenyGate;
3826        impl Gate for AlwaysDenyGate {
3827            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
3828                Ok(GateDecision::deny("test deny"))
3829            }
3830        }
3831
3832        let hook = Arc::new(CountingHook::default());
3833        let mut builder = VerbRegistryBuilder::new();
3834        builder.register(SimplePack);
3835        builder.with_gate(Arc::new(AlwaysDenyGate));
3836        builder.with_dispatch_hook(hook.clone());
3837        let reg = builder.build().expect("registry builds");
3838
3839        let err = reg.dispatch("ping", Value::Null).await.unwrap_err();
3840        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
3841
3842        assert_eq!(
3843            hook.calls.load(Ordering::SeqCst),
3844            0,
3845            "hook must NOT fire when gate denies dispatch"
3846        );
3847    }
3848
3849    #[tokio::test]
3850    async fn dispatch_hook_event_carries_namespace_from_params() {
3851        let hook = Arc::new(CountingHook::default());
3852
3853        #[derive(Default)]
3854        struct NsCapturingHook {
3855            ns: StdMutex<String>,
3856        }
3857
3858        #[async_trait]
3859        impl DispatchHook for NsCapturingHook {
3860            async fn on_dispatch(&self, view: &EventView) {
3861                *self.ns.lock().unwrap() = view.event.namespace.clone();
3862            }
3863        }
3864
3865        let ns_hook = Arc::new(NsCapturingHook::default());
3866        let mut builder = VerbRegistryBuilder::new();
3867        builder.register(SimplePack);
3868        builder.with_dispatch_hook(ns_hook.clone());
3869        let reg = builder.build().expect("registry builds");
3870
3871        reg.dispatch("ping", serde_json::json!({"namespace": "tenant-abc"}))
3872            .await
3873            .unwrap();
3874
3875        assert_eq!(
3876            ns_hook.ns.lock().unwrap().as_str(),
3877            "tenant-abc",
3878            "dispatch hook event must carry the resolved namespace"
3879        );
3880
3881        // Suppress unused-variable warning from the outer hook.
3882        drop(hook);
3883    }
3884
3885    #[tokio::test]
3886    async fn no_dispatch_hook_configured_dispatch_succeeds() {
3887        // Regression: registries without a hook must still work.
3888        let mut builder = VerbRegistryBuilder::new();
3889        builder.register(SimplePack);
3890        // No with_dispatch_hook call.
3891        let reg = builder.build().expect("registry builds");
3892
3893        let res = reg.dispatch("ping", Value::Null).await.unwrap();
3894        assert_eq!(res["verb"], "ping");
3895    }
3896}
3897
3898// ── help=true tests (issue #287) ──────────────────────────────────────────────
3899
3900#[cfg(test)]
3901mod help_tests {
3902    use super::*;
3903    use async_trait::async_trait;
3904    use khive_types::Pack;
3905    use std::sync::{
3906        atomic::{AtomicUsize, Ordering},
3907        Arc,
3908    };
3909
3910    // ── HelpPack: a minimal pack with one handler that records invocation count.
3911    //
3912    // Used to verify that help=true never reaches the pack's dispatch method.
3913
3914    static CREATE_PARAMS: [ParamDef; 2] = [
3915        ParamDef {
3916            name: "kind",
3917            param_type: "string",
3918            required: true,
3919            description: "Granular kind (concept | document | ...).",
3920        },
3921        ParamDef {
3922            name: "name",
3923            param_type: "string",
3924            required: false,
3925            description: "Human-readable name.",
3926        },
3927    ];
3928
3929    static RECALL_PARAMS: [ParamDef; 2] = [
3930        ParamDef {
3931            name: "query",
3932            param_type: "string",
3933            required: true,
3934            description: "Semantic recall query.",
3935        },
3936        ParamDef {
3937            name: "limit",
3938            param_type: "integer",
3939            required: false,
3940            description: "Maximum memories to return.",
3941        },
3942    ];
3943
3944    // A subhandler with no params — mirrors recall.embed / brain.emit / etc.
3945    // Used to test that help=true on a Subhandler returns callable_via_mcp: false.
3946    static EMBED_PARAMS: [ParamDef; 0] = [];
3947
3948    struct HelpPack {
3949        invocations: Arc<AtomicUsize>,
3950    }
3951
3952    impl Pack for HelpPack {
3953        const NAME: &'static str = "helptest";
3954        const NOTE_KINDS: &'static [&'static str] = &[];
3955        const ENTITY_KINDS: &'static [&'static str] = &[];
3956        const HANDLERS: &'static [HandlerDef] = &[
3957            HandlerDef {
3958                name: "create",
3959                description: "Create an entity or note",
3960                visibility: Visibility::Verb,
3961                category: VerbCategory::Commissive,
3962                params: &CREATE_PARAMS,
3963            },
3964            HandlerDef {
3965                name: "recall",
3966                description: "Recall memory notes with decay-aware hybrid ranking",
3967                visibility: Visibility::Verb,
3968                category: VerbCategory::Assertive,
3969                params: &RECALL_PARAMS,
3970            },
3971            // ue-help-introspection C1: a Subhandler used to test that
3972            // help=true returns callable_via_mcp: false for internal verbs.
3973            HandlerDef {
3974                name: "recall.embed",
3975                description: "Return the embedding vector used by memory recall",
3976                visibility: Visibility::Subhandler,
3977                category: VerbCategory::Assertive,
3978                params: &EMBED_PARAMS,
3979            },
3980        ];
3981    }
3982
3983    #[async_trait]
3984    impl PackRuntime for HelpPack {
3985        fn name(&self) -> &str {
3986            HelpPack::NAME
3987        }
3988        fn note_kinds(&self) -> &'static [&'static str] {
3989            HelpPack::NOTE_KINDS
3990        }
3991        fn entity_kinds(&self) -> &'static [&'static str] {
3992            HelpPack::ENTITY_KINDS
3993        }
3994        fn handlers(&self) -> &'static [HandlerDef] {
3995            HelpPack::HANDLERS
3996        }
3997        async fn dispatch(
3998            &self,
3999            verb: &str,
4000            _params: Value,
4001            _registry: &VerbRegistry,
4002            _token: &NamespaceToken,
4003        ) -> Result<Value, RuntimeError> {
4004            self.invocations.fetch_add(1, Ordering::SeqCst);
4005            Ok(serde_json::json!({ "pack": "helptest", "verb": verb }))
4006        }
4007    }
4008
4009    fn build_help_registry(invocations: Arc<AtomicUsize>) -> VerbRegistry {
4010        let mut builder = VerbRegistryBuilder::new();
4011        builder.register(HelpPack { invocations });
4012        builder.build().expect("help registry builds")
4013    }
4014
4015    /// help=true on `create` returns a schema envelope with the correct verb name,
4016    /// pack name, description, and at least the required `kind` parameter.
4017    #[tokio::test]
4018    async fn test_help_true_returns_schema_for_kg_create() {
4019        let invocations = Arc::new(AtomicUsize::new(0));
4020        let reg = build_help_registry(invocations.clone());
4021
4022        let result = reg
4023            .dispatch("create", serde_json::json!({ "help": true }))
4024            .await
4025            .expect("help=true must succeed for a known verb");
4026
4027        // Shape checks.
4028        assert_eq!(result["verb"], "create", "envelope must name the verb");
4029        assert_eq!(
4030            result["pack"], "helptest",
4031            "envelope must name the owning pack"
4032        );
4033        assert!(
4034            result["description"].as_str().is_some(),
4035            "description must be a string"
4036        );
4037
4038        // Params array must be present and non-empty.
4039        let params = result["params"]
4040            .as_array()
4041            .expect("params must be a JSON array");
4042        assert!(!params.is_empty(), "params array must not be empty");
4043
4044        // The required `kind` param must appear.
4045        let kind_param = params.iter().find(|p| p["name"] == "kind");
4046        assert!(
4047            kind_param.is_some(),
4048            "params array must include the 'kind' parameter"
4049        );
4050        let kind_param = kind_param.unwrap();
4051        assert_eq!(
4052            kind_param["required"],
4053            serde_json::json!(true),
4054            "'kind' must be required"
4055        );
4056        assert_eq!(kind_param["type"], "string", "'kind' type must be 'string'");
4057    }
4058
4059    /// help=true on `recall` returns a schema envelope including the `query` param.
4060    #[tokio::test]
4061    async fn test_help_true_returns_schema_for_recall() {
4062        let invocations = Arc::new(AtomicUsize::new(0));
4063        let reg = build_help_registry(invocations.clone());
4064
4065        let result = reg
4066            .dispatch("recall", serde_json::json!({ "help": true }))
4067            .await
4068            .expect("help=true must succeed for recall");
4069
4070        assert_eq!(result["verb"], "recall");
4071        assert_eq!(result["pack"], "helptest");
4072
4073        let params = result["params"]
4074            .as_array()
4075            .expect("params must be a JSON array");
4076
4077        // `query` must be present and required.
4078        let query_param = params.iter().find(|p| p["name"] == "query");
4079        assert!(query_param.is_some(), "params must include 'query'");
4080        let query_param = query_param.unwrap();
4081        assert_eq!(
4082            query_param["required"],
4083            serde_json::json!(true),
4084            "'query' must be required"
4085        );
4086
4087        // `limit` must be present and optional.
4088        let limit_param = params.iter().find(|p| p["name"] == "limit");
4089        assert!(limit_param.is_some(), "params must include 'limit'");
4090        let limit_param = limit_param.unwrap();
4091        assert_eq!(
4092            limit_param["required"],
4093            serde_json::json!(false),
4094            "'limit' must be optional"
4095        );
4096    }
4097
4098    /// help=true is intercepted before pack dispatch — the pack's dispatch method
4099    /// must never be invoked when help=true is in the params.
4100    #[tokio::test]
4101    async fn test_help_true_does_not_execute_the_verb() {
4102        let invocations = Arc::new(AtomicUsize::new(0));
4103        let reg = build_help_registry(invocations.clone());
4104
4105        // Call both verbs with help=true.
4106        reg.dispatch("create", serde_json::json!({ "help": true }))
4107            .await
4108            .expect("help=true must succeed");
4109        reg.dispatch("recall", serde_json::json!({ "help": true }))
4110            .await
4111            .expect("help=true must succeed");
4112
4113        assert_eq!(
4114            invocations.load(Ordering::SeqCst),
4115            0,
4116            "pack dispatch MUST NOT be invoked when help=true; \
4117             got {} invocation(s)",
4118            invocations.load(Ordering::SeqCst)
4119        );
4120
4121        // Confirm that a normal call (without help=true) DOES invoke dispatch.
4122        reg.dispatch("create", serde_json::json!({}))
4123            .await
4124            .expect("normal dispatch must succeed");
4125        assert_eq!(
4126            invocations.load(Ordering::SeqCst),
4127            1,
4128            "pack dispatch must fire exactly once for a normal call"
4129        );
4130    }
4131
4132    // ── ue-help-introspection C1 regressions ─────────────────────────────────
4133    //
4134    // Subhandler verbs must return `callable_via_mcp: false` in their help
4135    // schema so agents who read help=true before probing see accurate
4136    // availability — not a "looks callable" schema followed by permission denied.
4137
4138    /// help=true on a `Visibility::Subhandler` verb returns `callable_via_mcp: false`
4139    /// and `visibility: "internal"` rather than a plain callable-looking envelope.
4140    #[tokio::test]
4141    async fn help_true_on_subhandler_returns_callable_via_mcp_false() {
4142        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
4143
4144        let result = reg
4145            .dispatch("recall.embed", serde_json::json!({ "help": true }))
4146            .await
4147            .expect("help=true on subhandler must succeed (no permission check on help path)");
4148
4149        assert_eq!(
4150            result["callable_via_mcp"],
4151            serde_json::json!(false),
4152            "subhandler help must carry callable_via_mcp: false"
4153        );
4154        assert_eq!(
4155            result["visibility"], "internal",
4156            "subhandler help must carry visibility: internal"
4157        );
4158        // The verb and pack fields must still be present so the caller knows
4159        // what the schema belongs to.
4160        assert_eq!(result["verb"], "recall.embed");
4161        assert_eq!(result["pack"], "helptest");
4162    }
4163
4164    /// Public Verb-visibility handlers must NOT have `callable_via_mcp: false`.
4165    #[tokio::test]
4166    async fn help_true_on_public_verb_does_not_have_callable_via_mcp_false() {
4167        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
4168
4169        let result = reg
4170            .dispatch("create", serde_json::json!({ "help": true }))
4171            .await
4172            .expect("help=true on public verb must succeed");
4173
4174        // callable_via_mcp must be absent or true for public verbs.
4175        assert_ne!(
4176            result.get("callable_via_mcp"),
4177            Some(&serde_json::json!(false)),
4178            "public verb help must NOT carry callable_via_mcp: false"
4179        );
4180        // visibility must be absent or 'public' (never 'internal') for public verbs.
4181        assert_ne!(
4182            result.get("visibility"),
4183            Some(&serde_json::json!("internal")),
4184            "public verb help must NOT carry visibility: internal"
4185        );
4186    }
4187
4188    /// help=true on an unknown verb returns an error (same behavior as normal dispatch).
4189    #[tokio::test]
4190    async fn help_true_on_unknown_verb_returns_error() {
4191        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
4192
4193        let err = reg
4194            .dispatch("nonexistent_verb", serde_json::json!({ "help": true }))
4195            .await
4196            .unwrap_err();
4197
4198        assert!(
4199            matches!(err, RuntimeError::InvalidInput(_)),
4200            "help=true on unknown verb must return InvalidInput, got {err:?}"
4201        );
4202        let msg = err.to_string();
4203        assert!(
4204            msg.contains("nonexistent_verb"),
4205            "error must name the unknown verb: {msg}"
4206        );
4207    }
4208
4209    /// Subhandler help must include params: [] even when the verb has no params.
4210    #[tokio::test]
4211    async fn help_true_on_subhandler_includes_params_field() {
4212        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
4213
4214        let result = reg
4215            .dispatch("recall.embed", serde_json::json!({ "help": true }))
4216            .await
4217            .expect("help=true on subhandler must succeed");
4218
4219        // params must always be present (H1: consistent shape).
4220        let params = result
4221            .get("params")
4222            .expect("subhandler help must include 'params' field");
4223        assert!(
4224            params.is_array(),
4225            "subhandler help params must be a JSON array"
4226        );
4227    }
4228
4229    // ── codex High: unknown-verb error must not leak subhandler names ─────────
4230
4231    /// `describe_verb` on an unknown verb must list only Verb-visibility names
4232    /// in the "available" list — never subhandler names like `recall.embed`
4233    /// (codex High — ue-help-introspection C1 / unknown-verb path).
4234    #[tokio::test]
4235    async fn help_true_unknown_verb_available_list_excludes_subhandlers() {
4236        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
4237
4238        let err = reg
4239            .dispatch("not_a_verb", serde_json::json!({ "help": true }))
4240            .await
4241            .unwrap_err();
4242
4243        let msg = err.to_string();
4244        // `recall.embed` is a Subhandler in HelpPack — must NOT appear in the
4245        // "available" list of an unknown-verb error.
4246        assert!(
4247            !msg.contains("recall.embed"),
4248            "unknown-verb help error must not advertise subhandler recall.embed: {msg}"
4249        );
4250        // Public verbs must still appear so the agent knows what to call.
4251        assert!(
4252            msg.contains("create"),
4253            "unknown-verb help error must still list public verb 'create': {msg}"
4254        );
4255        assert!(
4256            msg.contains("recall"),
4257            "unknown-verb help error must still list public verb 'recall': {msg}"
4258        );
4259    }
4260
4261    /// Normal dispatch on an unknown verb must also not leak subhandler names.
4262    #[tokio::test]
4263    async fn dispatch_unknown_verb_available_list_excludes_subhandlers() {
4264        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
4265
4266        let err = reg
4267            .dispatch("not_a_verb", serde_json::json!({}))
4268            .await
4269            .unwrap_err();
4270
4271        let msg = err.to_string();
4272        // `recall.embed` is a Subhandler in HelpPack — must NOT appear in the
4273        // "available" list of an unknown-verb dispatch error.
4274        assert!(
4275            !msg.contains("recall.embed"),
4276            "dispatch unknown-verb error must not advertise subhandler recall.embed: {msg}"
4277        );
4278        // Public verbs must still appear so the agent knows what to call.
4279        assert!(
4280            msg.contains("create"),
4281            "dispatch unknown-verb error must still list public verb 'create': {msg}"
4282        );
4283        assert!(
4284            msg.contains("recall"),
4285            "dispatch unknown-verb error must still list public verb 'recall': {msg}"
4286        );
4287    }
4288
4289    // ── ADR-028 multi-backend schema routing tests ───────────────────────────
4290
4291    /// A test pack that returns a real SchemaPlan so we can assert routing.
4292    struct SchemaPack {
4293        pack_name: &'static str,
4294        statements: &'static [&'static str],
4295    }
4296
4297    impl Pack for SchemaPack {
4298        const NAME: &'static str = "schema-pack";
4299        const NOTE_KINDS: &'static [&'static str] = &[];
4300        const ENTITY_KINDS: &'static [&'static str] = &[];
4301        const HANDLERS: &'static [HandlerDef] = &[];
4302    }
4303
4304    #[async_trait]
4305    impl PackRuntime for SchemaPack {
4306        fn name(&self) -> &str {
4307            self.pack_name
4308        }
4309        fn note_kinds(&self) -> &'static [&'static str] {
4310            &[]
4311        }
4312        fn entity_kinds(&self) -> &'static [&'static str] {
4313            &[]
4314        }
4315        fn handlers(&self) -> &'static [HandlerDef] {
4316            &[]
4317        }
4318        fn schema_plan(&self) -> SchemaPlan {
4319            SchemaPlan {
4320                pack: self.pack_name,
4321                statements: self.statements,
4322            }
4323        }
4324        async fn dispatch(
4325            &self,
4326            verb: &str,
4327            _params: Value,
4328            _registry: &VerbRegistry,
4329            _token: &NamespaceToken,
4330        ) -> Result<Value, RuntimeError> {
4331            Ok(serde_json::json!({ "pack": self.pack_name, "verb": verb }))
4332        }
4333    }
4334
4335    // ADR-028: all_schema_plans_named returns (pack_name, SchemaPlan) pairs
4336    // where pack_name comes from SchemaPlan::pack (always &'static str).
4337    #[test]
4338    fn all_schema_plans_named_returns_correct_pairs() {
4339        let mut builder = VerbRegistryBuilder::new();
4340        builder.register_boxed(Box::new(SchemaPack {
4341            pack_name: "alpha",
4342            statements: &["CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER PRIMARY KEY)"],
4343        }));
4344        builder.register_boxed(Box::new(SchemaPack {
4345            pack_name: "beta",
4346            statements: &[],
4347        }));
4348        let reg = builder.build().expect("registry builds");
4349
4350        let named = reg.all_schema_plans_named();
4351        assert_eq!(named.len(), 2);
4352
4353        let alpha_entry = named.iter().find(|(n, _)| *n == "alpha");
4354        let beta_entry = named.iter().find(|(n, _)| *n == "beta");
4355
4356        assert!(alpha_entry.is_some(), "alpha must appear in named plans");
4357        assert!(beta_entry.is_some(), "beta must appear in named plans");
4358
4359        let (_, alpha_plan) = alpha_entry.unwrap();
4360        assert_eq!(alpha_plan.statements.len(), 1);
4361        assert!(!alpha_plan.is_empty());
4362
4363        let (_, beta_plan) = beta_entry.unwrap();
4364        assert!(beta_plan.is_empty());
4365    }
4366
4367    // ADR-028: apply_schema_plans_with_map routes non-empty plans to the
4368    // correct per-pack backend instead of the default.
4369    //
4370    // Verification: apply DDL to routed backend, then confirm the table is
4371    // present on pack_backend and absent on default_backend by attempting to
4372    // apply the same DDL again — if the table already exists on pack_backend
4373    // the idempotent CREATE IF NOT EXISTS succeeds; applying to default_backend
4374    // would only matter if the table were routed there.  We verify isolation
4375    // by applying the plan and then running a targeted DDL on each backend
4376    // that would fail if the table did not already exist (CREATE without
4377    // IF NOT EXISTS on a duplicate raises an error), combined with a no-error
4378    // path on the correct backend.
4379    //
4380    // Simpler approach: confirm the plan applies without error (routing is
4381    // correct) and that the opposite backend returns an error when we try to
4382    // INSERT into the routed table (table-not-found = SQLITE_ERROR).
4383    #[tokio::test]
4384    async fn apply_schema_plans_with_map_routes_to_correct_backend() {
4385        use khive_storage::types::{SqlStatement, SqlValue};
4386
4387        let default_backend = khive_db::StorageBackend::memory().expect("default memory backend");
4388        let pack_backend =
4389            khive_db::StorageBackend::memory().expect("pack-specific memory backend");
4390
4391        let mut builder = VerbRegistryBuilder::new();
4392        builder.register_boxed(Box::new(SchemaPack {
4393            pack_name: "routed",
4394            statements: &["CREATE TABLE IF NOT EXISTS t_routed (id INTEGER PRIMARY KEY)"],
4395        }));
4396        let reg = builder.build().expect("registry builds");
4397
4398        let mut backend_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
4399        backend_map.insert("routed", &pack_backend);
4400
4401        reg.apply_schema_plans_with_map(&backend_map, &default_backend)
4402            .expect("schema application must not collide");
4403
4404        // On pack_backend: INSERT must succeed (table exists).
4405        let mut writer = pack_backend.sql().writer().await.expect("writer");
4406        let result = writer
4407            .execute(SqlStatement {
4408                sql: "INSERT INTO t_routed (id) VALUES (?1)".into(),
4409                params: vec![SqlValue::Integer(1)],
4410                label: None,
4411            })
4412            .await;
4413        assert!(
4414            result.is_ok(),
4415            "t_routed must exist on pack_backend after routing: {result:?}"
4416        );
4417
4418        // On default_backend: INSERT must fail (table not there).
4419        let mut default_writer = default_backend.sql().writer().await.expect("writer");
4420        let default_result = default_writer
4421            .execute(SqlStatement {
4422                sql: "INSERT INTO t_routed (id) VALUES (?1)".into(),
4423                params: vec![SqlValue::Integer(2)],
4424                label: None,
4425            })
4426            .await;
4427        assert!(
4428            default_result.is_err(),
4429            "t_routed must NOT exist on default_backend (table should not be there)"
4430        );
4431    }
4432
4433    // ADR-028: apply_schema_plans_with_map uses default backend for packs
4434    // absent from the map.
4435    #[tokio::test]
4436    async fn apply_schema_plans_with_map_falls_back_to_default_for_unmapped_packs() {
4437        use khive_storage::types::{SqlStatement, SqlValue};
4438
4439        let default_backend = khive_db::StorageBackend::memory().expect("default memory backend");
4440
4441        let mut builder = VerbRegistryBuilder::new();
4442        builder.register_boxed(Box::new(SchemaPack {
4443            pack_name: "unmapped",
4444            statements: &["CREATE TABLE IF NOT EXISTS t_unmapped (id INTEGER PRIMARY KEY)"],
4445        }));
4446        let reg = builder.build().expect("registry builds");
4447
4448        let backend_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
4449        reg.apply_schema_plans_with_map(&backend_map, &default_backend)
4450            .expect("schema application must not collide");
4451
4452        // On default_backend: INSERT must succeed (table fell back here).
4453        let mut writer = default_backend.sql().writer().await.expect("writer");
4454        let result = writer
4455            .execute(SqlStatement {
4456                sql: "INSERT INTO t_unmapped (id) VALUES (?1)".into(),
4457                params: vec![SqlValue::Integer(1)],
4458                label: None,
4459            })
4460            .await;
4461        assert!(
4462            result.is_ok(),
4463            "t_unmapped must exist on default_backend for unmapped pack: {result:?}"
4464        );
4465    }
4466
4467    // ADR-028 B-SHOULD-FIX-3: two packs declaring the same auxiliary table on
4468    // the same backend must cause apply_schema_plans_with_map to return an
4469    // error that names both packs and the table — it is a boot-time failure,
4470    // not a silent DDL race.
4471    #[test]
4472    fn apply_schema_plans_with_map_collision_is_an_error() {
4473        let backend = khive_db::StorageBackend::memory().expect("memory backend");
4474        let empty_map: HashMap<&str, &khive_db::StorageBackend> = HashMap::new();
4475
4476        let mut builder = VerbRegistryBuilder::new();
4477        builder.register_boxed(Box::new(SchemaPack {
4478            pack_name: "pack_alpha",
4479            statements: &["CREATE TABLE IF NOT EXISTS collision_table (id INTEGER PRIMARY KEY)"],
4480        }));
4481        builder.register_boxed(Box::new(SchemaPack {
4482            pack_name: "pack_beta",
4483            statements: &["CREATE TABLE IF NOT EXISTS collision_table (id INTEGER PRIMARY KEY)"],
4484        }));
4485        let registry = builder.build().expect("registry builds");
4486
4487        let result = registry.apply_schema_plans_with_map(&empty_map, &backend);
4488        assert!(
4489            result.is_err(),
4490            "two packs declaring the same table on the same backend must produce a collision error"
4491        );
4492        let err = result.unwrap_err();
4493        let msg = err.to_string();
4494        assert!(
4495            msg.contains("pack_alpha"),
4496            "collision error must name first pack; got: {msg}"
4497        );
4498        assert!(
4499            msg.contains("pack_beta"),
4500            "collision error must name second pack; got: {msg}"
4501        );
4502        assert!(
4503            msg.contains("collision_table"),
4504            "collision error must name the table; got: {msg}"
4505        );
4506    }
4507}