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