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