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    // ---- Rego gate: fail-closed end-to-end (issue #30) ----
1873
1874    /// A `RegoGate` whose policy lacks the named entrypoint rule must cause
1875    /// `VerbRegistry::dispatch` to return `RuntimeError::PermissionDenied` —
1876    /// never to proceed to the pack handler.
1877    ///
1878    /// This is the runtime-level assertion for the fix to security issue #30.
1879    /// `RegoGate::check` converts all evaluation failures (missing rule,
1880    /// undefined result, serialization error, poisoned engine) to
1881    /// `Ok(GateDecision::Deny)`, so dispatch is blocked. The runtime's
1882    /// fail-open `Err(_)` branch remains for non-evaluation gate errors
1883    /// (e.g. infrastructure faults from other `Gate` implementations).
1884    #[tokio::test]
1885    async fn rego_gate_missing_entrypoint_returns_permission_denied() {
1886        use khive_gate_rego::RegoGate;
1887
1888        // Policy defines `verdict` but NOT `data.khive.gate.decision` (the
1889        // default entrypoint).  Construction succeeds — from_policy_str does
1890        // not validate the default entrypoint.  check() must convert the
1891        // missing-rule evaluation error to Ok(Deny) so the runtime denies
1892        // the request rather than treating the Err as a fail-open signal.
1893        let policy = r#"
1894            package khive.gate
1895            import rego.v1
1896            verdict := "allow"
1897        "#;
1898        let gate = Arc::new(RegoGate::from_policy_str(policy).expect("policy compiles"));
1899
1900        let mut builder = VerbRegistryBuilder::new();
1901        builder.register(AlphaPack);
1902        builder.with_gate(gate);
1903        let reg = builder.build().expect("registry builds");
1904
1905        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
1906        assert!(
1907            matches!(err, RuntimeError::PermissionDenied { ref verb, .. } if verb == "create"),
1908            "expected PermissionDenied for missing rego entrypoint, got {err:?}"
1909        );
1910    }
1911
1912    // ---- Audit tracing emission ----
1913    //
1914    // The AuditCapturingGate tests above prove that AuditEvent::from_check is
1915    // called with the right inputs, but they observe the event *inside* the
1916    // gate impl — they would still pass if dispatch's
1917    // `tracing::info!(audit_event = ..., "gate.check")` were deleted or
1918    // renamed. The tests below install a capture Layer and assert on the
1919    // actual tracing event surfaced from dispatch. This locks the public
1920    // observability contract: one `gate.check` info event per dispatch,
1921    // carrying an `audit_event` field that round-trips back to an `AuditEvent`.
1922
1923    use std::sync::{Mutex as StdMutex, Once, OnceLock};
1924
1925    use serial_test::serial;
1926    use tracing::field::{Field, Visit};
1927
1928    #[derive(Clone, Debug, Default)]
1929    struct CapturedEvent {
1930        message: Option<String>,
1931        audit_event: Option<String>,
1932    }
1933
1934    #[derive(Default)]
1935    struct CapturedEventVisitor(CapturedEvent);
1936
1937    impl Visit for CapturedEventVisitor {
1938        fn record_str(&mut self, field: &Field, value: &str) {
1939            match field.name() {
1940                "message" => self.0.message = Some(value.to_string()),
1941                "audit_event" => self.0.audit_event = Some(value.to_string()),
1942                _ => {}
1943            }
1944        }
1945
1946        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
1947            // `tracing::info!(audit_event = %expr, "msg")` records via the
1948            // Display-wrapped Debug path, so we receive the JSON string here.
1949            // `"msg"` literal records as a `message` field via `record_debug`
1950            // with a quoted Debug representation; strip the surrounding quotes
1951            // so the captured message matches the source.
1952            let formatted = format!("{value:?}");
1953            let cleaned = formatted
1954                .trim_start_matches('"')
1955                .trim_end_matches('"')
1956                .to_string();
1957            match field.name() {
1958                "message" => self.0.message = Some(cleaned),
1959                "audit_event" => self.0.audit_event = Some(cleaned),
1960                _ => {}
1961            }
1962        }
1963    }
1964
1965    /// Minimal `tracing::Subscriber` that captures events into a shared vec.
1966    ///
1967    /// Implemented directly (without `tracing_subscriber::registry()` layering)
1968    /// to avoid the layer machinery that can cause thread-local dispatch to be
1969    /// bypassed when the registry's internal global state is initialised by
1970    /// another subscriber in the same test binary.
1971    ///
1972    /// Isolation across concurrent tests is handled at the dispatcher level by
1973    /// `tracing::dispatcher::with_default`, which installs this subscriber
1974    /// as the thread-local default for the duration of the test closure.
1975    /// Other threads (e.g. `#[tokio::test]` pool workers) emit through their
1976    /// own (typically NoSubscriber) dispatchers and never reach this instance.
1977    struct CaptureSubscriber {
1978        events: Arc<StdMutex<Vec<CapturedEvent>>>,
1979    }
1980
1981    impl CaptureSubscriber {
1982        fn new(events: Arc<StdMutex<Vec<CapturedEvent>>>) -> Self {
1983            Self { events }
1984        }
1985    }
1986
1987    impl tracing::Subscriber for CaptureSubscriber {
1988        fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
1989            true
1990        }
1991        fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1992            tracing::span::Id::from_u64(1)
1993        }
1994        fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
1995        fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
1996        fn event(&self, event: &tracing::Event<'_>) {
1997            let mut visitor = CapturedEventVisitor::default();
1998            event.record(&mut visitor);
1999            self.events.lock().unwrap().push(visitor.0);
2000        }
2001        fn enter(&self, _: &tracing::span::Id) {}
2002        fn exit(&self, _: &tracing::span::Id) {}
2003    }
2004
2005    /// Global capture buffer for the tracing tests.
2006    ///
2007    /// The subscriber is installed exactly once via `set_global_default`
2008    /// (thread-local dispatchers via `with_default` proved unreliable when
2009    /// other tests in the binary configure their own dispatchers in parallel —
2010    /// the global state interacted unpredictably and events were lost).
2011    ///
2012    /// Each test that uses this buffer is `#[serial]`, so only one
2013    /// runs at a time. The buffer is cleared at the start of each capture call.
2014    static GLOBAL_CAPTURE: OnceLock<Arc<StdMutex<Vec<CapturedEvent>>>> = OnceLock::new();
2015    static GLOBAL_INIT: Once = Once::new();
2016
2017    fn global_capture() -> Arc<StdMutex<Vec<CapturedEvent>>> {
2018        GLOBAL_INIT.call_once(|| {
2019            let buffer = Arc::new(StdMutex::new(Vec::new()));
2020            let subscriber = CaptureSubscriber::new(Arc::clone(&buffer));
2021            // Ignore error: if another subscriber is already set globally, our
2022            // subscriber installation fails, but the buffer will simply stay
2023            // empty and tests will fail with a clear "got 0 events" message
2024            // rather than a silent corruption.
2025            let _ = tracing::subscriber::set_global_default(subscriber);
2026            let _ = GLOBAL_CAPTURE.set(buffer);
2027        });
2028        Arc::clone(GLOBAL_CAPTURE.get().expect("global capture initialized"))
2029    }
2030
2031    /// Run an async block under the global capture subscriber and return
2032    /// the events emitted during the run. Clears the buffer at the start.
2033    ///
2034    /// Callers MUST be `#[serial]` to prevent concurrent buffer pollution.
2035    fn capture_dispatch_events<Fut>(future: Fut) -> Vec<CapturedEvent>
2036    where
2037        Fut: std::future::Future<Output = ()>,
2038    {
2039        let buffer = global_capture();
2040        buffer.lock().unwrap().clear();
2041
2042        let rt = tokio::runtime::Builder::new_current_thread()
2043            .enable_all()
2044            .build()
2045            .expect("build current-thread tokio runtime");
2046        rt.block_on(future);
2047
2048        let result = buffer.lock().unwrap().clone();
2049        result
2050    }
2051
2052    /// Pull every captured event whose `message` matches `"gate.check"` AND
2053    /// whose audit_event JSON declares the expected `gate_impl` name.
2054    ///
2055    /// Filtering by `gate_impl` lets concurrent tests in the same binary
2056    /// emit their own gate.check events into the global capture buffer
2057    /// without polluting each others' counts.
2058    fn gate_check_events_for(events: &[CapturedEvent], gate_impl: &str) -> Vec<CapturedEvent> {
2059        events
2060            .iter()
2061            .filter(|e| e.message.as_deref() == Some("gate.check"))
2062            .filter(|e| {
2063                e.audit_event
2064                    .as_deref()
2065                    .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
2066                    .and_then(|v| {
2067                        v.get("gate_impl")
2068                            .and_then(|g| g.as_str().map(|s| s.to_string()))
2069                    })
2070                    .as_deref()
2071                    == Some(gate_impl)
2072            })
2073            .cloned()
2074            .collect()
2075    }
2076
2077    #[test]
2078    #[serial]
2079    fn dispatch_tracing_emits_one_gate_check_event_on_allow() {
2080        #[derive(Debug)]
2081        struct TracingAllowGate;
2082        impl Gate for TracingAllowGate {
2083            fn check(&self, _: &GateRequest) -> Result<GateDecision, GateError> {
2084                Ok(GateDecision::allow())
2085            }
2086            fn impl_name(&self) -> &'static str {
2087                "TracingAllowGate"
2088            }
2089        }
2090
2091        let events = capture_dispatch_events(async {
2092            let mut builder = VerbRegistryBuilder::new();
2093            builder.register(AlphaPack);
2094            builder.with_gate(Arc::new(TracingAllowGate));
2095            builder.with_default_namespace("tenant-default");
2096            let reg = builder.build().expect("registry builds");
2097            reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
2098                .await
2099                .unwrap();
2100        });
2101
2102        let gate_events = gate_check_events_for(&events, "TracingAllowGate");
2103        assert_eq!(
2104            gate_events.len(),
2105            1,
2106            "exactly one gate.check tracing event per dispatch (allow); got {gate_events:?}"
2107        );
2108        let payload = gate_events[0]
2109            .audit_event
2110            .as_ref()
2111            .expect("gate.check event must carry an audit_event field");
2112        let audit: khive_gate::AuditEvent =
2113            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
2114        assert_eq!(audit.decision, AuditDecision::Allow);
2115        assert_eq!(audit.verb, "list");
2116        assert_eq!(audit.namespace, "tenant-q");
2117        assert_eq!(audit.gate_impl, "TracingAllowGate");
2118        assert!(
2119            audit.deny_reason.is_none(),
2120            "deny_reason must be None on Allow"
2121        );
2122    }
2123
2124    // ---- Hard enforcement + EventStore persistence ----
2125
2126    use crate::runtime::NamespaceToken;
2127    use async_trait::async_trait;
2128    use khive_storage::{
2129        BatchWriteSummary, Event, EventFilter, EventStore, Page, PageRequest, SubstrateKind,
2130    };
2131    use khive_types::EventOutcome;
2132
2133    /// In-memory EventStore for unit tests — avoids file-backed SQLite.
2134    #[derive(Default, Debug)]
2135    struct MemoryEventStore {
2136        events: std::sync::Mutex<Vec<Event>>,
2137    }
2138
2139    #[async_trait]
2140    impl EventStore for MemoryEventStore {
2141        async fn append_event(&self, event: Event) -> khive_storage::StorageResult<()> {
2142            self.events.lock().unwrap().push(event);
2143            Ok(())
2144        }
2145        async fn append_events(
2146            &self,
2147            events: Vec<Event>,
2148        ) -> khive_storage::StorageResult<BatchWriteSummary> {
2149            let attempted = events.len() as u64;
2150            let affected = attempted;
2151            self.events.lock().unwrap().extend(events);
2152            Ok(BatchWriteSummary {
2153                attempted,
2154                affected,
2155                failed: 0,
2156                first_error: String::new(),
2157            })
2158        }
2159        async fn get_event(&self, id: uuid::Uuid) -> khive_storage::StorageResult<Option<Event>> {
2160            Ok(self
2161                .events
2162                .lock()
2163                .unwrap()
2164                .iter()
2165                .find(|e| e.id == id)
2166                .cloned())
2167        }
2168        async fn query_events(
2169            &self,
2170            _filter: EventFilter,
2171            _page: PageRequest,
2172        ) -> khive_storage::StorageResult<Page<Event>> {
2173            let items = self.events.lock().unwrap().clone();
2174            let total = items.len() as u64;
2175            Ok(Page {
2176                items,
2177                total: Some(total),
2178            })
2179        }
2180        async fn count_events(&self, _filter: EventFilter) -> khive_storage::StorageResult<u64> {
2181            Ok(self.events.lock().unwrap().len() as u64)
2182        }
2183    }
2184
2185    #[tokio::test]
2186    async fn allow_all_gate_default_remains_backward_compatible() {
2187        // No gate set — AllowAllGate is the default. Dispatch must succeed.
2188        let mut builder = VerbRegistryBuilder::new();
2189        builder.register(AlphaPack);
2190        let reg = builder.build().expect("registry builds");
2191
2192        let res = reg.dispatch("list", Value::Null).await.unwrap();
2193        assert_eq!(
2194            res["pack"], "alpha",
2195            "AllowAllGate must allow every verb — backward compat guarantee"
2196        );
2197        let res = reg.dispatch("create", Value::Null).await.unwrap();
2198        assert_eq!(res["pack"], "alpha");
2199    }
2200
2201    #[tokio::test]
2202    async fn deny_gate_returns_permission_denied_pack_never_invoked() {
2203        #[derive(Debug)]
2204        struct AlwaysDenyGate;
2205        impl Gate for AlwaysDenyGate {
2206            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2207                Ok(GateDecision::deny("test: always deny"))
2208            }
2209        }
2210
2211        // Track whether dispatch was ever invoked on the pack.
2212        #[derive(Debug)]
2213        struct TrackedPack {
2214            invoked: Arc<AtomicUsize>,
2215        }
2216
2217        impl khive_types::Pack for TrackedPack {
2218            const NAME: &'static str = "tracked";
2219            const NOTE_KINDS: &'static [&'static str] = &[];
2220            const ENTITY_KINDS: &'static [&'static str] = &[];
2221            const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
2222                name: "guarded",
2223                description: "a guarded verb",
2224                visibility: Visibility::Verb,
2225                category: VerbCategory::Assertive,
2226                params: &[],
2227            }];
2228        }
2229
2230        #[async_trait]
2231        impl PackRuntime for TrackedPack {
2232            fn name(&self) -> &str {
2233                Self::NAME
2234            }
2235            fn note_kinds(&self) -> &'static [&'static str] {
2236                Self::NOTE_KINDS
2237            }
2238            fn entity_kinds(&self) -> &'static [&'static str] {
2239                Self::ENTITY_KINDS
2240            }
2241            fn handlers(&self) -> &'static [HandlerDef] {
2242                Self::HANDLERS
2243            }
2244            async fn dispatch(
2245                &self,
2246                _verb: &str,
2247                _params: Value,
2248                _registry: &VerbRegistry,
2249                _token: &NamespaceToken,
2250            ) -> Result<Value, RuntimeError> {
2251                self.invoked.fetch_add(1, Ordering::SeqCst);
2252                Ok(serde_json::json!({"invoked": true}))
2253            }
2254        }
2255
2256        let invoked = Arc::new(AtomicUsize::new(0));
2257        let mut builder = VerbRegistryBuilder::new();
2258        builder.register(TrackedPack {
2259            invoked: invoked.clone(),
2260        });
2261        builder.with_gate(Arc::new(AlwaysDenyGate));
2262        let reg = builder.build().expect("registry builds");
2263
2264        let err = reg.dispatch("guarded", Value::Null).await.unwrap_err();
2265        assert!(
2266            matches!(err, RuntimeError::PermissionDenied { ref verb, ref reason } if verb == "guarded" && reason.contains("always deny")),
2267            "expected PermissionDenied with verb=guarded and reason, got: {err:?}"
2268        );
2269        assert_eq!(
2270            invoked.load(Ordering::SeqCst),
2271            0,
2272            "pack dispatch MUST NOT be invoked when gate denies"
2273        );
2274    }
2275
2276    #[tokio::test]
2277    async fn audit_event_persists_to_event_store_on_allow() {
2278        let store = Arc::new(MemoryEventStore::default());
2279        let mut builder = VerbRegistryBuilder::new();
2280        builder.register(AlphaPack);
2281        builder.with_event_store(store.clone());
2282        let reg = builder.build().expect("registry builds");
2283
2284        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
2285            .await
2286            .unwrap();
2287
2288        let count = store.count_events(EventFilter::default()).await.unwrap();
2289        assert_eq!(count, 1, "one audit event persisted to EventStore on allow");
2290
2291        let page = store
2292            .query_events(
2293                EventFilter::default(),
2294                PageRequest {
2295                    limit: 10,
2296                    offset: 0,
2297                },
2298            )
2299            .await
2300            .unwrap();
2301        let ev = &page.items[0];
2302        assert_eq!(ev.verb, "list");
2303        assert_eq!(ev.namespace, "test-ns");
2304        assert_eq!(ev.substrate, SubstrateKind::Event);
2305        assert_eq!(ev.outcome, EventOutcome::Success);
2306    }
2307
2308    #[tokio::test]
2309    async fn audit_event_persists_to_event_store_on_deny() {
2310        #[derive(Debug)]
2311        struct AlwaysDenyGate;
2312        impl Gate for AlwaysDenyGate {
2313            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2314                Ok(GateDecision::deny("denied by test"))
2315            }
2316        }
2317
2318        let store = Arc::new(MemoryEventStore::default());
2319        let mut builder = VerbRegistryBuilder::new();
2320        builder.register(AlphaPack);
2321        builder.with_gate(Arc::new(AlwaysDenyGate));
2322        builder.with_event_store(store.clone());
2323        let reg = builder.build().expect("registry builds");
2324
2325        // Hard enforce → PermissionDenied returned.
2326        let err = reg
2327            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
2328            .await
2329            .unwrap_err();
2330        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
2331
2332        let count = store.count_events(EventFilter::default()).await.unwrap();
2333        assert_eq!(count, 1, "one audit event persisted to EventStore on deny");
2334
2335        let page = store
2336            .query_events(
2337                EventFilter::default(),
2338                PageRequest {
2339                    limit: 10,
2340                    offset: 0,
2341                },
2342            )
2343            .await
2344            .unwrap();
2345        let ev = &page.items[0];
2346        assert_eq!(ev.verb, "list");
2347        assert_eq!(ev.outcome, EventOutcome::Denied);
2348    }
2349
2350    #[tokio::test]
2351    async fn gate_error_does_not_persist_to_event_store() {
2352        #[derive(Debug)]
2353        struct FailingGate;
2354        impl Gate for FailingGate {
2355            fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
2356                Err(khive_gate::GateError::Internal("gate broken".into()))
2357            }
2358        }
2359
2360        let store = Arc::new(MemoryEventStore::default());
2361        let mut builder = VerbRegistryBuilder::new();
2362        builder.register(AlphaPack);
2363        builder.with_gate(Arc::new(FailingGate));
2364        builder.with_event_store(store.clone());
2365        let reg = builder.build().expect("registry builds");
2366
2367        // Gate Err → fail-open, dispatch proceeds.
2368        let res = reg.dispatch("list", Value::Null).await.unwrap();
2369        assert_eq!(
2370            res["pack"], "alpha",
2371            "gate error must fail-open, not block dispatch"
2372        );
2373
2374        let count = store.count_events(EventFilter::default()).await.unwrap();
2375        assert_eq!(
2376            count, 0,
2377            "gate infrastructure error must NOT produce an audit event in EventStore"
2378        );
2379    }
2380
2381    #[tokio::test]
2382    async fn no_event_store_configured_tracing_only() {
2383        // When no event_store is configured, dispatch must succeed without error.
2384        // (The tracing path is exercised in the tracing tests above; here we just
2385        // verify the absence of event_store does not break dispatch.)
2386        let mut builder = VerbRegistryBuilder::new();
2387        builder.register(AlphaPack);
2388        let reg = builder.build().expect("registry builds");
2389
2390        let res = reg.dispatch("list", Value::Null).await.unwrap();
2391        assert_eq!(res["pack"], "alpha");
2392    }
2393
2394    #[test]
2395    #[serial]
2396    fn dispatch_tracing_emits_gate_check_event_with_deny_payload() {
2397        #[derive(Debug)]
2398        struct TracingDenyGate;
2399        impl Gate for TracingDenyGate {
2400            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2401                Ok(GateDecision::deny("denied by test gate"))
2402            }
2403            fn impl_name(&self) -> &'static str {
2404                "TracingDenyGate"
2405            }
2406        }
2407
2408        let events = capture_dispatch_events(async {
2409            let mut builder = VerbRegistryBuilder::new();
2410            builder.register(AlphaPack);
2411            builder.with_gate(Arc::new(TracingDenyGate));
2412            let reg = builder.build().expect("registry builds");
2413            // Hard enforcement — dispatch returns PermissionDenied on Deny.
2414            // The tracing audit event is still emitted before the error is returned.
2415            let _ = reg.dispatch("create", serde_json::Value::Null).await;
2416        });
2417
2418        let gate_events = gate_check_events_for(&events, "TracingDenyGate");
2419        assert_eq!(
2420            gate_events.len(),
2421            1,
2422            "exactly one gate.check tracing event per dispatch (deny); got {gate_events:?}"
2423        );
2424        let payload = gate_events[0]
2425            .audit_event
2426            .as_ref()
2427            .expect("gate.check event must carry an audit_event field on Deny");
2428        let audit: khive_gate::AuditEvent =
2429            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
2430        assert_eq!(audit.decision, AuditDecision::Deny);
2431        assert_eq!(audit.deny_reason.as_deref(), Some("denied by test gate"));
2432        assert_eq!(audit.gate_impl, "TracingDenyGate");
2433        // Wire-shape rule: obligations is always serialized as an array, empty
2434        // on Deny. Round-trip back through serde_json::Value to confirm the
2435        // field exists on the wire and is `[]`, not missing.
2436        let payload_json: serde_json::Value =
2437            serde_json::from_str(payload).expect("payload must be valid JSON");
2438        assert_eq!(
2439            payload_json["obligations"],
2440            serde_json::Value::Array(Vec::new()),
2441            "obligations must be `[]` on Deny on the tracing payload, not omitted"
2442        );
2443    }
2444
2445    // ---- EventStore audit envelope round-trip ----
2446    //
2447    // Codex review finding (Major #1): EventStore was persisting a summary
2448    // Event without the full AuditEvent fields (deny_reason, gate_impl,
2449    // obligations). This test verifies the complete envelope survives
2450    // append_event → query_events.
2451
2452    #[tokio::test]
2453    async fn audit_envelope_round_trips_deny_reason_and_gate_impl_through_event_store() {
2454        #[derive(Debug)]
2455        struct DenyGateWithName;
2456        impl Gate for DenyGateWithName {
2457            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2458                Ok(GateDecision::deny("policy: write forbidden for anon"))
2459            }
2460            fn impl_name(&self) -> &'static str {
2461                "DenyGateWithName"
2462            }
2463        }
2464
2465        let store = Arc::new(MemoryEventStore::default());
2466        let mut builder = VerbRegistryBuilder::new();
2467        builder.register(AlphaPack);
2468        builder.with_gate(Arc::new(DenyGateWithName));
2469        builder.with_event_store(store.clone());
2470        let reg = builder.build().expect("registry builds");
2471
2472        // Dispatch is denied — PermissionDenied returned.
2473        let err = reg
2474            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
2475            .await
2476            .unwrap_err();
2477        assert!(
2478            matches!(err, RuntimeError::PermissionDenied { .. }),
2479            "expected PermissionDenied, got {err:?}"
2480        );
2481
2482        // Exactly one event in the store.
2483        let page = store
2484            .query_events(
2485                EventFilter::default(),
2486                PageRequest {
2487                    limit: 10,
2488                    offset: 0,
2489                },
2490            )
2491            .await
2492            .unwrap();
2493        assert_eq!(
2494            page.items.len(),
2495            1,
2496            "one audit event must be persisted on deny"
2497        );
2498
2499        let ev = &page.items[0];
2500        assert_eq!(ev.outcome, EventOutcome::Denied);
2501
2502        // The payload field must hold the full AuditEvent envelope.
2503        let data = &ev.payload;
2504
2505        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
2506            .expect("Event.payload must deserialize to AuditEvent");
2507
2508        assert_eq!(
2509            audit.deny_reason.as_deref(),
2510            Some("policy: write forbidden for anon"),
2511            "deny_reason must be preserved through EventStore"
2512        );
2513        assert_eq!(
2514            audit.gate_impl, "DenyGateWithName",
2515            "gate_impl must be preserved through EventStore"
2516        );
2517        assert_eq!(
2518            audit.decision,
2519            khive_gate::AuditDecision::Deny,
2520            "decision field must be preserved through EventStore"
2521        );
2522    }
2523
2524    #[tokio::test]
2525    async fn audit_envelope_round_trips_obligations_through_event_store() {
2526        use khive_gate::Obligation;
2527
2528        #[derive(Debug)]
2529        struct ObligationGate;
2530        impl Gate for ObligationGate {
2531            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2532                Ok(GateDecision::allow_with(vec![Obligation::Audit {
2533                    tag: "billing.meter".into(),
2534                }]))
2535            }
2536            fn impl_name(&self) -> &'static str {
2537                "ObligationGate"
2538            }
2539        }
2540
2541        let store = Arc::new(MemoryEventStore::default());
2542        let mut builder = VerbRegistryBuilder::new();
2543        builder.register(AlphaPack);
2544        builder.with_gate(Arc::new(ObligationGate));
2545        builder.with_event_store(store.clone());
2546        let reg = builder.build().expect("registry builds");
2547
2548        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
2549            .await
2550            .unwrap();
2551
2552        let page = store
2553            .query_events(
2554                EventFilter::default(),
2555                PageRequest {
2556                    limit: 10,
2557                    offset: 0,
2558                },
2559            )
2560            .await
2561            .unwrap();
2562        assert_eq!(page.items.len(), 1);
2563
2564        let ev = &page.items[0];
2565        assert_eq!(ev.outcome, EventOutcome::Success);
2566
2567        let data = &ev.payload;
2568
2569        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
2570            .expect("Event.payload must deserialize to AuditEvent");
2571
2572        assert_eq!(audit.gate_impl, "ObligationGate");
2573        assert_eq!(
2574            audit.obligations.len(),
2575            1,
2576            "obligations must be preserved through EventStore"
2577        );
2578        match &audit.obligations[0] {
2579            Obligation::Audit { tag } => assert_eq!(tag, "billing.meter"),
2580            other => panic!("expected Audit obligation, got {other:?}"),
2581        }
2582    }
2583
2584    // ---- SQL-backed audit envelope round-trip (codex r2) ----
2585    //
2586    // The two tests above use MemoryEventStore (no serialization). This test
2587    // wires the production SqlEventStore via KhiveRuntime::memory() to verify
2588    // that the full AuditEvent envelope survives the SQL text→parse round-trip
2589    // (Event.data is stored as TEXT and parsed back on read).
2590
2591    #[tokio::test]
2592    async fn sql_backed_audit_envelope_round_trips_deny_reason_gate_impl_and_obligations() {
2593        #[derive(Debug)]
2594        struct SqlTestDenyGate;
2595        impl Gate for SqlTestDenyGate {
2596            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2597                Ok(GateDecision::deny("sql-path: write denied"))
2598            }
2599            fn impl_name(&self) -> &'static str {
2600                "SqlTestDenyGate"
2601            }
2602        }
2603
2604        // KhiveRuntime::memory() creates an in-memory SQLite pool (is_file_backed=false).
2605        // events_for_namespace ensures the events schema and returns a SqlEventStore
2606        // scoped to "test-ns". The pool is shared so reads and writes see the same data.
2607        let rt = KhiveRuntime::memory().expect("in-memory runtime");
2608        let test_tok = NamespaceToken::for_namespace(Namespace::parse("test-ns").unwrap());
2609        let sql_store = rt
2610            .events(&test_tok)
2611            .expect("events_for_namespace must succeed");
2612
2613        let mut builder = VerbRegistryBuilder::new();
2614        builder.register(AlphaPack);
2615        builder.with_gate(Arc::new(SqlTestDenyGate));
2616        builder.with_event_store(sql_store.clone());
2617        let reg = builder.build().expect("registry builds");
2618
2619        // Dispatch is denied — PermissionDenied returned.
2620        let err = reg
2621            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
2622            .await
2623            .unwrap_err();
2624        assert!(
2625            matches!(err, RuntimeError::PermissionDenied { .. }),
2626            "expected PermissionDenied, got {err:?}"
2627        );
2628
2629        // Query via the same SqlEventStore — this is the SQL read path.
2630        let page = sql_store
2631            .query_events(
2632                EventFilter::default(),
2633                PageRequest {
2634                    limit: 10,
2635                    offset: 0,
2636                },
2637            )
2638            .await
2639            .unwrap();
2640        assert_eq!(
2641            page.items.len(),
2642            1,
2643            "one audit event must be persisted on deny through SqlEventStore"
2644        );
2645
2646        let ev = &page.items[0];
2647        assert_eq!(ev.outcome, EventOutcome::Denied);
2648
2649        // Event.payload must hold the full AuditEvent serialized as JSON text and
2650        // parsed back. If the SQL path was lossy, this deserialization would fail
2651        // or the field assertions below would fail.
2652        let data = &ev.payload;
2653
2654        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
2655            .expect("Event.payload must deserialize to AuditEvent after SQL round-trip");
2656
2657        assert_eq!(
2658            audit.deny_reason.as_deref(),
2659            Some("sql-path: write denied"),
2660            "deny_reason must survive the SQL text round-trip"
2661        );
2662        assert_eq!(
2663            audit.gate_impl, "SqlTestDenyGate",
2664            "gate_impl must survive the SQL text round-trip"
2665        );
2666        assert_eq!(
2667            audit.decision,
2668            khive_gate::AuditDecision::Deny,
2669            "decision field must survive the SQL text round-trip"
2670        );
2671        // obligations is [] on a Deny gate (no obligations returned).
2672        // Verify the field is present and empty after SQL round-trip.
2673        assert!(
2674            audit.obligations.is_empty(),
2675            "obligations must be preserved as empty [] through SQL round-trip"
2676        );
2677    }
2678
2679    // ---- SQL-backed audit envelope: non-empty obligations survive round-trip ----
2680    //
2681    // Codex r3 identified a blind spot: the deny-path SQL test above only
2682    // asserts obligations == [], which passes even if the SQL path drops the
2683    // field entirely (AuditEvent.obligations has #[serde(default)]).
2684    //
2685    // This test installs an allow-path gate that returns a non-empty obligations
2686    // vec. After dispatch, the same SqlEventStore is queried and both layers are
2687    // checked:
2688    //   1. Raw Event.data["obligations"] is a non-empty JSON array.
2689    //   2. Deserialized AuditEvent.obligations[0] matches the expected variant.
2690    #[tokio::test]
2691    async fn sql_backed_audit_envelope_round_trips_non_empty_obligations() {
2692        use khive_gate::Obligation;
2693
2694        #[derive(Debug)]
2695        struct SqlTestAllowWithObligationGate;
2696        impl Gate for SqlTestAllowWithObligationGate {
2697            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2698                Ok(GateDecision::allow_with(vec![Obligation::Audit {
2699                    tag: "sql-path-billing.meter".into(),
2700                }]))
2701            }
2702            fn impl_name(&self) -> &'static str {
2703                "SqlTestAllowWithObligationGate"
2704            }
2705        }
2706
2707        let rt = KhiveRuntime::memory().expect("in-memory runtime");
2708        let test_tok = NamespaceToken::for_namespace(Namespace::parse("test-ns").unwrap());
2709        let sql_store = rt
2710            .events(&test_tok)
2711            .expect("events_for_namespace must succeed");
2712
2713        let mut builder = VerbRegistryBuilder::new();
2714        builder.register(AlphaPack);
2715        builder.with_gate(Arc::new(SqlTestAllowWithObligationGate));
2716        builder.with_event_store(sql_store.clone());
2717        let reg = builder.build().expect("registry builds");
2718
2719        // Dispatch succeeds — the gate allows with obligations.
2720        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
2721            .await
2722            .expect("dispatch must succeed when gate allows");
2723
2724        // Query via the same SqlEventStore — this is the SQL read path.
2725        let page = sql_store
2726            .query_events(
2727                EventFilter::default(),
2728                PageRequest {
2729                    limit: 10,
2730                    offset: 0,
2731                },
2732            )
2733            .await
2734            .unwrap();
2735        assert_eq!(
2736            page.items.len(),
2737            1,
2738            "one audit event must be persisted on allow through SqlEventStore"
2739        );
2740
2741        let ev = &page.items[0];
2742        assert_eq!(ev.outcome, EventOutcome::Success);
2743
2744        let data = &ev.payload;
2745
2746        // Layer 1: raw JSON check — obligations must be a non-empty array in
2747        // the persisted TEXT. If the SQL path dropped the field, the default
2748        // #[serde(default)] would silently deserialize it to [], so we verify
2749        // the raw JSON before deserializing.
2750        let obligations_raw = data
2751            .get("obligations")
2752            .expect("Event.data JSON must contain 'obligations' key");
2753        let obligations_arr = obligations_raw
2754            .as_array()
2755            .expect("'obligations' must be a JSON array");
2756        assert!(
2757            !obligations_arr.is_empty(),
2758            "raw Event.data['obligations'] must be non-empty after SQL round-trip"
2759        );
2760
2761        // Layer 2: deserialized AuditEvent check — the obligation variant and
2762        // payload must survive the text round-trip faithfully.
2763        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
2764            .expect("Event.data must deserialize to AuditEvent after SQL round-trip");
2765
2766        assert_eq!(
2767            audit.gate_impl, "SqlTestAllowWithObligationGate",
2768            "gate_impl must survive the SQL text round-trip"
2769        );
2770        assert_eq!(
2771            audit.decision,
2772            khive_gate::AuditDecision::Allow,
2773            "decision field must survive the SQL text round-trip"
2774        );
2775        assert_eq!(
2776            audit.obligations.len(),
2777            1,
2778            "obligations must be non-empty after SQL round-trip (not silently defaulted to [])"
2779        );
2780        match &audit.obligations[0] {
2781            Obligation::Audit { tag } => assert_eq!(
2782                tag, "sql-path-billing.meter",
2783                "Audit obligation tag must survive the SQL text round-trip"
2784            ),
2785            other => panic!("expected Audit obligation, got {other:?}"),
2786        }
2787    }
2788
2789    // ---- Audit payload shape for 'create' verb dispatch ----
2790    //
2791    // The previous audit tests verify the envelope shape for the 'list' verb.
2792    // This test dispatches 'create' (matching the create_note + annotates path)
2793    // and verifies that ev.verb, ev.outcome, and ev.data all round-trip correctly
2794    // through the EventStore. Ensures the wire shape is independent of which verb
2795    // triggers the gate check.
2796    #[tokio::test]
2797    async fn audit_event_payload_shape_for_create_verb() {
2798        let store = Arc::new(MemoryEventStore::default());
2799        let mut builder = VerbRegistryBuilder::new();
2800        builder.register(AlphaPack);
2801        builder.with_event_store(store.clone());
2802        builder.with_default_namespace("test-ns");
2803        let reg = builder.build().expect("registry builds");
2804
2805        // Dispatch 'create' — AlphaPack returns a stub value; what matters is
2806        // the EventStore entry emitted by the registry's gate-check path.
2807        reg.dispatch("create", serde_json::json!({"namespace": "test-ns"}))
2808            .await
2809            .unwrap();
2810
2811        let count = store.count_events(EventFilter::default()).await.unwrap();
2812        assert_eq!(count, 1, "exactly one audit event for one dispatch");
2813
2814        let page = store
2815            .query_events(
2816                EventFilter::default(),
2817                PageRequest {
2818                    limit: 10,
2819                    offset: 0,
2820                },
2821            )
2822            .await
2823            .unwrap();
2824        let ev = &page.items[0];
2825
2826        // Top-level Event fields.
2827        assert_eq!(ev.verb, "create", "ev.verb must be the dispatched verb");
2828        assert_eq!(
2829            ev.outcome,
2830            EventOutcome::Success,
2831            "ev.outcome must be Success on allow"
2832        );
2833        assert_eq!(
2834            ev.namespace, "test-ns",
2835            "ev.namespace must match the dispatch namespace"
2836        );
2837
2838        // ev.payload must hold the full AuditEvent envelope.
2839        let data = &ev.payload;
2840
2841        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
2842            .expect("ev.payload must deserialize to AuditEvent");
2843
2844        assert_eq!(
2845            audit.decision,
2846            khive_gate::AuditDecision::Allow,
2847            "AuditEvent.decision must be Allow"
2848        );
2849        assert_eq!(audit.verb, "create", "AuditEvent.verb must be 'create'");
2850        assert_eq!(
2851            audit.namespace, "test-ns",
2852            "AuditEvent.namespace must be preserved"
2853        );
2854        assert_eq!(
2855            audit.gate_impl, "AllowAllGate",
2856            "AuditEvent.gate_impl must name the gate implementation"
2857        );
2858        assert!(
2859            audit.deny_reason.is_none(),
2860            "AuditEvent.deny_reason must be None on Allow"
2861        );
2862        // Wire-shape check: obligations serializes as [] on AllowAllGate.
2863        let payload_json: serde_json::Value =
2864            serde_json::from_value(data.clone()).expect("data must be valid JSON");
2865        assert_eq!(
2866            payload_json["obligations"],
2867            serde_json::Value::Array(Vec::new()),
2868            "obligations must be [] on AllowAllGate"
2869        );
2870    }
2871
2872    // #282: registry audit event must carry target_id when dispatch params include it.
2873    #[tokio::test]
2874    async fn audit_event_threads_target_id_from_dispatch_args() {
2875        let store = Arc::new(MemoryEventStore::default());
2876        let target = uuid::Uuid::new_v4();
2877        let mut builder = VerbRegistryBuilder::new();
2878        builder.register(AlphaPack);
2879        builder.with_event_store(store.clone());
2880        builder.with_default_namespace("test-ns");
2881        let reg = builder.build().expect("registry builds");
2882
2883        reg.dispatch(
2884            "create",
2885            serde_json::json!({"namespace": "test-ns", "target_id": target}),
2886        )
2887        .await
2888        .unwrap();
2889
2890        let page = store
2891            .query_events(
2892                EventFilter::default(),
2893                PageRequest {
2894                    offset: 0,
2895                    limit: 10,
2896                },
2897            )
2898            .await
2899            .unwrap();
2900        assert_eq!(
2901            page.items[0].target_id,
2902            Some(target),
2903            "#282: audit event must carry target_id from dispatch params"
2904        );
2905    }
2906}
2907
2908// ---- Inter-pack dependency checking ----
2909
2910#[cfg(test)]
2911mod dep_tests {
2912    use super::*;
2913    use async_trait::async_trait;
2914    use khive_types::Pack;
2915    use serde_json::Value;
2916
2917    struct KgDepPack;
2918    struct MemoryDepPack;
2919    struct ADepPack;
2920    struct BDepPack;
2921
2922    impl Pack for KgDepPack {
2923        const NAME: &'static str = "kg_dep";
2924        const NOTE_KINDS: &'static [&'static str] = &["observation"];
2925        const ENTITY_KINDS: &'static [&'static str] = &["concept"];
2926        const HANDLERS: &'static [HandlerDef] = &[];
2927    }
2928
2929    impl Pack for MemoryDepPack {
2930        const NAME: &'static str = "memory_dep";
2931        const NOTE_KINDS: &'static [&'static str] = &["memory"];
2932        const ENTITY_KINDS: &'static [&'static str] = &[];
2933        const HANDLERS: &'static [HandlerDef] = &[];
2934        const REQUIRES: &'static [&'static str] = &["kg_dep"];
2935    }
2936
2937    impl Pack for ADepPack {
2938        const NAME: &'static str = "pack_a";
2939        const NOTE_KINDS: &'static [&'static str] = &[];
2940        const ENTITY_KINDS: &'static [&'static str] = &[];
2941        const HANDLERS: &'static [HandlerDef] = &[];
2942        const REQUIRES: &'static [&'static str] = &["pack_b"];
2943    }
2944
2945    impl Pack for BDepPack {
2946        const NAME: &'static str = "pack_b";
2947        const NOTE_KINDS: &'static [&'static str] = &[];
2948        const ENTITY_KINDS: &'static [&'static str] = &[];
2949        const HANDLERS: &'static [HandlerDef] = &[];
2950        const REQUIRES: &'static [&'static str] = &["pack_a"];
2951    }
2952
2953    #[async_trait]
2954    impl PackRuntime for KgDepPack {
2955        fn name(&self) -> &str {
2956            Self::NAME
2957        }
2958        fn note_kinds(&self) -> &'static [&'static str] {
2959            Self::NOTE_KINDS
2960        }
2961        fn entity_kinds(&self) -> &'static [&'static str] {
2962            Self::ENTITY_KINDS
2963        }
2964        fn handlers(&self) -> &'static [HandlerDef] {
2965            Self::HANDLERS
2966        }
2967        async fn dispatch(
2968            &self,
2969            verb: &str,
2970            _: Value,
2971            _: &VerbRegistry,
2972            _: &NamespaceToken,
2973        ) -> Result<Value, RuntimeError> {
2974            Err(RuntimeError::InvalidInput(format!(
2975                "KgDepPack has no verbs: {verb}"
2976            )))
2977        }
2978    }
2979
2980    #[async_trait]
2981    impl PackRuntime for MemoryDepPack {
2982        fn name(&self) -> &str {
2983            Self::NAME
2984        }
2985        fn note_kinds(&self) -> &'static [&'static str] {
2986            Self::NOTE_KINDS
2987        }
2988        fn entity_kinds(&self) -> &'static [&'static str] {
2989            Self::ENTITY_KINDS
2990        }
2991        fn handlers(&self) -> &'static [HandlerDef] {
2992            Self::HANDLERS
2993        }
2994        fn requires(&self) -> &'static [&'static str] {
2995            Self::REQUIRES
2996        }
2997        async fn dispatch(
2998            &self,
2999            verb: &str,
3000            _: Value,
3001            _: &VerbRegistry,
3002            _: &NamespaceToken,
3003        ) -> Result<Value, RuntimeError> {
3004            Err(RuntimeError::InvalidInput(format!(
3005                "MemoryDepPack has no verbs: {verb}"
3006            )))
3007        }
3008    }
3009
3010    #[async_trait]
3011    impl PackRuntime for ADepPack {
3012        fn name(&self) -> &str {
3013            Self::NAME
3014        }
3015        fn note_kinds(&self) -> &'static [&'static str] {
3016            Self::NOTE_KINDS
3017        }
3018        fn entity_kinds(&self) -> &'static [&'static str] {
3019            Self::ENTITY_KINDS
3020        }
3021        fn handlers(&self) -> &'static [HandlerDef] {
3022            Self::HANDLERS
3023        }
3024        fn requires(&self) -> &'static [&'static str] {
3025            Self::REQUIRES
3026        }
3027        async fn dispatch(
3028            &self,
3029            verb: &str,
3030            _: Value,
3031            _: &VerbRegistry,
3032            _: &NamespaceToken,
3033        ) -> Result<Value, RuntimeError> {
3034            Err(RuntimeError::InvalidInput(format!(
3035                "ADepPack has no verbs: {verb}"
3036            )))
3037        }
3038    }
3039
3040    #[async_trait]
3041    impl PackRuntime for BDepPack {
3042        fn name(&self) -> &str {
3043            Self::NAME
3044        }
3045        fn note_kinds(&self) -> &'static [&'static str] {
3046            Self::NOTE_KINDS
3047        }
3048        fn entity_kinds(&self) -> &'static [&'static str] {
3049            Self::ENTITY_KINDS
3050        }
3051        fn handlers(&self) -> &'static [HandlerDef] {
3052            Self::HANDLERS
3053        }
3054        fn requires(&self) -> &'static [&'static str] {
3055            Self::REQUIRES
3056        }
3057        async fn dispatch(
3058            &self,
3059            verb: &str,
3060            _: Value,
3061            _: &VerbRegistry,
3062            _: &NamespaceToken,
3063        ) -> Result<Value, RuntimeError> {
3064            Err(RuntimeError::InvalidInput(format!(
3065                "BDepPack has no verbs: {verb}"
3066            )))
3067        }
3068    }
3069
3070    #[test]
3071    fn test_pack_deps_happy_path() {
3072        let mut builder = VerbRegistryBuilder::new();
3073        builder.register(MemoryDepPack);
3074        builder.register(KgDepPack);
3075        let reg = builder
3076            .build()
3077            .expect("kg_dep satisfies memory_dep dependency");
3078        assert_eq!(reg.pack_requires("memory_dep").unwrap(), &["kg_dep"]);
3079        let names = reg.pack_names();
3080        let kg_pos = names.iter().position(|&n| n == "kg_dep").unwrap();
3081        let mem_pos = names.iter().position(|&n| n == "memory_dep").unwrap();
3082        assert!(
3083            kg_pos < mem_pos,
3084            "kg_dep must be loaded before memory_dep; order: {names:?}"
3085        );
3086    }
3087
3088    #[test]
3089    fn test_pack_deps_missing() {
3090        let mut builder = VerbRegistryBuilder::new();
3091        builder.register(MemoryDepPack);
3092        let err = match builder.build() {
3093            Ok(_) => panic!("expected Err, got Ok"),
3094            Err(e) => e,
3095        };
3096        assert!(
3097            matches!(err, RuntimeError::MissingPackDependency(_)),
3098            "expected MissingPackDependency, got {err:?}"
3099        );
3100        let msg = err.to_string();
3101        assert!(
3102            msg.contains("memory_dep"),
3103            "error must name the dependent pack: {msg}"
3104        );
3105        assert!(
3106            msg.contains("kg_dep"),
3107            "error must name the missing dep: {msg}"
3108        );
3109    }
3110
3111    #[test]
3112    fn test_pack_deps_circular() {
3113        let mut builder = VerbRegistryBuilder::new();
3114        builder.register(ADepPack);
3115        builder.register(BDepPack);
3116        let err = match builder.build() {
3117            Ok(_) => panic!("expected Err, got Ok"),
3118            Err(e) => e,
3119        };
3120        assert!(
3121            matches!(err, RuntimeError::CircularPackDependency(_)),
3122            "expected CircularPackDependency, got {err:?}"
3123        );
3124        let msg = err.to_string();
3125        assert!(msg.contains("pack_a"), "error must name pack_a: {msg}");
3126        assert!(msg.contains("pack_b"), "error must name pack_b: {msg}");
3127    }
3128
3129    #[test]
3130    fn test_pack_deps_no_deps() {
3131        struct NoDepsA;
3132        struct NoDepsB;
3133
3134        impl Pack for NoDepsA {
3135            const NAME: &'static str = "no_deps_a";
3136            const NOTE_KINDS: &'static [&'static str] = &[];
3137            const ENTITY_KINDS: &'static [&'static str] = &[];
3138            const HANDLERS: &'static [HandlerDef] = &[];
3139        }
3140
3141        impl Pack for NoDepsB {
3142            const NAME: &'static str = "no_deps_b";
3143            const NOTE_KINDS: &'static [&'static str] = &[];
3144            const ENTITY_KINDS: &'static [&'static str] = &[];
3145            const HANDLERS: &'static [HandlerDef] = &[];
3146        }
3147
3148        #[async_trait]
3149        impl PackRuntime for NoDepsA {
3150            fn name(&self) -> &str {
3151                Self::NAME
3152            }
3153            fn note_kinds(&self) -> &'static [&'static str] {
3154                Self::NOTE_KINDS
3155            }
3156            fn entity_kinds(&self) -> &'static [&'static str] {
3157                Self::ENTITY_KINDS
3158            }
3159            fn handlers(&self) -> &'static [HandlerDef] {
3160                Self::HANDLERS
3161            }
3162            async fn dispatch(
3163                &self,
3164                verb: &str,
3165                _: Value,
3166                _: &VerbRegistry,
3167                _: &NamespaceToken,
3168            ) -> Result<Value, RuntimeError> {
3169                Err(RuntimeError::InvalidInput(format!("NoDepsA: {verb}")))
3170            }
3171        }
3172
3173        #[async_trait]
3174        impl PackRuntime for NoDepsB {
3175            fn name(&self) -> &str {
3176                Self::NAME
3177            }
3178            fn note_kinds(&self) -> &'static [&'static str] {
3179                Self::NOTE_KINDS
3180            }
3181            fn entity_kinds(&self) -> &'static [&'static str] {
3182                Self::ENTITY_KINDS
3183            }
3184            fn handlers(&self) -> &'static [HandlerDef] {
3185                Self::HANDLERS
3186            }
3187            async fn dispatch(
3188                &self,
3189                verb: &str,
3190                _: Value,
3191                _: &VerbRegistry,
3192                _: &NamespaceToken,
3193            ) -> Result<Value, RuntimeError> {
3194                Err(RuntimeError::InvalidInput(format!("NoDepsB: {verb}")))
3195            }
3196        }
3197
3198        let mut builder = VerbRegistryBuilder::new();
3199        builder.register(NoDepsA);
3200        builder.register(NoDepsB);
3201        let reg = builder.build().expect("packs with REQUIRES=&[] build");
3202        assert_eq!(reg.pack_requires("no_deps_a").unwrap(), &[] as &[&str]);
3203        assert_eq!(reg.pack_requires("no_deps_b").unwrap(), &[] as &[&str]);
3204    }
3205}
3206
3207// ── Dispatch hook tests (Issue #158) ─────────────────────────────────────────
3208
3209#[cfg(test)]
3210mod hook_tests {
3211    use super::*;
3212    use async_trait::async_trait;
3213    use khive_types::Pack;
3214    use std::sync::atomic::{AtomicUsize, Ordering};
3215    use std::sync::Mutex as StdMutex;
3216
3217    struct SimplePack;
3218
3219    impl Pack for SimplePack {
3220        const NAME: &'static str = "simple";
3221        const NOTE_KINDS: &'static [&'static str] = &[];
3222        const ENTITY_KINDS: &'static [&'static str] = &[];
3223        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
3224            name: "ping",
3225            description: "ping",
3226            visibility: Visibility::Verb,
3227            category: VerbCategory::Assertive,
3228            params: &[],
3229        }];
3230    }
3231
3232    #[async_trait]
3233    impl PackRuntime for SimplePack {
3234        fn name(&self) -> &str {
3235            SimplePack::NAME
3236        }
3237        fn note_kinds(&self) -> &'static [&'static str] {
3238            SimplePack::NOTE_KINDS
3239        }
3240        fn entity_kinds(&self) -> &'static [&'static str] {
3241            SimplePack::ENTITY_KINDS
3242        }
3243        fn handlers(&self) -> &'static [HandlerDef] {
3244            SimplePack::HANDLERS
3245        }
3246        async fn dispatch(
3247            &self,
3248            verb: &str,
3249            _params: Value,
3250            _registry: &VerbRegistry,
3251            _token: &NamespaceToken,
3252        ) -> Result<Value, RuntimeError> {
3253            Ok(serde_json::json!({ "verb": verb }))
3254        }
3255    }
3256
3257    /// Hook that counts calls and records the last verb seen.
3258    #[derive(Default)]
3259    struct CountingHook {
3260        calls: AtomicUsize,
3261        last_verb: StdMutex<String>,
3262    }
3263
3264    #[async_trait]
3265    impl DispatchHook for CountingHook {
3266        async fn on_dispatch(&self, view: &EventView) {
3267            self.calls.fetch_add(1, Ordering::SeqCst);
3268            *self.last_verb.lock().unwrap() = view.event.verb.clone();
3269        }
3270    }
3271
3272    #[tokio::test]
3273    async fn dispatch_hook_fires_on_successful_dispatch() {
3274        let hook = Arc::new(CountingHook::default());
3275        let mut builder = VerbRegistryBuilder::new();
3276        builder.register(SimplePack);
3277        builder.with_dispatch_hook(hook.clone());
3278        let reg = builder.build().expect("registry builds");
3279
3280        reg.dispatch("ping", Value::Null).await.unwrap();
3281
3282        assert_eq!(
3283            hook.calls.load(Ordering::SeqCst),
3284            1,
3285            "hook must fire once per successful dispatch"
3286        );
3287        assert_eq!(
3288            hook.last_verb.lock().unwrap().as_str(),
3289            "ping",
3290            "hook event must carry the dispatched verb"
3291        );
3292    }
3293
3294    #[tokio::test]
3295    async fn dispatch_hook_fires_multiple_times() {
3296        let hook = Arc::new(CountingHook::default());
3297        let mut builder = VerbRegistryBuilder::new();
3298        builder.register(SimplePack);
3299        builder.with_dispatch_hook(hook.clone());
3300        let reg = builder.build().expect("registry builds");
3301
3302        reg.dispatch("ping", Value::Null).await.unwrap();
3303        reg.dispatch("ping", Value::Null).await.unwrap();
3304        reg.dispatch("ping", Value::Null).await.unwrap();
3305
3306        assert_eq!(
3307            hook.calls.load(Ordering::SeqCst),
3308            3,
3309            "hook must fire once per successful dispatch"
3310        );
3311    }
3312
3313    #[tokio::test]
3314    async fn dispatch_hook_does_not_fire_on_unknown_verb() {
3315        let hook = Arc::new(CountingHook::default());
3316        let mut builder = VerbRegistryBuilder::new();
3317        builder.register(SimplePack);
3318        builder.with_dispatch_hook(hook.clone());
3319        let reg = builder.build().expect("registry builds");
3320
3321        let _ = reg.dispatch("nonexistent", Value::Null).await;
3322
3323        assert_eq!(
3324            hook.calls.load(Ordering::SeqCst),
3325            0,
3326            "hook must NOT fire for unknown verb (dispatch returns error)"
3327        );
3328    }
3329
3330    #[tokio::test]
3331    async fn dispatch_hook_does_not_fire_on_gate_deny() {
3332        use khive_gate::{Gate, GateDecision, GateError};
3333
3334        #[derive(Debug)]
3335        struct AlwaysDenyGate;
3336        impl Gate for AlwaysDenyGate {
3337            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
3338                Ok(GateDecision::deny("test deny"))
3339            }
3340        }
3341
3342        let hook = Arc::new(CountingHook::default());
3343        let mut builder = VerbRegistryBuilder::new();
3344        builder.register(SimplePack);
3345        builder.with_gate(Arc::new(AlwaysDenyGate));
3346        builder.with_dispatch_hook(hook.clone());
3347        let reg = builder.build().expect("registry builds");
3348
3349        let err = reg.dispatch("ping", Value::Null).await.unwrap_err();
3350        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
3351
3352        assert_eq!(
3353            hook.calls.load(Ordering::SeqCst),
3354            0,
3355            "hook must NOT fire when gate denies dispatch"
3356        );
3357    }
3358
3359    #[tokio::test]
3360    async fn dispatch_hook_event_carries_namespace_from_params() {
3361        let hook = Arc::new(CountingHook::default());
3362
3363        #[derive(Default)]
3364        struct NsCapturingHook {
3365            ns: StdMutex<String>,
3366        }
3367
3368        #[async_trait]
3369        impl DispatchHook for NsCapturingHook {
3370            async fn on_dispatch(&self, view: &EventView) {
3371                *self.ns.lock().unwrap() = view.event.namespace.clone();
3372            }
3373        }
3374
3375        let ns_hook = Arc::new(NsCapturingHook::default());
3376        let mut builder = VerbRegistryBuilder::new();
3377        builder.register(SimplePack);
3378        builder.with_dispatch_hook(ns_hook.clone());
3379        let reg = builder.build().expect("registry builds");
3380
3381        reg.dispatch("ping", serde_json::json!({"namespace": "tenant-abc"}))
3382            .await
3383            .unwrap();
3384
3385        assert_eq!(
3386            ns_hook.ns.lock().unwrap().as_str(),
3387            "tenant-abc",
3388            "dispatch hook event must carry the resolved namespace"
3389        );
3390
3391        // Suppress unused-variable warning from the outer hook.
3392        drop(hook);
3393    }
3394
3395    #[tokio::test]
3396    async fn no_dispatch_hook_configured_dispatch_succeeds() {
3397        // Regression: registries without a hook must still work.
3398        let mut builder = VerbRegistryBuilder::new();
3399        builder.register(SimplePack);
3400        // No with_dispatch_hook call.
3401        let reg = builder.build().expect("registry builds");
3402
3403        let res = reg.dispatch("ping", Value::Null).await.unwrap();
3404        assert_eq!(res["verb"], "ping");
3405    }
3406}
3407
3408// ── help=true tests (issue #287) ──────────────────────────────────────────────
3409
3410#[cfg(test)]
3411mod help_tests {
3412    use super::*;
3413    use async_trait::async_trait;
3414    use khive_types::Pack;
3415    use std::sync::{
3416        atomic::{AtomicUsize, Ordering},
3417        Arc,
3418    };
3419
3420    // ── HelpPack: a minimal pack with one handler that records invocation count.
3421    //
3422    // Used to verify that help=true never reaches the pack's dispatch method.
3423
3424    static CREATE_PARAMS: [ParamDef; 2] = [
3425        ParamDef {
3426            name: "kind",
3427            param_type: "string",
3428            required: true,
3429            description: "Granular kind (concept | document | ...).",
3430        },
3431        ParamDef {
3432            name: "name",
3433            param_type: "string",
3434            required: false,
3435            description: "Human-readable name.",
3436        },
3437    ];
3438
3439    static RECALL_PARAMS: [ParamDef; 2] = [
3440        ParamDef {
3441            name: "query",
3442            param_type: "string",
3443            required: true,
3444            description: "Semantic recall query.",
3445        },
3446        ParamDef {
3447            name: "limit",
3448            param_type: "integer",
3449            required: false,
3450            description: "Maximum memories to return.",
3451        },
3452    ];
3453
3454    // A subhandler with no params — mirrors recall.embed / brain.emit / etc.
3455    // Used to test that help=true on a Subhandler returns callable_via_mcp: false.
3456    static EMBED_PARAMS: [ParamDef; 0] = [];
3457
3458    struct HelpPack {
3459        invocations: Arc<AtomicUsize>,
3460    }
3461
3462    impl Pack for HelpPack {
3463        const NAME: &'static str = "helptest";
3464        const NOTE_KINDS: &'static [&'static str] = &[];
3465        const ENTITY_KINDS: &'static [&'static str] = &[];
3466        const HANDLERS: &'static [HandlerDef] = &[
3467            HandlerDef {
3468                name: "create",
3469                description: "Create an entity or note",
3470                visibility: Visibility::Verb,
3471                category: VerbCategory::Commissive,
3472                params: &CREATE_PARAMS,
3473            },
3474            HandlerDef {
3475                name: "recall",
3476                description: "Recall memory notes with decay-aware hybrid ranking",
3477                visibility: Visibility::Verb,
3478                category: VerbCategory::Assertive,
3479                params: &RECALL_PARAMS,
3480            },
3481            // ue-help-introspection C1: a Subhandler used to test that
3482            // help=true returns callable_via_mcp: false for internal verbs.
3483            HandlerDef {
3484                name: "recall.embed",
3485                description: "Return the embedding vector used by memory recall",
3486                visibility: Visibility::Subhandler,
3487                category: VerbCategory::Assertive,
3488                params: &EMBED_PARAMS,
3489            },
3490        ];
3491    }
3492
3493    #[async_trait]
3494    impl PackRuntime for HelpPack {
3495        fn name(&self) -> &str {
3496            HelpPack::NAME
3497        }
3498        fn note_kinds(&self) -> &'static [&'static str] {
3499            HelpPack::NOTE_KINDS
3500        }
3501        fn entity_kinds(&self) -> &'static [&'static str] {
3502            HelpPack::ENTITY_KINDS
3503        }
3504        fn handlers(&self) -> &'static [HandlerDef] {
3505            HelpPack::HANDLERS
3506        }
3507        async fn dispatch(
3508            &self,
3509            verb: &str,
3510            _params: Value,
3511            _registry: &VerbRegistry,
3512            _token: &NamespaceToken,
3513        ) -> Result<Value, RuntimeError> {
3514            self.invocations.fetch_add(1, Ordering::SeqCst);
3515            Ok(serde_json::json!({ "pack": "helptest", "verb": verb }))
3516        }
3517    }
3518
3519    fn build_help_registry(invocations: Arc<AtomicUsize>) -> VerbRegistry {
3520        let mut builder = VerbRegistryBuilder::new();
3521        builder.register(HelpPack { invocations });
3522        builder.build().expect("help registry builds")
3523    }
3524
3525    /// help=true on `create` returns a schema envelope with the correct verb name,
3526    /// pack name, description, and at least the required `kind` parameter.
3527    #[tokio::test]
3528    async fn test_help_true_returns_schema_for_kg_create() {
3529        let invocations = Arc::new(AtomicUsize::new(0));
3530        let reg = build_help_registry(invocations.clone());
3531
3532        let result = reg
3533            .dispatch("create", serde_json::json!({ "help": true }))
3534            .await
3535            .expect("help=true must succeed for a known verb");
3536
3537        // Shape checks.
3538        assert_eq!(result["verb"], "create", "envelope must name the verb");
3539        assert_eq!(
3540            result["pack"], "helptest",
3541            "envelope must name the owning pack"
3542        );
3543        assert!(
3544            result["description"].as_str().is_some(),
3545            "description must be a string"
3546        );
3547
3548        // Params array must be present and non-empty.
3549        let params = result["params"]
3550            .as_array()
3551            .expect("params must be a JSON array");
3552        assert!(!params.is_empty(), "params array must not be empty");
3553
3554        // The required `kind` param must appear.
3555        let kind_param = params.iter().find(|p| p["name"] == "kind");
3556        assert!(
3557            kind_param.is_some(),
3558            "params array must include the 'kind' parameter"
3559        );
3560        let kind_param = kind_param.unwrap();
3561        assert_eq!(
3562            kind_param["required"],
3563            serde_json::json!(true),
3564            "'kind' must be required"
3565        );
3566        assert_eq!(kind_param["type"], "string", "'kind' type must be 'string'");
3567    }
3568
3569    /// help=true on `recall` returns a schema envelope including the `query` param.
3570    #[tokio::test]
3571    async fn test_help_true_returns_schema_for_recall() {
3572        let invocations = Arc::new(AtomicUsize::new(0));
3573        let reg = build_help_registry(invocations.clone());
3574
3575        let result = reg
3576            .dispatch("recall", serde_json::json!({ "help": true }))
3577            .await
3578            .expect("help=true must succeed for recall");
3579
3580        assert_eq!(result["verb"], "recall");
3581        assert_eq!(result["pack"], "helptest");
3582
3583        let params = result["params"]
3584            .as_array()
3585            .expect("params must be a JSON array");
3586
3587        // `query` must be present and required.
3588        let query_param = params.iter().find(|p| p["name"] == "query");
3589        assert!(query_param.is_some(), "params must include 'query'");
3590        let query_param = query_param.unwrap();
3591        assert_eq!(
3592            query_param["required"],
3593            serde_json::json!(true),
3594            "'query' must be required"
3595        );
3596
3597        // `limit` must be present and optional.
3598        let limit_param = params.iter().find(|p| p["name"] == "limit");
3599        assert!(limit_param.is_some(), "params must include 'limit'");
3600        let limit_param = limit_param.unwrap();
3601        assert_eq!(
3602            limit_param["required"],
3603            serde_json::json!(false),
3604            "'limit' must be optional"
3605        );
3606    }
3607
3608    /// help=true is intercepted before pack dispatch — the pack's dispatch method
3609    /// must never be invoked when help=true is in the params.
3610    #[tokio::test]
3611    async fn test_help_true_does_not_execute_the_verb() {
3612        let invocations = Arc::new(AtomicUsize::new(0));
3613        let reg = build_help_registry(invocations.clone());
3614
3615        // Call both verbs with help=true.
3616        reg.dispatch("create", serde_json::json!({ "help": true }))
3617            .await
3618            .expect("help=true must succeed");
3619        reg.dispatch("recall", serde_json::json!({ "help": true }))
3620            .await
3621            .expect("help=true must succeed");
3622
3623        assert_eq!(
3624            invocations.load(Ordering::SeqCst),
3625            0,
3626            "pack dispatch MUST NOT be invoked when help=true; \
3627             got {} invocation(s)",
3628            invocations.load(Ordering::SeqCst)
3629        );
3630
3631        // Confirm that a normal call (without help=true) DOES invoke dispatch.
3632        reg.dispatch("create", serde_json::json!({}))
3633            .await
3634            .expect("normal dispatch must succeed");
3635        assert_eq!(
3636            invocations.load(Ordering::SeqCst),
3637            1,
3638            "pack dispatch must fire exactly once for a normal call"
3639        );
3640    }
3641
3642    // ── ue-help-introspection C1 regressions ─────────────────────────────────
3643    //
3644    // Subhandler verbs must return `callable_via_mcp: false` in their help
3645    // schema so agents who read help=true before probing see accurate
3646    // availability — not a "looks callable" schema followed by permission denied.
3647
3648    /// help=true on a `Visibility::Subhandler` verb returns `callable_via_mcp: false`
3649    /// and `visibility: "internal"` rather than a plain callable-looking envelope.
3650    #[tokio::test]
3651    async fn help_true_on_subhandler_returns_callable_via_mcp_false() {
3652        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
3653
3654        let result = reg
3655            .dispatch("recall.embed", serde_json::json!({ "help": true }))
3656            .await
3657            .expect("help=true on subhandler must succeed (no permission check on help path)");
3658
3659        assert_eq!(
3660            result["callable_via_mcp"],
3661            serde_json::json!(false),
3662            "subhandler help must carry callable_via_mcp: false"
3663        );
3664        assert_eq!(
3665            result["visibility"], "internal",
3666            "subhandler help must carry visibility: internal"
3667        );
3668        // The verb and pack fields must still be present so the caller knows
3669        // what the schema belongs to.
3670        assert_eq!(result["verb"], "recall.embed");
3671        assert_eq!(result["pack"], "helptest");
3672    }
3673
3674    /// Public Verb-visibility handlers must NOT have `callable_via_mcp: false`.
3675    #[tokio::test]
3676    async fn help_true_on_public_verb_does_not_have_callable_via_mcp_false() {
3677        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
3678
3679        let result = reg
3680            .dispatch("create", serde_json::json!({ "help": true }))
3681            .await
3682            .expect("help=true on public verb must succeed");
3683
3684        // callable_via_mcp must be absent or true for public verbs.
3685        assert_ne!(
3686            result.get("callable_via_mcp"),
3687            Some(&serde_json::json!(false)),
3688            "public verb help must NOT carry callable_via_mcp: false"
3689        );
3690        // visibility must be absent or 'public' (never 'internal') for public verbs.
3691        assert_ne!(
3692            result.get("visibility"),
3693            Some(&serde_json::json!("internal")),
3694            "public verb help must NOT carry visibility: internal"
3695        );
3696    }
3697
3698    /// help=true on an unknown verb returns an error (same behavior as normal dispatch).
3699    #[tokio::test]
3700    async fn help_true_on_unknown_verb_returns_error() {
3701        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
3702
3703        let err = reg
3704            .dispatch("nonexistent_verb", serde_json::json!({ "help": true }))
3705            .await
3706            .unwrap_err();
3707
3708        assert!(
3709            matches!(err, RuntimeError::InvalidInput(_)),
3710            "help=true on unknown verb must return InvalidInput, got {err:?}"
3711        );
3712        let msg = err.to_string();
3713        assert!(
3714            msg.contains("nonexistent_verb"),
3715            "error must name the unknown verb: {msg}"
3716        );
3717    }
3718
3719    /// Subhandler help must include params: [] even when the verb has no params.
3720    #[tokio::test]
3721    async fn help_true_on_subhandler_includes_params_field() {
3722        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
3723
3724        let result = reg
3725            .dispatch("recall.embed", serde_json::json!({ "help": true }))
3726            .await
3727            .expect("help=true on subhandler must succeed");
3728
3729        // params must always be present (H1: consistent shape).
3730        let params = result
3731            .get("params")
3732            .expect("subhandler help must include 'params' field");
3733        assert!(
3734            params.is_array(),
3735            "subhandler help params must be a JSON array"
3736        );
3737    }
3738
3739    // ── codex High: unknown-verb error must not leak subhandler names ─────────
3740
3741    /// `describe_verb` on an unknown verb must list only Verb-visibility names
3742    /// in the "available" list — never subhandler names like `recall.embed`
3743    /// (codex High — ue-help-introspection C1 / unknown-verb path).
3744    #[tokio::test]
3745    async fn help_true_unknown_verb_available_list_excludes_subhandlers() {
3746        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
3747
3748        let err = reg
3749            .dispatch("not_a_verb", serde_json::json!({ "help": true }))
3750            .await
3751            .unwrap_err();
3752
3753        let msg = err.to_string();
3754        // `recall.embed` is a Subhandler in HelpPack — must NOT appear in the
3755        // "available" list of an unknown-verb error.
3756        assert!(
3757            !msg.contains("recall.embed"),
3758            "unknown-verb help error must not advertise subhandler recall.embed: {msg}"
3759        );
3760        // Public verbs must still appear so the agent knows what to call.
3761        assert!(
3762            msg.contains("create"),
3763            "unknown-verb help error must still list public verb 'create': {msg}"
3764        );
3765        assert!(
3766            msg.contains("recall"),
3767            "unknown-verb help error must still list public verb 'recall': {msg}"
3768        );
3769    }
3770
3771    /// Normal dispatch on an unknown verb must also not leak subhandler names.
3772    #[tokio::test]
3773    async fn dispatch_unknown_verb_available_list_excludes_subhandlers() {
3774        let reg = build_help_registry(Arc::new(AtomicUsize::new(0)));
3775
3776        let err = reg
3777            .dispatch("not_a_verb", serde_json::json!({}))
3778            .await
3779            .unwrap_err();
3780
3781        let msg = err.to_string();
3782        // `recall.embed` is a Subhandler in HelpPack — must NOT appear in the
3783        // "available" list of an unknown-verb dispatch error.
3784        assert!(
3785            !msg.contains("recall.embed"),
3786            "dispatch unknown-verb error must not advertise subhandler recall.embed: {msg}"
3787        );
3788        // Public verbs must still appear so the agent knows what to call.
3789        assert!(
3790            msg.contains("create"),
3791            "dispatch unknown-verb error must still list public verb 'create': {msg}"
3792        );
3793        assert!(
3794            msg.contains("recall"),
3795            "dispatch unknown-verb error must still list public verb 'recall': {msg}"
3796        );
3797    }
3798}