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    /// 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}