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