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(¶meter.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}