khive_types/pack.rs
1//! Pack trait — the declarative composition unit for khive.
2//!
3//! A pack declares vocabulary (note kinds, entity kinds), verbs, and edge
4//! endpoint rules. This is purely static metadata — no I/O, no async.
5//! Runtime dispatch lives in `khive-runtime` (`PackRuntime` trait +
6//! `VerbRegistry`).
7//!
8//! This trait lives in khive-types (no_std, zero deps) so downstream crates
9//! can reference pack metadata without pulling in the full runtime.
10
11use crate::edge::EdgeRelation;
12
13/// Visibility tier for a handler.
14///
15/// `Verb` entries appear on the MCP wire and are invokable by agents.
16/// `Subhandler` entries are internal — callable by the operator via CLI
17/// but not surfaced as top-level MCP verbs.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum Visibility {
20 /// Externally invokable via MCP `request` tool.
21 Verb,
22 /// Internal — operator-only via `kkernel call <pack> <handler>`.
23 Subhandler,
24}
25
26/// Illocutionary force classification for a verb handler.
27///
28/// Follows Searle's five speech-act categories (1976). Every `Visibility::Verb`
29/// handler in the MCP surface MUST carry a category. `Subhandler` entries may
30/// use the category of their parent verb or `Assertive` as a sensible default.
31///
32/// The category is a documentation / introspection tag. It is NOT used for
33/// permission checking, transport routing, or return-shape selection.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum VerbCategory {
36 /// Speaker represents a state of affairs — retrieves and presents facts.
37 /// Examples: `get`, `list`, `search`, `recall`.
38 Assertive,
39 /// Speaker attempts to get the hearer to do something.
40 /// Examples: `assign`, `transition`.
41 Directive,
42 /// Speaker commits to a persistent change.
43 /// Examples: `create`, `remember`, `link`, `send`.
44 Commissive,
45 /// Speaker changes institutional status by fiat.
46 /// Examples: `update`, `delete`, `merge`, `complete`.
47 Declaration,
48 // `Expressive` is intentionally absent — no verb currently uses it.
49}
50
51/// Parameter type for `help=true` schema envelopes.
52///
53/// Declares the name, type hint, required flag, and one-line description for
54/// a single verb parameter. Stored as a `&'static` slice on [`HandlerDef`] so
55/// the registry can return it without any allocation at call time.
56///
57/// The `param_type` field is a free-form string (e.g. `"string"`, `"uuid"`,
58/// `"bool"`, `"integer"`, `"string | null"`) — it is documentation-only and
59/// not used for validation.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub struct ParamDef {
62 /// Parameter name as used in the DSL (e.g. `"id"`, `"kind"`, `"query"`).
63 pub name: &'static str,
64 /// Free-form type hint for documentation (e.g. `"string"`, `"uuid"`, `"bool"`).
65 pub param_type: &'static str,
66 /// Whether the caller must supply this parameter.
67 pub required: bool,
68 /// One-line human-readable description.
69 pub description: &'static str,
70}
71
72/// Handler metadata for discovery and documentation.
73///
74/// Replaces the previous `VerbDef`. Every entry carries a `visibility` tag
75/// so the registry can separate the MCP-exposed surface from internal handlers,
76/// and a `category` that classifies the illocutionary force of the verb
77/// per the speech-act taxonomy.
78///
79/// The `params` slice is used by `VerbRegistry::describe_verb` to build the
80/// `help=true` schema envelope. Packs that predate this field leave it empty
81/// (`&[]`) which is backward-compatible — callers receive a schema envelope
82/// with zero params rather than an error.
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct HandlerDef {
85 pub name: &'static str,
86 pub description: &'static str,
87 pub visibility: Visibility,
88 /// Illocutionary force classification. Use `Assertive` for `Subhandler`
89 /// entries that have no external callers.
90 pub category: VerbCategory,
91 /// Parameter schema for `help=true` introspection (issue #287).
92 ///
93 /// Empty (`&[]`) is the correct default for handlers that predate this
94 /// field or have no fixed parameter schema (e.g. free-form query verbs).
95 pub params: &'static [ParamDef],
96}
97
98/// Presentation override for a verb handler.
99///
100/// Most verbs use the default `Standard` policy which allows the caller's
101/// requested `PresentationMode` to apply. A small set declare `AlwaysVerbose`
102/// because Agent-mode trimming (UUID shortening, empty-field dropping) would
103/// corrupt their response for downstream chaining — e.g. `get` returns UUIDs
104/// that callers pipe into `link`; shortening them here breaks the chain.
105///
106/// The policy is carried as a `const` in [`HandlerDef`] so the registry can
107/// consult it before applying the presentation transform.
108#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
109pub enum VerbPresentationPolicy {
110 /// Apply the caller's requested `PresentationMode` unchanged.
111 #[default]
112 Standard,
113 /// Always use `Verbose` output regardless of the caller's mode.
114 ///
115 /// Declared verbs: `get`, `link`, `query`, `traverse`, `neighbors`,
116 /// `brain.feedback`.
117 ///
118 /// `link` is included because the returned edge ID is the only handle for
119 /// follow-up `neighbors`/`traverse` calls; short-form IDs risk prefix
120 /// collision at scale (~65K edges can share an 8-char prefix).
121 ///
122 /// `brain.feedback` is included because callers chain `target_id` from the
123 /// response back into subsequent feedback or profile queries; an 8-char
124 /// prefix is ambiguous and defeats the acknowledged-ID contract (#545).
125 AlwaysVerbose,
126}
127
128impl HandlerDef {
129 /// Resolve the presentation policy for this handler.
130 ///
131 /// Returns [`VerbPresentationPolicy::AlwaysVerbose`] for verbs whose
132 /// semantics demand full output (full UUIDs, complete timestamps) regardless
133 /// of the caller's requested presentation mode.
134 ///
135 /// New verbs that need this override must be added here; omission from the
136 /// list means `Standard` applies.
137 pub fn presentation_policy(&self) -> VerbPresentationPolicy {
138 // AlwaysVerbose verbs bypass agent-mode transforms entirely.
139 //
140 // `link` is AlwaysVerbose because the edge ID returned is the only handle
141 // for follow-up `neighbors`/`traverse` calls. At scale, two edges can share
142 // the same 8-char prefix (birthday collision ~65K edges), so shortening the
143 // edge ID in agent mode breaks downstream chaining.
144 match self.name {
145 "get" | "link" | "query" | "traverse" | "neighbors" | "brain.feedback" => {
146 VerbPresentationPolicy::AlwaysVerbose
147 }
148 _ => VerbPresentationPolicy::Standard,
149 }
150 }
151}
152
153/// Backward-compatible type alias. Existing code that names `VerbDef` still
154/// compiles; new code should use `HandlerDef` directly.
155#[deprecated(since = "0.2.0", note = "Use HandlerDef instead")]
156pub type VerbDef = HandlerDef;
157
158/// Match spec for one end of an [`EdgeEndpointRule`].
159///
160/// Identifies a substrate + kind pair that the rule applies to. Note that
161/// `kind` strings refer to the pack-declared note kinds / entity kinds — not
162/// the closed [`EdgeRelation`] set, which is universal.
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164pub enum EndpointKind {
165 /// A note whose `kind` field equals the given string (e.g. `"task"`).
166 NoteOfKind(&'static str),
167 /// An entity whose `kind` field equals the given string (e.g. `"concept"`).
168 EntityOfKind(&'static str),
169}
170
171/// A pack-declared endpoint rule for a specific edge relation.
172///
173/// Rules are **additive**: they extend the set of allowed
174/// `(source, relation, target)` triples beyond the base contract.
175/// Packs cannot tighten the base rules — only broaden them. The closed
176/// [`EdgeRelation`] taxonomy itself is not extended; only the endpoint
177/// contract per relation is.
178///
179/// Example — GTD pack allows `depends_on` between task notes:
180///
181/// ```ignore
182/// EdgeEndpointRule {
183/// relation: EdgeRelation::DependsOn,
184/// source: EndpointKind::NoteOfKind("task"),
185/// target: EndpointKind::NoteOfKind("task"),
186/// }
187/// ```
188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189pub struct EdgeEndpointRule {
190 pub relation: EdgeRelation,
191 pub source: EndpointKind,
192 pub target: EndpointKind,
193}
194
195/// Lifecycle specification for a note kind.
196///
197/// Declares which field holds the kind's domain state, the initial value,
198/// terminal values, and allowed transitions. The runtime uses this to
199/// validate lifecycle operations at the verb boundary without hard-coding
200/// kind-specific logic in the shared CRUD path.
201///
202/// Phase 1 (current): packs declare the spec; the runtime records it for
203/// documentation and future enforcement.
204/// Phase 2 (future): the runtime uses `field` to route lifecycle writes
205/// to a first-class column rather than `properties`.
206#[derive(Clone, Debug, PartialEq, Eq)]
207pub struct NoteLifecycleSpec {
208 /// The field name that holds the kind's lifecycle state.
209 ///
210 /// Use `"kind_status"` for pack-owned lifecycle fields to avoid the
211 /// semantic collision with `Note.status` (NoteStatus).
212 pub field: &'static str,
213 /// The value assigned when a note of this kind is first created.
214 pub initial: &'static str,
215 /// Values from which no further transitions are possible.
216 pub terminal: &'static [&'static str],
217 /// Allowed `(from, to)` transitions. `"*"` as `from` matches any state.
218 pub transitions: &'static [(&'static str, &'static str)],
219}
220
221/// Kind-level schema specification for a note kind.
222///
223/// Each pack-registered note kind may declare a `NoteKindSpec` to describe
224/// its lifecycle semantics. The runtime collects these at boot time via
225/// [`Pack::NOTE_KIND_SPECS`] for documentation, introspection, and future
226/// enforcement.
227#[derive(Clone, Debug, PartialEq, Eq)]
228pub struct NoteKindSpec {
229 /// The note kind string this spec governs (e.g. `"task"`).
230 pub kind: &'static str,
231 /// Alternate names this kind accepts on the wire.
232 pub aliases: &'static [&'static str],
233 /// Lifecycle state machine for this kind.
234 pub lifecycle: NoteLifecycleSpec,
235}
236
237/// DDL statements the pack needs applied to the auxiliary schema.
238///
239/// Pack-auxiliary tables use idempotent `CREATE TABLE IF NOT EXISTS`; they are
240/// not part of the core versioned migration chain. The runtime applies these
241/// statements once at pack registration time (or startup) against the active
242/// storage backend.
243#[derive(Clone, Debug, PartialEq, Eq)]
244pub struct PackSchemaPlan {
245 /// The pack this schema plan belongs to (used for error reporting).
246 pub pack: &'static str,
247 /// Idempotent SQL statements to apply.
248 pub statements: &'static [&'static str],
249}
250
251/// A composable module that contributes vocabulary, verbs, and edge endpoint
252/// rules to the khive runtime.
253///
254/// Packs declare what entity kinds, note kinds, and verbs they introduce, and
255/// optionally extend the per-relation endpoint contract via [`EDGE_RULES`].
256/// The runtime merges vocabularies from all loaded packs and rejects
257/// unregistered kinds at the service boundary.
258///
259/// The closed [`EdgeRelation`] enum is not extensible — only its
260/// per-relation endpoint contract is extensible by packs.
261///
262/// [`EDGE_RULES`]: Pack::EDGE_RULES
263pub trait Pack {
264 /// Short identifier for this pack (e.g. "kg", "tasks").
265 const NAME: &'static str;
266
267 /// Note kinds this pack contributes to the runtime vocabulary.
268 const NOTE_KINDS: &'static [&'static str];
269
270 /// Entity kinds this pack contributes to the runtime vocabulary.
271 const ENTITY_KINDS: &'static [&'static str];
272
273 /// Handlers this pack registers.
274 ///
275 /// The runtime routes verb calls to the pack that declares them.
276 /// Only entries with `visibility: Visibility::Verb` are surfaced on the
277 /// MCP wire; `Visibility::Subhandler` entries are internal.
278 const HANDLERS: &'static [HandlerDef];
279
280 /// Additional edge endpoint rules this pack contributes.
281 ///
282 /// Defaults to empty — packs that introduce no new endpoint pairs (or
283 /// only rely on the base endpoint contract) can ignore this.
284 const EDGE_RULES: &'static [EdgeEndpointRule] = &[];
285
286 /// Other pack names whose vocabulary this pack references.
287 ///
288 /// The runtime checks that every name in `REQUIRES` appears in the
289 /// loaded pack set before any pack is registered. Defaults to empty
290 /// so existing packs compile without changes.
291 const REQUIRES: &'static [&'static str] = &[];
292
293 /// Lifecycle and schema specs for note kinds this pack owns.
294 ///
295 /// Packs that introduce note kinds with explicit lifecycle semantics
296 /// (e.g. GTD's `task` kind) declare the spec here. The runtime collects
297 /// these at boot time for introspection and future enforcement. Defaults
298 /// to empty so existing packs compile without changes.
299 const NOTE_KIND_SPECS: &'static [NoteKindSpec] = &[];
300
301 /// Pack-auxiliary schema plan.
302 ///
303 /// Packs that need their own auxiliary tables (e.g. GTD's
304 /// `gtd_lifecycle_audit`) declare idempotent DDL statements here.
305 /// The runtime applies them once at registration time. Defaults to
306 /// `None` so packs with no auxiliary schema cost nothing.
307 const SCHEMA_PLAN: Option<PackSchemaPlan> = None;
308
309 /// Validation rule IDs contributed by this pack.
310 ///
311 /// Rule IDs are namespaced by pack name: `<pack-name>/<rule-id>`.
312 /// The runtime merges rule IDs from all packs; the actual rule
313 /// implementations live in `khive-runtime::validation::ValidationRule`
314 /// (not in `khive-types`, which stays `no_std`). This const serves as
315 /// the declarative catalog of rule identifiers so the validation
316 /// infrastructure can enumerate what rules a pack claims without
317 /// loading the runtime.
318 ///
319 /// Defaults to empty — packs with no domain-specific validation rules
320 /// can leave this unset.
321 const VALIDATION_RULES: &'static [&'static str] = &[];
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 struct TestPack;
329
330 impl Pack for TestPack {
331 const NAME: &'static str = "test";
332 const NOTE_KINDS: &'static [&'static str] = &["memo"];
333 const ENTITY_KINDS: &'static [&'static str] = &["widget"];
334 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
335 name: "do_thing",
336 description: "does a thing",
337 visibility: Visibility::Verb,
338 category: VerbCategory::Commissive,
339 params: &[],
340 }];
341 }
342
343 #[test]
344 fn pack_trait_compiles() {
345 assert_eq!(TestPack::NAME, "test");
346 assert_eq!(TestPack::NOTE_KINDS, &["memo"]);
347 assert_eq!(TestPack::ENTITY_KINDS, &["widget"]);
348 assert_eq!(TestPack::HANDLERS.len(), 1);
349 assert_eq!(TestPack::HANDLERS[0].name, "do_thing");
350 assert_eq!(TestPack::HANDLERS[0].visibility, Visibility::Verb);
351 assert_eq!(TestPack::HANDLERS[0].category, VerbCategory::Commissive);
352 }
353
354 #[test]
355 fn verb_category_variants_exist() {
356 // Just ensuring the enum variants are accessible — no runtime assertion
357 // needed beyond confirming they exist at compile time.
358 let _ = VerbCategory::Assertive;
359 let _ = VerbCategory::Directive;
360 let _ = VerbCategory::Commissive;
361 let _ = VerbCategory::Declaration;
362 }
363
364 #[test]
365 fn pack_validation_rules_default_empty() {
366 assert!(TestPack::VALIDATION_RULES.is_empty());
367 }
368
369 // `link` must be AlwaysVerbose so edge IDs are not shortened.
370 #[test]
371 fn link_handler_is_always_verbose() {
372 let link_def = HandlerDef {
373 name: "link",
374 description: "Create a typed directed edge",
375 visibility: Visibility::Verb,
376 category: VerbCategory::Commissive,
377 params: &[],
378 };
379 assert_eq!(
380 link_def.presentation_policy(),
381 VerbPresentationPolicy::AlwaysVerbose,
382 "link must be AlwaysVerbose"
383 );
384 }
385
386 // AlwaysVerbose set regression: ensure get/query/traverse/neighbors/brain.feedback remain.
387 #[test]
388 fn always_verbose_set_contains_expected_verbs() {
389 let always_verbose = [
390 "get",
391 "link",
392 "query",
393 "traverse",
394 "neighbors",
395 "brain.feedback",
396 ];
397 for name in always_verbose {
398 let h = HandlerDef {
399 name,
400 description: "",
401 visibility: Visibility::Verb,
402 category: VerbCategory::Assertive,
403 params: &[],
404 };
405 assert_eq!(
406 h.presentation_policy(),
407 VerbPresentationPolicy::AlwaysVerbose,
408 "{name:?} must be AlwaysVerbose"
409 );
410 }
411 }
412
413 // Standard policy for all other verbs.
414 #[test]
415 fn non_verbose_verbs_are_standard_policy() {
416 let standard = [
417 "create", "list", "update", "delete", "search", "recall", "remember",
418 ];
419 for name in standard {
420 let h = HandlerDef {
421 name,
422 description: "",
423 visibility: Visibility::Verb,
424 category: VerbCategory::Commissive,
425 params: &[],
426 };
427 assert_eq!(
428 h.presentation_policy(),
429 VerbPresentationPolicy::Standard,
430 "{name:?} must be Standard (not AlwaysVerbose)"
431 );
432 }
433 }
434}