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