Skip to main content

khive_runtime/
pack.rs

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