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;
16
17use async_trait::async_trait;
18use khive_gate::{ActorRef, AllowAllGate, AuditEvent, GateDecision, GateRef, GateRequest};
19use khive_storage::{Event, EventStore, SubstrateKind};
20use khive_types::{EventOutcome, Namespace};
21use serde_json::Value;
22
23pub use khive_types::{EdgeEndpointRule, EndpointKind, VerbDef};
24
25/// Hook called after every successful verb dispatch (Issue #158).
26///
27/// Packs that want to observe real-time dispatch outcomes (e.g. brain pack
28/// updating its posteriors) implement this trait and register it via
29/// [`VerbRegistryBuilder::with_dispatch_hook`]. The hook is opt-in: when no
30/// hook is registered, dispatch incurs zero overhead.
31///
32/// The hook receives the synthesized `Event` that was built from the dispatch
33/// outcome — same representation used by the EventStore audit path — so brain
34/// pack's `EventFold` can process it without extra conversion.
35#[async_trait]
36pub trait DispatchHook: Send + Sync {
37    /// Called with the dispatch-outcome event after a successful pack dispatch.
38    ///
39    /// Errors are logged via `tracing::warn!` and never propagated to the
40    /// caller — the dispatch has already succeeded.
41    async fn on_dispatch(&self, event: &Event);
42}
43
44use crate::error::{
45    CircularPackDependency, MissingPackDependencies, MissingPackDependency, RuntimeError,
46};
47use crate::KhiveRuntime;
48
49/// Async dispatch trait for packs (ADR-025).
50///
51/// This is the object-safe behavioral counterpart to `khive_types::Pack`.
52/// `Pack` uses const associated items (not object-safe in Rust); this trait
53/// mirrors that metadata as methods and adds async dispatch.
54///
55/// Registration requires `P: Pack + PackRuntime` — the compiler enforces
56/// that every runtime pack also declares its vocabulary via `Pack`.
57#[async_trait]
58pub trait PackRuntime: Send + Sync {
59    /// Pack name — must equal `<Self as Pack>::NAME`.
60    fn name(&self) -> &str;
61
62    /// Note kinds this pack owns — must equal `<Self as Pack>::NOTE_KINDS`.
63    fn note_kinds(&self) -> &'static [&'static str];
64
65    /// Entity kinds this pack owns — must equal `<Self as Pack>::ENTITY_KINDS`.
66    fn entity_kinds(&self) -> &'static [&'static str];
67
68    /// Verbs this pack handles — must equal `<Self as Pack>::VERBS`.
69    fn verbs(&self) -> &'static [VerbDef];
70
71    /// Pack-extensible edge endpoint rules — must equal `<Self as Pack>::EDGE_RULES`.
72    /// Defaults to empty so existing packs that don't extend the edge contract
73    /// can ignore it (ADR-031).
74    fn edge_rules(&self) -> &'static [EdgeEndpointRule] {
75        &[]
76    }
77
78    /// Pack names whose vocabulary this pack references (ADR-037).
79    /// Defaults to empty so existing packs compile without changes.
80    fn requires(&self) -> &'static [&'static str] {
81        &[]
82    }
83
84    /// Optional per-kind hook for shared CRUD specialization (ADR-030).
85    ///
86    /// When a kind is owned by this pack (declared in `note_kinds()` or
87    /// `entity_kinds()`), returning `Some(hook)` opts that kind into
88    /// pack-specific behavior — defaults, derived properties, side-effect
89    /// edges — through the shared `create` path. Returning `None` keeps
90    /// the kind as plain storage with no specialization.
91    fn kind_hook(&self, _kind: &str) -> Option<Arc<dyn KindHook>> {
92        None
93    }
94
95    /// Dispatch a verb call. Returns serialized JSON response.
96    ///
97    /// The `registry` parameter gives the handler access to the merged
98    /// vocabulary and kind hooks across all loaded packs (ADR-030).
99    async fn dispatch(
100        &self,
101        verb: &str,
102        params: Value,
103        registry: &VerbRegistry,
104    ) -> Result<Value, RuntimeError>;
105}
106
107/// Per-kind specialization for shared CRUD (ADR-030).
108///
109/// Packs implement `KindHook` for kinds they own that need:
110/// - **Defaults** filled into create args (e.g. `status="inbox"` for tasks)
111/// - **Derived properties** computed from args (e.g. salience from priority)
112/// - **Side-effect writes** after the storage commit (e.g. `depends_on` edges)
113///
114/// Hooks are stateless from the framework's perspective — they receive the
115/// runtime as a method parameter and operate on the args `Value` directly.
116/// The pack registers them via [`PackRuntime::kind_hook`].
117///
118/// Lifecycle verbs (e.g. gtd's `complete`, `transition`) remain pack-owned
119/// verbs and do not flow through this trait — only the create path does.
120#[async_trait]
121pub trait KindHook: Send + Sync + std::fmt::Debug {
122    /// Mutate args before the storage write. Fill defaults, normalize values,
123    /// rearrange user-facing fields into the storage shape expected by the
124    /// shared CRUD handler.
125    ///
126    /// Returning an error aborts the create call (no storage write happens).
127    async fn prepare_create(
128        &self,
129        runtime: &KhiveRuntime,
130        args: &mut Value,
131    ) -> Result<(), RuntimeError>;
132
133    /// Fire side effects after a successful storage write — graph edges,
134    /// derived observations, etc. The newly created record's UUID is passed
135    /// so the hook can attach metadata referencing it.
136    ///
137    /// Errors here are **logged but not propagated** — the storage write has
138    /// already succeeded; failing the call would mislead the caller.
139    /// Implementations should `tracing::warn!` and return `Ok(())` for
140    /// best-effort side effects.
141    async fn after_create(
142        &self,
143        runtime: &KhiveRuntime,
144        id: uuid::Uuid,
145        args: &Value,
146    ) -> Result<(), RuntimeError>;
147}
148
149/// Builder for constructing a `VerbRegistry`.
150///
151/// Packs are registered here; once `.build()` is called the registry is
152/// immutable and cheaply cloneable.
153pub struct VerbRegistryBuilder {
154    packs: Vec<Box<dyn PackRuntime>>,
155    gate: GateRef,
156    default_namespace: String,
157    /// Optional audit event sink (ADR-035).
158    ///
159    /// When set, every gate check writes a storage `Event` in addition to the
160    /// `tracing::info!` emission. The store is `Arc<dyn EventStore>` so the
161    /// registry does not depend on the full `KhiveRuntime` surface — only the
162    /// audit-persistence capability is needed here.
163    event_store: Option<Arc<dyn EventStore>>,
164    /// Optional post-dispatch hook (Issue #158).
165    ///
166    /// When set, every successful pack dispatch calls `hook.on_dispatch(event)`
167    /// with a synthesized Event describing the outcome. Opt-in: when None,
168    /// no overhead is incurred.
169    dispatch_hook: Option<Arc<dyn DispatchHook>>,
170}
171
172impl VerbRegistryBuilder {
173    pub fn new() -> Self {
174        Self {
175            packs: Vec::new(),
176            gate: std::sync::Arc::new(AllowAllGate),
177            default_namespace: Namespace::default_ns().as_str().to_string(),
178            event_store: None,
179            dispatch_hook: None,
180        }
181    }
182
183    /// Register a pack. The bound `P: Pack + PackRuntime` ensures the pack
184    /// declares vocabulary via `Pack` consts alongside runtime dispatch.
185    pub fn register<P: khive_types::Pack + PackRuntime + 'static>(&mut self, pack: P) -> &mut Self {
186        self.packs.push(Box::new(pack));
187        self
188    }
189
190    /// Register a boxed pack directly (ADR-063).
191    ///
192    /// Crate-private: only [`PackRegistry::register_packs`] should call this.
193    /// External callers must use the typed [`Self::register`] which enforces the
194    /// `Pack + PackRuntime` dual-impl contract at the call site.  Here the
195    /// contract is satisfied upstream at the [`PackFactory::create`] site.
196    pub(crate) fn register_boxed(&mut self, pack: Box<dyn PackRuntime>) -> &mut Self {
197        self.packs.push(pack);
198        self
199    }
200
201    /// Set the authorization gate consulted on every dispatch (ADR-029).
202    ///
203    /// Defaults to `AllowAllGate` if not set. In v0.2 the gate is **advisory** —
204    /// deny decisions are logged via `tracing::warn!` but do not block dispatch.
205    pub fn with_gate(&mut self, gate: GateRef) -> &mut Self {
206        self.gate = gate;
207        self
208    }
209
210    /// Set the namespace surfaced to the gate when a verb does not carry an
211    /// explicit `namespace` argument. Transports should plumb the runtime's
212    /// `default_namespace` so the gate's `input.namespace` always reflects
213    /// the operation's true tenant (ADR-029 + ADR-007).
214    pub fn with_default_namespace(&mut self, ns: impl Into<String>) -> &mut Self {
215        self.default_namespace = ns.into();
216        self
217    }
218
219    /// Set the `EventStore` used to persist audit events (ADR-035).
220    ///
221    /// When configured, every gate check appends one `Event` (substrate =
222    /// `Event`, outcome = `Success` on allow, `Denied` on deny) in addition to
223    /// the `tracing::info!` emission that was already present in v0.2.
224    ///
225    /// Callers that do not set this field continue to use tracing-only emission
226    /// (the v0.2 default). There is no behavior change for them.
227    pub fn with_event_store(&mut self, store: Arc<dyn EventStore>) -> &mut Self {
228        self.event_store = Some(store);
229        self
230    }
231
232    /// Register a post-dispatch hook (Issue #158).
233    ///
234    /// When set, every successful pack dispatch calls `hook.on_dispatch(event)`
235    /// with a synthesized [`Event`] describing the verb outcome. The hook is
236    /// opt-in: registries without a hook incur zero overhead on the dispatch
237    /// hot path.
238    ///
239    /// Brain pack uses this to update its posteriors in real time without
240    /// polling the EventStore. Errors from `on_dispatch` are logged via
241    /// `tracing::warn!` and never propagated.
242    pub fn with_dispatch_hook(&mut self, hook: Arc<dyn DispatchHook>) -> &mut Self {
243        self.dispatch_hook = Some(hook);
244        self
245    }
246
247    /// Consume the builder and produce an immutable, cloneable registry.
248    ///
249    /// Performs a topological sort of packs using Kahn's algorithm (ADR-037).
250    /// Returns an error if any declared dependency is missing from the loaded
251    /// pack set, or if a circular dependency is detected.
252    pub fn build(self) -> Result<VerbRegistry, RuntimeError> {
253        let packs = self.packs;
254        let mut name_to_idx: HashMap<&str, usize> = HashMap::with_capacity(packs.len());
255        for (idx, pack) in packs.iter().enumerate() {
256            if let Some(prev_idx) = name_to_idx.insert(pack.name(), idx) {
257                return Err(RuntimeError::PackRedeclared {
258                    name: pack.name().to_string(),
259                    first_idx: prev_idx,
260                    second_idx: idx,
261                });
262            }
263        }
264
265        let mut missing: Vec<MissingPackDependency> = Vec::new();
266        let mut indegree = vec![0usize; packs.len()];
267        let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); packs.len()];
268
269        for (idx, pack) in packs.iter().enumerate() {
270            for &requires in pack.requires() {
271                match name_to_idx.get(requires).copied() {
272                    Some(dep_idx) => {
273                        dependents[dep_idx].push(idx);
274                        indegree[idx] += 1;
275                    }
276                    None => missing.push(MissingPackDependency {
277                        from: pack.name().to_string(),
278                        requires: requires.to_string(),
279                    }),
280                }
281            }
282        }
283
284        if !missing.is_empty() {
285            return if missing.len() == 1 {
286                Err(RuntimeError::MissingPackDependency(missing.remove(0)))
287            } else {
288                Err(RuntimeError::MissingPackDependencies(
289                    MissingPackDependencies { missing },
290                ))
291            };
292        }
293
294        let mut ready: VecDeque<usize> = indegree
295            .iter()
296            .enumerate()
297            .filter_map(|(idx, degree)| (*degree == 0).then_some(idx))
298            .collect();
299        let mut ordered_indices = Vec::with_capacity(packs.len());
300
301        while let Some(idx) = ready.pop_front() {
302            ordered_indices.push(idx);
303            for &dep_idx in &dependents[idx] {
304                indegree[dep_idx] -= 1;
305                if indegree[dep_idx] == 0 {
306                    ready.push_back(dep_idx);
307                }
308            }
309        }
310
311        if ordered_indices.len() != packs.len() {
312            let cycle_nodes: HashSet<usize> = indegree
313                .iter()
314                .enumerate()
315                .filter_map(|(idx, degree)| (*degree > 0).then_some(idx))
316                .collect();
317            let cycle = find_pack_dependency_cycle(&packs, &name_to_idx, &cycle_nodes);
318            return Err(RuntimeError::CircularPackDependency(
319                CircularPackDependency { cycle },
320            ));
321        }
322
323        let mut slots: Vec<Option<Box<dyn PackRuntime>>> = packs.into_iter().map(Some).collect();
324        let ordered_packs: Vec<Box<dyn PackRuntime>> = ordered_indices
325            .into_iter()
326            .map(|idx| slots[idx].take().expect("topological index must exist"))
327            .collect();
328
329        Ok(VerbRegistry {
330            packs: Arc::new(ordered_packs),
331            gate: self.gate,
332            default_namespace: self.default_namespace,
333            event_store: self.event_store,
334            dispatch_hook: self.dispatch_hook,
335        })
336    }
337}
338
339fn find_pack_dependency_cycle(
340    packs: &[Box<dyn PackRuntime>],
341    name_to_idx: &HashMap<&str, usize>,
342    cycle_nodes: &HashSet<usize>,
343) -> Vec<String> {
344    fn visit(
345        idx: usize,
346        packs: &[Box<dyn PackRuntime>],
347        name_to_idx: &HashMap<&str, usize>,
348        cycle_nodes: &HashSet<usize>,
349        visiting: &mut Vec<usize>,
350        visited: &mut HashSet<usize>,
351    ) -> Option<Vec<String>> {
352        if let Some(pos) = visiting.iter().position(|&seen| seen == idx) {
353            let mut cycle: Vec<String> = visiting[pos..]
354                .iter()
355                .map(|&i| packs[i].name().to_string())
356                .collect();
357            cycle.push(packs[idx].name().to_string());
358            return Some(cycle);
359        }
360        if !visited.insert(idx) {
361            return None;
362        }
363        visiting.push(idx);
364        for &req in packs[idx].requires() {
365            let Some(&dep_idx) = name_to_idx.get(req) else {
366                continue;
367            };
368            if cycle_nodes.contains(&dep_idx) {
369                if let Some(cycle) =
370                    visit(dep_idx, packs, name_to_idx, cycle_nodes, visiting, visited)
371                {
372                    return Some(cycle);
373                }
374            }
375        }
376        visiting.pop();
377        None
378    }
379
380    let mut visited = HashSet::new();
381    for &idx in cycle_nodes {
382        let mut visiting = Vec::new();
383        if let Some(cycle) = visit(
384            idx,
385            packs,
386            name_to_idx,
387            cycle_nodes,
388            &mut visiting,
389            &mut visited,
390        ) {
391            return cycle;
392        }
393    }
394    cycle_nodes
395        .iter()
396        .map(|&idx| packs[idx].name().to_string())
397        .collect()
398}
399
400impl Default for VerbRegistryBuilder {
401    fn default() -> Self {
402        Self::new()
403    }
404}
405
406/// Immutable registry that dispatches verb calls to registered packs.
407///
408/// Clone is cheap (Arc-wrapped). Constructed via `VerbRegistryBuilder`.
409#[derive(Clone)]
410pub struct VerbRegistry {
411    packs: std::sync::Arc<Vec<Box<dyn PackRuntime>>>,
412    gate: GateRef,
413    default_namespace: String,
414    /// Audit event sink — `None` means tracing-only (v0.2 default) (ADR-035).
415    event_store: Option<Arc<dyn EventStore>>,
416    /// Post-dispatch hook — `None` means no real-time observation (Issue #158).
417    dispatch_hook: Option<Arc<dyn DispatchHook>>,
418}
419
420impl VerbRegistry {
421    /// Dispatch a verb to the first pack that handles it.
422    ///
423    /// When multiple packs declare the same verb, the first registered pack wins.
424    ///
425    /// The configured [`Gate`](khive_gate::Gate) is consulted before dispatch
426    /// (ADR-029, ADR-035). `Deny` decisions return
427    /// [`RuntimeError::PermissionDenied`] immediately — the pack is never
428    /// invoked. `Allow` decisions proceed to pack dispatch as before.
429    ///
430    /// Every gate consultation emits one `tracing::info!(... "gate.check")` event
431    /// with a structured `audit_event` field (ADR-033). When a [`EventStore`]
432    /// is configured via [`VerbRegistryBuilder::with_event_store`], an `Event`
433    /// is also persisted to the substrate (ADR-035). Storage errors are logged
434    /// via `tracing::warn!` and never propagated.
435    ///
436    /// When `gate.check` itself returns an error (gate infrastructure failure),
437    /// the error is logged via `tracing::warn!` and dispatch proceeds (fail-open,
438    /// consistent with ADR-029 §Rationale "Why advisory in v0.2"). No audit event
439    /// is persisted for an errored gate check — no decision was produced.
440    ///
441    /// The synthesized `GateRequest` carries `ActorRef::anonymous()` and the
442    /// operation's namespace — pulled from `params["namespace"]` when present
443    /// (including an explicit empty string, which `KhiveRuntime::ns` also
444    /// preserves), otherwise the registry's default namespace (configured via
445    /// [`VerbRegistryBuilder::with_default_namespace`]). Gate-visible
446    /// namespace and runtime-visible namespace MUST stay aligned; coercing an
447    /// empty string here while the runtime keeps `""` would create an
448    /// authorization/audit blind spot on the field ADR-029 declares public.
449    /// Transports that have richer caller context (auth headers, session
450    /// info) will gain a sibling dispatch path in a follow-up.
451    pub async fn dispatch(&self, verb: &str, params: Value) -> Result<Value, RuntimeError> {
452        // Resolve namespace as an owned String before `params` is moved into
453        // pack.dispatch, so the post-dispatch hook can reference it.
454        let ns_str: String = params
455            .get("namespace")
456            .and_then(Value::as_str)
457            .map(str::to_string)
458            .unwrap_or_else(|| self.default_namespace.clone());
459        let gate_req = GateRequest::new(
460            ActorRef::anonymous(),
461            Namespace::new(&ns_str),
462            verb,
463            params.clone(),
464        );
465
466        // Consult the gate (ADR-029, ADR-035).
467        //
468        // - Ok(Allow) → proceed to pack dispatch (tracing + optional EventStore).
469        // - Ok(Deny) → emit audit, persist if store configured, return PermissionDenied.
470        // - Err(_) → warn via tracing, fail-open (no audit persisted).
471        let gate_blocked = match self.gate.check(&gate_req) {
472            Ok(decision) => {
473                let is_deny = matches!(decision, GateDecision::Deny { .. });
474
475                // Emit audit event via tracing (ADR-033 — preserved path).
476                let audit = AuditEvent::from_check(&gate_req, &decision, self.gate.impl_name());
477                tracing::info!(
478                    audit_event = %serde_json::to_string(&audit)
479                        .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
480                    "gate.check"
481                );
482
483                // Persist to EventStore when configured (ADR-035).
484                if let Some(store) = &self.event_store {
485                    let outcome = if is_deny {
486                        EventOutcome::Denied
487                    } else {
488                        EventOutcome::Success
489                    };
490                    let audit_data = serde_json::to_value(&audit).unwrap_or_else(|e| {
491                        tracing::warn!(error = %e, "failed to serialize AuditEvent for EventStore");
492                        serde_json::Value::Null
493                    });
494                    let storage_event = Event::new(
495                        gate_req.namespace.as_str(),
496                        verb,
497                        SubstrateKind::Event,
498                        format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
499                    )
500                    .with_outcome(outcome)
501                    .with_data(audit_data);
502                    if let Err(store_err) = store.append_event(storage_event).await {
503                        tracing::warn!(
504                            verb,
505                            error = %store_err,
506                            "audit event store write failed (non-fatal)"
507                        );
508                    }
509                }
510
511                if is_deny {
512                    let reason = match decision {
513                        GateDecision::Deny { reason } => reason,
514                        _ => String::new(),
515                    };
516                    Some(reason)
517                } else {
518                    None
519                }
520            }
521            Err(err) => {
522                // Gate infrastructure failure — fail-open (ADR-029 §Rationale).
523                // No decision was produced; no audit event is persisted.
524                tracing::warn!(verb, error = %err, "gate check failed (fail-open)");
525                None
526            }
527        };
528
529        // Hard enforcement (ADR-035): Deny is now authoritative.
530        if let Some(reason) = gate_blocked {
531            return Err(RuntimeError::PermissionDenied {
532                verb: verb.to_string(),
533                reason,
534            });
535        }
536
537        for pack in self.packs.iter() {
538            if pack.verbs().iter().any(|v| v.name == verb) {
539                let result = pack.dispatch(verb, params, self).await;
540
541                // Post-dispatch hook: fires on success, opt-in (Issue #158).
542                if let (Ok(_), Some(hook)) = (&result, &self.dispatch_hook) {
543                    let dispatch_event =
544                        Event::new(ns_str.as_str(), verb, SubstrateKind::Event, pack.name())
545                            .with_outcome(EventOutcome::Success);
546                    let hook = Arc::clone(hook);
547                    hook.on_dispatch(&dispatch_event).await;
548                }
549
550                return result;
551            }
552        }
553        let available: Vec<&str> = self
554            .packs
555            .iter()
556            .flat_map(|p| p.verbs().iter().map(|v| v.name))
557            .collect();
558        Err(RuntimeError::InvalidInput(format!(
559            "unknown verb {verb:?}; available: {}",
560            available.join(", ")
561        )))
562    }
563
564    /// Find a kind hook (ADR-030) among the registered packs.
565    ///
566    /// Walks packs in registration order; the first pack that both owns the
567    /// kind (declares it in `note_kinds()` or `entity_kinds()`) and returns
568    /// a hook from `kind_hook(kind)` wins. Returns `None` if the kind is
569    /// unknown to all packs or no owning pack registered a hook.
570    pub fn find_kind_hook(&self, kind: &str) -> Option<Arc<dyn KindHook>> {
571        for pack in self.packs.iter() {
572            let owns = pack.note_kinds().contains(&kind) || pack.entity_kinds().contains(&kind);
573            if owns {
574                if let Some(hook) = pack.kind_hook(kind) {
575                    return Some(hook);
576                }
577            }
578        }
579        None
580    }
581
582    /// All verb definitions across all registered packs.
583    ///
584    /// Returned with `'static` lifetime since pack verbs are `&'static [VerbDef]`
585    /// constants — callers can keep the slice references beyond the registry's
586    /// borrow.
587    pub fn all_verbs(&self) -> Vec<&'static VerbDef> {
588        self.packs.iter().flat_map(|p| p.verbs().iter()).collect()
589    }
590
591    /// All verb definitions paired with the name of the pack that owns them.
592    ///
593    /// Useful for building catalogs that attribute each verb to its source pack.
594    /// The pack name has the same lifetime as `&self`; the `VerbDef` reference
595    /// is `'static`.
596    pub fn all_verbs_with_names(&self) -> Vec<(&str, &'static VerbDef)> {
597        self.packs
598            .iter()
599            .flat_map(|p| p.verbs().iter().map(move |v| (p.name(), v)))
600            .collect()
601    }
602
603    /// Merged set of note kinds across all registered packs (deduplicated,
604    /// first-seen order preserved).
605    pub fn all_note_kinds(&self) -> Vec<&'static str> {
606        let mut seen = std::collections::HashSet::new();
607        self.packs
608            .iter()
609            .flat_map(|p| p.note_kinds().iter().copied())
610            .filter(|k| seen.insert(*k))
611            .collect()
612    }
613
614    /// Merged set of entity kinds across all registered packs (deduplicated,
615    /// first-seen order preserved).
616    pub fn all_entity_kinds(&self) -> Vec<&'static str> {
617        let mut seen = std::collections::HashSet::new();
618        self.packs
619            .iter()
620            .flat_map(|p| p.entity_kinds().iter().copied())
621            .filter(|k| seen.insert(*k))
622            .collect()
623    }
624
625    /// Names of packs in topological load order.
626    pub fn pack_names(&self) -> Vec<&str> {
627        self.packs.iter().map(|p| p.name()).collect()
628    }
629
630    /// Declared dependencies for a registered pack (ADR-037).
631    pub fn pack_requires(&self, name: &str) -> Option<&'static [&'static str]> {
632        self.packs
633            .iter()
634            .find(|p| p.name() == name)
635            .map(|p| p.requires())
636    }
637
638    /// Note kinds owned by a specific registered pack.
639    ///
640    /// Returns `None` if no pack with `name` is registered. The slice is
641    /// the pack's `NOTE_KINDS` constant — `'static` lifetime, no allocation.
642    pub fn pack_note_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
643        self.packs
644            .iter()
645            .find(|p| p.name() == name)
646            .map(|p| p.note_kinds())
647    }
648
649    /// Entity kinds owned by a specific registered pack.
650    ///
651    /// Returns `None` if no pack with `name` is registered. The slice is
652    /// the pack's `ENTITY_KINDS` constant — `'static` lifetime, no allocation.
653    pub fn pack_entity_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
654        self.packs
655            .iter()
656            .find(|p| p.name() == name)
657            .map(|p| p.entity_kinds())
658    }
659
660    /// Verbs declared by a specific registered pack.
661    ///
662    /// Returns `None` if no pack with `name` is registered. Each `VerbDef`
663    /// carries name + description — sufficient for introspection clients
664    /// like `kkernel pack handler` (ADR-076).
665    pub fn pack_verbs(&self, name: &str) -> Option<&'static [VerbDef]> {
666        self.packs
667            .iter()
668            .find(|p| p.name() == name)
669            .map(|p| p.verbs())
670    }
671
672    /// All pack-declared edge endpoint rules across registered packs (ADR-031).
673    ///
674    /// Order follows topological pack registration; duplicates are *not* deduplicated —
675    /// validation only checks membership, and an exact-duplicate rule is a
676    /// harmless restatement.
677    pub fn all_edge_rules(&self) -> Vec<EdgeEndpointRule> {
678        self.packs
679            .iter()
680            .flat_map(|p| p.edge_rules().iter().copied())
681            .collect()
682    }
683}
684
685// ── ADR-063: inventory-based dynamic pack loading ─────────────────────────────
686
687/// Factory for creating pack instances registered via `inventory` at link time
688/// (ADR-063). Each pack crate submits a `&'static dyn PackFactory` wrapped in a
689/// [`PackRegistration`]; the binary's linker collects them all into a single
690/// slice iterable at runtime.
691///
692/// Implementors must be `Send + Sync + 'static` because the registry is built
693/// once and shared across async tasks.
694pub trait PackFactory: Send + Sync + 'static {
695    /// Canonical lowercase name for this pack (e.g. `"kg"`, `"gtd"`).
696    fn name(&self) -> &'static str;
697
698    /// Names of packs that must be loaded before this one (ADR-037).
699    ///
700    /// Defaults to empty so pack crates that have no dependencies compile
701    /// without changes. [`PackRegistry::register_packs`] uses this to compute
702    /// the transitive closure of required packs before registering anything.
703    fn requires(&self) -> &'static [&'static str] {
704        &[]
705    }
706
707    /// Create a new pack instance for the given runtime.
708    fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime>;
709}
710
711/// Newtype wrapper collected by `inventory` so pack crates can submit
712/// `&'static dyn PackFactory` references without the type-ascription syntax
713/// that `inventory::submit!` does not support for bare trait-object references
714/// (ADR-063).
715pub struct PackRegistration(pub &'static dyn PackFactory);
716
717inventory::collect!(PackRegistration);
718
719/// Registry of pack factories discovered via `inventory` at link time (ADR-063).
720///
721/// No instance is needed — all methods are associated functions that walk the
722/// globally-collected [`PackRegistration`] slice.
723pub struct PackRegistry;
724
725impl PackRegistry {
726    /// Names of all pack factories discovered via `inventory`.
727    pub fn discovered_names() -> Vec<&'static str> {
728        inventory::iter::<PackRegistration>
729            .into_iter()
730            .map(|r| r.0.name())
731            .collect()
732    }
733
734    /// Register the named packs into `builder` using the supplied `runtime`.
735    ///
736    /// Resolves transitive `requires()` dependencies declared on each
737    /// [`PackFactory`] before registering anything. A pack that declares
738    /// `requires = &["kg"]` will cause `"kg"` to be included even if the caller
739    /// only asked for `"gtd"`.  The [`VerbRegistryBuilder::build`] topo-sort
740    /// then ensures correct load order.
741    ///
742    /// Returns `Ok(())` when all names (including their transitive deps) are
743    /// recognised; returns `Err(name)` for the first unrecognised name so
744    /// callers can surface a clear error.
745    pub fn register_packs(
746        names: &[String],
747        runtime: KhiveRuntime,
748        builder: &mut VerbRegistryBuilder,
749    ) -> Result<(), String> {
750        // Build a name→factory index once.
751        let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
752            .into_iter()
753            .map(|r| r.0)
754            .collect();
755        let factory_for = |name: &str| -> Option<&'static dyn PackFactory> {
756            all.iter().copied().find(|f| f.name() == name)
757        };
758
759        // BFS transitive closure: start with the explicitly requested names,
760        // then walk each factory's requires() to pull in dependencies.
761        let mut full_set: std::collections::HashSet<&str> = std::collections::HashSet::new();
762        let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
763
764        for name in names {
765            queue.push_back(name.as_str());
766        }
767
768        while let Some(name) = queue.pop_front() {
769            if !full_set.insert(name) {
770                continue; // already visited
771            }
772            let factory = factory_for(name).ok_or_else(|| name.to_string())?;
773            for &dep in factory.requires() {
774                if !full_set.contains(dep) {
775                    queue.push_back(dep);
776                }
777            }
778        }
779
780        // Register every pack in the resolved set; VerbRegistryBuilder::build()
781        // performs the topo-sort, so insertion order here does not matter.
782        for name in &full_set {
783            // factory_for cannot fail here: every name in full_set passed the
784            // lookup above without returning Err.
785            let factory = factory_for(name).unwrap();
786            builder.register_boxed(factory.create(runtime.clone()));
787        }
788
789        Ok(())
790    }
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796    use khive_types::Pack;
797
798    struct AlphaPack;
799
800    impl Pack for AlphaPack {
801        const NAME: &'static str = "alpha";
802        const NOTE_KINDS: &'static [&'static str] = &["memo", "log"];
803        const ENTITY_KINDS: &'static [&'static str] = &["widget"];
804        const VERBS: &'static [VerbDef] = &[
805            VerbDef {
806                name: "create",
807                description: "create a widget",
808            },
809            VerbDef {
810                name: "list",
811                description: "list widgets",
812            },
813        ];
814    }
815
816    #[async_trait]
817    impl PackRuntime for AlphaPack {
818        fn name(&self) -> &str {
819            AlphaPack::NAME
820        }
821        fn note_kinds(&self) -> &'static [&'static str] {
822            AlphaPack::NOTE_KINDS
823        }
824        fn entity_kinds(&self) -> &'static [&'static str] {
825            AlphaPack::ENTITY_KINDS
826        }
827        fn verbs(&self) -> &'static [VerbDef] {
828            AlphaPack::VERBS
829        }
830        async fn dispatch(
831            &self,
832            verb: &str,
833            _params: Value,
834            _registry: &VerbRegistry,
835        ) -> Result<Value, RuntimeError> {
836            Ok(serde_json::json!({ "pack": "alpha", "verb": verb }))
837        }
838    }
839
840    struct BetaPack;
841
842    impl Pack for BetaPack {
843        const NAME: &'static str = "beta";
844        const NOTE_KINDS: &'static [&'static str] = &["log", "alert"];
845        const ENTITY_KINDS: &'static [&'static str] = &["widget", "gadget"];
846        const VERBS: &'static [VerbDef] = &[
847            VerbDef {
848                name: "notify",
849                description: "send alert",
850            },
851            VerbDef {
852                name: "create",
853                description: "create a gadget",
854            },
855        ];
856    }
857
858    #[async_trait]
859    impl PackRuntime for BetaPack {
860        fn name(&self) -> &str {
861            BetaPack::NAME
862        }
863        fn note_kinds(&self) -> &'static [&'static str] {
864            BetaPack::NOTE_KINDS
865        }
866        fn entity_kinds(&self) -> &'static [&'static str] {
867            BetaPack::ENTITY_KINDS
868        }
869        fn verbs(&self) -> &'static [VerbDef] {
870            BetaPack::VERBS
871        }
872        async fn dispatch(
873            &self,
874            verb: &str,
875            _params: Value,
876            _registry: &VerbRegistry,
877        ) -> Result<Value, RuntimeError> {
878            Ok(serde_json::json!({ "pack": "beta", "verb": verb }))
879        }
880    }
881
882    fn build_registry() -> VerbRegistry {
883        let mut builder = VerbRegistryBuilder::new();
884        builder.register(AlphaPack);
885        builder.register(BetaPack);
886        builder.build().expect("registry builds")
887    }
888
889    #[tokio::test]
890    async fn dispatch_routes_to_correct_pack() {
891        let reg = build_registry();
892
893        let res = reg.dispatch("list", Value::Null).await.unwrap();
894        assert_eq!(res["pack"], "alpha");
895
896        let res = reg.dispatch("notify", Value::Null).await.unwrap();
897        assert_eq!(res["pack"], "beta");
898    }
899
900    #[tokio::test]
901    async fn dispatch_first_registered_wins_on_collision() {
902        let reg = build_registry();
903
904        let res = reg.dispatch("create", Value::Null).await.unwrap();
905        assert_eq!(res["pack"], "alpha", "first registered pack wins");
906    }
907
908    #[tokio::test]
909    async fn dispatch_unknown_verb_returns_error() {
910        let reg = build_registry();
911
912        let err = reg.dispatch("explode", Value::Null).await.unwrap_err();
913        let msg = err.to_string();
914        assert!(msg.contains("explode"));
915        assert!(msg.contains("create"));
916    }
917
918    #[test]
919    fn all_verbs_aggregates_across_packs() {
920        let reg = build_registry();
921        let verbs: Vec<&str> = reg.all_verbs().iter().map(|v| v.name).collect();
922        assert_eq!(verbs, vec!["create", "list", "notify", "create"]);
923    }
924
925    #[test]
926    fn all_verbs_with_names_pairs_pack_name() {
927        let reg = build_registry();
928        let pairs: Vec<(&str, &str)> = reg
929            .all_verbs_with_names()
930            .iter()
931            .map(|(pack, v)| (*pack, v.name))
932            .collect();
933        assert_eq!(
934            pairs,
935            vec![
936                ("alpha", "create"),
937                ("alpha", "list"),
938                ("beta", "notify"),
939                ("beta", "create"),
940            ]
941        );
942    }
943
944    #[test]
945    fn note_kinds_are_deduplicated() {
946        let reg = build_registry();
947        let kinds = reg.all_note_kinds();
948        assert_eq!(kinds, vec!["memo", "log", "alert"]);
949    }
950
951    #[test]
952    fn entity_kinds_are_deduplicated() {
953        let reg = build_registry();
954        let kinds = reg.all_entity_kinds();
955        assert_eq!(kinds, vec!["widget", "gadget"]);
956    }
957
958    // ---- Gate wiring (ADR-029) ----
959
960    use khive_gate::{Gate, GateError};
961    use std::sync::atomic::{AtomicUsize, Ordering};
962    use std::sync::Arc;
963
964    #[derive(Default, Debug)]
965    struct CountingGate {
966        calls: AtomicUsize,
967        deny_verb: Option<&'static str>,
968    }
969
970    impl Gate for CountingGate {
971        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
972            self.calls.fetch_add(1, Ordering::SeqCst);
973            if Some(req.verb.as_str()) == self.deny_verb {
974                Ok(GateDecision::deny(format!("test deny for {}", req.verb)))
975            } else {
976                Ok(GateDecision::allow())
977            }
978        }
979    }
980
981    #[tokio::test]
982    async fn dispatch_consults_the_gate() {
983        let gate = Arc::new(CountingGate::default());
984        let mut builder = VerbRegistryBuilder::new();
985        builder.register(AlphaPack);
986        builder.with_gate(gate.clone());
987        let reg = builder.build().expect("registry builds");
988
989        reg.dispatch("list", Value::Null).await.unwrap();
990        reg.dispatch("create", Value::Null).await.unwrap();
991        assert_eq!(
992            gate.calls.load(Ordering::SeqCst),
993            2,
994            "gate should be consulted once per dispatch"
995        );
996    }
997
998    #[tokio::test]
999    async fn dispatch_returns_permission_denied_on_deny_v03() {
1000        let gate = Arc::new(CountingGate {
1001            calls: AtomicUsize::new(0),
1002            deny_verb: Some("create"),
1003        });
1004        let mut builder = VerbRegistryBuilder::new();
1005        builder.register(AlphaPack);
1006        builder.with_gate(gate.clone());
1007        let reg = builder.build().expect("registry builds");
1008
1009        // Gate denies — dispatch now returns PermissionDenied (hard enforcement, ADR-035).
1010        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
1011        assert!(
1012            matches!(err, RuntimeError::PermissionDenied { ref verb, .. } if verb == "create"),
1013            "expected PermissionDenied, got {err:?}"
1014        );
1015        let msg = err.to_string();
1016        assert!(
1017            msg.contains("create"),
1018            "error message must name the verb: {msg}"
1019        );
1020        assert!(
1021            msg.contains("test deny for create"),
1022            "error message must carry the deny reason: {msg}"
1023        );
1024        assert_eq!(gate.calls.load(Ordering::SeqCst), 1);
1025    }
1026
1027    #[tokio::test]
1028    async fn dispatch_allow_verb_succeeds_even_with_deny_gate_for_other_verb() {
1029        // Deny only "create" — "list" must still work.
1030        let gate = Arc::new(CountingGate {
1031            calls: AtomicUsize::new(0),
1032            deny_verb: Some("create"),
1033        });
1034        let mut builder = VerbRegistryBuilder::new();
1035        builder.register(AlphaPack);
1036        builder.with_gate(gate.clone());
1037        let reg = builder.build().expect("registry builds");
1038
1039        let res = reg.dispatch("list", Value::Null).await.unwrap();
1040        assert_eq!(res["pack"], "alpha");
1041    }
1042
1043    #[tokio::test]
1044    async fn dispatch_uses_allow_all_gate_by_default() {
1045        // No `with_gate` call — builder should use `AllowAllGate` so dispatch works.
1046        let reg = build_registry();
1047        let res = reg.dispatch("list", Value::Null).await.unwrap();
1048        assert_eq!(res["pack"], "alpha");
1049    }
1050
1051    // Captures the namespace each call sees so we can assert what the gate
1052    // actually receives — codex round-1 caught us hard-wiring `default_ns()`.
1053    #[derive(Default, Debug)]
1054    struct NamespaceCapturingGate {
1055        seen: std::sync::Mutex<Vec<String>>,
1056    }
1057
1058    impl Gate for NamespaceCapturingGate {
1059        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
1060            self.seen
1061                .lock()
1062                .unwrap()
1063                .push(req.namespace.as_str().to_string());
1064            Ok(GateDecision::allow())
1065        }
1066    }
1067
1068    #[tokio::test]
1069    async fn dispatch_propagates_params_namespace_to_gate() {
1070        let gate = Arc::new(NamespaceCapturingGate::default());
1071        let mut builder = VerbRegistryBuilder::new();
1072        builder.register(AlphaPack);
1073        builder.with_gate(gate.clone());
1074        builder.with_default_namespace("tenant-x");
1075        let reg = builder.build().expect("registry builds");
1076
1077        // Explicit namespace in params wins.
1078        reg.dispatch("list", serde_json::json!({"namespace": "tenant-y"}))
1079            .await
1080            .unwrap();
1081        // Missing namespace → registry default.
1082        reg.dispatch("list", Value::Null).await.unwrap();
1083        // Explicit empty namespace string is preserved (it is what
1084        // `KhiveRuntime::ns` would also see). Gate and runtime MUST agree on
1085        // the namespace they observe; coercing here while the runtime
1086        // continues to honor `""` would create an audit blind spot.
1087        reg.dispatch("list", serde_json::json!({"namespace": ""}))
1088            .await
1089            .unwrap();
1090
1091        let seen = gate.seen.lock().unwrap().clone();
1092        assert_eq!(seen, vec!["tenant-y", "tenant-x", ""]);
1093    }
1094
1095    #[tokio::test]
1096    async fn dispatch_falls_back_to_local_when_no_default_set() {
1097        // Builder default mirrors `Namespace::default_ns()`.
1098        let gate = Arc::new(NamespaceCapturingGate::default());
1099        let mut builder = VerbRegistryBuilder::new();
1100        builder.register(AlphaPack);
1101        builder.with_gate(gate.clone());
1102        let reg = builder.build().expect("registry builds");
1103
1104        reg.dispatch("list", Value::Null).await.unwrap();
1105        let seen = gate.seen.lock().unwrap().clone();
1106        assert_eq!(seen, vec!["local"]);
1107    }
1108
1109    // ---- Audit event emission (ADR-033) ----
1110
1111    use khive_gate::{AuditDecision, AuditEvent, Obligation};
1112
1113    /// A gate that records every audit event emitted via from_check.
1114    #[derive(Default, Debug)]
1115    struct AuditCapturingGate {
1116        events: std::sync::Mutex<Vec<AuditEvent>>,
1117        deny_verb: Option<&'static str>,
1118    }
1119
1120    impl Gate for AuditCapturingGate {
1121        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
1122            let decision = if Some(req.verb.as_str()) == self.deny_verb {
1123                GateDecision::deny("test deny")
1124            } else {
1125                GateDecision::allow_with(vec![Obligation::Audit {
1126                    tag: format!("{}.check", req.verb),
1127                }])
1128            };
1129            // Capture what dispatch will also emit.
1130            let ev = AuditEvent::from_check(req, &decision, self.impl_name());
1131            self.events.lock().unwrap().push(ev);
1132            Ok(decision)
1133        }
1134
1135        fn impl_name(&self) -> &'static str {
1136            "AuditCapturingGate"
1137        }
1138    }
1139
1140    #[tokio::test]
1141    async fn dispatch_emits_one_audit_event_per_call() {
1142        let gate = Arc::new(AuditCapturingGate::default());
1143        let mut builder = VerbRegistryBuilder::new();
1144        builder.register(AlphaPack);
1145        builder.with_gate(gate.clone());
1146        let reg = builder.build().expect("registry builds");
1147
1148        reg.dispatch("list", Value::Null).await.unwrap();
1149        reg.dispatch("create", Value::Null).await.unwrap();
1150
1151        let evs = gate.events.lock().unwrap();
1152        assert_eq!(evs.len(), 2, "exactly one audit event per dispatch call");
1153    }
1154
1155    #[tokio::test]
1156    async fn dispatch_audit_event_allow_carries_obligations() {
1157        let gate = Arc::new(AuditCapturingGate::default());
1158        let mut builder = VerbRegistryBuilder::new();
1159        builder.register(AlphaPack);
1160        builder.with_gate(gate.clone());
1161        let reg = builder.build().expect("registry builds");
1162
1163        reg.dispatch("list", Value::Null).await.unwrap();
1164
1165        let evs = gate.events.lock().unwrap();
1166        let ev = &evs[0];
1167        assert_eq!(ev.verb, "list");
1168        assert_eq!(ev.decision, AuditDecision::Allow);
1169        assert!(ev.deny_reason.is_none());
1170        assert_eq!(ev.obligations.len(), 1);
1171        assert_eq!(ev.gate_impl, "AuditCapturingGate");
1172    }
1173
1174    #[tokio::test]
1175    async fn dispatch_audit_event_deny_carries_reason() {
1176        let gate = Arc::new(AuditCapturingGate {
1177            events: Default::default(),
1178            deny_verb: Some("create"),
1179        });
1180        let mut builder = VerbRegistryBuilder::new();
1181        builder.register(AlphaPack);
1182        builder.with_gate(gate.clone());
1183        let reg = builder.build().expect("registry builds");
1184
1185        // Gate denies — dispatch returns PermissionDenied (hard enforcement, ADR-035).
1186        // The audit event is still recorded (captured inside the gate impl).
1187        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
1188        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
1189
1190        let evs = gate.events.lock().unwrap();
1191        let ev = &evs[0];
1192        assert_eq!(ev.verb, "create");
1193        assert_eq!(ev.decision, AuditDecision::Deny);
1194        assert_eq!(ev.deny_reason.as_deref(), Some("test deny"));
1195        assert!(ev.obligations.is_empty());
1196    }
1197
1198    #[tokio::test]
1199    async fn dispatch_audit_event_fields_match_gate_request() {
1200        let gate = Arc::new(AuditCapturingGate::default());
1201        let mut builder = VerbRegistryBuilder::new();
1202        builder.register(AlphaPack);
1203        builder.with_gate(gate.clone());
1204        builder.with_default_namespace("tenant-z");
1205        let reg = builder.build().expect("registry builds");
1206
1207        reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
1208            .await
1209            .unwrap();
1210
1211        let evs = gate.events.lock().unwrap();
1212        let ev = &evs[0];
1213        // Namespace from params wins (ADR-029 alignment rule).
1214        assert_eq!(ev.namespace, "tenant-q");
1215        assert_eq!(ev.verb, "list");
1216        assert_eq!(ev.actor.kind, "anonymous");
1217    }
1218
1219    // ---- Audit tracing emission (ADR-033 §"Emission site") ----
1220    //
1221    // The AuditCapturingGate tests above prove that AuditEvent::from_check is
1222    // called with the right inputs, but they observe the event *inside* the
1223    // gate impl — they would still pass if dispatch's
1224    // `tracing::info!(audit_event = ..., "gate.check")` were deleted or
1225    // renamed. The tests below install a capture Layer and assert on the
1226    // actual tracing event surfaced from dispatch. This locks the public
1227    // observability contract from ADR-033: one `gate.check` info event per
1228    // dispatch, carrying an `audit_event` field that round-trips back to an
1229    // `AuditEvent`.
1230
1231    use std::sync::{Mutex as StdMutex, Once, OnceLock};
1232
1233    use serial_test::serial;
1234    use tracing::field::{Field, Visit};
1235
1236    #[derive(Clone, Debug, Default)]
1237    struct CapturedEvent {
1238        message: Option<String>,
1239        audit_event: Option<String>,
1240    }
1241
1242    #[derive(Default)]
1243    struct CapturedEventVisitor(CapturedEvent);
1244
1245    impl Visit for CapturedEventVisitor {
1246        fn record_str(&mut self, field: &Field, value: &str) {
1247            match field.name() {
1248                "message" => self.0.message = Some(value.to_string()),
1249                "audit_event" => self.0.audit_event = Some(value.to_string()),
1250                _ => {}
1251            }
1252        }
1253
1254        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
1255            // `tracing::info!(audit_event = %expr, "msg")` records via the
1256            // Display-wrapped Debug path, so we receive the JSON string here.
1257            // `"msg"` literal records as a `message` field via `record_debug`
1258            // with a quoted Debug representation; strip the surrounding quotes
1259            // so the captured message matches the source.
1260            let formatted = format!("{value:?}");
1261            let cleaned = formatted
1262                .trim_start_matches('"')
1263                .trim_end_matches('"')
1264                .to_string();
1265            match field.name() {
1266                "message" => self.0.message = Some(cleaned),
1267                "audit_event" => self.0.audit_event = Some(cleaned),
1268                _ => {}
1269            }
1270        }
1271    }
1272
1273    /// Minimal `tracing::Subscriber` that captures events into a shared vec.
1274    ///
1275    /// Implemented directly (without `tracing_subscriber::registry()` layering)
1276    /// to avoid the layer machinery that can cause thread-local dispatch to be
1277    /// bypassed when the registry's internal global state is initialised by
1278    /// another subscriber in the same test binary.
1279    ///
1280    /// Isolation across concurrent tests is handled at the dispatcher level by
1281    /// `tracing::dispatcher::with_default`, which installs this subscriber
1282    /// as the thread-local default for the duration of the test closure.
1283    /// Other threads (e.g. `#[tokio::test]` pool workers) emit through their
1284    /// own (typically NoSubscriber) dispatchers and never reach this instance.
1285    struct CaptureSubscriber {
1286        events: Arc<StdMutex<Vec<CapturedEvent>>>,
1287    }
1288
1289    impl CaptureSubscriber {
1290        fn new(events: Arc<StdMutex<Vec<CapturedEvent>>>) -> Self {
1291            Self { events }
1292        }
1293    }
1294
1295    impl tracing::Subscriber for CaptureSubscriber {
1296        fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
1297            true
1298        }
1299        fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1300            tracing::span::Id::from_u64(1)
1301        }
1302        fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
1303        fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
1304        fn event(&self, event: &tracing::Event<'_>) {
1305            let mut visitor = CapturedEventVisitor::default();
1306            event.record(&mut visitor);
1307            self.events.lock().unwrap().push(visitor.0);
1308        }
1309        fn enter(&self, _: &tracing::span::Id) {}
1310        fn exit(&self, _: &tracing::span::Id) {}
1311    }
1312
1313    /// Global capture buffer for the tracing tests.
1314    ///
1315    /// The subscriber is installed exactly once via `set_global_default`
1316    /// (thread-local dispatchers via `with_default` proved unreliable when
1317    /// other tests in the binary configure their own dispatchers in parallel —
1318    /// the global state interacted unpredictably and events were lost).
1319    ///
1320    /// Each test that uses this buffer is `#[serial]`, so only one
1321    /// runs at a time. The buffer is cleared at the start of each capture call.
1322    static GLOBAL_CAPTURE: OnceLock<Arc<StdMutex<Vec<CapturedEvent>>>> = OnceLock::new();
1323    static GLOBAL_INIT: Once = Once::new();
1324
1325    fn global_capture() -> Arc<StdMutex<Vec<CapturedEvent>>> {
1326        GLOBAL_INIT.call_once(|| {
1327            let buffer = Arc::new(StdMutex::new(Vec::new()));
1328            let subscriber = CaptureSubscriber::new(Arc::clone(&buffer));
1329            // Ignore error: if another subscriber is already set globally, our
1330            // subscriber installation fails, but the buffer will simply stay
1331            // empty and tests will fail with a clear "got 0 events" message
1332            // rather than a silent corruption.
1333            let _ = tracing::subscriber::set_global_default(subscriber);
1334            let _ = GLOBAL_CAPTURE.set(buffer);
1335        });
1336        Arc::clone(GLOBAL_CAPTURE.get().expect("global capture initialized"))
1337    }
1338
1339    /// Run an async block under the global capture subscriber and return
1340    /// the events emitted during the run. Clears the buffer at the start.
1341    ///
1342    /// Callers MUST be `#[serial]` to prevent concurrent buffer pollution.
1343    fn capture_dispatch_events<Fut>(future: Fut) -> Vec<CapturedEvent>
1344    where
1345        Fut: std::future::Future<Output = ()>,
1346    {
1347        let buffer = global_capture();
1348        buffer.lock().unwrap().clear();
1349
1350        let rt = tokio::runtime::Builder::new_current_thread()
1351            .enable_all()
1352            .build()
1353            .expect("build current-thread tokio runtime");
1354        rt.block_on(future);
1355
1356        let result = buffer.lock().unwrap().clone();
1357        result
1358    }
1359
1360    /// Pull every captured event whose `message` matches `"gate.check"` AND
1361    /// whose audit_event JSON declares the expected `gate_impl` name.
1362    ///
1363    /// Filtering by `gate_impl` lets concurrent tests in the same binary
1364    /// emit their own gate.check events into the global capture buffer
1365    /// without polluting each others' counts.
1366    fn gate_check_events_for(events: &[CapturedEvent], gate_impl: &str) -> Vec<CapturedEvent> {
1367        events
1368            .iter()
1369            .filter(|e| e.message.as_deref() == Some("gate.check"))
1370            .filter(|e| {
1371                e.audit_event
1372                    .as_deref()
1373                    .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
1374                    .and_then(|v| {
1375                        v.get("gate_impl")
1376                            .and_then(|g| g.as_str().map(|s| s.to_string()))
1377                    })
1378                    .as_deref()
1379                    == Some(gate_impl)
1380            })
1381            .cloned()
1382            .collect()
1383    }
1384
1385    #[test]
1386    #[serial]
1387    fn dispatch_tracing_emits_one_gate_check_event_on_allow() {
1388        #[derive(Debug)]
1389        struct TracingAllowGate;
1390        impl Gate for TracingAllowGate {
1391            fn check(&self, _: &GateRequest) -> Result<GateDecision, GateError> {
1392                Ok(GateDecision::allow())
1393            }
1394            fn impl_name(&self) -> &'static str {
1395                "TracingAllowGate"
1396            }
1397        }
1398
1399        let events = capture_dispatch_events(async {
1400            let mut builder = VerbRegistryBuilder::new();
1401            builder.register(AlphaPack);
1402            builder.with_gate(Arc::new(TracingAllowGate));
1403            builder.with_default_namespace("tenant-default");
1404            let reg = builder.build().expect("registry builds");
1405            reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
1406                .await
1407                .unwrap();
1408        });
1409
1410        let gate_events = gate_check_events_for(&events, "TracingAllowGate");
1411        assert_eq!(
1412            gate_events.len(),
1413            1,
1414            "exactly one gate.check tracing event per dispatch (allow); got {gate_events:?}"
1415        );
1416        let payload = gate_events[0]
1417            .audit_event
1418            .as_ref()
1419            .expect("gate.check event must carry an audit_event field");
1420        let audit: khive_gate::AuditEvent =
1421            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
1422        assert_eq!(audit.decision, AuditDecision::Allow);
1423        assert_eq!(audit.verb, "list");
1424        assert_eq!(audit.namespace, "tenant-q");
1425        assert_eq!(audit.gate_impl, "TracingAllowGate");
1426        assert!(
1427            audit.deny_reason.is_none(),
1428            "deny_reason must be None on Allow"
1429        );
1430    }
1431
1432    // ---- Hard enforcement + EventStore persistence (ADR-035) ----
1433
1434    use async_trait::async_trait;
1435    use khive_storage::{
1436        BatchWriteSummary, Event, EventFilter, EventStore, Page, PageRequest, SubstrateKind,
1437    };
1438    use khive_types::EventOutcome;
1439
1440    /// In-memory EventStore for unit tests — avoids file-backed SQLite.
1441    #[derive(Default, Debug)]
1442    struct MemoryEventStore {
1443        events: std::sync::Mutex<Vec<Event>>,
1444    }
1445
1446    #[async_trait]
1447    impl EventStore for MemoryEventStore {
1448        async fn append_event(&self, event: Event) -> khive_storage::StorageResult<()> {
1449            self.events.lock().unwrap().push(event);
1450            Ok(())
1451        }
1452        async fn append_events(
1453            &self,
1454            events: Vec<Event>,
1455        ) -> khive_storage::StorageResult<BatchWriteSummary> {
1456            let attempted = events.len() as u64;
1457            let affected = attempted;
1458            self.events.lock().unwrap().extend(events);
1459            Ok(BatchWriteSummary {
1460                attempted,
1461                affected,
1462                failed: 0,
1463                first_error: String::new(),
1464            })
1465        }
1466        async fn get_event(&self, id: uuid::Uuid) -> khive_storage::StorageResult<Option<Event>> {
1467            Ok(self
1468                .events
1469                .lock()
1470                .unwrap()
1471                .iter()
1472                .find(|e| e.id == id)
1473                .cloned())
1474        }
1475        async fn query_events(
1476            &self,
1477            _filter: EventFilter,
1478            _page: PageRequest,
1479        ) -> khive_storage::StorageResult<Page<Event>> {
1480            let items = self.events.lock().unwrap().clone();
1481            let total = items.len() as u64;
1482            Ok(Page {
1483                items,
1484                total: Some(total),
1485            })
1486        }
1487        async fn count_events(&self, _filter: EventFilter) -> khive_storage::StorageResult<u64> {
1488            Ok(self.events.lock().unwrap().len() as u64)
1489        }
1490    }
1491
1492    #[tokio::test]
1493    async fn allow_all_gate_default_remains_backward_compatible() {
1494        // No gate set — AllowAllGate is the default. Dispatch must succeed.
1495        let mut builder = VerbRegistryBuilder::new();
1496        builder.register(AlphaPack);
1497        let reg = builder.build().expect("registry builds");
1498
1499        let res = reg.dispatch("list", Value::Null).await.unwrap();
1500        assert_eq!(
1501            res["pack"], "alpha",
1502            "AllowAllGate must allow every verb — backward compat guarantee"
1503        );
1504        let res = reg.dispatch("create", Value::Null).await.unwrap();
1505        assert_eq!(res["pack"], "alpha");
1506    }
1507
1508    #[tokio::test]
1509    async fn deny_gate_returns_permission_denied_pack_never_invoked() {
1510        #[derive(Debug)]
1511        struct AlwaysDenyGate;
1512        impl Gate for AlwaysDenyGate {
1513            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1514                Ok(GateDecision::deny("test: always deny"))
1515            }
1516        }
1517
1518        // Track whether dispatch was ever invoked on the pack.
1519        #[derive(Debug)]
1520        struct TrackedPack {
1521            invoked: Arc<AtomicUsize>,
1522        }
1523
1524        impl khive_types::Pack for TrackedPack {
1525            const NAME: &'static str = "tracked";
1526            const NOTE_KINDS: &'static [&'static str] = &[];
1527            const ENTITY_KINDS: &'static [&'static str] = &[];
1528            const VERBS: &'static [VerbDef] = &[VerbDef {
1529                name: "guarded",
1530                description: "a guarded verb",
1531            }];
1532        }
1533
1534        #[async_trait]
1535        impl PackRuntime for TrackedPack {
1536            fn name(&self) -> &str {
1537                Self::NAME
1538            }
1539            fn note_kinds(&self) -> &'static [&'static str] {
1540                Self::NOTE_KINDS
1541            }
1542            fn entity_kinds(&self) -> &'static [&'static str] {
1543                Self::ENTITY_KINDS
1544            }
1545            fn verbs(&self) -> &'static [VerbDef] {
1546                Self::VERBS
1547            }
1548            async fn dispatch(
1549                &self,
1550                _verb: &str,
1551                _params: Value,
1552                _registry: &VerbRegistry,
1553            ) -> Result<Value, RuntimeError> {
1554                self.invoked.fetch_add(1, Ordering::SeqCst);
1555                Ok(serde_json::json!({"invoked": true}))
1556            }
1557        }
1558
1559        let invoked = Arc::new(AtomicUsize::new(0));
1560        let mut builder = VerbRegistryBuilder::new();
1561        builder.register(TrackedPack {
1562            invoked: invoked.clone(),
1563        });
1564        builder.with_gate(Arc::new(AlwaysDenyGate));
1565        let reg = builder.build().expect("registry builds");
1566
1567        let err = reg.dispatch("guarded", Value::Null).await.unwrap_err();
1568        assert!(
1569            matches!(err, RuntimeError::PermissionDenied { ref verb, ref reason } if verb == "guarded" && reason.contains("always deny")),
1570            "expected PermissionDenied with verb=guarded and reason, got: {err:?}"
1571        );
1572        assert_eq!(
1573            invoked.load(Ordering::SeqCst),
1574            0,
1575            "pack dispatch MUST NOT be invoked when gate denies"
1576        );
1577    }
1578
1579    #[tokio::test]
1580    async fn audit_event_persists_to_event_store_on_allow() {
1581        let store = Arc::new(MemoryEventStore::default());
1582        let mut builder = VerbRegistryBuilder::new();
1583        builder.register(AlphaPack);
1584        builder.with_event_store(store.clone());
1585        let reg = builder.build().expect("registry builds");
1586
1587        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
1588            .await
1589            .unwrap();
1590
1591        let count = store.count_events(EventFilter::default()).await.unwrap();
1592        assert_eq!(count, 1, "one audit event persisted to EventStore on allow");
1593
1594        let page = store
1595            .query_events(
1596                EventFilter::default(),
1597                PageRequest {
1598                    limit: 10,
1599                    offset: 0,
1600                },
1601            )
1602            .await
1603            .unwrap();
1604        let ev = &page.items[0];
1605        assert_eq!(ev.verb, "list");
1606        assert_eq!(ev.namespace, "test-ns");
1607        assert_eq!(ev.substrate, SubstrateKind::Event);
1608        assert_eq!(ev.outcome, EventOutcome::Success);
1609    }
1610
1611    #[tokio::test]
1612    async fn audit_event_persists_to_event_store_on_deny() {
1613        #[derive(Debug)]
1614        struct AlwaysDenyGate;
1615        impl Gate for AlwaysDenyGate {
1616            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1617                Ok(GateDecision::deny("denied by test"))
1618            }
1619        }
1620
1621        let store = Arc::new(MemoryEventStore::default());
1622        let mut builder = VerbRegistryBuilder::new();
1623        builder.register(AlphaPack);
1624        builder.with_gate(Arc::new(AlwaysDenyGate));
1625        builder.with_event_store(store.clone());
1626        let reg = builder.build().expect("registry builds");
1627
1628        // Hard enforce → PermissionDenied returned.
1629        let err = reg
1630            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
1631            .await
1632            .unwrap_err();
1633        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
1634
1635        let count = store.count_events(EventFilter::default()).await.unwrap();
1636        assert_eq!(count, 1, "one audit event persisted to EventStore on deny");
1637
1638        let page = store
1639            .query_events(
1640                EventFilter::default(),
1641                PageRequest {
1642                    limit: 10,
1643                    offset: 0,
1644                },
1645            )
1646            .await
1647            .unwrap();
1648        let ev = &page.items[0];
1649        assert_eq!(ev.verb, "list");
1650        assert_eq!(ev.outcome, EventOutcome::Denied);
1651    }
1652
1653    #[tokio::test]
1654    async fn gate_error_does_not_persist_to_event_store() {
1655        #[derive(Debug)]
1656        struct FailingGate;
1657        impl Gate for FailingGate {
1658            fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
1659                Err(khive_gate::GateError::Internal("gate broken".into()))
1660            }
1661        }
1662
1663        let store = Arc::new(MemoryEventStore::default());
1664        let mut builder = VerbRegistryBuilder::new();
1665        builder.register(AlphaPack);
1666        builder.with_gate(Arc::new(FailingGate));
1667        builder.with_event_store(store.clone());
1668        let reg = builder.build().expect("registry builds");
1669
1670        // Gate Err → fail-open, dispatch proceeds.
1671        let res = reg.dispatch("list", Value::Null).await.unwrap();
1672        assert_eq!(
1673            res["pack"], "alpha",
1674            "gate error must fail-open, not block dispatch"
1675        );
1676
1677        let count = store.count_events(EventFilter::default()).await.unwrap();
1678        assert_eq!(
1679            count, 0,
1680            "gate infrastructure error must NOT produce an audit event in EventStore"
1681        );
1682    }
1683
1684    #[tokio::test]
1685    async fn no_event_store_configured_tracing_only() {
1686        // When no event_store is configured, dispatch must succeed without error.
1687        // (The tracing path is exercised in the tracing tests above; here we just
1688        // verify the absence of event_store does not break dispatch.)
1689        let mut builder = VerbRegistryBuilder::new();
1690        builder.register(AlphaPack);
1691        let reg = builder.build().expect("registry builds");
1692
1693        let res = reg.dispatch("list", Value::Null).await.unwrap();
1694        assert_eq!(res["pack"], "alpha");
1695    }
1696
1697    #[test]
1698    #[serial]
1699    fn dispatch_tracing_emits_gate_check_event_with_deny_payload() {
1700        #[derive(Debug)]
1701        struct TracingDenyGate;
1702        impl Gate for TracingDenyGate {
1703            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1704                Ok(GateDecision::deny("denied by test gate"))
1705            }
1706            fn impl_name(&self) -> &'static str {
1707                "TracingDenyGate"
1708            }
1709        }
1710
1711        let events = capture_dispatch_events(async {
1712            let mut builder = VerbRegistryBuilder::new();
1713            builder.register(AlphaPack);
1714            builder.with_gate(Arc::new(TracingDenyGate));
1715            let reg = builder.build().expect("registry builds");
1716            // Hard enforcement (ADR-035) — dispatch returns PermissionDenied on Deny.
1717            // The tracing audit event is still emitted before the error is returned.
1718            let _ = reg.dispatch("create", serde_json::Value::Null).await;
1719        });
1720
1721        let gate_events = gate_check_events_for(&events, "TracingDenyGate");
1722        assert_eq!(
1723            gate_events.len(),
1724            1,
1725            "exactly one gate.check tracing event per dispatch (deny); got {gate_events:?}"
1726        );
1727        let payload = gate_events[0]
1728            .audit_event
1729            .as_ref()
1730            .expect("gate.check event must carry an audit_event field on Deny");
1731        let audit: khive_gate::AuditEvent =
1732            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
1733        assert_eq!(audit.decision, AuditDecision::Deny);
1734        assert_eq!(audit.deny_reason.as_deref(), Some("denied by test gate"));
1735        assert_eq!(audit.gate_impl, "TracingDenyGate");
1736        // Wire-shape rule from ADR-033: obligations is always serialized as an
1737        // array, empty on Deny. Round-trip back through serde_json::Value to
1738        // confirm the field exists on the wire and is `[]`, not missing.
1739        let payload_json: serde_json::Value =
1740            serde_json::from_str(payload).expect("payload must be valid JSON");
1741        assert_eq!(
1742            payload_json["obligations"],
1743            serde_json::Value::Array(Vec::new()),
1744            "obligations must be `[]` on Deny on the tracing payload, not omitted"
1745        );
1746    }
1747
1748    // ---- EventStore audit envelope round-trip (ADR-033 / ADR-035) ----
1749    //
1750    // Codex review finding (Major #1): EventStore was persisting a summary
1751    // Event without the full AuditEvent fields (deny_reason, gate_impl,
1752    // obligations). This test verifies the complete envelope survives
1753    // append_event → query_events.
1754
1755    #[tokio::test]
1756    async fn audit_envelope_round_trips_deny_reason_and_gate_impl_through_event_store() {
1757        #[derive(Debug)]
1758        struct DenyGateWithName;
1759        impl Gate for DenyGateWithName {
1760            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1761                Ok(GateDecision::deny("policy: write forbidden for anon"))
1762            }
1763            fn impl_name(&self) -> &'static str {
1764                "DenyGateWithName"
1765            }
1766        }
1767
1768        let store = Arc::new(MemoryEventStore::default());
1769        let mut builder = VerbRegistryBuilder::new();
1770        builder.register(AlphaPack);
1771        builder.with_gate(Arc::new(DenyGateWithName));
1772        builder.with_event_store(store.clone());
1773        let reg = builder.build().expect("registry builds");
1774
1775        // Dispatch is denied — PermissionDenied returned.
1776        let err = reg
1777            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
1778            .await
1779            .unwrap_err();
1780        assert!(
1781            matches!(err, RuntimeError::PermissionDenied { .. }),
1782            "expected PermissionDenied, got {err:?}"
1783        );
1784
1785        // Exactly one event in the store.
1786        let page = store
1787            .query_events(
1788                EventFilter::default(),
1789                PageRequest {
1790                    limit: 10,
1791                    offset: 0,
1792                },
1793            )
1794            .await
1795            .unwrap();
1796        assert_eq!(
1797            page.items.len(),
1798            1,
1799            "one audit event must be persisted on deny"
1800        );
1801
1802        let ev = &page.items[0];
1803        assert_eq!(ev.outcome, EventOutcome::Denied);
1804
1805        // The data field must hold the full AuditEvent envelope (ADR-033 contract).
1806        let data = ev
1807            .data
1808            .as_ref()
1809            .expect("Event.data must be Some — full AuditEvent envelope must be persisted");
1810
1811        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
1812            .expect("Event.data must deserialize to AuditEvent");
1813
1814        assert_eq!(
1815            audit.deny_reason.as_deref(),
1816            Some("policy: write forbidden for anon"),
1817            "deny_reason must be preserved through EventStore"
1818        );
1819        assert_eq!(
1820            audit.gate_impl, "DenyGateWithName",
1821            "gate_impl must be preserved through EventStore"
1822        );
1823        assert_eq!(
1824            audit.decision,
1825            khive_gate::AuditDecision::Deny,
1826            "decision field must be preserved through EventStore"
1827        );
1828    }
1829
1830    #[tokio::test]
1831    async fn audit_envelope_round_trips_obligations_through_event_store() {
1832        use khive_gate::Obligation;
1833
1834        #[derive(Debug)]
1835        struct ObligationGate;
1836        impl Gate for ObligationGate {
1837            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1838                Ok(GateDecision::allow_with(vec![Obligation::Audit {
1839                    tag: "billing.meter".into(),
1840                }]))
1841            }
1842            fn impl_name(&self) -> &'static str {
1843                "ObligationGate"
1844            }
1845        }
1846
1847        let store = Arc::new(MemoryEventStore::default());
1848        let mut builder = VerbRegistryBuilder::new();
1849        builder.register(AlphaPack);
1850        builder.with_gate(Arc::new(ObligationGate));
1851        builder.with_event_store(store.clone());
1852        let reg = builder.build().expect("registry builds");
1853
1854        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
1855            .await
1856            .unwrap();
1857
1858        let page = store
1859            .query_events(
1860                EventFilter::default(),
1861                PageRequest {
1862                    limit: 10,
1863                    offset: 0,
1864                },
1865            )
1866            .await
1867            .unwrap();
1868        assert_eq!(page.items.len(), 1);
1869
1870        let ev = &page.items[0];
1871        assert_eq!(ev.outcome, EventOutcome::Success);
1872
1873        let data = ev
1874            .data
1875            .as_ref()
1876            .expect("Event.data must be Some — AuditEvent envelope must be persisted on allow");
1877
1878        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
1879            .expect("Event.data must deserialize to AuditEvent");
1880
1881        assert_eq!(audit.gate_impl, "ObligationGate");
1882        assert_eq!(
1883            audit.obligations.len(),
1884            1,
1885            "obligations must be preserved through EventStore"
1886        );
1887        match &audit.obligations[0] {
1888            Obligation::Audit { tag } => assert_eq!(tag, "billing.meter"),
1889            other => panic!("expected Audit obligation, got {other:?}"),
1890        }
1891    }
1892
1893    // ---- SQL-backed audit envelope round-trip (ADR-033 / ADR-035, codex r2) ----
1894    //
1895    // The two tests above use MemoryEventStore (no serialization). This test
1896    // wires the production SqlEventStore via KhiveRuntime::memory() to verify
1897    // that the full AuditEvent envelope survives the SQL text→parse round-trip
1898    // (Event.data is stored as TEXT and parsed back on read).
1899
1900    #[tokio::test]
1901    async fn sql_backed_audit_envelope_round_trips_deny_reason_gate_impl_and_obligations() {
1902        #[derive(Debug)]
1903        struct SqlTestDenyGate;
1904        impl Gate for SqlTestDenyGate {
1905            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1906                Ok(GateDecision::deny("sql-path: write denied"))
1907            }
1908            fn impl_name(&self) -> &'static str {
1909                "SqlTestDenyGate"
1910            }
1911        }
1912
1913        // KhiveRuntime::memory() creates an in-memory SQLite pool (is_file_backed=false).
1914        // events_for_namespace ensures the events schema and returns a SqlEventStore
1915        // scoped to "test-ns". The pool is shared so reads and writes see the same data.
1916        let rt = KhiveRuntime::memory().expect("in-memory runtime");
1917        let sql_store = rt
1918            .events(Some("test-ns"))
1919            .expect("events_for_namespace must succeed");
1920
1921        let mut builder = VerbRegistryBuilder::new();
1922        builder.register(AlphaPack);
1923        builder.with_gate(Arc::new(SqlTestDenyGate));
1924        builder.with_event_store(sql_store.clone());
1925        let reg = builder.build().expect("registry builds");
1926
1927        // Dispatch is denied — PermissionDenied returned.
1928        let err = reg
1929            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
1930            .await
1931            .unwrap_err();
1932        assert!(
1933            matches!(err, RuntimeError::PermissionDenied { .. }),
1934            "expected PermissionDenied, got {err:?}"
1935        );
1936
1937        // Query via the same SqlEventStore — this is the SQL read path.
1938        let page = sql_store
1939            .query_events(
1940                EventFilter::default(),
1941                PageRequest {
1942                    limit: 10,
1943                    offset: 0,
1944                },
1945            )
1946            .await
1947            .unwrap();
1948        assert_eq!(
1949            page.items.len(),
1950            1,
1951            "one audit event must be persisted on deny through SqlEventStore"
1952        );
1953
1954        let ev = &page.items[0];
1955        assert_eq!(ev.outcome, EventOutcome::Denied);
1956
1957        // Event.data must hold the full AuditEvent serialized as JSON text and
1958        // parsed back. If the SQL path was lossy, this deserialization would fail
1959        // or the field assertions below would fail.
1960        let data = ev
1961            .data
1962            .as_ref()
1963            .expect("Event.data must be Some — SqlEventStore must persist AuditEvent envelope");
1964
1965        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
1966            .expect("Event.data must deserialize to AuditEvent after SQL round-trip");
1967
1968        assert_eq!(
1969            audit.deny_reason.as_deref(),
1970            Some("sql-path: write denied"),
1971            "deny_reason must survive the SQL text round-trip"
1972        );
1973        assert_eq!(
1974            audit.gate_impl, "SqlTestDenyGate",
1975            "gate_impl must survive the SQL text round-trip"
1976        );
1977        assert_eq!(
1978            audit.decision,
1979            khive_gate::AuditDecision::Deny,
1980            "decision field must survive the SQL text round-trip"
1981        );
1982        // obligations is [] on a Deny gate (no obligations returned).
1983        // Verify the field is present and empty after SQL round-trip.
1984        assert!(
1985            audit.obligations.is_empty(),
1986            "obligations must be preserved as empty [] through SQL round-trip"
1987        );
1988    }
1989
1990    // ---- SQL-backed audit envelope: non-empty obligations survive round-trip ----
1991    //
1992    // Codex r3 identified a blind spot: the deny-path SQL test above only
1993    // asserts obligations == [], which passes even if the SQL path drops the
1994    // field entirely (AuditEvent.obligations has #[serde(default)]).
1995    //
1996    // This test installs an allow-path gate that returns a non-empty obligations
1997    // vec. After dispatch, the same SqlEventStore is queried and both layers are
1998    // checked:
1999    //   1. Raw Event.data["obligations"] is a non-empty JSON array.
2000    //   2. Deserialized AuditEvent.obligations[0] matches the expected variant.
2001    #[tokio::test]
2002    async fn sql_backed_audit_envelope_round_trips_non_empty_obligations() {
2003        use khive_gate::Obligation;
2004
2005        #[derive(Debug)]
2006        struct SqlTestAllowWithObligationGate;
2007        impl Gate for SqlTestAllowWithObligationGate {
2008            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2009                Ok(GateDecision::allow_with(vec![Obligation::Audit {
2010                    tag: "sql-path-billing.meter".into(),
2011                }]))
2012            }
2013            fn impl_name(&self) -> &'static str {
2014                "SqlTestAllowWithObligationGate"
2015            }
2016        }
2017
2018        let rt = KhiveRuntime::memory().expect("in-memory runtime");
2019        let sql_store = rt
2020            .events(Some("test-ns"))
2021            .expect("events_for_namespace must succeed");
2022
2023        let mut builder = VerbRegistryBuilder::new();
2024        builder.register(AlphaPack);
2025        builder.with_gate(Arc::new(SqlTestAllowWithObligationGate));
2026        builder.with_event_store(sql_store.clone());
2027        let reg = builder.build().expect("registry builds");
2028
2029        // Dispatch succeeds — the gate allows with obligations.
2030        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
2031            .await
2032            .expect("dispatch must succeed when gate allows");
2033
2034        // Query via the same SqlEventStore — this is the SQL read path.
2035        let page = sql_store
2036            .query_events(
2037                EventFilter::default(),
2038                PageRequest {
2039                    limit: 10,
2040                    offset: 0,
2041                },
2042            )
2043            .await
2044            .unwrap();
2045        assert_eq!(
2046            page.items.len(),
2047            1,
2048            "one audit event must be persisted on allow through SqlEventStore"
2049        );
2050
2051        let ev = &page.items[0];
2052        assert_eq!(ev.outcome, EventOutcome::Success);
2053
2054        let data = ev
2055            .data
2056            .as_ref()
2057            .expect("Event.data must be Some — SqlEventStore must persist AuditEvent envelope");
2058
2059        // Layer 1: raw JSON check — obligations must be a non-empty array in
2060        // the persisted TEXT. If the SQL path dropped the field, the default
2061        // #[serde(default)] would silently deserialize it to [], so we verify
2062        // the raw JSON before deserializing.
2063        let obligations_raw = data
2064            .get("obligations")
2065            .expect("Event.data JSON must contain 'obligations' key");
2066        let obligations_arr = obligations_raw
2067            .as_array()
2068            .expect("'obligations' must be a JSON array");
2069        assert!(
2070            !obligations_arr.is_empty(),
2071            "raw Event.data['obligations'] must be non-empty after SQL round-trip"
2072        );
2073
2074        // Layer 2: deserialized AuditEvent check — the obligation variant and
2075        // payload must survive the text round-trip faithfully.
2076        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
2077            .expect("Event.data must deserialize to AuditEvent after SQL round-trip");
2078
2079        assert_eq!(
2080            audit.gate_impl, "SqlTestAllowWithObligationGate",
2081            "gate_impl must survive the SQL text round-trip"
2082        );
2083        assert_eq!(
2084            audit.decision,
2085            khive_gate::AuditDecision::Allow,
2086            "decision field must survive the SQL text round-trip"
2087        );
2088        assert_eq!(
2089            audit.obligations.len(),
2090            1,
2091            "obligations must be non-empty after SQL round-trip (not silently defaulted to [])"
2092        );
2093        match &audit.obligations[0] {
2094            Obligation::Audit { tag } => assert_eq!(
2095                tag, "sql-path-billing.meter",
2096                "Audit obligation tag must survive the SQL text round-trip"
2097            ),
2098            other => panic!("expected Audit obligation, got {other:?}"),
2099        }
2100    }
2101
2102    // ---- Audit payload shape for 'create' verb dispatch (ADR-033 / ADR-035) ----
2103    //
2104    // The previous audit tests verify the envelope shape for the 'list' verb.
2105    // This test dispatches 'create' (matching the create_note + annotates path)
2106    // and verifies that ev.verb, ev.outcome, and ev.data all round-trip correctly
2107    // through the EventStore. Ensures the ADR-035 wire shape is independent of
2108    // which verb triggers the gate check.
2109    #[tokio::test]
2110    async fn audit_event_payload_shape_for_create_verb_matches_adr035_envelope() {
2111        let store = Arc::new(MemoryEventStore::default());
2112        let mut builder = VerbRegistryBuilder::new();
2113        builder.register(AlphaPack);
2114        builder.with_event_store(store.clone());
2115        builder.with_default_namespace("test-ns");
2116        let reg = builder.build().expect("registry builds");
2117
2118        // Dispatch 'create' — AlphaPack returns a stub value; what matters is
2119        // the EventStore entry emitted by the registry's gate-check path.
2120        reg.dispatch("create", serde_json::json!({"namespace": "test-ns"}))
2121            .await
2122            .unwrap();
2123
2124        let count = store.count_events(EventFilter::default()).await.unwrap();
2125        assert_eq!(count, 1, "exactly one audit event for one dispatch");
2126
2127        let page = store
2128            .query_events(
2129                EventFilter::default(),
2130                PageRequest {
2131                    limit: 10,
2132                    offset: 0,
2133                },
2134            )
2135            .await
2136            .unwrap();
2137        let ev = &page.items[0];
2138
2139        // Top-level Event fields (ADR-035 §Emission).
2140        assert_eq!(ev.verb, "create", "ev.verb must be the dispatched verb");
2141        assert_eq!(
2142            ev.outcome,
2143            EventOutcome::Success,
2144            "ev.outcome must be Success on allow"
2145        );
2146        assert_eq!(
2147            ev.namespace, "test-ns",
2148            "ev.namespace must match the dispatch namespace"
2149        );
2150
2151        // ev.data must hold the full AuditEvent envelope (ADR-033 / ADR-035 contract).
2152        let data = ev
2153            .data
2154            .as_ref()
2155            .expect("ev.data must be Some — full AuditEvent envelope required by ADR-035");
2156
2157        let audit: khive_gate::AuditEvent =
2158            serde_json::from_value(data.clone()).expect("ev.data must deserialize to AuditEvent");
2159
2160        assert_eq!(
2161            audit.decision,
2162            khive_gate::AuditDecision::Allow,
2163            "AuditEvent.decision must be Allow"
2164        );
2165        assert_eq!(audit.verb, "create", "AuditEvent.verb must be 'create'");
2166        assert_eq!(
2167            audit.namespace, "test-ns",
2168            "AuditEvent.namespace must be preserved"
2169        );
2170        assert_eq!(
2171            audit.gate_impl, "AllowAllGate",
2172            "AuditEvent.gate_impl must name the gate implementation"
2173        );
2174        assert!(
2175            audit.deny_reason.is_none(),
2176            "AuditEvent.deny_reason must be None on Allow"
2177        );
2178        // Wire-shape check: obligations serializes as [] on AllowAllGate.
2179        let payload_json: serde_json::Value =
2180            serde_json::from_value(data.clone()).expect("data must be valid JSON");
2181        assert_eq!(
2182            payload_json["obligations"],
2183            serde_json::Value::Array(Vec::new()),
2184            "obligations must be [] on AllowAllGate (wire-shape rule ADR-033)"
2185        );
2186    }
2187}
2188
2189// ---- ADR-037: inter-pack dependency checking ----
2190
2191#[cfg(test)]
2192mod dep_tests {
2193    use super::*;
2194    use async_trait::async_trait;
2195    use khive_types::Pack;
2196    use serde_json::Value;
2197
2198    struct KgDepPack;
2199    struct MemoryDepPack;
2200    struct ADepPack;
2201    struct BDepPack;
2202
2203    impl Pack for KgDepPack {
2204        const NAME: &'static str = "kg_dep";
2205        const NOTE_KINDS: &'static [&'static str] = &["observation"];
2206        const ENTITY_KINDS: &'static [&'static str] = &["concept"];
2207        const VERBS: &'static [VerbDef] = &[];
2208    }
2209
2210    impl Pack for MemoryDepPack {
2211        const NAME: &'static str = "memory_dep";
2212        const NOTE_KINDS: &'static [&'static str] = &["memory"];
2213        const ENTITY_KINDS: &'static [&'static str] = &[];
2214        const VERBS: &'static [VerbDef] = &[];
2215        const REQUIRES: &'static [&'static str] = &["kg_dep"];
2216    }
2217
2218    impl Pack for ADepPack {
2219        const NAME: &'static str = "pack_a";
2220        const NOTE_KINDS: &'static [&'static str] = &[];
2221        const ENTITY_KINDS: &'static [&'static str] = &[];
2222        const VERBS: &'static [VerbDef] = &[];
2223        const REQUIRES: &'static [&'static str] = &["pack_b"];
2224    }
2225
2226    impl Pack for BDepPack {
2227        const NAME: &'static str = "pack_b";
2228        const NOTE_KINDS: &'static [&'static str] = &[];
2229        const ENTITY_KINDS: &'static [&'static str] = &[];
2230        const VERBS: &'static [VerbDef] = &[];
2231        const REQUIRES: &'static [&'static str] = &["pack_a"];
2232    }
2233
2234    #[async_trait]
2235    impl PackRuntime for KgDepPack {
2236        fn name(&self) -> &str {
2237            Self::NAME
2238        }
2239        fn note_kinds(&self) -> &'static [&'static str] {
2240            Self::NOTE_KINDS
2241        }
2242        fn entity_kinds(&self) -> &'static [&'static str] {
2243            Self::ENTITY_KINDS
2244        }
2245        fn verbs(&self) -> &'static [VerbDef] {
2246            Self::VERBS
2247        }
2248        async fn dispatch(
2249            &self,
2250            verb: &str,
2251            _: Value,
2252            _: &VerbRegistry,
2253        ) -> Result<Value, RuntimeError> {
2254            Err(RuntimeError::InvalidInput(format!(
2255                "KgDepPack has no verbs: {verb}"
2256            )))
2257        }
2258    }
2259
2260    #[async_trait]
2261    impl PackRuntime for MemoryDepPack {
2262        fn name(&self) -> &str {
2263            Self::NAME
2264        }
2265        fn note_kinds(&self) -> &'static [&'static str] {
2266            Self::NOTE_KINDS
2267        }
2268        fn entity_kinds(&self) -> &'static [&'static str] {
2269            Self::ENTITY_KINDS
2270        }
2271        fn verbs(&self) -> &'static [VerbDef] {
2272            Self::VERBS
2273        }
2274        fn requires(&self) -> &'static [&'static str] {
2275            Self::REQUIRES
2276        }
2277        async fn dispatch(
2278            &self,
2279            verb: &str,
2280            _: Value,
2281            _: &VerbRegistry,
2282        ) -> Result<Value, RuntimeError> {
2283            Err(RuntimeError::InvalidInput(format!(
2284                "MemoryDepPack has no verbs: {verb}"
2285            )))
2286        }
2287    }
2288
2289    #[async_trait]
2290    impl PackRuntime for ADepPack {
2291        fn name(&self) -> &str {
2292            Self::NAME
2293        }
2294        fn note_kinds(&self) -> &'static [&'static str] {
2295            Self::NOTE_KINDS
2296        }
2297        fn entity_kinds(&self) -> &'static [&'static str] {
2298            Self::ENTITY_KINDS
2299        }
2300        fn verbs(&self) -> &'static [VerbDef] {
2301            Self::VERBS
2302        }
2303        fn requires(&self) -> &'static [&'static str] {
2304            Self::REQUIRES
2305        }
2306        async fn dispatch(
2307            &self,
2308            verb: &str,
2309            _: Value,
2310            _: &VerbRegistry,
2311        ) -> Result<Value, RuntimeError> {
2312            Err(RuntimeError::InvalidInput(format!(
2313                "ADepPack has no verbs: {verb}"
2314            )))
2315        }
2316    }
2317
2318    #[async_trait]
2319    impl PackRuntime for BDepPack {
2320        fn name(&self) -> &str {
2321            Self::NAME
2322        }
2323        fn note_kinds(&self) -> &'static [&'static str] {
2324            Self::NOTE_KINDS
2325        }
2326        fn entity_kinds(&self) -> &'static [&'static str] {
2327            Self::ENTITY_KINDS
2328        }
2329        fn verbs(&self) -> &'static [VerbDef] {
2330            Self::VERBS
2331        }
2332        fn requires(&self) -> &'static [&'static str] {
2333            Self::REQUIRES
2334        }
2335        async fn dispatch(
2336            &self,
2337            verb: &str,
2338            _: Value,
2339            _: &VerbRegistry,
2340        ) -> Result<Value, RuntimeError> {
2341            Err(RuntimeError::InvalidInput(format!(
2342                "BDepPack has no verbs: {verb}"
2343            )))
2344        }
2345    }
2346
2347    #[test]
2348    fn test_pack_deps_happy_path() {
2349        let mut builder = VerbRegistryBuilder::new();
2350        builder.register(MemoryDepPack);
2351        builder.register(KgDepPack);
2352        let reg = builder
2353            .build()
2354            .expect("kg_dep satisfies memory_dep dependency");
2355        assert_eq!(reg.pack_requires("memory_dep").unwrap(), &["kg_dep"]);
2356        let names = reg.pack_names();
2357        let kg_pos = names.iter().position(|&n| n == "kg_dep").unwrap();
2358        let mem_pos = names.iter().position(|&n| n == "memory_dep").unwrap();
2359        assert!(
2360            kg_pos < mem_pos,
2361            "kg_dep must be loaded before memory_dep; order: {names:?}"
2362        );
2363    }
2364
2365    #[test]
2366    fn test_pack_deps_missing() {
2367        let mut builder = VerbRegistryBuilder::new();
2368        builder.register(MemoryDepPack);
2369        let err = match builder.build() {
2370            Ok(_) => panic!("expected Err, got Ok"),
2371            Err(e) => e,
2372        };
2373        assert!(
2374            matches!(err, RuntimeError::MissingPackDependency(_)),
2375            "expected MissingPackDependency, got {err:?}"
2376        );
2377        let msg = err.to_string();
2378        assert!(
2379            msg.contains("memory_dep"),
2380            "error must name the dependent pack: {msg}"
2381        );
2382        assert!(
2383            msg.contains("kg_dep"),
2384            "error must name the missing dep: {msg}"
2385        );
2386    }
2387
2388    #[test]
2389    fn test_pack_deps_circular() {
2390        let mut builder = VerbRegistryBuilder::new();
2391        builder.register(ADepPack);
2392        builder.register(BDepPack);
2393        let err = match builder.build() {
2394            Ok(_) => panic!("expected Err, got Ok"),
2395            Err(e) => e,
2396        };
2397        assert!(
2398            matches!(err, RuntimeError::CircularPackDependency(_)),
2399            "expected CircularPackDependency, got {err:?}"
2400        );
2401        let msg = err.to_string();
2402        assert!(msg.contains("pack_a"), "error must name pack_a: {msg}");
2403        assert!(msg.contains("pack_b"), "error must name pack_b: {msg}");
2404    }
2405
2406    #[test]
2407    fn test_pack_deps_no_deps() {
2408        struct NoDepsA;
2409        struct NoDepsB;
2410
2411        impl Pack for NoDepsA {
2412            const NAME: &'static str = "no_deps_a";
2413            const NOTE_KINDS: &'static [&'static str] = &[];
2414            const ENTITY_KINDS: &'static [&'static str] = &[];
2415            const VERBS: &'static [VerbDef] = &[];
2416        }
2417
2418        impl Pack for NoDepsB {
2419            const NAME: &'static str = "no_deps_b";
2420            const NOTE_KINDS: &'static [&'static str] = &[];
2421            const ENTITY_KINDS: &'static [&'static str] = &[];
2422            const VERBS: &'static [VerbDef] = &[];
2423        }
2424
2425        #[async_trait]
2426        impl PackRuntime for NoDepsA {
2427            fn name(&self) -> &str {
2428                Self::NAME
2429            }
2430            fn note_kinds(&self) -> &'static [&'static str] {
2431                Self::NOTE_KINDS
2432            }
2433            fn entity_kinds(&self) -> &'static [&'static str] {
2434                Self::ENTITY_KINDS
2435            }
2436            fn verbs(&self) -> &'static [VerbDef] {
2437                Self::VERBS
2438            }
2439            async fn dispatch(
2440                &self,
2441                verb: &str,
2442                _: Value,
2443                _: &VerbRegistry,
2444            ) -> Result<Value, RuntimeError> {
2445                Err(RuntimeError::InvalidInput(format!("NoDepsA: {verb}")))
2446            }
2447        }
2448
2449        #[async_trait]
2450        impl PackRuntime for NoDepsB {
2451            fn name(&self) -> &str {
2452                Self::NAME
2453            }
2454            fn note_kinds(&self) -> &'static [&'static str] {
2455                Self::NOTE_KINDS
2456            }
2457            fn entity_kinds(&self) -> &'static [&'static str] {
2458                Self::ENTITY_KINDS
2459            }
2460            fn verbs(&self) -> &'static [VerbDef] {
2461                Self::VERBS
2462            }
2463            async fn dispatch(
2464                &self,
2465                verb: &str,
2466                _: Value,
2467                _: &VerbRegistry,
2468            ) -> Result<Value, RuntimeError> {
2469                Err(RuntimeError::InvalidInput(format!("NoDepsB: {verb}")))
2470            }
2471        }
2472
2473        let mut builder = VerbRegistryBuilder::new();
2474        builder.register(NoDepsA);
2475        builder.register(NoDepsB);
2476        let reg = builder.build().expect("packs with REQUIRES=&[] build");
2477        assert_eq!(reg.pack_requires("no_deps_a").unwrap(), &[] as &[&str]);
2478        assert_eq!(reg.pack_requires("no_deps_b").unwrap(), &[] as &[&str]);
2479    }
2480}
2481
2482// ── Dispatch hook tests (Issue #158) ─────────────────────────────────────────
2483
2484#[cfg(test)]
2485mod hook_tests {
2486    use super::*;
2487    use async_trait::async_trait;
2488    use khive_types::Pack;
2489    use std::sync::atomic::{AtomicUsize, Ordering};
2490    use std::sync::Mutex as StdMutex;
2491
2492    struct SimplePack;
2493
2494    impl Pack for SimplePack {
2495        const NAME: &'static str = "simple";
2496        const NOTE_KINDS: &'static [&'static str] = &[];
2497        const ENTITY_KINDS: &'static [&'static str] = &[];
2498        const VERBS: &'static [VerbDef] = &[VerbDef {
2499            name: "ping",
2500            description: "ping",
2501        }];
2502    }
2503
2504    #[async_trait]
2505    impl PackRuntime for SimplePack {
2506        fn name(&self) -> &str {
2507            SimplePack::NAME
2508        }
2509        fn note_kinds(&self) -> &'static [&'static str] {
2510            SimplePack::NOTE_KINDS
2511        }
2512        fn entity_kinds(&self) -> &'static [&'static str] {
2513            SimplePack::ENTITY_KINDS
2514        }
2515        fn verbs(&self) -> &'static [VerbDef] {
2516            SimplePack::VERBS
2517        }
2518        async fn dispatch(
2519            &self,
2520            verb: &str,
2521            _params: Value,
2522            _registry: &VerbRegistry,
2523        ) -> Result<Value, RuntimeError> {
2524            Ok(serde_json::json!({ "verb": verb }))
2525        }
2526    }
2527
2528    /// Hook that counts calls and records the last verb seen.
2529    #[derive(Default)]
2530    struct CountingHook {
2531        calls: AtomicUsize,
2532        last_verb: StdMutex<String>,
2533    }
2534
2535    #[async_trait]
2536    impl DispatchHook for CountingHook {
2537        async fn on_dispatch(&self, event: &Event) {
2538            self.calls.fetch_add(1, Ordering::SeqCst);
2539            *self.last_verb.lock().unwrap() = event.verb.clone();
2540        }
2541    }
2542
2543    #[tokio::test]
2544    async fn dispatch_hook_fires_on_successful_dispatch() {
2545        let hook = Arc::new(CountingHook::default());
2546        let mut builder = VerbRegistryBuilder::new();
2547        builder.register(SimplePack);
2548        builder.with_dispatch_hook(hook.clone());
2549        let reg = builder.build().expect("registry builds");
2550
2551        reg.dispatch("ping", Value::Null).await.unwrap();
2552
2553        assert_eq!(
2554            hook.calls.load(Ordering::SeqCst),
2555            1,
2556            "hook must fire once per successful dispatch"
2557        );
2558        assert_eq!(
2559            hook.last_verb.lock().unwrap().as_str(),
2560            "ping",
2561            "hook event must carry the dispatched verb"
2562        );
2563    }
2564
2565    #[tokio::test]
2566    async fn dispatch_hook_fires_multiple_times() {
2567        let hook = Arc::new(CountingHook::default());
2568        let mut builder = VerbRegistryBuilder::new();
2569        builder.register(SimplePack);
2570        builder.with_dispatch_hook(hook.clone());
2571        let reg = builder.build().expect("registry builds");
2572
2573        reg.dispatch("ping", Value::Null).await.unwrap();
2574        reg.dispatch("ping", Value::Null).await.unwrap();
2575        reg.dispatch("ping", Value::Null).await.unwrap();
2576
2577        assert_eq!(
2578            hook.calls.load(Ordering::SeqCst),
2579            3,
2580            "hook must fire once per successful dispatch"
2581        );
2582    }
2583
2584    #[tokio::test]
2585    async fn dispatch_hook_does_not_fire_on_unknown_verb() {
2586        let hook = Arc::new(CountingHook::default());
2587        let mut builder = VerbRegistryBuilder::new();
2588        builder.register(SimplePack);
2589        builder.with_dispatch_hook(hook.clone());
2590        let reg = builder.build().expect("registry builds");
2591
2592        let _ = reg.dispatch("nonexistent", Value::Null).await;
2593
2594        assert_eq!(
2595            hook.calls.load(Ordering::SeqCst),
2596            0,
2597            "hook must NOT fire for unknown verb (dispatch returns error)"
2598        );
2599    }
2600
2601    #[tokio::test]
2602    async fn dispatch_hook_does_not_fire_on_gate_deny() {
2603        use khive_gate::{Gate, GateDecision, GateError};
2604
2605        #[derive(Debug)]
2606        struct AlwaysDenyGate;
2607        impl Gate for AlwaysDenyGate {
2608            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
2609                Ok(GateDecision::deny("test deny"))
2610            }
2611        }
2612
2613        let hook = Arc::new(CountingHook::default());
2614        let mut builder = VerbRegistryBuilder::new();
2615        builder.register(SimplePack);
2616        builder.with_gate(Arc::new(AlwaysDenyGate));
2617        builder.with_dispatch_hook(hook.clone());
2618        let reg = builder.build().expect("registry builds");
2619
2620        let err = reg.dispatch("ping", Value::Null).await.unwrap_err();
2621        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
2622
2623        assert_eq!(
2624            hook.calls.load(Ordering::SeqCst),
2625            0,
2626            "hook must NOT fire when gate denies dispatch"
2627        );
2628    }
2629
2630    #[tokio::test]
2631    async fn dispatch_hook_event_carries_namespace_from_params() {
2632        let hook = Arc::new(CountingHook::default());
2633
2634        #[derive(Default)]
2635        struct NsCapturingHook {
2636            ns: StdMutex<String>,
2637        }
2638
2639        #[async_trait]
2640        impl DispatchHook for NsCapturingHook {
2641            async fn on_dispatch(&self, event: &Event) {
2642                *self.ns.lock().unwrap() = event.namespace.clone();
2643            }
2644        }
2645
2646        let ns_hook = Arc::new(NsCapturingHook::default());
2647        let mut builder = VerbRegistryBuilder::new();
2648        builder.register(SimplePack);
2649        builder.with_dispatch_hook(ns_hook.clone());
2650        let reg = builder.build().expect("registry builds");
2651
2652        reg.dispatch("ping", serde_json::json!({"namespace": "tenant-abc"}))
2653            .await
2654            .unwrap();
2655
2656        assert_eq!(
2657            ns_hook.ns.lock().unwrap().as_str(),
2658            "tenant-abc",
2659            "dispatch hook event must carry the resolved namespace"
2660        );
2661
2662        // Suppress unused-variable warning from the outer hook.
2663        drop(hook);
2664    }
2665
2666    #[tokio::test]
2667    async fn no_dispatch_hook_configured_dispatch_succeeds() {
2668        // Regression: registries without a hook must still work.
2669        let mut builder = VerbRegistryBuilder::new();
2670        builder.register(SimplePack);
2671        // No with_dispatch_hook call.
2672        let reg = builder.build().expect("registry builds");
2673
2674        let res = reg.dispatch("ping", Value::Null).await.unwrap();
2675        assert_eq!(res["verb"], "ping");
2676    }
2677}