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