Skip to main content

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}