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