Skip to main content

khive_types/
pack.rs

1//! Pack trait — the declarative composition unit for khive.
2//!
3//! A pack declares vocabulary (note kinds, entity kinds, brain consumer
4//! kinds), verbs, and edge endpoint rules. This is purely static metadata —
5//! no I/O, no async.
6//! Runtime dispatch lives in `khive-runtime` (`PackRuntime` trait +
7//! `VerbRegistry`).
8//!
9//! This trait lives in khive-types (no_std, zero deps) so downstream crates
10//! can reference pack metadata without pulling in the full runtime.
11
12use crate::edge::EdgeRelation;
13use crate::entity_type::EntityTypeDef;
14
15/// Argument names owned by the outer request envelope rather than verb handlers.
16///
17/// Handler metadata must not advertise these names because every request parser rejects them
18/// before pack dispatch. Keeping this list in the shared type crate lets both request parsing and
19/// registry construction enforce one exact contract.
20pub const RESERVED_ENVELOPE_ARGS: &[&str] = &["presentation", "presentation_per_op"];
21
22/// Tag marking a `tool` pack registry object.
23///
24/// The value is on-disk data on every row the tool registry has ever minted,
25/// so it is not free to change.
26pub const TOOL_REGISTRY_TAG: &str = "tool-registry";
27
28/// Tags whose presence makes an entity a pack's registry row.
29///
30/// Such a row is not ordinary metadata: its fields are policy inputs. The
31/// tool registry's `source` names the binary a granted tool name resolves to,
32/// and `side_effect` is read at run time and handed to the policy decision, so
33/// a caller who can patch the row can change what a granted name does without
34/// registering anything. The generic entity verbs therefore refuse to write a
35/// row carrying one of these tags, including a patch that would remove the tag
36/// itself, and the owning pack's verbs are the only writer.
37///
38/// This is a list rather than a field allow-list on purpose: a list of
39/// protected properties goes stale the first time a pack adds a
40/// capability-bearing property, which is exactly how `side_effect` was missed.
41pub const PACK_REGISTRY_TAGS: &[&str] = &[TOOL_REGISTRY_TAG];
42
43/// Canonical registry tag carried by a row, including legacy case variants.
44/// Storage name resolution folds tags to lowercase, so generic write guards
45/// must use the same comparison or a differently cased tag can bypass them.
46pub fn pack_registry_tag(tag: &str) -> Option<&'static str> {
47    PACK_REGISTRY_TAGS
48        .iter()
49        .copied()
50        .find(|registered| registered.eq_ignore_ascii_case(tag))
51}
52
53/// Visibility tier for a handler.
54///
55/// `Verb` entries appear on the MCP wire and are invokable by agents.
56/// `Subhandler` entries are internal — callable by the operator via CLI
57/// but not surfaced as top-level MCP verbs.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum Visibility {
60    /// Externally invokable via MCP `request` tool.
61    Verb,
62    /// Internal — operator-only via `kkernel exec '<pack>.<handler>(...)'`.
63    Subhandler,
64}
65
66/// Illocutionary force classification for a verb handler.
67///
68/// Follows Searle's five speech-act categories (1976). Every `Visibility::Verb`
69/// handler in the MCP surface MUST carry a category. `Subhandler` entries may
70/// use the category of their parent verb or `Assertive` as a sensible default.
71///
72/// The category is a documentation / introspection tag. It is NOT used for
73/// permission checking. It is one input — never the sole proof — to two
74/// narrow, sanctioned runtime decisions, both in `khive-runtime`'s
75/// `VerbRegistry`:
76/// - `admission_degrade_safe` treats `Assertive` as a necessary condition
77///   for letting a dispatch's own audit row degrade under transient
78///   admission pressure, combined with an explicit per-verb allowlist.
79/// - `is_retry_safe_after_frame_omission` treats `Assertive` as a necessary
80///   condition for telling a caller that a response lost to the MCP
81///   daemon's frame budget is safe to re-issue, combined with an explicit
82///   exclusion list.
83///
84/// Both combine the category with an audited list rather than trusting it
85/// alone, because several `Assertive` handlers schedule their own persisted
86/// or accounting-bearing side effect on every dispatch (`memory.recall`'s
87/// serve ledger, `search`'s `SearchExecuted` telemetry) that the speech-act
88/// classification cannot see.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum VerbCategory {
91    /// Speaker represents a state of affairs — retrieves and presents facts.
92    /// Examples: `get`, `list`, `search`, `recall`.
93    Assertive,
94    /// Speaker attempts to get the hearer to do something.
95    /// Examples: `assign`, `transition`.
96    Directive,
97    /// Speaker commits to a persistent change.
98    /// Examples: `create`, `remember`, `link`, `send`.
99    Commissive,
100    /// Speaker changes institutional status by fiat.
101    /// Examples: `update`, `delete`, `merge`, `complete`.
102    Declaration,
103    // `Expressive` is intentionally absent — no verb currently uses it.
104}
105
106/// How a `uuid` or `array of uuid` parameter resolves a caller-supplied
107/// identifier — full UUID and short hex-prefix acceptance, and whether
108/// either form is checked against a namespace. This is a property of the
109/// handler's own resolution code, declared explicitly per parameter so
110/// `VerbRegistry::describe_verb`'s rendered contract can never drift from
111/// what the handler actually does (see khive-runtime's `pack.rs`
112/// `IdResolutionMode` rendering table for the exact wording per variant).
113///
114/// Every variant here must correspond to a resolver function that actually
115/// exists in the codebase — this is not a place to guess. When a new `uuid`
116/// parameter is added, its handler must be read to determine which existing
117/// variant matches (or whether a new one is needed); the parameter name
118/// alone never determines the mode.
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
120pub enum IdResolutionMode {
121    /// Not a UUID-typed identifier parameter — no resolver contract applies.
122    #[default]
123    NotApplicable,
124    /// ADR-007 Rev 6 by-ID contract: a full UUID and a short hex prefix both
125    /// resolve with no namespace predicate at all. The Gate, not
126    /// storage-layer filtering, is the authorization seam. Used by `get`,
127    /// `update`, `delete`, `merge`, `link`'s endpoint parameters, GTD's
128    /// lifecycle `id` parameters, and brain's feedback `target_id`.
129    UnscopedById,
130    /// A full UUID resolves as given, with no namespace check performed by
131    /// the resolver. A short hex prefix is resolved by searching only the
132    /// caller's primary namespace. Used by graph-read anchors
133    /// (`neighbors`/`traverse`) and citation targets.
134    PrefixScopedToPrimary,
135    /// Both a full UUID and a short hex prefix are validated against the
136    /// caller's primary namespace: a record that exists but belongs to a
137    /// different namespace resolves as not found.
138    FullAndPrefixScopedToPrimary,
139    /// Only a full UUID is accepted — a short hex prefix is rejected
140    /// outright — and the UUID is validated against the caller's own
141    /// (primary) namespace.
142    FullUuidOnlyScopedToPrimary,
143    /// Only a full UUID is accepted — a short hex prefix is rejected
144    /// outright — and no namespace check is performed by the resolver
145    /// itself (any namespace scoping comes from the enclosing operation,
146    /// not from this parameter).
147    UnscopedFullUuidOnly,
148    /// `list.target_id`: edge lists resolve full UUIDs as given and prefixes
149    /// or entity names in the primary namespace; event lists accept only an
150    /// exact full subject UUID, with visibility applied to event rows.
151    EdgeOrEventTarget,
152}
153
154/// Parameter type for `help=true` schema envelopes.
155///
156/// Declares the name, type hint, required flag, and one-line description for
157/// a single verb parameter. Stored as a `&'static` slice on [`HandlerDef`] so
158/// the registry can return it without any allocation at call time.
159///
160/// The `param_type` field is drawn from a closed vocabulary (e.g. `"string"`,
161/// `"uuid"`, `"boolean"`, `"integer"`), asserted as an exact set by a
162/// registry-wide test. It does not validate the argument, but it is not
163/// documentation-only either: the runtime derives each verb's published JSON
164/// Schema from it, so an unmapped spelling withholds that verb's schema.
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub struct ParamDef {
167    /// Parameter name as used in the DSL (e.g. `"id"`, `"kind"`, `"query"`).
168    pub name: &'static str,
169    /// Type hint from the closed vocabulary (e.g. `"string"`, `"uuid"`, `"boolean"`).
170    pub param_type: &'static str,
171    /// Whether the caller must supply this parameter.
172    pub required: bool,
173    /// One-line human-readable description.
174    pub description: &'static str,
175    /// Identifier-resolution mode for `param_type` `"uuid"` or `"array of
176    /// uuid"` parameters; [`IdResolutionMode::NotApplicable`] for every
177    /// other parameter type.
178    pub resolution_mode: IdResolutionMode,
179}
180
181/// Handler metadata for discovery and documentation.
182///
183/// Replaces the previous `VerbDef`. Every entry carries a `visibility` tag
184/// so the registry can separate the MCP-exposed surface from internal handlers,
185/// and a `category` that classifies the illocutionary force of the verb
186/// per the speech-act taxonomy.
187///
188/// The `params` slice is used by `VerbRegistry::describe_verb` to build the
189/// `help=true` schema envelope. Packs that predate this field leave it empty
190/// (`&[]`) which is backward-compatible — callers receive a schema envelope
191/// with zero params rather than an error.
192#[derive(Clone, Debug, PartialEq, Eq)]
193pub struct HandlerDef {
194    pub name: &'static str,
195    pub description: &'static str,
196    pub visibility: Visibility,
197    /// Illocutionary force classification. Use `Assertive` for `Subhandler`
198    /// entries that have no external callers.
199    pub category: VerbCategory,
200    /// Parameter schema for `help=true` introspection.
201    ///
202    /// Empty (`&[]`) is the correct default for handlers that predate this
203    /// field or have no fixed parameter schema (e.g. free-form query verbs).
204    pub params: &'static [ParamDef],
205}
206
207/// Presentation override for a verb handler.
208///
209/// Most verbs use the default `Standard` policy which allows the caller's
210/// requested `PresentationMode` to apply.  A small set declare `AlwaysVerbose`
211/// because Agent-mode trimming (UUID shortening, empty-field dropping) would
212/// corrupt their response for downstream chaining — e.g. `get` returns UUIDs
213/// that callers pipe into `link`; shortening them here breaks the chain.
214///
215/// The policy is carried as a `const` in [`HandlerDef`] so the registry can
216/// consult it before applying the presentation transform.
217#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
218pub enum VerbPresentationPolicy {
219    /// Apply the caller's requested `PresentationMode` unchanged.
220    #[default]
221    Standard,
222    /// Always use `Verbose` output regardless of the caller's mode.
223    ///
224    /// Declared verbs: `get`, `link`, `query`, `traverse`, `neighbors`,
225    /// `brain.feedback`, `brain.auto_feedback`, `memory.feedback`,
226    /// `comm.delivered`, `comm.transport_status`, `git.digest`, `git.ingest_cursor`, `schema`.
227    ///
228    /// `link` is included because the returned edge ID is the only handle for
229    /// follow-up `neighbors`/`traverse` calls; short-form IDs risk prefix
230    /// collision at scale (~65K edges can share an 8-char prefix).
231    ///
232    /// `brain.feedback` is included because callers chain `target_id` from the
233    /// response back into subsequent feedback or profile queries; an 8-char
234    /// prefix is ambiguous and defeats the acknowledged-ID contract.
235    /// `memory.feedback` has the same exact-target contract and rejects prefix
236    /// resolution, so its acknowledged `target_id` must remain canonical.
237    /// `brain.auto_feedback` acknowledges the same canonical `target_id` and
238    /// feeds those strict paths, so it carries the same guarantee.
239    /// `comm.delivered` is included because its `id` is an exact correlation
240    /// key and the verb deliberately rejects prefix resolution (#1482).
241    /// `comm.transport_status` preserves the same exact outbound correlation key.
242    ///
243    /// `git.digest` is included because its successful response is also the
244    /// durable receipt payload. Presentation must not shorten `receipt_id` or
245    /// otherwise make the returned result differ from the stored result.
246    /// `git.ingest_cursor` preserves raw checkpoint strings, full project UUIDs,
247    /// and stored microsecond timestamps for persisted-position inspection.
248    /// `schema` preserves empty vocabulary arrays and its complete content-hashed payload.
249    AlwaysVerbose,
250    /// Preserve only the root append receipt's `created_at` string in Agent mode.
251    StreamAppendReceipt,
252    /// Preserve `created_at` and `updated_at` strings on immediate `results`
253    /// array members in Agent mode.
254    StreamBatchReceipts,
255    /// Preserve the root agenda continuation and empty event array in Agent mode.
256    /// Event rows retain the ordinary metadata transform.
257    AgendaContinuation,
258}
259
260impl HandlerDef {
261    /// Resolve the presentation policy for this handler.
262    ///
263    /// Returns [`VerbPresentationPolicy::AlwaysVerbose`] for verbs whose
264    /// semantics demand full output (full UUIDs, complete timestamps) regardless
265    /// of the caller's requested presentation mode.
266    ///
267    /// Stream writers retain exact receipt timestamps while keeping other
268    /// Agent transforms. Unlisted verbs use `Standard`.
269    pub fn presentation_policy(&self) -> VerbPresentationPolicy {
270        match self.name {
271            "get"
272            | "link"
273            | "query"
274            | "traverse"
275            | "neighbors"
276            | "brain.feedback"
277            | "brain.auto_feedback"
278            | "memory.feedback"
279            | "comm.delivered"
280            | "comm.transport_status"
281            | "git.digest"
282            | "git.ingest_cursor"
283            | "schema" => VerbPresentationPolicy::AlwaysVerbose,
284            "stream.append" => VerbPresentationPolicy::StreamAppendReceipt,
285            "stream.batch" => VerbPresentationPolicy::StreamBatchReceipts,
286            "schedule.agenda" => VerbPresentationPolicy::AgendaContinuation,
287            _ => VerbPresentationPolicy::Standard,
288        }
289    }
290}
291
292/// Backward-compatible type alias.  Existing code that names `VerbDef` still
293/// compiles; new code should use `HandlerDef` directly.
294#[deprecated(since = "0.2.0", note = "Use HandlerDef instead")]
295pub type VerbDef = HandlerDef;
296
297/// Match spec for one end of an [`EdgeEndpointRule`].
298///
299/// Identifies a substrate + kind pair that the rule applies to. Note that
300/// `kind` strings refer to the pack-declared note kinds / entity kinds — not
301/// the closed [`EdgeRelation`] set, which is universal.
302#[derive(Clone, Copy, Debug, PartialEq, Eq)]
303pub enum EndpointKind {
304    /// A note whose `kind` field equals the given string (e.g. `"task"`).
305    NoteOfKind(&'static str),
306    /// An entity whose `kind` field equals the given string (e.g. `"concept"`).
307    EntityOfKind(&'static str),
308    /// An entity whose base `kind` AND `entity_type` subtype both match the
309    /// given strings (e.g. `kind: "concept", entity_type: "theorem"`). Both
310    /// fields must match — enforcing the `(EntityKind, entity_type)` registry
311    /// invariant required by ADR-001:102. Required for granular entity subtypes
312    /// (formal-math theorem/definition, AMR gene/drug/pathogen): `EntityOfKind`
313    /// only sees the base kind (`"concept"`), so an `EntityOfKind("theorem")`
314    /// rule is silently inert. Additive — tightens nothing in the closed relation
315    /// set.
316    EntityOfType {
317        /// Base entity kind that must match (e.g. `"concept"`).
318        kind: &'static str,
319        /// Canonical `entity_type` subtype that must match (e.g. `"theorem"`).
320        entity_type: &'static str,
321    },
322}
323
324/// A pack-declared endpoint rule for a specific edge relation.
325///
326/// Rules are **additive**: they extend the set of allowed
327/// `(source, relation, target)` triples beyond the base contract.
328/// Packs cannot tighten the base rules — only broaden them. The closed
329/// [`EdgeRelation`] taxonomy itself is not extended; only the endpoint
330/// contract per relation is.
331///
332/// Example — GTD pack allows `depends_on` between task notes:
333///
334/// ```ignore
335/// EdgeEndpointRule {
336///     relation: EdgeRelation::DependsOn,
337///     source: EndpointKind::NoteOfKind("task"),
338///     target: EndpointKind::NoteOfKind("task"),
339/// }
340/// ```
341#[derive(Clone, Copy, Debug, PartialEq, Eq)]
342pub struct EdgeEndpointRule {
343    pub relation: EdgeRelation,
344    pub source: EndpointKind,
345    pub target: EndpointKind,
346}
347
348/// Lifecycle specification for a note kind.
349///
350/// Declares which field holds the kind's domain state, the initial value,
351/// terminal values, and allowed transitions.  The runtime uses this to
352/// validate lifecycle operations at the verb boundary without hard-coding
353/// kind-specific logic in the shared CRUD path.
354///
355/// Phase 1 (current): packs declare the spec; the runtime records it for
356/// documentation and future enforcement.
357/// Phase 2 (future): the runtime uses `field` to route lifecycle writes
358/// to a first-class column rather than `properties`.
359#[derive(Clone, Debug, PartialEq, Eq)]
360pub struct NoteLifecycleSpec {
361    /// The field name that holds the kind's lifecycle state.
362    ///
363    /// Use `"kind_status"` for pack-owned lifecycle fields to avoid the
364    /// semantic collision with `Note.status` (NoteStatus).
365    pub field: &'static str,
366    /// The value assigned when a note of this kind is first created.
367    pub initial: &'static str,
368    /// Values from which no further transitions are possible.
369    pub terminal: &'static [&'static str],
370    /// Allowed `(from, to)` transitions. `"*"` as `from` matches any state.
371    pub transitions: &'static [(&'static str, &'static str)],
372}
373
374/// Kind-level schema specification for a note kind.
375///
376/// Each pack-registered note kind may declare a `NoteKindSpec` to describe
377/// its lifecycle semantics.  The runtime collects these at boot time via
378/// [`Pack::NOTE_KIND_SPECS`] for documentation, introspection, and future
379/// enforcement.
380#[derive(Clone, Debug, PartialEq, Eq)]
381pub struct NoteKindSpec {
382    /// The note kind string this spec governs (e.g. `"task"`).
383    pub kind: &'static str,
384    /// Alternate names this kind accepts on the wire.
385    pub aliases: &'static [&'static str],
386    /// Lifecycle state machine for this kind.
387    pub lifecycle: NoteLifecycleSpec,
388}
389
390/// Declared ownership policy for one note property.
391///
392/// This metadata does not itself enforce writes or grant authority.
393#[derive(Clone, Copy, Debug, PartialEq, Eq)]
394pub enum PropertyPolicy {
395    /// The substrate or owning pack derives this property from trusted runtime context.
396    Derived,
397    /// Only the owning pack's specialized writer establishes this property.
398    OwnerOnly,
399}
400
401/// Property policy for a note kind declared by the same pack.
402#[derive(Clone, Copy, Debug, PartialEq, Eq)]
403pub struct NotePropertyPolicySpec {
404    /// The owned note kind.
405    pub kind: &'static str,
406    /// The exact property key, without normalization.
407    pub key: &'static str,
408    /// The property's declared ownership policy.
409    pub policy: PropertyPolicy,
410}
411
412/// Which registered embedding spaces a note kind writes by default.
413#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
414pub enum NoteEmbeddingPolicy {
415    #[default]
416    AllModels,
417    DefaultModel,
418}
419
420/// Pack-owned embedding policy for one declared note kind.
421#[derive(Clone, Copy, Debug, PartialEq, Eq)]
422pub struct NoteEmbeddingPolicySpec {
423    pub kind: &'static str,
424    pub policy: NoteEmbeddingPolicy,
425}
426
427/// SQLite storage type for a nullable pack-auxiliary column addition.
428#[derive(Clone, Copy, Debug, PartialEq, Eq)]
429pub enum PackColumnAffinity {
430    /// A nullable `TEXT` column with no default or primary key.
431    Text,
432    /// A nullable `INTEGER` column with no default or primary key.
433    Integer,
434}
435
436/// An idempotent nullable-column addition to a pack-owned auxiliary table.
437///
438/// Identifiers must use ASCII letters, digits, or underscores and cannot start
439/// with a digit. Existing columns must match the declared type and have no
440/// default, primary key, generated expression, or `NOT NULL` constraint. A
441/// compatible partial upgrade completes without changing existing column values.
442/// The pack's full create-table DDL must also include every declared column.
443#[derive(Clone, Copy, Debug, PartialEq, Eq)]
444pub struct PackColumnAddition {
445    /// Pack-owned auxiliary table in the backend's main schema.
446    pub table: &'static str,
447    /// Column to add when absent from an existing table.
448    pub column: &'static str,
449    /// Exact declared SQLite type required for this column.
450    pub affinity: PackColumnAffinity,
451}
452
453/// DDL statements the pack needs applied to the auxiliary schema.
454///
455/// Pack-auxiliary tables use idempotent `CREATE TABLE IF NOT EXISTS`; they are
456/// not part of the core versioned migration chain.  The runtime applies these
457/// statements once at pack registration time (or startup) against the active
458/// storage backend.
459#[derive(Clone, Debug, PartialEq, Eq)]
460pub struct PackSchemaPlan {
461    /// The pack this schema plan belongs to (used for error reporting).
462    pub pack: &'static str,
463    /// Idempotent SQL statements to apply.
464    pub statements: &'static [&'static str],
465}
466
467/// A composable module that contributes vocabulary, verbs, and edge endpoint
468/// rules to the khive runtime.
469///
470/// Packs declare what entity kinds, note kinds, and verbs they introduce, and
471/// optionally extend the per-relation endpoint contract via [`EDGE_RULES`].
472/// The runtime merges vocabularies from all loaded packs and rejects
473/// unregistered kinds at the service boundary.
474///
475/// The closed [`EdgeRelation`] enum is not extensible — only its
476/// per-relation endpoint contract is extensible by packs.
477///
478/// [`EDGE_RULES`]: Pack::EDGE_RULES
479pub trait Pack {
480    /// Short identifier for this pack (e.g. "kg", "tasks").
481    const NAME: &'static str;
482
483    /// Note kinds this pack contributes to the runtime vocabulary.
484    const NOTE_KINDS: &'static [&'static str];
485
486    /// Entity kinds this pack contributes to the runtime vocabulary.
487    const ENTITY_KINDS: &'static [&'static str];
488
489    /// Brain profile consumer kinds this pack requests.
490    ///
491    /// A pack that resolves a brain profile for a consumer (for example,
492    /// `"recall"`) declares that wire-level kind here. The runtime composes
493    /// declarations from all loaded packs so `brain.bind` can reject bindings
494    /// that no registered consumer will ever request. Multiple packs may
495    /// declare the same consumer kind; composition deduplicates them.
496    ///
497    /// The `"*"` binding wildcard is registry-owned and must not be declared
498    /// by a pack.
499    const BRAIN_CONSUMER_KINDS: &'static [&'static str] = &[];
500
501    /// Handlers this pack registers.
502    ///
503    /// The runtime routes verb calls to the pack that declares them.
504    /// Only entries with `visibility: Visibility::Verb` are surfaced on the
505    /// MCP wire; `Visibility::Subhandler` entries are internal.
506    const HANDLERS: &'static [HandlerDef];
507
508    /// Additional edge endpoint rules this pack contributes.
509    ///
510    /// Defaults to empty — packs that introduce no new endpoint pairs (or
511    /// only rely on the base endpoint contract) can ignore this.
512    const EDGE_RULES: &'static [EdgeEndpointRule] = &[];
513
514    /// Entity-type subtypes this pack contributes to the `(EntityKind,
515    /// entity_type)` registry (the `entity_type` axis is distinct from and
516    /// finer-grained than the closed [`EntityKind`](crate::entity::EntityKind)
517    /// taxonomy — see `khive-types::entity_type::EntityTypeRegistry`).
518    ///
519    /// Defaults to empty, mirroring [`EDGE_RULES`]'s additive contract:
520    /// packs that introduce no new subtypes can ignore this. Entries here
521    /// are composed with `EntityTypeRegistry::builtin()` at runtime boot
522    /// (`VerbRegistry::all_entity_types`); packs must declare only new
523    /// `(kind, type_name)` pairs — they may not tighten or shadow a builtin
524    /// or another pack's declared subtype.
525    ///
526    /// [`EDGE_RULES`]: Pack::EDGE_RULES
527    const ENTITY_TYPES: &'static [EntityTypeDef] = &[];
528
529    /// Other pack names whose vocabulary this pack references.
530    ///
531    /// The runtime checks that every name in `REQUIRES` appears in the
532    /// loaded pack set before any pack is registered. Defaults to empty
533    /// so existing packs compile without changes.
534    const REQUIRES: &'static [&'static str] = &[];
535
536    /// Lifecycle and schema specs for note kinds this pack owns.
537    ///
538    /// Packs that introduce note kinds with explicit lifecycle semantics
539    /// (e.g. GTD's `task` kind) declare the spec here.  The runtime collects
540    /// these at boot time for introspection and future enforcement.  Defaults
541    /// to empty so existing packs compile without changes.
542    const NOTE_KIND_SPECS: &'static [NoteKindSpec] = &[];
543
544    /// Property policies for note kinds owned by this pack.
545    ///
546    /// Mirror this slice through `PackRuntime::note_property_policies`. The
547    /// registry validates and snapshots it at boot; declarations alone do not
548    /// change write enforcement. Unlisted keys carry no declared restriction.
549    const NOTE_PROPERTY_POLICIES: &'static [NotePropertyPolicySpec] = &[];
550
551    /// Write-time embedding policy for note kinds declared in `NOTE_KINDS`.
552    /// Unlisted kinds retain the all-model default.
553    const NOTE_EMBEDDING_POLICIES: &'static [NoteEmbeddingPolicySpec] = &[];
554
555    /// Pack-auxiliary schema plan.
556    ///
557    /// Packs that need their own auxiliary tables (e.g. GTD's
558    /// `gtd_lifecycle_audit`) declare idempotent DDL statements here.
559    /// The runtime applies them once at registration time.  Defaults to
560    /// `None` so packs with no auxiliary schema cost nothing.
561    const SCHEMA_PLAN: Option<PackSchemaPlan> = None;
562
563    /// Nullable-column upgrades for this pack's auxiliary tables.
564    ///
565    /// The backend validates and adds missing columns on existing tables before
566    /// applying [`Self::SCHEMA_PLAN`], then validates the final schema. The
567    /// entire plan is transactional. Absent tables are created only by the
568    /// pack's full DDL, so unloaded packs do not acquire auxiliary tables.
569    const SCHEMA_COLUMN_ADDITIONS: &'static [PackColumnAddition] = &[];
570
571    /// Validation rule IDs contributed by this pack.
572    ///
573    /// Rule IDs are namespaced by pack name: `<pack-name>/<rule-id>`.
574    /// The runtime merges rule IDs from all packs; the actual rule
575    /// implementations live in `khive-runtime::validation::ValidationRule`
576    /// (not in `khive-types`, which stays `no_std`). This const serves as
577    /// the declarative catalog of rule identifiers so the validation
578    /// infrastructure can enumerate what rules a pack claims without
579    /// loading the runtime.
580    ///
581    /// Defaults to empty — packs with no domain-specific validation rules
582    /// can leave this unset.
583    const VALIDATION_RULES: &'static [&'static str] = &[];
584}
585
586/// ADR-099 D3 — the v1 atomic-admissible verb set for `--atomic` bulk apply.
587///
588/// This is an EXPLICIT per-verb allowlist, never derived from [`VerbCategory`]
589/// or any other classification ("never a pack-level category", ADR-099 D3).
590/// Every verb here has a prepare/apply seam whose in-transaction phase reduces
591/// to synchronous DML — the atomic-unit suspend-free invariant
592/// (`SqlAccess::atomic_unit` in `khive-storage`). Extending this list is a
593/// design decision (ADR-099 amendment), not a code-review-only change; the
594/// `atomic_admissible_list_matches_adr` test below pins this exact set so an
595/// edit here forces the editor to touch that test and its ADR citation.
596pub const ATOMIC_ADMISSIBLE_VERBS: &[&str] = &[
597    "update",
598    "delete",
599    "link",
600    "merge",
601    "gtd.transition",
602    "gtd.complete",
603    "propose",
604    "review",
605    "withdraw",
606];
607
608/// Verbs rejected under `--atomic` because their write still computes an
609/// embedding synchronously and no prepare/apply seam hoists that embedding
610/// out of the transaction yet (ADR-099 D3, "v1 rejected — embedding-bearing").
611const ATOMIC_EMBEDDING_BEARING_VERBS: &[&str] = &[
612    "create",
613    "memory.remember",
614    "gtd.assign",
615    "comm.send",
616    "comm.reply",
617    "comm.ingest",
618];
619
620/// Verbs on [`ATOMIC_ADMISSIBLE_VERBS`] (ADR-099 D3 conceptually admissible)
621/// that have no *full-parity* prepare/apply seam yet, so they are rejected up
622/// front (checked BEFORE the general admissible-list check) rather than
623/// admitted with a silent gap. See
624/// crates/khive-types/docs/api/pack.md#adr-099-d3-atomic-admissibility-rejection-classes
625/// for why each verb is deferred and the ADR-099 B3 ordering rationale.
626pub const ATOMIC_KNOWN_UNIMPLEMENTED_VERBS: &[&str] = &["propose", "review", "withdraw", "merge"];
627
628/// Read verbs rejected under `--atomic` — they produce no write plan to apply
629/// (ADR-099 D3, "v1 rejected — reads").
630const ATOMIC_READ_VERBS: &[&str] = &[
631    "search",
632    "recall",
633    "query",
634    "traverse",
635    "list",
636    "get",
637    "neighbors",
638    "context",
639    "stats",
640    "verbs",
641];
642
643/// Conservative default maximum op count for one `--atomic` unit (ADR-099
644/// migration step 7 / B3). Override per invocation with
645/// `kkernel exec --atomic --atomic-max-ops N`. See
646/// crates/khive-types/docs/api/pack.md#atomic_max_ops_default--2000--rationale
647/// for why 2000 specifically was chosen and when to revisit it.
648pub const ATOMIC_MAX_OPS_DEFAULT: usize = 2000;
649
650/// Why a verb was rejected from an `--atomic` op list (ADR-099 D3, migration
651/// step 2). Distinguishes the two named rejection classes from a generic
652/// "not yet admitted" fallback so callers can produce an actionable message.
653#[derive(Clone, Copy, Debug, PartialEq, Eq)]
654pub enum AtomicRejectionReason {
655    /// The verb still computes an embedding synchronously in its write path.
656    EmbeddingBearing,
657    /// The verb is a read — it has no write plan to apply.
658    Read,
659    /// Neither on the v1 admissible list nor a known rejected category (e.g.
660    /// a verb added after this list was written). Rejected by default —
661    /// admissibility is opt-in, never inferred.
662    Unlisted,
663    /// On [`ATOMIC_ADMISSIBLE_VERBS`] per ADR-099 D3 (conceptually admissible,
664    /// intended to gain a seam) but has no prepare/apply implementation in
665    /// this slice yet ([`ATOMIC_KNOWN_UNIMPLEMENTED_VERBS`]). Rejected at the
666    /// same pre-runtime static-guard stage as every other rejection reason —
667    /// never silently no-opped, never deferred until after a runtime/write
668    /// attempt.
669    KnownUnimplemented,
670}
671
672/// Static admissibility classification for `verb_name` under ADR-099
673/// `--atomic` bulk apply.
674///
675/// Returns `None` when the verb is admissible; `Some(reason)` names why it is
676/// rejected. Default-deny: a verb name absent from every list here is
677/// [`AtomicRejectionReason::Unlisted`], never silently admitted.
678///
679/// `ATOMIC_KNOWN_UNIMPLEMENTED_VERBS` is checked BEFORE the general
680/// admissible-list membership check (ADR-099 B3): those
681/// verbs are members of `ATOMIC_ADMISSIBLE_VERBS`, so checking membership
682/// first would admit them (`None`) and defer their rejection to prepare time,
683/// after a runtime has already been constructed.
684pub fn atomic_admissibility(verb_name: &str) -> Option<AtomicRejectionReason> {
685    if ATOMIC_KNOWN_UNIMPLEMENTED_VERBS.contains(&verb_name) {
686        return Some(AtomicRejectionReason::KnownUnimplemented);
687    }
688    if ATOMIC_ADMISSIBLE_VERBS.contains(&verb_name) {
689        return None;
690    }
691    if ATOMIC_EMBEDDING_BEARING_VERBS.contains(&verb_name) {
692        return Some(AtomicRejectionReason::EmbeddingBearing);
693    }
694    if ATOMIC_READ_VERBS.contains(&verb_name) {
695        return Some(AtomicRejectionReason::Read);
696    }
697    Some(AtomicRejectionReason::Unlisted)
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    struct TestPack;
705
706    impl Pack for TestPack {
707        const NAME: &'static str = "test";
708        const NOTE_KINDS: &'static [&'static str] = &["memo"];
709        const ENTITY_KINDS: &'static [&'static str] = &["widget"];
710        const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
711            name: "do_thing",
712            description: "does a thing",
713            visibility: Visibility::Verb,
714            category: VerbCategory::Commissive,
715            params: &[],
716        }];
717    }
718
719    #[test]
720    fn pack_trait_compiles() {
721        assert_eq!(TestPack::NAME, "test");
722        assert_eq!(TestPack::NOTE_KINDS, &["memo"]);
723        assert_eq!(TestPack::ENTITY_KINDS, &["widget"]);
724        assert_eq!(TestPack::HANDLERS.len(), 1);
725        assert_eq!(TestPack::HANDLERS[0].name, "do_thing");
726        assert_eq!(TestPack::HANDLERS[0].visibility, Visibility::Verb);
727        assert_eq!(TestPack::HANDLERS[0].category, VerbCategory::Commissive);
728    }
729
730    #[test]
731    fn verb_category_variants_exist() {
732        // Just ensuring the enum variants are accessible — no runtime assertion
733        // needed beyond confirming they exist at compile time.
734        let _ = VerbCategory::Assertive;
735        let _ = VerbCategory::Directive;
736        let _ = VerbCategory::Commissive;
737        let _ = VerbCategory::Declaration;
738    }
739
740    #[test]
741    fn pack_validation_rules_default_empty() {
742        assert!(TestPack::VALIDATION_RULES.is_empty());
743    }
744
745    #[test]
746    fn pack_entity_types_default_empty() {
747        assert!(TestPack::ENTITY_TYPES.is_empty());
748    }
749
750    // `link` must be AlwaysVerbose so edge IDs are not shortened.
751    #[test]
752    fn link_handler_is_always_verbose() {
753        let link_def = HandlerDef {
754            name: "link",
755            description: "Create a typed directed edge",
756            visibility: Visibility::Verb,
757            category: VerbCategory::Commissive,
758            params: &[],
759        };
760        assert_eq!(
761            link_def.presentation_policy(),
762            VerbPresentationPolicy::AlwaysVerbose,
763            "link must be AlwaysVerbose"
764        );
765    }
766
767    // AlwaysVerbose set regression: protect every identifier-bearing contract.
768    #[test]
769    fn always_verbose_set_contains_expected_verbs() {
770        let always_verbose = [
771            "get",
772            "link",
773            "query",
774            "traverse",
775            "neighbors",
776            "brain.feedback",
777            "brain.auto_feedback",
778            "memory.feedback",
779            "comm.delivered",
780            "comm.transport_status",
781            "git.digest",
782            "git.ingest_cursor",
783            "schema",
784        ];
785        for name in always_verbose {
786            let h = HandlerDef {
787                name,
788                description: "",
789                visibility: Visibility::Verb,
790                category: VerbCategory::Assertive,
791                params: &[],
792            };
793            assert_eq!(
794                h.presentation_policy(),
795                VerbPresentationPolicy::AlwaysVerbose,
796                "{name:?} must be AlwaysVerbose"
797            );
798        }
799    }
800
801    #[test]
802    fn stream_receipt_policies_are_closed_to_the_registered_writers() {
803        for (name, policy) in [
804            ("stream.append", VerbPresentationPolicy::StreamAppendReceipt),
805            ("stream.batch", VerbPresentationPolicy::StreamBatchReceipts),
806            ("stream.read", VerbPresentationPolicy::Standard),
807            ("stream.batch.other", VerbPresentationPolicy::Standard),
808            ("list", VerbPresentationPolicy::Standard),
809        ] {
810            let handler = HandlerDef {
811                name,
812                description: "",
813                visibility: Visibility::Verb,
814                category: VerbCategory::Commissive,
815                params: &[],
816            };
817            assert_eq!(handler.presentation_policy(), policy, "{name}");
818        }
819    }
820
821    // Standard policy for all other verbs.
822    #[test]
823    fn non_verbose_verbs_are_standard_policy() {
824        let standard = [
825            "create", "list", "update", "delete", "search", "recall", "remember",
826        ];
827        for name in standard {
828            let h = HandlerDef {
829                name,
830                description: "",
831                visibility: Visibility::Verb,
832                category: VerbCategory::Commissive,
833                params: &[],
834            };
835            assert_eq!(
836                h.presentation_policy(),
837                VerbPresentationPolicy::Standard,
838                "{name:?} must be Standard (not AlwaysVerbose)"
839            );
840        }
841    }
842
843    // ── ADR-099 D3 atomic admissibility ────────────────────────────────────
844
845    // Drift-pin: a hardcoded copy of the ADR-099 D3 v1 admissible list. If
846    // someone edits `ATOMIC_ADMISSIBLE_VERBS`, this test fails until they also
847    // update this literal — forcing a look at ADR-099 D3 ("Decision: admit
848    // only verbs that expose a prepare/apply seam...") before the set changes.
849    #[test]
850    fn atomic_admissible_list_matches_adr099_d3() {
851        let adr_099_d3_v1_admissible_set: &[&str] = &[
852            "update",
853            "delete",
854            "link",
855            "merge",
856            "gtd.transition",
857            "gtd.complete",
858            "propose",
859            "review",
860            "withdraw",
861        ];
862        assert_eq!(
863            ATOMIC_ADMISSIBLE_VERBS, adr_099_d3_v1_admissible_set,
864            "ATOMIC_ADMISSIBLE_VERBS drifted from ADR-099 D3's explicit v1 list"
865        );
866    }
867
868    #[test]
869    fn atomic_admissible_verbs_are_admitted() {
870        for verb in ATOMIC_ADMISSIBLE_VERBS {
871            // Governance verbs are on ATOMIC_ADMISSIBLE_VERBS per ADR-099 D3
872            // (conceptually admissible) but are checked separately below:
873            // they are rejected at this same static layer for a distinct
874            // reason (KnownUnimplemented), not admitted (None).
875            if ATOMIC_KNOWN_UNIMPLEMENTED_VERBS.contains(verb) {
876                continue;
877            }
878            assert_eq!(
879                atomic_admissibility(verb),
880                None,
881                "{verb:?} is on the v1 admissible list and must be admitted"
882            );
883        }
884    }
885
886    #[test]
887    fn atomic_known_unimplemented_verbs_rejected_before_runtime() {
888        // ADR-099 B3: propose/review/withdraw
889        // remain on ATOMIC_ADMISSIBLE_VERBS (ADR-099 D3 intends them to gain a
890        // seam) but must be rejected at this SAME static pre-runtime guard —
891        // not admitted here and only failed later inside
892        // `atomic_prepare::prepare_op` after a runtime was already built.
893        for verb in ATOMIC_KNOWN_UNIMPLEMENTED_VERBS {
894            assert!(
895                ATOMIC_ADMISSIBLE_VERBS.contains(verb),
896                "{verb:?} must remain on ATOMIC_ADMISSIBLE_VERBS per ADR-099 D3"
897            );
898            assert_eq!(
899                atomic_admissibility(verb),
900                Some(AtomicRejectionReason::KnownUnimplemented),
901                "{verb:?} must be rejected as known-unimplemented, not admitted"
902            );
903        }
904    }
905
906    #[test]
907    fn atomic_embedding_bearing_verbs_rejected_named() {
908        for verb in [
909            "create",
910            "memory.remember",
911            "gtd.assign",
912            "comm.send",
913            "comm.reply",
914        ] {
915            assert_eq!(
916                atomic_admissibility(verb),
917                Some(AtomicRejectionReason::EmbeddingBearing),
918                "{verb:?} must be rejected as embedding-bearing (ADR-099 acceptance criteria)"
919            );
920        }
921    }
922
923    #[test]
924    fn atomic_read_verbs_rejected() {
925        for verb in [
926            "search",
927            "recall",
928            "query",
929            "traverse",
930            "list",
931            "get",
932            "neighbors",
933            "context",
934        ] {
935            assert_eq!(
936                atomic_admissibility(verb),
937                Some(AtomicRejectionReason::Read),
938                "{verb:?} must be rejected as a read verb"
939            );
940        }
941    }
942
943    #[test]
944    fn atomic_unknown_verb_defaults_to_unlisted_rejection() {
945        assert_eq!(
946            atomic_admissibility("some_future_verb_nobody_classified_yet"),
947            Some(AtomicRejectionReason::Unlisted),
948            "an unrecognized verb must default-deny, never silently admit"
949        );
950    }
951}