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`, embedder/entity-type/
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.
341 pub fn build_ingest_registry(
342 runtime: &KhiveRuntime,
343 audit_store: IngestAuditStore,
344 ) -> Result<VerbRegistry, RuntimeError> {
345 let mut builder = VerbRegistryBuilder::new();
346 builder.with_gate(runtime.config().gate.clone());
347 builder.with_default_namespace(runtime.config().default_namespace.as_str());
348 builder.with_visible_namespaces(runtime.config().visible_namespaces.clone());
349 builder.with_actor_id(runtime.config().actor_id.clone());
350 if audit_store == IngestAuditStore::Attach {
351 if runtime.is_read_only() {
352 builder.with_read_only_audit_store();
353 } else {
354 // Attach requires a usable sink; build propagates open failures.
355 builder.with_runtime_event_store(runtime)?;
356 }
357 }
358 Self::register_packs(
359 &runtime.config().packs.clone(),
360 runtime.clone(),
361 &mut builder,
362 )
363 .map_err(|e| RuntimeError::Internal(format!("pack registration failed: {e:?}")))?;
364 let registry = builder.build()?;
365 runtime.install_edge_rules(registry.all_edge_rules());
366 Ok(registry)
367 }
368
369 /// Register the named packs into `builder`, routing each pack to its own runtime.
370 ///
371 /// `runtimes` maps pack name → `KhiveRuntime` (one per backend assignment).
372 /// `default_runtime` is used for any pack whose name is not in `runtimes`.
373 /// The validation logic (unknown pack, missing dependency) is identical to
374 /// [`PackRegistry::register_packs`].
375 ///
376 /// This is the multi-backend boot path (ADR-028). Single-backend callers
377 /// should continue using [`PackRegistry::register_packs`].
378 pub fn register_packs_with_runtimes(
379 names: &[String],
380 runtimes: &HashMap<String, KhiveRuntime>,
381 default_runtime: &KhiveRuntime,
382 builder: &mut VerbRegistryBuilder,
383 ) -> Result<(), PackLoadError> {
384 let all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
385 .into_iter()
386 .map(|r| r.0)
387 .collect();
388 Self::register_packs_with_runtimes_from(&all, names, runtimes, default_runtime, builder)
389 }
390
391 /// Like [`Self::register_packs_with_runtimes`], but resolves pack names
392 /// against the link-time `inventory` registry **plus** `extra_factories` —
393 /// pack factories the composition root supplies directly rather than
394 /// discovers through `inventory::iter::<PackRegistration>` (ADR-191 D6,
395 /// ADR-192 S4: "a pack compiled outside this repository ... extends the
396 /// web ontology without any change here" — a host binary that depends on
397 /// a pinned khive revision plus an out-of-tree pack crate, or a
398 /// composition root registering a credential-provider/request-hook
399 /// consumer pack, has no `inventory` presence in *this* binary short of
400 /// its own force-link anchor). An inventory-discovered factory always
401 /// wins a name collision with an `extra_factories` entry — the linked set
402 /// is the trusted default; an extra factory only fills a name inventory
403 /// does not already answer.
404 ///
405 /// This is the seam D6 describes as "kkernel exposes its server
406 /// construction as a library entry point that accepts additional pack
407 /// factories" — the `kkernel` library entry point itself lives in
408 /// `kkernel::compose`, built on this function exactly as
409 /// `khive-mcp/src/serve.rs` builds on [`Self::register_packs_with_runtimes`].
410 pub fn register_packs_with_runtimes_with_extra_factories(
411 extra_factories: &[&'static dyn PackFactory],
412 names: &[String],
413 runtimes: &HashMap<String, KhiveRuntime>,
414 default_runtime: &KhiveRuntime,
415 builder: &mut VerbRegistryBuilder,
416 ) -> Result<(), PackLoadError> {
417 let mut all: Vec<&'static dyn PackFactory> = inventory::iter::<PackRegistration>
418 .into_iter()
419 .map(|r| r.0)
420 .collect();
421 all.extend(extra_factories.iter().copied());
422 Self::register_packs_with_runtimes_from(&all, names, runtimes, default_runtime, builder)
423 }
424
425 /// Shared body for [`Self::register_packs_with_runtimes`] and
426 /// [`Self::register_packs_with_runtimes_with_extra_factories`]: both
427 /// build a `factories` index (inventory-only, or inventory-plus-extra)
428 /// and delegate here. `factory_for` resolves by first match, so a
429 /// duplicate name earlier in `factories` wins over a later one — the two
430 /// public callers above rely on that for their stated collision rule.
431 fn register_packs_with_runtimes_from(
432 factories: &[&'static dyn PackFactory],
433 names: &[String],
434 runtimes: &HashMap<String, KhiveRuntime>,
435 default_runtime: &KhiveRuntime,
436 builder: &mut VerbRegistryBuilder,
437 ) -> Result<(), PackLoadError> {
438 let factory_for = |name: &str| -> Option<&'static dyn PackFactory> {
439 factories.iter().copied().find(|f| f.name() == name)
440 };
441
442 Self::validate_pack_selection_from(factories, names)?;
443
444 builder.kg_read_resolver = Some(Arc::new(crate::kg_read::KgReadResolver::new(
445 default_runtime,
446 runtimes,
447 )));
448
449 for name in names {
450 let factory = factory_for(name.as_str()).unwrap();
451 let runtime = runtimes
452 .get(name.as_str())
453 .cloned()
454 .unwrap_or_else(|| default_runtime.clone());
455 let install = factory.create_install(runtime);
456 check_pack_has_public_verbs(factory, &install, name)?;
457 if CHANNEL_INGEST_CAPABLE_PACKS.contains(&name.as_str()) {
458 install
459 .runtime
460 .accept_channel_ingest_capability(ChannelIngestCapability { _sealed: () });
461 }
462 builder
463 .pack_versions
464 .insert(install.runtime.name().to_owned(), factory.version());
465 builder.register_boxed(install.runtime);
466 if let Some(resolver) = install.resolver {
467 builder.register_resolver(name.clone(), resolver);
468 }
469 if let Some(hook) = install.dispatch_hook {
470 builder.with_dispatch_hook(hook);
471 }
472 }
473
474 Ok(())
475 }
476}