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