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 crate::ast::Sexp;
15use crate::error::{LispError, Result};
16
17/// Phase F: a Rust type (typically a unit-only enum) whose variants map to
18/// a single Lisp keyword atom — e.g., `Role::Master` ↔ `:master`. Used by
19/// `#[derive(TataraDomain)]` fields with `#[tatara(keyword_enum)]`. Derive
20/// via `#[derive(KeywordSexp)]` from `tatara-lisp-derive`.
21pub trait KeywordSexp: Sized {
22    /// Parse `s` (the keyword name without the leading `:`) into Self.
23    fn from_keyword(s: &str) -> Result<Self>;
24    /// The keyword name (without leading `:`) for this variant.
25    fn to_keyword(self) -> &'static str;
26}
27
28/// A Rust type compilable from a Lisp form.
29pub trait TataraDomain: Sized {
30    /// The Lisp keyword (e.g., `"defmonitor"`).
31    const KEYWORD: &'static str;
32
33    /// Parse the argument list (everything after the keyword) into Self.
34    fn compile_from_args(args: &[Sexp]) -> Result<Self>;
35
36    /// Parse a complete form; validates the head symbol matches `KEYWORD`.
37    fn compile_from_sexp(form: &Sexp) -> Result<Self> {
38        let list = form.as_list().ok_or_else(|| LispError::Compile {
39            form: Self::KEYWORD.to_string(),
40            message: "expected list form".into(),
41        })?;
42        let head = list
43            .first()
44            .and_then(|s| s.as_symbol())
45            .ok_or_else(|| LispError::Compile {
46                form: Self::KEYWORD.to_string(),
47                message: "missing head symbol".into(),
48            })?;
49        if head != Self::KEYWORD {
50            return Err(LispError::Compile {
51                form: Self::KEYWORD.to_string(),
52                message: format!("expected ({} ...), got ({} ...)", Self::KEYWORD, head),
53            });
54        }
55        Self::compile_from_args(&list[1..])
56    }
57}
58
59// ── kwarg parsing + typed extractors used by the derive macro ──────
60
61pub type Kwargs<'a> = HashMap<String, &'a Sexp>;
62
63pub fn parse_kwargs(args: &[Sexp]) -> Result<Kwargs<'_>> {
64    let mut kw = HashMap::new();
65    let mut i = 0;
66    while i + 1 < args.len() {
67        let key = args[i].as_keyword().ok_or_else(|| LispError::Compile {
68            form: "kwargs".into(),
69            message: format!("expected keyword at position {i}"),
70        })?;
71        kw.insert(key.to_string(), &args[i + 1]);
72        i += 2;
73    }
74    if i < args.len() {
75        return Err(LispError::OddKwargs);
76    }
77    Ok(kw)
78}
79
80pub fn required<'a>(kw: &'a Kwargs<'_>, key: &str) -> Result<&'a Sexp> {
81    kw.get(key).copied().ok_or_else(|| LispError::Compile {
82        form: format!(":{key}"),
83        message: "required but not provided".into(),
84    })
85}
86
87fn type_err(key: &str, expected: &str) -> LispError {
88    LispError::Compile {
89        form: format!(":{key}"),
90        message: format!("expected {expected}"),
91    }
92}
93
94pub fn extract_string<'a>(kw: &'a Kwargs<'a>, key: &str) -> Result<&'a str> {
95    required(kw, key)?
96        .as_string()
97        .ok_or_else(|| type_err(key, "string"))
98}
99
100pub fn extract_optional_string<'a>(kw: &'a Kwargs<'a>, key: &str) -> Result<Option<&'a str>> {
101    match kw.get(key) {
102        None => Ok(None),
103        Some(v) => match v.as_string() {
104            Some(s) => Ok(Some(s)),
105            None => Err(type_err(key, "string")),
106        },
107    }
108}
109
110pub fn extract_string_list(kw: &Kwargs<'_>, key: &str) -> Result<Vec<String>> {
111    let v = kw.get(key).copied();
112    let Some(v) = v else {
113        return Ok(vec![]);
114    };
115    let list = v
116        .as_list()
117        .ok_or_else(|| type_err(key, "list of strings"))?;
118    list.iter()
119        .map(|s| {
120            s.as_string()
121                .map(String::from)
122                .ok_or_else(|| type_err(key, "list of strings"))
123        })
124        .collect()
125}
126
127pub fn extract_int(kw: &Kwargs<'_>, key: &str) -> Result<i64> {
128    required(kw, key)?
129        .as_int()
130        .ok_or_else(|| type_err(key, "int"))
131}
132
133pub fn extract_optional_int(kw: &Kwargs<'_>, key: &str) -> Result<Option<i64>> {
134    match kw.get(key) {
135        None => Ok(None),
136        Some(v) => v.as_int().map(Some).ok_or_else(|| type_err(key, "int")),
137    }
138}
139
140pub fn extract_float(kw: &Kwargs<'_>, key: &str) -> Result<f64> {
141    required(kw, key)?
142        .as_float()
143        .ok_or_else(|| type_err(key, "number"))
144}
145
146pub fn extract_optional_float(kw: &Kwargs<'_>, key: &str) -> Result<Option<f64>> {
147    match kw.get(key) {
148        None => Ok(None),
149        Some(v) => v
150            .as_float()
151            .map(Some)
152            .ok_or_else(|| type_err(key, "number")),
153    }
154}
155
156pub fn extract_bool(kw: &Kwargs<'_>, key: &str) -> Result<bool> {
157    required(kw, key)?
158        .as_bool()
159        .ok_or_else(|| type_err(key, "bool"))
160}
161
162pub fn extract_optional_bool(kw: &Kwargs<'_>, key: &str) -> Result<Option<bool>> {
163    match kw.get(key) {
164        None => Ok(None),
165        Some(v) => v.as_bool().map(Some).ok_or_else(|| type_err(key, "bool")),
166    }
167}
168
169// ── Near-match suggestion ──────────────────────────────────────────
170//
171// Ported verbatim from pleme-io/tatara's `tatara-lisp/src/domain.rs:983-1073`
172// ahead of the rest of the domain helper layer (phase 2 step 3), because
173// `tatara-closed-set`'s `ClosedSet::suggest_closest` composes it and the
174// alternative was a second copy of the metric. One primitive, two crates,
175// one dependency edge — never two implementations of edit distance.
176
177/// Suggest the candidate closest to `needle` by Levenshtein distance,
178/// when the closest candidate is within a bounded edit distance.
179///
180/// The bound scales with `needle`'s character length:
181///   - len ≤ 3: bound 1 (single-character typo on a short identifier)
182///   - len ≤ 7: bound 2 (insertion + transposition, two typos)
183///   - len ≥ 8: bound 3 (longer identifiers absorb more drift)
184///
185/// Returns the closest candidate within the bound. Ties are broken
186/// lexicographically so two operators on two machines see the same hint
187/// for the same input — diagnostics are deterministic. An exact match in
188/// `candidates` is excluded (the caller already has the keyword; the
189/// suggestion exists for near-misses only). Empty `candidates` returns
190/// `None`.
191///
192/// One named primitive lifts the substrate's understanding of "near-match
193/// across a candidate set" out of any per-call-site implementation. The
194/// unknown-kwarg diagnostic in `reject_unknown_kwargs` is the first
195/// consumer; future consumers — `LispError::HeadMismatch`'s "did you
196/// mean a registered domain?" hint, `tatara-check`'s registry-dispatch
197/// suggestions, the LSP's completion-failure fallback — bind to one
198/// helper rather than re-implementing edit distance.
199///
200/// Theory anchor: THEORY.md §V.1 — "Knowable platform … Render Anywhere."
201/// Naming the likely intended candidate is the floor of a constructive
202/// diagnostic. THEORY.md §VI.1 — generation over composition: every
203/// near-match suggestion in the substrate routes through ONE primitive.
204///
205/// Frontier inspiration: rustc's `find_best_match_for_name`, Idris's
206/// "did you mean …?" elaborator hint, Roslyn's `SymbolMatcher` — bounded
207/// edit distance over a symbol table. Translation through pleme-io
208/// primitives: a pure function over `&[&str]`, no new error variant, no
209/// new IR layer, no new dep.
210#[must_use]
211pub fn suggest<'a>(needle: &str, candidates: &[&'a str]) -> Option<&'a str> {
212    let bound = suggestion_bound(needle);
213    let mut best: Option<(usize, &'a str)> = None;
214    for &candidate in candidates {
215        if candidate == needle {
216            continue;
217        }
218        let dist = levenshtein(needle, candidate);
219        if dist > bound {
220            continue;
221        }
222        match best {
223            None => best = Some((dist, candidate)),
224            Some((bd, bc)) if dist < bd || (dist == bd && candidate < bc) => {
225                best = Some((dist, candidate));
226            }
227            _ => {}
228        }
229    }
230    best.map(|(_, c)| c)
231}
232
233fn suggestion_bound(needle: &str) -> usize {
234    let n = needle.chars().count();
235    if n <= 3 {
236        1
237    } else if n <= 7 {
238        2
239    } else {
240        3
241    }
242}
243
244/// Classic two-row Levenshtein. Operates on `char`s so multibyte input
245/// (e.g. a domain authored with non-ASCII identifiers) measures
246/// character-distance, not byte-distance.
247fn levenshtein(a: &str, b: &str) -> usize {
248    let a: Vec<char> = a.chars().collect();
249    let b: Vec<char> = b.chars().collect();
250    if a.is_empty() {
251        return b.len();
252    }
253    if b.is_empty() {
254        return a.len();
255    }
256    let mut prev: Vec<usize> = (0..=b.len()).collect();
257    let mut curr: Vec<usize> = vec![0; b.len() + 1];
258    for (i, ca) in a.iter().enumerate() {
259        curr[0] = i + 1;
260        for (j, cb) in b.iter().enumerate() {
261            let cost = usize::from(ca != cb);
262            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
263        }
264        std::mem::swap(&mut prev, &mut curr);
265    }
266    prev[b.len()]
267}
268
269// ── Domain registry (runtime-registered, callable by keyword) ───────
270
271/// Erased handler that knows how to compile a form and hand back a typed
272/// serde-JSON representation. JSON is the least-common-denominator typed
273/// surface — every `TataraDomain` derives `serde::Serialize` by convention.
274pub struct DomainHandler {
275    pub keyword: &'static str,
276    pub compile: fn(args: &[Sexp]) -> Result<serde_json::Value>,
277}
278
279static REGISTRY: OnceLock<Mutex<HashMap<&'static str, DomainHandler>>> = OnceLock::new();
280
281fn registry() -> &'static Mutex<HashMap<&'static str, DomainHandler>> {
282    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
283}
284
285/// Register a `TataraDomain` type with the global dispatcher.
286/// Idempotent — repeated registrations overwrite.
287pub fn register<T>()
288where
289    T: TataraDomain + serde::Serialize,
290{
291    let handler = DomainHandler {
292        keyword: T::KEYWORD,
293        compile: |args| {
294            let v = T::compile_from_args(args)?;
295            serde_json::to_value(&v).map_err(|e| LispError::Compile {
296                form: T::KEYWORD.to_string(),
297                message: format!("serialize: {e}"),
298            })
299        },
300    };
301    registry().lock().unwrap().insert(T::KEYWORD, handler);
302}
303
304/// Look up a handler by keyword.
305pub fn lookup(keyword: &str) -> Option<DomainHandler> {
306    let reg = registry().lock().unwrap();
307    reg.get(keyword).map(|h| DomainHandler {
308        keyword: h.keyword,
309        compile: h.compile,
310    })
311}
312
313/// List currently registered keywords.
314pub fn registered_keywords() -> Vec<&'static str> {
315    registry().lock().unwrap().keys().copied().collect()
316}
317
318// ── Capability registries — compounding metadata layer ────────────
319//
320// Each registered domain can ALSO carry capability metadata —
321// orthogonal concerns the rest of the platform needs to ask about
322// the type without importing it. Today: `RenderMetadata` (used by
323// tatara-render to emit Kubernetes CR YAML without a hard-coded
324// match). Future: `ComplianceMetadata`, `DocumentationMetadata`,
325// `AttestationMetadata` — same shape, additional concerns.
326//
327// Each metadata kind has its own static registry parallel to
328// `REGISTRY` (the handler registry). Domain crates call
329// `register_render::<T>()` alongside `register::<T>()` during
330// boot; consumers like `tatara-render` look up by keyword.
331
332/// Type that knows its Kubernetes-CR rendering metadata. Tiny —
333/// just constants. Implementing crates derive nothing; they
334/// `impl RenderableDomain for FooSpec { … }` with three lines.
335pub trait RenderableDomain {
336    /// Kubernetes apiVersion the resource lives under
337    /// (`gateway.networking.k8s.io/v1`, `cilium.io/v2`, etc.).
338    const API_VERSION: &'static str;
339    /// Kubernetes kind (`Gateway`, `CiliumNetworkPolicy`).
340    const KIND: &'static str;
341    /// Field name (in the typed JSON) that supplies the CR's
342    /// `metadata.name`. Most domains use `name`; gateway-api
343    /// uses `gateway_class_name`. Defaults via `Default` impl.
344    const NAME_FIELD: &'static str = "name";
345}
346
347/// Erased render metadata — what `tatara-render` consumes.
348#[derive(Clone, Copy, Debug)]
349pub struct RenderHandler {
350    pub keyword: &'static str,
351    pub api_version: &'static str,
352    pub kind: &'static str,
353    pub name_field: &'static str,
354}
355
356static RENDER_REGISTRY: OnceLock<Mutex<HashMap<&'static str, RenderHandler>>> = OnceLock::new();
357
358fn render_registry() -> &'static Mutex<HashMap<&'static str, RenderHandler>> {
359    RENDER_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
360}
361
362/// Register a `RenderableDomain`'s metadata. Idempotent.
363/// Domain crates call this once at boot, alongside `register::<T>()`.
364pub fn register_render<T>()
365where
366    T: TataraDomain + RenderableDomain,
367{
368    let handler = RenderHandler {
369        keyword: T::KEYWORD,
370        api_version: T::API_VERSION,
371        kind: T::KIND,
372        name_field: T::NAME_FIELD,
373    };
374    render_registry().lock().unwrap().insert(T::KEYWORD, handler);
375}
376
377/// Look up render metadata by keyword.
378#[must_use]
379pub fn lookup_render(keyword: &str) -> Option<RenderHandler> {
380    render_registry().lock().unwrap().get(keyword).copied()
381}
382
383/// List every keyword that has render metadata registered.
384#[must_use]
385pub fn registered_render_keywords() -> Vec<&'static str> {
386    render_registry().lock().unwrap().keys().copied().collect()
387}
388
389// ── Documented capability ─────────────────────────────────────────
390//
391// Third capability layer (compile / render / doc). Each domain
392// can carry its struct-level + field-level documentation strings
393// for catalog browsers, IDE hover-help, and the `tatara doc`
394// CLI to consult uniformly.
395
396/// Type that knows its human-readable documentation. Tiny: one
397/// `&'static str` for the type-level summary, plus an array of
398/// (field, doc) pairs.
399pub trait DocumentedDomain {
400    /// Top-level docstring for the type — what an embedder sees
401    /// when hovering the keyword in a catalog browser.
402    const DOCSTRING: &'static str;
403    /// Per-field docstrings, in declaration order. Empty when no
404    /// docs were captured upstream (typical for hand-written
405    /// domains until they fill them in). Forge-generated domains
406    /// populate this from CRD `description` fields.
407    const FIELD_DOCS: &'static [(&'static str, &'static str)];
408}
409
410/// Erased doc handle.
411#[derive(Clone, Copy, Debug)]
412pub struct DocHandler {
413    pub keyword: &'static str,
414    pub docstring: &'static str,
415    pub field_docs: &'static [(&'static str, &'static str)],
416}
417
418static DOC_REGISTRY: OnceLock<Mutex<HashMap<&'static str, DocHandler>>> = OnceLock::new();
419
420fn doc_registry() -> &'static Mutex<HashMap<&'static str, DocHandler>> {
421    DOC_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
422}
423
424/// Register a `DocumentedDomain`'s metadata. Idempotent.
425pub fn register_doc<T>()
426where
427    T: TataraDomain + DocumentedDomain,
428{
429    let handler = DocHandler {
430        keyword: T::KEYWORD,
431        docstring: T::DOCSTRING,
432        field_docs: T::FIELD_DOCS,
433    };
434    doc_registry().lock().unwrap().insert(T::KEYWORD, handler);
435}
436
437/// Look up doc metadata by keyword.
438#[must_use]
439pub fn lookup_doc(keyword: &str) -> Option<DocHandler> {
440    doc_registry().lock().unwrap().get(keyword).copied()
441}
442
443/// List every keyword that has doc metadata registered.
444#[must_use]
445pub fn registered_doc_keywords() -> Vec<&'static str> {
446    doc_registry().lock().unwrap().keys().copied().collect()
447}
448
449// ── Dependent capability ──────────────────────────────────────────
450//
451// Fourth capability layer (compile / render / doc / deps). Each
452// domain can declare which OTHER keywords its instances logically
453// depend on. The rollout pipeline consumes this to topo-sort the
454// `Plan` so deploys land in the right order — apply
455// `defservice` before `defpodmonitor` before `defciliumnetworkpolicy`,
456// drain in reverse.
457
458/// Type-level dependency declarations. The strings are keywords
459/// of OTHER domains this one expects to be present (e.g. a
460/// `defciliumnetworkpolicy` depends on a `defservice` whose pods
461/// it selects). The dependency relation is type-to-type, not
462/// instance-to-instance — finer-grained refs live on the typed
463/// resource value itself.
464pub trait DependentDomain {
465    /// Keywords this domain logically depends on. Empty by
466    /// default for forge-generated domains since CRDs don't
467    /// generally declare deps; hand-written domains override
468    /// to capture real ordering constraints.
469    const DEPENDS_ON: &'static [&'static str];
470}
471
472/// Erased dep handle — what the topo-sort consumer reads.
473#[derive(Clone, Copy, Debug)]
474pub struct DepsHandler {
475    pub keyword: &'static str,
476    pub depends_on: &'static [&'static str],
477}
478
479static DEPS_REGISTRY: OnceLock<Mutex<HashMap<&'static str, DepsHandler>>> = OnceLock::new();
480
481fn deps_registry() -> &'static Mutex<HashMap<&'static str, DepsHandler>> {
482    DEPS_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
483}
484
485/// Register a `DependentDomain`'s deps. Idempotent.
486pub fn register_deps<T>()
487where
488    T: TataraDomain + DependentDomain,
489{
490    let handler = DepsHandler {
491        keyword: T::KEYWORD,
492        depends_on: T::DEPENDS_ON,
493    };
494    deps_registry().lock().unwrap().insert(T::KEYWORD, handler);
495}
496
497/// Look up dep metadata by keyword.
498#[must_use]
499pub fn lookup_deps(keyword: &str) -> Option<DepsHandler> {
500    deps_registry().lock().unwrap().get(keyword).copied()
501}
502
503/// List every keyword that has dep metadata registered.
504#[must_use]
505pub fn registered_deps_keywords() -> Vec<&'static str> {
506    deps_registry().lock().unwrap().keys().copied().collect()
507}
508
509// ── Schematic capability ──────────────────────────────────────────
510//
511// Fifth capability layer: per-domain JSON Schema export. Forge-
512// generated domains preserve the source CRD's openAPIV3Schema
513// verbatim; hand-written domains can either skip the layer or
514// hand-curate a schema. Consumers: IDE hover-help, web
515// validators, openapi exporters, admin-UI form generators —
516// everyone who wants the typed shape without depending on the
517// Rust struct directly.
518
519pub trait SchematicDomain {
520    /// JSON Schema source for this type. Preserved verbatim from
521    /// the CRD's openAPIV3Schema for forge-generated domains;
522    /// hand-curated for non-CRD domains. Consumers parse this on
523    /// demand — keeping it as a static string avoids paying
524    /// serde_json::Value at startup for every domain.
525    const SCHEMA_JSON: &'static str;
526}
527
528#[derive(Clone, Copy, Debug)]
529pub struct SchemaHandler {
530    pub keyword: &'static str,
531    pub schema_json: &'static str,
532}
533
534static SCHEMA_REGISTRY: OnceLock<Mutex<HashMap<&'static str, SchemaHandler>>> = OnceLock::new();
535
536fn schema_registry() -> &'static Mutex<HashMap<&'static str, SchemaHandler>> {
537    SCHEMA_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
538}
539
540pub fn register_schema<T>()
541where
542    T: TataraDomain + SchematicDomain,
543{
544    let handler = SchemaHandler {
545        keyword: T::KEYWORD,
546        schema_json: T::SCHEMA_JSON,
547    };
548    schema_registry().lock().unwrap().insert(T::KEYWORD, handler);
549}
550
551#[must_use]
552pub fn lookup_schema(keyword: &str) -> Option<SchemaHandler> {
553    schema_registry().lock().unwrap().get(keyword).copied()
554}
555
556#[must_use]
557pub fn registered_schema_keywords() -> Vec<&'static str> {
558    schema_registry().lock().unwrap().keys().copied().collect()
559}
560
561// ── Attestable capability ─────────────────────────────────────────
562//
563// Sixth capability layer: each domain declares its **attestation
564// namespace** — the bucket the tameshi BLAKE3 chain groups its
565// resources under. The canonical hash itself is namespace-aware
566// (`blake3(namespace || canonical_json(value))`) so two resources
567// with identical content but different domains never collide in
568// the attestation tree. Closes the trust loop in the rollout
569// pipeline.
570
571pub trait AttestableDomain {
572    /// Bucket name for the tameshi attestation chain. Forge-
573    /// generated CRD domains use the CRD's group (e.g.
574    /// `gateway.networking.k8s.io`); hand-written domains pick
575    /// a stable namespace (e.g. `pleme.io/ebpf`). The namespace
576    /// is hashed into the resource's BLAKE3 so cross-domain
577    /// collisions are impossible.
578    const ATTESTATION_NAMESPACE: &'static str;
579}
580
581#[derive(Clone, Copy, Debug)]
582pub struct AttestHandler {
583    pub keyword: &'static str,
584    pub namespace: &'static str,
585}
586
587static ATTEST_REGISTRY: OnceLock<Mutex<HashMap<&'static str, AttestHandler>>> = OnceLock::new();
588
589fn attest_registry() -> &'static Mutex<HashMap<&'static str, AttestHandler>> {
590    ATTEST_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
591}
592
593pub fn register_attest<T>()
594where
595    T: TataraDomain + AttestableDomain,
596{
597    let handler = AttestHandler {
598        keyword: T::KEYWORD,
599        namespace: T::ATTESTATION_NAMESPACE,
600    };
601    attest_registry().lock().unwrap().insert(T::KEYWORD, handler);
602}
603
604#[must_use]
605pub fn lookup_attest(keyword: &str) -> Option<AttestHandler> {
606    attest_registry().lock().unwrap().get(keyword).copied()
607}
608
609#[must_use]
610pub fn registered_attest_keywords() -> Vec<&'static str> {
611    attest_registry().lock().unwrap().keys().copied().collect()
612}
613
614/// Compute a namespaced BLAKE3 attestation for a typed value.
615///
616/// `BLAKE3(ATTESTATION_NAMESPACE || ":" || canonical_json(value))`
617///
618/// The namespace prefix prevents cross-domain hash collisions in
619/// the tameshi attestation tree — two resources with identical
620/// JSON but different domain semantics produce different hashes.
621/// The canonical-JSON serialization is what `serde_json::to_string`
622/// produces; consumers can rely on the hash being stable across
623/// processes given the same input value.
624#[must_use]
625pub fn attest_value(namespace: &str, value: &serde_json::Value) -> String {
626    let canonical = serde_json::to_string(value).unwrap_or_default();
627    let mut hasher = blake3::Hasher::new();
628    hasher.update(namespace.as_bytes());
629    hasher.update(b":");
630    hasher.update(canonical.as_bytes());
631    hasher.finalize().to_hex().to_string()
632}
633
634// ── Validated capability ──────────────────────────────────────────
635//
636// Seventh capability layer: per-domain semantic validators. The
637// first capability with **executable behavior** (not just static
638// metadata) — the registry stores function pointers, not
639// constants. Each domain plugs in its own logic; the env-level
640// validator dispatches.
641
642/// Type that carries a semantic validator for its typed values.
643/// Default impl returns `Ok(())` — so domains opt in, never
644/// out. The validator runs AFTER `compile_from_args` succeeds —
645/// it's a chance to enforce cross-field invariants the type
646/// system alone can't catch (e.g. "if `kind = :xdp`, `attach`
647/// must include an interface").
648pub trait ValidatedDomain {
649    /// Validate the typed JSON form of a domain instance. The
650    /// default returns Ok — domains override to add real checks.
651    /// Errors carry a human-readable message naming the
652    /// offending field + constraint.
653    fn validate_value(_value: &serde_json::Value) -> std::result::Result<(), String> {
654        Ok(())
655    }
656}
657
658/// Erased validator handle — function pointer, no captured state.
659#[derive(Clone, Copy)]
660pub struct ValidateHandler {
661    pub keyword: &'static str,
662    pub validate: fn(&serde_json::Value) -> std::result::Result<(), String>,
663}
664
665impl std::fmt::Debug for ValidateHandler {
666    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
667        f.debug_struct("ValidateHandler")
668            .field("keyword", &self.keyword)
669            .field("validate", &"<fn>")
670            .finish()
671    }
672}
673
674static VALIDATE_REGISTRY: OnceLock<Mutex<HashMap<&'static str, ValidateHandler>>> = OnceLock::new();
675
676fn validate_registry() -> &'static Mutex<HashMap<&'static str, ValidateHandler>> {
677    VALIDATE_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
678}
679
680pub fn register_validate<T>()
681where
682    T: TataraDomain + ValidatedDomain,
683{
684    let handler = ValidateHandler {
685        keyword: T::KEYWORD,
686        validate: <T as ValidatedDomain>::validate_value,
687    };
688    validate_registry().lock().unwrap().insert(T::KEYWORD, handler);
689}
690
691#[must_use]
692pub fn lookup_validate(keyword: &str) -> Option<ValidateHandler> {
693    validate_registry().lock().unwrap().get(keyword).copied()
694}
695
696#[must_use]
697pub fn registered_validate_keywords() -> Vec<&'static str> {
698    validate_registry().lock().unwrap().keys().copied().collect()
699}
700
701// ── Lifecycle capability ──────────────────────────────────────────
702//
703// Eighth capability layer: per-domain rollout strategy. Where
704// Layer 4 (DependentDomain) declares **apply X before Y**, Layer
705// 8 declares **when X changes, here's how to swap it**.
706//
707// Different shapes need different protocols:
708//   - service-shaped CRs (Gateway, Service): RollingUpdate
709//   - stateful resources (ConfigMaps owned by stateful sets):
710//     Recreate
711//   - kernel-attached programs (eBPF): BlueGreen — load new
712//     before unloading old, atomic-swap (the verifier rejects
713//     half-loaded state, so blue/green is the only safe shape)
714//   - config CRs (most CRD-shaped resources): Immediate
715//
716// `tatara-rollout` (and future `tatara-deploy`) consult this
717// per Change to pick the right swap protocol for each resource.
718
719#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
720pub enum RolloutStrategy {
721    /// Apply once, no transition. Most config-shaped CRDs.
722    Immediate,
723    /// Tear down, then create. Stateful resources where in-place
724    /// updates aren't safe.
725    Recreate,
726    /// Standard rolling update — replace pod-by-pod with health
727    /// probes between. Service-shaped CRs.
728    RollingUpdate,
729    /// Install new alongside old, switch traffic, drain old.
730    /// Kernel-attached programs (eBPF) — the verifier won't
731    /// accept half-loaded state, so blue/green is the only
732    /// safe shape.
733    BlueGreen,
734    /// Percentage traffic shift over time. Service mesh primary
735    /// pattern.
736    Canary,
737}
738
739pub trait LifecycleProtocol {
740    /// How changes to this domain's resources roll out.
741    const STRATEGY: RolloutStrategy;
742    /// Seconds to wait for graceful termination before force-kill.
743    /// 30s default matches K8s pod terminationGracePeriodSeconds.
744    const DRAIN_SECONDS: u32 = 30;
745}
746
747#[derive(Clone, Copy, Debug)]
748pub struct LifecycleHandler {
749    pub keyword: &'static str,
750    pub strategy: RolloutStrategy,
751    pub drain_seconds: u32,
752}
753
754static LIFECYCLE_REGISTRY: OnceLock<Mutex<HashMap<&'static str, LifecycleHandler>>> =
755    OnceLock::new();
756
757fn lifecycle_registry() -> &'static Mutex<HashMap<&'static str, LifecycleHandler>> {
758    LIFECYCLE_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
759}
760
761pub fn register_lifecycle<T>()
762where
763    T: TataraDomain + LifecycleProtocol,
764{
765    let handler = LifecycleHandler {
766        keyword: T::KEYWORD,
767        strategy: T::STRATEGY,
768        drain_seconds: T::DRAIN_SECONDS,
769    };
770    lifecycle_registry().lock().unwrap().insert(T::KEYWORD, handler);
771}
772
773#[must_use]
774pub fn lookup_lifecycle(keyword: &str) -> Option<LifecycleHandler> {
775    lifecycle_registry().lock().unwrap().get(keyword).copied()
776}
777
778#[must_use]
779pub fn registered_lifecycle_keywords() -> Vec<&'static str> {
780    lifecycle_registry().lock().unwrap().keys().copied().collect()
781}
782
783// ── Meta-compounder: capability_layer! macro ──────────────────────
784//
785// Layers 1–8 above each take ~50 lines of boilerplate (trait +
786// handler struct + registry + 3 fns). The macro below collapses
787// every static-data capability layer to ~10 lines of declaration.
788// First-class compounding the compounding: each new layer is now
789// shorter to author than its predecessors.
790//
791// Use the macro for layers whose trait holds only `const` items
792// (and whose handler is a flat struct of those values). Layers
793// with executable behavior (Validated, layer 7) keep the
794// hand-written form because the trait carries a method, not
795// constants — `fn validate_value(&Value) -> Result<…>` doesn't
796// fit a `const` slot.
797//
798// Shape:
799//
800//   capability_layer! {
801//       trait $Trait,                     // pub trait + name
802//       handler $Handler,                 // erased Handler struct
803//       static $REGISTRY,                 // backing OnceLock
804//       registry_fn $internal_fn,         // private accessor
805//       register $register_fn,            // pub register::<T>()
806//       lookup $lookup_fn,                // pub lookup(kw) -> Option<Handler>
807//       list $list_fn,                    // pub list registered keywords
808//       consts {
809//           const NAME: ty => field name,  // trait const → handler field
810//           ...
811//       }
812//   }
813
814#[macro_export]
815macro_rules! capability_layer {
816    (
817        trait $Trait:ident,
818        handler $Handler:ident,
819        static $REGISTRY:ident,
820        registry_fn $registry_fn:ident,
821        register $register:ident,
822        lookup $lookup:ident,
823        list $list:ident,
824        consts {
825            $(const $CONST:ident: $ty:ty => field $field:ident),* $(,)?
826        } $(,)?
827    ) => {
828        pub trait $Trait {
829            $(const $CONST: $ty;)*
830        }
831
832        #[derive(Clone, Copy, Debug)]
833        pub struct $Handler {
834            pub keyword: &'static str,
835            $(pub $field: $ty,)*
836        }
837
838        static $REGISTRY: ::std::sync::OnceLock<
839            ::std::sync::Mutex<::std::collections::HashMap<&'static str, $Handler>>
840        > = ::std::sync::OnceLock::new();
841
842        fn $registry_fn() -> &'static ::std::sync::Mutex<
843            ::std::collections::HashMap<&'static str, $Handler>
844        > {
845            $REGISTRY.get_or_init(|| {
846                ::std::sync::Mutex::new(::std::collections::HashMap::new())
847            })
848        }
849
850        pub fn $register<T>()
851        where
852            T: $crate::domain::TataraDomain + $Trait,
853        {
854            let handler = $Handler {
855                keyword: T::KEYWORD,
856                $($field: T::$CONST,)*
857            };
858            $registry_fn().lock().unwrap().insert(T::KEYWORD, handler);
859        }
860
861        #[must_use]
862        pub fn $lookup(keyword: &str) -> Option<$Handler> {
863            $registry_fn().lock().unwrap().get(keyword).copied()
864        }
865
866        #[must_use]
867        pub fn $list() -> Vec<&'static str> {
868            $registry_fn().lock().unwrap().keys().copied().collect()
869        }
870    };
871}
872
873// ── Layer 9: Compliant capability (via the macro) ─────────────────
874//
875// First layer authored with the meta-compounder. Compounding the
876// compounding made operational. Per-domain compliance posture —
877// which baselines the resource satisfies (NIST 800-53, CIS,
878// FedRAMP, PCI DSS, SOC 2). Consumers: kensa (compliance engine),
879// sekiban (admission webhook), tameshi (heartbeat chain).
880
881capability_layer! {
882    trait CompliantDomain,
883    handler ComplianceHandler,
884    static COMPLIANCE_REGISTRY,
885    registry_fn compliance_registry,
886    register register_compliance,
887    lookup lookup_compliance,
888    list registered_compliance_keywords,
889    consts {
890        const FRAMEWORKS: &'static [&'static str] => field frameworks,
891        const CONTROLS: &'static [&'static str] => field controls,
892    }
893}
894
895// ── Layer 10: Observable capability (via the macro) ───────────────
896//
897// Per-domain Prometheus metric prefix + log label names.
898// Consumers: arch-synthesizer (auto-generates ServiceMonitor +
899// PodMonitor specs that scrape the right prefixes) and the
900// Loki query layer (knows which labels each domain emits).
901
902capability_layer! {
903    trait ObservableDomain,
904    handler ObservabilityHandler,
905    static OBSERVABILITY_REGISTRY,
906    registry_fn observability_registry,
907    register register_observability,
908    lookup lookup_observability,
909    list registered_observability_keywords,
910    consts {
911        const METRIC_PREFIX: &'static str => field metric_prefix,
912        const LOG_LABELS: &'static [&'static str] => field log_labels,
913    }
914}
915
916// ── Layer 11: Authoring help capability (via the macro) ───────────
917//
918// Per-domain authoring examples + a one-liner mnemonic for the
919// catalog browser. Consumers: tatara-doc (renders examples in
920// the catalog), IDE hover-help, the future `tatara init` CLI
921// that scaffolds new programs from examples.
922
923capability_layer! {
924    trait HelpDomain,
925    handler HelpHandler,
926    static HELP_REGISTRY,
927    registry_fn help_registry,
928    register register_help,
929    lookup lookup_help,
930    list registered_help_keywords,
931    consts {
932        const MNEMONIC: &'static str => field mnemonic,
933        const EXAMPLES: &'static [&'static str] => field examples,
934    }
935}
936
937// ── Layer 12: Stable capability (via the macro) ───────────────────
938//
939// Per-domain stability signal. Consumers: caixa-lint (warns on
940// unstable usages), tatara-doc (decorates the catalog), CI
941// gates (blocks promotion to prod when an unstable resource
942// crosses a `:tier "prod"` env boundary).
943
944capability_layer! {
945    trait StableDomain,
946    handler StabilityHandler,
947    static STABILITY_REGISTRY,
948    registry_fn stability_registry,
949    register register_stability,
950    lookup lookup_stability,
951    list registered_stability_keywords,
952    consts {
953        const STABILITY: &'static str => field stability,
954        const SINCE_VERSION: &'static str => field since_version,
955    }
956}
957
958// ── Meta-meta-compounder: impl_default_capabilities! ──────────────
959//
960// Forge-generated domains plug into the platform with a single
961// macro call:
962//
963//   impl_default_capabilities!(MyDomainSpec);
964//
965// Expands to default `impl` blocks for every static-data
966// capability layer that *has* a meaningful default. Layers
967// without a sensible default (Render, Validated — Render needs
968// real api_version+kind, Validated has its trait-default
969// `validate_value`) are skipped here; the forge emits those
970// separately when CRD metadata is available.
971//
972// **Why this matters**: previously, adding a new capability
973// layer required editing both `tatara-lisp::domain` (define the
974// layer) AND `tatara-domain-forge::emit` (emit per-layer impl
975// blocks). Now the forge's emit is a single line; new layers
976// land in this macro alone. Compounding the compounding the
977// compounding — three orders deep.
978
979#[macro_export]
980macro_rules! impl_default_capabilities {
981    ($Spec:ty) => {
982        // NOTE: Layer 3 (Documented) is intentionally NOT here.
983        // Forge-generated domains emit it explicitly with real
984        // docs from CRD descriptions; hand-written domains
985        // override directly. The macro covering it would create
986        // a double-impl conflict in both cases.
987        //
988        // Layer 4 — Dependent (forge default empty).
989        impl $crate::domain::DependentDomain for $Spec {
990            const DEPENDS_ON: &'static [&'static str] = &[];
991        }
992        // Layer 7 — Validated (uses the trait's default fn).
993        impl $crate::domain::ValidatedDomain for $Spec {}
994        // Layer 8 — Lifecycle (Immediate is the safe CRD default).
995        impl $crate::domain::LifecycleProtocol for $Spec {
996            const STRATEGY: $crate::domain::RolloutStrategy =
997                $crate::domain::RolloutStrategy::Immediate;
998        }
999        // Layer 9 — Compliance (claims none by default).
1000        impl $crate::domain::CompliantDomain for $Spec {
1001            const FRAMEWORKS: &'static [&'static str] = &[];
1002            const CONTROLS: &'static [&'static str] = &[];
1003        }
1004        // Layer 10 — Observable (no metrics by default).
1005        impl $crate::domain::ObservableDomain for $Spec {
1006            const METRIC_PREFIX: &'static str = "";
1007            const LOG_LABELS: &'static [&'static str] = &[];
1008        }
1009        // Layer 11 — Authoring help.
1010        impl $crate::domain::HelpDomain for $Spec {
1011            const MNEMONIC: &'static str = "";
1012            const EXAMPLES: &'static [&'static str] = &[];
1013        }
1014        // Layer 12 — Stability (assume stable + 0.1.0 unless
1015        // overridden; loud-failure beats silent missing field).
1016        impl $crate::domain::StableDomain for $Spec {
1017            const STABILITY: &'static str = "stable";
1018            const SINCE_VERSION: &'static str = "0.1.0";
1019        }
1020    };
1021}
1022
1023/// Companion to `impl_default_capabilities!` — registers every
1024/// layer's handler in one call. Domains that have explicit
1025/// Render + Schema + Attest metadata also call those register
1026/// fns separately (they're not part of this macro because not
1027/// every domain has them — hand-written ebpf doesn't have render
1028/// metadata). Adding a new always-present layer means updating
1029/// this macro and `impl_default_capabilities!` once.
1030#[macro_export]
1031macro_rules! register_all_capabilities {
1032    ($Spec:ty) => {
1033        $crate::domain::register::<$Spec>();
1034        $crate::domain::register_doc::<$Spec>();
1035        $crate::domain::register_deps::<$Spec>();
1036        $crate::domain::register_validate::<$Spec>();
1037        $crate::domain::register_lifecycle::<$Spec>();
1038        $crate::domain::register_compliance::<$Spec>();
1039        $crate::domain::register_observability::<$Spec>();
1040        $crate::domain::register_help::<$Spec>();
1041        $crate::domain::register_stability::<$Spec>();
1042    };
1043}
1044
1045// ── Sexp ↔ serde_json bridge (universal type support) ──────────────
1046//
1047// Lets the derive macro fall through to `serde_json::from_value` for any
1048// field type implementing `Deserialize`. Handles enums (via symbol→string),
1049// nested structs (via kwargs→object), and `Vec<T>` of either.
1050
1051use crate::ast::Atom;
1052use serde_json::Value as JValue;
1053
1054/// Convert a Sexp to its canonical JSON form.
1055///
1056/// Rules:
1057///   - Symbols + Keywords → `Value::String`
1058///     (symbols are enum discriminants; keywords prefix with `:`)
1059///   - Strings, ints, floats, bools → their JSON counterpart
1060///   - Lists that look like `:k v :k v …` → `Value::Object`
1061///   - Other lists → `Value::Array`
1062///   - Quote/Quasiquote/Unquote/UnquoteSplice → convert the inner (strips quote)
1063pub fn sexp_to_json(s: &Sexp) -> JValue {
1064    match s {
1065        Sexp::Nil => JValue::Null,
1066        Sexp::Atom(Atom::Symbol(s)) => JValue::String(s.clone()),
1067        Sexp::Atom(Atom::Keyword(s)) => JValue::String(format!(":{s}")),
1068        Sexp::Atom(Atom::Str(s)) => JValue::String(s.clone()),
1069        Sexp::Atom(Atom::Int(n)) => JValue::Number((*n).into()),
1070        Sexp::Atom(Atom::Float(n)) => serde_json::Number::from_f64(*n)
1071            .map(JValue::Number)
1072            .unwrap_or(JValue::Null),
1073        Sexp::Atom(Atom::Bool(b)) => JValue::Bool(*b),
1074        Sexp::List(items) => {
1075            if is_kwargs_list(items) {
1076                let mut map = serde_json::Map::with_capacity(items.len() / 2);
1077                let mut i = 0;
1078                while i + 1 < items.len() {
1079                    if let Some(k) = items[i].as_keyword() {
1080                        map.insert(kebab_to_camel(k), sexp_to_json(&items[i + 1]));
1081                        i += 2;
1082                    } else {
1083                        break;
1084                    }
1085                }
1086                JValue::Object(map)
1087            } else {
1088                JValue::Array(items.iter().map(sexp_to_json).collect())
1089            }
1090        }
1091        Sexp::Quote(inner)
1092        | Sexp::Quasiquote(inner)
1093        | Sexp::Unquote(inner)
1094        | Sexp::UnquoteSplice(inner) => sexp_to_json(inner),
1095    }
1096}
1097
1098/// Convert serde_json back to Sexp — inverse of `sexp_to_json`.
1099/// Used by `rewrite_typed` to round-trip a typed value through Lisp forms.
1100pub fn json_to_sexp(v: &JValue) -> Sexp {
1101    match v {
1102        JValue::Null => Sexp::Nil,
1103        JValue::Bool(b) => Sexp::boolean(*b),
1104        JValue::Number(n) => {
1105            if let Some(i) = n.as_i64() {
1106                Sexp::int(i)
1107            } else if let Some(f) = n.as_f64() {
1108                Sexp::float(f)
1109            } else {
1110                Sexp::int(0)
1111            }
1112        }
1113        JValue::String(s) => Sexp::string(s.clone()),
1114        JValue::Array(items) => Sexp::List(items.iter().map(json_to_sexp).collect()),
1115        JValue::Object(map) => {
1116            let mut out = Vec::with_capacity(map.len() * 2);
1117            for (k, v) in map {
1118                out.push(Sexp::keyword(camel_to_kebab(k)));
1119                out.push(json_to_sexp(v));
1120            }
1121            Sexp::List(out)
1122        }
1123    }
1124}
1125
1126fn is_kwargs_list(items: &[Sexp]) -> bool {
1127    !items.is_empty()
1128        && items.len() % 2 == 0
1129        && items.iter().step_by(2).all(|s| s.as_keyword().is_some())
1130}
1131
1132/// `must-reach` → `mustReach`, `point-type` → `pointType`.
1133fn kebab_to_camel(s: &str) -> String {
1134    let mut out = String::with_capacity(s.len());
1135    let mut upper = false;
1136    for c in s.chars() {
1137        if c == '-' {
1138            upper = true;
1139        } else if upper {
1140            out.extend(c.to_uppercase());
1141            upper = false;
1142        } else {
1143            out.push(c);
1144        }
1145    }
1146    out
1147}
1148
1149/// `mustReach` → `must-reach` (inverse of `kebab_to_camel`).
1150fn camel_to_kebab(s: &str) -> String {
1151    let mut out = String::with_capacity(s.len() + 2);
1152    for (i, c) in s.chars().enumerate() {
1153        if c.is_uppercase() && i > 0 {
1154            out.push('-');
1155            out.extend(c.to_lowercase());
1156        } else {
1157            out.push(c);
1158        }
1159    }
1160    out
1161}
1162
1163// ── TypedRewriter — the self-optimization primitive ────────────────
1164//
1165// Takes a typed value, converts to Sexp, applies a Lisp rewrite, then
1166// re-enters the typed boundary via `compile_from_args`. Any rewrite that
1167// passes the typed re-validation is safe by construction — the Rust type
1168// system is the floor.
1169
1170/// Rewrite a typed `T` through Lisp form and re-validate on the way back.
1171///
1172/// The rewriter receives the value's kwargs representation (a `Sexp::List`
1173/// of alternating keywords + values) and returns a modified kwargs list.
1174/// `T::compile_from_args` validates the result — any ill-formed rewrite
1175/// produces a typed error; any well-formed rewrite produces a valid `T`.
1176pub fn rewrite_typed<T, F>(input: T, rewrite: F) -> Result<T>
1177where
1178    T: TataraDomain + serde::Serialize,
1179    F: FnOnce(Sexp) -> Result<Sexp>,
1180{
1181    let json = serde_json::to_value(&input).map_err(|e| LispError::Compile {
1182        form: T::KEYWORD.to_string(),
1183        message: format!("serialize {}: {e}", T::KEYWORD),
1184    })?;
1185    let sexp = json_to_sexp(&json);
1186    let rewritten = rewrite(sexp)?;
1187    let args = match rewritten {
1188        Sexp::List(items) => items,
1189        other => {
1190            return Err(LispError::Compile {
1191                form: T::KEYWORD.to_string(),
1192                message: format!("rewriter must return a list; got {other}"),
1193            })
1194        }
1195    };
1196    T::compile_from_args(&args)
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201    use super::*;
1202    use crate::reader::read;
1203    use serde::Serialize;
1204    use tatara_lisp_derive::TataraDomain as DeriveTataraDomain;
1205
1206    /// Example domain authorable as Lisp — proves derive macro, trait, and
1207    /// registry all agree end-to-end.
1208    #[derive(DeriveTataraDomain, Serialize, Debug, PartialEq)]
1209    #[tatara(keyword = "defmonitor")]
1210    struct MonitorSpec {
1211        name: String,
1212        query: String,
1213        threshold: f64,
1214        window_seconds: Option<i64>,
1215        tags: Vec<String>,
1216        enabled: Option<bool>,
1217    }
1218
1219    #[test]
1220    fn derive_emits_correct_keyword() {
1221        assert_eq!(MonitorSpec::KEYWORD, "defmonitor");
1222    }
1223
1224    #[test]
1225    fn derive_compiles_full_form() {
1226        let forms = read(
1227            r#"(defmonitor
1228                 :name "prom-up"
1229                 :query "up{job='prometheus'}"
1230                 :threshold 0.99
1231                 :window-seconds 300
1232                 :tags ("prod" "observability")
1233                 :enabled #t)"#,
1234        )
1235        .unwrap();
1236        let spec = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
1237        assert_eq!(
1238            spec,
1239            MonitorSpec {
1240                name: "prom-up".into(),
1241                query: "up{job='prometheus'}".into(),
1242                threshold: 0.99,
1243                window_seconds: Some(300),
1244                tags: vec!["prod".into(), "observability".into()],
1245                enabled: Some(true),
1246            }
1247        );
1248    }
1249
1250    #[test]
1251    fn derive_accepts_missing_optionals() {
1252        let forms = read(r#"(defmonitor :name "x" :query "q" :threshold 0.5)"#).unwrap();
1253        let spec = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
1254        assert_eq!(spec.name, "x");
1255        assert!(spec.window_seconds.is_none());
1256        assert!(spec.enabled.is_none());
1257        assert!(spec.tags.is_empty());
1258    }
1259
1260    #[test]
1261    fn derive_errors_on_missing_required() {
1262        let forms = read(r#"(defmonitor :name "x" :query "q")"#).unwrap();
1263        assert!(MonitorSpec::compile_from_sexp(&forms[0]).is_err());
1264    }
1265
1266    #[test]
1267    fn derive_errors_on_wrong_head() {
1268        let forms = read(r#"(not-a-monitor :name "x")"#).unwrap();
1269        let err = MonitorSpec::compile_from_sexp(&forms[0]).unwrap_err();
1270        assert!(format!("{err}").contains("expected (defmonitor"));
1271    }
1272
1273    #[test]
1274    fn registry_dispatches_by_keyword() {
1275        register::<MonitorSpec>();
1276        assert!(registered_keywords().contains(&"defmonitor"));
1277        let handler = lookup("defmonitor").expect("registered");
1278        assert_eq!(handler.keyword, "defmonitor");
1279        let forms = read(r#"(ignored :name "prom" :query "q" :threshold 0.5)"#).unwrap();
1280        let args = forms[0].as_list().unwrap();
1281        let json = (handler.compile)(&args[1..]).unwrap();
1282        assert_eq!(json["name"], "prom");
1283        assert_eq!(json["query"], "q");
1284        assert_eq!(json["threshold"], 0.5);
1285    }
1286}