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, PackRuntime, SchemaPlan, VerbCategory, VerbRegistry,
21 Visibility, 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(¬e.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 ¬e.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(¬e.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 /// Collect declared kinds once, retaining their first-seen registration order.
284 fn collect_pack_kinds(
285 &self,
286 select: impl Fn(&dyn PackRuntime) -> &'static [&'static str],
287 ) -> Vec<&'static str> {
288 let mut seen = std::collections::HashSet::new();
289 self.packs
290 .iter()
291 .flat_map(|pack| select(pack.as_ref()).iter().copied())
292 .filter(|kind| seen.insert(*kind))
293 .collect()
294 }
295
296 /// Merged set of note kinds across all registered packs (deduplicated,
297 /// first-seen order preserved).
298 pub fn all_note_kinds(&self) -> Vec<&'static str> {
299 self.collect_pack_kinds(|pack| pack.note_kinds())
300 }
301
302 /// Note kinds owned by a pack, i.e. every kind in [`all_note_kinds`] that
303 /// is not one of the generic-CRUD pack's own kinds.
304 ///
305 /// [`GENERIC_CRUD_PACK`] declares the general-purpose note kinds the shared
306 /// CRUD verbs exist to serve (`observation`, `insight`, …); every other
307 /// pack's kinds are records that pack's own verbs create and maintain.
308 /// Derived from the packs' `NOTE_KINDS` constants, so a pack that adds or
309 /// drops a kind moves this set with it — nothing is hardcoded here but the
310 /// name of the generic pack itself.
311 ///
312 /// [`all_note_kinds`]: Self::all_note_kinds
313 pub fn pack_owned_note_kinds(&self) -> Vec<&'static str> {
314 let generic: std::collections::HashSet<&'static str> = self
315 .packs
316 .iter()
317 .filter(|p| p.name() == GENERIC_CRUD_PACK)
318 .flat_map(|p| p.note_kinds().iter().copied())
319 .collect();
320 let mut seen = std::collections::HashSet::new();
321 self.packs
322 .iter()
323 .filter(|p| p.name() != GENERIC_CRUD_PACK)
324 .flat_map(|p| p.note_kinds().iter().copied())
325 .filter(|k| !generic.contains(k) && seen.insert(*k))
326 .collect()
327 }
328
329 /// Merged set of entity kinds across all registered packs (deduplicated,
330 /// first-seen order preserved).
331 pub fn all_entity_kinds(&self) -> Vec<&'static str> {
332 self.collect_pack_kinds(|pack| pack.entity_kinds())
333 }
334
335 /// Merged set of brain profile consumer kinds requested by registered
336 /// packs (deduplicated, first-seen order preserved).
337 pub fn all_brain_consumer_kinds(&self) -> Vec<&'static str> {
338 self.collect_pack_kinds(|pack| pack.brain_consumer_kinds())
339 }
340
341 /// Names of packs in topological load order.
342 pub fn pack_names(&self) -> Vec<&str> {
343 self.packs.iter().map(|p| p.name()).collect()
344 }
345
346 /// Borrow a registered pack's shared host state without reconstructing
347 /// that pack. Missing packs, absent state, and type mismatches return None.
348 pub fn pack_host_state<T: Any + Send + Sync>(&self, name: &str) -> Option<Arc<T>> {
349 self.packs
350 .iter()
351 .find(|pack| pack.name() == name)?
352 .host_state()?
353 .downcast::<T>()
354 .ok()
355 }
356
357 /// Declared dependencies for a registered pack.
358 pub fn pack_requires(&self, name: &str) -> Option<&'static [&'static str]> {
359 self.packs
360 .iter()
361 .find(|p| p.name() == name)
362 .map(|p| p.requires())
363 }
364
365 /// Note kinds owned by a specific registered pack.
366 ///
367 /// Returns `None` if no pack with `name` is registered. The slice is
368 /// the pack's `NOTE_KINDS` constant — `'static` lifetime, no allocation.
369 pub fn pack_note_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
370 self.packs
371 .iter()
372 .find(|p| p.name() == name)
373 .map(|p| p.note_kinds())
374 }
375
376 /// Entity kinds owned by a specific registered pack.
377 ///
378 /// Returns `None` if no pack with `name` is registered. The slice is
379 /// the pack's `ENTITY_KINDS` constant — `'static` lifetime, no allocation.
380 pub fn pack_entity_kinds(&self, name: &str) -> Option<&'static [&'static str]> {
381 self.packs
382 .iter()
383 .find(|p| p.name() == name)
384 .map(|p| p.entity_kinds())
385 }
386
387 /// Handlers declared by a specific registered pack.
388 ///
389 /// Returns `None` if no pack with `name` is registered. Each `HandlerDef`
390 /// carries name + description + visibility — sufficient for introspection clients.
391 pub fn pack_verbs(&self, name: &str) -> Option<&'static [HandlerDef]> {
392 self.packs
393 .iter()
394 .find(|p| p.name() == name)
395 .map(|p| p.handlers())
396 }
397
398 /// Collect pack items in registration order without filtering or deduplication.
399 fn collect_pack_items<T, I>(&self, select: impl Fn(&dyn PackRuntime) -> I) -> Vec<T>
400 where
401 I: IntoIterator<Item = T>,
402 {
403 self.packs
404 .iter()
405 .flat_map(|pack| select(pack.as_ref()))
406 .collect()
407 }
408
409 /// All pack-declared edge endpoint rules across registered packs.
410 ///
411 /// Order follows topological pack registration; duplicates are *not* deduplicated —
412 /// validation only checks membership, and an exact-duplicate rule is a
413 /// harmless restatement.
414 pub fn all_edge_rules(&self) -> Vec<EdgeEndpointRule> {
415 self.collect_pack_items(|pack| pack.edge_rules().iter().copied())
416 }
417
418 /// All pack-declared entity-type subtypes across registered packs.
419 ///
420 /// Order follows topological pack registration; duplicates are *not*
421 /// deduplicated here — same posture as [`all_edge_rules`](Self::all_edge_rules).
422 /// Consumers compose this with `EntityTypeRegistry::builtin()` via
423 /// `EntityTypeRegistry::with_extra` to get the boot-time composed registry.
424 pub fn all_entity_types(&self) -> Vec<EntityTypeDef> {
425 self.collect_pack_items(|pack| pack.entity_types().iter().cloned())
426 }
427
428 /// Collect all `NoteKindSpec` declarations from every loaded pack.
429 ///
430 /// Used by the runtime for lifecycle introspection and future enforcement.
431 pub fn all_note_kind_specs(&self) -> Vec<&'static NoteKindSpec> {
432 self.collect_pack_items(|pack| pack.note_kind_specs().iter())
433 }
434
435 /// Collect pack-declared embedding policies for registered note kinds.
436 pub fn all_note_embedding_policies(&self) -> Vec<NoteEmbeddingPolicySpec> {
437 self.collect_pack_items(|pack| pack.note_embedding_policies().iter().copied())
438 }
439
440 /// All pack-contributed validation rules across registered packs.
441 ///
442 /// Returns references into the pack-owned `'static` slices — no allocation
443 /// beyond the outer `Vec`. Rule IDs are namespaced by pack; callers can
444 /// group by `rule.id.split_once('/')` to attribute rules to their packs.
445 pub fn all_validation_rules(&self) -> Vec<&'static ValidationRule> {
446 self.collect_pack_items(|pack| pack.validation_rules().iter())
447 }
448
449 /// Pack-auxiliary schema plans for all registered packs.
450 ///
451 /// Returns one `SchemaPlan` per pack. Callers (typically the runtime
452 /// bootstrap) apply each plan to the pack's assigned backend. Empty plans
453 /// are included so the caller can iterate uniformly; callers that want to
454 /// skip empty plans should check `plan.is_empty()`. Schema application must
455 /// use [`Self::all_schema_plans_with_columns`] to retain column upgrades.
456 pub fn all_schema_plans(&self) -> Vec<SchemaPlan> {
457 self.packs.iter().map(|p| p.schema_plan()).collect()
458 }
459
460 /// Schema plans paired with the same owning pack's nullable-column upgrades.
461 ///
462 /// Callers applying plans directly must pass both entries to
463 /// `StorageBackend::apply_pack_ddl_statements_with_columns`.
464 pub fn all_schema_plans_with_columns(
465 &self,
466 ) -> Vec<(SchemaPlan, &'static [PackColumnAddition])> {
467 self.packs
468 .iter()
469 .map(|pack| (pack.schema_plan(), pack.schema_column_additions()))
470 .collect()
471 }
472
473 /// Invoke `PackRuntime::register_embedders` on every registered pack.
474 ///
475 /// Called by the transport during startup, after the registry is built and
476 /// before the first verb dispatch, so that custom embedding providers
477 /// contributed by packs are reachable via `KhiveRuntime::embedder(name)`.
478 ///
479 /// Packs whose `register_embedders` is the default no-op pay no overhead.
480 /// The method is idempotent when the underlying registry uses last-wins
481 /// semantics for duplicate provider names.
482 pub fn call_register_embedders(&self, runtime: &KhiveRuntime) {
483 for pack in self.packs.iter() {
484 pack.register_embedders(runtime);
485 }
486 }
487
488 /// Invoke `PackRuntime::register_entity_type_validator` on every registered pack.
489 ///
490 /// Called by the transport during startup, after the registry is built and
491 /// before the first verb dispatch, so that entity-type validation at the
492 /// runtime layer is active for all write paths including direct `create_many`
493 /// callers that bypass the handler layer.
494 ///
495 /// Packs whose `register_entity_type_validator` is the default no-op pay
496 /// no overhead.
497 ///
498 /// Composes [`all_entity_types`](Self::all_entity_types) once and passes
499 /// the same aggregate to every pack, mirroring how `install_edge_rules`
500 /// installs one `all_edge_rules()` aggregate for the whole registry.
501 pub fn call_register_entity_type_validators(&self, runtime: &KhiveRuntime) {
502 let entity_types = self.all_entity_types();
503 for pack in self.packs.iter() {
504 pack.register_entity_type_validator_with_types(runtime, &entity_types);
505 }
506 }
507
508 /// Invoke `PackRuntime::register_note_mutation_hook` on every registered pack.
509 ///
510 /// Called by the transport during startup, after the registry is built and
511 /// before the first verb dispatch, so that note-mutation notifications at
512 /// the runtime layer are active for all write paths — including KG's
513 /// `update`/`delete` verbs reaching a `kind="memory"` note, which have no
514 /// crate-level dependency on `khive-pack-memory`.
515 ///
516 /// Packs whose `register_note_mutation_hook` is the default no-op pay no
517 /// overhead.
518 pub fn call_register_note_mutation_hooks(&self, runtime: &KhiveRuntime) {
519 for pack in self.packs.iter() {
520 pack.register_note_mutation_hook(runtime);
521 }
522 }
523
524 /// Install pack-owned note-search candidate sources before warm-up or
525 /// dispatch, following the same registration timing as mutation hooks.
526 pub fn call_register_note_search_ann_providers(&self, runtime: &KhiveRuntime) {
527 for pack in self.packs.iter() {
528 pack.register_note_search_ann_provider(runtime);
529 }
530 }
531
532 /// Invoke `PackRuntime::register_note_write_validator` on every registered pack.
533 ///
534 /// Called by the transport during startup with the same timing as
535 /// `call_register_note_mutation_hooks`, so note-write validation is active
536 /// at the runtime layer for every write path — the generic `create` verb,
537 /// direct Rust callers, and proposal apply, none of which dispatch a pack
538 /// hook of their own on the note-write.
539 pub fn call_register_note_write_validators(&self, runtime: &KhiveRuntime) {
540 for pack in self.packs.iter() {
541 pack.register_note_write_validator(runtime);
542 }
543 }
544
545 /// Invoke `PackRuntime::warm` on every registered pack.
546 /// Called by the daemon at boot (in a background task) so expensive in-memory
547 /// state (ANN indexes) is pre-loaded without blocking request serving.
548 pub async fn call_warm_all(&self) {
549 for pack in self.packs.iter() {
550 pack.warm().await;
551 }
552 }
553
554 /// Resolve the presentation policy for a verb name.
555 ///
556 /// Uses the first registered handler (including subhandlers) with this name
557 /// and returns its declared [`VerbPresentationPolicy`].
558 /// Returns `Standard` for unknown verbs — unknown verbs will fail at
559 /// dispatch anyway, so the fallback here is safe.
560 pub fn presentation_policy_for(&self, verb: &str) -> khive_types::VerbPresentationPolicy {
561 self.handler_by_name
562 .get(verb)
563 .map_or(khive_types::VerbPresentationPolicy::Standard, |handler| {
564 handler.presentation_policy()
565 })
566 }
567
568 /// Resolve the declared [`VerbCategory`] for a verb name.
569 ///
570 /// Uses the first registered handler (including subhandlers) with this name
571 /// and returns its speech-act category. Returns `None` for
572 /// an unregistered verb name, so a caller deciding transport-level
573 /// behavior (e.g. whether a post-dispatch condition is safe to retry)
574 /// can fail closed on an unknown verb instead of guessing a category.
575 pub fn verb_category(&self, verb: &str) -> Option<VerbCategory> {
576 self.handler_by_name
577 .get(verb)
578 .map(|handler| handler.category)
579 }
580
581 /// Verbs classified [`VerbCategory::Assertive`] that nonetheless schedule
582 /// can schedule a persisted write on a successful dispatch, so a caller re-issuing
583 /// a call in this list after a lost response duplicates that write:
584 ///
585 /// - `memory.recall` schedules `brain.record_serve`, which inserts a
586 /// serve-ledger row keyed in part on a `served_at` timestamp captured
587 /// fresh at dispatch time — a second dispatch inserts a second row
588 /// rather than colliding with the first.
589 /// - `search` (the `kg` pack's bare verb) appends a `search_executed`
590 /// event with a freshly generated id and no natural key at all.
591 /// - `telemetry.emit` can append a durable stream record with a fresh
592 /// identity and sequence, depending on the configured channel policy.
593 /// - `tool.check` appends a `tool_check_decided` receipt with a fresh
594 /// event id for every evaluated decision (ADR-180 Amendment 6).
595 ///
596 /// The speech-act category alone cannot rule this out — it describes
597 /// what the verb tells the *caller*, not what it schedules against
598 /// storage. Adding a verb here (or removing one because its side effect
599 /// was made idempotent) is a correctness decision requiring the same
600 /// scrutiny as the categorization itself.
601 pub const SIDE_EFFECTING_ASSERTIVE_VERBS: &'static [&'static str] =
602 &["memory.recall", "search", "telemetry.emit", "tool.check"];
603
604 /// Whether a response lost to the daemon frame budget may be truthfully
605 /// advertised as safe to re-issue: the verb is [`VerbCategory::Assertive`]
606 /// (no institutional commitment was made) and is not on
607 /// `Self::SIDE_EFFECTING_ASSERTIVE_VERBS` (no persisted write to
608 /// duplicate on a second dispatch). An unregistered verb name resolves to
609 /// `None` from [`Self::verb_category`] and fails closed here.
610 ///
611 /// Used only by the MCP daemon's frame-budget omission decision; never
612 /// for permission checking or return-shape selection.
613 pub fn is_retry_safe_after_frame_omission(&self, verb: &str) -> bool {
614 matches!(self.verb_category(verb), Some(VerbCategory::Assertive))
615 && !Self::SIDE_EFFECTING_ASSERTIVE_VERBS.contains(&verb)
616 }
617
618 /// Returns `true` if the named verb exists and is tagged
619 /// `Visibility::Subhandler` (internal / operator-only).
620 ///
621 /// Used by the MCP server to gate subhandler invocation at the wire
622 /// boundary without blocking internal callers that invoke the same verbs
623 /// through the runtime directly.
624 pub fn is_subhandler_verb(&self, verb: &str) -> bool {
625 self.handler_by_name
626 .get(verb)
627 .is_some_and(|handler| matches!(handler.visibility, Visibility::Subhandler))
628 }
629
630 /// Apply all non-empty pack-auxiliary schema plans to the given backend.
631 ///
632 /// This is the centralized startup hook that replaced the previous lazy
633 /// per-pack self-bootstrap pattern. Each pack's `SchemaPlan` carries
634 /// idempotent `CREATE TABLE IF NOT EXISTS` DDL; calling this more than once
635 /// is safe. Plans with neither SQL nor column upgrades are skipped.
636 ///
637 /// Errors from individual plans are logged via `tracing::warn!` and not
638 /// propagated so that a single pack's schema failure does not prevent the
639 /// rest from loading. Serving hosts must instead use the fallible
640 /// [`Self::apply_schema_plans_with_map`] (with an empty map for one backend)
641 /// so a required schema failure cannot leave a pack's verbs unavailable.
642 pub fn apply_schema_plans(&self, backend: &khive_db::StorageBackend) {
643 if backend.is_read_only() {
644 tracing::info!(
645 "skipping pack schema plans because the backend is read-only; snapshot schema is used as-is"
646 );
647 return;
648 }
649 for (plan, additions) in self.all_schema_plans_with_columns() {
650 if plan.is_empty() && additions.is_empty() {
651 continue;
652 }
653 if let Err(e) =
654 backend.apply_pack_ddl_statements_with_columns(plan.statements, additions)
655 {
656 tracing::warn!(
657 pack = plan.pack,
658 error = %e,
659 "failed to apply pack schema plan at startup (non-fatal)"
660 );
661 }
662 }
663 }
664
665 /// Pack-auxiliary schema plans with their owning pack names.
666 ///
667 /// Returns `(pack_name, SchemaPlan)` pairs for every registered pack.
668 /// Used by the multi-backend boot path to apply each plan to the pack's
669 /// assigned backend rather than a single shared backend. Direct schema
670 /// application must use [`Self::all_schema_plans_with_columns`] so column
671 /// upgrades are retained.
672 pub fn all_schema_plans_named(&self) -> Vec<(&'static str, SchemaPlan)> {
673 self.packs
674 .iter()
675 .map(|p| {
676 let plan = p.schema_plan();
677 (plan.pack, plan)
678 })
679 .collect()
680 }
681
682 /// Apply pack-auxiliary schema plans using a per-pack backend map.
683 ///
684 /// For each plan and its owning pack's column additions, applies the full
685 /// plan to `backend_for_pack[plan.pack]` when present,
686 /// falling back to `default_backend` for any pack not in the map.
687 ///
688 /// Returns an error when two packs on the same backend declare the same
689 /// auxiliary table (ADR-028 §7 collision policy: boot failure naming both
690 /// packs and the conflicting table).
691 ///
692 /// Both single- and multi-backend hosts use this boot path (ADR-028).
693 /// An empty map selects the default backend for every pack. Read-only
694 /// backends validate declared columns without applying SQL or acquiring a
695 /// writer; missing or incompatible columns refuse boot with the pack name.
696 pub fn apply_schema_plans_with_map(
697 &self,
698 backend_for_pack: &HashMap<&str, &khive_db::StorageBackend>,
699 default_backend: &khive_db::StorageBackend,
700 ) -> Result<(), crate::PackSchemaCollisionError> {
701 // Track which pack first claimed each table on each backend.
702 // Backend identity is the raw pointer of the underlying connection pool Arc.
703 let mut claimed: HashMap<(*const (), String), &'static str> = HashMap::new();
704
705 let plans = self.all_schema_plans_with_columns();
706 // Check every declaration before applying any pack DDL. A collision
707 // must not leave earlier plans installed on a failed boot.
708 for (plan, additions) in &plans {
709 if plan.is_empty() && additions.is_empty() {
710 continue;
711 }
712 let pack_name = plan.pack;
713 let backend = backend_for_pack
714 .get(pack_name)
715 .copied()
716 .unwrap_or(default_backend);
717 let backend_ptr = std::sync::Arc::as_ptr(&backend.pool_arc()) as *const ();
718
719 // Collect DDL table ownership for the full plan set.
720 for stmt in plan.statements {
721 for table_name in extract_table_names(stmt) {
722 let key = (backend_ptr, table_name.clone());
723 match claimed.entry(key) {
724 std::collections::hash_map::Entry::Vacant(e) => {
725 e.insert(pack_name);
726 }
727 std::collections::hash_map::Entry::Occupied(e) => {
728 let prior_pack = *e.get();
729 return Err(crate::PackSchemaCollisionError {
730 pack_a: prior_pack,
731 pack_b: pack_name,
732 table: table_name,
733 });
734 }
735 }
736 }
737 }
738 for addition in *additions {
739 let table_name = addition.table.to_ascii_lowercase();
740 let key = (backend_ptr, table_name.clone());
741 match claimed.entry(key) {
742 std::collections::hash_map::Entry::Vacant(entry) => {
743 entry.insert(pack_name);
744 }
745 std::collections::hash_map::Entry::Occupied(entry) => {
746 let prior_pack = *entry.get();
747 // A pack's full CREATE and its upgrades declare the
748 // same table; this is one ownership claim.
749 if prior_pack != pack_name {
750 return Err(crate::PackSchemaCollisionError {
751 pack_a: prior_pack,
752 pack_b: pack_name,
753 table: table_name,
754 });
755 }
756 }
757 }
758 }
759 }
760
761 for (plan, additions) in plans {
762 if plan.is_empty() && additions.is_empty() {
763 continue;
764 }
765 let pack_name = plan.pack;
766 let backend = backend_for_pack
767 .get(pack_name)
768 .copied()
769 .unwrap_or(default_backend);
770 if backend.is_read_only() {
771 backend.validate_pack_schema_columns(additions).map_err(|error| {
772 crate::PackSchemaCollisionError {
773 pack_a: pack_name,
774 pack_b: pack_name,
775 table: format!("read-only schema validation failed: {error}; open the database writable to apply the pack schema upgrade"),
776 }
777 })?;
778 continue;
779 }
780
781 backend
782 .apply_pack_ddl_statements_with_columns(plan.statements, additions)
783 .map_err(|e| crate::PackSchemaCollisionError {
784 pack_a: pack_name,
785 pack_b: pack_name,
786 table: format!("DDL error: {e}"),
787 })?;
788 }
789 Ok(())
790 }
791}