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