Skip to main content

tatara_lisp/
domain.rs

1//! `TataraDomain` — a Rust type authorable as a Lisp `(<keyword> :k v …)` form.
2//!
3//! Apply `#[derive(TataraDomain)]` (from `tatara-lisp-derive`) and a plain
4//! struct gains a full Lisp compiler: keyword dispatch, kwarg parsing, typed
5//! field extraction.
6//!
7//! Also exposes a `DomainRegistry` + `linkme`-free `register_domain!` macro
8//! so any crate that derives `TataraDomain` can auto-register itself; the
9//! dispatcher then looks up unknown top-level forms by keyword at runtime.
10
11use std::collections::HashMap;
12use std::sync::{Mutex, OnceLock};
13
14use serde::de::DeserializeOwned;
15
16use crate::ast::{Atom, Sexp};
17use crate::error::{ExpectedKwargShape, KwargPath, LispError, Result, SexpShape, SexpWitness};
18
19/// Phase F: a Rust type (typically a unit-only enum) whose variants map to
20/// a single Lisp keyword atom — e.g., `Role::Master` ↔ `:master`. Used by
21/// `#[derive(TataraDomain)]` fields with `#[tatara(keyword_enum)]`. Derive
22/// via `#[derive(KeywordSexp)]` from `tatara-lisp-derive`.
23pub trait KeywordSexp: Sized {
24    /// Parse `s` (the keyword name without the leading `:`) into Self.
25    fn from_keyword(s: &str) -> Result<Self>;
26    /// The keyword name (without leading `:`) for this variant.
27    fn to_keyword(self) -> &'static str;
28}
29
30/// A Rust type compilable from a Lisp form.
31pub trait TataraDomain: Sized {
32    /// The Lisp keyword (e.g., `"defmonitor"`).
33    const KEYWORD: &'static str;
34
35    /// Parse the argument list (everything after the keyword) into Self.
36    fn compile_from_args(args: &[Sexp]) -> Result<Self>;
37
38    /// Parse a complete form; validates the head symbol matches `KEYWORD`.
39    fn compile_from_sexp(form: &Sexp) -> Result<Self> {
40        let list = form
41            .as_list()
42            .ok_or_else(|| not_a_list_form_err(Self::KEYWORD))?;
43        // The two sub-modes of "head can't be projected to a symbol" — empty
44        // list (`first()` is `None`) vs. present-but-not-a-symbol
45        // (`as_symbol()` is `None`) — share ONE structural variant
46        // (`MissingHeadSymbol { keyword, got }`) but bind to distinct
47        // `got` payloads (`None` vs. `Some(<sexp display>)`). This lets
48        // an authoring tool render "your form is empty" vs. "your
49        // form's head is `5`, not a symbol" without re-parsing the
50        // source — the legacy `Compile`-shaped diagnostic collapsed
51        // both into one message.
52        let head_sexp = list
53            .first()
54            .ok_or_else(|| missing_head_err(Self::KEYWORD, None))?;
55        let head = head_sexp
56            .as_symbol()
57            .ok_or_else(|| missing_head_err(Self::KEYWORD, Some(head_sexp.witness())))?;
58        if head != Self::KEYWORD {
59            return Err(head_mismatch(Self::KEYWORD, head.to_string()));
60        }
61        Self::compile_from_args(&list[1..])
62    }
63}
64
65// ── compile_from_sexp diagnostics — the form-shape gate primitives ─
66//
67// `compile_from_sexp` (the trait default) gates every `TataraDomain`
68// invocation that takes a complete `(KEYWORD …)` form: ProcessSpec,
69// MonitorSpec, AlertPolicySpec, every hand-written impl. Three failure
70// modes — not a list, missing head symbol, wrong head — used to be
71// inline `LispError::Compile { form: KEYWORD.to_string(), message: …}`
72// triples in the trait default. The three-times-rule signal
73// (THEORY.md §VI.1) calls for one named primitive per shape; these
74// are them.
75//
76// All three are now structural: `not_a_list_form_err` returns
77// `LispError::NotAListForm`, `missing_head_err` returns
78// `LispError::MissingHeadSymbol { keyword, got }` (`got: None` for
79// empty list, `got: Some(<sexp display>)` for present-but-not-symbol),
80// and `head_mismatch` returns `LispError::HeadMismatch`. Each carries
81// its distinguishing data (the offending head's display projection,
82// the keyword) as first-class variant fields so authoring tools
83// pattern-match structurally instead of substring-grepping the
84// rendered message. The entire `compile_from_sexp` rejection chain
85// — bare-atom → empty/not-symbol head → wrong-keyword head — is
86// closed: every distinct typed-entry rejection at the form-shape
87// gate binds to ONE structural variant of `LispError`.
88
89/// `T::compile_from_sexp` was passed something that isn't a list.
90/// One named primitive every TataraDomain impl shares — returns the
91/// dedicated `LispError::NotAListForm { keyword }` variant so
92/// authoring surfaces (REPL, LSP, `tatara-check`) bind to the
93/// first-class `keyword` field instead of substring-parsing the
94/// rendered message. Display matches the legacy `Compile`-shaped
95/// diagnostic byte-for-byte (`"compile error in {keyword}: expected
96/// list form"`), so existing `format!("{err}").contains("expected
97/// list form")` assertions pass unchanged.
98///
99/// Theory anchor: THEORY.md §V.1 — knowable platform. The legacy
100/// `Compile { form, message }` shape required consumers to
101/// pattern-match on `message == "expected list form"` to recognize
102/// this specific gate (versus the sibling `missing head symbol`
103/// gate, which produces the same `Compile` shape with a different
104/// message). After this lift the discriminator is the variant
105/// itself — a regression that drifts the message string can no
106/// longer drift the gate's identity. THEORY.md §II.1 invariant 1 —
107/// typed entry; a non-list form is exactly the failure mode the
108/// typed-entry gate exists to reject, and the gate's identity is
109/// now load-bearing in the type system.
110#[must_use]
111pub fn not_a_list_form_err(keyword: &'static str) -> LispError {
112    LispError::NotAListForm { keyword }
113}
114
115/// `T::compile_from_sexp` was passed `()` or a list whose first
116/// element isn't a symbol — there's nothing to dispatch on. One named
117/// primitive every `TataraDomain` impl shares; returns the dedicated
118/// `LispError::MissingHeadSymbol { keyword, got }` variant so authoring
119/// surfaces (REPL, LSP, `tatara-check`) bind to the first-class
120/// `keyword` and `got` fields instead of substring-parsing the
121/// rendered message. `got: None` for the empty-list case (`()`),
122/// `got: Some(SexpWitness)` for the present-but-not-symbol case
123/// (`(5 …)`, `(:foo …)`, `("x" …)`, `((nested) …)`) — the legacy
124/// `Compile`-shaped diagnostic collapsed both into one message; this
125/// builder bifurcates them structurally so the renderable detail
126/// names which sub-mode fired. The `Some` arm carries the typed
127/// joint identity (`SexpShape` + `Sexp::Display`) routed through
128/// `sexp_witness(_)` so authoring tools that want to surface a
129/// structural autofix — "you wrote `:foo` at the head slot where a
130/// symbol was expected (did you mean `foo`?)" — bind on
131/// `got.shape == SexpShape::Keyword` directly, no substring-grep on
132/// the rendered display required.
133///
134/// Display matches the legacy `Compile`-shaped diagnostic byte-for-
135/// byte for the prefix (`"compile error in {keyword}: missing head
136/// symbol"`); the structural detail is appended in a parenthetical
137/// (`(empty list)` for `None`, `(got {g})` for `Some(g)`), parallel
138/// to how `RestParamMissingName` appends `(rest marker at position
139/// {n}, {got|none provided})` and how `SpliceOutsideList` appends
140/// `(got ,@{got})`. The `{g}` slot flows through `SexpWitness::Display`,
141/// which writes only the `display` field, so existing
142/// `format!("{err}").contains("missing head symbol")` assertions pass
143/// unchanged.
144///
145/// Theory anchor: THEORY.md §V.1 — knowable platform. The legacy
146/// `Compile { form, message }` shape required consumers to
147/// pattern-match on `message == "missing head symbol"` to recognize
148/// this specific gate (versus the sibling `expected list form` and
149/// head-mismatch gates, which produced different `message` strings
150/// in the same `Compile` shape). After this lift the discriminator
151/// is the variant itself — a regression that drifts the message
152/// string can no longer drift the gate's identity, AND the two
153/// distinct sub-modes (empty vs. present-but-not-symbol) are
154/// structurally addressable. THEORY.md §II.1 invariant 1 — typed
155/// entry; an empty form / non-symbol-head form is exactly the
156/// failure mode the typed-entry gate exists to reject, and the
157/// gate's identity is now load-bearing in the type system.
158#[must_use]
159pub fn missing_head_err(keyword: &'static str, got: Option<SexpWitness>) -> LispError {
160    LispError::MissingHeadSymbol { keyword, got }
161}
162
163/// Structural head-mismatch builder. Returns the dedicated
164/// `LispError::HeadMismatch` variant so authoring surfaces (REPL, LSP,
165/// `tatara-check`) bind to first-class `keyword`/`got` fields instead
166/// of substring-parsing the rendered message. Display matches the
167/// legacy `Compile`-shaped diagnostic byte-for-byte, so existing
168/// `format!("{err}").contains("expected ({KEYWORD}")` assertions pass
169/// unchanged.
170///
171/// Theory anchor: THEORY.md §V.1 — knowable platform. A diagnostic
172/// whose `got` is embedded in a free-form message is structurally
173/// incomplete; an authoring surface that wants to render
174/// "did-you-mean" suggestions on the offending head must re-parse
175/// the message. After this lift the slot exists in the variant's
176/// data shape itself.
177#[must_use]
178pub fn head_mismatch(keyword: &'static str, got: String) -> LispError {
179    LispError::HeadMismatch { keyword, got }
180}
181
182/// The substrate-wide [`TataraDomain`] well-formedness testkit — closes
183/// the four typed-entry rejection gates on the trait's default
184/// [`TataraDomain::compile_from_sexp`], three [`TataraDomain::KEYWORD`]
185/// grammar invariants, AND the reader round-trip theorem at ONE call
186/// every implementor's test module reaches for.
187///
188/// Peer of [`crate::closed_set::assert_closed_set_well_formed`] on the
189/// sibling [`crate::ClosedSet`] contract — after this lift both
190/// homoiconic-authoring contracts (the closed-set enum idiom AND the
191/// derived-domain idiom) carry ONE substrate-wide structural checker
192/// each, and every downstream implementor's test module reduces to a
193/// single-line invocation instead of re-deriving the invariants
194/// per-implementor.
195///
196/// ## The four `compile_from_sexp` rejection gates
197///
198///   1. `NotAListForm { keyword }` on a bare atom — the typed-entry
199///      gate rejects the form-shape mismatch before descending into
200///      the list.
201///   2. `MissingHeadSymbol { keyword, got: None }` on the empty list
202///      `()` — `list.first()` returns `None`, there's no head to
203///      project.
204///   3. `MissingHeadSymbol { keyword, got: Some(_) }` on a list whose
205///      first element is not a symbol — `list.first().as_symbol()`
206///      returns `None`, and the offending element's typed identity
207///      threads into the `got` slot.
208///   4. `HeadMismatch { keyword, got }` on a list headed by a symbol
209///      other than `T::KEYWORD` — the substring-free structural
210///      discriminator.
211///
212/// ## The three `KEYWORD` grammar invariants
213///
214///   5. `KEYWORD` is non-empty — a keyword-less form cannot be
215///      dispatched.
216///   6. `KEYWORD` classifies as [`Atom::Symbol`] through the substrate's
217///      typed-entry classifier [`Atom::from_lexeme`] — the ONE
218///      projection every bare-atom lexeme routes through inside the
219///      reader's parse arm. Subsumes the pre-lift "no leading ASCII
220///      digit" heuristic (a KEYWORD `"42"` decodes as [`Atom::Int`],
221///      `"1.5"` as [`Atom::Float`]) AND catches the two shapes the
222///      pre-lift check silently accepted: a leading `:` (KEYWORD
223///      `":foo"` decodes as [`Atom::Keyword`]) and the two boolean
224///      literals (`"#t"` / `"#f"` decode as [`Atom::Bool`]) — none of
225///      which the trait's `as_symbol()` head-match would fire on. Binds
226///      the invariant to the substrate's typed reader-classifier
227///      algebra so a future seventh [`Atom`] variant (e.g. `Char` for
228///      `#\x` reader syntax, `Bigint` for arbitrary-precision integers)
229///      strengthens the check ONCE at [`Atom::from_lexeme`] rather than
230///      re-heuristicing per implementor's test module.
231///   7. `KEYWORD` contains no [`Sexp::is_bare_atom_boundary`] char —
232///      the ONE typed projection on the outer [`Sexp`] algebra that
233///      names "this char breaks the reader's bare-atom accumulator."
234///      Subsumes the pre-lift "no ASCII whitespace" heuristic (via
235///      `char::is_whitespace()` covering the Unicode whitespace surface
236///      the reader also splits on — NBSP `\u{00A0}`, ideographic space
237///      `\u{3000}`, and every other codepoint the pre-lift ASCII-only
238///      check silently accepted) AND catches the seven non-whitespace
239///      terminators the pre-lift check ignored:
240///      [`Sexp::LIST_OPEN`] `(`, [`Sexp::LIST_CLOSE`] `)`,
241///      [`crate::ast::QuoteForm::QUOTE_LEAD`] `'`,
242///      [`crate::ast::QuoteForm::QUASIQUOTE_LEAD`] `` ` ``,
243///      [`crate::ast::QuoteForm::UNQUOTE_LEAD`] `,`,
244///      [`Atom::STR_DELIMITER`] `"`, [`Sexp::COMMENT_LEAD`] `;` — every
245///      char that would tokenize a KEYWORD like `"def(x"` / `"def;x"` /
246///      `"def\"x"` into TWO tokens, breaking the trait's head-match
247///      structurally. Binds the invariant to the substrate's typed
248///      reader-boundary algebra so a future eighth outer-dispatch
249///      category (e.g. `#|…|#` block-comment lead byte) strengthens the
250///      check ONCE at [`Sexp::is_bare_atom_boundary`] rather than
251///      per-implementor re-derivation.
252///
253/// ## The round-trip theorem
254///
255///   8. `read(KEYWORD)` produces exactly one form, and that form's
256///      [`Sexp::as_symbol`] projection returns `Some(KEYWORD)`. This is
257///      the STRUCTURAL condition the trait's default
258///      [`TataraDomain::compile_from_sexp`] head-match depends on: the
259///      reader tokenizes the head slot, projects through
260///      [`Atom::from_lexeme`], and the head-match calls `as_symbol()`
261///      on the resulting [`Sexp`]. If the round-trip holds, the
262///      head-match fires on the intended keyword; if it fails, no
263///      other invariant matters. Invariants (6) and (7) together
264///      *entail* this theorem — (6) closes the classifier axis,
265///      (7) closes the tokenizer axis — so a KEYWORD that passes both
266///      structural checks always passes the round-trip; pinning the
267///      theorem explicitly closes the LOOP at the verification site
268///      and catches drift outside the closed-set structural surface
269///      (e.g. a future reader-level input transformation that couldn't
270///      be reduced to either axis).
271///
272/// A hand-written implementor that overrides
273/// [`TataraDomain::compile_from_sexp`] and drifts any of the four gates
274/// from the substrate-wide structural variants surfaces here rather
275/// than as a mystery integration failure downstream — the same posture
276/// [`crate::closed_set::assert_closed_set_well_formed`] takes on the
277/// override-prone `parse_label` / `parse_label_with_hint` /
278/// `labels_joined` axes.
279///
280/// ## Usage
281///
282/// ```ignore
283/// #[test]
284/// fn my_spec_is_well_formed_tatara_domain() {
285///     tatara_lisp::assert_tatara_domain_well_formed::<MySpec>();
286/// }
287/// ```
288///
289/// ## Theory grounding
290///
291/// THEORY.md §II.1 invariant 1 — typed entry; the four structural
292/// gates ARE the typed-entry boundary of the derived-domain idiom, and
293/// the testkit makes their identity load-bearing at the per-implementor
294/// test surface.
295///
296/// THEORY.md §V.1 — knowable platform; the four structural rejections
297/// were previously re-derived per implementor with
298/// `matches!(err, LispError::NotAListForm { ... })` scaffolds. The
299/// testkit collapses the scaffolds onto ONE substrate entry so future
300/// implementors inherit the contract by calling one line — mirrors the
301/// `assert_closed_set_well_formed` posture that closed the closed-set
302/// enum idiom's 36+ per-implementor test modules onto ONE checker.
303///
304/// THEORY.md §VI.1 — generation over composition; the four gate
305/// primitives ([`not_a_list_form_err`], [`missing_head_err`],
306/// [`head_mismatch`]) already compose the structural rejections at the
307/// GENERATION site. This testkit closes the LOOP at the VERIFICATION
308/// site so the two ends of the substrate meet at ONE structural
309/// witness — every implementor's test module inherits both halves
310/// through ONE call rather than restating the four `matches!` arms
311/// per-implementor.
312#[track_caller]
313pub fn assert_tatara_domain_well_formed<T>()
314where
315    T: TataraDomain,
316{
317    let type_name = core::any::type_name::<T>();
318    let keyword = T::KEYWORD;
319
320    // (1) — KEYWORD is non-empty. A keyword-less form has no head
321    // symbol for the dispatch to key on; the trait's contract is
322    // structurally degenerate without a discriminating lexeme.
323    assert!(
324        !keyword.is_empty(),
325        "{type_name}: TataraDomain::KEYWORD is empty — the head symbol has no lexeme to dispatch on",
326    );
327
328    // (2) — KEYWORD classifies as `Atom::Symbol` through the substrate's
329    // typed-entry classifier `Atom::from_lexeme`. The reader routes every
330    // bare-atom lexeme through this ONE projection; anything that decodes
331    // as `Bool` / `Keyword` / `Int` / `Float` / `Str` never reaches the
332    // trait's head-match as a symbol. Subsumes the pre-lift "no leading
333    // ASCII digit" heuristic (`"42"` → `Int`, `"1.5"` → `Float`) AND
334    // catches the two shapes the pre-lift check silently accepted:
335    // `":foo"` → `Keyword` and `"#t"` / `"#f"` → `Bool`. Binding to the
336    // substrate's classifier means a future seventh `Atom` variant
337    // (`Char`, `Bigint`) strengthens the check ONCE.
338    match Atom::from_lexeme(keyword) {
339        Atom::Symbol(s) if s == keyword => {}
340        classified => panic!(
341            "{type_name}: KEYWORD {keyword:?} classifies as {classified:?} via Atom::from_lexeme — the Lisp reader would not project the head as a symbol at the trait's head-match",
342        ),
343    }
344
345    // (3) — KEYWORD contains no `Sexp::is_bare_atom_boundary` char. The
346    // substrate's typed reader-boundary projection covers BOTH the
347    // Unicode-whitespace surface (via `char::is_whitespace`) AND the
348    // seven non-whitespace terminators (`(` `)` `'` `` ` `` `,` `"` `;`)
349    // that would tokenize the KEYWORD into two tokens, breaking the
350    // trait's head-match structurally. Subsumes the pre-lift
351    // "no ASCII whitespace" heuristic; binding to the substrate's typed
352    // reader-boundary algebra means a future eighth outer-dispatch
353    // category (`#|` block-comment lead) strengthens the check ONCE.
354    if let Some(ch) = keyword.chars().find(|&c| Sexp::is_bare_atom_boundary(c)) {
355        panic!(
356            "{type_name}: KEYWORD {keyword:?} contains reader-boundary char {ch:?} (Sexp::is_bare_atom_boundary → true) — the Lisp reader would split it into multiple tokens, breaking the head-match structurally",
357        );
358    }
359
360    // (4) — a bare-atom form rejects with `NotAListForm { keyword }`.
361    // The typed-entry gate rejects the form-shape mismatch before
362    // descending into the list's head; the variant carries the keyword
363    // as structural data so authoring surfaces bind on
364    // `LispError::NotAListForm { keyword }` rather than substring-
365    // parsing the rendered message.
366    let bare_atom = Sexp::int(0);
367    match T::compile_from_sexp(&bare_atom) {
368        Err(LispError::NotAListForm { keyword: k }) => assert_eq!(
369            k, keyword,
370            "{type_name}: NotAListForm.keyword {k:?} drifted from T::KEYWORD {keyword:?}",
371        ),
372        Ok(_) => panic!(
373            "{type_name}: compile_from_sexp accepted a bare-atom form — the typed-entry gate would let a non-list form silently reach the kwargs decoder",
374        ),
375        Err(other) => panic!(
376            "{type_name}: compile_from_sexp on a bare-atom form emitted {other:?}, expected LispError::NotAListForm {{ keyword: {keyword:?} }}",
377        ),
378    }
379
380    // (5) — the empty list `()` rejects with
381    // `MissingHeadSymbol { keyword, got: None }`. `list.first()`
382    // returns `None`, so no head-witness is threaded through the
383    // rejection — the `got: None` arm names the empty-list sub-mode
384    // structurally.
385    let empty_list = Sexp::List(Vec::new());
386    match T::compile_from_sexp(&empty_list) {
387        Err(LispError::MissingHeadSymbol {
388            keyword: k,
389            got: None,
390        }) => assert_eq!(
391            k, keyword,
392            "{type_name}: MissingHeadSymbol.keyword {k:?} drifted from T::KEYWORD {keyword:?} on the empty-list arm",
393        ),
394        Ok(_) => panic!(
395            "{type_name}: compile_from_sexp accepted the empty list `()` — the typed-entry gate would let a headless form silently reach the head-match",
396        ),
397        Err(other) => panic!(
398            "{type_name}: compile_from_sexp on the empty list `()` emitted {other:?}, expected LispError::MissingHeadSymbol {{ keyword: {keyword:?}, got: None }}",
399        ),
400    }
401
402    // (6) — a list whose head is a non-symbol atom rejects with
403    // `MissingHeadSymbol { keyword, got: Some(_) }`. The offending
404    // element's typed identity threads through `SexpWitness` into the
405    // `got` slot so authoring surfaces can render "your form's head
406    // is `0`, an int, not a symbol" without re-parsing the source.
407    let non_symbol_head = Sexp::List(vec![Sexp::int(0)]);
408    match T::compile_from_sexp(&non_symbol_head) {
409        Err(LispError::MissingHeadSymbol {
410            keyword: k,
411            got: Some(_),
412        }) => assert_eq!(
413            k, keyword,
414            "{type_name}: MissingHeadSymbol.keyword {k:?} drifted from T::KEYWORD {keyword:?} on the non-symbol-head arm",
415        ),
416        Ok(_) => panic!(
417            "{type_name}: compile_from_sexp accepted a form with a non-symbol head — the typed-entry gate would let a numeric-head form silently reach the head-match",
418        ),
419        Err(other) => panic!(
420            "{type_name}: compile_from_sexp on a non-symbol-head form emitted {other:?}, expected LispError::MissingHeadSymbol {{ keyword: {keyword:?}, got: Some(_) }}",
421        ),
422    }
423
424    // (7) — a symbol-headed list whose head is NOT `T::KEYWORD`
425    // rejects with `HeadMismatch { keyword, got }`. The probe symbol
426    // is chosen to be lexically distinct from every conceivable
427    // canonical keyword across the substrate so no real implementor
428    // can accidentally match it. A hard equality assertion rules out
429    // the degenerate case where an implementor's KEYWORD collides
430    // with the reserved probe.
431    let probe = "__assert_tatara_domain_well_formed_probe__";
432    assert_ne!(
433        keyword, probe,
434        "{type_name}: T::KEYWORD collides with the reserved probe {probe:?} — the wrong-head arm cannot rule out an implementor whose KEYWORD equals the probe; rename either side",
435    );
436    let wrong_head = Sexp::List(vec![Sexp::symbol(probe)]);
437    match T::compile_from_sexp(&wrong_head) {
438        Err(LispError::HeadMismatch { keyword: k, got }) => {
439            assert_eq!(
440                k, keyword,
441                "{type_name}: HeadMismatch.keyword {k:?} drifted from T::KEYWORD {keyword:?}",
442            );
443            assert_eq!(
444                got, probe,
445                "{type_name}: HeadMismatch.got {got:?} drifted from the offending head {probe:?}",
446            );
447        }
448        Ok(_) => panic!(
449            "{type_name}: compile_from_sexp accepted a form headed by the reserved probe {probe:?} — the typed-entry gate would let a wrong-head form silently reach the kwargs decoder",
450        ),
451        Err(other) => panic!(
452            "{type_name}: compile_from_sexp on the wrong-head form emitted {other:?}, expected LispError::HeadMismatch {{ keyword: {keyword:?}, got: {probe:?} }}",
453        ),
454    }
455
456    // (8) — reader round-trip theorem. `read(KEYWORD)` produces exactly
457    // one form, and that form's `as_symbol()` projection returns
458    // `Some(KEYWORD)`. This is the SUFFICIENT condition invariants
459    // (6) + (7) together entail: (6) closes the classifier axis (the
460    // token, once assembled, classifies as `Atom::Symbol`), (7) closes
461    // the tokenizer axis (the KEYWORD arrives at the classifier as ONE
462    // token). Pinning the composition explicitly closes the LOOP at
463    // the verification site — a substrate-owned theorem the two
464    // structural checks compose into — and catches drift outside the
465    // closed-set structural surface (e.g. a future reader-level input
466    // transformation that couldn't be reduced to either axis).
467    match crate::reader::read(keyword) {
468        Ok(forms) if forms.len() == 1 && forms[0].as_symbol() == Some(keyword) => {}
469        Ok(forms) => panic!(
470            "{type_name}: KEYWORD {keyword:?} did not round-trip through read → as_symbol — read produced {forms:?} (expected one form projecting to Some({keyword:?}))",
471        ),
472        Err(err) => panic!(
473            "{type_name}: KEYWORD {keyword:?} failed to tokenize at all — read returned {err:?}",
474        ),
475    }
476}
477
478// ── kwarg parsing + typed extractors used by the derive macro ──────
479
480pub type Kwargs<'a> = HashMap<String, &'a Sexp>;
481
482/// Parse `:k v :k v …` into a kwargs map. Rejects duplicate keywords so the
483/// typed-entry gate fires on `(defX :name "a" :name "b")` instead of silently
484/// keeping the last value — same posture `reject_unknown_kwargs` takes for
485/// typo'd kwargs. A duplicate is ill-typed input: the author either meant
486/// distinct keys (typo) or a list (`:tags ("a" "b")`).
487///
488/// Odd-length kwargs lists fail with `LispError::OddKwargs { dangling }`,
489/// where `dangling` is the offending element's `Sexp::Display` projection
490/// — `:query` for a keyword whose value got lost, or the literal form of a
491/// stray non-keyword. Naming the dangling element keeps the diagnostic
492/// structurally complete instead of merely flagging "odd number"; authoring
493/// surfaces (REPL, LSP, `tatara-check`) render the mismatch without
494/// re-reading the source.
495///
496/// Theory anchor: THEORY.md §II.1 invariant 1 — "Typed entry. Ill-typed input
497/// errors before the value exists." THEORY.md §V.1 — "knowable platform"
498/// requires the diagnostic to name what was passed, not only what was
499/// expected.
500pub fn parse_kwargs(args: &[Sexp]) -> Result<Kwargs<'_>> {
501    let mut kw = HashMap::new();
502    let mut i = 0;
503    while i + 1 < args.len() {
504        let key = args[i].as_keyword().ok_or_else(|| {
505            type_mismatch(kwargs_pos_form(i), ExpectedKwargShape::Keyword, &args[i])
506        })?;
507        if kw.insert(key.to_string(), &args[i + 1]).is_some() {
508            return Err(duplicate_kwarg(key));
509        }
510        i += 2;
511    }
512    if i < args.len() {
513        return Err(LispError::OddKwargs {
514            dangling: args[i].to_string(),
515        });
516    }
517    Ok(kw)
518}
519
520/// Reject any keyword in `kw` that isn't in `allowed`. Closes the typed-entry
521/// hole where typos like `:tthreshold 0.99` would otherwise parse silently
522/// with the field unset. Emitted by `#[derive(TataraDomain)]` after
523/// `parse_kwargs` so every derived domain rejects unknown kwargs by default.
524///
525/// When the offending keyword is a near-miss of an allowed kwarg (bounded
526/// edit distance via `suggest`), the diagnostic prepends a `did you mean
527/// :X?` hint so the operator goes straight to the fix without scanning the
528/// allowed-list. The hint is purely additive — `unknown keyword` and the
529/// full allowed list still appear — so existing assertions
530/// (`msg.contains("unknown keyword")`, `msg.contains(":threshold")`) pass
531/// unchanged.
532///
533/// Returns the structural `LispError::UnknownKwarg { key, hint, allowed }`
534/// variant — same posture as the `OddKwargs` / `DuplicateKwarg` /
535/// `MissingKwarg` siblings. After this lift every distinct typed-entry
536/// kwarg-gate failure mode binds to ONE structural variant of `LispError`,
537/// not a `Compile`-shaped substring.
538///
539/// Theory anchor: THEORY.md §II.1 invariant 1 (typed entry — "Ill-typed input
540/// errors before the value exists"); §V.1 ("knowable platform … Render
541/// Anywhere" — naming the likely intended keyword is the floor of a
542/// constructive diagnostic).
543pub fn reject_unknown_kwargs(kw: &Kwargs<'_>, allowed: &[&str]) -> Result<()> {
544    for key in kw.keys() {
545        if !allowed.contains(&key.as_str()) {
546            return Err(unknown_kwarg(key, allowed));
547        }
548    }
549    Ok(())
550}
551
552/// Parse `:k v :k v …` AND gate the result against a closed allowed-key set —
553/// the fused typed-entry kwargs gate. ONE named primitive every
554/// `TataraDomain` impl shares for "compile-from-args header": every
555/// `#[derive(TataraDomain)]`-generated `compile_from_args` body emitted by
556/// `tatara-lisp-derive` begins with this single call, and every hand-
557/// written impl in the forge / lattice / tameshi crates that wants the
558/// substrate's closed-set kwargs posture binds to ONE function instead of
559/// remembering to call [`parse_kwargs`] AND [`reject_unknown_kwargs`] in
560/// that order.
561///
562/// Before this lift the derive emitted the two-call sequence
563/// `let kw = parse_kwargs(args)?; reject_unknown_kwargs(&kw, ALLOWED)?;`
564/// verbatim at every consumer's `compile_from_args` body — well past the
565/// ≥2 PRIME-DIRECTIVE trigger once the fleet's seven-plus
566/// `#[derive(TataraDomain)]` consumers (ProcessSpec, EphemeralSpec,
567/// MonitorSpec, NotifySpec, AlertPolicySpec, EscalationStep, CompilerSpec,
568/// and every future derived domain) inline the same two lines through the
569/// proc-macro emitter. The two-call sequence is structurally one
570/// operation — "parse the keyword/value run, then assert every key sits
571/// in the static allowed-set" — and a regression that drifts ONE
572/// consumer's gate from the others (e.g. the derive emits one call but a
573/// hand-written impl emits only the other, or a future emitter swaps the
574/// order so `reject_unknown_kwargs` runs against an unparsed slice) is
575/// the silent typed-entry hole this primitive closes by construction.
576///
577/// The two stages are composed in the canonical order:
578///   1. [`parse_kwargs`] runs first — odd-length input, non-keyword at a
579///      key position, and duplicate keys surface as their structural
580///      variants ([`LispError::OddKwargs`] / [`LispError::TypeMismatch`]
581///      with `form = kwargs_pos_form(i)` / [`LispError::DuplicateKwarg`]).
582///   2. Only on `Ok(kw)` does [`reject_unknown_kwargs`] run — keys
583///      outside `allowed` surface as [`LispError::UnknownKwarg`] with the
584///      typed `hint` / `allowed` slots populated.
585///
586/// This ordering is structural: `reject_unknown_kwargs` cannot inspect
587/// an unparsed `&[Sexp]`, so parse-stage rejection MUST precede
588/// reject-stage rejection. A call with BOTH an odd-length tail AND an
589/// unknown kwarg surfaces as `OddKwargs` (parse-stage), never as
590/// `UnknownKwarg` (reject-stage) — the gate is single-pass and the
591/// stages compose in exactly one order. Naming the composition makes
592/// that order load-bearing data on the substrate, not a discipline the
593/// derive's emit template happens to encode correctly.
594///
595/// Theory anchor: THEORY.md §II.1 invariant 1 — "Typed entry. Ill-typed
596/// input errors before the value exists." The kwargs gate is the
597/// typed-entry boundary for every derived domain; closing the gate
598/// behind ONE primitive lifts the closed-set posture from the derive's
599/// emit template to the substrate's typed surface. THEORY.md §VI.1 —
600/// generation over composition; the two-call sequence in the derive's
601/// emit template, multiplied across every consumer in the fleet, is
602/// well past the three-times rule once the structural shape is named.
603/// THEORY.md §V.1 — knowable platform; authoring tools (REPL, LSP,
604/// `tatara-check`) that want to surface "this form's kwargs gate
605/// rejected because …" bind to the unified primitive's call site
606/// instead of guessing which of the two component functions the
607/// rejection came from. THEORY.md §II.1 invariant 2 (free middle) —
608/// every consumer routes through the SAME composition, so a regression
609/// that drifts the order or skips a stage on one path can never reach
610/// the substrate's runtime: the type system binds every consumer to
611/// the fused primitive's single emission shape.
612///
613/// Lifetime: the returned [`Kwargs<'a>`] borrows from `args` (the typed
614/// alias is `HashMap<String, &'a Sexp>`), so the call site keeps the
615/// `&[Sexp]` slice alive for the lifetime of the parsed map — same
616/// posture as [`parse_kwargs`]. The fused primitive does not allocate
617/// beyond [`parse_kwargs`]'s map: [`reject_unknown_kwargs`] is a pure
618/// `O(allowed.len() · kw.len())` scan that returns `Ok(())` on success.
619pub fn parse_kwargs_strict<'a>(args: &'a [Sexp], allowed: &[&str]) -> Result<Kwargs<'a>> {
620    let kw = parse_kwargs(args)?;
621    reject_unknown_kwargs(&kw, allowed)?;
622    Ok(kw)
623}
624
625/// Structural unknown-kwarg builder. Returns the dedicated
626/// `LispError::UnknownKwarg` variant so authoring surfaces (REPL, LSP,
627/// `tatara-check`) bind to first-class `key` / `hint` / `allowed`
628/// fields instead of substring-parsing the rendered message. Display
629/// matches the legacy `Compile { form: kwarg_form(key), message:
630/// "unknown keyword (...)" }` rendering byte-for-byte
631/// (`"compile error in :{key}: unknown keyword (did you mean :{hint}?;
632/// allowed: :a, :b, :c)"` with a hint, `"compile error in :{key}:
633/// unknown keyword (allowed: :a, :b, :c)"` without), so existing
634/// `msg.contains("unknown keyword")` / `msg.contains(":threshold")` /
635/// `msg.contains("did you mean :threshold?")` assertions keep
636/// passing.
637///
638/// Encapsulates the three otherwise-inline steps every unknown-kwarg
639/// site shares: (1) ranking the near-miss via `suggest`, (2) sorting
640/// the allowed-set lexicographically so two operators on two machines
641/// see the same message for the same input — diagnostics are
642/// deterministic, (3) materializing the allowed-set as owned
643/// `Vec<String>` so the variant lives independent of the call frame
644/// and crosses thread boundaries cleanly. A future "registry-aware
645/// near-miss for unknown registry-dispatched forms" path
646/// (`tatara-check`'s unknown-keyword fallthrough) binds to this
647/// helper rather than re-formatting the shape per call site.
648///
649/// `reject_unknown_kwargs` is the first consumer; hand-written
650/// `TataraDomain` impls in the forge / lattice / tameshi crates that
651/// don't fit the derive's closed-field-type set bind to the
652/// substrate's primitive instead of inline `LispError::Compile { … }`
653/// assembly. After this lift `reject_unknown_kwargs` is no longer the
654/// last `LispError::Compile { ... }` site in the kwarg-gate's
655/// diagnostic surface — every distinct kwarg-gate failure mode is now
656/// a structural variant of `LispError`.
657///
658/// Theory anchor: THEORY.md §V.1 — "Knowable platform … Render
659/// Anywhere." A diagnostic whose offending `key` / hint / allowed-set
660/// are embedded in a free-form message is structurally incomplete; an
661/// authoring surface that wants to render a squiggly under the typo
662/// or surface the allowed-set as completions must re-parse the
663/// message. After this lift the slots exist in the variant's data
664/// shape itself. THEORY.md §II.1 invariant 1 (typed entry) — an
665/// unknown kwarg is exactly the failure mode the typed-entry gate
666/// exists to reject; naming it structurally is the typed posture for
667/// that gate's diagnostic. THEORY.md §VI.1 (generation over
668/// composition — one named primitive per structural shape).
669#[must_use]
670pub fn unknown_kwarg(key: &str, allowed: &[&str]) -> LispError {
671    let hint = suggest(key, allowed).map(String::from);
672    let mut sorted: Vec<String> = allowed.iter().map(|s| (*s).to_string()).collect();
673    sorted.sort();
674    LispError::UnknownKwarg {
675        key: key.to_string(),
676        hint,
677        allowed: sorted,
678    }
679}
680
681/// The typed-entry kwargs-gate's OPTIONAL lookup primitive — `Some(&Sexp)`
682/// when `key` is present in `kw`, `None` when absent. ONE named projection
683/// on the substrate's `Kwargs<'a>` algebra every optional-kwarg consumer
684/// (`extract_optional_atom`, `extract_list`, `extract_optional_via_serde`)
685/// routes through, and the sibling [`required`](self::required) composes
686/// directly atop it as `optional(kw, key).ok_or_else(|| missing_kwarg(key))`.
687/// Before this lift the same `kw.get(key).copied()` projection — turning
688/// `Option<&&'a Sexp>` (the raw `HashMap::get` return) into the consumer-
689/// shaped `Option<&'a Sexp>` — was inlined verbatim at FOUR sites: once
690/// inside `required`'s composition, and once inside each of the three
691/// optional consumers' absence-handling preludes. After this lift the
692/// projection lives in ONE place; `required` becomes the closed-form
693/// composition `optional + ok_or_else(missing_kwarg)`, and the three
694/// optional consumers read through `optional(kw, key)` without re-stating
695/// the `Option<&&Sexp>` → `Option<&Sexp>` projection at each call site.
696///
697/// Sibling pair with [`required`](self::required): together the two close
698/// the substrate's typed-entry kwargs-LOOKUP surface — `required` is the
699/// mandatory-presence path returning `Result<&Sexp>` (absence → typed
700/// `LispError::MissingKwarg`); `optional` is the may-be-absent path
701/// returning `Option<&Sexp>` (absence → `None`, the consumer decides
702/// what default behavior absence triggers — `None` for atoms, empty `Vec`
703/// for lists, `Sexp::Nil` for params). The TWO primitives between them
704/// cover every consumer's kwargs-lookup posture; a third would be a
705/// structural extension the type system would surface at every call site.
706/// The composition `required = optional + ok_or_else(missing_kwarg)` is
707/// the structural identity binding the two — `required(kw, key)` and
708/// `optional(kw, key).ok_or_else(|| missing_kwarg(key))` are
709/// observationally identical, and naming the composition makes the
710/// identity a substrate-owned theorem rather than a hand-inlined
711/// duplication discipline four sites had to keep in lockstep.
712///
713/// The returned `&'a Sexp` carries the SAME lifetime contract as
714/// [`required`](self::required)'s `Ok(&'a Sexp)` — the projection borrows
715/// from the kwargs map's value slot via `.copied()`, so the optional
716/// consumers can hold the reference through their absence-arm match
717/// without an intermediate clone. `'a` is the outer borrow lifetime
718/// (mirroring `required`); the inner `'_` is free so call sites with
719/// `Kwargs<'a>` (the typical `parse_kwargs` output binding) and
720/// `Kwargs<'static>` (a future static-bound shape) both type-check
721/// uniformly.
722///
723/// Theory anchor: THEORY.md §VI.1 — generation over composition; four
724/// inline copies of one structural projection past the three-times rule
725/// once the structural shape is named. THEORY.md §V.1 — knowable
726/// platform; the substrate's typed-entry kwargs-lookup surface is now
727/// the named PAIR `{required, optional}` — authoring tools (REPL, LSP,
728/// `tatara-check`) that want to surface "this domain reads kwarg X as
729/// optional" bind to the `optional` primitive's signature, not the
730/// HashMap-level `get` chain. THEORY.md §II.1 invariant 1 — typed entry;
731/// the kwargs-lookup gate's two postures (required vs. optional) are
732/// now structurally named, so a future fourth posture (e.g. "required
733/// with non-empty constraint") extends the pair as a peer rather than
734/// silently piggybacking on the inlined `get(key).copied()` chain.
735/// THEORY.md §II.1 invariant 2 — free middle; the typed-entry kwargs
736/// gate's lookup shape is uniform across every derived domain (and
737/// every hand-written `TataraDomain` impl), so a future emitter that
738/// wants to instrument the lookup (a span-aware lookup, a debug-mode
739/// lookup logger) wraps ONE function rather than four inline sites.
740#[must_use]
741pub fn optional<'a>(kw: &'a Kwargs<'_>, key: &str) -> Option<&'a Sexp> {
742    kw.get(key).copied()
743}
744
745/// The typed-entry kwargs-gate's REQUIRED lookup primitive — `Ok(&Sexp)`
746/// when `key` is present in `kw`, `Err(LispError::MissingKwarg)` when
747/// absent. Composes [`optional`](self::optional) (the may-be-absent
748/// lookup) with [`missing_kwarg`](self::missing_kwarg) (the canonical
749/// rejection on absence) so the substrate's typed-entry kwargs-lookup
750/// surface is named as the PAIR `{required, optional}` with `required`
751/// expressed as the closed-form composition of its two sibling
752/// primitives. Sibling pair documented in [`optional`](self::optional).
753pub fn required<'a>(kw: &'a Kwargs<'_>, key: &str) -> Result<&'a Sexp> {
754    optional(kw, key).ok_or_else(|| missing_kwarg(key))
755}
756
757/// Canonical typed `form:` value for a kwarg-level `LispError::TypeMismatch`.
758/// Every typed-entry diagnostic that names a kwarg (`required`, `type_err`,
759/// `deserialize_err`, the duplicate-keyword paths in `parse_kwargs` and
760/// `sexp_to_json`, the unknown-keyword path in `reject_unknown_kwargs`,
761/// the non-list path in `extract_vec_via_serde`) routes through this one
762/// helper, so authoring surfaces (REPL, LSP, `tatara-check`) bind to a
763/// single named primitive rather than seven inline `format!(":{key}")`
764/// copies.
765///
766/// Returns the typed `crate::error::KwargPath::Named(key.to_string())` value
767/// directly — consumers feed it into `LispError::TypeMismatch.form: KwargPath`
768/// where it is structurally bound via pattern-match (`KwargPath::Named(_)`),
769/// not substring-matched. The canonical `:<key>` literal lives in ONE place
770/// (`KwargPath`'s Display match arm) alongside its sibling shapes
771/// `kwarg_item_form` / `kwargs_pos_form`, so a typo in any of the three
772/// can never drift independent of the others.
773///
774/// Theory anchor: THEORY.md §VI.1 — "Generation over composition.
775/// Three-times rule: when a pattern repeats three times, extract an
776/// archetype/backend/synthesizer and generate from it." Seven inline
777/// copies in one module is the textbook signal. THEORY.md §V.1 —
778/// knowable platform; the typed `KwargPath` enum encodes the closed set
779/// of three reachable path shapes at the type level so authoring tools
780/// bind to path-shape identity rather than substring-matching the
781/// rendered prefix. THEORY.md §II.1 invariant 1 (typed entry) — the
782/// kwargs-path identity is now load-bearing data on the variant rather
783/// than a projection-to-String.
784#[must_use]
785pub fn kwarg_form(key: &str) -> crate::error::KwargPath {
786    crate::error::KwargPath::named(key)
787}
788
789/// Canonical `form:` label for a failure inside the Nth item of a
790/// list-typed kwarg — `:steps[1]` when the second item of `:steps` fails
791/// to deserialize, `:tags[2]` when the third tag isn't a string. The
792/// substrate names the item-path so the operator sees both *which kwarg*
793/// and *which element* misfired without re-counting from the source.
794///
795/// Frontier inspiration: JSON Pointer (`/steps/1`) and jq path
796/// expressions — lossless paths through value projections so downstream
797/// tooling (LSP underlines, structural rewrites) bind to the path
798/// instead of parsing the diagnostic message. Translation through
799/// pleme-io primitives: the surface syntax authors already write
800/// (`:<key>` + `[idx]`), no new error variant, no new IR layer. When a
801/// future run gives `Sexp` source spans, the indexed form gains a
802/// position the same way `kwarg_form` will — one helper, every consumer
803/// inherits.
804///
805/// Theory anchor: THEORY.md §V.1 — "Knowable platform … Render
806/// Anywhere." A diagnostic that names the kwarg but loses the item index
807/// is structurally incomplete; the path completes it.
808///
809/// Returns the typed `crate::error::KwargPath::Item { key, idx }` value
810/// directly — consumers feed it into `LispError::TypeMismatch.form: KwargPath`
811/// where it is structurally bound via pattern-match (`KwargPath::Item { .. }`),
812/// not substring-matched. The canonical `:<key>[<idx>]` literal lives in ONE
813/// place alongside `kwarg_form` / `kwargs_pos_form`. See `kwarg_form` for the
814/// typed-enum's role.
815#[must_use]
816pub fn kwarg_item_form(key: &str, idx: usize) -> crate::error::KwargPath {
817    crate::error::KwargPath::item(key, idx)
818}
819
820/// Canonical `form:` label for a kwargs-list slot whose key position is
821/// not yet known — the slot itself failed the
822/// "this-position-must-be-a-keyword" gate, so there is no `:<key>` to
823/// hang the path off. Renders `kwargs[<idx>]` — parallel to
824/// `kwarg_item_form`'s `:<key>[<idx>]` shape, rooted at the kwargs
825/// slice rather than at a named kwarg.
826///
827/// Used by `parse_kwargs` to label the structural type-mismatch when
828/// the element at an even position isn't a `Sexp::Atom(Keyword(_))`.
829/// Pairing this label with the existing `LispError::TypeMismatch`
830/// variant (`expected: "keyword"`, `got: sexp_type_name(_)`) means
831/// authoring surfaces (REPL, LSP, `tatara-check`) bind to ONE variant
832/// identity for every typed-entry mismatch — `:<key>` for kwarg-level
833/// failures, `:<key>[<idx>]` for per-item failures, and now
834/// `kwargs[<idx>]` for not-a-keyword-yet failures. When a future run
835/// gives `Sexp` source spans, the slot-form gains a position the same
836/// way `kwarg_form` / `kwarg_item_form` will — one helper, every
837/// consumer inherits.
838///
839/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
840/// fourth `form:`-label primitive after `kwarg_form`,
841/// `kwarg_item_form`, and the registry-keyword path; one helper per
842/// distinct path shape so the substrate's diagnostic surface stays
843/// structurally complete).
844///
845/// Returns the typed `crate::error::KwargPath::Slot(idx)` value directly —
846/// consumers feed it into `LispError::TypeMismatch.form: KwargPath` where it
847/// is structurally bound via pattern-match (`KwargPath::Slot(_)`), not
848/// substring-matched. The canonical `kwargs[<idx>]` literal lives in ONE
849/// place alongside `kwarg_form` / `kwarg_item_form`. See `kwarg_form` for
850/// the typed-enum's role.
851#[must_use]
852pub fn kwargs_pos_form(idx: usize) -> crate::error::KwargPath {
853    crate::error::KwargPath::Slot(idx)
854}
855
856/// Typed projection of a `Sexp`'s outermost shape into the closed-set
857/// `SexpShape` enum — the twelve reachable shapes the reader can produce.
858/// Used by the typed extractors to thread the observed shape into
859/// `LispError::TypeMismatch.got: SexpShape` /
860/// `LispError::NamedFormNonSymbolName.got: SexpShape` so a typed-entry
861/// gate's rejection-shape identity is load-bearing data in the type
862/// system, not a `&'static str` projection at the helper boundary.
863/// Consumers (REPL, LSP, `tatara-check`) pattern-match on
864/// `SexpShape::Int` etc. directly rather than substring-matching the
865/// rendered `got` literal.
866///
867/// Theory anchor: THEORY.md §V.1 — knowable platform. An error that names
868/// only the expected side leaves the operator to guess what was passed;
869/// naming both is the floor of constructive diagnostics. The typed
870/// projection extends that posture: not just naming both sides, but
871/// encoding the observed shape's identity as a TYPE so a regression that
872/// drifts the label becomes a compile error, not a runtime substring
873/// drift. When a future run gives `Sexp` source spans, this helper is
874/// the single site that learns to thread `got Y at <pos>`; today's call
875/// sites pick up the span automatically.
876/// Free-function delegate to the [`Sexp::shape`] inherent method on the
877/// `Sexp` algebra. Retained for backwards compatibility with consumers
878/// that import this helper by name (no callers reach in through the
879/// module path post-lift); the inherent method is the canonical site
880/// for the (Sexp variant, SexpShape variant) projection family —
881/// `Atom::kind().sexp_shape()` (atomic axis), `as_quote_form().map(|(qf,
882/// _)| qf.sexp_shape())` (quote-family axis), with `Nil` / `List`
883/// arms projecting to their own `SexpShape` variants directly. See
884/// [`Sexp::shape`]'s docstring for the closed-set composition law and
885/// the THEORY anchors.
886#[must_use]
887pub fn sexp_shape(s: &Sexp) -> SexpShape {
888    s.shape()
889}
890
891/// Thin delegate to [`Sexp::type_name`] retained for callers that
892/// want the free-function reach — the canonical site is now the
893/// inherent method on the [`Sexp`] algebra. Stable, human-readable
894/// name of a `Sexp`'s outermost shape — the `&'static str`
895/// projection of `s.shape().label()`. Retained for callers that
896/// want the canonical literal directly (e.g. test assertions on the
897/// rendered `expected X, got Y` substring); new code constructing
898/// `LispError::TypeMismatch` / `NamedFormNonSymbolName` passes
899/// through `sexp_shape` directly so the typed identity rides the
900/// variant slot rather than collapsing through the literal at the
901/// helper boundary.
902///
903/// Composition law: `sexp_type_name(s) == s.type_name() ==
904/// s.shape().label()` for every `s: &Sexp`. Pre-lift the dispatcher
905/// lived here as the canonical site; post-lift the inherent method
906/// [`Sexp::type_name`] is the canonical site and this free function
907/// delegates so existing callers continue to compile. Same lift
908/// posture as [`super::domain::sexp_shape`] → [`Sexp::shape`]
909/// (commit 121bb60), [`super::domain::sexp_witness`] →
910/// [`Sexp::witness`] (commit a427e3b), [`super::domain::sexp_to_json`]
911/// → [`Sexp::to_json`] (commit 875ee3b), and
912/// [`super::domain::json_to_sexp`] → [`Sexp::from_json`] (commit
913/// 4a467eb): the algebra-level projection sits on the value, the
914/// free function is a one-line thin delegate. The
915/// `LispError::TypeMismatch.got` projection at
916/// `compile::compile_typed`'s typed-entry rejection site and every
917/// legacy substring-grep rejection-message test routes through
918/// `s.type_name()` after this lift.
919///
920/// Sibling of [`sexp_shape`] (the typed-shape projection feeding
921/// `TypeMismatch.expected` typed slot) and [`sexp_witness`] (the
922/// joint typed-shape + renderable-literal projection feeding
923/// `NamedFormNonSymbolName.got` / `NonSymbolUnquoteTarget.got` /
924/// etc.). [`Sexp::type_name`] is the canonical-label-only
925/// projection — the `&'static str` literal flattened from the
926/// typed identity for substring-grep callers and the
927/// `TypeMismatch.got` slot.
928///
929/// Theory anchor: THEORY.md §V.1 — knowable platform / constructive
930/// diagnostics. The canonical-label projection becomes a NAMED
931/// primitive on the substrate's `Sexp` algebra rather than a free
932/// function consumers reach across module boundaries to call.
933/// THEORY.md §VI.1 — generation over composition; the projection now
934/// lives on the typed `Sexp` algebra alongside `Sexp::shape` /
935/// `Sexp::witness` / `Sexp::to_json` / `Sexp::from_json`, so a
936/// future `Sexp` variant lands at the algebra's match site (via
937/// `Sexp::shape`'s exhaustive arm) without a module-path
938/// indirection. THEORY.md §II.1 invariant 1 — typed entry; the
939/// offending Sexp's canonical-label identity is part of the proof
940/// of WHAT the typed-entry gate rejected.
941#[must_use]
942pub fn sexp_type_name(s: &Sexp) -> &'static str {
943    s.type_name()
944}
945
946/// Thin delegate to [`Sexp::witness`] retained for callers that want
947/// the free-function reach — the canonical site is now the inherent
948/// method on the [`Sexp`] algebra. Pairs the typed [`SexpShape`]
949/// (structural identity) with the renderable [`Sexp::Display`]
950/// projection in ONE owned [`SexpWitness`] value so the variant lives
951/// independent of the call frame and crosses thread boundaries
952/// cleanly.
953///
954/// Composition law: `sexp_witness(s) == s.witness()` for every
955/// `s: &Sexp`. Pre-lift the dispatcher lived here as the canonical
956/// site; post-lift the inherent method [`Sexp::witness`] is the
957/// canonical site and this free function delegates so existing
958/// callers continue to compile. Same lift posture as
959/// [`super::domain::sexp_shape`] → [`Sexp::shape`] (commit 121bb60):
960/// the algebra-level projection sits on the value, the free
961/// function is a one-line thin delegate. The 8 typed-entry
962/// rejection-builder callers in `macro_expand.rs`
963/// (`non_symbol_unquote_target`, `splice_outside_list`,
964/// `non_symbol_param`, `rest_param_missing_name`,
965/// `rest_param_trailing_tokens`, `optional_param_malformed`,
966/// `defmacro_non_symbol_name`, `defmacro_non_list_params`), the
967/// `missing_head_err` invocation in the `TataraDomain` blanket impl
968/// at line 46, and the typed-exit `rewriter_non_list_err` builder
969/// all route through `s.witness()` after this lift.
970///
971/// Sibling of [`sexp_shape`] (the shape-only projection feeding
972/// `TypeMismatch.got` / `NamedFormNonSymbolName.got`) and
973/// [`sexp_type_name`] (the `&'static str`-only projection feeding
974/// legacy substring-grep consumers). [`Sexp::witness`] is the
975/// typed JOINT projection — both halves of the identity bundled
976/// into ONE owned `SexpWitness` value.
977///
978/// Theory anchor: THEORY.md §V.1 — knowable platform / constructive
979/// diagnostics. An error that names only the shape leaves the operator
980/// to guess what they wrote; an error that names only the literal
981/// withholds the structural identity tools want to pattern-match on.
982/// The witness names both. THEORY.md §VI.1 — generation over
983/// composition; the projection now lives on the typed `Sexp` algebra
984/// alongside `Sexp::shape`, so a future `Sexp` variant lands at the
985/// algebra's match site (via `Sexp::shape`'s exhaustive arm) without
986/// a module-path indirection. THEORY.md §II.1 invariant 1 — typed
987/// entry; the offending Sexp's identity is part of the proof of WHAT
988/// the typed-entry gate rejected.
989#[must_use]
990pub fn sexp_witness(s: &Sexp) -> SexpWitness {
991    s.witness()
992}
993// ── Near-match suggestion ──────────────────────────────────────────
994//
995// The metric itself lives in `tatara-closed-set`, its only consumer
996// (`ClosedSet::suggest_closest`). It was briefly homed here by phase 2
997// step 1, which created a `tatara-closed-set → tatara-lisp` edge and so
998// made the reverse edge — the one step 2 needs, to carry LispError
999// variants whose payloads are ClosedSet implementors — a cargo cycle.
1000// INVERTed: `suggest` went back to its only call site and this crate
1001// depends on that one instead.
1002//
1003// Re-exported rather than moved-and-forgotten so `tatara_lisp::domain::suggest`
1004// still resolves — A-side parity, and step 3's `unknown_kwarg` /
1005// `suggest_keyword` hints reach the metric through this path.
1006// One primitive, one implementation of edit distance, either way.
1007
1008/// The substrate's bounded edit-distance near-match metric.
1009///
1010/// Defined in [`tatara_closed_set`] (its only consumer) and re-exported
1011/// here so `tatara_lisp::domain::suggest` stays the canonical path.
1012pub use tatara_closed_set::suggest;
1013
1014/// Structural duplicate-kwarg builder. Returns the dedicated
1015/// `LispError::DuplicateKwarg` variant so authoring surfaces (REPL, LSP,
1016/// `tatara-check`) bind to a first-class `key` field instead of
1017/// substring-parsing the rendered message. Display matches the legacy
1018/// `Compile { form: kwarg_form(key), message: "duplicate keyword" }`
1019/// rendering byte-for-byte (`"compile error in :{key}: duplicate
1020/// keyword"`), so existing `msg.contains("duplicate keyword")` /
1021/// `msg.contains(":name")` assertions keep passing.
1022///
1023/// Two inline copies of the same triple — `parse_kwargs`'s top-level
1024/// duplicate-keyword path and `sexp_to_json`'s nested-kwargs duplicate-
1025/// keyword path — used to assemble this shape by hand. One named
1026/// primitive lifts both into the substrate's structural-variant surface,
1027/// so every `parse_kwargs` failure mode (`OddKwargs` for odd length,
1028/// `TypeMismatch` for not-a-keyword-at-position, `DuplicateKwarg` for
1029/// duplicate key) is now a structural variant of `LispError`, not a
1030/// `Compile`-shaped substring.
1031///
1032/// Theory anchor: THEORY.md §V.1 — "Knowable platform … Render
1033/// Anywhere." A diagnostic whose offending `key` is embedded in a
1034/// free-form message is structurally incomplete; an authoring surface
1035/// that wants to render a squiggly under the duplicate or hint a fix
1036/// must re-parse the message. After this lift the slot exists in the
1037/// variant's data shape itself. THEORY.md §II.1 invariant 1 (typed
1038/// entry — "Ill-typed input errors before the value exists") — a
1039/// duplicate kwarg is exactly the failure mode the typed-entry gate
1040/// exists to reject; naming it structurally is the typed posture for
1041/// that gate's diagnostic.
1042#[must_use]
1043pub fn duplicate_kwarg(key: &str) -> LispError {
1044    LispError::DuplicateKwarg {
1045        key: key.to_string(),
1046    }
1047}
1048
1049/// Structural missing-kwarg builder. Returns the dedicated
1050/// `LispError::MissingKwarg` variant so authoring surfaces (REPL, LSP,
1051/// `tatara-check`) bind to a first-class `key` field instead of
1052/// substring-parsing the rendered message. Display matches the legacy
1053/// `Compile { form: kwarg_form(key), message: "required but not
1054/// provided" }` rendering byte-for-byte (`"compile error in :{key}:
1055/// required but not provided"`), so existing
1056/// `msg.contains("required")` / `msg.contains(":threshold")` assertions
1057/// keep passing.
1058///
1059/// `required` (the kwarg lookup helper that fronts every typed
1060/// extractor — `extract_string`, `extract_int`, `extract_float`,
1061/// `extract_bool`, `extract_via_serde`, plus every hand-written
1062/// `TataraDomain` impl in the forge / lattice / tameshi crates) used
1063/// to assemble this shape inline. One named primitive lifts that into
1064/// the substrate's structural-variant surface, so every kwarg-level
1065/// "required-but-absent" failure routes through ONE function instead
1066/// of re-formatting the shape per call site. After this lift every
1067/// distinct `parse_kwargs` + `required` typed-entry kwarg failure mode
1068/// (odd length, not-a-keyword-at-position, duplicate key, missing
1069/// required key) is now a structural variant of `LispError`, not a
1070/// `Compile`-shaped substring.
1071///
1072/// Sibling of the pre-existing `Missing(&'static str)` variant —
1073/// `MissingKwarg` covers the runtime-key path the kwargs extractors
1074/// share (every derive-generated extractor and every hand-written
1075/// `TataraDomain` impl); `Missing` stays for compile-time-known names.
1076///
1077/// Theory anchor: THEORY.md §V.1 — "Knowable platform … Render
1078/// Anywhere." A diagnostic whose offending `key` is embedded in a
1079/// free-form message is structurally incomplete; an authoring surface
1080/// that wants to render a squiggly under the missing kwarg slot or
1081/// render a "did you mean :X?" hint must re-parse the message. After
1082/// this lift the slot exists in the variant's data shape itself.
1083/// THEORY.md §II.1 invariant 1 (typed entry — "Ill-typed input errors
1084/// before the value exists") — a missing required kwarg is exactly the
1085/// failure mode the typed-entry gate exists to reject; naming it
1086/// structurally is the typed posture for that gate's diagnostic.
1087#[must_use]
1088pub fn missing_kwarg(key: &str) -> LispError {
1089    LispError::MissingKwarg {
1090        key: key.to_string(),
1091    }
1092}
1093
1094/// Structural type-mismatch builder. Pairs a typed `form: KwargPath`
1095/// (typically `kwarg_form(_)` / `kwarg_item_form(_, _)` /
1096/// `kwargs_pos_form(_)`) with the static `expected` label and the `got`
1097/// projection of the offending `Sexp` through `sexp_type_name`. Returns
1098/// the dedicated `LispError::TypeMismatch` variant so authoring surfaces
1099/// (REPL, LSP, `tatara-check`) bind to first-class `form`/`expected`/`got`
1100/// fields — pattern-matching on `KwargPath::Item { .. }` etc. directly —
1101/// instead of substring-parsing the rendered message.
1102///
1103/// Three inline `format!("expected {X}, got {}", sexp_type_name(_))`
1104/// copies in this module (`type_err`, `extract_string_list` per-item,
1105/// `extract_vec_via_serde` non-list) used to assemble the same shape by
1106/// hand; the three-times rule (THEORY.md §VI.1) calls for one named
1107/// primitive. This is it. Future runs that thread `pos: Option<usize>`
1108/// from `Sexp` spans add ONE field to the variant; every type-mismatch
1109/// site inherits positional rendering with no consumer changes.
1110#[must_use]
1111pub fn type_mismatch(
1112    form: crate::error::KwargPath,
1113    expected: ExpectedKwargShape,
1114    got: &Sexp,
1115) -> LispError {
1116    LispError::TypeMismatch {
1117        form,
1118        expected,
1119        got: got.shape(),
1120    }
1121}
1122
1123fn type_err(key: &str, expected: ExpectedKwargShape, got: &Sexp) -> LispError {
1124    type_mismatch(kwarg_form(key), expected, got)
1125}
1126
1127/// Item-indexed sibling of `type_err` — pairs `kwarg_item_form` with
1128/// `type_mismatch` so a per-item failure inside a list-typed kwarg names
1129/// `KwargPath::Item { key, idx }` plus the structural `expected`/`got` shape.
1130/// Used by `extract_string_list`'s per-item path; future per-item type-mismatch
1131/// sites (e.g. typed enums-of-strings, typed numeric vecs) bind here
1132/// rather than re-inlining the shape.
1133fn type_err_at(key: &str, idx: usize, expected: ExpectedKwargShape, got: &Sexp) -> LispError {
1134    type_mismatch(kwarg_item_form(key, idx), expected, got)
1135}
1136
1137/// Required atomic-kwarg extractor — fronts every typed-atom public
1138/// `extract_X` helper (`extract_string`, `extract_int`, `extract_float`,
1139/// `extract_bool`). The four byte-identical inline shapes —
1140///
1141/// ```ignore
1142/// let v = required(kw, key)?;
1143/// v.as_X().ok_or_else(|| type_err(key, "<X-name>", v))
1144/// ```
1145///
1146/// — collapse to ONE generic primitive parameterized by the projection
1147/// function `project: FnOnce(&'a Sexp) -> Option<T>` and the typed-name
1148/// label `expected: &'static str`. The four-times rule (THEORY.md §VI.1)
1149/// is decisively crossed; lifting it into ONE primitive means the next
1150/// change to the typed-atom failure-projection shape (e.g. threading
1151/// `pos: Option<usize>` once `Sexp` carries spans, attaching a structural
1152/// `source: SexpTypeMismatch` chain) lands as ONE signature change inside
1153/// `extract_atom`, and all four public extractors pick up the upgrade
1154/// mechanically — no per-extractor edit, no per-extractor test drift.
1155///
1156/// `T` is generic so the helper handles both owned (`i64`, `f64`, `bool`)
1157/// and borrowed (`&'a str`) projections uniformly — the lifetime
1158/// threading `&'a Sexp → Option<&'a str>` works because every
1159/// `Sexp::as_*` method is `for<'b> fn(&'b Self) -> Option<…&'b str…>`;
1160/// the helper inherits that lifetime quantification through
1161/// `FnOnce(&'a Sexp) -> Option<T>`. Calling `extract_atom(kw, key,
1162/// "string", Sexp::as_string)` infers `T = &'a str`; calling
1163/// `extract_atom(kw, key, "int", Sexp::as_int)` infers `T = i64`.
1164///
1165/// Sibling of `extract_optional_atom` for the optional kwarg path —
1166/// together the two close every distinct typed-atom kwarg extractor's
1167/// shape: required vs. optional, returning `Result<T>` vs.
1168/// `Result<Option<T>>` from the same underlying projection. Future
1169/// extension to additional atomic types (e.g. `Atom::Bytes` if/when
1170/// added) is ONE one-line public delegate plus ONE call site — no
1171/// new error-path duplication.
1172///
1173/// Theory anchor: THEORY.md §VI.1 — generation over composition;
1174/// three-times rule decisively crossed (four byte-identical
1175/// extract+project+type-err shapes across `extract_string`,
1176/// `extract_int`, `extract_float`, `extract_bool`). THEORY.md §V.1 —
1177/// knowable platform / constructive diagnostics: the typed-atom
1178/// kwarg-failure projection lives in ONE primitive so authoring
1179/// surfaces (`tatara-check`, REPL, LSP) pick up the diagnostic-shape
1180/// promotion mechanically once the variant is structurally extended.
1181/// THEORY.md §II.1 invariant 1 — typed entry; the typed-atom
1182/// extractor IS the rust-level typed-entry gate for primitive kwargs,
1183/// and naming its single shape lifts the gate from four-site
1184/// duplication to one rust function the substrate's diagnostic
1185/// promotions hang off of.
1186fn extract_atom<'a, T, F>(
1187    kw: &'a Kwargs<'a>,
1188    key: &str,
1189    expected: ExpectedKwargShape,
1190    project: F,
1191) -> Result<T>
1192where
1193    F: FnOnce(&'a Sexp) -> Option<T>,
1194{
1195    let v = required(kw, key)?;
1196    project(v).ok_or_else(|| type_err(key, expected, v))
1197}
1198
1199/// Optional sibling of `extract_atom` — collapses the four byte-identical
1200/// inline shapes of `extract_optional_string`, `extract_optional_int`,
1201/// `extract_optional_float`, `extract_optional_bool`:
1202///
1203/// ```ignore
1204/// match kw.get(key) {
1205///     None => Ok(None),
1206///     Some(v) => v.as_X().map(Some).ok_or_else(|| type_err(key, "<X-name>", v)),
1207/// }
1208/// ```
1209///
1210/// into ONE generic primitive. Same `T`/`project`/`expected` shape as
1211/// `extract_atom`; the difference is the `kw.get(key)` short-circuit at
1212/// the `None` arm — an absent kwarg is not an error for optional
1213/// extractors, only a malformed-present one is. The `.copied()` on
1214/// `kw.get(key)` projects `Option<&&'a Sexp>` to `Option<&'a Sexp>` so
1215/// the `project` call gets the same `&'a Sexp` shape as the required
1216/// path — type-checks against the same projection functions
1217/// (`Sexp::as_string`, `Sexp::as_int`, etc.) without per-call casts.
1218///
1219/// Future structural promotion of the type-mismatch diagnostic lands at
1220/// ONE call site inside this helper — same property as `extract_atom`.
1221fn extract_optional_atom<'a, T, F>(
1222    kw: &'a Kwargs<'a>,
1223    key: &str,
1224    expected: ExpectedKwargShape,
1225    project: F,
1226) -> Result<Option<T>>
1227where
1228    F: FnOnce(&'a Sexp) -> Option<T>,
1229{
1230    match optional(kw, key) {
1231        None => Ok(None),
1232        Some(v) => project(v)
1233            .map(Some)
1234            .ok_or_else(|| type_err(key, expected, v)),
1235    }
1236}
1237
1238/// List-typed kwarg extractor — fronts every public `extract_*` helper
1239/// that reads a kwarg as a `Sexp::List` and projects each element to an
1240/// owned `T`. The two byte-identical inline skeletons —
1241///
1242/// ```ignore
1243/// let Some(v) = kw.get(key).copied() else { return Ok(Vec::new()) };
1244/// let list = v.as_list().ok_or_else(|| type_err(key, <list-shape>, v))?;
1245/// list.iter().enumerate().map(<per-item>).collect()
1246/// ```
1247///
1248/// — `extract_string_list` (each item projected via `as_string`, per-item
1249/// failure via `type_err_at`) and `extract_vec_via_serde` (each item via
1250/// `from_value_with_path`, per-item failure carrying `KwargPath::item`) —
1251/// collapse to ONE generic primitive parameterized by the outer-shape
1252/// label `list_shape: ExpectedKwargShape` and the per-element projection
1253/// `item: FnMut(usize, &Sexp) -> Result<T>`. The skeleton owns the three
1254/// fixed decisions both extractors share: absent kwarg → `Ok(Vec::new())`
1255/// (an absent list kwarg is the empty list, never an error — same posture
1256/// `extract_optional_atom` takes for absent atoms); present-but-not-a-list
1257/// → `type_err(key, list_shape, v)` (the outer-shape gate, labeled by the
1258/// caller-supplied `list_shape` so `ListOfStrings` vs. `List` stays a
1259/// per-caller decision, not baked into the skeleton); and the
1260/// `iter().enumerate().map(item).collect()` per-element walk that threads
1261/// the element index into the projection so per-item diagnostics can name
1262/// `:<key>[<idx>]` without re-counting from the source.
1263///
1264/// This is the list-family sibling of `extract_atom` / `extract_optional_atom`
1265/// (the atom-family generic projection primitives). Together the three close
1266/// every distinct typed-kwarg extractor's outer skeleton: required atom,
1267/// optional atom, and list. The per-element projection is `FnMut(usize,
1268/// &Sexp) -> Result<T>` — generic over `T` so it handles both the owned-
1269/// `String` (`extract_string_list`) and `DeserializeOwned`-`T`
1270/// (`extract_vec_via_serde`) element shapes uniformly, and threading the
1271/// `usize` index lets the projection construct the item-keyed
1272/// `KwargPath::Item { key, idx }` / `type_err_at` path the per-item gate
1273/// reports through.
1274///
1275/// Future structural promotion of the outer not-a-list diagnostic, or a
1276/// move to a fallible-streaming collect that short-circuits on the first
1277/// bad element with its position, lands at ONE site inside this helper —
1278/// both public list extractors pick up the upgrade mechanically, same
1279/// property `extract_atom` gives the four atom extractors.
1280///
1281/// Theory anchor: THEORY.md §VI.1 — generation over composition; the
1282/// list-typed extractor skeleton recurs at two sites (the PRIME-DIRECTIVE
1283/// ≥2 trigger) and is lifted to one owner, exactly as the atom skeleton was.
1284/// THEORY.md §V.1 — knowable platform; the list-kwarg outer gate + per-item
1285/// path live in ONE primitive so authoring surfaces (`tatara-check`, REPL,
1286/// LSP) pick up diagnostic-shape promotions once, not per-extractor.
1287/// THEORY.md §II.1 invariant 1 — typed entry; the list extractor IS the
1288/// rust-level typed-entry gate for list-shaped kwargs, and naming its single
1289/// skeleton lifts the gate from two-site duplication to one function the
1290/// substrate's diagnostic promotions hang off of.
1291fn extract_list<T, F>(
1292    kw: &Kwargs<'_>,
1293    key: &str,
1294    list_shape: ExpectedKwargShape,
1295    mut item: F,
1296) -> Result<Vec<T>>
1297where
1298    F: FnMut(usize, &Sexp) -> Result<T>,
1299{
1300    let Some(v) = optional(kw, key) else {
1301        return Ok(Vec::new());
1302    };
1303    let list = v.as_list().ok_or_else(|| type_err(key, list_shape, v))?;
1304    list.iter()
1305        .enumerate()
1306        .map(|(idx, e)| item(idx, e))
1307        .collect()
1308}
1309
1310pub fn extract_string<'a>(kw: &'a Kwargs<'a>, key: &str) -> Result<&'a str> {
1311    extract_atom(kw, key, ExpectedKwargShape::String, Sexp::as_string)
1312}
1313
1314pub fn extract_optional_string<'a>(kw: &'a Kwargs<'a>, key: &str) -> Result<Option<&'a str>> {
1315    extract_optional_atom(kw, key, ExpectedKwargShape::String, Sexp::as_string)
1316}
1317
1318pub fn extract_string_list(kw: &Kwargs<'_>, key: &str) -> Result<Vec<String>> {
1319    extract_list(kw, key, ExpectedKwargShape::ListOfStrings, |idx, s| {
1320        s.as_string()
1321            .map(String::from)
1322            .ok_or_else(|| type_err_at(key, idx, ExpectedKwargShape::String, s))
1323    })
1324}
1325
1326pub fn extract_int(kw: &Kwargs<'_>, key: &str) -> Result<i64> {
1327    extract_atom(kw, key, ExpectedKwargShape::Int, Sexp::as_int)
1328}
1329
1330pub fn extract_optional_int(kw: &Kwargs<'_>, key: &str) -> Result<Option<i64>> {
1331    extract_optional_atom(kw, key, ExpectedKwargShape::Int, Sexp::as_int)
1332}
1333
1334pub fn extract_float(kw: &Kwargs<'_>, key: &str) -> Result<f64> {
1335    extract_atom(kw, key, ExpectedKwargShape::Number, Sexp::as_float)
1336}
1337
1338pub fn extract_optional_float(kw: &Kwargs<'_>, key: &str) -> Result<Option<f64>> {
1339    extract_optional_atom(kw, key, ExpectedKwargShape::Number, Sexp::as_float)
1340}
1341
1342pub fn extract_bool(kw: &Kwargs<'_>, key: &str) -> Result<bool> {
1343    extract_atom(kw, key, ExpectedKwargShape::Bool, Sexp::as_bool)
1344}
1345
1346pub fn extract_optional_bool(kw: &Kwargs<'_>, key: &str) -> Result<Option<bool>> {
1347    extract_optional_atom(kw, key, ExpectedKwargShape::Bool, Sexp::as_bool)
1348}
1349
1350// ── Universal serde-Deserialize fallthrough (enums, nested structs, …) ──
1351//
1352// `#[derive(TataraDomain)]` covers `String` / numeric / `bool` / their
1353// `Option` and `Vec<String>` shapes with the typed extractors above. Any
1354// field type outside that closed set falls through to these helpers, which
1355// project the kwarg `Sexp` to canonical JSON via `sexp_to_json` and feed
1356// it to `serde_json::from_value` — works for any `serde::Deserialize`.
1357//
1358// The shape used to live inline in three `quote!` blocks in the derive
1359// macro (`Kind::Deserialize`, `Kind::OptionalDeserialize`,
1360// `Kind::VecDeserialize`). Lifting them here means:
1361//   - Hand-written `TataraDomain` impls share the same error path.
1362//   - Future diagnostic upgrades (attaching a source position once `Sexp`
1363//     carries spans, richer field-path traces) happen in ONE function,
1364//     not three macro-emitted copies.
1365//   - The `:<key> deserialize: …` message is a single named primitive in
1366//     the substrate — `tatara-check` / LSP / REPL render it uniformly.
1367//
1368// Both helpers below funnel through the structural
1369// `LispError::KwargDeserialize { path: KwargPath, message }` variant —
1370// the typed-entry-side `from_value` mirror of the typed-exit-side
1371// `to_value` `LispError::DomainSerialize { keyword, message }` lift. The
1372// two sites bifurcate via the typed `KwargPath` enum's variant identity:
1373// `KwargPath::Named(key)` for kwarg-keyed failures (the
1374// `extract_via_serde` / `extract_optional_via_serde` path),
1375// `KwargPath::Item { key, idx }` for kwarg-AND-index-keyed failures (the
1376// `extract_vec_via_serde` per-item path). After this lift the
1377// `from_value` boundary's two distinct rejection modes BOTH bind to ONE
1378// structural variant of `LispError`, not a `Compile`-shaped substring;
1379// the `(key, idx: Option<usize>)` bifurcation collapses into
1380// `KwargPath`'s `Named` vs. `Item` variant identity, so the invalid
1381// sibling-slot combination `(key: "", idx: Some(_))` for a scalar path
1382// is structurally unrepresentable rather than re-asserted at the helper
1383// boundary via runtime `Option::is_some` comparison. Together with
1384// `DomainSerialize`, every distinct `serde_json` failure mode at the
1385// typed-domain JSON boundary — both directions of the round-trip — is
1386// now structurally typed. This is the LAST `LispError::Compile { ... }`
1387// construction site in this file.
1388//
1389// Theory anchor: THEORY.md §VI.1 (generation over composition — the
1390// generator must lean on the library, not duplicate the library inline).
1391// THEORY.md §II.1 invariant 1 (typed entry) — `from_value` failures are
1392// exactly the failure mode the typed-entry JSON gate exists to reject;
1393// naming them structurally is the typed posture for that gate's
1394// diagnostic.
1395
1396/// Project a single `&Sexp` through the typed-entry JSON boundary —
1397/// `sexp_to_json` canonical-JSON projection + `serde_json::from_value::<T>`
1398/// + structural `LispError::KwargDeserialize { path, message }` on failure.
1399///
1400/// THREE call sites in this module used to assemble this shape inline:
1401/// `extract_via_serde` (required scalar kwarg path), `extract_optional_via_serde`
1402/// (optional scalar kwarg path), and `extract_vec_via_serde`'s per-item
1403/// closure (each item in a `Vec<T>` kwarg). The three byte-identical
1404/// `let json = sexp_to_json(sexp)?; serde_json::from_value(json).map_err(|e|
1405/// deserialize_*_err(<path-args>, &e))` shapes — modulo the typed
1406/// `KwargPath` constructor (`KwargPath::Named` vs. `KwargPath::Item`) —
1407/// collapse to ONE primitive parameterized by `path: KwargPath`. The
1408/// path's variant identity bifurcates scalar-vs-item rendering inside
1409/// `KwargPath`'s Display impl (`:<key>` vs. `:<key>[<idx>]`) so the helper
1410/// is shape-of-typed-entry-JSON-boundary, not shape-of-call-site.
1411///
1412/// After this lift the three-times-rule on the `from_value` projection
1413/// shape is decisively crossed; the two prior-run thin `deserialize_err`
1414/// / `deserialize_item_err` shims — which encapsulated only the
1415/// `KwargPath::named(_)` / `KwargPath::item(_,_)` constructor projection
1416/// over an already-extant `serde_json::Error` reference — are subsumed
1417/// by this primitive's `map_err` closure. The three extractor entry
1418/// points now bind on `from_value_with_path::<T>` directly with their
1419/// `KwargPath` constructed at the call boundary; the JSON-boundary's
1420/// rejection shape (`LispError::KwargDeserialize { path, message }`)
1421/// lives in ONE place — the `map_err` arm here — instead of being
1422/// re-asserted at three site-specific shims.
1423///
1424/// `<T: DeserializeOwned>` is generic so the helper handles every serde-
1425/// projectable typed-domain field uniformly — scalar `i64` / `String` /
1426/// nested struct / `Vec<Nested>` / enum-by-symbol — same posture as the
1427/// `extract_atom` / `extract_optional_atom` generic-projection primitives
1428/// for the atom-typed kwarg path. `path: KwargPath` flows into the
1429/// variant's typed slot directly (owned), parallel to how `type_mismatch`
1430/// threads `KwargPath` into `LispError::TypeMismatch.form`. A future
1431/// fourth path shape (e.g. `:<key>.<field>` for nested-struct kwarg
1432/// failures) extends `KwargPath` ONCE and rustc-enforces matching at
1433/// every projection site; this helper picks up the new shape mechanically
1434/// with no signature change.
1435///
1436/// Theory anchor: THEORY.md §VI.1 — generation over composition; the
1437/// three-times rule's load-bearing trigger. THEORY.md §V.1 — knowable
1438/// platform; the typed-entry JSON-projection boundary's rejection shape
1439/// lives in ONE primitive so authoring surfaces (`tatara-check`, REPL,
1440/// LSP) pick up the diagnostic-shape promotion mechanically once the
1441/// variant is structurally extended. THEORY.md §II.1 invariant 1 (typed
1442/// entry) — a `from_value` failure is exactly the failure mode the
1443/// typed-entry JSON gate exists to reject; naming its single shape lifts
1444/// the gate from three-site duplication to one rust function the
1445/// substrate's diagnostic promotions hang off of.
1446fn from_value_with_path<T: DeserializeOwned>(sexp: &Sexp, path: KwargPath) -> Result<T> {
1447    let json = sexp.to_json()?;
1448    serde_json::from_value(json).map_err(|e| LispError::KwargDeserialize {
1449        path,
1450        message: e.to_string(),
1451    })
1452}
1453
1454/// Required field — feeds the kwarg's canonical-JSON projection to
1455/// `serde_json::from_value::<T>` via `from_value_with_path` with a
1456/// `KwargPath::Named(key)` path slot. Errors carry `:key` so authoring
1457/// tools can point at the offending kwarg.
1458pub fn extract_via_serde<T: DeserializeOwned>(kw: &Kwargs<'_>, key: &str) -> Result<T> {
1459    from_value_with_path(required(kw, key)?, KwargPath::named(key))
1460}
1461
1462/// Optional field — `None` if the kwarg is absent; `Some(T)` after a
1463/// successful `from_value_with_path` round-trip with a `KwargPath::Named(key)`
1464/// path slot.
1465pub fn extract_optional_via_serde<T: DeserializeOwned>(
1466    kw: &Kwargs<'_>,
1467    key: &str,
1468) -> Result<Option<T>> {
1469    let Some(sexp) = optional(kw, key) else {
1470        return Ok(None);
1471    };
1472    from_value_with_path(sexp, KwargPath::named(key)).map(Some)
1473}
1474
1475/// `Vec<T>` field — empty vec if the kwarg is absent; otherwise the kwarg
1476/// must be a `Sexp::List` and each item flows through `from_value_with_path`
1477/// with a `KwargPath::Item { key, idx }` path slot, naming both the outer
1478/// kwarg AND the failing item index in any per-item rejection.
1479pub fn extract_vec_via_serde<T: DeserializeOwned>(kw: &Kwargs<'_>, key: &str) -> Result<Vec<T>> {
1480    extract_list(kw, key, ExpectedKwargShape::List, |idx, item| {
1481        from_value_with_path(item, KwargPath::item(key, idx))
1482    })
1483}
1484
1485// ── Domain registry (runtime-registered, callable by keyword) ───────
1486
1487/// Erased handler that knows how to compile a form and hand back a typed
1488/// serde-JSON representation. JSON is the least-common-denominator typed
1489/// surface — every `TataraDomain` derives `serde::Serialize` by convention.
1490pub struct DomainHandler {
1491    pub keyword: &'static str,
1492    /// `type_name` of the Rust type that holds this keyword. Carried so a
1493    /// collision can NAME the incumbent, and so [`registrations`] can render a
1494    /// census an operator can read. Without it, "who already owns
1495    /// `defplugin`?" is answerable only by reading every crate that links in.
1496    pub owner: &'static str,
1497    pub compile: fn(args: &[Sexp]) -> Result<serde_json::Value>,
1498}
1499
1500/// A second, DIFFERENT type tried to claim a keyword that is already held.
1501///
1502/// This is the typed form of a defect that used to be a discarded `insert`
1503/// return value. `register` writes into a process-global map; before this type
1504/// existed, a colliding registration silently displaced the incumbent and every
1505/// subsequent `lookup` compiled the wrong struct — no error, no log, no
1506/// diagnostic, and the winner decided by link and call order rather than by
1507/// anything an author wrote.
1508///
1509/// Not a [`LispError`] variant on purpose: nothing here is a source-level
1510/// mistake in a `.tlisp` file. It is a fact about how one *process* was
1511/// assembled, and a caller that hits it must fix its crate graph, not its Lisp.
1512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1513pub struct KeywordCollision {
1514    /// The contested keyword, e.g. `"defplugin"`.
1515    pub keyword: &'static str,
1516    /// `type_name` of the type that got there first and KEPT the keyword.
1517    pub incumbent: &'static str,
1518    /// `type_name` of the type that was turned away.
1519    pub challenger: &'static str,
1520}
1521
1522impl std::fmt::Display for KeywordCollision {
1523    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1524        write!(
1525            f,
1526            "keyword `{}` is already registered to `{}`; `{}` was refused \
1527             (one keyword, one type, per process)",
1528            self.keyword, self.incumbent, self.challenger
1529        )
1530    }
1531}
1532
1533impl std::error::Error for KeywordCollision {}
1534
1535static REGISTRY: OnceLock<Mutex<HashMap<&'static str, DomainHandler>>> = OnceLock::new();
1536
1537fn registry() -> &'static Mutex<HashMap<&'static str, DomainHandler>> {
1538    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
1539}
1540
1541/// Register a `TataraDomain` type with the global dispatcher.
1542///
1543/// **First writer wins, and the loser is told.** Three outcomes, exhaustively:
1544///
1545/// | registry state | result |
1546/// |---|---|
1547/// | keyword free | inserted, `Ok(())` |
1548/// | keyword held by `T` itself | no-op, `Ok(())` — the documented idempotency |
1549/// | keyword held by a different type | registry UNCHANGED, `Err(KeywordCollision)` |
1550///
1551/// The middle row is why this is not simply "reject a second insert": several
1552/// crates call `Foo::register()` from more than one entry point (a `register_all`
1553/// seed plus a lazy path), and that has always been legitimate. Only the third
1554/// row is the defect, and it used to be spelled `insert` — displacing the
1555/// incumbent and discarding it.
1556///
1557/// `#[must_use]`: a discarded result is now a compiler warning at every call
1558/// site, which is the point. It is deliberately a warning and not a hard break
1559/// — every one of the call sites measured across the pleme-io tree on
1560/// 2026-07-31 (165 `domain::register::<…>` sites, 0 of them in tail position)
1561/// is a statement ending in `;`, so widening the return type from `()` to
1562/// `Result` compiles unchanged everywhere and merely gets loud.
1563///
1564/// **Tier-honest.** This makes a collision *impossible to be silent within one
1565/// process*: the value exists, it is typed, and it is `#[must_use]`. It does
1566/// NOT make a collision unrepresentable — a caller may still write `let _ =
1567/// register::<T>();`, and two crates in two repos can still declare the same
1568/// `#[tatara(keyword = "…")]` and never be linked together, so nothing here
1569/// observes them. Catching *that* needs a source-level census across the tree,
1570/// which is what `tatara-keywords` does; this function covers only what one
1571/// running process can see.
1572#[must_use = "a refused registration means this keyword is already held by another type; \
1573              ignoring the result restores the silent-overwrite defect"]
1574pub fn register<T>() -> std::result::Result<(), KeywordCollision>
1575where
1576    T: TataraDomain + serde::Serialize,
1577{
1578    let owner = std::any::type_name::<T>();
1579    let mut reg = registry().lock().unwrap();
1580
1581    if let Some(existing) = reg.get(T::KEYWORD) {
1582        // Same type re-registering: the documented idempotency, kept.
1583        if existing.owner == owner {
1584            return Ok(());
1585        }
1586        // Different type: refuse, and leave the incumbent exactly where it is.
1587        // Reporting a rejection while still mutating would be strictly worse
1588        // than the overwrite it replaces.
1589        return Err(KeywordCollision {
1590            keyword: T::KEYWORD,
1591            incumbent: existing.owner,
1592            challenger: owner,
1593        });
1594    }
1595
1596    reg.insert(
1597        T::KEYWORD,
1598        DomainHandler {
1599            keyword: T::KEYWORD,
1600            owner,
1601            compile: |args| {
1602                let v = T::compile_from_args(args)?;
1603                serde_json::to_value(&v).map_err(|e| LispError::Compile {
1604                    form: T::KEYWORD.to_string(),
1605                    message: format!("serialize: {e}"),
1606                })
1607            },
1608        },
1609    );
1610    Ok(())
1611}
1612
1613/// Look up a handler by keyword.
1614pub fn lookup(keyword: &str) -> Option<DomainHandler> {
1615    let reg = registry().lock().unwrap();
1616    reg.get(keyword).map(|h| DomainHandler {
1617        keyword: h.keyword,
1618        owner: h.owner,
1619        compile: h.compile,
1620    })
1621}
1622
1623/// List currently registered keywords.
1624pub fn registered_keywords() -> Vec<&'static str> {
1625    registry().lock().unwrap().keys().copied().collect()
1626}
1627
1628/// The live census: every registered keyword paired with the `type_name` of the
1629/// type holding it, sorted by keyword.
1630///
1631/// The queryable form of "who owns what" inside a running process. A binary that
1632/// links two crates claiming one keyword can print this and see exactly one row
1633/// for the contested keyword, naming the winner — where before, the losing
1634/// handler had been dropped with nothing recording that it ever existed.
1635#[must_use]
1636pub fn registrations() -> Vec<(&'static str, &'static str)> {
1637    let reg = registry().lock().unwrap();
1638    let mut rows: Vec<(&'static str, &'static str)> =
1639        reg.values().map(|h| (h.keyword, h.owner)).collect();
1640    rows.sort_unstable();
1641    rows
1642}
1643
1644// ── Capability registries — compounding metadata layer ────────────
1645//
1646// Each registered domain can ALSO carry capability metadata —
1647// orthogonal concerns the rest of the platform needs to ask about
1648// the type without importing it. Today: `RenderMetadata` (used by
1649// tatara-render to emit Kubernetes CR YAML without a hard-coded
1650// match). Future: `ComplianceMetadata`, `DocumentationMetadata`,
1651// `AttestationMetadata` — same shape, additional concerns.
1652//
1653// Each metadata kind has its own static registry parallel to
1654// `REGISTRY` (the handler registry). Domain crates call
1655// `register_render::<T>()` alongside `register::<T>()` during
1656// boot; consumers like `tatara-render` look up by keyword.
1657
1658/// Type that knows its Kubernetes-CR rendering metadata. Tiny —
1659/// just constants. Implementing crates derive nothing; they
1660/// `impl RenderableDomain for FooSpec { … }` with three lines.
1661pub trait RenderableDomain {
1662    /// Kubernetes apiVersion the resource lives under
1663    /// (`gateway.networking.k8s.io/v1`, `cilium.io/v2`, etc.).
1664    const API_VERSION: &'static str;
1665    /// Kubernetes kind (`Gateway`, `CiliumNetworkPolicy`).
1666    const KIND: &'static str;
1667    /// Field name (in the typed JSON) that supplies the CR's
1668    /// `metadata.name`. Most domains use `name`; gateway-api
1669    /// uses `gateway_class_name`. Defaults via `Default` impl.
1670    const NAME_FIELD: &'static str = "name";
1671}
1672
1673/// Erased render metadata — what `tatara-render` consumes.
1674#[derive(Clone, Copy, Debug)]
1675pub struct RenderHandler {
1676    pub keyword: &'static str,
1677    pub api_version: &'static str,
1678    pub kind: &'static str,
1679    pub name_field: &'static str,
1680}
1681
1682static RENDER_REGISTRY: OnceLock<Mutex<HashMap<&'static str, RenderHandler>>> = OnceLock::new();
1683
1684fn render_registry() -> &'static Mutex<HashMap<&'static str, RenderHandler>> {
1685    RENDER_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
1686}
1687
1688/// Register a `RenderableDomain`'s metadata. Idempotent.
1689/// Domain crates call this once at boot, alongside `register::<T>()`.
1690pub fn register_render<T>()
1691where
1692    T: TataraDomain + RenderableDomain,
1693{
1694    let handler = RenderHandler {
1695        keyword: T::KEYWORD,
1696        api_version: T::API_VERSION,
1697        kind: T::KIND,
1698        name_field: T::NAME_FIELD,
1699    };
1700    render_registry()
1701        .lock()
1702        .unwrap()
1703        .insert(T::KEYWORD, handler);
1704}
1705
1706/// Look up render metadata by keyword.
1707#[must_use]
1708pub fn lookup_render(keyword: &str) -> Option<RenderHandler> {
1709    render_registry().lock().unwrap().get(keyword).copied()
1710}
1711
1712/// List every keyword that has render metadata registered.
1713#[must_use]
1714pub fn registered_render_keywords() -> Vec<&'static str> {
1715    render_registry().lock().unwrap().keys().copied().collect()
1716}
1717
1718// ── Documented capability ─────────────────────────────────────────
1719//
1720// Third capability layer (compile / render / doc). Each domain
1721// can carry its struct-level + field-level documentation strings
1722// for catalog browsers, IDE hover-help, and the `tatara doc`
1723// CLI to consult uniformly.
1724
1725/// Type that knows its human-readable documentation. Tiny: one
1726/// `&'static str` for the type-level summary, plus an array of
1727/// (field, doc) pairs.
1728pub trait DocumentedDomain {
1729    /// Top-level docstring for the type — what an embedder sees
1730    /// when hovering the keyword in a catalog browser.
1731    const DOCSTRING: &'static str;
1732    /// Per-field docstrings, in declaration order. Empty when no
1733    /// docs were captured upstream (typical for hand-written
1734    /// domains until they fill them in). Forge-generated domains
1735    /// populate this from CRD `description` fields.
1736    const FIELD_DOCS: &'static [(&'static str, &'static str)];
1737}
1738
1739/// Erased doc handle.
1740#[derive(Clone, Copy, Debug)]
1741pub struct DocHandler {
1742    pub keyword: &'static str,
1743    pub docstring: &'static str,
1744    pub field_docs: &'static [(&'static str, &'static str)],
1745}
1746
1747static DOC_REGISTRY: OnceLock<Mutex<HashMap<&'static str, DocHandler>>> = OnceLock::new();
1748
1749fn doc_registry() -> &'static Mutex<HashMap<&'static str, DocHandler>> {
1750    DOC_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
1751}
1752
1753/// Register a `DocumentedDomain`'s metadata. Idempotent.
1754pub fn register_doc<T>()
1755where
1756    T: TataraDomain + DocumentedDomain,
1757{
1758    let handler = DocHandler {
1759        keyword: T::KEYWORD,
1760        docstring: T::DOCSTRING,
1761        field_docs: T::FIELD_DOCS,
1762    };
1763    doc_registry().lock().unwrap().insert(T::KEYWORD, handler);
1764}
1765
1766/// Look up doc metadata by keyword.
1767#[must_use]
1768pub fn lookup_doc(keyword: &str) -> Option<DocHandler> {
1769    doc_registry().lock().unwrap().get(keyword).copied()
1770}
1771
1772/// List every keyword that has doc metadata registered.
1773#[must_use]
1774pub fn registered_doc_keywords() -> Vec<&'static str> {
1775    doc_registry().lock().unwrap().keys().copied().collect()
1776}
1777
1778// ── Dependent capability ──────────────────────────────────────────
1779//
1780// Fourth capability layer (compile / render / doc / deps). Each
1781// domain can declare which OTHER keywords its instances logically
1782// depend on. The rollout pipeline consumes this to topo-sort the
1783// `Plan` so deploys land in the right order — apply
1784// `defservice` before `defpodmonitor` before `defciliumnetworkpolicy`,
1785// drain in reverse.
1786
1787/// Type-level dependency declarations. The strings are keywords
1788/// of OTHER domains this one expects to be present (e.g. a
1789/// `defciliumnetworkpolicy` depends on a `defservice` whose pods
1790/// it selects). The dependency relation is type-to-type, not
1791/// instance-to-instance — finer-grained refs live on the typed
1792/// resource value itself.
1793pub trait DependentDomain {
1794    /// Keywords this domain logically depends on. Empty by
1795    /// default for forge-generated domains since CRDs don't
1796    /// generally declare deps; hand-written domains override
1797    /// to capture real ordering constraints.
1798    const DEPENDS_ON: &'static [&'static str];
1799}
1800
1801/// Erased dep handle — what the topo-sort consumer reads.
1802#[derive(Clone, Copy, Debug)]
1803pub struct DepsHandler {
1804    pub keyword: &'static str,
1805    pub depends_on: &'static [&'static str],
1806}
1807
1808static DEPS_REGISTRY: OnceLock<Mutex<HashMap<&'static str, DepsHandler>>> = OnceLock::new();
1809
1810fn deps_registry() -> &'static Mutex<HashMap<&'static str, DepsHandler>> {
1811    DEPS_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
1812}
1813
1814/// Register a `DependentDomain`'s deps. Idempotent.
1815pub fn register_deps<T>()
1816where
1817    T: TataraDomain + DependentDomain,
1818{
1819    let handler = DepsHandler {
1820        keyword: T::KEYWORD,
1821        depends_on: T::DEPENDS_ON,
1822    };
1823    deps_registry().lock().unwrap().insert(T::KEYWORD, handler);
1824}
1825
1826/// Look up dep metadata by keyword.
1827#[must_use]
1828pub fn lookup_deps(keyword: &str) -> Option<DepsHandler> {
1829    deps_registry().lock().unwrap().get(keyword).copied()
1830}
1831
1832/// List every keyword that has dep metadata registered.
1833#[must_use]
1834pub fn registered_deps_keywords() -> Vec<&'static str> {
1835    deps_registry().lock().unwrap().keys().copied().collect()
1836}
1837
1838// ── Schematic capability ──────────────────────────────────────────
1839//
1840// Fifth capability layer: per-domain JSON Schema export. Forge-
1841// generated domains preserve the source CRD's openAPIV3Schema
1842// verbatim; hand-written domains can either skip the layer or
1843// hand-curate a schema. Consumers: IDE hover-help, web
1844// validators, openapi exporters, admin-UI form generators —
1845// everyone who wants the typed shape without depending on the
1846// Rust struct directly.
1847
1848pub trait SchematicDomain {
1849    /// JSON Schema source for this type. Preserved verbatim from
1850    /// the CRD's openAPIV3Schema for forge-generated domains;
1851    /// hand-curated for non-CRD domains. Consumers parse this on
1852    /// demand — keeping it as a static string avoids paying
1853    /// serde_json::Value at startup for every domain.
1854    const SCHEMA_JSON: &'static str;
1855}
1856
1857#[derive(Clone, Copy, Debug)]
1858pub struct SchemaHandler {
1859    pub keyword: &'static str,
1860    pub schema_json: &'static str,
1861}
1862
1863static SCHEMA_REGISTRY: OnceLock<Mutex<HashMap<&'static str, SchemaHandler>>> = OnceLock::new();
1864
1865fn schema_registry() -> &'static Mutex<HashMap<&'static str, SchemaHandler>> {
1866    SCHEMA_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
1867}
1868
1869pub fn register_schema<T>()
1870where
1871    T: TataraDomain + SchematicDomain,
1872{
1873    let handler = SchemaHandler {
1874        keyword: T::KEYWORD,
1875        schema_json: T::SCHEMA_JSON,
1876    };
1877    schema_registry()
1878        .lock()
1879        .unwrap()
1880        .insert(T::KEYWORD, handler);
1881}
1882
1883#[must_use]
1884pub fn lookup_schema(keyword: &str) -> Option<SchemaHandler> {
1885    schema_registry().lock().unwrap().get(keyword).copied()
1886}
1887
1888#[must_use]
1889pub fn registered_schema_keywords() -> Vec<&'static str> {
1890    schema_registry().lock().unwrap().keys().copied().collect()
1891}
1892
1893// ── Attestable capability ─────────────────────────────────────────
1894//
1895// Sixth capability layer: each domain declares its **attestation
1896// namespace** — the bucket the tameshi BLAKE3 chain groups its
1897// resources under. The canonical hash itself is namespace-aware
1898// (`blake3(namespace || canonical_json(value))`) so two resources
1899// with identical content but different domains never collide in
1900// the attestation tree. Closes the trust loop in the rollout
1901// pipeline.
1902
1903pub trait AttestableDomain {
1904    /// Bucket name for the tameshi attestation chain. Forge-
1905    /// generated CRD domains use the CRD's group (e.g.
1906    /// `gateway.networking.k8s.io`); hand-written domains pick
1907    /// a stable namespace (e.g. `pleme.io/ebpf`). The namespace
1908    /// is hashed into the resource's BLAKE3 so cross-domain
1909    /// collisions are impossible.
1910    const ATTESTATION_NAMESPACE: &'static str;
1911}
1912
1913#[derive(Clone, Copy, Debug)]
1914pub struct AttestHandler {
1915    pub keyword: &'static str,
1916    pub namespace: &'static str,
1917}
1918
1919static ATTEST_REGISTRY: OnceLock<Mutex<HashMap<&'static str, AttestHandler>>> = OnceLock::new();
1920
1921fn attest_registry() -> &'static Mutex<HashMap<&'static str, AttestHandler>> {
1922    ATTEST_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
1923}
1924
1925pub fn register_attest<T>()
1926where
1927    T: TataraDomain + AttestableDomain,
1928{
1929    let handler = AttestHandler {
1930        keyword: T::KEYWORD,
1931        namespace: T::ATTESTATION_NAMESPACE,
1932    };
1933    attest_registry()
1934        .lock()
1935        .unwrap()
1936        .insert(T::KEYWORD, handler);
1937}
1938
1939#[must_use]
1940pub fn lookup_attest(keyword: &str) -> Option<AttestHandler> {
1941    attest_registry().lock().unwrap().get(keyword).copied()
1942}
1943
1944#[must_use]
1945pub fn registered_attest_keywords() -> Vec<&'static str> {
1946    attest_registry().lock().unwrap().keys().copied().collect()
1947}
1948
1949/// Compute a namespaced BLAKE3 attestation for a typed value.
1950///
1951/// `BLAKE3(ATTESTATION_NAMESPACE || ":" || canonical_json(value))`
1952///
1953/// The namespace prefix prevents cross-domain hash collisions in
1954/// the tameshi attestation tree — two resources with identical
1955/// JSON but different domain semantics produce different hashes.
1956/// The canonical-JSON serialization is what `serde_json::to_string`
1957/// produces; consumers can rely on the hash being stable across
1958/// processes given the same input value.
1959#[must_use]
1960pub fn attest_value(namespace: &str, value: &serde_json::Value) -> String {
1961    let canonical = serde_json::to_string(value).unwrap_or_default();
1962    let mut hasher = blake3::Hasher::new();
1963    hasher.update(namespace.as_bytes());
1964    hasher.update(b":");
1965    hasher.update(canonical.as_bytes());
1966    hasher.finalize().to_hex().to_string()
1967}
1968
1969// ── Validated capability ──────────────────────────────────────────
1970//
1971// Seventh capability layer: per-domain semantic validators. The
1972// first capability with **executable behavior** (not just static
1973// metadata) — the registry stores function pointers, not
1974// constants. Each domain plugs in its own logic; the env-level
1975// validator dispatches.
1976
1977/// Type that carries a semantic validator for its typed values.
1978/// Default impl returns `Ok(())` — so domains opt in, never
1979/// out. The validator runs AFTER `compile_from_args` succeeds —
1980/// it's a chance to enforce cross-field invariants the type
1981/// system alone can't catch (e.g. "if `kind = :xdp`, `attach`
1982/// must include an interface").
1983pub trait ValidatedDomain {
1984    /// Validate the typed JSON form of a domain instance. The
1985    /// default returns Ok — domains override to add real checks.
1986    /// Errors carry a human-readable message naming the
1987    /// offending field + constraint.
1988    fn validate_value(_value: &serde_json::Value) -> std::result::Result<(), String> {
1989        Ok(())
1990    }
1991}
1992
1993/// Erased validator handle — function pointer, no captured state.
1994#[derive(Clone, Copy)]
1995pub struct ValidateHandler {
1996    pub keyword: &'static str,
1997    pub validate: fn(&serde_json::Value) -> std::result::Result<(), String>,
1998}
1999
2000impl std::fmt::Debug for ValidateHandler {
2001    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2002        f.debug_struct("ValidateHandler")
2003            .field("keyword", &self.keyword)
2004            .field("validate", &"<fn>")
2005            .finish()
2006    }
2007}
2008
2009static VALIDATE_REGISTRY: OnceLock<Mutex<HashMap<&'static str, ValidateHandler>>> = OnceLock::new();
2010
2011fn validate_registry() -> &'static Mutex<HashMap<&'static str, ValidateHandler>> {
2012    VALIDATE_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
2013}
2014
2015pub fn register_validate<T>()
2016where
2017    T: TataraDomain + ValidatedDomain,
2018{
2019    let handler = ValidateHandler {
2020        keyword: T::KEYWORD,
2021        validate: <T as ValidatedDomain>::validate_value,
2022    };
2023    validate_registry()
2024        .lock()
2025        .unwrap()
2026        .insert(T::KEYWORD, handler);
2027}
2028
2029#[must_use]
2030pub fn lookup_validate(keyword: &str) -> Option<ValidateHandler> {
2031    validate_registry().lock().unwrap().get(keyword).copied()
2032}
2033
2034#[must_use]
2035pub fn registered_validate_keywords() -> Vec<&'static str> {
2036    validate_registry()
2037        .lock()
2038        .unwrap()
2039        .keys()
2040        .copied()
2041        .collect()
2042}
2043
2044// ── Lifecycle capability ──────────────────────────────────────────
2045//
2046// Eighth capability layer: per-domain rollout strategy. Where
2047// Layer 4 (DependentDomain) declares **apply X before Y**, Layer
2048// 8 declares **when X changes, here's how to swap it**.
2049//
2050// Different shapes need different protocols:
2051//   - service-shaped CRs (Gateway, Service): RollingUpdate
2052//   - stateful resources (ConfigMaps owned by stateful sets):
2053//     Recreate
2054//   - kernel-attached programs (eBPF): BlueGreen — load new
2055//     before unloading old, atomic-swap (the verifier rejects
2056//     half-loaded state, so blue/green is the only safe shape)
2057//   - config CRs (most CRD-shaped resources): Immediate
2058//
2059// `tatara-rollout` (and future `tatara-deploy`) consult this
2060// per Change to pick the right swap protocol for each resource.
2061
2062#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2063pub enum RolloutStrategy {
2064    /// Apply once, no transition. Most config-shaped CRDs.
2065    Immediate,
2066    /// Tear down, then create. Stateful resources where in-place
2067    /// updates aren't safe.
2068    Recreate,
2069    /// Standard rolling update — replace pod-by-pod with health
2070    /// probes between. Service-shaped CRs.
2071    RollingUpdate,
2072    /// Install new alongside old, switch traffic, drain old.
2073    /// Kernel-attached programs (eBPF) — the verifier won't
2074    /// accept half-loaded state, so blue/green is the only
2075    /// safe shape.
2076    BlueGreen,
2077    /// Percentage traffic shift over time. Service mesh primary
2078    /// pattern.
2079    Canary,
2080}
2081
2082pub trait LifecycleProtocol {
2083    /// How changes to this domain's resources roll out.
2084    const STRATEGY: RolloutStrategy;
2085    /// Seconds to wait for graceful termination before force-kill.
2086    /// 30s default matches K8s pod terminationGracePeriodSeconds.
2087    const DRAIN_SECONDS: u32 = 30;
2088}
2089
2090#[derive(Clone, Copy, Debug)]
2091pub struct LifecycleHandler {
2092    pub keyword: &'static str,
2093    pub strategy: RolloutStrategy,
2094    pub drain_seconds: u32,
2095}
2096
2097static LIFECYCLE_REGISTRY: OnceLock<Mutex<HashMap<&'static str, LifecycleHandler>>> =
2098    OnceLock::new();
2099
2100fn lifecycle_registry() -> &'static Mutex<HashMap<&'static str, LifecycleHandler>> {
2101    LIFECYCLE_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
2102}
2103
2104pub fn register_lifecycle<T>()
2105where
2106    T: TataraDomain + LifecycleProtocol,
2107{
2108    let handler = LifecycleHandler {
2109        keyword: T::KEYWORD,
2110        strategy: T::STRATEGY,
2111        drain_seconds: T::DRAIN_SECONDS,
2112    };
2113    lifecycle_registry()
2114        .lock()
2115        .unwrap()
2116        .insert(T::KEYWORD, handler);
2117}
2118
2119#[must_use]
2120pub fn lookup_lifecycle(keyword: &str) -> Option<LifecycleHandler> {
2121    lifecycle_registry().lock().unwrap().get(keyword).copied()
2122}
2123
2124#[must_use]
2125pub fn registered_lifecycle_keywords() -> Vec<&'static str> {
2126    lifecycle_registry()
2127        .lock()
2128        .unwrap()
2129        .keys()
2130        .copied()
2131        .collect()
2132}
2133
2134// ── Meta-compounder: capability_layer! macro ──────────────────────
2135//
2136// Layers 1–8 above each take ~50 lines of boilerplate (trait +
2137// handler struct + registry + 3 fns). The macro below collapses
2138// every static-data capability layer to ~10 lines of declaration.
2139// First-class compounding the compounding: each new layer is now
2140// shorter to author than its predecessors.
2141//
2142// Use the macro for layers whose trait holds only `const` items
2143// (and whose handler is a flat struct of those values). Layers
2144// with executable behavior (Validated, layer 7) keep the
2145// hand-written form because the trait carries a method, not
2146// constants — `fn validate_value(&Value) -> Result<…>` doesn't
2147// fit a `const` slot.
2148//
2149// Shape:
2150//
2151//   capability_layer! {
2152//       trait $Trait,                     // pub trait + name
2153//       handler $Handler,                 // erased Handler struct
2154//       static $REGISTRY,                 // backing OnceLock
2155//       registry_fn $internal_fn,         // private accessor
2156//       register $register_fn,            // pub register::<T>()
2157//       lookup $lookup_fn,                // pub lookup(kw) -> Option<Handler>
2158//       list $list_fn,                    // pub list registered keywords
2159//       consts {
2160//           const NAME: ty => field name,  // trait const → handler field
2161//           ...
2162//       }
2163//   }
2164
2165#[macro_export]
2166macro_rules! capability_layer {
2167    (
2168        trait $Trait:ident,
2169        handler $Handler:ident,
2170        static $REGISTRY:ident,
2171        registry_fn $registry_fn:ident,
2172        register $register:ident,
2173        lookup $lookup:ident,
2174        list $list:ident,
2175        consts {
2176            $(const $CONST:ident: $ty:ty => field $field:ident),* $(,)?
2177        } $(,)?
2178    ) => {
2179        pub trait $Trait {
2180            $(const $CONST: $ty;)*
2181        }
2182
2183        #[derive(Clone, Copy, Debug)]
2184        pub struct $Handler {
2185            pub keyword: &'static str,
2186            $(pub $field: $ty,)*
2187        }
2188
2189        static $REGISTRY: ::std::sync::OnceLock<
2190            ::std::sync::Mutex<::std::collections::HashMap<&'static str, $Handler>>
2191        > = ::std::sync::OnceLock::new();
2192
2193        fn $registry_fn() -> &'static ::std::sync::Mutex<
2194            ::std::collections::HashMap<&'static str, $Handler>
2195        > {
2196            $REGISTRY.get_or_init(|| {
2197                ::std::sync::Mutex::new(::std::collections::HashMap::new())
2198            })
2199        }
2200
2201        pub fn $register<T>()
2202        where
2203            T: $crate::domain::TataraDomain + $Trait,
2204        {
2205            let handler = $Handler {
2206                keyword: T::KEYWORD,
2207                $($field: T::$CONST,)*
2208            };
2209            $registry_fn().lock().unwrap().insert(T::KEYWORD, handler);
2210        }
2211
2212        #[must_use]
2213        pub fn $lookup(keyword: &str) -> Option<$Handler> {
2214            $registry_fn().lock().unwrap().get(keyword).copied()
2215        }
2216
2217        #[must_use]
2218        pub fn $list() -> Vec<&'static str> {
2219            $registry_fn().lock().unwrap().keys().copied().collect()
2220        }
2221    };
2222}
2223
2224// ── Layer 9: Compliant capability (via the macro) ─────────────────
2225//
2226// First layer authored with the meta-compounder. Compounding the
2227// compounding made operational. Per-domain compliance posture —
2228// which baselines the resource satisfies (NIST 800-53, CIS,
2229// FedRAMP, PCI DSS, SOC 2). Consumers: kensa (compliance engine),
2230// sekiban (admission webhook), tameshi (heartbeat chain).
2231
2232capability_layer! {
2233    trait CompliantDomain,
2234    handler ComplianceHandler,
2235    static COMPLIANCE_REGISTRY,
2236    registry_fn compliance_registry,
2237    register register_compliance,
2238    lookup lookup_compliance,
2239    list registered_compliance_keywords,
2240    consts {
2241        const FRAMEWORKS: &'static [&'static str] => field frameworks,
2242        const CONTROLS: &'static [&'static str] => field controls,
2243    }
2244}
2245
2246// ── Layer 10: Observable capability (via the macro) ───────────────
2247//
2248// Per-domain Prometheus metric prefix + log label names.
2249// Consumers: arch-synthesizer (auto-generates ServiceMonitor +
2250// PodMonitor specs that scrape the right prefixes) and the
2251// Loki query layer (knows which labels each domain emits).
2252
2253capability_layer! {
2254    trait ObservableDomain,
2255    handler ObservabilityHandler,
2256    static OBSERVABILITY_REGISTRY,
2257    registry_fn observability_registry,
2258    register register_observability,
2259    lookup lookup_observability,
2260    list registered_observability_keywords,
2261    consts {
2262        const METRIC_PREFIX: &'static str => field metric_prefix,
2263        const LOG_LABELS: &'static [&'static str] => field log_labels,
2264    }
2265}
2266
2267// ── Layer 11: Authoring help capability (via the macro) ───────────
2268//
2269// Per-domain authoring examples + a one-liner mnemonic for the
2270// catalog browser. Consumers: tatara-doc (renders examples in
2271// the catalog), IDE hover-help, the future `tatara init` CLI
2272// that scaffolds new programs from examples.
2273
2274capability_layer! {
2275    trait HelpDomain,
2276    handler HelpHandler,
2277    static HELP_REGISTRY,
2278    registry_fn help_registry,
2279    register register_help,
2280    lookup lookup_help,
2281    list registered_help_keywords,
2282    consts {
2283        const MNEMONIC: &'static str => field mnemonic,
2284        const EXAMPLES: &'static [&'static str] => field examples,
2285    }
2286}
2287
2288// ── Layer 12: Stable capability (via the macro) ───────────────────
2289//
2290// Per-domain stability signal. Consumers: caixa-lint (warns on
2291// unstable usages), tatara-doc (decorates the catalog), CI
2292// gates (blocks promotion to prod when an unstable resource
2293// crosses a `:tier "prod"` env boundary).
2294
2295capability_layer! {
2296    trait StableDomain,
2297    handler StabilityHandler,
2298    static STABILITY_REGISTRY,
2299    registry_fn stability_registry,
2300    register register_stability,
2301    lookup lookup_stability,
2302    list registered_stability_keywords,
2303    consts {
2304        const STABILITY: &'static str => field stability,
2305        const SINCE_VERSION: &'static str => field since_version,
2306    }
2307}
2308
2309// ── Meta-meta-compounder: impl_default_capabilities! ──────────────
2310//
2311// Forge-generated domains plug into the platform with a single
2312// macro call:
2313//
2314//   impl_default_capabilities!(MyDomainSpec);
2315//
2316// Expands to default `impl` blocks for every static-data
2317// capability layer that *has* a meaningful default. Layers
2318// without a sensible default (Render, Validated — Render needs
2319// real api_version+kind, Validated has its trait-default
2320// `validate_value`) are skipped here; the forge emits those
2321// separately when CRD metadata is available.
2322//
2323// **Why this matters**: previously, adding a new capability
2324// layer required editing both `tatara-lisp::domain` (define the
2325// layer) AND `tatara-domain-forge::emit` (emit per-layer impl
2326// blocks). Now the forge's emit is a single line; new layers
2327// land in this macro alone. Compounding the compounding the
2328// compounding — three orders deep.
2329
2330#[macro_export]
2331macro_rules! impl_default_capabilities {
2332    ($Spec:ty) => {
2333        // NOTE: Layer 3 (Documented) is intentionally NOT here.
2334        // Forge-generated domains emit it explicitly with real
2335        // docs from CRD descriptions; hand-written domains
2336        // override directly. The macro covering it would create
2337        // a double-impl conflict in both cases.
2338        //
2339        // Layer 4 — Dependent (forge default empty).
2340        impl $crate::domain::DependentDomain for $Spec {
2341            const DEPENDS_ON: &'static [&'static str] = &[];
2342        }
2343        // Layer 7 — Validated (uses the trait's default fn).
2344        impl $crate::domain::ValidatedDomain for $Spec {}
2345        // Layer 8 — Lifecycle (Immediate is the safe CRD default).
2346        impl $crate::domain::LifecycleProtocol for $Spec {
2347            const STRATEGY: $crate::domain::RolloutStrategy =
2348                $crate::domain::RolloutStrategy::Immediate;
2349        }
2350        // Layer 9 — Compliance (claims none by default).
2351        impl $crate::domain::CompliantDomain for $Spec {
2352            const FRAMEWORKS: &'static [&'static str] = &[];
2353            const CONTROLS: &'static [&'static str] = &[];
2354        }
2355        // Layer 10 — Observable (no metrics by default).
2356        impl $crate::domain::ObservableDomain for $Spec {
2357            const METRIC_PREFIX: &'static str = "";
2358            const LOG_LABELS: &'static [&'static str] = &[];
2359        }
2360        // Layer 11 — Authoring help.
2361        impl $crate::domain::HelpDomain for $Spec {
2362            const MNEMONIC: &'static str = "";
2363            const EXAMPLES: &'static [&'static str] = &[];
2364        }
2365        // Layer 12 — Stability (assume stable + 0.1.0 unless
2366        // overridden; loud-failure beats silent missing field).
2367        impl $crate::domain::StableDomain for $Spec {
2368            const STABILITY: &'static str = "stable";
2369            const SINCE_VERSION: &'static str = "0.1.0";
2370        }
2371    };
2372}
2373
2374/// Companion to `impl_default_capabilities!` — registers every
2375/// layer's handler in one call. Domains that have explicit
2376/// Render + Schema + Attest metadata also call those register
2377/// fns separately (they're not part of this macro because not
2378/// every domain has them — hand-written ebpf doesn't have render
2379/// metadata). Adding a new always-present layer means updating
2380/// this macro and `impl_default_capabilities!` once.
2381///
2382/// Expands to an **expression** of type `Result<(), KeywordCollision>`, not a
2383/// statement block: the handler registry is the only one of the nine layers
2384/// that can refuse, and swallowing that refusal inside the macro would put the
2385/// silent overwrite back one level down where it is harder to see. The eight
2386/// capability layers still overwrite — they carry metadata about a keyword
2387/// whose ownership the handler registry has already adjudicated, so a second
2388/// writer there cannot change which struct a form compiles to.
2389#[macro_export]
2390macro_rules! register_all_capabilities {
2391    ($Spec:ty) => {{
2392        $crate::domain::register_doc::<$Spec>();
2393        $crate::domain::register_deps::<$Spec>();
2394        $crate::domain::register_validate::<$Spec>();
2395        $crate::domain::register_lifecycle::<$Spec>();
2396        $crate::domain::register_compliance::<$Spec>();
2397        $crate::domain::register_observability::<$Spec>();
2398        $crate::domain::register_help::<$Spec>();
2399        $crate::domain::register_stability::<$Spec>();
2400        $crate::domain::register::<$Spec>()
2401    }};
2402}
2403
2404// ── Sexp ↔ serde_json bridge (universal type support) ──────────────
2405//
2406// Lets the derive macro fall through to `serde_json::from_value` for any
2407// field type implementing `Deserialize`. Handles enums (via symbol→string),
2408// nested structs (via kwargs→object), and `Vec<T>` of either.
2409
2410use serde_json::Value as JValue;
2411
2412/// Thin delegate to [`Sexp::to_json`] retained for callers that want
2413/// the free-function reach — the canonical site is now the inherent
2414/// method on the [`Sexp`] algebra (sibling-lift posture to
2415/// [`super::domain::sexp_shape`] → [`Sexp::shape`] (commit 121bb60)
2416/// and [`super::domain::sexp_witness`] → [`Sexp::witness`] (commit
2417/// a427e3b)). Rules + round-trip semantics live at
2418/// [`Sexp::to_json`]'s docstring.
2419///
2420/// Composition law: `sexp_to_json(s) == s.to_json()` for every `s:
2421/// &Sexp`. Pre-lift the dispatcher lived here as the canonical site;
2422/// post-lift the inherent method [`Sexp::to_json`] is the canonical
2423/// site and this free function delegates so existing callers
2424/// continue to compile.
2425pub fn sexp_to_json(s: &Sexp) -> Result<JValue> {
2426    s.to_json()
2427}
2428
2429/// Thin delegate to [`Sexp::from_json`] retained for callers that want
2430/// the free-function reach — the canonical site is now the inherent
2431/// associated function on the [`Sexp`] algebra (sibling-lift posture to
2432/// [`super::domain::sexp_to_json`] → [`Sexp::to_json`] (commit 875ee3b),
2433/// [`super::domain::sexp_witness`] → [`Sexp::witness`] (commit a427e3b),
2434/// and [`super::domain::sexp_shape`] → [`Sexp::shape`] (commit
2435/// 121bb60)). Rules + round-trip semantics live at
2436/// [`Sexp::from_json`]'s docstring.
2437///
2438/// Composition law: `json_to_sexp(v) == Sexp::from_json(v)` for every
2439/// `v: &JValue`. Pre-lift the dispatcher lived here as the canonical
2440/// site; post-lift the inherent associated function
2441/// [`Sexp::from_json`] is the canonical site and this free function
2442/// delegates so existing callers continue to compile. With this lift the
2443/// substrate's `Sexp` ↔ `serde_json::Value` round-trip closure
2444/// ([`Sexp::to_json`] + [`Sexp::from_json`]) lives entirely on the
2445/// [`Sexp`] algebra; the four free functions that pre-dated the lift
2446/// chain (`sexp_to_json`, `json_to_sexp`, `sexp_shape`, `sexp_witness`)
2447/// are all delegates now — the canonical-form / structural-projection
2448/// surface is structurally on the algebra.
2449pub fn json_to_sexp(v: &JValue) -> Sexp {
2450    Sexp::from_json(v)
2451}
2452
2453/// `must-reach` → `mustReach`, `point-type` → `pointType`.
2454pub(crate) fn kebab_to_camel(s: &str) -> String {
2455    let mut out = String::with_capacity(s.len());
2456    let mut upper = false;
2457    for c in s.chars() {
2458        if c == '-' {
2459            upper = true;
2460        } else if upper {
2461            out.extend(c.to_uppercase());
2462            upper = false;
2463        } else {
2464            out.push(c);
2465        }
2466    }
2467    out
2468}
2469
2470/// `mustReach` → `must-reach` (inverse of `kebab_to_camel`).
2471pub(crate) fn camel_to_kebab(s: &str) -> String {
2472    let mut out = String::with_capacity(s.len() + 2);
2473    for (i, c) in s.chars().enumerate() {
2474        if c.is_uppercase() && i > 0 {
2475            out.push('-');
2476            out.extend(c.to_lowercase());
2477        } else {
2478            out.push(c);
2479        }
2480    }
2481    out
2482}
2483
2484// ── TypedRewriter — the self-optimization primitive ────────────────
2485//
2486// Takes a typed value, converts to Sexp, applies a Lisp rewrite, then
2487// re-enters the typed boundary via `compile_from_args`. Any rewrite that
2488// passes the typed re-validation is safe by construction — the Rust type
2489// system is the floor.
2490
2491/// Rewrite a typed `T` through Lisp form and re-validate on the way back.
2492///
2493/// The rewriter receives the value's kwargs representation (a `Sexp::List`
2494/// of alternating keywords + values) and returns a modified kwargs list.
2495/// `T::compile_from_args` validates the result — any ill-formed rewrite
2496/// produces a typed error; any well-formed rewrite produces a valid `T`.
2497pub fn rewrite_typed<T, F>(input: T, rewrite: F) -> Result<T>
2498where
2499    T: TataraDomain + serde::Serialize,
2500    F: FnOnce(Sexp) -> Result<Sexp>,
2501{
2502    let json = serde_json::to_value(&input).map_err(|e| LispError::Compile {
2503        form: T::KEYWORD.to_string(),
2504        message: format!("serialize {}: {e}", T::KEYWORD),
2505    })?;
2506    let sexp = json_to_sexp(&json);
2507    let rewritten = rewrite(sexp)?;
2508    let args = match rewritten {
2509        Sexp::List(items) => items,
2510        other => {
2511            return Err(LispError::Compile {
2512                form: T::KEYWORD.to_string(),
2513                message: format!("rewriter must return a list; got {other}"),
2514            })
2515        }
2516    };
2517    T::compile_from_args(&args)
2518}
2519
2520#[cfg(test)]
2521mod tests {
2522    use super::*;
2523    use crate::reader::read;
2524    use serde::Serialize;
2525    use tatara_lisp_derive::TataraDomain as DeriveTataraDomain;
2526
2527    /// Example domain authorable as Lisp — proves derive macro, trait, and
2528    /// registry all agree end-to-end.
2529    #[derive(DeriveTataraDomain, Serialize, Debug, PartialEq)]
2530    #[tatara(keyword = "defmonitor")]
2531    struct MonitorSpec {
2532        name: String,
2533        query: String,
2534        threshold: f64,
2535        window_seconds: Option<i64>,
2536        tags: Vec<String>,
2537        enabled: Option<bool>,
2538    }
2539
2540    #[test]
2541    fn derive_emits_correct_keyword() {
2542        assert_eq!(MonitorSpec::KEYWORD, "defmonitor");
2543    }
2544
2545    // ── The two kwarg gates, each proved to REJECT ──────────────────
2546    //
2547    // Both of these forms used to compile successfully, which is the
2548    // whole reason the helper layer moved: a `HashMap::insert` whose
2549    // return value was discarded made a repeated `:key` a silent
2550    // last-one-wins, and the total absence of an allowed-set check made
2551    // a typo'd `:key` a silent fall-through to the field's default.
2552    // Both are now typed rejections at the parse boundary, and both
2553    // tests fail (compile succeeds, `unwrap_err` panics) if either gate
2554    // is ever removed.
2555
2556    /// G1 — a repeated `:key` is REJECTED, and the diagnostic names it.
2557    #[test]
2558    fn duplicate_kwarg_is_rejected_not_silently_last_wins() {
2559        let forms = read(
2560            r#"(defmonitor
2561                 :name "first"
2562                 :query "up"
2563                 :threshold 0.5
2564                 :name "second")"#,
2565        )
2566        .expect("reads");
2567        let err = MonitorSpec::compile_from_sexp(&forms[0])
2568            .expect_err("a repeated :name must not parse — pre-fix it silently took \"second\"");
2569        assert!(
2570            matches!(&err, LispError::DuplicateKwarg { key } if key == "name"),
2571            "expected DuplicateKwarg {{ key: \"name\" }}, got {err:?}"
2572        );
2573    }
2574
2575    /// G1, the other half — the FIRST binding is not quietly kept either.
2576    /// Rejection, not a silent choice of winner, is the contract.
2577    #[test]
2578    fn duplicate_kwarg_rejects_rather_than_picking_a_winner() {
2579        let forms =
2580            read(r#"(defmonitor :name "a" :name "a" :query "q" :threshold 1.0)"#).expect("reads");
2581        assert!(MonitorSpec::compile_from_sexp(&forms[0]).is_err());
2582    }
2583
2584    /// G2 — an unknown `:key` is REJECTED rather than ignored, and a
2585    /// near-miss carries an edit-distance hint.
2586    #[test]
2587    fn unknown_kwarg_is_rejected_with_a_suggestion() {
2588        let forms = read(
2589            r#"(defmonitor
2590                 :name "prom-up"
2591                 :query "up"
2592                 :threshold 0.5
2593                 :thrshold 0.9)"#,
2594        )
2595        .expect("reads");
2596        let err = MonitorSpec::compile_from_sexp(&forms[0]).expect_err(
2597            "a typo'd :thrshold must not parse — pre-fix it was dropped and `threshold` \
2598             kept whatever the correctly-spelled slot held",
2599        );
2600        let LispError::UnknownKwarg { key, hint, .. } = &err else {
2601            panic!("expected UnknownKwarg, got {err:?}");
2602        };
2603        assert_eq!(key, "thrshold");
2604        assert_eq!(
2605            hint.as_deref(),
2606            Some("threshold"),
2607            "a one-character transposition is inside the suggestion bound"
2608        );
2609    }
2610
2611    /// G2, the far-from-anything case — still rejected, just without a
2612    /// hint. A wrong hint is worse than no hint.
2613    #[test]
2614    fn unknown_kwarg_with_no_near_match_is_still_rejected() {
2615        let forms =
2616            read(r#"(defmonitor :name "n" :query "q" :threshold 1.0 :zzzzzzzz 1)"#).expect("reads");
2617        let err = MonitorSpec::compile_from_sexp(&forms[0]).expect_err("unknown key must reject");
2618        let LispError::UnknownKwarg { key, hint, .. } = &err else {
2619            panic!("expected UnknownKwarg, got {err:?}");
2620        };
2621        assert_eq!(key, "zzzzzzzz");
2622        assert_eq!(hint.as_deref(), None);
2623    }
2624
2625    /// The gate must not over-reject: every declared field's kebab key —
2626    /// including the ones reached through the four extractor branches —
2627    /// stays accepted. This is what would fail if the allowed-set were
2628    /// collected inside a branch that `continue`s.
2629    #[test]
2630    fn every_declared_field_key_stays_accepted() {
2631        let forms = read(
2632            r#"(defmonitor
2633                 :name "n"
2634                 :query "q"
2635                 :threshold 1.0
2636                 :window-seconds 30
2637                 :tags ("a")
2638                 :enabled #f)"#,
2639        )
2640        .expect("reads");
2641        MonitorSpec::compile_from_sexp(&forms[0]).expect("all six declared keys must remain valid");
2642    }
2643
2644    #[test]
2645    fn derive_compiles_full_form() {
2646        let forms = read(
2647            r#"(defmonitor
2648                 :name "prom-up"
2649                 :query "up{job='prometheus'}"
2650                 :threshold 0.99
2651                 :window-seconds 300
2652                 :tags ("prod" "observability")
2653                 :enabled #t)"#,
2654        )
2655        .unwrap();
2656        let spec = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
2657        assert_eq!(
2658            spec,
2659            MonitorSpec {
2660                name: "prom-up".into(),
2661                query: "up{job='prometheus'}".into(),
2662                threshold: 0.99,
2663                window_seconds: Some(300),
2664                tags: vec!["prod".into(), "observability".into()],
2665                enabled: Some(true),
2666            }
2667        );
2668    }
2669
2670    #[test]
2671    fn derive_accepts_missing_optionals() {
2672        let forms = read(r#"(defmonitor :name "x" :query "q" :threshold 0.5)"#).unwrap();
2673        let spec = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
2674        assert_eq!(spec.name, "x");
2675        assert!(spec.window_seconds.is_none());
2676        assert!(spec.enabled.is_none());
2677        assert!(spec.tags.is_empty());
2678    }
2679
2680    #[test]
2681    fn derive_errors_on_missing_required() {
2682        let forms = read(r#"(defmonitor :name "x" :query "q")"#).unwrap();
2683        assert!(MonitorSpec::compile_from_sexp(&forms[0]).is_err());
2684    }
2685
2686    #[test]
2687    fn derive_errors_on_wrong_head() {
2688        let forms = read(r#"(not-a-monitor :name "x")"#).unwrap();
2689        let err = MonitorSpec::compile_from_sexp(&forms[0]).unwrap_err();
2690        assert!(format!("{err}").contains("expected (defmonitor"));
2691    }
2692
2693    #[test]
2694    fn registry_dispatches_by_keyword() {
2695        register::<MonitorSpec>().expect("keyword namespace must be free in this test binary");
2696        assert!(registered_keywords().contains(&"defmonitor"));
2697        let handler = lookup("defmonitor").expect("registered");
2698        assert_eq!(handler.keyword, "defmonitor");
2699        let forms = read(r#"(ignored :name "prom" :query "q" :threshold 0.5)"#).unwrap();
2700        let args = forms[0].as_list().unwrap();
2701        let json = (handler.compile)(&args[1..]).unwrap();
2702        assert_eq!(json["name"], "prom");
2703        assert_eq!(json["query"], "q");
2704        assert_eq!(json["threshold"], 0.5);
2705    }
2706
2707    // ── assert_tatara_domain_well_formed — the substrate-wide testkit ──
2708
2709    #[test]
2710    fn assert_tatara_domain_well_formed_passes_on_the_derive_reference_impl() {
2711        // The reference implementor `MonitorSpec` inherits the trait
2712        // default `compile_from_sexp` through the derive; every one of
2713        // the four rejection gates (bare atom, empty list, non-symbol
2714        // head, wrong-head symbol) MUST fire with the substrate-wide
2715        // structural `LispError` variant, AND its KEYWORD `"defmonitor"`
2716        // MUST pass the three grammar invariants (non-empty; classifies
2717        // as `Atom::Symbol` via `Atom::from_lexeme`; contains no
2718        // `Sexp::is_bare_atom_boundary` char) AND the round-trip
2719        // theorem (`read("defmonitor")` projects to
2720        // `Some("defmonitor")`). The single line below pins all EIGHT
2721        // at once — every future `#[derive(TataraDomain)]` implementor
2722        // reduces to the same one-line check in its test module,
2723        // mirroring the `assert_closed_set_well_formed` deployment
2724        // across 44+ closed-set implementor test sites.
2725        assert_tatara_domain_well_formed::<MonitorSpec>();
2726    }
2727
2728    #[test]
2729    fn assert_tatara_domain_well_formed_panics_on_empty_keyword() {
2730        // Negative arm on invariant (1) — a hand-written impl whose
2731        // KEYWORD is the empty string tries to be a keyword-less
2732        // dispatch target. The trait can't discriminate `(some-form
2733        // …)` from `(other-form …)` without a lexeme, so the testkit
2734        // MUST fire on this degenerate shape. Uses `catch_unwind` to
2735        // observe the panic without terminating the test process —
2736        // same posture the closed-set testkit's negative-arm tests
2737        // take (see `closed_set.rs`).
2738        struct EmptyKeyword;
2739        impl TataraDomain for EmptyKeyword {
2740            const KEYWORD: &'static str = "";
2741            fn compile_from_args(_: &[Sexp]) -> Result<Self> {
2742                unreachable!("compile_from_args unreachable — invariant (1) trips first")
2743            }
2744        }
2745        let result = std::panic::catch_unwind(|| {
2746            assert_tatara_domain_well_formed::<EmptyKeyword>();
2747        });
2748        let payload = result.expect_err("expected empty-KEYWORD invariant to panic");
2749        let msg = payload
2750            .downcast_ref::<String>()
2751            .map(String::as_str)
2752            .or_else(|| payload.downcast_ref::<&'static str>().copied())
2753            .unwrap_or("");
2754        assert!(
2755            msg.contains("KEYWORD is empty"),
2756            "expected empty-KEYWORD panic message to name the invariant, got {msg:?}",
2757        );
2758    }
2759
2760    /// Extract the panic message of the closure so the test module's
2761    /// negative-arm sweep binds to ONE substrate-owned decode instead of
2762    /// re-inlining the `catch_unwind` + `downcast_ref::<String>` + fallback
2763    /// to `&'static str` cascade at every arm.
2764    fn assert_panic_msg_contains(needle: &str, f: impl FnOnce() + std::panic::UnwindSafe) {
2765        let result = std::panic::catch_unwind(f);
2766        let payload = result.expect_err("expected invariant to panic");
2767        let msg = payload
2768            .downcast_ref::<String>()
2769            .map(String::as_str)
2770            .or_else(|| payload.downcast_ref::<&'static str>().copied())
2771            .unwrap_or("");
2772        assert!(
2773            msg.contains(needle),
2774            "expected panic message to contain {needle:?}, got {msg:?}",
2775        );
2776    }
2777
2778    #[test]
2779    fn assert_tatara_domain_well_formed_panics_on_ascii_whitespace_keyword() {
2780        // Negative arm on invariant (7) — a KEYWORD like `"def foo"`
2781        // would arrive at the trait's head-match as two tokens because
2782        // `Sexp::is_bare_atom_boundary(' ') == true` via
2783        // `char::is_whitespace`. The testkit MUST catch this before an
2784        // integration surface silently drops the trailing word. The
2785        // pre-lift ASCII-only heuristic caught the same case; the
2786        // sharpened invariant catches it via the substrate's typed
2787        // reader-boundary projection.
2788        struct WhitespaceKeyword;
2789        impl TataraDomain for WhitespaceKeyword {
2790            const KEYWORD: &'static str = "def foo";
2791            fn compile_from_args(_: &[Sexp]) -> Result<Self> {
2792                unreachable!("compile_from_args unreachable — invariant (7) trips first")
2793            }
2794        }
2795        assert_panic_msg_contains("reader-boundary char", || {
2796            assert_tatara_domain_well_formed::<WhitespaceKeyword>();
2797        });
2798    }
2799
2800    #[test]
2801    fn assert_tatara_domain_well_formed_panics_on_unicode_whitespace_keyword() {
2802        // Negative arm on invariant (7) — a KEYWORD carrying the
2803        // no-break-space codepoint `\u{00A0}` (a Unicode-whitespace
2804        // char the pre-lift `is_ascii_whitespace()` check silently
2805        // accepted). The reader's outer-dispatch calls
2806        // `char::is_whitespace()` (Unicode-aware) via
2807        // `Sexp::is_bare_atom_boundary`, so a KEYWORD `"def\u{00A0}foo"`
2808        // would split into two tokens. Binding the invariant to the
2809        // substrate's typed reader-boundary projection closes this hole
2810        // that the pre-lift ASCII-only heuristic left open.
2811        struct NbspKeyword;
2812        impl TataraDomain for NbspKeyword {
2813            const KEYWORD: &'static str = "def\u{00A0}foo";
2814            fn compile_from_args(_: &[Sexp]) -> Result<Self> {
2815                unreachable!("compile_from_args unreachable — invariant (7) trips first")
2816            }
2817        }
2818        assert_panic_msg_contains("reader-boundary char", || {
2819            assert_tatara_domain_well_formed::<NbspKeyword>();
2820        });
2821    }
2822
2823    #[test]
2824    fn assert_tatara_domain_well_formed_panics_on_list_open_char_keyword() {
2825        // Negative arm on invariant (7) — a KEYWORD like `"def(x"`
2826        // embeds `Sexp::LIST_OPEN` mid-lexeme; the reader's bare-atom
2827        // terminator disjunct fires on `(`, splitting the token so the
2828        // trait's head-match would see `"def"` followed by an opening
2829        // paren — the head-match would fire on `"def"`, silently
2830        // matching a DIFFERENT keyword. This is the reader-boundary
2831        // hole the pre-lift ASCII-whitespace heuristic silently
2832        // accepted; binding to `Sexp::is_bare_atom_boundary` catches
2833        // it structurally.
2834        struct ListOpenKeyword;
2835        impl TataraDomain for ListOpenKeyword {
2836            const KEYWORD: &'static str = "def(x";
2837            fn compile_from_args(_: &[Sexp]) -> Result<Self> {
2838                unreachable!("compile_from_args unreachable — invariant (7) trips first")
2839            }
2840        }
2841        assert_panic_msg_contains("reader-boundary char", || {
2842            assert_tatara_domain_well_formed::<ListOpenKeyword>();
2843        });
2844    }
2845
2846    #[test]
2847    fn assert_tatara_domain_well_formed_panics_on_comment_lead_char_keyword() {
2848        // Negative arm on invariant (7) — a KEYWORD like `"def;bad"`
2849        // embeds `Sexp::COMMENT_LEAD` mid-lexeme; the reader's outer
2850        // dispatch would treat `;` as the start of a line comment,
2851        // discarding everything after it up to newline. The trait's
2852        // head-match would fire on `"def"` (the token before `;`),
2853        // silently matching a DIFFERENT keyword. Sibling coverage to
2854        // the list-open arm above on the seven-terminator disjunction
2855        // `Sexp::NON_WHITESPACE_BARE_ATOM_TERMINATORS`.
2856        struct CommentLeadKeyword;
2857        impl TataraDomain for CommentLeadKeyword {
2858            const KEYWORD: &'static str = "def;bad";
2859            fn compile_from_args(_: &[Sexp]) -> Result<Self> {
2860                unreachable!("compile_from_args unreachable — invariant (7) trips first")
2861            }
2862        }
2863        assert_panic_msg_contains("reader-boundary char", || {
2864            assert_tatara_domain_well_formed::<CommentLeadKeyword>();
2865        });
2866    }
2867
2868    #[test]
2869    fn assert_tatara_domain_well_formed_panics_on_keyword_marker_prefix() {
2870        // Negative arm on invariant (6) — a KEYWORD `":foo"` classifies
2871        // as `Atom::Keyword` via the reader's `Atom::from_lexeme`
2872        // classifier (the `:` prefix is stripped and the remainder
2873        // becomes the keyword payload). The pre-lift "no leading ASCII
2874        // digit" heuristic silently accepted this shape; the sharpened
2875        // invariant binds to the substrate's typed classifier so the
2876        // shape rejects structurally.
2877        struct KeywordMarkerKeyword;
2878        impl TataraDomain for KeywordMarkerKeyword {
2879            const KEYWORD: &'static str = ":foo";
2880            fn compile_from_args(_: &[Sexp]) -> Result<Self> {
2881                unreachable!("compile_from_args unreachable — invariant (6) trips first")
2882            }
2883        }
2884        assert_panic_msg_contains("Atom::from_lexeme", || {
2885            assert_tatara_domain_well_formed::<KeywordMarkerKeyword>();
2886        });
2887    }
2888
2889    #[test]
2890    fn assert_tatara_domain_well_formed_panics_on_bool_literal_keyword() {
2891        // Negative arm on invariant (6) — a KEYWORD `"#t"` classifies
2892        // as `Atom::Bool(true)` via `Atom::from_lexeme`'s bool-literal
2893        // arm. The pre-lift heuristic silently accepted this shape
2894        // (starts with `#`, not a digit); the sharpened invariant binds
2895        // to the substrate's typed classifier so the shape rejects
2896        // structurally. Peer coverage to the `:foo` arm above on the
2897        // classifier's non-`Symbol` decode paths.
2898        struct BoolLiteralKeyword;
2899        impl TataraDomain for BoolLiteralKeyword {
2900            const KEYWORD: &'static str = "#t";
2901            fn compile_from_args(_: &[Sexp]) -> Result<Self> {
2902                unreachable!("compile_from_args unreachable — invariant (6) trips first")
2903            }
2904        }
2905        assert_panic_msg_contains("Atom::from_lexeme", || {
2906            assert_tatara_domain_well_formed::<BoolLiteralKeyword>();
2907        });
2908    }
2909
2910    #[test]
2911    fn assert_tatara_domain_well_formed_panics_on_numeric_keyword() {
2912        // Negative arm on invariant (6) — a KEYWORD `"42"` classifies
2913        // as `Atom::Int(42)` via `Atom::from_lexeme`'s `parse::<i64>`
2914        // arm. The pre-lift heuristic caught this via the leading-
2915        // digit check; the sharpened invariant catches it via the
2916        // classifier's typed decode — a stricter check with a
2917        // structurally-named diagnostic.
2918        struct NumericKeyword;
2919        impl TataraDomain for NumericKeyword {
2920            const KEYWORD: &'static str = "42";
2921            fn compile_from_args(_: &[Sexp]) -> Result<Self> {
2922                unreachable!("compile_from_args unreachable — invariant (6) trips first")
2923            }
2924        }
2925        assert_panic_msg_contains("Atom::from_lexeme", || {
2926            assert_tatara_domain_well_formed::<NumericKeyword>();
2927        });
2928    }
2929
2930    #[test]
2931    fn assert_tatara_domain_well_formed_panics_on_drifted_compile_from_sexp() {
2932        // Negative arm on invariant (4) — an override that swallows
2933        // the bare-atom form and returns `Ok(_)` drifts the trait's
2934        // typed-entry gate; the testkit MUST fire on this drift so
2935        // the substrate-wide `NotAListForm` contract stays enforced
2936        // across every implementor rather than only across those that
2937        // keep the trait default.
2938        #[derive(Debug, PartialEq)]
2939        struct SwallowsBareAtom;
2940        impl TataraDomain for SwallowsBareAtom {
2941            const KEYWORD: &'static str = "defbogus";
2942            fn compile_from_args(_: &[Sexp]) -> Result<Self> {
2943                Ok(SwallowsBareAtom)
2944            }
2945            fn compile_from_sexp(_form: &Sexp) -> Result<Self> {
2946                // Intentionally-broken: this override accepts EVERY
2947                // form, including a bare atom — the drift the testkit
2948                // catches.
2949                Ok(SwallowsBareAtom)
2950            }
2951        }
2952        let result = std::panic::catch_unwind(|| {
2953            assert_tatara_domain_well_formed::<SwallowsBareAtom>();
2954        });
2955        let payload = result.expect_err("expected drifted-override invariant to panic");
2956        let msg = payload
2957            .downcast_ref::<String>()
2958            .map(String::as_str)
2959            .or_else(|| payload.downcast_ref::<&'static str>().copied())
2960            .unwrap_or("");
2961        assert!(
2962            msg.contains("accepted a bare-atom form"),
2963            "expected drifted-override panic message to name the invariant, got {msg:?}",
2964        );
2965    }
2966}