Skip to main content

khive_types/
pack.rs

1//! Pack trait — the declarative composition unit for khive.
2//!
3//! A pack declares vocabulary (note kinds, entity kinds, brain consumer
4//! kinds), verbs, and edge endpoint rules. This is purely static metadata —
5//! no I/O, no async.
6//! Runtime dispatch lives in `khive-runtime` (`PackRuntime` trait +
7//! `VerbRegistry`).
8//!
9//! This trait lives in khive-types (no_std, zero deps) so downstream crates
10//! can reference pack metadata without pulling in the full runtime.
11
12use crate::edge::EdgeRelation;
13use crate::entity_type::EntityTypeDef;
14
15/// 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}