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