Skip to main content

khive_runtime/pack/
builder.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::sync::Arc;
3
4#[cfg(doc)]
5use khive_gate::GateRequest;
6use khive_gate::{AllowAllGate, GateRef};
7use khive_storage::EventStore;
8#[cfg(doc)]
9use khive_storage::EventView;
10use khive_types::{Namespace, NotePropertyPolicySpec};
11use serde_json::Value;
12
13use crate::error::{
14    CircularPackDependency, MissingPackDependencies, MissingPackDependency, RuntimeError,
15};
16use crate::KhiveRuntime;
17
18use super::{
19    DispatchHook, EdgeEndpointRule, EntityTypeDef, HandlerDef, PackByIdResolver, PackRuntime,
20    VerbCategory, VerbRegistry, Visibility, RESERVED_ENVELOPE_ARGS,
21};
22#[cfg(doc)]
23use super::{PackFactory, PackRegistry};
24
25/// Builder for constructing a `VerbRegistry`.
26///
27/// Packs are registered here; once `.build()` is called the registry is
28/// immutable and cheaply cloneable.
29pub struct VerbRegistryBuilder {
30    packs: Vec<Box<dyn PackRuntime>>,
31    pub(super) pack_versions: HashMap<String, &'static str>,
32    /// Parallel to `packs`: whether the composition root vouches for the
33    /// pack at the same index, recorded by the registration method the
34    /// *caller* chose rather than anything the pack reports about itself.
35    /// [`Self::register`] (public, reachable from any pack crate) always
36    /// pushes `false`; `register_boxed` (crate-private, exercised only by
37    /// [`PackRegistry::register_packs`]'s `inventory`-discovered factories)
38    /// and the test-only `register_trusted` push `true`. A pack has no API
39    /// surface to set its own entry here — see
40    /// [`VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS`]'s doc for why
41    /// `pack.name()` alone cannot be trusted for this decision.
42    pack_trusted: Vec<bool>,
43    disabled_verbs: HashMap<String, Vec<String>>,
44    resolvers: Vec<(String, Box<dyn PackByIdResolver>)>,
45    pub(super) kg_read_resolver: Option<Arc<crate::kg_read::KgReadResolver>>,
46    gate: GateRef,
47    default_namespace: String,
48    /// Operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
49    ///
50    /// Threads into `VerbRegistry::visible_namespaces` and is consumed by the
51    /// default dispatch path to widen read scope to `['local'] ∪ visible_namespaces`.
52    /// Writes remain pinned to `'local'`. An explicit `namespace=` request param
53    /// is a precise escape and is not widened by this set. A cloud gate may also
54    /// consult the list as policy input at its own layer.
55    visible_namespaces: Vec<Namespace>,
56    /// Configured actor identity label (ADR-057). When set, dispatch mints tokens
57    /// carrying this actor so that `comm.inbox` filters by `to_actor`.
58    actor_id: Option<String>,
59    /// Optional audit event sink.
60    ///
61    /// When set, every gate check writes a storage `Event` in addition to the
62    /// `tracing::info!` emission. The store is `Arc<dyn EventStore>` so the
63    /// registry does not depend on the full `KhiveRuntime` surface — only the
64    /// audit-persistence capability is needed here.
65    event_store: Option<Arc<dyn EventStore>>,
66    /// Defers the runtime sink's namespace-scoped read binding until build.
67    runtime_event_store: Option<KhiveRuntime>,
68    /// The configured audit backend is intentionally read-only, so dispatch
69    /// omits the known-failing append and the transport surfaces an advisory.
70    audit_store_read_only: bool,
71    /// Optional post-dispatch hook.
72    ///
73    /// When set, every successful pack dispatch calls `hook.on_dispatch(view)`
74    /// with a synthetic `EventView` describing the outcome and carrying no
75    /// observations. Opt-in: when None, no overhead is incurred.
76    dispatch_hook: Option<Arc<dyn DispatchHook>>,
77    /// ADR-133 audit-batch config override, applied when `build()` lazily
78    /// constructs the batch seam from `event_store`. `None` uses
79    /// `AuditBatchConfig::default()`.
80    audit_batch_config: Option<crate::audit_batch::AuditBatchConfig>,
81}
82
83impl VerbRegistryBuilder {
84    /// Create a builder with no packs, `AllowAllGate`, and the local namespace as default.
85    pub fn new() -> Self {
86        Self {
87            packs: Vec::new(),
88            pack_versions: HashMap::new(),
89            pack_trusted: Vec::new(),
90            disabled_verbs: HashMap::new(),
91            resolvers: Vec::new(),
92            kg_read_resolver: None,
93            gate: std::sync::Arc::new(AllowAllGate),
94            default_namespace: Namespace::local().as_str().to_string(),
95            visible_namespaces: vec![],
96            actor_id: None,
97            event_store: None,
98            runtime_event_store: None,
99            audit_store_read_only: false,
100            dispatch_hook: None,
101            audit_batch_config: None,
102        }
103    }
104
105    /// Disable public handlers owned by a loaded pack; validated when the registry builds.
106    pub fn with_disabled_verbs(&mut self, pack: &str, verbs: &[String]) -> &mut Self {
107        self.disabled_verbs.insert(pack.to_owned(), verbs.to_vec());
108        self
109    }
110
111    /// Set the operator-configured read-visibility set (ADR-007 Rev 4 Rule 3b).
112    ///
113    /// On the default (no explicit `namespace=` param) dispatch path, reads fan
114    /// out over `['local'] ∪ ns`. Writes remain pinned to `'local'`. An explicit
115    /// `namespace=` request parameter is a precise single-namespace escape and
116    /// is not widened by this set. A cloud gate may also consult the list as
117    /// policy input at its own layer.
118    pub fn with_visible_namespaces(&mut self, ns: Vec<Namespace>) -> &mut Self {
119        self.visible_namespaces = ns;
120        self
121    }
122
123    /// Set the configured actor identity label (ADR-057).
124    ///
125    /// When set, the dispatch path mints tokens carrying this actor so that
126    /// `comm.inbox` applies the `to_actor` filter for directed delivery.
127    /// When `None` (default), tokens carry `ActorRef::anonymous()` and inbox
128    /// falls back to party-line behavior.
129    pub fn with_actor_id(&mut self, actor_id: Option<String>) -> &mut Self {
130        self.actor_id = actor_id;
131        self
132    }
133
134    /// Register a pack. The bound `P: Pack + PackRuntime` ensures the pack
135    /// declares vocabulary via `Pack` consts alongside runtime dispatch.
136    ///
137    /// This is the untrusted path: reachable from any external pack crate,
138    /// so the pack registered here is never eligible for admission-degrade
139    /// under `VerbRegistry::admission_degrade_safe`, regardless of what
140    /// `pack.name()`/handler category it reports. Use `register_boxed`
141    /// (composition root) or `register_trusted` (tests) for a pack the
142    /// caller actually vouches for.
143    pub fn register<P: khive_types::Pack + PackRuntime + 'static>(&mut self, pack: P) -> &mut Self {
144        self.packs.push(Box::new(pack));
145        self.pack_trusted.push(false);
146        self
147    }
148
149    /// Register a boxed pack directly, vouched for by the composition root.
150    ///
151    /// Crate-private: only [`PackRegistry::register_packs`]/
152    /// `register_packs_with_runtimes` should call this — both resolve the
153    /// pack from an `inventory`-discovered `&'static dyn PackFactory`
154    /// (collected at link time from `inventory::submit!` call sites, not
155    /// from request-time data), so the trust grant recorded here reflects a
156    /// decision the composition root made, never something the pack itself
157    /// supplied. External callers must use the typed [`Self::register`]
158    /// which enforces the `Pack + PackRuntime` dual-impl contract at the
159    /// call site but is never trusted. Here the `Pack + PackRuntime`
160    /// contract is satisfied upstream at the [`PackFactory::create`] site.
161    pub(crate) fn register_boxed(&mut self, pack: Box<dyn PackRuntime>) -> &mut Self {
162        self.packs.push(pack);
163        self.pack_trusted.push(true);
164        self
165    }
166
167    /// Register an owned mounted namespace without native-pack trust privileges.
168    pub fn register_mounted(
169        &mut self,
170        pack: Box<dyn PackRuntime>,
171    ) -> Result<&mut Self, RuntimeError> {
172        if pack.mounted_namespace() != Some(pack.name()) || !pack.handlers().is_empty() {
173            return Err(RuntimeError::InvalidInput(
174                "invalid mounted namespace registration".into(),
175            ));
176        }
177        self.packs.push(pack);
178        self.pack_trusted.push(false);
179        Ok(self)
180    }
181
182    /// Test-only trusted registration, mirroring `register_boxed`'s trust
183    /// grant for external test binaries (e.g.
184    /// `tests/read_verb_admission_exhaustion.rs`) that cannot reach a
185    /// crate-private method directly — the same reason
186    /// [`VerbRegistry::admission_degrade_safe_probe`] is `pub` rather than
187    /// `pub(crate)`. A test using this method is asserting that the pack it
188    /// registers stands in for a pack the real composition root would load,
189    /// not an untrusted/third-party one.
190    #[cfg(any(test, feature = "test-internals"))]
191    pub fn register_trusted<P: khive_types::Pack + PackRuntime + 'static>(
192        &mut self,
193        pack: P,
194    ) -> &mut Self {
195        self.packs.push(Box::new(pack));
196        self.pack_trusted.push(true);
197        self
198    }
199
200    /// Register a by-ID resolver for a pack that owns private SQL tables.
201    ///
202    /// Packs that implement `PackByIdResolver` call this during their boot path
203    /// so that `get(id)` and `delete(id)` can reach their records.
204    pub fn register_resolver(
205        &mut self,
206        name: impl Into<String>,
207        resolver: Box<dyn PackByIdResolver>,
208    ) -> &mut Self {
209        self.resolvers.push((name.into(), resolver));
210        self
211    }
212
213    /// Set the authorization gate consulted on every dispatch.
214    ///
215    /// Defaults to `AllowAllGate` if not set. `Deny` is authoritative — a deny
216    /// decision aborts dispatch with `RuntimeError::PermissionDenied`. Gate
217    /// infrastructure errors abort dispatch with `RuntimeError::GateUnavailable`.
218    pub fn with_gate(&mut self, gate: GateRef) -> &mut Self {
219        self.gate = gate;
220        self
221    }
222
223    /// Set the namespace surfaced to the gate when a verb does not carry an
224    /// explicit `namespace` argument. Transports should plumb the runtime's
225    /// `default_namespace` so the gate's `input.namespace` always reflects
226    /// the operation's true tenant.
227    pub fn with_default_namespace(&mut self, ns: impl Into<String>) -> &mut Self {
228        self.default_namespace = ns.into();
229        self
230    }
231
232    /// Set the `EventStore` used to persist audit events.
233    ///
234    /// When configured, every gate check appends one `Event` (substrate =
235    /// `Event`, outcome = `Success` on allow, `Denied` on deny, or `Error` on
236    /// gate unavailability) in addition to the `tracing::info!` emission.
237    ///
238    /// Callers that do not set this field continue to use tracing-only emission
239    /// (the v0.2 default), except `git.digest`: its successful response carries
240    /// a durable receipt and therefore fails safely when no store is configured.
241    pub fn with_event_store(&mut self, store: Arc<dyn EventStore>) -> &mut Self {
242        self.event_store = Some(store);
243        self.runtime_event_store = None;
244        self.audit_store_read_only = false;
245        self
246    }
247
248    /// Configure the registry's trusted audit sink from a runtime.
249    ///
250    /// Registry audit constructors stamp namespace and actor directly from
251    /// each resolved [`GateRequest`], including per-request daemon identity
252    /// overrides. This deliberately uses the runtime's undecorated sink: the
253    /// public token-scoped [`KhiveRuntime::events`] decorator would otherwise
254    /// replace every per-request stamp with the single actor that happened to
255    /// construct the registry.
256    ///
257    /// The sink is resolved during [`Self::build`] using the final default
258    /// namespace, so the order of namespace and sink configuration does not
259    /// change its read scope. Sink initialization errors are returned by build:
260    /// a serving registry never silently drops a configured runtime audit sink.
261    /// Metadata builds and explicit replacement sinks do not open this sink.
262    pub fn with_runtime_event_store(
263        &mut self,
264        runtime: &KhiveRuntime,
265    ) -> Result<&mut Self, RuntimeError> {
266        self.event_store = None;
267        self.runtime_event_store = Some(runtime.clone());
268        self.audit_store_read_only = false;
269        Ok(self)
270    }
271
272    /// Override the ADR-133 audit-batch seam's tunables, applied when
273    /// `build()` lazily constructs the batch from `event_store`.
274    /// `None` (the default) uses `AuditBatchConfig::default()`. Exposed for
275    /// tests that need to force a small `max_pending_rows` or a short
276    /// `admission_deadline` to exercise admission-pressure paths
277    /// deterministically (#2117, #2147, #2208, #2217).
278    pub fn with_audit_batch_config(
279        &mut self,
280        config: crate::audit_batch::AuditBatchConfig,
281    ) -> &mut Self {
282        self.audit_batch_config = Some(config);
283        self
284    }
285
286    /// Mark audit persistence unavailable because its backend is read-only.
287    ///
288    /// No `EventStore` is retained, so dispatch never attempts a write that is
289    /// known to fail. Successful request entries expose a machine-readable
290    /// advisory without changing their canonical verb result shape.
291    pub fn with_read_only_audit_store(&mut self) -> &mut Self {
292        self.event_store = None;
293        self.runtime_event_store = None;
294        self.audit_store_read_only = true;
295        self
296    }
297
298    /// Register a post-dispatch hook.
299    ///
300    /// When set, every successful pack dispatch calls `hook.on_dispatch(view)`
301    /// with a synthetic [`EventView`] describing the verb outcome. Its
302    /// `observations` vector is empty; callers that need persisted provenance
303    /// must load it explicitly. The hook is opt-in: registries without a hook
304    /// incur zero overhead on the dispatch hot path.
305    ///
306    /// Brain pack uses this as a best-effort in-memory update path. Errors from
307    /// `on_dispatch` are logged via `tracing::warn!` and never propagated.
308    pub fn with_dispatch_hook(&mut self, hook: Arc<dyn DispatchHook>) -> &mut Self {
309        self.dispatch_hook = Some(hook);
310        self
311    }
312
313    /// Consume the builder and produce an immutable, cloneable registry.
314    ///
315    /// Performs a topological sort of packs using Kahn's algorithm.
316    /// Returns an error if any declared dependency is missing from the loaded
317    /// pack set, or if a circular dependency is detected.
318    pub fn build(self) -> Result<VerbRegistry, RuntimeError> {
319        self.build_registry(true)
320    }
321
322    /// Inspect pack metadata without activating any registered pack.
323    /// The result exposes no dispatch, preparation hooks, or serving-registry conversion.
324    pub fn build_metadata(mut self) -> Result<PackMetadataRegistry, RuntimeError> {
325        self.event_store = None;
326        self.runtime_event_store = None;
327        self.dispatch_hook = None;
328        self.resolvers.clear();
329        self.build_registry(false)
330            .map(|registry| PackMetadataRegistry { registry })
331    }
332
333    fn build_registry(self, activate: bool) -> Result<VerbRegistry, RuntimeError> {
334        let packs = self.packs;
335        let mut name_to_idx: HashMap<&str, usize> = HashMap::with_capacity(packs.len());
336        for (idx, pack) in packs.iter().enumerate() {
337            if let Some(prev_idx) = name_to_idx.insert(pack.name(), idx) {
338                return Err(RuntimeError::PackRedeclared {
339                    name: pack.name().to_string(),
340                    first_idx: prev_idx,
341                    second_idx: idx,
342                });
343            }
344        }
345
346        for mounted in packs
347            .iter()
348            .filter(|pack| pack.mounted_namespace().is_some())
349        {
350            let prefix = format!("{}.", mounted.name());
351            if packs
352                .iter()
353                .flat_map(|pack| pack.handlers())
354                .any(|handler| handler.name.starts_with(&prefix))
355            {
356                return Err(RuntimeError::InvalidInput(
357                    "mounted namespace collides with a native verb".into(),
358                ));
359            }
360        }
361
362        // Apply this metadata invariant to every HandlerDef, including Subhandlers. Subhandlers
363        // are not top-level MCP-callable, but their describe/help contract still cannot truthfully
364        // advertise a name rejected by every typed request parser before visibility dispatch.
365        for pack in &packs {
366            for handler in pack.handlers() {
367                for parameter in handler.params {
368                    if RESERVED_ENVELOPE_ARGS.contains(&parameter.name) {
369                        return Err(RuntimeError::ReservedEnvelopeParam {
370                            pack: pack.name().to_string(),
371                            verb: handler.name.to_string(),
372                            param: parameter.name.to_string(),
373                        });
374                    }
375                }
376            }
377        }
378
379        let mut missing: Vec<MissingPackDependency> = Vec::new();
380        let mut indegree = vec![0usize; packs.len()];
381        let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); packs.len()];
382
383        for (idx, pack) in packs.iter().enumerate() {
384            for &requires in pack.requires() {
385                match name_to_idx.get(requires).copied() {
386                    Some(dep_idx) => {
387                        dependents[dep_idx].push(idx);
388                        indegree[idx] += 1;
389                    }
390                    None => missing.push(MissingPackDependency {
391                        from: pack.name().to_string(),
392                        requires: requires.to_string(),
393                    }),
394                }
395            }
396        }
397
398        if !missing.is_empty() {
399            return if missing.len() == 1 {
400                Err(RuntimeError::MissingPackDependency(missing.remove(0)))
401            } else {
402                Err(RuntimeError::MissingPackDependencies(
403                    MissingPackDependencies { missing },
404                ))
405            };
406        }
407
408        let mut ready: VecDeque<usize> = indegree
409            .iter()
410            .enumerate()
411            .filter_map(|(idx, degree)| (*degree == 0).then_some(idx))
412            .collect();
413        let mut ordered_indices = Vec::with_capacity(packs.len());
414
415        while let Some(idx) = ready.pop_front() {
416            ordered_indices.push(idx);
417            for &dep_idx in &dependents[idx] {
418                indegree[dep_idx] -= 1;
419                if indegree[dep_idx] == 0 {
420                    ready.push_back(dep_idx);
421                }
422            }
423        }
424
425        if ordered_indices.len() != packs.len() {
426            let cycle_nodes: HashSet<usize> = indegree
427                .iter()
428                .enumerate()
429                .filter_map(|(idx, degree)| (*degree > 0).then_some(idx))
430                .collect();
431            let cycle = find_pack_dependency_cycle(&packs, &name_to_idx, &cycle_nodes);
432            return Err(RuntimeError::CircularPackDependency(
433                CircularPackDependency { cycle },
434            ));
435        }
436
437        let mut pack_slots: Vec<Option<Box<dyn PackRuntime>>> =
438            packs.into_iter().map(Some).collect();
439        let mut trusted_slots: Vec<Option<bool>> =
440            self.pack_trusted.into_iter().map(Some).collect();
441        let mut ordered_packs: Vec<Box<dyn PackRuntime>> = Vec::with_capacity(pack_slots.len());
442        let mut ordered_trusted: Vec<bool> = Vec::with_capacity(trusted_slots.len());
443        for idx in ordered_indices {
444            ordered_packs.push(
445                pack_slots[idx]
446                    .take()
447                    .expect("topological index must exist"),
448            );
449            ordered_trusted.push(
450                trusted_slots[idx]
451                    .take()
452                    .expect("topological index must exist"),
453            );
454        }
455
456        validate_unique_note_kinds(&ordered_packs)?;
457        validate_unique_verb_names(&ordered_packs)?;
458        validate_unique_entity_types(&ordered_packs)?;
459        validate_entity_type_note_kind_collisions(&ordered_packs)?;
460        validate_brain_consumer_kinds(&ordered_packs)?;
461        let mut disabled_verbs = HashSet::new();
462        let mut policies: Vec<_> = self.disabled_verbs.iter().collect();
463        policies.sort_by_key(|(pack, _)| *pack);
464        for (name, verbs) in policies {
465            if verbs.is_empty() {
466                continue;
467            }
468            let pack = ordered_packs
469                .iter()
470                .find(|pack| pack.name() == name.as_str())
471                .ok_or_else(|| {
472                    RuntimeError::InvalidInput(format!(
473                        "packs.{name}.verbs_disabled: pack is not loaded"
474                    ))
475                })?;
476            for verb in verbs {
477                let handler = pack
478                    .handlers()
479                    .iter()
480                    .find(|handler| {
481                        handler.name == verb.as_str()
482                            && matches!(handler.visibility, Visibility::Verb)
483                    })
484                    .ok_or_else(|| {
485                        RuntimeError::InvalidInput(format!(
486                    "packs.{name}.verbs_disabled: {verb:?} is not a public verb owned by this pack"
487                ))
488                    })?;
489                disabled_verbs.insert(handler.name);
490            }
491        }
492        let note_property_policies = collect_note_property_policies(&ordered_packs)?;
493        if activate {
494            for pack in &ordered_packs {
495                pack.validate_config()?;
496            }
497        }
498
499        let available_verbs: Vec<&'static str> = ordered_packs
500            .iter()
501            .flat_map(|p| p.handlers().iter())
502            .filter(|h| {
503                matches!(h.visibility, Visibility::Verb) && !disabled_verbs.contains(h.name)
504            })
505            .map(|h| h.name)
506            .collect();
507
508        // Keep the first declaration for duplicate subhandler names, matching
509        // the registry's existing pack-order metadata lookup behavior. Public
510        // verb names have already been checked for uniqueness above.
511        let mut handler_by_name: HashMap<&'static str, &'static HandlerDef> = HashMap::new();
512        for pack in &ordered_packs {
513            for handler in pack.handlers() {
514                handler_by_name.entry(handler.name).or_insert(handler);
515            }
516        }
517
518        // Admission-degrade eligibility (#2147/#2217, khive-oss#2311): decided
519        // once here, from the trust bit the composition root recorded at
520        // registration time (never from `pack.name()`'s self-report) plus
521        // each handler's declared category and the `(pack, verb)` allowlist —
522        // see `VerbRegistry::admission_degrade_safe`'s doc. A verb is
523        // globally unique across `Visibility::Verb` handlers at this point
524        // (`validate_unique_verb_names` above already enforced that), so a
525        // flat `HashSet<&'static str>` is an unambiguous key: no dispatch
526        // call site needs to re-resolve which pack owns a verb to answer
527        // this question, and none does (`VerbRegistry::admission_degrade_safe`
528        // is a single hash-set lookup with no per-call pack/handler scan).
529        let mut degrade_safe_verbs: HashSet<&'static str> = HashSet::new();
530        let mut read_replay_safe_verbs = HashSet::new();
531        for (pack, &trusted) in ordered_packs.iter().zip(ordered_trusted.iter()) {
532            if !trusted {
533                continue;
534            }
535            let pack_name = pack.name();
536            for handler in pack.handlers() {
537                let canonical_owner = handler
538                    .name
539                    .split_once('.')
540                    .map_or("kg", |(owner, _)| owner);
541                if matches!(handler.visibility, Visibility::Verb)
542                    && pack_name == canonical_owner
543                    && crate::classify_operation(handler.name) == Some(crate::OperationAccess::Read)
544                    && !VerbRegistry::SIDE_EFFECTING_ASSERTIVE_VERBS.contains(&handler.name)
545                {
546                    read_replay_safe_verbs.insert(handler.name);
547                }
548                if !matches!(handler.visibility, Visibility::Verb)
549                    || handler.category != VerbCategory::Assertive
550                {
551                    continue;
552                }
553                let eligible = VerbRegistry::admission_degrade_safe_sorted()
554                    .binary_search_by(|&(p, v)| p.cmp(pack_name).then_with(|| v.cmp(handler.name)))
555                    .is_ok();
556                if eligible {
557                    degrade_safe_verbs.insert(handler.name);
558                }
559            }
560        }
561
562        // ADR-133: incidental audit writes route through one batch seam per
563        // configured `EventStore` instead of taking a writer-task
564        // acquisition per dispatch. No store configured (tracing-only or
565        // read-only-audit registries) means no seam to construct.
566        //
567        // A configured store that does not implement the seam's
568        // `preflight_event`/`append_events_idempotent` pair would otherwise
569        // build silently: every submitted row is rejected at preflight, the
570        // dispatch that produced it still reports success, and nothing here
571        // distinguishes that from a healthy registry. Reject it now, with an
572        // actionable message, instead of at the first audited dispatch.
573        let event_store = match self.runtime_event_store {
574            Some(runtime) => Some(runtime.raw_events_for_namespace(&self.default_namespace)?),
575            None => self.event_store,
576        };
577        if let Some(store) = &event_store {
578            if !store.supports_idempotent_audit_batch() {
579                return Err(RuntimeError::IncompatibleEventStore(
580                    "the configured EventStore does not implement ADR-133's \
581                     preflight_event/append_events_idempotent pair \
582                     (supports_idempotent_audit_batch() returned false); every \
583                     audited dispatch would silently lose its audit row while \
584                     still reporting success. Implement both methods and \
585                     override supports_idempotent_audit_batch() to opt in, or \
586                     do not call with_event_store() for this backend."
587                        .to_string(),
588                ));
589            }
590        }
591        let audit_batch = event_store.clone().map(|store| {
592            crate::audit_batch::AuditBatch::new(
593                store,
594                self.audit_batch_config.clone().unwrap_or_default(),
595            )
596        });
597
598        // Snapshot ownership before flattening rules for runtime installation.
599        // Keep declaration order and duplicate rules, including identical rules
600        // contributed by different packs.
601        let attributed_edge_rules = ordered_packs
602            .iter()
603            .flat_map(|pack| {
604                pack.edge_rules()
605                    .iter()
606                    .map(move |rule| (pack.name().to_owned(), *rule))
607            })
608            .collect();
609
610        Ok(VerbRegistry {
611            packs: Arc::new(ordered_packs),
612            attributed_edge_rules: Arc::new(attributed_edge_rules),
613            pack_versions: Arc::new(self.pack_versions),
614            note_property_policies: Arc::new(note_property_policies),
615            resolvers: Arc::new(self.resolvers),
616            kg_read_resolver: self.kg_read_resolver,
617            gate: self.gate,
618            default_namespace: self.default_namespace,
619            visible_namespaces: self.visible_namespaces,
620            actor_id: self.actor_id,
621            event_store,
622            audit_store_read_only: self.audit_store_read_only,
623            dispatch_hook: self.dispatch_hook,
624            available_verbs: Arc::new(available_verbs),
625            disabled_verbs: Arc::new(disabled_verbs),
626            handler_by_name: Arc::new(handler_by_name),
627            degrade_safe_verbs: Arc::new(degrade_safe_verbs),
628            read_replay_safe_verbs: Arc::new(read_replay_safe_verbs),
629            reference_ring: Arc::new(crate::reference_ring::ReferenceRing::new()),
630            audit_batch,
631        })
632    }
633}
634
635/// Validate and capture property metadata in topological pack/declaration order.
636fn collect_note_property_policies(
637    packs: &[Box<dyn PackRuntime>],
638) -> Result<Vec<NotePropertyPolicySpec>, RuntimeError> {
639    let mut policies = Vec::new();
640    let mut seen = HashSet::new();
641    for pack in packs {
642        let owned_kinds = pack.note_kinds();
643        for spec in pack.note_property_policies() {
644            if !owned_kinds.contains(&spec.kind) {
645                return Err(RuntimeError::InvalidInput(format!(
646                    "pack {:?} declares property policy for unowned note kind {:?}, key {:?}",
647                    pack.name(),
648                    spec.kind,
649                    spec.key
650                )));
651            }
652            if !seen.insert((spec.kind, spec.key)) {
653                return Err(RuntimeError::InvalidInput(format!(
654                    "pack {:?} declares duplicate property policy for note kind {:?}, key {:?}",
655                    pack.name(),
656                    spec.kind,
657                    spec.key
658                )));
659            }
660            policies.push(*spec);
661        }
662    }
663    Ok(policies)
664}
665
666/// Validate that no two packs declare the same note kind.
667///
668/// Boot-time duplicate detection prevents pack configuration errors from
669/// silently corrupting note kind routing. Returns an error naming the
670/// duplicate kind and the two packs that claim it.
671fn validate_unique_note_kinds(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
672    let mut seen: HashMap<&str, &str> = HashMap::new();
673    for pack in packs {
674        for &kind in pack.note_kinds() {
675            if let Some(first_pack) = seen.insert(kind, pack.name()) {
676                return Err(RuntimeError::InvalidInput(format!(
677                    "duplicate note kind {kind:?}: claimed by both {first_pack:?} and {:?}",
678                    pack.name()
679                )));
680            }
681        }
682    }
683    Ok(())
684}
685
686/// Validate pack-declared brain consumer kinds at the composition boundary.
687///
688/// The wildcard belongs to the binding matcher rather than any consumer, and
689/// whitespace-bearing values can never equal the exact wire values callers
690/// request. Reject both at boot so a malformed declaration cannot make an
691/// otherwise unreachable binding appear valid.
692fn validate_brain_consumer_kinds(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
693    for pack in packs {
694        for &kind in pack.brain_consumer_kinds() {
695            if kind == "*" || kind.trim().is_empty() || kind.trim() != kind {
696                return Err(RuntimeError::InvalidInput(format!(
697                    "pack {:?} declares invalid brain consumer kind {kind:?}; declarations must be non-empty exact wire values and must not use the registry-owned \"*\" wildcard",
698                    pack.name()
699                )));
700            }
701        }
702    }
703    Ok(())
704}
705
706/// Validate that no two packs declare the same `Visibility::Verb` handler name.
707///
708/// `Visibility::Subhandler` entries are pack-prefixed by convention and excluded
709/// from cross-pack collision detection. Two packs declaring the same subhandler
710/// name prefix (e.g. `recall.embed`) would be a pack-authoring error but does not
711/// produce a cross-pack routing conflict since only the owning pack dispatches them.
712fn validate_unique_verb_names(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
713    let mut seen: HashMap<&str, &str> = HashMap::new();
714    for pack in packs {
715        for handler in pack.handlers() {
716            if !matches!(handler.visibility, Visibility::Verb) {
717                continue;
718            }
719            if let Some(first_pack) = seen.insert(handler.name, pack.name()) {
720                return Err(RuntimeError::VerbCollision {
721                    verb: handler.name.to_string(),
722                    first_pack: first_pack.to_string(),
723                    second_pack: pack.name().to_string(),
724                });
725            }
726        }
727    }
728    Ok(())
729}
730
731/// Validate that no two owners (the built-in table or a loaded pack) declare
732/// a colliding `entity_type` canonical name or alias.
733///
734/// Boot-time duplicate detection prevents pack configuration errors from
735/// silently applying insertion-order semantics to entity-type resolution
736/// (ADR-001's registry-ownership collision rule: same `(base_kind,
737/// canonical_name)` from two different packs, or an alias collision, is a
738/// boot error). Returns an error naming the colliding key and both
739/// contributing owners.
740fn validate_unique_entity_types(packs: &[Box<dyn PackRuntime>]) -> Result<(), RuntimeError> {
741    let owned_defs = packs
742        .iter()
743        .flat_map(|p| p.entity_types().iter().map(move |def| (p.name(), def)));
744    khive_types::EntityTypeRegistry::check_extra_collisions(owned_defs)
745        .map_err(RuntimeError::InvalidInput)
746}
747
748/// A granular kind token must identify only one substrate after pack composition.
749/// Check aliases too: both subtype and note-kind spellings are normalized at
750/// the request boundary, so a cosmetic spelling difference is still a clash.
751fn validate_entity_type_note_kind_collisions(
752    packs: &[Box<dyn PackRuntime>],
753) -> Result<(), RuntimeError> {
754    let mut note_kinds = HashMap::new();
755    for pack in packs {
756        for &kind in pack.note_kinds() {
757            note_kinds
758                .entry(khive_types::to_snake_case(kind))
759                .or_insert(pack.name());
760        }
761    }
762
763    let check_definition = |definition: &EntityTypeDef, owner: &str| {
764        for name in std::iter::once(definition.type_name).chain(definition.aliases.iter().copied())
765        {
766            let normalized = khive_types::to_snake_case(name);
767            if let Some(note_owner) = note_kinds.get(&normalized) {
768                return Err(RuntimeError::InvalidInput(format!(
769                    "entity subtype {name:?} from {owner:?} collides with note kind {normalized:?} from pack {note_owner:?}"
770                )));
771            }
772        }
773        Ok(())
774    };
775
776    let builtin = khive_types::EntityTypeRegistry::builtin();
777    for definition in builtin.definitions() {
778        check_definition(definition, "builtin")?;
779    }
780    for pack in packs {
781        for definition in pack.entity_types() {
782            check_definition(definition, pack.name())?;
783        }
784    }
785    Ok(())
786}
787
788fn find_pack_dependency_cycle(
789    packs: &[Box<dyn PackRuntime>],
790    name_to_idx: &HashMap<&str, usize>,
791    cycle_nodes: &HashSet<usize>,
792) -> Vec<String> {
793    fn visit(
794        idx: usize,
795        packs: &[Box<dyn PackRuntime>],
796        name_to_idx: &HashMap<&str, usize>,
797        cycle_nodes: &HashSet<usize>,
798        visiting: &mut Vec<usize>,
799        visited: &mut HashSet<usize>,
800    ) -> Option<Vec<String>> {
801        if let Some(pos) = visiting.iter().position(|&seen| seen == idx) {
802            let mut cycle: Vec<String> = visiting[pos..]
803                .iter()
804                .map(|&i| packs[i].name().to_string())
805                .collect();
806            cycle.push(packs[idx].name().to_string());
807            return Some(cycle);
808        }
809        if !visited.insert(idx) {
810            return None;
811        }
812        visiting.push(idx);
813        for &req in packs[idx].requires() {
814            let Some(&dep_idx) = name_to_idx.get(req) else {
815                continue;
816            };
817            if cycle_nodes.contains(&dep_idx) {
818                if let Some(cycle) =
819                    visit(dep_idx, packs, name_to_idx, cycle_nodes, visiting, visited)
820                {
821                    return Some(cycle);
822                }
823            }
824        }
825        visiting.pop();
826        None
827    }
828
829    let mut visited = HashSet::new();
830    for &idx in cycle_nodes {
831        let mut visiting = Vec::new();
832        if let Some(cycle) = visit(
833            idx,
834            packs,
835            name_to_idx,
836            cycle_nodes,
837            &mut visiting,
838            &mut visited,
839        ) {
840            return cycle;
841        }
842    }
843    cycle_nodes
844        .iter()
845        .map(|&idx| packs[idx].name().to_string())
846        .collect()
847}
848
849impl Default for VerbRegistryBuilder {
850    fn default() -> Self {
851        Self::new()
852    }
853}
854
855/// Pack metadata with no executable registry capability.
856///
857/// ```compile_fail
858/// fn dispatch(metadata: &khive_runtime::PackMetadataRegistry) {
859///     metadata.dispatch("telemetry.emit", serde_json::json!({}));
860/// }
861/// ```
862pub struct PackMetadataRegistry {
863    pub(super) registry: VerbRegistry,
864}
865
866impl PackMetadataRegistry {
867    pub fn has_verb(&self, verb: &str) -> bool {
868        self.registry.has_verb(verb)
869    }
870
871    pub fn describe_verb(&self, verb: &str) -> Result<Value, RuntimeError> {
872        self.registry.describe_verb(verb)
873    }
874
875    pub fn all_handlers_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
876        self.registry.all_handlers_with_names()
877    }
878
879    pub fn all_verbs(&self) -> Vec<&'static HandlerDef> {
880        self.registry.all_verbs()
881    }
882
883    pub fn pack_names(&self) -> Vec<&str> {
884        self.registry.pack_names()
885    }
886
887    /// Factory-reported version, if this pack was registered through a factory.
888    pub fn pack_version(&self, name: &str) -> Option<&'static str> {
889        self.registry.pack_version(name)
890    }
891
892    pub fn pack_requires(&self, name: &str) -> Option<&'static [&'static str]> {
893        self.registry.pack_requires(name)
894    }
895
896    pub fn pack_note_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
897        self.registry.pack_note_kinds(name)
898    }
899
900    pub fn pack_entity_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
901        self.registry.pack_entity_kinds(name)
902    }
903
904    pub fn pack_verbs(&self, name: &str) -> Option<&'static [HandlerDef]> {
905        self.registry.pack_verbs(name)
906    }
907
908    pub fn all_entity_kinds(&self) -> Vec<&'static str> {
909        self.registry.all_entity_kinds()
910    }
911
912    pub fn all_note_kinds(&self) -> Vec<&'static str> {
913        self.registry.all_note_kinds()
914    }
915
916    pub fn all_edge_rules(&self) -> Vec<EdgeEndpointRule> {
917        self.registry.all_edge_rules()
918    }
919
920    /// Pack ownership for every declared endpoint rule, in installation order.
921    pub fn all_edge_rules_with_packs(&self) -> Vec<(&str, EdgeEndpointRule)> {
922        self.registry.all_edge_rules_with_packs()
923    }
924}