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