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}