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), 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}