khive_types/pack.rs
1//! Pack trait — the declarative composition unit for khive.
2//!
3//! A pack declares vocabulary (note kinds, entity kinds), verbs, and edge
4//! endpoint rules. This is purely static metadata — no I/O, no async.
5//! Runtime dispatch lives in `khive-runtime` (`PackRuntime` trait +
6//! `VerbRegistry`).
7//!
8//! This trait lives in khive-types (no_std, zero deps) so downstream crates
9//! can reference pack metadata without pulling in the full runtime.
10
11use crate::edge::EdgeRelation;
12use crate::entity_type::EntityTypeDef;
13
14/// Visibility tier for a handler.
15///
16/// `Verb` entries appear on the MCP wire and are invokable by agents.
17/// `Subhandler` entries are internal — callable by the operator via CLI
18/// but not surfaced as top-level MCP verbs.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum Visibility {
21 /// Externally invokable via MCP `request` tool.
22 Verb,
23 /// Internal — operator-only via `kkernel exec '<pack>.<handler>(...)'`.
24 Subhandler,
25}
26
27/// Illocutionary force classification for a verb handler.
28///
29/// Follows Searle's five speech-act categories (1976). Every `Visibility::Verb`
30/// handler in the MCP surface MUST carry a category. `Subhandler` entries may
31/// use the category of their parent verb or `Assertive` as a sensible default.
32///
33/// The category is a documentation / introspection tag. It is NOT used for
34/// permission checking, transport routing, or return-shape selection.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum VerbCategory {
37 /// Speaker represents a state of affairs — retrieves and presents facts.
38 /// Examples: `get`, `list`, `search`, `recall`.
39 Assertive,
40 /// Speaker attempts to get the hearer to do something.
41 /// Examples: `assign`, `transition`.
42 Directive,
43 /// Speaker commits to a persistent change.
44 /// Examples: `create`, `remember`, `link`, `send`.
45 Commissive,
46 /// Speaker changes institutional status by fiat.
47 /// Examples: `update`, `delete`, `merge`, `complete`.
48 Declaration,
49 // `Expressive` is intentionally absent — no verb currently uses it.
50}
51
52/// Parameter type for `help=true` schema envelopes.
53///
54/// Declares the name, type hint, required flag, and one-line description for
55/// a single verb parameter. Stored as a `&'static` slice on [`HandlerDef`] so
56/// the registry can return it without any allocation at call time.
57///
58/// The `param_type` field is a free-form string (e.g. `"string"`, `"uuid"`,
59/// `"bool"`, `"integer"`, `"string | null"`) — it is documentation-only and
60/// not used for validation.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub struct ParamDef {
63 /// Parameter name as used in the DSL (e.g. `"id"`, `"kind"`, `"query"`).
64 pub name: &'static str,
65 /// Free-form type hint for documentation (e.g. `"string"`, `"uuid"`, `"bool"`).
66 pub param_type: &'static str,
67 /// Whether the caller must supply this parameter.
68 pub required: bool,
69 /// One-line human-readable description.
70 pub description: &'static str,
71}
72
73/// Handler metadata for discovery and documentation.
74///
75/// Replaces the previous `VerbDef`. Every entry carries a `visibility` tag
76/// so the registry can separate the MCP-exposed surface from internal handlers,
77/// and a `category` that classifies the illocutionary force of the verb
78/// per the speech-act taxonomy.
79///
80/// The `params` slice is used by `VerbRegistry::describe_verb` to build the
81/// `help=true` schema envelope. Packs that predate this field leave it empty
82/// (`&[]`) which is backward-compatible — callers receive a schema envelope
83/// with zero params rather than an error.
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct HandlerDef {
86 pub name: &'static str,
87 pub description: &'static str,
88 pub visibility: Visibility,
89 /// Illocutionary force classification. Use `Assertive` for `Subhandler`
90 /// entries that have no external callers.
91 pub category: VerbCategory,
92 /// Parameter schema for `help=true` introspection (issue #287).
93 ///
94 /// Empty (`&[]`) is the correct default for handlers that predate this
95 /// field or have no fixed parameter schema (e.g. free-form query verbs).
96 pub params: &'static [ParamDef],
97}
98
99/// Presentation override for a verb handler.
100///
101/// Most verbs use the default `Standard` policy which allows the caller's
102/// requested `PresentationMode` to apply. A small set declare `AlwaysVerbose`
103/// because Agent-mode trimming (UUID shortening, empty-field dropping) would
104/// corrupt their response for downstream chaining — e.g. `get` returns UUIDs
105/// that callers pipe into `link`; shortening them here breaks the chain.
106///
107/// The policy is carried as a `const` in [`HandlerDef`] so the registry can
108/// consult it before applying the presentation transform.
109#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
110pub enum VerbPresentationPolicy {
111 /// Apply the caller's requested `PresentationMode` unchanged.
112 #[default]
113 Standard,
114 /// Always use `Verbose` output regardless of the caller's mode.
115 ///
116 /// Declared verbs: `get`, `link`, `query`, `traverse`, `neighbors`,
117 /// `brain.feedback`.
118 ///
119 /// `link` is included because the returned edge ID is the only handle for
120 /// follow-up `neighbors`/`traverse` calls; short-form IDs risk prefix
121 /// collision at scale (~65K edges can share an 8-char prefix).
122 ///
123 /// `brain.feedback` is included because callers chain `target_id` from the
124 /// response back into subsequent feedback or profile queries; an 8-char
125 /// prefix is ambiguous and defeats the acknowledged-ID contract (#545).
126 AlwaysVerbose,
127}
128
129impl HandlerDef {
130 /// Resolve the presentation policy for this handler.
131 ///
132 /// Returns [`VerbPresentationPolicy::AlwaysVerbose`] for verbs whose
133 /// semantics demand full output (full UUIDs, complete timestamps) regardless
134 /// of the caller's requested presentation mode.
135 ///
136 /// New verbs that need this override must be added here; omission from the
137 /// list means `Standard` applies.
138 pub fn presentation_policy(&self) -> VerbPresentationPolicy {
139 match self.name {
140 "get" | "link" | "query" | "traverse" | "neighbors" | "brain.feedback" => {
141 VerbPresentationPolicy::AlwaysVerbose
142 }
143 _ => VerbPresentationPolicy::Standard,
144 }
145 }
146}
147
148/// Backward-compatible type alias. Existing code that names `VerbDef` still
149/// compiles; new code should use `HandlerDef` directly.
150#[deprecated(since = "0.2.0", note = "Use HandlerDef instead")]
151pub type VerbDef = HandlerDef;
152
153/// Match spec for one end of an [`EdgeEndpointRule`].
154///
155/// Identifies a substrate + kind pair that the rule applies to. Note that
156/// `kind` strings refer to the pack-declared note kinds / entity kinds — not
157/// the closed [`EdgeRelation`] set, which is universal.
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159pub enum EndpointKind {
160 /// A note whose `kind` field equals the given string (e.g. `"task"`).
161 NoteOfKind(&'static str),
162 /// An entity whose `kind` field equals the given string (e.g. `"concept"`).
163 EntityOfKind(&'static str),
164 /// An entity whose base `kind` AND `entity_type` subtype both match the
165 /// given strings (e.g. `kind: "concept", entity_type: "theorem"`). Both
166 /// fields must match — enforcing the `(EntityKind, entity_type)` registry
167 /// invariant required by ADR-001:102. Required for granular entity subtypes
168 /// (formal-math theorem/definition, AMR gene/drug/pathogen): `EntityOfKind`
169 /// only sees the base kind (`"concept"`), so an `EntityOfKind("theorem")`
170 /// rule is silently inert. Additive — tightens nothing in the closed relation
171 /// set.
172 EntityOfType {
173 /// Base entity kind that must match (e.g. `"concept"`).
174 kind: &'static str,
175 /// Canonical `entity_type` subtype that must match (e.g. `"theorem"`).
176 entity_type: &'static str,
177 },
178}
179
180/// A pack-declared endpoint rule for a specific edge relation.
181///
182/// Rules are **additive**: they extend the set of allowed
183/// `(source, relation, target)` triples beyond the base contract.
184/// Packs cannot tighten the base rules — only broaden them. The closed
185/// [`EdgeRelation`] taxonomy itself is not extended; only the endpoint
186/// contract per relation is.
187///
188/// Example — GTD pack allows `depends_on` between task notes:
189///
190/// ```ignore
191/// EdgeEndpointRule {
192/// relation: EdgeRelation::DependsOn,
193/// source: EndpointKind::NoteOfKind("task"),
194/// target: EndpointKind::NoteOfKind("task"),
195/// }
196/// ```
197#[derive(Clone, Copy, Debug, PartialEq, Eq)]
198pub struct EdgeEndpointRule {
199 pub relation: EdgeRelation,
200 pub source: EndpointKind,
201 pub target: EndpointKind,
202}
203
204/// Lifecycle specification for a note kind.
205///
206/// Declares which field holds the kind's domain state, the initial value,
207/// terminal values, and allowed transitions. The runtime uses this to
208/// validate lifecycle operations at the verb boundary without hard-coding
209/// kind-specific logic in the shared CRUD path.
210///
211/// Phase 1 (current): packs declare the spec; the runtime records it for
212/// documentation and future enforcement.
213/// Phase 2 (future): the runtime uses `field` to route lifecycle writes
214/// to a first-class column rather than `properties`.
215#[derive(Clone, Debug, PartialEq, Eq)]
216pub struct NoteLifecycleSpec {
217 /// The field name that holds the kind's lifecycle state.
218 ///
219 /// Use `"kind_status"` for pack-owned lifecycle fields to avoid the
220 /// semantic collision with `Note.status` (NoteStatus).
221 pub field: &'static str,
222 /// The value assigned when a note of this kind is first created.
223 pub initial: &'static str,
224 /// Values from which no further transitions are possible.
225 pub terminal: &'static [&'static str],
226 /// Allowed `(from, to)` transitions. `"*"` as `from` matches any state.
227 pub transitions: &'static [(&'static str, &'static str)],
228}
229
230/// Kind-level schema specification for a note kind.
231///
232/// Each pack-registered note kind may declare a `NoteKindSpec` to describe
233/// its lifecycle semantics. The runtime collects these at boot time via
234/// [`Pack::NOTE_KIND_SPECS`] for documentation, introspection, and future
235/// enforcement.
236#[derive(Clone, Debug, PartialEq, Eq)]
237pub struct NoteKindSpec {
238 /// The note kind string this spec governs (e.g. `"task"`).
239 pub kind: &'static str,
240 /// Alternate names this kind accepts on the wire.
241 pub aliases: &'static [&'static str],
242 /// Lifecycle state machine for this kind.
243 pub lifecycle: NoteLifecycleSpec,
244}
245
246/// DDL statements the pack needs applied to the auxiliary schema.
247///
248/// Pack-auxiliary tables use idempotent `CREATE TABLE IF NOT EXISTS`; they are
249/// not part of the core versioned migration chain. The runtime applies these
250/// statements once at pack registration time (or startup) against the active
251/// storage backend.
252#[derive(Clone, Debug, PartialEq, Eq)]
253pub struct PackSchemaPlan {
254 /// The pack this schema plan belongs to (used for error reporting).
255 pub pack: &'static str,
256 /// Idempotent SQL statements to apply.
257 pub statements: &'static [&'static str],
258}
259
260/// A composable module that contributes vocabulary, verbs, and edge endpoint
261/// rules to the khive runtime.
262///
263/// Packs declare what entity kinds, note kinds, and verbs they introduce, and
264/// optionally extend the per-relation endpoint contract via [`EDGE_RULES`].
265/// The runtime merges vocabularies from all loaded packs and rejects
266/// unregistered kinds at the service boundary.
267///
268/// The closed [`EdgeRelation`] enum is not extensible — only its
269/// per-relation endpoint contract is extensible by packs.
270///
271/// [`EDGE_RULES`]: Pack::EDGE_RULES
272pub trait Pack {
273 /// Short identifier for this pack (e.g. "kg", "tasks").
274 const NAME: &'static str;
275
276 /// Note kinds this pack contributes to the runtime vocabulary.
277 const NOTE_KINDS: &'static [&'static str];
278
279 /// Entity kinds this pack contributes to the runtime vocabulary.
280 const ENTITY_KINDS: &'static [&'static str];
281
282 /// Handlers this pack registers.
283 ///
284 /// The runtime routes verb calls to the pack that declares them.
285 /// Only entries with `visibility: Visibility::Verb` are surfaced on the
286 /// MCP wire; `Visibility::Subhandler` entries are internal.
287 const HANDLERS: &'static [HandlerDef];
288
289 /// Additional edge endpoint rules this pack contributes.
290 ///
291 /// Defaults to empty — packs that introduce no new endpoint pairs (or
292 /// only rely on the base endpoint contract) can ignore this.
293 const EDGE_RULES: &'static [EdgeEndpointRule] = &[];
294
295 /// Entity-type subtypes this pack contributes to the `(EntityKind,
296 /// entity_type)` registry (the `entity_type` axis is distinct from and
297 /// finer-grained than the closed [`EntityKind`](crate::entity::EntityKind)
298 /// taxonomy — see `khive-types::entity_type::EntityTypeRegistry`).
299 ///
300 /// Defaults to empty, mirroring [`EDGE_RULES`]'s additive contract:
301 /// packs that introduce no new subtypes can ignore this. Entries here
302 /// are composed with `EntityTypeRegistry::builtin()` at runtime boot
303 /// (`VerbRegistry::all_entity_types`); packs must declare only new
304 /// `(kind, type_name)` pairs — they may not tighten or shadow a builtin
305 /// or another pack's declared subtype.
306 ///
307 /// [`EDGE_RULES`]: Pack::EDGE_RULES
308 const ENTITY_TYPES: &'static [EntityTypeDef] = &[];
309
310 /// Other pack names whose vocabulary this pack references.
311 ///
312 /// The runtime checks that every name in `REQUIRES` appears in the
313 /// loaded pack set before any pack is registered. Defaults to empty
314 /// so existing packs compile without changes.
315 const REQUIRES: &'static [&'static str] = &[];
316
317 /// Lifecycle and schema specs for note kinds this pack owns.
318 ///
319 /// Packs that introduce note kinds with explicit lifecycle semantics
320 /// (e.g. GTD's `task` kind) declare the spec here. The runtime collects
321 /// these at boot time for introspection and future enforcement. Defaults
322 /// to empty so existing packs compile without changes.
323 const NOTE_KIND_SPECS: &'static [NoteKindSpec] = &[];
324
325 /// Pack-auxiliary schema plan.
326 ///
327 /// Packs that need their own auxiliary tables (e.g. GTD's
328 /// `gtd_lifecycle_audit`) declare idempotent DDL statements here.
329 /// The runtime applies them once at registration time. Defaults to
330 /// `None` so packs with no auxiliary schema cost nothing.
331 const SCHEMA_PLAN: Option<PackSchemaPlan> = None;
332
333 /// Validation rule IDs contributed by this pack.
334 ///
335 /// Rule IDs are namespaced by pack name: `<pack-name>/<rule-id>`.
336 /// The runtime merges rule IDs from all packs; the actual rule
337 /// implementations live in `khive-runtime::validation::ValidationRule`
338 /// (not in `khive-types`, which stays `no_std`). This const serves as
339 /// the declarative catalog of rule identifiers so the validation
340 /// infrastructure can enumerate what rules a pack claims without
341 /// loading the runtime.
342 ///
343 /// Defaults to empty — packs with no domain-specific validation rules
344 /// can leave this unset.
345 const VALIDATION_RULES: &'static [&'static str] = &[];
346}
347
348/// ADR-099 D3 — the v1 atomic-admissible verb set for `--atomic` bulk apply.
349///
350/// This is an EXPLICIT per-verb allowlist, never derived from [`VerbCategory`]
351/// or any other classification ("never a pack-level category", ADR-099 D3).
352/// Every verb here has a prepare/apply seam whose in-transaction phase reduces
353/// to synchronous DML — the atomic-unit suspend-free invariant
354/// (`SqlAccess::atomic_unit` in `khive-storage`). Extending this list is a
355/// design decision (ADR-099 amendment), not a code-review-only change; the
356/// `atomic_admissible_list_matches_adr` test below pins this exact set so an
357/// edit here forces the editor to touch that test and its ADR citation.
358pub const ATOMIC_ADMISSIBLE_VERBS: &[&str] = &[
359 "update",
360 "delete",
361 "link",
362 "merge",
363 "gtd.transition",
364 "gtd.complete",
365 "propose",
366 "review",
367 "withdraw",
368];
369
370/// Verbs rejected under `--atomic` because their write still computes an
371/// embedding synchronously and no prepare/apply seam hoists that embedding
372/// out of the transaction yet (ADR-099 D3, "v1 rejected — embedding-bearing").
373const ATOMIC_EMBEDDING_BEARING_VERBS: &[&str] = &[
374 "create",
375 "memory.remember",
376 "gtd.assign",
377 "comm.send",
378 "comm.reply",
379 "comm.ingest",
380];
381
382/// Verbs on [`ATOMIC_ADMISSIBLE_VERBS`] (ADR-099 D3 conceptually admissible)
383/// that have no *full-parity* prepare/apply seam yet, so they are rejected up
384/// front (checked BEFORE the general admissible-list check) rather than
385/// admitted with a silent gap. See
386/// crates/khive-types/docs/api/pack.md#adr-099-d3-atomic-admissibility-rejection-classes
387/// for why each verb is deferred and the ADR-099 B3 ordering rationale.
388pub const ATOMIC_KNOWN_UNIMPLEMENTED_VERBS: &[&str] = &["propose", "review", "withdraw", "merge"];
389
390/// Read verbs rejected under `--atomic` — they produce no write plan to apply
391/// (ADR-099 D3, "v1 rejected — reads").
392const ATOMIC_READ_VERBS: &[&str] = &[
393 "search",
394 "recall",
395 "query",
396 "traverse",
397 "list",
398 "get",
399 "neighbors",
400 "context",
401 "stats",
402 "verbs",
403];
404
405/// Conservative default maximum op count for one `--atomic` unit (ADR-099
406/// migration step 7 / B3). Override per invocation with
407/// `kkernel exec --atomic --atomic-max-ops N`. See
408/// crates/khive-types/docs/api/pack.md#atomic_max_ops_default--2000--rationale
409/// for why 2000 specifically was chosen and when to revisit it.
410pub const ATOMIC_MAX_OPS_DEFAULT: usize = 2000;
411
412/// Why a verb was rejected from an `--atomic` op list (ADR-099 D3, migration
413/// step 2). Distinguishes the two named rejection classes from a generic
414/// "not yet admitted" fallback so callers can produce an actionable message.
415#[derive(Clone, Copy, Debug, PartialEq, Eq)]
416pub enum AtomicRejectionReason {
417 /// The verb still computes an embedding synchronously in its write path.
418 EmbeddingBearing,
419 /// The verb is a read — it has no write plan to apply.
420 Read,
421 /// Neither on the v1 admissible list nor a known rejected category (e.g.
422 /// a verb added after this list was written). Rejected by default —
423 /// admissibility is opt-in, never inferred.
424 Unlisted,
425 /// On [`ATOMIC_ADMISSIBLE_VERBS`] per ADR-099 D3 (conceptually admissible,
426 /// intended to gain a seam) but has no prepare/apply implementation in
427 /// this slice yet ([`ATOMIC_KNOWN_UNIMPLEMENTED_VERBS`]). Rejected at the
428 /// same pre-runtime static-guard stage as every other rejection reason —
429 /// never silently no-opped, never deferred until after a runtime/write
430 /// attempt.
431 KnownUnimplemented,
432}
433
434/// Static admissibility classification for `verb_name` under ADR-099
435/// `--atomic` bulk apply.
436///
437/// Returns `None` when the verb is admissible; `Some(reason)` names why it is
438/// rejected. Default-deny: a verb name absent from every list here is
439/// [`AtomicRejectionReason::Unlisted`], never silently admitted.
440///
441/// `ATOMIC_KNOWN_UNIMPLEMENTED_VERBS` is checked BEFORE the general
442/// admissible-list membership check (ADR-099 B3): those
443/// verbs are members of `ATOMIC_ADMISSIBLE_VERBS`, so checking membership
444/// first would admit them (`None`) and defer their rejection to prepare time,
445/// after a runtime has already been constructed.
446pub fn atomic_admissibility(verb_name: &str) -> Option<AtomicRejectionReason> {
447 if ATOMIC_KNOWN_UNIMPLEMENTED_VERBS.contains(&verb_name) {
448 return Some(AtomicRejectionReason::KnownUnimplemented);
449 }
450 if ATOMIC_ADMISSIBLE_VERBS.contains(&verb_name) {
451 return None;
452 }
453 if ATOMIC_EMBEDDING_BEARING_VERBS.contains(&verb_name) {
454 return Some(AtomicRejectionReason::EmbeddingBearing);
455 }
456 if ATOMIC_READ_VERBS.contains(&verb_name) {
457 return Some(AtomicRejectionReason::Read);
458 }
459 Some(AtomicRejectionReason::Unlisted)
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 struct TestPack;
467
468 impl Pack for TestPack {
469 const NAME: &'static str = "test";
470 const NOTE_KINDS: &'static [&'static str] = &["memo"];
471 const ENTITY_KINDS: &'static [&'static str] = &["widget"];
472 const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
473 name: "do_thing",
474 description: "does a thing",
475 visibility: Visibility::Verb,
476 category: VerbCategory::Commissive,
477 params: &[],
478 }];
479 }
480
481 #[test]
482 fn pack_trait_compiles() {
483 assert_eq!(TestPack::NAME, "test");
484 assert_eq!(TestPack::NOTE_KINDS, &["memo"]);
485 assert_eq!(TestPack::ENTITY_KINDS, &["widget"]);
486 assert_eq!(TestPack::HANDLERS.len(), 1);
487 assert_eq!(TestPack::HANDLERS[0].name, "do_thing");
488 assert_eq!(TestPack::HANDLERS[0].visibility, Visibility::Verb);
489 assert_eq!(TestPack::HANDLERS[0].category, VerbCategory::Commissive);
490 }
491
492 #[test]
493 fn verb_category_variants_exist() {
494 // Just ensuring the enum variants are accessible — no runtime assertion
495 // needed beyond confirming they exist at compile time.
496 let _ = VerbCategory::Assertive;
497 let _ = VerbCategory::Directive;
498 let _ = VerbCategory::Commissive;
499 let _ = VerbCategory::Declaration;
500 }
501
502 #[test]
503 fn pack_validation_rules_default_empty() {
504 assert!(TestPack::VALIDATION_RULES.is_empty());
505 }
506
507 #[test]
508 fn pack_entity_types_default_empty() {
509 assert!(TestPack::ENTITY_TYPES.is_empty());
510 }
511
512 // `link` must be AlwaysVerbose so edge IDs are not shortened.
513 #[test]
514 fn link_handler_is_always_verbose() {
515 let link_def = HandlerDef {
516 name: "link",
517 description: "Create a typed directed edge",
518 visibility: Visibility::Verb,
519 category: VerbCategory::Commissive,
520 params: &[],
521 };
522 assert_eq!(
523 link_def.presentation_policy(),
524 VerbPresentationPolicy::AlwaysVerbose,
525 "link must be AlwaysVerbose"
526 );
527 }
528
529 // AlwaysVerbose set regression: ensure get/query/traverse/neighbors/brain.feedback remain.
530 #[test]
531 fn always_verbose_set_contains_expected_verbs() {
532 let always_verbose = [
533 "get",
534 "link",
535 "query",
536 "traverse",
537 "neighbors",
538 "brain.feedback",
539 ];
540 for name in always_verbose {
541 let h = HandlerDef {
542 name,
543 description: "",
544 visibility: Visibility::Verb,
545 category: VerbCategory::Assertive,
546 params: &[],
547 };
548 assert_eq!(
549 h.presentation_policy(),
550 VerbPresentationPolicy::AlwaysVerbose,
551 "{name:?} must be AlwaysVerbose"
552 );
553 }
554 }
555
556 // Standard policy for all other verbs.
557 #[test]
558 fn non_verbose_verbs_are_standard_policy() {
559 let standard = [
560 "create", "list", "update", "delete", "search", "recall", "remember",
561 ];
562 for name in standard {
563 let h = HandlerDef {
564 name,
565 description: "",
566 visibility: Visibility::Verb,
567 category: VerbCategory::Commissive,
568 params: &[],
569 };
570 assert_eq!(
571 h.presentation_policy(),
572 VerbPresentationPolicy::Standard,
573 "{name:?} must be Standard (not AlwaysVerbose)"
574 );
575 }
576 }
577
578 // ── ADR-099 D3 atomic admissibility ────────────────────────────────────
579
580 // Drift-pin: a hardcoded copy of the ADR-099 D3 v1 admissible list. If
581 // someone edits `ATOMIC_ADMISSIBLE_VERBS`, this test fails until they also
582 // update this literal — forcing a look at ADR-099 D3 ("Decision: admit
583 // only verbs that expose a prepare/apply seam...") before the set changes.
584 #[test]
585 fn atomic_admissible_list_matches_adr099_d3() {
586 let adr_099_d3_v1_admissible_set: &[&str] = &[
587 "update",
588 "delete",
589 "link",
590 "merge",
591 "gtd.transition",
592 "gtd.complete",
593 "propose",
594 "review",
595 "withdraw",
596 ];
597 assert_eq!(
598 ATOMIC_ADMISSIBLE_VERBS, adr_099_d3_v1_admissible_set,
599 "ATOMIC_ADMISSIBLE_VERBS drifted from ADR-099 D3's explicit v1 list"
600 );
601 }
602
603 #[test]
604 fn atomic_admissible_verbs_are_admitted() {
605 for verb in ATOMIC_ADMISSIBLE_VERBS {
606 // Governance verbs are on ATOMIC_ADMISSIBLE_VERBS per ADR-099 D3
607 // (conceptually admissible) but are checked separately below:
608 // they are rejected at this same static layer for a distinct
609 // reason (KnownUnimplemented), not admitted (None).
610 if ATOMIC_KNOWN_UNIMPLEMENTED_VERBS.contains(verb) {
611 continue;
612 }
613 assert_eq!(
614 atomic_admissibility(verb),
615 None,
616 "{verb:?} is on the v1 admissible list and must be admitted"
617 );
618 }
619 }
620
621 #[test]
622 fn atomic_known_unimplemented_verbs_rejected_before_runtime() {
623 // ADR-099 B3: propose/review/withdraw
624 // remain on ATOMIC_ADMISSIBLE_VERBS (ADR-099 D3 intends them to gain a
625 // seam) but must be rejected at this SAME static pre-runtime guard —
626 // not admitted here and only failed later inside
627 // `atomic_prepare::prepare_op` after a runtime was already built.
628 for verb in ATOMIC_KNOWN_UNIMPLEMENTED_VERBS {
629 assert!(
630 ATOMIC_ADMISSIBLE_VERBS.contains(verb),
631 "{verb:?} must remain on ATOMIC_ADMISSIBLE_VERBS per ADR-099 D3"
632 );
633 assert_eq!(
634 atomic_admissibility(verb),
635 Some(AtomicRejectionReason::KnownUnimplemented),
636 "{verb:?} must be rejected as known-unimplemented, not admitted"
637 );
638 }
639 }
640
641 #[test]
642 fn atomic_embedding_bearing_verbs_rejected_named() {
643 for verb in [
644 "create",
645 "memory.remember",
646 "gtd.assign",
647 "comm.send",
648 "comm.reply",
649 ] {
650 assert_eq!(
651 atomic_admissibility(verb),
652 Some(AtomicRejectionReason::EmbeddingBearing),
653 "{verb:?} must be rejected as embedding-bearing (ADR-099 acceptance criteria)"
654 );
655 }
656 }
657
658 #[test]
659 fn atomic_read_verbs_rejected() {
660 for verb in [
661 "search",
662 "recall",
663 "query",
664 "traverse",
665 "list",
666 "get",
667 "neighbors",
668 "context",
669 ] {
670 assert_eq!(
671 atomic_admissibility(verb),
672 Some(AtomicRejectionReason::Read),
673 "{verb:?} must be rejected as a read verb"
674 );
675 }
676 }
677
678 #[test]
679 fn atomic_unknown_verb_defaults_to_unlisted_rejection() {
680 assert_eq!(
681 atomic_admissibility("some_future_verb_nobody_classified_yet"),
682 Some(AtomicRejectionReason::Unlisted),
683 "an unrecognized verb must default-deny, never silently admit"
684 );
685 }
686}