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}