Skip to main content

khive_runtime/
pack.rs

1//! Pack runtime trait and verb registry (ADR-025 step 2).
2//!
3//! Packs register verbs into the runtime. The registry routes verb calls
4//! to the pack that declares them.
5//!
6//! `Pack` (in khive-types) uses const associated items which are not
7//! object-safe. `PackRuntime` mirrors that metadata as methods so the
8//! registry can store packs as trait objects. See ADR-025 §PackRuntime.
9//!
10//! Lifecycle: build with `VerbRegistryBuilder`, then call `.build()` to
11//! get a cheaply-cloneable `VerbRegistry`. Registration is only possible
12//! through the builder.
13
14use std::collections::{HashMap, HashSet, VecDeque};
15use std::sync::Arc;
16
17use async_trait::async_trait;
18use khive_gate::{ActorRef, AllowAllGate, AuditEvent, GateDecision, GateRef, GateRequest};
19use khive_storage::{Event, EventStore, SubstrateKind};
20use khive_types::{EventOutcome, Namespace};
21use serde_json::Value;
22
23pub use khive_types::{EdgeEndpointRule, EndpointKind, VerbDef};
24
25use crate::error::{
26    CircularPackDependency, MissingPackDependencies, MissingPackDependency, RuntimeError,
27};
28use crate::KhiveRuntime;
29
30/// Async dispatch trait for packs (ADR-025).
31///
32/// This is the object-safe behavioral counterpart to `khive_types::Pack`.
33/// `Pack` uses const associated items (not object-safe in Rust); this trait
34/// mirrors that metadata as methods and adds async dispatch.
35///
36/// Registration requires `P: Pack + PackRuntime` — the compiler enforces
37/// that every runtime pack also declares its vocabulary via `Pack`.
38#[async_trait]
39pub trait PackRuntime: Send + Sync {
40    /// Pack name — must equal `<Self as Pack>::NAME`.
41    fn name(&self) -> &str;
42
43    /// Note kinds this pack owns — must equal `<Self as Pack>::NOTE_KINDS`.
44    fn note_kinds(&self) -> &'static [&'static str];
45
46    /// Entity kinds this pack owns — must equal `<Self as Pack>::ENTITY_KINDS`.
47    fn entity_kinds(&self) -> &'static [&'static str];
48
49    /// Verbs this pack handles — must equal `<Self as Pack>::VERBS`.
50    fn verbs(&self) -> &'static [VerbDef];
51
52    /// Pack-extensible edge endpoint rules — must equal `<Self as Pack>::EDGE_RULES`.
53    /// Defaults to empty so existing packs that don't extend the edge contract
54    /// can ignore it (ADR-031).
55    fn edge_rules(&self) -> &'static [EdgeEndpointRule] {
56        &[]
57    }
58
59    /// Pack names whose vocabulary this pack references (ADR-037).
60    /// Defaults to empty so existing packs compile without changes.
61    fn requires(&self) -> &'static [&'static str] {
62        &[]
63    }
64
65    /// Optional per-kind hook for shared CRUD specialization (ADR-030).
66    ///
67    /// When a kind is owned by this pack (declared in `note_kinds()` or
68    /// `entity_kinds()`), returning `Some(hook)` opts that kind into
69    /// pack-specific behavior — defaults, derived properties, side-effect
70    /// edges — through the shared `create` path. Returning `None` keeps
71    /// the kind as plain storage with no specialization.
72    fn kind_hook(&self, _kind: &str) -> Option<Arc<dyn KindHook>> {
73        None
74    }
75
76    /// Dispatch a verb call. Returns serialized JSON response.
77    ///
78    /// The `registry` parameter gives the handler access to the merged
79    /// vocabulary and kind hooks across all loaded packs (ADR-030).
80    async fn dispatch(
81        &self,
82        verb: &str,
83        params: Value,
84        registry: &VerbRegistry,
85    ) -> Result<Value, RuntimeError>;
86}
87
88/// Per-kind specialization for shared CRUD (ADR-030).
89///
90/// Packs implement `KindHook` for kinds they own that need:
91/// - **Defaults** filled into create args (e.g. `status="inbox"` for tasks)
92/// - **Derived properties** computed from args (e.g. salience from priority)
93/// - **Side-effect writes** after the storage commit (e.g. `depends_on` edges)
94///
95/// Hooks are stateless from the framework's perspective — they receive the
96/// runtime as a method parameter and operate on the args `Value` directly.
97/// The pack registers them via [`PackRuntime::kind_hook`].
98///
99/// Lifecycle verbs (e.g. gtd's `complete`, `transition`) remain pack-owned
100/// verbs and do not flow through this trait — only the create path does.
101#[async_trait]
102pub trait KindHook: Send + Sync + std::fmt::Debug {
103    /// Mutate args before the storage write. Fill defaults, normalize values,
104    /// rearrange user-facing fields into the storage shape expected by the
105    /// shared CRUD handler.
106    ///
107    /// Returning an error aborts the create call (no storage write happens).
108    async fn prepare_create(
109        &self,
110        runtime: &KhiveRuntime,
111        args: &mut Value,
112    ) -> Result<(), RuntimeError>;
113
114    /// Fire side effects after a successful storage write — graph edges,
115    /// derived observations, etc. The newly created record's UUID is passed
116    /// so the hook can attach metadata referencing it.
117    ///
118    /// Errors here are **logged but not propagated** — the storage write has
119    /// already succeeded; failing the call would mislead the caller.
120    /// Implementations should `tracing::warn!` and return `Ok(())` for
121    /// best-effort side effects.
122    async fn after_create(
123        &self,
124        runtime: &KhiveRuntime,
125        id: uuid::Uuid,
126        args: &Value,
127    ) -> Result<(), RuntimeError>;
128}
129
130/// Builder for constructing a `VerbRegistry`.
131///
132/// Packs are registered here; once `.build()` is called the registry is
133/// immutable and cheaply cloneable.
134pub struct VerbRegistryBuilder {
135    packs: Vec<Box<dyn PackRuntime>>,
136    gate: GateRef,
137    default_namespace: String,
138    /// Optional audit event sink (ADR-035).
139    ///
140    /// When set, every gate check writes a storage `Event` in addition to the
141    /// `tracing::info!` emission. The store is `Arc<dyn EventStore>` so the
142    /// registry does not depend on the full `KhiveRuntime` surface — only the
143    /// audit-persistence capability is needed here.
144    event_store: Option<Arc<dyn EventStore>>,
145}
146
147impl VerbRegistryBuilder {
148    pub fn new() -> Self {
149        Self {
150            packs: Vec::new(),
151            gate: std::sync::Arc::new(AllowAllGate),
152            default_namespace: Namespace::default_ns().as_str().to_string(),
153            event_store: None,
154        }
155    }
156
157    /// Register a pack. The bound `P: Pack + PackRuntime` ensures the pack
158    /// declares vocabulary via `Pack` consts alongside runtime dispatch.
159    pub fn register<P: khive_types::Pack + PackRuntime + 'static>(&mut self, pack: P) -> &mut Self {
160        self.packs.push(Box::new(pack));
161        self
162    }
163
164    /// Set the authorization gate consulted on every dispatch (ADR-029).
165    ///
166    /// Defaults to `AllowAllGate` if not set. In v0.2 the gate is **advisory** —
167    /// deny decisions are logged via `tracing::warn!` but do not block dispatch.
168    pub fn with_gate(&mut self, gate: GateRef) -> &mut Self {
169        self.gate = gate;
170        self
171    }
172
173    /// Set the namespace surfaced to the gate when a verb does not carry an
174    /// explicit `namespace` argument. Transports should plumb the runtime's
175    /// `default_namespace` so the gate's `input.namespace` always reflects
176    /// the operation's true tenant (ADR-029 + ADR-007).
177    pub fn with_default_namespace(&mut self, ns: impl Into<String>) -> &mut Self {
178        self.default_namespace = ns.into();
179        self
180    }
181
182    /// Set the `EventStore` used to persist audit events (ADR-035).
183    ///
184    /// When configured, every gate check appends one `Event` (substrate =
185    /// `Event`, outcome = `Success` on allow, `Denied` on deny) in addition to
186    /// the `tracing::info!` emission that was already present in v0.2.
187    ///
188    /// Callers that do not set this field continue to use tracing-only emission
189    /// (the v0.2 default). There is no behavior change for them.
190    pub fn with_event_store(&mut self, store: Arc<dyn EventStore>) -> &mut Self {
191        self.event_store = Some(store);
192        self
193    }
194
195    /// Consume the builder and produce an immutable, cloneable registry.
196    ///
197    /// Performs a topological sort of packs using Kahn's algorithm (ADR-037).
198    /// Returns an error if any declared dependency is missing from the loaded
199    /// pack set, or if a circular dependency is detected.
200    pub fn build(self) -> Result<VerbRegistry, RuntimeError> {
201        let packs = self.packs;
202        let mut name_to_idx: HashMap<&str, usize> = HashMap::with_capacity(packs.len());
203        for (idx, pack) in packs.iter().enumerate() {
204            if let Some(prev_idx) = name_to_idx.insert(pack.name(), idx) {
205                return Err(RuntimeError::PackRedeclared {
206                    name: pack.name().to_string(),
207                    first_idx: prev_idx,
208                    second_idx: idx,
209                });
210            }
211        }
212
213        let mut missing: Vec<MissingPackDependency> = Vec::new();
214        let mut indegree = vec![0usize; packs.len()];
215        let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); packs.len()];
216
217        for (idx, pack) in packs.iter().enumerate() {
218            for &requires in pack.requires() {
219                match name_to_idx.get(requires).copied() {
220                    Some(dep_idx) => {
221                        dependents[dep_idx].push(idx);
222                        indegree[idx] += 1;
223                    }
224                    None => missing.push(MissingPackDependency {
225                        from: pack.name().to_string(),
226                        requires: requires.to_string(),
227                    }),
228                }
229            }
230        }
231
232        if !missing.is_empty() {
233            return if missing.len() == 1 {
234                Err(RuntimeError::MissingPackDependency(missing.remove(0)))
235            } else {
236                Err(RuntimeError::MissingPackDependencies(
237                    MissingPackDependencies { missing },
238                ))
239            };
240        }
241
242        let mut ready: VecDeque<usize> = indegree
243            .iter()
244            .enumerate()
245            .filter_map(|(idx, degree)| (*degree == 0).then_some(idx))
246            .collect();
247        let mut ordered_indices = Vec::with_capacity(packs.len());
248
249        while let Some(idx) = ready.pop_front() {
250            ordered_indices.push(idx);
251            for &dep_idx in &dependents[idx] {
252                indegree[dep_idx] -= 1;
253                if indegree[dep_idx] == 0 {
254                    ready.push_back(dep_idx);
255                }
256            }
257        }
258
259        if ordered_indices.len() != packs.len() {
260            let cycle_nodes: HashSet<usize> = indegree
261                .iter()
262                .enumerate()
263                .filter_map(|(idx, degree)| (*degree > 0).then_some(idx))
264                .collect();
265            let cycle = find_pack_dependency_cycle(&packs, &name_to_idx, &cycle_nodes);
266            return Err(RuntimeError::CircularPackDependency(
267                CircularPackDependency { cycle },
268            ));
269        }
270
271        let mut slots: Vec<Option<Box<dyn PackRuntime>>> = packs.into_iter().map(Some).collect();
272        let ordered_packs: Vec<Box<dyn PackRuntime>> = ordered_indices
273            .into_iter()
274            .map(|idx| slots[idx].take().expect("topological index must exist"))
275            .collect();
276
277        Ok(VerbRegistry {
278            packs: Arc::new(ordered_packs),
279            gate: self.gate,
280            default_namespace: self.default_namespace,
281            event_store: self.event_store,
282        })
283    }
284}
285
286fn find_pack_dependency_cycle(
287    packs: &[Box<dyn PackRuntime>],
288    name_to_idx: &HashMap<&str, usize>,
289    cycle_nodes: &HashSet<usize>,
290) -> Vec<String> {
291    fn visit(
292        idx: usize,
293        packs: &[Box<dyn PackRuntime>],
294        name_to_idx: &HashMap<&str, usize>,
295        cycle_nodes: &HashSet<usize>,
296        visiting: &mut Vec<usize>,
297        visited: &mut HashSet<usize>,
298    ) -> Option<Vec<String>> {
299        if let Some(pos) = visiting.iter().position(|&seen| seen == idx) {
300            let mut cycle: Vec<String> = visiting[pos..]
301                .iter()
302                .map(|&i| packs[i].name().to_string())
303                .collect();
304            cycle.push(packs[idx].name().to_string());
305            return Some(cycle);
306        }
307        if !visited.insert(idx) {
308            return None;
309        }
310        visiting.push(idx);
311        for &req in packs[idx].requires() {
312            let Some(&dep_idx) = name_to_idx.get(req) else {
313                continue;
314            };
315            if cycle_nodes.contains(&dep_idx) {
316                if let Some(cycle) =
317                    visit(dep_idx, packs, name_to_idx, cycle_nodes, visiting, visited)
318                {
319                    return Some(cycle);
320                }
321            }
322        }
323        visiting.pop();
324        None
325    }
326
327    let mut visited = HashSet::new();
328    for &idx in cycle_nodes {
329        let mut visiting = Vec::new();
330        if let Some(cycle) = visit(
331            idx,
332            packs,
333            name_to_idx,
334            cycle_nodes,
335            &mut visiting,
336            &mut visited,
337        ) {
338            return cycle;
339        }
340    }
341    cycle_nodes
342        .iter()
343        .map(|&idx| packs[idx].name().to_string())
344        .collect()
345}
346
347impl Default for VerbRegistryBuilder {
348    fn default() -> Self {
349        Self::new()
350    }
351}
352
353/// Immutable registry that dispatches verb calls to registered packs.
354///
355/// Clone is cheap (Arc-wrapped). Constructed via `VerbRegistryBuilder`.
356#[derive(Clone)]
357pub struct VerbRegistry {
358    packs: std::sync::Arc<Vec<Box<dyn PackRuntime>>>,
359    gate: GateRef,
360    default_namespace: String,
361    /// Audit event sink — `None` means tracing-only (v0.2 default) (ADR-035).
362    event_store: Option<Arc<dyn EventStore>>,
363}
364
365impl VerbRegistry {
366    /// Dispatch a verb to the first pack that handles it.
367    ///
368    /// When multiple packs declare the same verb, the first registered pack wins.
369    ///
370    /// The configured [`Gate`](khive_gate::Gate) is consulted before dispatch
371    /// (ADR-029, ADR-035). `Deny` decisions return
372    /// [`RuntimeError::PermissionDenied`] immediately — the pack is never
373    /// invoked. `Allow` decisions proceed to pack dispatch as before.
374    ///
375    /// Every gate consultation emits one `tracing::info!(... "gate.check")` event
376    /// with a structured `audit_event` field (ADR-033). When a [`EventStore`]
377    /// is configured via [`VerbRegistryBuilder::with_event_store`], an `Event`
378    /// is also persisted to the substrate (ADR-035). Storage errors are logged
379    /// via `tracing::warn!` and never propagated.
380    ///
381    /// When `gate.check` itself returns an error (gate infrastructure failure),
382    /// the error is logged via `tracing::warn!` and dispatch proceeds (fail-open,
383    /// consistent with ADR-029 §Rationale "Why advisory in v0.2"). No audit event
384    /// is persisted for an errored gate check — no decision was produced.
385    ///
386    /// The synthesized `GateRequest` carries `ActorRef::anonymous()` and the
387    /// operation's namespace — pulled from `params["namespace"]` when present
388    /// (including an explicit empty string, which `KhiveRuntime::ns` also
389    /// preserves), otherwise the registry's default namespace (configured via
390    /// [`VerbRegistryBuilder::with_default_namespace`]). Gate-visible
391    /// namespace and runtime-visible namespace MUST stay aligned; coercing an
392    /// empty string here while the runtime keeps `""` would create an
393    /// authorization/audit blind spot on the field ADR-029 declares public.
394    /// Transports that have richer caller context (auth headers, session
395    /// info) will gain a sibling dispatch path in a follow-up.
396    pub async fn dispatch(&self, verb: &str, params: Value) -> Result<Value, RuntimeError> {
397        let ns_str = params
398            .get("namespace")
399            .and_then(Value::as_str)
400            .unwrap_or(&self.default_namespace);
401        let gate_req = GateRequest::new(
402            ActorRef::anonymous(),
403            Namespace::new(ns_str),
404            verb,
405            params.clone(),
406        );
407
408        // Consult the gate (ADR-029, ADR-035).
409        //
410        // - Ok(Allow) → proceed to pack dispatch (tracing + optional EventStore).
411        // - Ok(Deny) → emit audit, persist if store configured, return PermissionDenied.
412        // - Err(_) → warn via tracing, fail-open (no audit persisted).
413        let gate_blocked = match self.gate.check(&gate_req) {
414            Ok(decision) => {
415                let is_deny = matches!(decision, GateDecision::Deny { .. });
416
417                // Emit audit event via tracing (ADR-033 — preserved path).
418                let audit = AuditEvent::from_check(&gate_req, &decision, self.gate.impl_name());
419                tracing::info!(
420                    audit_event = %serde_json::to_string(&audit)
421                        .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
422                    "gate.check"
423                );
424
425                // Persist to EventStore when configured (ADR-035).
426                if let Some(store) = &self.event_store {
427                    let outcome = if is_deny {
428                        EventOutcome::Denied
429                    } else {
430                        EventOutcome::Success
431                    };
432                    let audit_data = serde_json::to_value(&audit).unwrap_or_else(|e| {
433                        tracing::warn!(error = %e, "failed to serialize AuditEvent for EventStore");
434                        serde_json::Value::Null
435                    });
436                    let storage_event = Event::new(
437                        gate_req.namespace.as_str(),
438                        verb,
439                        SubstrateKind::Event,
440                        format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
441                    )
442                    .with_outcome(outcome)
443                    .with_data(audit_data);
444                    if let Err(store_err) = store.append_event(storage_event).await {
445                        tracing::warn!(
446                            verb,
447                            error = %store_err,
448                            "audit event store write failed (non-fatal)"
449                        );
450                    }
451                }
452
453                if is_deny {
454                    let reason = match decision {
455                        GateDecision::Deny { reason } => reason,
456                        _ => String::new(),
457                    };
458                    Some(reason)
459                } else {
460                    None
461                }
462            }
463            Err(err) => {
464                // Gate infrastructure failure — fail-open (ADR-029 §Rationale).
465                // No decision was produced; no audit event is persisted.
466                tracing::warn!(verb, error = %err, "gate check failed (fail-open)");
467                None
468            }
469        };
470
471        // Hard enforcement (ADR-035): Deny is now authoritative.
472        if let Some(reason) = gate_blocked {
473            return Err(RuntimeError::PermissionDenied {
474                verb: verb.to_string(),
475                reason,
476            });
477        }
478
479        for pack in self.packs.iter() {
480            if pack.verbs().iter().any(|v| v.name == verb) {
481                return pack.dispatch(verb, params, self).await;
482            }
483        }
484        let available: Vec<&str> = self
485            .packs
486            .iter()
487            .flat_map(|p| p.verbs().iter().map(|v| v.name))
488            .collect();
489        Err(RuntimeError::InvalidInput(format!(
490            "unknown verb {verb:?}; available: {}",
491            available.join(", ")
492        )))
493    }
494
495    /// Find a kind hook (ADR-030) among the registered packs.
496    ///
497    /// Walks packs in registration order; the first pack that both owns the
498    /// kind (declares it in `note_kinds()` or `entity_kinds()`) and returns
499    /// a hook from `kind_hook(kind)` wins. Returns `None` if the kind is
500    /// unknown to all packs or no owning pack registered a hook.
501    pub fn find_kind_hook(&self, kind: &str) -> Option<Arc<dyn KindHook>> {
502        for pack in self.packs.iter() {
503            let owns = pack.note_kinds().contains(&kind) || pack.entity_kinds().contains(&kind);
504            if owns {
505                if let Some(hook) = pack.kind_hook(kind) {
506                    return Some(hook);
507                }
508            }
509        }
510        None
511    }
512
513    /// All verb definitions across all registered packs.
514    ///
515    /// Returned with `'static` lifetime since pack verbs are `&'static [VerbDef]`
516    /// constants — callers can keep the slice references beyond the registry's
517    /// borrow.
518    pub fn all_verbs(&self) -> Vec<&'static VerbDef> {
519        self.packs.iter().flat_map(|p| p.verbs().iter()).collect()
520    }
521
522    /// Merged set of note kinds across all registered packs (deduplicated,
523    /// first-seen order preserved).
524    pub fn all_note_kinds(&self) -> Vec<&'static str> {
525        let mut seen = std::collections::HashSet::new();
526        self.packs
527            .iter()
528            .flat_map(|p| p.note_kinds().iter().copied())
529            .filter(|k| seen.insert(*k))
530            .collect()
531    }
532
533    /// Merged set of entity kinds across all registered packs (deduplicated,
534    /// first-seen order preserved).
535    pub fn all_entity_kinds(&self) -> Vec<&'static str> {
536        let mut seen = std::collections::HashSet::new();
537        self.packs
538            .iter()
539            .flat_map(|p| p.entity_kinds().iter().copied())
540            .filter(|k| seen.insert(*k))
541            .collect()
542    }
543
544    /// Names of packs in topological load order.
545    pub fn pack_names(&self) -> Vec<&str> {
546        self.packs.iter().map(|p| p.name()).collect()
547    }
548
549    /// Declared dependencies for a registered pack (ADR-037).
550    pub fn pack_requires(&self, name: &str) -> Option<&'static [&'static str]> {
551        self.packs
552            .iter()
553            .find(|p| p.name() == name)
554            .map(|p| p.requires())
555    }
556
557    /// All pack-declared edge endpoint rules across registered packs (ADR-031).
558    ///
559    /// Order follows topological pack registration; duplicates are *not* deduplicated —
560    /// validation only checks membership, and an exact-duplicate rule is a
561    /// harmless restatement.
562    pub fn all_edge_rules(&self) -> Vec<EdgeEndpointRule> {
563        self.packs
564            .iter()
565            .flat_map(|p| p.edge_rules().iter().copied())
566            .collect()
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use khive_types::Pack;
574
575    struct AlphaPack;
576
577    impl Pack for AlphaPack {
578        const NAME: &'static str = "alpha";
579        const NOTE_KINDS: &'static [&'static str] = &["memo", "log"];
580        const ENTITY_KINDS: &'static [&'static str] = &["widget"];
581        const VERBS: &'static [VerbDef] = &[
582            VerbDef {
583                name: "create",
584                description: "create a widget",
585            },
586            VerbDef {
587                name: "list",
588                description: "list widgets",
589            },
590        ];
591    }
592
593    #[async_trait]
594    impl PackRuntime for AlphaPack {
595        fn name(&self) -> &str {
596            AlphaPack::NAME
597        }
598        fn note_kinds(&self) -> &'static [&'static str] {
599            AlphaPack::NOTE_KINDS
600        }
601        fn entity_kinds(&self) -> &'static [&'static str] {
602            AlphaPack::ENTITY_KINDS
603        }
604        fn verbs(&self) -> &'static [VerbDef] {
605            AlphaPack::VERBS
606        }
607        async fn dispatch(
608            &self,
609            verb: &str,
610            _params: Value,
611            _registry: &VerbRegistry,
612        ) -> Result<Value, RuntimeError> {
613            Ok(serde_json::json!({ "pack": "alpha", "verb": verb }))
614        }
615    }
616
617    struct BetaPack;
618
619    impl Pack for BetaPack {
620        const NAME: &'static str = "beta";
621        const NOTE_KINDS: &'static [&'static str] = &["log", "alert"];
622        const ENTITY_KINDS: &'static [&'static str] = &["widget", "gadget"];
623        const VERBS: &'static [VerbDef] = &[
624            VerbDef {
625                name: "notify",
626                description: "send alert",
627            },
628            VerbDef {
629                name: "create",
630                description: "create a gadget",
631            },
632        ];
633    }
634
635    #[async_trait]
636    impl PackRuntime for BetaPack {
637        fn name(&self) -> &str {
638            BetaPack::NAME
639        }
640        fn note_kinds(&self) -> &'static [&'static str] {
641            BetaPack::NOTE_KINDS
642        }
643        fn entity_kinds(&self) -> &'static [&'static str] {
644            BetaPack::ENTITY_KINDS
645        }
646        fn verbs(&self) -> &'static [VerbDef] {
647            BetaPack::VERBS
648        }
649        async fn dispatch(
650            &self,
651            verb: &str,
652            _params: Value,
653            _registry: &VerbRegistry,
654        ) -> Result<Value, RuntimeError> {
655            Ok(serde_json::json!({ "pack": "beta", "verb": verb }))
656        }
657    }
658
659    fn build_registry() -> VerbRegistry {
660        let mut builder = VerbRegistryBuilder::new();
661        builder.register(AlphaPack);
662        builder.register(BetaPack);
663        builder.build().expect("registry builds")
664    }
665
666    #[tokio::test]
667    async fn dispatch_routes_to_correct_pack() {
668        let reg = build_registry();
669
670        let res = reg.dispatch("list", Value::Null).await.unwrap();
671        assert_eq!(res["pack"], "alpha");
672
673        let res = reg.dispatch("notify", Value::Null).await.unwrap();
674        assert_eq!(res["pack"], "beta");
675    }
676
677    #[tokio::test]
678    async fn dispatch_first_registered_wins_on_collision() {
679        let reg = build_registry();
680
681        let res = reg.dispatch("create", Value::Null).await.unwrap();
682        assert_eq!(res["pack"], "alpha", "first registered pack wins");
683    }
684
685    #[tokio::test]
686    async fn dispatch_unknown_verb_returns_error() {
687        let reg = build_registry();
688
689        let err = reg.dispatch("explode", Value::Null).await.unwrap_err();
690        let msg = err.to_string();
691        assert!(msg.contains("explode"));
692        assert!(msg.contains("create"));
693    }
694
695    #[test]
696    fn all_verbs_aggregates_across_packs() {
697        let reg = build_registry();
698        let verbs: Vec<&str> = reg.all_verbs().iter().map(|v| v.name).collect();
699        assert_eq!(verbs, vec!["create", "list", "notify", "create"]);
700    }
701
702    #[test]
703    fn note_kinds_are_deduplicated() {
704        let reg = build_registry();
705        let kinds = reg.all_note_kinds();
706        assert_eq!(kinds, vec!["memo", "log", "alert"]);
707    }
708
709    #[test]
710    fn entity_kinds_are_deduplicated() {
711        let reg = build_registry();
712        let kinds = reg.all_entity_kinds();
713        assert_eq!(kinds, vec!["widget", "gadget"]);
714    }
715
716    // ---- Gate wiring (ADR-029) ----
717
718    use khive_gate::{Gate, GateError};
719    use std::sync::atomic::{AtomicUsize, Ordering};
720    use std::sync::Arc;
721
722    #[derive(Default, Debug)]
723    struct CountingGate {
724        calls: AtomicUsize,
725        deny_verb: Option<&'static str>,
726    }
727
728    impl Gate for CountingGate {
729        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
730            self.calls.fetch_add(1, Ordering::SeqCst);
731            if Some(req.verb.as_str()) == self.deny_verb {
732                Ok(GateDecision::deny(format!("test deny for {}", req.verb)))
733            } else {
734                Ok(GateDecision::allow())
735            }
736        }
737    }
738
739    #[tokio::test]
740    async fn dispatch_consults_the_gate() {
741        let gate = Arc::new(CountingGate::default());
742        let mut builder = VerbRegistryBuilder::new();
743        builder.register(AlphaPack);
744        builder.with_gate(gate.clone());
745        let reg = builder.build().expect("registry builds");
746
747        reg.dispatch("list", Value::Null).await.unwrap();
748        reg.dispatch("create", Value::Null).await.unwrap();
749        assert_eq!(
750            gate.calls.load(Ordering::SeqCst),
751            2,
752            "gate should be consulted once per dispatch"
753        );
754    }
755
756    #[tokio::test]
757    async fn dispatch_returns_permission_denied_on_deny_v03() {
758        let gate = Arc::new(CountingGate {
759            calls: AtomicUsize::new(0),
760            deny_verb: Some("create"),
761        });
762        let mut builder = VerbRegistryBuilder::new();
763        builder.register(AlphaPack);
764        builder.with_gate(gate.clone());
765        let reg = builder.build().expect("registry builds");
766
767        // Gate denies — dispatch now returns PermissionDenied (hard enforcement, ADR-035).
768        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
769        assert!(
770            matches!(err, RuntimeError::PermissionDenied { ref verb, .. } if verb == "create"),
771            "expected PermissionDenied, got {err:?}"
772        );
773        let msg = err.to_string();
774        assert!(
775            msg.contains("create"),
776            "error message must name the verb: {msg}"
777        );
778        assert!(
779            msg.contains("test deny for create"),
780            "error message must carry the deny reason: {msg}"
781        );
782        assert_eq!(gate.calls.load(Ordering::SeqCst), 1);
783    }
784
785    #[tokio::test]
786    async fn dispatch_allow_verb_succeeds_even_with_deny_gate_for_other_verb() {
787        // Deny only "create" — "list" must still work.
788        let gate = Arc::new(CountingGate {
789            calls: AtomicUsize::new(0),
790            deny_verb: Some("create"),
791        });
792        let mut builder = VerbRegistryBuilder::new();
793        builder.register(AlphaPack);
794        builder.with_gate(gate.clone());
795        let reg = builder.build().expect("registry builds");
796
797        let res = reg.dispatch("list", Value::Null).await.unwrap();
798        assert_eq!(res["pack"], "alpha");
799    }
800
801    #[tokio::test]
802    async fn dispatch_uses_allow_all_gate_by_default() {
803        // No `with_gate` call — builder should use `AllowAllGate` so dispatch works.
804        let reg = build_registry();
805        let res = reg.dispatch("list", Value::Null).await.unwrap();
806        assert_eq!(res["pack"], "alpha");
807    }
808
809    // Captures the namespace each call sees so we can assert what the gate
810    // actually receives — codex round-1 caught us hard-wiring `default_ns()`.
811    #[derive(Default, Debug)]
812    struct NamespaceCapturingGate {
813        seen: std::sync::Mutex<Vec<String>>,
814    }
815
816    impl Gate for NamespaceCapturingGate {
817        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
818            self.seen
819                .lock()
820                .unwrap()
821                .push(req.namespace.as_str().to_string());
822            Ok(GateDecision::allow())
823        }
824    }
825
826    #[tokio::test]
827    async fn dispatch_propagates_params_namespace_to_gate() {
828        let gate = Arc::new(NamespaceCapturingGate::default());
829        let mut builder = VerbRegistryBuilder::new();
830        builder.register(AlphaPack);
831        builder.with_gate(gate.clone());
832        builder.with_default_namespace("tenant-x");
833        let reg = builder.build().expect("registry builds");
834
835        // Explicit namespace in params wins.
836        reg.dispatch("list", serde_json::json!({"namespace": "tenant-y"}))
837            .await
838            .unwrap();
839        // Missing namespace → registry default.
840        reg.dispatch("list", Value::Null).await.unwrap();
841        // Explicit empty namespace string is preserved (it is what
842        // `KhiveRuntime::ns` would also see). Gate and runtime MUST agree on
843        // the namespace they observe; coercing here while the runtime
844        // continues to honor `""` would create an audit blind spot.
845        reg.dispatch("list", serde_json::json!({"namespace": ""}))
846            .await
847            .unwrap();
848
849        let seen = gate.seen.lock().unwrap().clone();
850        assert_eq!(seen, vec!["tenant-y", "tenant-x", ""]);
851    }
852
853    #[tokio::test]
854    async fn dispatch_falls_back_to_local_when_no_default_set() {
855        // Builder default mirrors `Namespace::default_ns()`.
856        let gate = Arc::new(NamespaceCapturingGate::default());
857        let mut builder = VerbRegistryBuilder::new();
858        builder.register(AlphaPack);
859        builder.with_gate(gate.clone());
860        let reg = builder.build().expect("registry builds");
861
862        reg.dispatch("list", Value::Null).await.unwrap();
863        let seen = gate.seen.lock().unwrap().clone();
864        assert_eq!(seen, vec!["local"]);
865    }
866
867    // ---- Audit event emission (ADR-033) ----
868
869    use khive_gate::{AuditDecision, AuditEvent, Obligation};
870
871    /// A gate that records every audit event emitted via from_check.
872    #[derive(Default, Debug)]
873    struct AuditCapturingGate {
874        events: std::sync::Mutex<Vec<AuditEvent>>,
875        deny_verb: Option<&'static str>,
876    }
877
878    impl Gate for AuditCapturingGate {
879        fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
880            let decision = if Some(req.verb.as_str()) == self.deny_verb {
881                GateDecision::deny("test deny")
882            } else {
883                GateDecision::allow_with(vec![Obligation::Audit {
884                    tag: format!("{}.check", req.verb),
885                }])
886            };
887            // Capture what dispatch will also emit.
888            let ev = AuditEvent::from_check(req, &decision, self.impl_name());
889            self.events.lock().unwrap().push(ev);
890            Ok(decision)
891        }
892
893        fn impl_name(&self) -> &'static str {
894            "AuditCapturingGate"
895        }
896    }
897
898    #[tokio::test]
899    async fn dispatch_emits_one_audit_event_per_call() {
900        let gate = Arc::new(AuditCapturingGate::default());
901        let mut builder = VerbRegistryBuilder::new();
902        builder.register(AlphaPack);
903        builder.with_gate(gate.clone());
904        let reg = builder.build().expect("registry builds");
905
906        reg.dispatch("list", Value::Null).await.unwrap();
907        reg.dispatch("create", Value::Null).await.unwrap();
908
909        let evs = gate.events.lock().unwrap();
910        assert_eq!(evs.len(), 2, "exactly one audit event per dispatch call");
911    }
912
913    #[tokio::test]
914    async fn dispatch_audit_event_allow_carries_obligations() {
915        let gate = Arc::new(AuditCapturingGate::default());
916        let mut builder = VerbRegistryBuilder::new();
917        builder.register(AlphaPack);
918        builder.with_gate(gate.clone());
919        let reg = builder.build().expect("registry builds");
920
921        reg.dispatch("list", Value::Null).await.unwrap();
922
923        let evs = gate.events.lock().unwrap();
924        let ev = &evs[0];
925        assert_eq!(ev.verb, "list");
926        assert_eq!(ev.decision, AuditDecision::Allow);
927        assert!(ev.deny_reason.is_none());
928        assert_eq!(ev.obligations.len(), 1);
929        assert_eq!(ev.gate_impl, "AuditCapturingGate");
930    }
931
932    #[tokio::test]
933    async fn dispatch_audit_event_deny_carries_reason() {
934        let gate = Arc::new(AuditCapturingGate {
935            events: Default::default(),
936            deny_verb: Some("create"),
937        });
938        let mut builder = VerbRegistryBuilder::new();
939        builder.register(AlphaPack);
940        builder.with_gate(gate.clone());
941        let reg = builder.build().expect("registry builds");
942
943        // Gate denies — dispatch returns PermissionDenied (hard enforcement, ADR-035).
944        // The audit event is still recorded (captured inside the gate impl).
945        let err = reg.dispatch("create", Value::Null).await.unwrap_err();
946        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
947
948        let evs = gate.events.lock().unwrap();
949        let ev = &evs[0];
950        assert_eq!(ev.verb, "create");
951        assert_eq!(ev.decision, AuditDecision::Deny);
952        assert_eq!(ev.deny_reason.as_deref(), Some("test deny"));
953        assert!(ev.obligations.is_empty());
954    }
955
956    #[tokio::test]
957    async fn dispatch_audit_event_fields_match_gate_request() {
958        let gate = Arc::new(AuditCapturingGate::default());
959        let mut builder = VerbRegistryBuilder::new();
960        builder.register(AlphaPack);
961        builder.with_gate(gate.clone());
962        builder.with_default_namespace("tenant-z");
963        let reg = builder.build().expect("registry builds");
964
965        reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
966            .await
967            .unwrap();
968
969        let evs = gate.events.lock().unwrap();
970        let ev = &evs[0];
971        // Namespace from params wins (ADR-029 alignment rule).
972        assert_eq!(ev.namespace, "tenant-q");
973        assert_eq!(ev.verb, "list");
974        assert_eq!(ev.actor.kind, "anonymous");
975    }
976
977    // ---- Audit tracing emission (ADR-033 §"Emission site") ----
978    //
979    // The AuditCapturingGate tests above prove that AuditEvent::from_check is
980    // called with the right inputs, but they observe the event *inside* the
981    // gate impl — they would still pass if dispatch's
982    // `tracing::info!(audit_event = ..., "gate.check")` were deleted or
983    // renamed. The tests below install a capture Layer and assert on the
984    // actual tracing event surfaced from dispatch. This locks the public
985    // observability contract from ADR-033: one `gate.check` info event per
986    // dispatch, carrying an `audit_event` field that round-trips back to an
987    // `AuditEvent`.
988
989    use std::sync::Mutex as StdMutex;
990    use tracing::field::{Field, Visit};
991    use tracing_subscriber::layer::SubscriberExt;
992    use tracing_subscriber::Layer;
993
994    #[derive(Clone, Debug, Default)]
995    struct CapturedEvent {
996        message: Option<String>,
997        audit_event: Option<String>,
998    }
999
1000    #[derive(Default)]
1001    struct CapturedEventVisitor(CapturedEvent);
1002
1003    impl Visit for CapturedEventVisitor {
1004        fn record_str(&mut self, field: &Field, value: &str) {
1005            match field.name() {
1006                "message" => self.0.message = Some(value.to_string()),
1007                "audit_event" => self.0.audit_event = Some(value.to_string()),
1008                _ => {}
1009            }
1010        }
1011
1012        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
1013            // `tracing::info!(audit_event = %expr, "msg")` records via the
1014            // Display-wrapped Debug path, so we receive the JSON string here.
1015            // `"msg"` literal records as a `message` field via `record_debug`
1016            // with a quoted Debug representation; strip the surrounding quotes
1017            // so the captured message matches the source.
1018            let formatted = format!("{value:?}");
1019            let cleaned = formatted
1020                .trim_start_matches('"')
1021                .trim_end_matches('"')
1022                .to_string();
1023            match field.name() {
1024                "message" => self.0.message = Some(cleaned),
1025                "audit_event" => self.0.audit_event = Some(cleaned),
1026                _ => {}
1027            }
1028        }
1029    }
1030
1031    struct CaptureLayer(Arc<StdMutex<Vec<CapturedEvent>>>);
1032
1033    impl<S: tracing::Subscriber> Layer<S> for CaptureLayer {
1034        fn on_event(
1035            &self,
1036            event: &tracing::Event<'_>,
1037            _: tracing_subscriber::layer::Context<'_, S>,
1038        ) {
1039            let mut visitor = CapturedEventVisitor::default();
1040            event.record(&mut visitor);
1041            self.0.lock().unwrap().push(visitor.0);
1042        }
1043    }
1044
1045    /// Run an async block under a scoped tracing subscriber and return the
1046    /// events captured during the run. Uses a current-thread tokio runtime so
1047    /// the thread-local subscriber set by `with_default` covers every task
1048    /// spawned in the body.
1049    fn capture_dispatch_events<Fut>(future: Fut) -> Vec<CapturedEvent>
1050    where
1051        Fut: std::future::Future<Output = ()>,
1052    {
1053        let captured: Arc<StdMutex<Vec<CapturedEvent>>> = Arc::new(StdMutex::new(Vec::new()));
1054        let subscriber = tracing_subscriber::registry().with(CaptureLayer(Arc::clone(&captured)));
1055
1056        tracing::subscriber::with_default(subscriber, || {
1057            let rt = tokio::runtime::Builder::new_current_thread()
1058                .enable_all()
1059                .build()
1060                .expect("build current-thread tokio runtime");
1061            rt.block_on(future);
1062        });
1063
1064        let guard = captured.lock().unwrap();
1065        guard.clone()
1066    }
1067
1068    /// Pull every captured event whose `message` matches `"gate.check"`.
1069    fn gate_check_events(events: &[CapturedEvent]) -> Vec<&CapturedEvent> {
1070        events
1071            .iter()
1072            .filter(|e| e.message.as_deref() == Some("gate.check"))
1073            .collect()
1074    }
1075
1076    #[test]
1077    fn dispatch_tracing_emits_one_gate_check_event_on_allow() {
1078        let events = capture_dispatch_events(async {
1079            let mut builder = VerbRegistryBuilder::new();
1080            builder.register(AlphaPack);
1081            builder.with_gate(Arc::new(AllowAllGate));
1082            builder.with_default_namespace("tenant-default");
1083            let reg = builder.build().expect("registry builds");
1084            reg.dispatch("list", serde_json::json!({"namespace": "tenant-q"}))
1085                .await
1086                .unwrap();
1087        });
1088
1089        let gate_events = gate_check_events(&events);
1090        assert_eq!(
1091            gate_events.len(),
1092            1,
1093            "exactly one gate.check tracing event per dispatch (allow); got {gate_events:?}"
1094        );
1095        let payload = gate_events[0]
1096            .audit_event
1097            .as_ref()
1098            .expect("gate.check event must carry an audit_event field");
1099        let audit: khive_gate::AuditEvent =
1100            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
1101        assert_eq!(audit.decision, AuditDecision::Allow);
1102        assert_eq!(audit.verb, "list");
1103        assert_eq!(audit.namespace, "tenant-q");
1104        assert_eq!(audit.gate_impl, "AllowAllGate");
1105        assert!(
1106            audit.deny_reason.is_none(),
1107            "deny_reason must be None on Allow"
1108        );
1109    }
1110
1111    // ---- Hard enforcement + EventStore persistence (ADR-035) ----
1112
1113    use async_trait::async_trait;
1114    use khive_storage::{
1115        BatchWriteSummary, Event, EventFilter, EventStore, Page, PageRequest, SubstrateKind,
1116    };
1117    use khive_types::EventOutcome;
1118
1119    /// In-memory EventStore for unit tests — avoids file-backed SQLite.
1120    #[derive(Default, Debug)]
1121    struct MemoryEventStore {
1122        events: std::sync::Mutex<Vec<Event>>,
1123    }
1124
1125    #[async_trait]
1126    impl EventStore for MemoryEventStore {
1127        async fn append_event(&self, event: Event) -> khive_storage::StorageResult<()> {
1128            self.events.lock().unwrap().push(event);
1129            Ok(())
1130        }
1131        async fn append_events(
1132            &self,
1133            events: Vec<Event>,
1134        ) -> khive_storage::StorageResult<BatchWriteSummary> {
1135            let attempted = events.len() as u64;
1136            let affected = attempted;
1137            self.events.lock().unwrap().extend(events);
1138            Ok(BatchWriteSummary {
1139                attempted,
1140                affected,
1141                failed: 0,
1142                first_error: String::new(),
1143            })
1144        }
1145        async fn get_event(&self, id: uuid::Uuid) -> khive_storage::StorageResult<Option<Event>> {
1146            Ok(self
1147                .events
1148                .lock()
1149                .unwrap()
1150                .iter()
1151                .find(|e| e.id == id)
1152                .cloned())
1153        }
1154        async fn query_events(
1155            &self,
1156            _filter: EventFilter,
1157            _page: PageRequest,
1158        ) -> khive_storage::StorageResult<Page<Event>> {
1159            let items = self.events.lock().unwrap().clone();
1160            let total = items.len() as u64;
1161            Ok(Page {
1162                items,
1163                total: Some(total),
1164            })
1165        }
1166        async fn count_events(&self, _filter: EventFilter) -> khive_storage::StorageResult<u64> {
1167            Ok(self.events.lock().unwrap().len() as u64)
1168        }
1169    }
1170
1171    #[tokio::test]
1172    async fn allow_all_gate_default_remains_backward_compatible() {
1173        // No gate set — AllowAllGate is the default. Dispatch must succeed.
1174        let mut builder = VerbRegistryBuilder::new();
1175        builder.register(AlphaPack);
1176        let reg = builder.build().expect("registry builds");
1177
1178        let res = reg.dispatch("list", Value::Null).await.unwrap();
1179        assert_eq!(
1180            res["pack"], "alpha",
1181            "AllowAllGate must allow every verb — backward compat guarantee"
1182        );
1183        let res = reg.dispatch("create", Value::Null).await.unwrap();
1184        assert_eq!(res["pack"], "alpha");
1185    }
1186
1187    #[tokio::test]
1188    async fn deny_gate_returns_permission_denied_pack_never_invoked() {
1189        #[derive(Debug)]
1190        struct AlwaysDenyGate;
1191        impl Gate for AlwaysDenyGate {
1192            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1193                Ok(GateDecision::deny("test: always deny"))
1194            }
1195        }
1196
1197        // Track whether dispatch was ever invoked on the pack.
1198        #[derive(Debug)]
1199        struct TrackedPack {
1200            invoked: Arc<AtomicUsize>,
1201        }
1202
1203        impl khive_types::Pack for TrackedPack {
1204            const NAME: &'static str = "tracked";
1205            const NOTE_KINDS: &'static [&'static str] = &[];
1206            const ENTITY_KINDS: &'static [&'static str] = &[];
1207            const VERBS: &'static [VerbDef] = &[VerbDef {
1208                name: "guarded",
1209                description: "a guarded verb",
1210            }];
1211        }
1212
1213        #[async_trait]
1214        impl PackRuntime for TrackedPack {
1215            fn name(&self) -> &str {
1216                Self::NAME
1217            }
1218            fn note_kinds(&self) -> &'static [&'static str] {
1219                Self::NOTE_KINDS
1220            }
1221            fn entity_kinds(&self) -> &'static [&'static str] {
1222                Self::ENTITY_KINDS
1223            }
1224            fn verbs(&self) -> &'static [VerbDef] {
1225                Self::VERBS
1226            }
1227            async fn dispatch(
1228                &self,
1229                _verb: &str,
1230                _params: Value,
1231                _registry: &VerbRegistry,
1232            ) -> Result<Value, RuntimeError> {
1233                self.invoked.fetch_add(1, Ordering::SeqCst);
1234                Ok(serde_json::json!({"invoked": true}))
1235            }
1236        }
1237
1238        let invoked = Arc::new(AtomicUsize::new(0));
1239        let mut builder = VerbRegistryBuilder::new();
1240        builder.register(TrackedPack {
1241            invoked: invoked.clone(),
1242        });
1243        builder.with_gate(Arc::new(AlwaysDenyGate));
1244        let reg = builder.build().expect("registry builds");
1245
1246        let err = reg.dispatch("guarded", Value::Null).await.unwrap_err();
1247        assert!(
1248            matches!(err, RuntimeError::PermissionDenied { ref verb, ref reason } if verb == "guarded" && reason.contains("always deny")),
1249            "expected PermissionDenied with verb=guarded and reason, got: {err:?}"
1250        );
1251        assert_eq!(
1252            invoked.load(Ordering::SeqCst),
1253            0,
1254            "pack dispatch MUST NOT be invoked when gate denies"
1255        );
1256    }
1257
1258    #[tokio::test]
1259    async fn audit_event_persists_to_event_store_on_allow() {
1260        let store = Arc::new(MemoryEventStore::default());
1261        let mut builder = VerbRegistryBuilder::new();
1262        builder.register(AlphaPack);
1263        builder.with_event_store(store.clone());
1264        let reg = builder.build().expect("registry builds");
1265
1266        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
1267            .await
1268            .unwrap();
1269
1270        let count = store.count_events(EventFilter::default()).await.unwrap();
1271        assert_eq!(count, 1, "one audit event persisted to EventStore on allow");
1272
1273        let page = store
1274            .query_events(
1275                EventFilter::default(),
1276                PageRequest {
1277                    limit: 10,
1278                    offset: 0,
1279                },
1280            )
1281            .await
1282            .unwrap();
1283        let ev = &page.items[0];
1284        assert_eq!(ev.verb, "list");
1285        assert_eq!(ev.namespace, "test-ns");
1286        assert_eq!(ev.substrate, SubstrateKind::Event);
1287        assert_eq!(ev.outcome, EventOutcome::Success);
1288    }
1289
1290    #[tokio::test]
1291    async fn audit_event_persists_to_event_store_on_deny() {
1292        #[derive(Debug)]
1293        struct AlwaysDenyGate;
1294        impl Gate for AlwaysDenyGate {
1295            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1296                Ok(GateDecision::deny("denied by test"))
1297            }
1298        }
1299
1300        let store = Arc::new(MemoryEventStore::default());
1301        let mut builder = VerbRegistryBuilder::new();
1302        builder.register(AlphaPack);
1303        builder.with_gate(Arc::new(AlwaysDenyGate));
1304        builder.with_event_store(store.clone());
1305        let reg = builder.build().expect("registry builds");
1306
1307        // Hard enforce → PermissionDenied returned.
1308        let err = reg
1309            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
1310            .await
1311            .unwrap_err();
1312        assert!(matches!(err, RuntimeError::PermissionDenied { .. }));
1313
1314        let count = store.count_events(EventFilter::default()).await.unwrap();
1315        assert_eq!(count, 1, "one audit event persisted to EventStore on deny");
1316
1317        let page = store
1318            .query_events(
1319                EventFilter::default(),
1320                PageRequest {
1321                    limit: 10,
1322                    offset: 0,
1323                },
1324            )
1325            .await
1326            .unwrap();
1327        let ev = &page.items[0];
1328        assert_eq!(ev.verb, "list");
1329        assert_eq!(ev.outcome, EventOutcome::Denied);
1330    }
1331
1332    #[tokio::test]
1333    async fn gate_error_does_not_persist_to_event_store() {
1334        #[derive(Debug)]
1335        struct FailingGate;
1336        impl Gate for FailingGate {
1337            fn check(&self, _req: &GateRequest) -> Result<GateDecision, khive_gate::GateError> {
1338                Err(khive_gate::GateError::Internal("gate broken".into()))
1339            }
1340        }
1341
1342        let store = Arc::new(MemoryEventStore::default());
1343        let mut builder = VerbRegistryBuilder::new();
1344        builder.register(AlphaPack);
1345        builder.with_gate(Arc::new(FailingGate));
1346        builder.with_event_store(store.clone());
1347        let reg = builder.build().expect("registry builds");
1348
1349        // Gate Err → fail-open, dispatch proceeds.
1350        let res = reg.dispatch("list", Value::Null).await.unwrap();
1351        assert_eq!(
1352            res["pack"], "alpha",
1353            "gate error must fail-open, not block dispatch"
1354        );
1355
1356        let count = store.count_events(EventFilter::default()).await.unwrap();
1357        assert_eq!(
1358            count, 0,
1359            "gate infrastructure error must NOT produce an audit event in EventStore"
1360        );
1361    }
1362
1363    #[tokio::test]
1364    async fn no_event_store_configured_tracing_only() {
1365        // When no event_store is configured, dispatch must succeed without error.
1366        // (The tracing path is exercised in the tracing tests above; here we just
1367        // verify the absence of event_store does not break dispatch.)
1368        let mut builder = VerbRegistryBuilder::new();
1369        builder.register(AlphaPack);
1370        let reg = builder.build().expect("registry builds");
1371
1372        let res = reg.dispatch("list", Value::Null).await.unwrap();
1373        assert_eq!(res["pack"], "alpha");
1374    }
1375
1376    #[test]
1377    fn dispatch_tracing_emits_gate_check_event_with_deny_payload() {
1378        #[derive(Debug)]
1379        struct AlwaysDenyGate;
1380        impl Gate for AlwaysDenyGate {
1381            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1382                Ok(GateDecision::deny("denied by test gate"))
1383            }
1384            fn impl_name(&self) -> &'static str {
1385                "AlwaysDenyGate"
1386            }
1387        }
1388
1389        let events = capture_dispatch_events(async {
1390            let mut builder = VerbRegistryBuilder::new();
1391            builder.register(AlphaPack);
1392            builder.with_gate(Arc::new(AlwaysDenyGate));
1393            let reg = builder.build().expect("registry builds");
1394            // Hard enforcement (ADR-035) — dispatch returns PermissionDenied on Deny.
1395            // The tracing audit event is still emitted before the error is returned.
1396            let _ = reg.dispatch("create", serde_json::Value::Null).await;
1397        });
1398
1399        let gate_events = gate_check_events(&events);
1400        assert_eq!(
1401            gate_events.len(),
1402            1,
1403            "exactly one gate.check tracing event per dispatch (deny); got {gate_events:?}"
1404        );
1405        let payload = gate_events[0]
1406            .audit_event
1407            .as_ref()
1408            .expect("gate.check event must carry an audit_event field on Deny");
1409        let audit: khive_gate::AuditEvent =
1410            serde_json::from_str(payload).expect("audit_event payload must decode to AuditEvent");
1411        assert_eq!(audit.decision, AuditDecision::Deny);
1412        assert_eq!(audit.deny_reason.as_deref(), Some("denied by test gate"));
1413        assert_eq!(audit.gate_impl, "AlwaysDenyGate");
1414        // Wire-shape rule from ADR-033: obligations is always serialized as an
1415        // array, empty on Deny. Round-trip back through serde_json::Value to
1416        // confirm the field exists on the wire and is `[]`, not missing.
1417        let payload_json: serde_json::Value =
1418            serde_json::from_str(payload).expect("payload must be valid JSON");
1419        assert_eq!(
1420            payload_json["obligations"],
1421            serde_json::Value::Array(Vec::new()),
1422            "obligations must be `[]` on Deny on the tracing payload, not omitted"
1423        );
1424    }
1425
1426    // ---- EventStore audit envelope round-trip (ADR-033 / ADR-035) ----
1427    //
1428    // Codex review finding (Major #1): EventStore was persisting a summary
1429    // Event without the full AuditEvent fields (deny_reason, gate_impl,
1430    // obligations). This test verifies the complete envelope survives
1431    // append_event → query_events.
1432
1433    #[tokio::test]
1434    async fn audit_envelope_round_trips_deny_reason_and_gate_impl_through_event_store() {
1435        #[derive(Debug)]
1436        struct DenyGateWithName;
1437        impl Gate for DenyGateWithName {
1438            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1439                Ok(GateDecision::deny("policy: write forbidden for anon"))
1440            }
1441            fn impl_name(&self) -> &'static str {
1442                "DenyGateWithName"
1443            }
1444        }
1445
1446        let store = Arc::new(MemoryEventStore::default());
1447        let mut builder = VerbRegistryBuilder::new();
1448        builder.register(AlphaPack);
1449        builder.with_gate(Arc::new(DenyGateWithName));
1450        builder.with_event_store(store.clone());
1451        let reg = builder.build().expect("registry builds");
1452
1453        // Dispatch is denied — PermissionDenied returned.
1454        let err = reg
1455            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
1456            .await
1457            .unwrap_err();
1458        assert!(
1459            matches!(err, RuntimeError::PermissionDenied { .. }),
1460            "expected PermissionDenied, got {err:?}"
1461        );
1462
1463        // Exactly one event in the store.
1464        let page = store
1465            .query_events(
1466                EventFilter::default(),
1467                PageRequest {
1468                    limit: 10,
1469                    offset: 0,
1470                },
1471            )
1472            .await
1473            .unwrap();
1474        assert_eq!(
1475            page.items.len(),
1476            1,
1477            "one audit event must be persisted on deny"
1478        );
1479
1480        let ev = &page.items[0];
1481        assert_eq!(ev.outcome, EventOutcome::Denied);
1482
1483        // The data field must hold the full AuditEvent envelope (ADR-033 contract).
1484        let data = ev
1485            .data
1486            .as_ref()
1487            .expect("Event.data must be Some — full AuditEvent envelope must be persisted");
1488
1489        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
1490            .expect("Event.data must deserialize to AuditEvent");
1491
1492        assert_eq!(
1493            audit.deny_reason.as_deref(),
1494            Some("policy: write forbidden for anon"),
1495            "deny_reason must be preserved through EventStore"
1496        );
1497        assert_eq!(
1498            audit.gate_impl, "DenyGateWithName",
1499            "gate_impl must be preserved through EventStore"
1500        );
1501        assert_eq!(
1502            audit.decision,
1503            khive_gate::AuditDecision::Deny,
1504            "decision field must be preserved through EventStore"
1505        );
1506    }
1507
1508    #[tokio::test]
1509    async fn audit_envelope_round_trips_obligations_through_event_store() {
1510        use khive_gate::Obligation;
1511
1512        #[derive(Debug)]
1513        struct ObligationGate;
1514        impl Gate for ObligationGate {
1515            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1516                Ok(GateDecision::allow_with(vec![Obligation::Audit {
1517                    tag: "billing.meter".into(),
1518                }]))
1519            }
1520            fn impl_name(&self) -> &'static str {
1521                "ObligationGate"
1522            }
1523        }
1524
1525        let store = Arc::new(MemoryEventStore::default());
1526        let mut builder = VerbRegistryBuilder::new();
1527        builder.register(AlphaPack);
1528        builder.with_gate(Arc::new(ObligationGate));
1529        builder.with_event_store(store.clone());
1530        let reg = builder.build().expect("registry builds");
1531
1532        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
1533            .await
1534            .unwrap();
1535
1536        let page = store
1537            .query_events(
1538                EventFilter::default(),
1539                PageRequest {
1540                    limit: 10,
1541                    offset: 0,
1542                },
1543            )
1544            .await
1545            .unwrap();
1546        assert_eq!(page.items.len(), 1);
1547
1548        let ev = &page.items[0];
1549        assert_eq!(ev.outcome, EventOutcome::Success);
1550
1551        let data = ev
1552            .data
1553            .as_ref()
1554            .expect("Event.data must be Some — AuditEvent envelope must be persisted on allow");
1555
1556        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
1557            .expect("Event.data must deserialize to AuditEvent");
1558
1559        assert_eq!(audit.gate_impl, "ObligationGate");
1560        assert_eq!(
1561            audit.obligations.len(),
1562            1,
1563            "obligations must be preserved through EventStore"
1564        );
1565        match &audit.obligations[0] {
1566            Obligation::Audit { tag } => assert_eq!(tag, "billing.meter"),
1567            other => panic!("expected Audit obligation, got {other:?}"),
1568        }
1569    }
1570
1571    // ---- SQL-backed audit envelope round-trip (ADR-033 / ADR-035, codex r2) ----
1572    //
1573    // The two tests above use MemoryEventStore (no serialization). This test
1574    // wires the production SqlEventStore via KhiveRuntime::memory() to verify
1575    // that the full AuditEvent envelope survives the SQL text→parse round-trip
1576    // (Event.data is stored as TEXT and parsed back on read).
1577
1578    #[tokio::test]
1579    async fn sql_backed_audit_envelope_round_trips_deny_reason_gate_impl_and_obligations() {
1580        #[derive(Debug)]
1581        struct SqlTestDenyGate;
1582        impl Gate for SqlTestDenyGate {
1583            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1584                Ok(GateDecision::deny("sql-path: write denied"))
1585            }
1586            fn impl_name(&self) -> &'static str {
1587                "SqlTestDenyGate"
1588            }
1589        }
1590
1591        // KhiveRuntime::memory() creates an in-memory SQLite pool (is_file_backed=false).
1592        // events_for_namespace ensures the events schema and returns a SqlEventStore
1593        // scoped to "test-ns". The pool is shared so reads and writes see the same data.
1594        let rt = KhiveRuntime::memory().expect("in-memory runtime");
1595        let sql_store = rt
1596            .events(Some("test-ns"))
1597            .expect("events_for_namespace must succeed");
1598
1599        let mut builder = VerbRegistryBuilder::new();
1600        builder.register(AlphaPack);
1601        builder.with_gate(Arc::new(SqlTestDenyGate));
1602        builder.with_event_store(sql_store.clone());
1603        let reg = builder.build().expect("registry builds");
1604
1605        // Dispatch is denied — PermissionDenied returned.
1606        let err = reg
1607            .dispatch("list", serde_json::json!({"namespace": "test-ns"}))
1608            .await
1609            .unwrap_err();
1610        assert!(
1611            matches!(err, RuntimeError::PermissionDenied { .. }),
1612            "expected PermissionDenied, got {err:?}"
1613        );
1614
1615        // Query via the same SqlEventStore — this is the SQL read path.
1616        let page = sql_store
1617            .query_events(
1618                EventFilter::default(),
1619                PageRequest {
1620                    limit: 10,
1621                    offset: 0,
1622                },
1623            )
1624            .await
1625            .unwrap();
1626        assert_eq!(
1627            page.items.len(),
1628            1,
1629            "one audit event must be persisted on deny through SqlEventStore"
1630        );
1631
1632        let ev = &page.items[0];
1633        assert_eq!(ev.outcome, EventOutcome::Denied);
1634
1635        // Event.data must hold the full AuditEvent serialized as JSON text and
1636        // parsed back. If the SQL path was lossy, this deserialization would fail
1637        // or the field assertions below would fail.
1638        let data = ev
1639            .data
1640            .as_ref()
1641            .expect("Event.data must be Some — SqlEventStore must persist AuditEvent envelope");
1642
1643        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
1644            .expect("Event.data must deserialize to AuditEvent after SQL round-trip");
1645
1646        assert_eq!(
1647            audit.deny_reason.as_deref(),
1648            Some("sql-path: write denied"),
1649            "deny_reason must survive the SQL text round-trip"
1650        );
1651        assert_eq!(
1652            audit.gate_impl, "SqlTestDenyGate",
1653            "gate_impl must survive the SQL text round-trip"
1654        );
1655        assert_eq!(
1656            audit.decision,
1657            khive_gate::AuditDecision::Deny,
1658            "decision field must survive the SQL text round-trip"
1659        );
1660        // obligations is [] on a Deny gate (no obligations returned).
1661        // Verify the field is present and empty after SQL round-trip.
1662        assert!(
1663            audit.obligations.is_empty(),
1664            "obligations must be preserved as empty [] through SQL round-trip"
1665        );
1666    }
1667
1668    // ---- SQL-backed audit envelope: non-empty obligations survive round-trip ----
1669    //
1670    // Codex r3 identified a blind spot: the deny-path SQL test above only
1671    // asserts obligations == [], which passes even if the SQL path drops the
1672    // field entirely (AuditEvent.obligations has #[serde(default)]).
1673    //
1674    // This test installs an allow-path gate that returns a non-empty obligations
1675    // vec. After dispatch, the same SqlEventStore is queried and both layers are
1676    // checked:
1677    //   1. Raw Event.data["obligations"] is a non-empty JSON array.
1678    //   2. Deserialized AuditEvent.obligations[0] matches the expected variant.
1679    #[tokio::test]
1680    async fn sql_backed_audit_envelope_round_trips_non_empty_obligations() {
1681        use khive_gate::Obligation;
1682
1683        #[derive(Debug)]
1684        struct SqlTestAllowWithObligationGate;
1685        impl Gate for SqlTestAllowWithObligationGate {
1686            fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
1687                Ok(GateDecision::allow_with(vec![Obligation::Audit {
1688                    tag: "sql-path-billing.meter".into(),
1689                }]))
1690            }
1691            fn impl_name(&self) -> &'static str {
1692                "SqlTestAllowWithObligationGate"
1693            }
1694        }
1695
1696        let rt = KhiveRuntime::memory().expect("in-memory runtime");
1697        let sql_store = rt
1698            .events(Some("test-ns"))
1699            .expect("events_for_namespace must succeed");
1700
1701        let mut builder = VerbRegistryBuilder::new();
1702        builder.register(AlphaPack);
1703        builder.with_gate(Arc::new(SqlTestAllowWithObligationGate));
1704        builder.with_event_store(sql_store.clone());
1705        let reg = builder.build().expect("registry builds");
1706
1707        // Dispatch succeeds — the gate allows with obligations.
1708        reg.dispatch("list", serde_json::json!({"namespace": "test-ns"}))
1709            .await
1710            .expect("dispatch must succeed when gate allows");
1711
1712        // Query via the same SqlEventStore — this is the SQL read path.
1713        let page = sql_store
1714            .query_events(
1715                EventFilter::default(),
1716                PageRequest {
1717                    limit: 10,
1718                    offset: 0,
1719                },
1720            )
1721            .await
1722            .unwrap();
1723        assert_eq!(
1724            page.items.len(),
1725            1,
1726            "one audit event must be persisted on allow through SqlEventStore"
1727        );
1728
1729        let ev = &page.items[0];
1730        assert_eq!(ev.outcome, EventOutcome::Success);
1731
1732        let data = ev
1733            .data
1734            .as_ref()
1735            .expect("Event.data must be Some — SqlEventStore must persist AuditEvent envelope");
1736
1737        // Layer 1: raw JSON check — obligations must be a non-empty array in
1738        // the persisted TEXT. If the SQL path dropped the field, the default
1739        // #[serde(default)] would silently deserialize it to [], so we verify
1740        // the raw JSON before deserializing.
1741        let obligations_raw = data
1742            .get("obligations")
1743            .expect("Event.data JSON must contain 'obligations' key");
1744        let obligations_arr = obligations_raw
1745            .as_array()
1746            .expect("'obligations' must be a JSON array");
1747        assert!(
1748            !obligations_arr.is_empty(),
1749            "raw Event.data['obligations'] must be non-empty after SQL round-trip"
1750        );
1751
1752        // Layer 2: deserialized AuditEvent check — the obligation variant and
1753        // payload must survive the text round-trip faithfully.
1754        let audit: khive_gate::AuditEvent = serde_json::from_value(data.clone())
1755            .expect("Event.data must deserialize to AuditEvent after SQL round-trip");
1756
1757        assert_eq!(
1758            audit.gate_impl, "SqlTestAllowWithObligationGate",
1759            "gate_impl must survive the SQL text round-trip"
1760        );
1761        assert_eq!(
1762            audit.decision,
1763            khive_gate::AuditDecision::Allow,
1764            "decision field must survive the SQL text round-trip"
1765        );
1766        assert_eq!(
1767            audit.obligations.len(),
1768            1,
1769            "obligations must be non-empty after SQL round-trip (not silently defaulted to [])"
1770        );
1771        match &audit.obligations[0] {
1772            Obligation::Audit { tag } => assert_eq!(
1773                tag, "sql-path-billing.meter",
1774                "Audit obligation tag must survive the SQL text round-trip"
1775            ),
1776            other => panic!("expected Audit obligation, got {other:?}"),
1777        }
1778    }
1779}
1780
1781// ---- ADR-037: inter-pack dependency checking ----
1782
1783#[cfg(test)]
1784mod dep_tests {
1785    use super::*;
1786    use async_trait::async_trait;
1787    use khive_types::Pack;
1788    use serde_json::Value;
1789
1790    struct KgDepPack;
1791    struct MemoryDepPack;
1792    struct ADepPack;
1793    struct BDepPack;
1794
1795    impl Pack for KgDepPack {
1796        const NAME: &'static str = "kg_dep";
1797        const NOTE_KINDS: &'static [&'static str] = &["observation"];
1798        const ENTITY_KINDS: &'static [&'static str] = &["concept"];
1799        const VERBS: &'static [VerbDef] = &[];
1800    }
1801
1802    impl Pack for MemoryDepPack {
1803        const NAME: &'static str = "memory_dep";
1804        const NOTE_KINDS: &'static [&'static str] = &["memory"];
1805        const ENTITY_KINDS: &'static [&'static str] = &[];
1806        const VERBS: &'static [VerbDef] = &[];
1807        const REQUIRES: &'static [&'static str] = &["kg_dep"];
1808    }
1809
1810    impl Pack for ADepPack {
1811        const NAME: &'static str = "pack_a";
1812        const NOTE_KINDS: &'static [&'static str] = &[];
1813        const ENTITY_KINDS: &'static [&'static str] = &[];
1814        const VERBS: &'static [VerbDef] = &[];
1815        const REQUIRES: &'static [&'static str] = &["pack_b"];
1816    }
1817
1818    impl Pack for BDepPack {
1819        const NAME: &'static str = "pack_b";
1820        const NOTE_KINDS: &'static [&'static str] = &[];
1821        const ENTITY_KINDS: &'static [&'static str] = &[];
1822        const VERBS: &'static [VerbDef] = &[];
1823        const REQUIRES: &'static [&'static str] = &["pack_a"];
1824    }
1825
1826    #[async_trait]
1827    impl PackRuntime for KgDepPack {
1828        fn name(&self) -> &str {
1829            Self::NAME
1830        }
1831        fn note_kinds(&self) -> &'static [&'static str] {
1832            Self::NOTE_KINDS
1833        }
1834        fn entity_kinds(&self) -> &'static [&'static str] {
1835            Self::ENTITY_KINDS
1836        }
1837        fn verbs(&self) -> &'static [VerbDef] {
1838            Self::VERBS
1839        }
1840        async fn dispatch(
1841            &self,
1842            verb: &str,
1843            _: Value,
1844            _: &VerbRegistry,
1845        ) -> Result<Value, RuntimeError> {
1846            Err(RuntimeError::InvalidInput(format!(
1847                "KgDepPack has no verbs: {verb}"
1848            )))
1849        }
1850    }
1851
1852    #[async_trait]
1853    impl PackRuntime for MemoryDepPack {
1854        fn name(&self) -> &str {
1855            Self::NAME
1856        }
1857        fn note_kinds(&self) -> &'static [&'static str] {
1858            Self::NOTE_KINDS
1859        }
1860        fn entity_kinds(&self) -> &'static [&'static str] {
1861            Self::ENTITY_KINDS
1862        }
1863        fn verbs(&self) -> &'static [VerbDef] {
1864            Self::VERBS
1865        }
1866        fn requires(&self) -> &'static [&'static str] {
1867            Self::REQUIRES
1868        }
1869        async fn dispatch(
1870            &self,
1871            verb: &str,
1872            _: Value,
1873            _: &VerbRegistry,
1874        ) -> Result<Value, RuntimeError> {
1875            Err(RuntimeError::InvalidInput(format!(
1876                "MemoryDepPack has no verbs: {verb}"
1877            )))
1878        }
1879    }
1880
1881    #[async_trait]
1882    impl PackRuntime for ADepPack {
1883        fn name(&self) -> &str {
1884            Self::NAME
1885        }
1886        fn note_kinds(&self) -> &'static [&'static str] {
1887            Self::NOTE_KINDS
1888        }
1889        fn entity_kinds(&self) -> &'static [&'static str] {
1890            Self::ENTITY_KINDS
1891        }
1892        fn verbs(&self) -> &'static [VerbDef] {
1893            Self::VERBS
1894        }
1895        fn requires(&self) -> &'static [&'static str] {
1896            Self::REQUIRES
1897        }
1898        async fn dispatch(
1899            &self,
1900            verb: &str,
1901            _: Value,
1902            _: &VerbRegistry,
1903        ) -> Result<Value, RuntimeError> {
1904            Err(RuntimeError::InvalidInput(format!(
1905                "ADepPack has no verbs: {verb}"
1906            )))
1907        }
1908    }
1909
1910    #[async_trait]
1911    impl PackRuntime for BDepPack {
1912        fn name(&self) -> &str {
1913            Self::NAME
1914        }
1915        fn note_kinds(&self) -> &'static [&'static str] {
1916            Self::NOTE_KINDS
1917        }
1918        fn entity_kinds(&self) -> &'static [&'static str] {
1919            Self::ENTITY_KINDS
1920        }
1921        fn verbs(&self) -> &'static [VerbDef] {
1922            Self::VERBS
1923        }
1924        fn requires(&self) -> &'static [&'static str] {
1925            Self::REQUIRES
1926        }
1927        async fn dispatch(
1928            &self,
1929            verb: &str,
1930            _: Value,
1931            _: &VerbRegistry,
1932        ) -> Result<Value, RuntimeError> {
1933            Err(RuntimeError::InvalidInput(format!(
1934                "BDepPack has no verbs: {verb}"
1935            )))
1936        }
1937    }
1938
1939    #[test]
1940    fn test_pack_deps_happy_path() {
1941        let mut builder = VerbRegistryBuilder::new();
1942        builder.register(MemoryDepPack);
1943        builder.register(KgDepPack);
1944        let reg = builder
1945            .build()
1946            .expect("kg_dep satisfies memory_dep dependency");
1947        assert_eq!(reg.pack_requires("memory_dep").unwrap(), &["kg_dep"]);
1948        let names = reg.pack_names();
1949        let kg_pos = names.iter().position(|&n| n == "kg_dep").unwrap();
1950        let mem_pos = names.iter().position(|&n| n == "memory_dep").unwrap();
1951        assert!(
1952            kg_pos < mem_pos,
1953            "kg_dep must be loaded before memory_dep; order: {names:?}"
1954        );
1955    }
1956
1957    #[test]
1958    fn test_pack_deps_missing() {
1959        let mut builder = VerbRegistryBuilder::new();
1960        builder.register(MemoryDepPack);
1961        let err = match builder.build() {
1962            Ok(_) => panic!("expected Err, got Ok"),
1963            Err(e) => e,
1964        };
1965        assert!(
1966            matches!(err, RuntimeError::MissingPackDependency(_)),
1967            "expected MissingPackDependency, got {err:?}"
1968        );
1969        let msg = err.to_string();
1970        assert!(
1971            msg.contains("memory_dep"),
1972            "error must name the dependent pack: {msg}"
1973        );
1974        assert!(
1975            msg.contains("kg_dep"),
1976            "error must name the missing dep: {msg}"
1977        );
1978    }
1979
1980    #[test]
1981    fn test_pack_deps_circular() {
1982        let mut builder = VerbRegistryBuilder::new();
1983        builder.register(ADepPack);
1984        builder.register(BDepPack);
1985        let err = match builder.build() {
1986            Ok(_) => panic!("expected Err, got Ok"),
1987            Err(e) => e,
1988        };
1989        assert!(
1990            matches!(err, RuntimeError::CircularPackDependency(_)),
1991            "expected CircularPackDependency, got {err:?}"
1992        );
1993        let msg = err.to_string();
1994        assert!(msg.contains("pack_a"), "error must name pack_a: {msg}");
1995        assert!(msg.contains("pack_b"), "error must name pack_b: {msg}");
1996    }
1997
1998    #[test]
1999    fn test_pack_deps_no_deps() {
2000        struct NoDepsA;
2001        struct NoDepsB;
2002
2003        impl Pack for NoDepsA {
2004            const NAME: &'static str = "no_deps_a";
2005            const NOTE_KINDS: &'static [&'static str] = &[];
2006            const ENTITY_KINDS: &'static [&'static str] = &[];
2007            const VERBS: &'static [VerbDef] = &[];
2008        }
2009
2010        impl Pack for NoDepsB {
2011            const NAME: &'static str = "no_deps_b";
2012            const NOTE_KINDS: &'static [&'static str] = &[];
2013            const ENTITY_KINDS: &'static [&'static str] = &[];
2014            const VERBS: &'static [VerbDef] = &[];
2015        }
2016
2017        #[async_trait]
2018        impl PackRuntime for NoDepsA {
2019            fn name(&self) -> &str {
2020                Self::NAME
2021            }
2022            fn note_kinds(&self) -> &'static [&'static str] {
2023                Self::NOTE_KINDS
2024            }
2025            fn entity_kinds(&self) -> &'static [&'static str] {
2026                Self::ENTITY_KINDS
2027            }
2028            fn verbs(&self) -> &'static [VerbDef] {
2029                Self::VERBS
2030            }
2031            async fn dispatch(
2032                &self,
2033                verb: &str,
2034                _: Value,
2035                _: &VerbRegistry,
2036            ) -> Result<Value, RuntimeError> {
2037                Err(RuntimeError::InvalidInput(format!("NoDepsA: {verb}")))
2038            }
2039        }
2040
2041        #[async_trait]
2042        impl PackRuntime for NoDepsB {
2043            fn name(&self) -> &str {
2044                Self::NAME
2045            }
2046            fn note_kinds(&self) -> &'static [&'static str] {
2047                Self::NOTE_KINDS
2048            }
2049            fn entity_kinds(&self) -> &'static [&'static str] {
2050                Self::ENTITY_KINDS
2051            }
2052            fn verbs(&self) -> &'static [VerbDef] {
2053                Self::VERBS
2054            }
2055            async fn dispatch(
2056                &self,
2057                verb: &str,
2058                _: Value,
2059                _: &VerbRegistry,
2060            ) -> Result<Value, RuntimeError> {
2061                Err(RuntimeError::InvalidInput(format!("NoDepsB: {verb}")))
2062            }
2063        }
2064
2065        let mut builder = VerbRegistryBuilder::new();
2066        builder.register(NoDepsA);
2067        builder.register(NoDepsB);
2068        let reg = builder.build().expect("packs with REQUIRES=&[] build");
2069        assert_eq!(reg.pack_requires("no_deps_a").unwrap(), &[] as &[&str]);
2070        assert_eq!(reg.pack_requires("no_deps_b").unwrap(), &[] as &[&str]);
2071    }
2072}