Skip to main content

khive_runtime/pack/
catalog.rs

1//! Registry catalog, kind hooks, and pack lifecycle integration.
2
3use std::any::Any;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use khive_types::NotePropertyPolicySpec;
8use serde_json::Value;
9
10use crate::context::ContextContributor;
11use crate::error::RuntimeError;
12use crate::operations::{LinkSpec, Resolved};
13use crate::runtime::NamespaceToken;
14use crate::validation::ValidationRule;
15use crate::KhiveRuntime;
16
17use super::request_identity::extract_table_names;
18#[cfg(doc)]
19use super::VerbPresentationPolicy;
20use super::{
21    EdgeEndpointRule, EntityTypeDef, HandlerDef, KindHook, NoteEmbeddingPolicySpec, NoteKindSpec,
22    PackByIdResolver, PackColumnAddition, PackRuntime, SchemaPlan, VerbCategory, VerbRegistry,
23    Visibility, GENERIC_CRUD_PACK,
24};
25
26impl VerbRegistry {
27    /// Registered pack-level by-ID resolvers, in registration order.
28    ///
29    /// Each element is `(pack_name, resolver)`. The kg `get` and `delete` handlers
30    /// iterate this slice to probe pack-private tables when the standard KG
31    /// substrates (entity/note/edge/event) return `None` for a given UUID.
32    pub fn resolvers(&self) -> &[(String, Box<dyn PackByIdResolver>)] {
33        &self.resolvers
34    }
35
36    /// The daemon-warm recently-referenced ring (unified-verb draft ADR,
37    /// Slice 1). Consumed by `resolve_reference` (Layer 0 stage 2) and by the
38    /// `resolve` verb handler; admitted-to by every successful by-id
39    /// dispatch (see the admission block in `dispatch_with_identity`).
40    pub fn reference_ring(&self) -> &Arc<crate::reference_ring::ReferenceRing> {
41        &self.reference_ring
42    }
43
44    /// Find a kind hook among the registered packs.
45    ///
46    /// Walks packs in registration order; the first pack that both owns the
47    /// kind (declares it in `note_kinds()` or `entity_kinds()`) and returns
48    /// a hook from `kind_hook(kind)` wins. Returns `None` if the kind is
49    /// unknown to all packs or no owning pack registered a hook.
50    pub fn find_kind_hook(&self, kind: &str) -> Option<Arc<dyn KindHook>> {
51        for pack in self.packs.iter() {
52            let owns = pack.note_kinds().contains(&kind) || pack.entity_kinds().contains(&kind);
53            if owns {
54                if let Some(hook) = pack.kind_hook(kind) {
55                    return Some(hook);
56                }
57            }
58        }
59        None
60    }
61
62    /// Every `(entity kind, hook)` pair for which the owning pack declares
63    /// the entity kind and registers a `KindHook` — the entity-scoped
64    /// subset of [`Self::find_kind_hook`]'s ownership check, computed once.
65    ///
66    /// `khive-runtime` does not hold a `VerbRegistry` (ownership runs the
67    /// other way: packs are constructed FROM a runtime handle), so
68    /// `KhiveRuntime::install_entity_kind_hooks` is the extension point
69    /// that carries this aggregate to the runtime layer — the transport
70    /// calls this after the registry is built, same timing as
71    /// [`Self::all_edge_rules`]. `Arc<dyn KindHook>` values returned here
72    /// hold no reference back to the pack or registry that produced them
73    /// (every production `kind_hook()` implementation constructs a fresh,
74    /// stateless hook per call), so installing this aggregate on the
75    /// runtime creates no ownership cycle.
76    pub fn entity_kind_hooks(&self) -> crate::runtime::EntityKindHooks {
77        let mut hooks = Vec::new();
78        for pack in self.packs.iter() {
79            for kind in pack.entity_kinds().iter().copied() {
80                if let Some(hook) = pack.kind_hook(kind) {
81                    hooks.push((kind.to_string(), hook));
82                }
83            }
84        }
85        hooks
86    }
87
88    /// Run the owning kind's shared-note-update normalizer/validator, if it declares one.
89    ///
90    /// Compatibility wrapper for callers that only need normalization and
91    /// validation. Writers use [`Self::prepare_note_update_policy`] and attach
92    /// its returned policy so kind-specific property removals reach storage.
93    ///
94    /// The ordering lives here, at the single dispatch site, rather than in a
95    /// [`KindHook`] method a pack could override: a pack implements the two
96    /// halves and cannot express a sequence, so it cannot replace the
97    /// validator by overriding the sequence. See ADR-017.
98    pub async fn prepare_note_update_hook(
99        &self,
100        runtime: &KhiveRuntime,
101        token: &NamespaceToken,
102        note: &khive_storage::Note,
103        args: &mut Value,
104    ) -> Result<(), RuntimeError> {
105        self.prepare_note_update_policy(runtime, token, note, args)
106            .await
107            .map(|_| ())
108    }
109
110    /// Normalize and validate a note update, then carry the owning kind's
111    /// property policy into the shared prepared write. Writers must attach the
112    /// returned policy to their `NotePatch` or snapshot update preparation;
113    /// [`Self::prepare_note_update_hook`] remains the validation-only wrapper.
114    pub async fn prepare_note_update_policy(
115        &self,
116        runtime: &KhiveRuntime,
117        token: &NamespaceToken,
118        note: &khive_storage::Note,
119        args: &mut Value,
120    ) -> Result<crate::NoteUpdatePolicy, RuntimeError> {
121        crate::curation::normalize_note_update_tags(args)?;
122        if let Some(hook) = self.find_kind_hook(&note.kind) {
123            hook.normalize_note_update(runtime, token, note, args)
124                .await?;
125            let properties = args.get("properties").filter(|value| !value.is_null());
126            hook.validate_note_update(runtime, token, note, properties)
127                .await?;
128            return Ok(crate::NoteUpdatePolicy::for_kind(
129                &note.kind,
130                hook.note_update_null_clearing_properties(),
131            ));
132        }
133        Ok(crate::NoteUpdatePolicy::default())
134    }
135
136    /// Run the owning kind's shared-note-update property validator, if it
137    /// declares one.
138    ///
139    /// Kept as the validation-only compatibility seam for callers that do not
140    /// own a mutable request object. Canonical and atomic CRUD use
141    /// [`Self::prepare_note_update_hook`] instead, so a hook's
142    /// [`KindHook::normalize_note_update`] can run before its validation does.
143    /// Reaching a hook through this seam therefore runs the validator alone:
144    /// that is the point of it, and it is why callers that CAN supply a
145    /// mutable request should not use it.
146    pub async fn validate_note_update_hook(
147        &self,
148        runtime: &KhiveRuntime,
149        token: &NamespaceToken,
150        note: &khive_storage::Note,
151        properties: Option<&Value>,
152    ) -> Result<(), RuntimeError> {
153        if let Some(hook) = self.find_kind_hook(&note.kind) {
154            hook.validate_note_update(runtime, token, note, properties)
155                .await?;
156        }
157        Ok(())
158    }
159
160    /// Run shared-link validators grouped by the owning source-note kind.
161    ///
162    /// Supplying the whole proposed batch lets a kind hook reject an invariant
163    /// violation formed only by multiple entries in that batch. Sources that
164    /// are not live notes, or whose kind has no hook, remain the canonical
165    /// endpoint validator's responsibility.
166    pub async fn validate_link_hooks(
167        &self,
168        runtime: &KhiveRuntime,
169        token: &NamespaceToken,
170        specs: &[LinkSpec],
171    ) -> Result<(), RuntimeError> {
172        let mut specs_by_kind: HashMap<String, Vec<LinkSpec>> = HashMap::new();
173        for spec in specs {
174            let Some(Resolved::Note(source)) = runtime.resolve_by_id(token, spec.source_id).await?
175            else {
176                continue;
177            };
178            specs_by_kind
179                .entry(source.kind)
180                .or_default()
181                .push(spec.clone());
182        }
183        for (kind, kind_specs) in specs_by_kind {
184            if let Some(hook) = self.find_kind_hook(&kind) {
185                hook.validate_links(runtime, token, &kind_specs).await?;
186            }
187        }
188        Ok(())
189    }
190
191    /// Whether any registered pack declares a handler with this verb name.
192    ///
193    /// A non-dispatch capability check: callers that would otherwise pay a
194    /// guaranteed-failed `dispatch` (and its audit write) when an optional
195    /// pack is absent can probe first and skip the call entirely.
196    pub fn has_verb(&self, verb: &str) -> bool {
197        self.handler_by_name.contains_key(verb) && !self.is_verb_disabled(verb)
198    }
199
200    /// Whether operator policy disables this registered public handler.
201    pub fn is_verb_disabled(&self, verb: &str) -> bool {
202        self.disabled_verbs.contains(verb)
203    }
204
205    pub(crate) fn unknown_verb_error(&self, verb: &str) -> RuntimeError {
206        RuntimeError::UnknownVerb(format!(
207            "unknown verb {verb:?}; available: {}",
208            self.available_verbs.join(", ")
209        ))
210    }
211
212    /// Advisory metadata for synchronous planning and MCP initialization.
213    pub fn mounted_verb_snapshot(&self) -> Vec<Value> {
214        self.packs
215            .iter()
216            .flat_map(|pack| {
217                pack.mounted_catalog_snapshot()
218                    .into_iter()
219                    .map(|verb| verb.describe(pack.name()))
220            })
221            .collect()
222    }
223
224    pub async fn mounted_verb_catalog(&self) -> Result<Vec<Value>, RuntimeError> {
225        let mut catalog = Vec::new();
226        for pack in self.packs.iter() {
227            for definition in pack.mounted_catalog().await? {
228                catalog.push(definition.describe(pack.name()));
229            }
230        }
231        Ok(catalog)
232    }
233
234    /// Apply section evidence through the installed brain instance. Callers must
235    /// validate their domain target and authorize their own operation first;
236    /// this trusted Rust hook adds no handler to dispatch or the wire catalog.
237    pub async fn apply_profile_section_feedback(
238        &self,
239        token: &NamespaceToken,
240        profile_id: &str,
241        section_signals: Value,
242        target_attribution: Option<String>,
243    ) -> Result<Value, RuntimeError> {
244        let brain = self
245            .packs
246            .iter()
247            .find(|pack| pack.name() == "brain")
248            .ok_or_else(|| {
249                RuntimeError::InvalidInput(
250                    "profile section feedback requires the brain pack".into(),
251                )
252            })?;
253        brain
254            .apply_profile_section_feedback(token, profile_id, section_signals, target_attribution)
255            .await
256    }
257
258    /// All MCP-exposed handlers across all registered packs (`Visibility::Verb` only).
259    ///
260    /// Disabled verbs and subhandlers (`Visibility::Subhandler`) are excluded — subhandlers are internal
261    /// pipeline steps not surfaced on the MCP wire. Returned with `'static`
262    /// lifetime since pack handlers are `&'static [HandlerDef]` constants.
263    pub fn all_verbs(&self) -> Vec<&'static HandlerDef> {
264        self.registered_handlers()
265            .map(|(_, handler)| handler)
266            .filter(|handler| self.is_enabled_verb(handler))
267            .collect()
268    }
269
270    /// All MCP-exposed handlers paired with the name of the pack that owns them
271    /// (`Visibility::Verb` only).
272    ///
273    /// Subhandlers (`Visibility::Subhandler`) are excluded from the MCP catalog
274    /// Use `all_handlers_with_names` when internal handlers must
275    /// also be enumerated (e.g. runtime introspection).
276    pub fn all_verbs_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
277        self.registered_handlers()
278            .map(|(pack, handler)| (pack.name(), handler))
279            .filter(|(_, handler)| self.is_enabled_verb(handler))
280            .collect()
281    }
282
283    /// All handler definitions across all registered packs, including disabled verbs and subhandlers.
284    ///
285    /// Unlike `all_verbs`, this includes `Visibility::Subhandler` entries. Useful
286    /// for runtime introspection (e.g. `list_handlers`) and tooling that needs
287    /// the complete handler surface.
288    pub fn all_handlers_with_names(&self) -> Vec<(&str, &'static HandlerDef)> {
289        self.registered_handlers()
290            .map(|(pack, handler)| (pack.name(), handler))
291            .collect()
292    }
293
294    fn registered_handlers(
295        &self,
296    ) -> impl Iterator<Item = (&dyn PackRuntime, &'static HandlerDef)> + '_ {
297        self.packs.iter().flat_map(|pack| {
298            let pack = pack.as_ref();
299            pack.handlers().iter().map(move |handler| (pack, handler))
300        })
301    }
302
303    fn is_enabled_verb(&self, handler: &HandlerDef) -> bool {
304        matches!(handler.visibility, Visibility::Verb) && !self.is_verb_disabled(handler.name)
305    }
306
307    /// Collect declared kinds once, retaining their first-seen registration order.
308    fn collect_pack_kinds(
309        &self,
310        select: impl Fn(&dyn PackRuntime) -> &'static [&'static str],
311    ) -> Vec<&'static str> {
312        let mut seen = std::collections::HashSet::new();
313        self.packs
314            .iter()
315            .flat_map(|pack| select(pack.as_ref()).iter().copied())
316            .filter(|kind| seen.insert(*kind))
317            .collect()
318    }
319
320    /// Merged set of note kinds across all registered packs (deduplicated,
321    /// first-seen order preserved).
322    pub fn all_note_kinds(&self) -> Vec<&'static str> {
323        self.collect_pack_kinds(|pack| pack.note_kinds())
324    }
325
326    /// Note kinds owned by a pack, i.e. every kind in [`all_note_kinds`] that
327    /// is not one of the generic-CRUD pack's own kinds.
328    ///
329    /// [`GENERIC_CRUD_PACK`] declares the general-purpose note kinds the shared
330    /// CRUD verbs exist to serve (`observation`, `insight`, …); every other
331    /// pack's kinds are records that pack's own verbs create and maintain.
332    /// Derived from the packs' `NOTE_KINDS` constants, so a pack that adds or
333    /// drops a kind moves this set with it — nothing is hardcoded here but the
334    /// name of the generic pack itself.
335    ///
336    /// [`all_note_kinds`]: Self::all_note_kinds
337    pub fn pack_owned_note_kinds(&self) -> Vec<&'static str> {
338        let generic: std::collections::HashSet<&'static str> = self
339            .packs
340            .iter()
341            .filter(|p| p.name() == GENERIC_CRUD_PACK)
342            .flat_map(|p| p.note_kinds().iter().copied())
343            .collect();
344        let mut seen = std::collections::HashSet::new();
345        self.packs
346            .iter()
347            .filter(|p| p.name() != GENERIC_CRUD_PACK)
348            .flat_map(|p| p.note_kinds().iter().copied())
349            .filter(|k| !generic.contains(k) && seen.insert(*k))
350            .collect()
351    }
352
353    /// Merged set of entity kinds across all registered packs (deduplicated,
354    /// first-seen order preserved).
355    pub fn all_entity_kinds(&self) -> Vec<&'static str> {
356        self.collect_pack_kinds(|pack| pack.entity_kinds())
357    }
358
359    /// Merged set of brain profile consumer kinds requested by registered
360    /// packs (deduplicated, first-seen order preserved).
361    pub fn all_brain_consumer_kinds(&self) -> Vec<&'static str> {
362        self.collect_pack_kinds(|pack| pack.brain_consumer_kinds())
363    }
364
365    /// Names of packs in topological load order.
366    pub fn pack_names(&self) -> Vec<&str> {
367        self.packs.iter().map(|p| p.name()).collect()
368    }
369
370    /// Borrow a registered pack's shared host state without reconstructing
371    /// that pack. Missing packs, absent state, and type mismatches return None.
372    pub fn pack_host_state<T: Any + Send + Sync>(&self, name: &str) -> Option<Arc<T>> {
373        self.find_pack(name)?.host_state()?.downcast::<T>().ok()
374    }
375
376    /// Factory-reported version for a registered pack.
377    ///
378    /// Returns `None` for unknown packs and packs registered directly without
379    /// a factory; their package version is not available to the registry.
380    pub fn pack_version(&self, name: &str) -> Option<&'static str> {
381        self.pack_versions.get(name).copied()
382    }
383
384    /// Declared dependencies for a registered pack.
385    pub fn pack_requires(&self, name: &str) -> Option<&'static [&'static str]> {
386        self.find_pack(name).map(|pack| pack.requires())
387    }
388
389    /// Note kinds owned by a specific registered pack.
390    ///
391    /// Returns `None` if no pack with `name` is registered. The slice is
392    /// the pack's `NOTE_KINDS` constant — `'static` lifetime, no allocation.
393    pub fn pack_note_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
394        self.find_pack(name).map(|pack| pack.note_kinds())
395    }
396
397    /// Entity kinds owned by a specific registered pack.
398    ///
399    /// Returns `None` if no pack with `name` is registered. The slice is
400    /// the pack's `ENTITY_KINDS` constant — `'static` lifetime, no allocation.
401    pub fn pack_entity_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
402        self.find_pack(name).map(|pack| pack.entity_kinds())
403    }
404
405    /// Handlers declared by a specific registered pack.
406    ///
407    /// Returns `None` if no pack with `name` is registered. Each `HandlerDef`
408    /// carries name + description + visibility — sufficient for introspection clients.
409    pub fn pack_verbs(&self, name: &str) -> Option<&'static [HandlerDef]> {
410        self.find_pack(name).map(|pack| pack.handlers())
411    }
412
413    fn find_pack(&self, name: &str) -> Option<&dyn PackRuntime> {
414        self.packs
415            .iter()
416            .find(|pack| pack.name() == name)
417            .map(|pack| pack.as_ref())
418    }
419
420    /// Collect pack items in registration order without filtering or deduplication.
421    fn collect_pack_items<T, I>(&self, select: impl Fn(&dyn PackRuntime) -> I) -> Vec<T>
422    where
423        I: IntoIterator<Item = T>,
424    {
425        self.packs
426            .iter()
427            .flat_map(|pack| select(pack.as_ref()))
428            .collect()
429    }
430
431    /// Optional context sources in the registry's topological pack order.
432    ///
433    /// Omits packs returning `None`, without caching, deduplicating or invoking contributors.
434    pub fn context_contributors(&self) -> Vec<Arc<dyn ContextContributor>> {
435        self.collect_pack_items(|pack| pack.context_contributor())
436    }
437
438    /// All pack-declared edge endpoint rules across registered packs.
439    ///
440    /// Order follows topological pack registration; duplicates are *not* deduplicated —
441    /// validation only checks membership, and an exact-duplicate rule is a
442    /// harmless restatement.
443    pub fn all_edge_rules(&self) -> Vec<EdgeEndpointRule> {
444        self.attributed_edge_rules
445            .iter()
446            .map(|(_, rule)| *rule)
447            .collect()
448    }
449
450    /// Pack-declared endpoint rules paired with their owning pack names.
451    ///
452    /// Ownership is captured when the registry is built. Order and duplicate
453    /// declarations match [`Self::all_edge_rules`] exactly; built-in substrate
454    /// rules are excluded from both accessors. Names borrow the immutable
455    /// registry snapshot, so registry clones retain the same attribution.
456    pub fn all_edge_rules_with_packs(&self) -> Vec<(&str, EdgeEndpointRule)> {
457        self.attributed_edge_rules
458            .iter()
459            .map(|(pack, rule)| (pack.as_str(), *rule))
460            .collect()
461    }
462
463    /// All pack-declared entity-type subtypes across registered packs.
464    ///
465    /// Order follows topological pack registration; duplicates are *not*
466    /// deduplicated here — same posture as [`all_edge_rules`](Self::all_edge_rules).
467    /// Consumers compose this with `EntityTypeRegistry::builtin()` via
468    /// `EntityTypeRegistry::with_extra` to get the boot-time composed registry.
469    pub fn all_entity_types(&self) -> Vec<EntityTypeDef> {
470        self.collect_pack_items(|pack| pack.entity_types().iter().cloned())
471    }
472
473    /// Collect all `NoteKindSpec` declarations from every loaded pack.
474    ///
475    /// Used by the runtime for lifecycle introspection and future enforcement.
476    pub fn all_note_kind_specs(&self) -> Vec<&'static NoteKindSpec> {
477        self.collect_pack_items(|pack| pack.note_kind_specs().iter())
478    }
479
480    /// Copy the validated boot snapshot in topological pack/declaration order.
481    ///
482    /// This metadata-only accessor never calls packs or changes write enforcement.
483    pub fn all_note_property_policies(&self) -> Vec<NotePropertyPolicySpec> {
484        self.note_property_policies.as_ref().clone()
485    }
486
487    /// Collect pack-declared embedding policies for registered note kinds.
488    pub fn all_note_embedding_policies(&self) -> Vec<NoteEmbeddingPolicySpec> {
489        self.collect_pack_items(|pack| pack.note_embedding_policies().iter().copied())
490    }
491
492    /// All pack-contributed validation rules across registered packs.
493    ///
494    /// Returns references into the pack-owned `'static` slices — no allocation
495    /// beyond the outer `Vec`. Rule IDs are namespaced by pack; callers can
496    /// group by `rule.id.split_once('/')` to attribute rules to their packs.
497    pub fn all_validation_rules(&self) -> Vec<&'static ValidationRule> {
498        self.collect_pack_items(|pack| pack.validation_rules().iter())
499    }
500
501    /// Pack-auxiliary schema plans for all registered packs.
502    ///
503    /// Returns one `SchemaPlan` per pack. Callers (typically the runtime
504    /// bootstrap) apply each plan to the pack's assigned backend. Empty plans
505    /// are included so the caller can iterate uniformly; callers that want to
506    /// skip empty plans should check `plan.is_empty()`. Schema application must
507    /// use [`Self::all_schema_plans_with_columns`] to retain column upgrades.
508    pub fn all_schema_plans(&self) -> Vec<SchemaPlan> {
509        self.packs.iter().map(|p| p.schema_plan()).collect()
510    }
511
512    /// Schema plans paired with the same owning pack's nullable-column upgrades.
513    ///
514    /// Callers applying plans directly must pass both entries to
515    /// `StorageBackend::apply_pack_ddl_statements_with_columns`.
516    pub fn all_schema_plans_with_columns(
517        &self,
518    ) -> Vec<(SchemaPlan, &'static [PackColumnAddition])> {
519        self.packs
520            .iter()
521            .map(|pack| (pack.schema_plan(), pack.schema_column_additions()))
522            .collect()
523    }
524
525    /// Invoke `PackRuntime::register_embedders` on every registered pack.
526    ///
527    /// Called by the transport during startup, after the registry is built and
528    /// before the first verb dispatch, so that custom embedding providers
529    /// contributed by packs are reachable via `KhiveRuntime::embedder(name)`.
530    ///
531    /// Packs whose `register_embedders` is the default no-op pay no overhead.
532    /// The method is idempotent when the underlying registry uses last-wins
533    /// semantics for duplicate provider names.
534    pub fn call_register_embedders(&self, runtime: &KhiveRuntime) {
535        for pack in self.packs.iter() {
536            pack.register_embedders(runtime);
537        }
538    }
539
540    /// Invoke `PackRuntime::register_entity_type_validator` on every registered pack.
541    ///
542    /// Called by the transport during startup, after the registry is built and
543    /// before the first verb dispatch, so that entity-type validation at the
544    /// runtime layer is active for all write paths including direct `create_many`
545    /// callers that bypass the handler layer.
546    ///
547    /// Packs whose `register_entity_type_validator` is the default no-op pay
548    /// no overhead.
549    ///
550    /// Composes [`all_entity_types`](Self::all_entity_types) once and passes
551    /// the same aggregate to every pack, mirroring how `install_edge_rules`
552    /// installs one `all_edge_rules()` aggregate for the whole registry.
553    pub fn call_register_entity_type_validators(&self, runtime: &KhiveRuntime) {
554        let entity_types = self.all_entity_types();
555        for pack in self.packs.iter() {
556            pack.register_entity_type_validator_with_types(runtime, &entity_types);
557        }
558    }
559
560    /// Invoke `PackRuntime::register_note_mutation_hook` on every registered pack.
561    ///
562    /// Called by the transport during startup, after the registry is built and
563    /// before the first verb dispatch, so that note-mutation notifications at
564    /// the runtime layer are active for all write paths — including KG's
565    /// `update`/`delete` verbs reaching a `kind="memory"` note, which have no
566    /// crate-level dependency on `khive-pack-memory`.
567    ///
568    /// Packs whose `register_note_mutation_hook` is the default no-op pay no
569    /// overhead.
570    pub fn call_register_note_mutation_hooks(&self, runtime: &KhiveRuntime) {
571        for pack in self.packs.iter() {
572            pack.register_note_mutation_hook(runtime);
573        }
574    }
575
576    /// Install pack-owned note-search candidate sources before warm-up or
577    /// dispatch, following the same registration timing as mutation hooks.
578    pub fn call_register_note_search_ann_providers(&self, runtime: &KhiveRuntime) {
579        for pack in self.packs.iter() {
580            pack.register_note_search_ann_provider(runtime);
581        }
582    }
583
584    /// Invoke `PackRuntime::register_note_write_validator` on every registered pack.
585    ///
586    /// Called by the transport during startup with the same timing as
587    /// `call_register_note_mutation_hooks`, so note-write validation is active
588    /// at the runtime layer for every write path — the generic `create` verb,
589    /// direct Rust callers, and proposal apply, none of which dispatch a pack
590    /// hook of their own on the note-write.
591    pub fn call_register_note_write_validators(&self, runtime: &KhiveRuntime) {
592        for pack in self.packs.iter() {
593            pack.register_note_write_validator(runtime);
594        }
595    }
596
597    /// Invoke `PackRuntime::warm` on every registered pack.
598    /// Called by the daemon at boot (in a background task) so expensive in-memory
599    /// state (ANN indexes) is pre-loaded without blocking request serving.
600    pub async fn call_warm_all(&self) {
601        for pack in self.packs.iter() {
602            pack.warm().await;
603        }
604    }
605
606    /// Resolve the presentation policy for a verb name.
607    ///
608    /// Uses the first registered handler (including subhandlers) with this name
609    /// and returns its declared [`VerbPresentationPolicy`].
610    /// Returns `Standard` for unknown verbs — unknown verbs will fail at
611    /// dispatch anyway, so the fallback here is safe.
612    pub fn presentation_policy_for(&self, verb: &str) -> khive_types::VerbPresentationPolicy {
613        self.handler_by_name
614            .get(verb)
615            .map_or(khive_types::VerbPresentationPolicy::Standard, |handler| {
616                handler.presentation_policy()
617            })
618    }
619
620    /// Resolve the declared [`VerbCategory`] for a verb name.
621    ///
622    /// Uses the first registered handler (including subhandlers) with this name
623    /// and returns its speech-act category. Returns `None` for
624    /// an unregistered verb name, so a caller deciding transport-level
625    /// behavior (e.g. whether a post-dispatch condition is safe to retry)
626    /// can fail closed on an unknown verb instead of guessing a category.
627    pub fn verb_category(&self, verb: &str) -> Option<VerbCategory> {
628        self.handler_by_name
629            .get(verb)
630            .map(|handler| handler.category)
631    }
632
633    /// Verbs classified [`VerbCategory::Assertive`] that nonetheless schedule
634    /// can schedule a persisted write on a successful dispatch, so a caller re-issuing
635    /// a call in this list after a lost response duplicates that write:
636    ///
637    /// - `memory.recall` schedules `brain.record_serve`, which inserts a
638    ///   serve-ledger row keyed in part on a `served_at` timestamp captured
639    ///   fresh at dispatch time — a second dispatch inserts a second row
640    ///   rather than colliding with the first.
641    /// - `search` (the `kg` pack's bare verb) appends a `search_executed`
642    ///   event with a freshly generated id and no natural key at all.
643    /// - `telemetry.emit` can append a durable stream record with a fresh
644    ///   identity and sequence, depending on the configured channel policy.
645    /// - `tool.check` appends a `tool_check_decided` receipt with a fresh
646    ///   event id for every evaluated decision (ADR-180 Amendment 6).
647    ///
648    /// The speech-act category alone cannot rule this out — it describes
649    /// what the verb tells the *caller*, not what it schedules against
650    /// storage. Adding a verb here (or removing one because its side effect
651    /// was made idempotent) is a correctness decision requiring the same
652    /// scrutiny as the categorization itself.
653    pub const SIDE_EFFECTING_ASSERTIVE_VERBS: &'static [&'static str] =
654        &["memory.recall", "search", "telemetry.emit", "tool.check"];
655
656    /// Whether a response lost to the daemon frame budget may be truthfully
657    /// advertised as safe to re-issue: the verb is [`VerbCategory::Assertive`]
658    /// (no institutional commitment was made) and is not on
659    /// `Self::SIDE_EFFECTING_ASSERTIVE_VERBS` (no persisted write to
660    /// duplicate on a second dispatch). An unregistered verb name resolves to
661    /// `None` from [`Self::verb_category`] and fails closed here.
662    ///
663    /// Used only by the MCP daemon's frame-budget omission decision; never
664    /// for permission checking or return-shape selection.
665    pub fn is_retry_safe_after_frame_omission(&self, verb: &str) -> bool {
666        matches!(self.verb_category(verb), Some(VerbCategory::Assertive))
667            && !Self::SIDE_EFFECTING_ASSERTIVE_VERBS.contains(&verb)
668    }
669
670    /// Returns `true` if the named verb exists and is tagged
671    /// `Visibility::Subhandler` (internal / operator-only).
672    ///
673    /// Used by the MCP server to gate subhandler invocation at the wire
674    /// boundary without blocking internal callers that invoke the same verbs
675    /// through the runtime directly.
676    pub fn is_subhandler_verb(&self, verb: &str) -> bool {
677        self.handler_by_name
678            .get(verb)
679            .is_some_and(|handler| matches!(handler.visibility, Visibility::Subhandler))
680    }
681
682    /// Apply all non-empty pack-auxiliary schema plans to the given backend.
683    ///
684    /// This is the centralized startup hook that replaced the previous lazy
685    /// per-pack self-bootstrap pattern. Each pack's `SchemaPlan` carries
686    /// idempotent `CREATE TABLE IF NOT EXISTS` DDL; calling this more than once
687    /// is safe. Plans with neither SQL nor column upgrades are skipped.
688    ///
689    /// Errors from individual plans are logged via `tracing::warn!` and not
690    /// propagated so that a single pack's schema failure does not prevent the
691    /// rest from loading. Serving hosts must instead use the fallible
692    /// [`Self::apply_schema_plans_with_map`] (with an empty map for one backend)
693    /// so a required schema failure cannot leave a pack's verbs unavailable.
694    pub fn apply_schema_plans(&self, backend: &khive_db::StorageBackend) {
695        if backend.is_read_only() {
696            tracing::info!(
697                "skipping pack schema plans because the backend is read-only; snapshot schema is used as-is"
698            );
699            return;
700        }
701        for (plan, additions) in self.all_schema_plans_with_columns() {
702            if plan.is_empty() && additions.is_empty() {
703                continue;
704            }
705            if let Err(e) =
706                backend.apply_pack_ddl_statements_with_columns(plan.statements, additions)
707            {
708                tracing::warn!(
709                    pack = plan.pack,
710                    error = %e,
711                    "failed to apply pack schema plan at startup (non-fatal)"
712                );
713            }
714        }
715    }
716
717    /// Pack-auxiliary schema plans with their owning pack names.
718    ///
719    /// Returns `(pack_name, SchemaPlan)` pairs for every registered pack.
720    /// Used by the multi-backend boot path to apply each plan to the pack's
721    /// assigned backend rather than a single shared backend. Direct schema
722    /// application must use [`Self::all_schema_plans_with_columns`] so column
723    /// upgrades are retained.
724    pub fn all_schema_plans_named(&self) -> Vec<(&'static str, SchemaPlan)> {
725        self.packs
726            .iter()
727            .map(|p| {
728                let plan = p.schema_plan();
729                (plan.pack, plan)
730            })
731            .collect()
732    }
733
734    /// Apply pack-auxiliary schema plans using a per-pack backend map.
735    ///
736    /// For each plan and its owning pack's column additions, applies the full
737    /// plan to `backend_for_pack[plan.pack]` when present,
738    /// falling back to `default_backend` for any pack not in the map.
739    ///
740    /// Returns an error when two packs on the same backend declare the same
741    /// auxiliary table (ADR-028 §7 collision policy: boot failure naming both
742    /// packs and the conflicting table).
743    ///
744    /// Both single- and multi-backend hosts use this boot path (ADR-028).
745    /// An empty map selects the default backend for every pack. Read-only
746    /// backends validate declared columns without applying SQL or acquiring a
747    /// writer; missing or incompatible columns refuse boot with the pack name.
748    pub fn apply_schema_plans_with_map(
749        &self,
750        backend_for_pack: &HashMap<&str, &khive_db::StorageBackend>,
751        default_backend: &khive_db::StorageBackend,
752    ) -> Result<(), crate::PackSchemaCollisionError> {
753        // Track which pack first claimed each table on each backend.
754        // Backend identity is the raw pointer of the underlying connection pool Arc.
755        let mut claimed: HashMap<(*const (), String), &'static str> = HashMap::new();
756
757        let plans = self.all_schema_plans_with_columns();
758        // Check every declaration before applying any pack DDL. A collision
759        // must not leave earlier plans installed on a failed boot.
760        for (plan, additions) in &plans {
761            if plan.is_empty() && additions.is_empty() {
762                continue;
763            }
764            let pack_name = plan.pack;
765            let backend = backend_for_pack
766                .get(pack_name)
767                .copied()
768                .unwrap_or(default_backend);
769            let backend_ptr = std::sync::Arc::as_ptr(&backend.pool_arc()) as *const ();
770
771            // Collect DDL table ownership for the full plan set.
772            for stmt in plan.statements {
773                for table_name in extract_table_names(stmt) {
774                    let key = (backend_ptr, table_name.clone());
775                    match claimed.entry(key) {
776                        std::collections::hash_map::Entry::Vacant(e) => {
777                            e.insert(pack_name);
778                        }
779                        std::collections::hash_map::Entry::Occupied(e) => {
780                            let prior_pack = *e.get();
781                            return Err(crate::PackSchemaCollisionError {
782                                pack_a: prior_pack,
783                                pack_b: pack_name,
784                                table: table_name,
785                            });
786                        }
787                    }
788                }
789            }
790            for addition in *additions {
791                let table_name = addition.table.to_ascii_lowercase();
792                let key = (backend_ptr, table_name.clone());
793                match claimed.entry(key) {
794                    std::collections::hash_map::Entry::Vacant(entry) => {
795                        entry.insert(pack_name);
796                    }
797                    std::collections::hash_map::Entry::Occupied(entry) => {
798                        let prior_pack = *entry.get();
799                        // A pack's full CREATE and its upgrades declare the
800                        // same table; this is one ownership claim.
801                        if prior_pack != pack_name {
802                            return Err(crate::PackSchemaCollisionError {
803                                pack_a: prior_pack,
804                                pack_b: pack_name,
805                                table: table_name,
806                            });
807                        }
808                    }
809                }
810            }
811        }
812
813        for (plan, additions) in plans {
814            if plan.is_empty() && additions.is_empty() {
815                continue;
816            }
817            let pack_name = plan.pack;
818            let backend = backend_for_pack
819                .get(pack_name)
820                .copied()
821                .unwrap_or(default_backend);
822            if backend.is_read_only() {
823                backend.validate_pack_schema_columns(additions).map_err(|error| {
824                    crate::PackSchemaCollisionError {
825                        pack_a: pack_name,
826                        pack_b: pack_name,
827                        table: format!("read-only schema validation failed: {error}; open the database writable to apply the pack schema upgrade"),
828                    }
829                })?;
830                continue;
831            }
832
833            backend
834                .apply_pack_ddl_statements_with_columns(plan.statements, additions)
835                .map_err(|e| crate::PackSchemaCollisionError {
836                    pack_a: pack_name,
837                    pack_b: pack_name,
838                    table: format!("DDL error: {e}"),
839                })?;
840        }
841        Ok(())
842    }
843}