khive_runtime/pack/loading.rs
1use super::{
2 Arc, DispatchHook, HashMap, KhiveRuntime, PackByIdResolver, PackRuntime, RuntimeError,
3 VerbRegistry, VerbRegistryBuilder, Visibility, CHANNEL_INGEST_CAPABLE_PACKS,
4};
5
6// ── Inventory-based dynamic pack loading ────────────────────────────────────
7
8/// Output of [`PackFactory::create_install`] — bundles the pack runtime with
9/// its optional by-ID resolver and dispatch hook so a factory can hand back
10/// all three built from one shared instance (see `BrainPackFactory` for why
11/// this matters: the dispatch hook must observe the same state the runtime
12/// mutates, not a second unrelated instance).
13pub struct PackInstall {
14 /// The pack runtime, registered into the builder's pack list.
15 pub runtime: Box<dyn PackRuntime>,
16 /// Optional by-ID resolver, registered when present.
17 pub resolver: Option<Box<dyn PackByIdResolver>>,
18 /// Optional post-dispatch observer, wired via `VerbRegistryBuilder::with_dispatch_hook`.
19 pub dispatch_hook: Option<Arc<dyn DispatchHook>>,
20}
21
22/// Factory for creating pack instances registered via `inventory` at link time.
23/// Each pack crate submits a `&'static dyn PackFactory` wrapped in a
24/// [`PackRegistration`]; the binary's linker collects them all into a single
25/// slice iterable at runtime.
26///
27/// Implementors must be `Send + Sync + 'static` because the registry is built
28/// once and shared across async tasks.
29/// Possession-bounded capability for the trusted channel-ingest note path.
30///
31/// Constructible only inside `khive-runtime` (the field is private), and
32/// granted during pack registration exclusively to factories named in
33/// `CHANNEL_INGEST_CAPABLE_PACKS`. Every call to
34/// [`crate::KhiveRuntime::try_create_note_as_trusted_ingest`] must present a
35/// reference to one, so the set of callers able to establish transport-owned
36/// message properties is bounded by possession at the composition root, not
37/// by a documentation prohibition. Two ways to obtain one: registering
38/// through [`PackRegistry::register_packs`]/`register_packs_with_runtimes`
39/// under the `comm` name (the allowlisted, automatic path), or a composition
40/// root that builds packs directly calling
41/// [`ChannelIngestCapability::grant_for_direct_composition`] and passing the
42/// result to [`crate::PackRuntime::accept_channel_ingest_capability`] (or a
43/// pack's constructor variant that does so) itself. The residual trust
44/// assumption is unchanged either way: whoever assembles the
45/// `VerbRegistryBuilder` already decides which packs are wired in and already
46/// holds a `KhiveRuntime`, so minting the grant explicitly carries no more
47/// privilege than that composition already had by choosing to register `comm`
48/// at all.
49pub struct ChannelIngestCapability {
50 pub(crate) _sealed: (),
51}
52
53impl ChannelIngestCapability {
54 /// Mint a capability for a composition root that constructs
55 /// channel-transport packs directly, bypassing
56 /// [`PackRegistry::register_packs`] (which grants this automatically).
57 ///
58 /// See the type-level doc for the trust argument: this carries no more
59 /// privilege than the caller already has by virtue of holding a
60 /// `KhiveRuntime` and choosing to wire the pack in.
61 pub fn grant_for_direct_composition() -> Self {
62 Self { _sealed: () }
63 }
64}
65
66pub trait PackFactory: Send + Sync + 'static {
67 /// Canonical lowercase name for this pack (e.g. `"kg"`, `"gtd"`).
68 fn name(&self) -> &'static str;
69
70 /// Version reported by this factory for introspection.
71 ///
72 /// The compatibility default is the version of `khive-runtime`, where
73 /// this trait is defined. Independently versioned pack crates should
74 /// override this method with their own `env!("CARGO_PKG_VERSION")`.
75 fn version(&self) -> &'static str {
76 env!("CARGO_PKG_VERSION")
77 }
78
79 /// Names of packs that must be loaded before this one.
80 ///
81 /// Defaults to empty so pack crates that have no dependencies compile
82 /// without changes. [`PackRegistry::register_packs`] validates that every
83 /// name listed here is present in the caller's explicit pack list — absent
84 /// dependencies are a boot error, not silently auto-added.
85 fn requires(&self) -> &'static [&'static str] {
86 &[]
87 }
88
89 /// Whether this pack intentionally exposes no top-level MCP verbs.
90 ///
91 /// Defaults to `false` so a declared pack whose runtime contributes no
92 /// [`Visibility::Verb`] handlers fails at registration instead of silently
93 /// disappearing from the served surface. Vocabulary- or ontology-only
94 /// packs must opt in explicitly.
95 fn intentionally_verbless(&self) -> bool {
96 false
97 }
98
99 /// Create a new pack instance for the given runtime.
100 fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime>;
101
102 /// Build the full installation bundle for this pack: runtime, optional
103 /// resolver, optional dispatch hook.
104 ///
105 /// Defaults to composing `create` and `create_resolver` with no dispatch
106 /// hook, so existing factories compile unchanged. Packs whose dispatch
107 /// hook must observe the same instance as the runtime (e.g. `brain`)
108 /// override this method instead of `create`, since the default would
109 /// otherwise require two independent instances to share state.
110 fn create_install(&self, runtime: KhiveRuntime) -> PackInstall {
111 let resolver = self.create_resolver(runtime.clone());
112 PackInstall {
113 runtime: self.create(runtime),
114 resolver,
115 dispatch_hook: None,
116 }
117 }
118
119 /// Optionally create a `PackByIdResolver` for this pack.
120 ///
121 /// Packs that own private SQL tables implement this to hook into
122 /// `get(id)` and `delete(id)`. Defaults to `None` so existing packs
123 /// compile without changes.
124 fn create_resolver(&self, _runtime: KhiveRuntime) -> Option<Box<dyn PackByIdResolver>> {
125 None
126 }
127}
128
129/// Newtype wrapper collected by `inventory` so pack crates can submit
130/// `&'static dyn PackFactory` references without the type-ascription syntax
131/// that `inventory::submit!` does not support for bare trait-object references.
132pub struct PackRegistration(pub &'static dyn PackFactory);
133
134inventory::collect!(PackRegistration);
135
136/// Error returned by [`PackRegistry::register_packs`] when boot validation fails.
137#[derive(Debug)]
138pub enum PackLoadError {
139 /// The requested pack name was not found in the inventory.
140 UnknownPack(String),
141 /// The requested pack name occurs more than once.
142 DuplicatePack(String),
143 /// A pack was requested but a declared dependency is absent from the list.
144 MissingDependency {
145 /// The pack that declared the dependency.
146 pack: String,
147 /// The dependency that is missing from the requested pack list.
148 dep: String,
149 },
150 /// A declared pack contributed no top-level verbs without explicitly
151 /// declaring itself vocabulary/ontology-only.
152 NoPublicVerbs {
153 /// The declared pack name.
154 pack: String,
155 },
156}
157
158impl std::fmt::Display for PackLoadError {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 match self {
161 PackLoadError::UnknownPack(name) => write!(f, "unknown pack {name:?}"),
162 PackLoadError::DuplicatePack(name) => write!(f, "duplicate pack {name:?}"),
163 PackLoadError::MissingDependency { pack, dep } => write!(
164 f,
165 "pack {pack:?} requires {dep:?}, which is not in the requested pack list; \
166 add --pack {dep} before --pack {pack}"
167 ),
168 PackLoadError::NoPublicVerbs { pack } => write!(
169 f,
170 "declared pack {pack:?} registers no public verbs; if this pack is \
171 intentionally vocabulary- or ontology-only, its factory must declare \
172 intentionally_verbless() = true"
173 ),
174 }
175 }
176}
177
178impl std::error::Error for PackLoadError {}
179
180/// Reject a declared pack whose runtime contributes no [`Visibility::Verb`]
181/// handlers unless its factory explicitly opts out via
182/// [`PackFactory::intentionally_verbless`].
183fn check_pack_has_public_verbs(
184 factory: &dyn PackFactory,
185 install: &PackInstall,
186 name: &str,
187) -> Result<(), PackLoadError> {
188 if !factory.intentionally_verbless()
189 && !install
190 .runtime
191 .handlers()
192 .iter()
193 .any(|handler| matches!(handler.visibility, Visibility::Verb))
194 {
195 return Err(PackLoadError::NoPublicVerbs {
196 pack: name.to_string(),
197 });
198 }
199 Ok(())
200}
201
202/// Registry of pack factories discovered via `inventory` at link time.
203///
204/// No instance is needed — all methods are associated functions that walk the
205/// globally-collected [`PackRegistration`] slice.
206pub struct PackRegistry;
207
208/// Whether [`PackRegistry::build_ingest_registry`] attaches the runtime's
209/// event store to the registry it builds.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum IngestAuditStore {
212 /// Mirror `KhiveMcpServer::with_packs` (`khive-mcp/src/server.rs`): a
213 /// writable runtime attaches its own event store and refuses to build if
214 /// sink initialization fails; a read-only runtime retains no `EventStore`
215 /// handle and an advisory travels beside each result instead.
216 Attach,
217 /// Build the registry with no audit event store, for a caller with no use
218 /// for persisted audit rows.
219 Detach,
220}
221
222impl PackRegistry {
223 /// Names of all pack factories discovered via `inventory`.
224 pub fn discovered_names() -> Vec<&'static str> {
225 inventory::iter::<PackRegistration>
226 .into_iter()
227 .map(|r| r.0.name())
228 .collect()
229 }
230
231 /// Validate linked pack names and explicit dependencies without creating
232 /// runtimes, opening stores, or constructing pack instances.
233 ///
234 /// Launchers can use this before publishing ownership. Registration uses
235 /// the same validation, including when extra factories are supplied.
236 pub fn validate_pack_selection(names: &[String]) -> Result<(), PackLoadError> {
237 let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
238 .into_iter()
239 .map(|r| r.0)
240 .collect();
241 Self::validate_pack_selection_from(&all, names)
242 }
243
244 fn validate_pack_selection_from(
245 factories: &[&'static dyn PackFactory],
246 names: &[String],
247 ) -> Result<(), PackLoadError> {
248 let factory_for = |name: &str| factories.iter().copied().find(|f| f.name() == name);
249 let mut requested = std::collections::HashSet::new();
250 for name in names {
251 factory_for(name).ok_or_else(|| PackLoadError::UnknownPack(name.clone()))?;
252 if !requested.insert(name.as_str()) {
253 return Err(PackLoadError::DuplicatePack(name.clone()));
254 }
255 }
256 for name in names {
257 let factory = factory_for(name).unwrap(); // All names were validated above.
258 for &dep in factory.requires() {
259 if !requested.contains(dep) {
260 return Err(PackLoadError::MissingDependency {
261 pack: name.clone(),
262 dep: dep.to_string(),
263 });
264 }
265 }
266 }
267 Ok(())
268 }
269
270 /// Register the named packs into `builder` using the supplied `runtime`.
271 ///
272 /// Validates the explicit pack list against `PackFactory::requires()` —
273 /// if any requested pack declares a dependency that is absent from `names`,
274 /// registration fails (missing dependency is a boot error, not silently
275 /// auto-added). Callers must include all required packs explicitly.
276 ///
277 /// The [`VerbRegistryBuilder::build`] topo-sort enforces correct load order.
278 ///
279 /// Returns `Ok(())` when all names are recognised and all declared
280 /// dependencies are satisfied; returns `Err(PackLoadError)` with a
281 /// distinct variants for unknown or duplicate packs and missing dependencies.
282 pub fn register_packs(
283 names: &[String],
284 runtime: KhiveRuntime,
285 builder: &mut VerbRegistryBuilder,
286 ) -> Result<(), PackLoadError> {
287 // Build a name→factory index once.
288 let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
289 .into_iter()
290 .map(|r| r.0)
291 .collect();
292 let factory_for = |name: &str| -> Option<&'static dyn PackFactory> {
293 all.iter().copied().find(|f| f.name() == name)
294 };
295
296 Self::validate_pack_selection_from(&all, names)?;
297
298 // Register every requested pack; VerbRegistryBuilder::build()
299 // performs the topo-sort, so insertion order here does not matter.
300 for name in names {
301 let factory = factory_for(name.as_str()).unwrap(); // validated above
302 let install = factory.create_install(runtime.clone());
303 check_pack_has_public_verbs(factory, &install, name)?;
304 if CHANNEL_INGEST_CAPABLE_PACKS.contains(&name.as_str()) {
305 install
306 .runtime
307 .accept_channel_ingest_capability(ChannelIngestCapability { _sealed: () });
308 }
309 builder
310 .pack_versions
311 .insert(install.runtime.name().to_owned(), factory.version());
312 builder.register_boxed(install.runtime);
313 if let Some(resolver) = install.resolver {
314 builder.register_resolver(name.clone(), resolver);
315 }
316 if let Some(hook) = install.dispatch_hook {
317 builder.with_dispatch_hook(hook);
318 }
319 }
320
321 Ok(())
322 }
323
324 /// Build a `VerbRegistry` from `runtime`'s own configuration: gate,
325 /// default namespace, visible namespaces, actor id, and the configured
326 /// pack set, then install the registry's aggregated edge rules back onto
327 /// `runtime`. This is the wiring shared by every one-shot CLI ingest path
328 /// (`kkernel code-ingest`, `kkernel git-ingest`) that needs a real
329 /// registry to dispatch through outside of a live MCP server.
330 ///
331 /// `audit_store` selects whether the registry gets the runtime's event
332 /// store; see [`IngestAuditStore`] for what each variant does.
333 ///
334 /// This helper carries only the subset every ingest path duplicated
335 /// verbatim. The MCP server's own registry construction additionally
336 /// wires channel-loop admission, `config_id`, entity-type and
337 /// note-mutation-hook registration, schema-plan application, and the WAL
338 /// checkpoint pool handle — all server-only concerns a one-shot CLI pass
339 /// has no use for, so `KhiveMcpServer::with_packs` keeps its own
340 /// construction rather than calling this helper. Both hosts register pack
341 /// embedding providers and finalize configured engine binding before return.
342 pub fn build_ingest_registry(
343 runtime: &KhiveRuntime,
344 audit_store: IngestAuditStore,
345 ) -> Result<VerbRegistry, RuntimeError> {
346 let mut builder = VerbRegistryBuilder::new();
347 builder.with_gate(runtime.config().gate.clone());
348 builder.with_default_namespace(runtime.config().default_namespace.as_str());
349 builder.with_visible_namespaces(runtime.config().visible_namespaces.clone());
350 builder.with_actor_id(runtime.config().actor_id.clone());
351 if audit_store == IngestAuditStore::Attach {
352 if runtime.is_read_only() {
353 builder.with_read_only_audit_store();
354 } else {
355 // Attach requires a usable sink; build propagates open failures.
356 builder.with_runtime_event_store(runtime)?;
357 }
358 }
359 Self::register_packs(
360 &runtime.config().packs.clone(),
361 runtime.clone(),
362 &mut builder,
363 )
364 .map_err(|e| RuntimeError::Internal(format!("pack registration failed: {e:?}")))?;
365 let registry = builder.build()?;
366 runtime.install_edge_rules(registry.all_edge_rules());
367 registry.initialize_embedding_engines(&[runtime])?;
368 Ok(registry)
369 }
370
371 /// Register the named packs into `builder`, routing each pack to its own runtime.
372 ///
373 /// `runtimes` maps pack name → `KhiveRuntime` (one per backend assignment).
374 /// `default_runtime` is used for any pack whose name is not in `runtimes`.
375 /// The validation logic (unknown pack, missing dependency) is identical to
376 /// [`PackRegistry::register_packs`].
377 ///
378 /// This is the multi-backend boot path (ADR-028). Single-backend callers
379 /// should continue using [`PackRegistry::register_packs`].
380 pub fn register_packs_with_runtimes(
381 names: &[String],
382 runtimes: &HashMap<String, KhiveRuntime>,
383 default_runtime: &KhiveRuntime,
384 builder: &mut VerbRegistryBuilder,
385 ) -> Result<(), PackLoadError> {
386 let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
387 .into_iter()
388 .map(|r| r.0)
389 .collect();
390 Self::register_packs_with_runtimes_from(&all, names, runtimes, default_runtime, builder)
391 }
392
393 /// Like [`Self::register_packs_with_runtimes`], but resolves pack names
394 /// against the link-time `inventory` registry **plus** `extra_factories` —
395 /// pack factories the composition root supplies directly rather than
396 /// discovers through `inventory::iter::<PackRegistration>` (ADR-191 D6,
397 /// ADR-192 S4: "a pack compiled outside this repository ... extends the
398 /// web ontology without any change here" — a host binary that depends on
399 /// a pinned khive revision plus an out-of-tree pack crate, or a
400 /// composition root registering a credential-provider/request-hook
401 /// consumer pack, has no `inventory` presence in *this* binary short of
402 /// its own force-link anchor). An inventory-discovered factory always
403 /// wins a name collision with an `extra_factories` entry — the linked set
404 /// is the trusted default; an extra factory only fills a name inventory
405 /// does not already answer.
406 ///
407 /// This is the seam D6 describes as "kkernel exposes its server
408 /// construction as a library entry point that accepts additional pack
409 /// factories" — the `kkernel` library entry point itself lives in
410 /// `kkernel::compose`, built on this function exactly as
411 /// `khive-mcp/src/serve.rs` builds on [`Self::register_packs_with_runtimes`].
412 pub fn register_packs_with_runtimes_with_extra_factories(
413 extra_factories: &[&'static dyn PackFactory],
414 names: &[String],
415 runtimes: &HashMap<String, KhiveRuntime>,
416 default_runtime: &KhiveRuntime,
417 builder: &mut VerbRegistryBuilder,
418 ) -> Result<(), PackLoadError> {
419 let mut all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
420 .into_iter()
421 .map(|r| r.0)
422 .collect();
423 all.extend(extra_factories.iter().copied());
424 Self::register_packs_with_runtimes_from(&all, names, runtimes, default_runtime, builder)
425 }
426
427 /// Shared body for [`Self::register_packs_with_runtimes`] and
428 /// [`Self::register_packs_with_runtimes_with_extra_factories`]: both
429 /// build a `factories` index (inventory-only, or inventory-plus-extra)
430 /// and delegate here. `factory_for` resolves by first match, so a
431 /// duplicate name earlier in `factories` wins over a later one — the two
432 /// public callers above rely on that for their stated collision rule.
433 fn register_packs_with_runtimes_from(
434 factories: &[&'static dyn PackFactory],
435 names: &[String],
436 runtimes: &HashMap<String, KhiveRuntime>,
437 default_runtime: &KhiveRuntime,
438 builder: &mut VerbRegistryBuilder,
439 ) -> Result<(), PackLoadError> {
440 let factory_for = |name: &str| -> Option<&'static dyn PackFactory> {
441 factories.iter().copied().find(|f| f.name() == name)
442 };
443
444 Self::validate_pack_selection_from(factories, names)?;
445
446 builder.kg_read_resolver = Some(Arc::new(crate::kg_read::KgReadResolver::new(
447 default_runtime,
448 runtimes,
449 )));
450
451 for name in names {
452 let factory = factory_for(name.as_str()).unwrap();
453 let runtime = runtimes
454 .get(name.as_str())
455 .cloned()
456 .unwrap_or_else(|| default_runtime.clone());
457 let install = factory.create_install(runtime);
458 check_pack_has_public_verbs(factory, &install, name)?;
459 if CHANNEL_INGEST_CAPABLE_PACKS.contains(&name.as_str()) {
460 install
461 .runtime
462 .accept_channel_ingest_capability(ChannelIngestCapability { _sealed: () });
463 }
464 builder
465 .pack_versions
466 .insert(install.runtime.name().to_owned(), factory.version());
467 builder.register_boxed(install.runtime);
468 if let Some(resolver) = install.resolver {
469 builder.register_resolver(name.clone(), resolver);
470 }
471 if let Some(hook) = install.dispatch_hook {
472 builder.with_dispatch_hook(hook);
473 }
474 }
475
476 Ok(())
477 }
478}