Skip to main content

tatara_process/
export.rs

1//! `ExportSpec` — what an ephemeral Process is allowed to leave behind.
2//!
3//! The compounding move: ephemeral envs default to **leaving nothing
4//! behind**. Anything that must survive teardown is named explicitly via
5//! one or more `ExportSpec`s on `:lifetime (:ephemeral … :exports …)`.
6//! Each export declares:
7//!
8//!   * what artifact to ship out (a [`ArtifactSource`] variant)
9//!   * where to ship it through (a [`VectorChannel`] variant — Vector
10//!     ingest endpoint, NATS JetStream subject, or stdout)
11//!   * when to ship it (an [`ExportTrigger`] variant)
12//!
13//! The pleme-io convention is that **everything emitted from a
14//! workload flows through the Vector + NATS layer**, never to ad-hoc
15//! sinks. `VectorChannel` enforces that at the type level — there is
16//! no `S3Bucket` or `RawFileSystem` variant. Vector's downstream
17//! sink graph (file, VictoriaLogs, VictoriaMetrics, Loki, …) handles
18//! durability + analytics; this primitive only names the *ingestion*
19//! shape.
20//!
21//! The reconciler's `Releasing` phase (between `Attested`/`Failed` and
22//! `Exiting`) reads `lifetime.ephemeral.exports`, filters by
23//! [`ExportTrigger`] against the terminal phase, and emits one
24//! tatara-export-worker Job per surviving spec. Each Job emits a
25//! receipt of its own export action so the export itself participates
26//! in the BLAKE3 attestation chain.
27//!
28//! Lisp authoring:
29//! ```lisp
30//! (defephemeral closed-loop-attest
31//!   :aplicacao  (…)
32//!   :ttl        "1h"
33//!   :teardown   OnAttested
34//!   :exports
35//!     (;; Receipts — tier-1 guaranteed delivery via NATS JetStream
36//!      (:source  (:receipts)
37//!       :channel (:nats-subject :subject "pleme.pleme-dev.ephemeral.{{run_id}}.receipt"
38//!                               :stream  "EPHEMERAL_RECEIPTS")
39//!       :when    OnAttested)
40//!      ;; Test report — best-effort via Vector HTTP ingest
41//!      (:source  (:test-report :configmap "demo-test-results"
42//!                              :key       "junit.xml"
43//!                              :format    Junit)
44//!       :channel (:http-event :signal-type "test-report")
45//!       :when    Always)
46//!      ;; Run marker — small synthetic event for shinryu cohort math
47//!      (:source  (:run-marker :labels (:run-id "{{run_id}}"
48//!                                       :phase "end"))
49//!       :channel (:http-event :signal-type "ephemeral-marker")
50//!       :when    Always)))
51//! ```
52
53use schemars::JsonSchema;
54use serde::{Deserialize, Serialize};
55use std::collections::BTreeMap;
56use std::fmt;
57
58use crate::phase::ProcessPhase;
59
60// ─── ExportSpec ────────────────────────────────────────────────────
61
62/// One declared export from an ephemeral Process.
63///
64/// Multiple `ExportSpec`s can be attached to a single ephemeral
65/// lifetime — each fires independently during the `Releasing` phase
66/// when its [`ExportTrigger`] matches the terminal phase reached.
67#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
68#[serde(rename_all = "camelCase")]
69pub struct ExportSpec {
70    /// What artifact to ship.
71    pub source: ArtifactSource,
72
73    /// Where to ship it. Always Vector-native — pleme-io routes every
74    /// emission through one of the four canonical channels.
75    pub channel: VectorChannel,
76
77    /// When to ship. Defaults to `OnAttested`.
78    #[serde(default)]
79    pub when: ExportTrigger,
80
81    /// Override the run-id label that templates into channel subjects
82    /// / signal-type metadata. Defaults to the Process's PID-derived
83    /// run id when unset.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub experiment_id_override: Option<String>,
86}
87
88// ─── ArtifactSource ────────────────────────────────────────────────
89
90/// What artifact this export ships out.
91///
92/// Exactly-one-Option pattern, matching the rest of the typescape
93/// (`Intent`, `Lifetime`). Adding a new artifact kind is additive on
94/// the wire — existing JSON keeps deserializing unchanged.
95#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
96#[serde(rename_all = "camelCase")]
97pub struct ArtifactSource {
98    /// Every `ReceiptEnvelope` emitted by this Process during its
99    /// lifetime — the BLAKE3-chained typed attestation stream.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub receipts: Option<ReceiptsSource>,
102
103    /// A test report stored in a ConfigMap by an in-cluster test
104    /// runner (Job, test harness, closed-loop probe). Worker reads the
105    /// ConfigMap, packages it per `format`, and forwards.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub test_report: Option<TestReportSource>,
108
109    /// The Process's own `ProcessSpec` + `ProcessStatus` snapshot at
110    /// teardown time, as canonical JSON. Useful for post-mortems on
111    /// failed ephemeral runs.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub process_snapshot: Option<ProcessSnapshotSource>,
114
115    /// A small synthetic event — start/end of run markers, cohort
116    /// tags, experiment correlation. Worker emits a single
117    /// timestamped event with the declared `labels`.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub run_marker: Option<RunMarkerSource>,
120}
121
122/// Resolved enum view used by the worker.
123#[derive(Clone, Debug)]
124pub enum ArtifactVariant<'a> {
125    Receipts(&'a ReceiptsSource),
126    TestReport(&'a TestReportSource),
127    ProcessSnapshot(&'a ProcessSnapshotSource),
128    RunMarker(&'a RunMarkerSource),
129}
130
131impl ArtifactVariant<'_> {
132    /// Reverse projection — every borrowed variant knows its
133    /// `ArtifactKind` discriminator. Pairs with `ArtifactKind::select`
134    /// so `ArtifactKind::select(source).map(|v| v.kind())` round-trips
135    /// the closed set on the populated side; pinned by
136    /// `artifact_kind_round_trips_through_variant_kind`. Future
137    /// kind-keyed consumers (metric labels like
138    /// `tatara_exports_total{artifact="receipts"}`, status-condition
139    /// reason strings, audit-trail classifiers, LSP completion) reach
140    /// through this projection instead of pattern-matching the
141    /// payload-carrying view.
142    pub fn kind(&self) -> ArtifactKind {
143        match self {
144            Self::Receipts(_) => ArtifactKind::Receipts,
145            Self::TestReport(_) => ArtifactKind::TestReport,
146            Self::ProcessSnapshot(_) => ArtifactKind::ProcessSnapshot,
147            Self::RunMarker(_) => ArtifactKind::RunMarker,
148        }
149    }
150}
151
152impl crate::tagged_union::VariantKind<ArtifactKind> for ArtifactVariant<'_> {
153    fn variant_kind(&self) -> ArtifactKind {
154        self.kind()
155    }
156}
157
158/// Closed-set discriminator over `ArtifactSource`'s four tagged-union
159/// slots. Single source of truth that drives `ArtifactSource::variant`'s
160/// ambiguity + emptiness resolver, the `ArtifactError::Empty` message,
161/// and the reverse `ArtifactVariant::kind` projection. Adding a fifth
162/// artifact variant lands at one `ALL` entry + one `as_str` arm + one
163/// `select` arm + one `ArtifactVariant::kind` arm — exhaustively
164/// checked by the compiler.
165///
166/// Sibling closed-set lifts on the same `ExportSpec` axis:
167/// [`crate::intent::IntentKind::ALL`], [`crate::lifetime::LifetimeKind::ALL`],
168/// [`ExportTrigger::ALL`], [`ReportFormat::ALL`],
169/// [`crate::lifetime::TeardownPolicy::ALL`].
170#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
171#[closed_set(via = "as_str", display, generate_unknown)]
172pub enum ArtifactKind {
173    Receipts,
174    TestReport,
175    ProcessSnapshot,
176    RunMarker,
177}
178
179impl ArtifactKind {
180    /// The closed set of artifact kinds — single source of truth that
181    /// drives `ArtifactSource::variant`'s sweep so a variant added
182    /// without an `ALL` entry never reaches the resolver. The `[Self; 4]`
183    /// array literal forces the arity at compile time.
184    pub const ALL: [Self; 4] = [
185        Self::Receipts,
186        Self::TestReport,
187        Self::ProcessSnapshot,
188        Self::RunMarker,
189    ];
190
191    /// Canonical camelCase wire-format key — matches the serde
192    /// `rename_all = "camelCase"` field name on `ArtifactSource`. The
193    /// `ArtifactError::Empty` message composes the human-readable list
194    /// from this projection so a new variant lands in the
195    /// operator-facing diagnostic automatically via the `ALL` sweep,
196    /// not via hand-maintained error-string drift. Pinned by
197    /// `artifact_kind_as_str_matches_field_name`.
198    pub const fn as_str(self) -> &'static str {
199        match self {
200            Self::Receipts => "receipts",
201            Self::TestReport => "testReport",
202            Self::ProcessSnapshot => "processSnapshot",
203            Self::RunMarker => "runMarker",
204        }
205    }
206
207    /// Project an `ArtifactSource` borrow into the optional typed variant
208    /// view for this kind. Returns `None` iff the matching slot is
209    /// `None`. Composes the closed-set sweep `ArtifactSource::variant`
210    /// loops over. Mirrors [`crate::intent::IntentKind::select`].
211    pub fn select<'a>(self, source: &'a ArtifactSource) -> Option<ArtifactVariant<'a>> {
212        match self {
213            Self::Receipts => source.receipts.as_ref().map(ArtifactVariant::Receipts),
214            Self::TestReport => source.test_report.as_ref().map(ArtifactVariant::TestReport),
215            Self::ProcessSnapshot => source
216                .process_snapshot
217                .as_ref()
218                .map(ArtifactVariant::ProcessSnapshot),
219            Self::RunMarker => source.run_marker.as_ref().map(ArtifactVariant::RunMarker),
220        }
221    }
222}
223
224// `impl fmt::Display for ArtifactKind` + `impl FromStr for
225// ArtifactKind` + `impl tatara_lisp::ClosedSet for ArtifactKind` +
226// `pub struct UnknownArtifactKind(pub String)` are generated by
227// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
228// "as_str", display, generate_unknown)]` on the enum declaration above.
229// The auto-derived label `"artifact kind"` matches the prior hand-
230// rolled `#[error("unknown artifact kind: {0}")]` verbatim. The
231// inherent `as_str` projection stays load-bearing — the camelCase
232// wire-format that matches the serde rename + the `ArtifactSource`
233// struct-field name + the `ArtifactError::Empty` diagnostic verbatim
234// — while the trait method `label` gives generic consumers a STABLE
235// name across the workspace-wide closed-set implementors.
236
237crate::declare_tagged_union_error! {
238    pub ArtifactError,
239    empty = "artifact source has no variant set (one of {0} required)",
240    ambiguous = "artifact source has multiple variants set; exactly one required",
241}
242
243/// Slash-joined list of every `ArtifactKind::as_str()` — composed once
244/// at compile time so `ArtifactError::Empty`'s diagnostic carries the
245/// closed-set summary without per-variant string drift. Mirrors
246/// [`crate::intent::INTENT_KIND_LIST`] in shape.
247pub(crate) const ARTIFACT_KIND_LIST: &str = "receipts/testReport/processSnapshot/runMarker";
248
249crate::declare_tagged_union_impls! {
250    parent = ArtifactSource,
251    kind = ArtifactKind,
252    variant = ArtifactVariant,
253    error = ArtifactError,
254    kind_list = ARTIFACT_KIND_LIST,
255}
256
257/// Receipts source — no fields. The worker reads every
258/// `ReceiptEnvelope` annotated with this Process's PID.
259#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
260#[serde(rename_all = "camelCase")]
261pub struct ReceiptsSource {}
262
263/// Test report source — a ConfigMap key with optional format hint.
264#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
265#[serde(rename_all = "camelCase")]
266pub struct TestReportSource {
267    /// ConfigMap name (in the Process's namespace) the runner wrote to.
268    pub configmap: String,
269    /// Key inside the ConfigMap holding the report bytes.
270    pub key: String,
271    /// Report shape hint — downstream parsers in shinryu key off this.
272    #[serde(default)]
273    pub format: ReportFormat,
274    /// Optional ConfigMap namespace override.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub namespace: Option<String>,
277}
278
279/// Process snapshot source — bundles spec + status as JSON.
280#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
281#[serde(rename_all = "camelCase")]
282pub struct ProcessSnapshotSource {
283    /// When true, also bundle Process attestation history.
284    #[serde(default)]
285    pub include_attestation_chain: bool,
286}
287
288/// Run marker source — small synthetic event with labels.
289#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
290#[serde(rename_all = "camelCase")]
291pub struct RunMarkerSource {
292    /// Labels emitted on the marker event. Free-form key/value;
293    /// downstream consumers (shinryu cohort math, vector transforms)
294    /// read by key name.
295    #[serde(default)]
296    pub labels: BTreeMap<String, String>,
297}
298
299/// Bytes-shape hint for `TestReportSource`. Tatara emits the bytes
300/// untransformed and tags the Vector event with this so shinryu
301/// can route to the right parser tier.
302#[derive(
303    Clone,
304    Copy,
305    Debug,
306    PartialEq,
307    Eq,
308    Hash,
309    Serialize,
310    Deserialize,
311    JsonSchema,
312    Default,
313    tatara_closed_set::DeriveClosedSet,
314)]
315#[serde(rename_all = "PascalCase")]
316#[closed_set(via = "as_str", generate_unknown)]
317pub enum ReportFormat {
318    /// xUnit / JUnit XML — the closed-loop probe + test harnesses emit this.
319    Junit,
320    /// TAP v13 — Bash/Bats test suites.
321    TapV13,
322    /// Newline-delimited JSON — one event per line, native shinryu shape.
323    NdJson,
324    /// Opaque bytes — no parser hint, downstream stores as-is.
325    #[default]
326    Raw,
327}
328
329/// How the export worker should embed a `TestReportSource`'s bytes into
330/// the shipped `ExportEvent.payload`. Lifted as a closed-set typed
331/// projection from [`ReportFormat::payload_shape`] so the worker's
332/// dispatch is exhaustive on `ReportPayloadShape`, not on `ReportFormat`
333/// with a silent `_` arm. Adding a future `ReportFormat` variant forces
334/// the author to pick its shape here (single edit site); adding a
335/// future shape (e.g. compressed) forces every consumer to handle it.
336///
337/// The (shape, JSON-embed-field) pairing — `NdJsonLines` → `"ndjson"`,
338/// `OpaqueBytes` → `"raw_b64"` — binds at ONE typed projection
339/// ([`Self::payload_field`]) rather than at the future worker's
340/// embed-site string literals; pre-lift the field names lived in this
341/// enum's per-variant docstring prose AND would have lived at the
342/// worker's `payload.insert("ndjson", …)` / `payload.insert("raw_b64",
343/// …)` call sites, where a rename of `"raw_b64"` → `"raw"` at one site
344/// drifts silently from the docstring and the operator-facing shinryu
345/// schema.
346///
347/// Sibling typed-projection lift over a closed enum (rather than
348/// `matches!` / `_` arm dispatch):
349/// [`crate::lifetime::TeardownPolicy::should_teardown_on`],
350/// [`ExportTrigger::fires_on`], [`crate::phase::ProcessPhase::as_str`].
351///
352/// Sibling closed-set [`Self::ALL`] lift in lockstep with every other
353/// `ALL`-keyed enum on the same `ExportSpec` axis ([`ReportFormat::ALL`],
354/// [`ExportTrigger::ALL`]) and across the crate
355/// ([`crate::phase::ProcessPhase::ALL`],
356/// [`crate::signal::ProcessSignal::ALL`],
357/// [`crate::boundary::ConditionKind::ALL`],
358/// [`crate::lifetime::TeardownPolicy::ALL`]).
359#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
360#[closed_set(via = "as_str", display, generate_unknown = "report payload shape")]
361pub enum ReportPayloadShape {
362    /// Newline-delimited JSON — split on `\n`, parse each non-empty
363    /// line as JSON, embed under `payload.<payload_field>` (=
364    /// `payload.ndjson`) as an array. Field name is the typed
365    /// projection [`Self::payload_field`] so no embed-site literal
366    /// can drift from this docstring.
367    NdJsonLines,
368    /// Opaque bytes — base64-encode the artifact verbatim, embed
369    /// under `payload.<payload_field>` (= `payload.raw_b64`) as a
370    /// string. The default shape for anything Vector / shinryu
371    /// shouldn't pre-parse. Field name is the typed projection
372    /// [`Self::payload_field`].
373    OpaqueBytes,
374}
375
376impl ReportPayloadShape {
377    /// The closed set of payload shapes — single source of truth
378    /// that drives the [`Self::as_str`] / [`fmt::Display`] pair and
379    /// the [`Self::payload_field`] projection. Adding a third shape
380    /// (e.g. `Compressed` → `"gzip"`) lands at one `ALL` entry + one
381    /// `as_str` arm + one `payload_field` arm + at least one new
382    /// `ReportFormat::payload_shape` mapping — exhaustively checked
383    /// by the compiler (the `[Self; 2]` array literal forces the
384    /// arity).
385    ///
386    /// Sibling closed-set lifts on the same `ExportSpec` axis:
387    /// [`ReportFormat::ALL`], [`ExportTrigger::ALL`],
388    /// [`crate::lifetime::TeardownPolicy::ALL`],
389    /// [`crate::boundary::ConditionKind::ALL`],
390    /// [`crate::phase::ProcessPhase::ALL`],
391    /// [`crate::signal::ProcessSignal::ALL`],
392    /// [`crate::encapsulates::EncapsulationMode::ALL`].
393    pub const ALL: [Self; 2] = [Self::NdJsonLines, Self::OpaqueBytes];
394
395    /// Canonical PascalCase identifier — used by [`fmt::Display`] (so
396    /// `format!("{shape}")` never reaches for `{:?}` Debug) and as
397    /// the operator-facing reason-string projection. No serde wire
398    /// shape today (the enum is worker-internal), but the identifier
399    /// matches the sibling-aligned `as_str` shape that every other
400    /// closed-set enum in this crate exposes
401    /// ([`ReportFormat::as_str`], [`ExportTrigger::as_str`],
402    /// [`crate::phase::ProcessPhase::as_str`]). Pinned by
403    /// `report_payload_shape_as_str_unique_per_variant`.
404    pub const fn as_str(self) -> &'static str {
405        match self {
406            Self::NdJsonLines => "NdJsonLines",
407            Self::OpaqueBytes => "OpaqueBytes",
408        }
409    }
410
411    /// The JSON field name within `ExportEvent.payload` where the
412    /// worker embeds this shape's encoded value — `"ndjson"` for the
413    /// newline-split array, `"raw_b64"` for the base64-encoded
414    /// opaque string. Pre-lift these names lived ONLY in the
415    /// per-variant docstring prose; once the export worker lands, a
416    /// rename at the embed site (`payload.insert("ndjson", …)` →
417    /// `payload.insert("events", …)`) silently drifts from the
418    /// docstring and from the operator-facing shinryu schema with no
419    /// compile or runtime signal. Post-lift the worker's embed site
420    /// is `payload.insert(shape.payload_field().into(), …)` and a
421    /// rename lands at ONE arm here. The per-variant uniqueness
422    /// invariant (no two shapes alias to the same field name) is
423    /// pinned by `report_payload_shape_payload_field_unique_per_
424    /// variant` so the worker's embed site cannot have two shapes
425    /// collide on the same destination key. Truth table pinned by
426    /// `report_payload_shape_payload_field_truth_table` so a future
427    /// rename lands here at one site, not in the worker.
428    pub const fn payload_field(self) -> &'static str {
429        match self {
430            Self::NdJsonLines => "ndjson",
431            Self::OpaqueBytes => "raw_b64",
432        }
433    }
434}
435
436// `impl fmt::Display for ReportPayloadShape` +
437// `impl std::str::FromStr for ReportPayloadShape` +
438// `impl tatara_lisp::ClosedSet for ReportPayloadShape` +
439// `pub struct UnknownReportPayloadShape(pub String)` are all generated
440// by `#[derive(tatara_closed_set::DeriveClosedSet)]` +
441// `#[closed_set(via = "as_str", display, generate_unknown = "report
442// payload shape")]` on the enum declaration above. The explicit label
443// preserves the natural spelling "report payload shape" against the
444// auto-projection `pascal_to_spaced_lowercase("ReportPayloadShape")`.
445// The inherent `as_str` projection stays load-bearing — the canonical
446// `"NdJsonLines" | "OpaqueBytes"` string every worker-facing reason
447// projection reads; `via = "as_str"` binds `ClosedSet::label` to the
448// same projection so the substrate-wide
449// `assert_display_matches_label` / `assert_closed_set_well_formed`
450// primitives dispatch through the same byte-identical shape every other
451// closed-set implementor across the crate publishes. Aligns
452// `ReportPayloadShape` with the substrate-wide
453// `#[derive(DeriveClosedSet)]` idiom that every sibling closed-set enum
454// on this `ExportSpec` axis (`ReportFormat`, `ArtifactKind`,
455// `ChannelKind`, `ExportTrigger`) already carries — the last hand-rolled
456// `impl fmt::Display` on the axis is closed at ONE substrate site.
457
458impl ReportFormat {
459    /// The closed set of report formats — single source of truth that
460    /// drives the `as_str` / Display / `FromStr` triad and the typed
461    /// `payload_shape` dispatch. Adding a fifth variant lands at one
462    /// `ALL` entry + one `as_str` arm + one `payload_shape` arm —
463    /// exhaustively checked by the compiler (the `[Self; 4]` array
464    /// literal forces the arity).
465    ///
466    /// Sibling closed-set lifts on the same `ExportSpec` axis:
467    /// [`ExportTrigger::ALL`], [`crate::lifetime::TeardownPolicy::ALL`],
468    /// [`crate::boundary::ConditionKind::ALL`],
469    /// [`crate::phase::ProcessPhase::ALL`],
470    /// [`crate::signal::ProcessSignal::ALL`],
471    /// [`crate::encapsulates::EncapsulationMode::ALL`].
472    pub const ALL: [Self; 4] = [Self::Junit, Self::TapV13, Self::NdJson, Self::Raw];
473
474    /// Canonical PascalCase wire-format projection — matches the serde
475    /// `rename_all = "PascalCase"` output verbatim. Used by Display
476    /// (single source of truth) and by `FromStr`'s sweep of `ALL` so
477    /// the typed surface and the YAML wire format cannot drift. Pinned
478    /// by `report_format_as_str_matches_serde`.
479    pub const fn as_str(self) -> &'static str {
480        match self {
481            Self::Junit => "Junit",
482            Self::TapV13 => "TapV13",
483            Self::NdJson => "NdJson",
484            Self::Raw => "Raw",
485        }
486    }
487
488    /// Typed projection: which payload-embedding strategy the export
489    /// worker should pick for this format. ONE typed dispatch that
490    /// replaces the worker's `match tr.format { NdJson => …, _ => … }`
491    /// silent-default arm. Adding a new `ReportFormat` variant forces
492    /// the author to decide its shape here (the compiler exhaustively
493    /// checks this match); the worker's dispatch on the returned
494    /// `ReportPayloadShape` then remains a closed 2-arm match. Pinned
495    /// by `report_format_payload_shape_truth_table`.
496    pub const fn payload_shape(self) -> ReportPayloadShape {
497        match self {
498            Self::NdJson => ReportPayloadShape::NdJsonLines,
499            Self::Junit | Self::TapV13 | Self::Raw => ReportPayloadShape::OpaqueBytes,
500        }
501    }
502}
503
504impl fmt::Display for ReportFormat {
505    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
506        f.write_str(self.as_str())
507    }
508}
509
510// `impl FromStr for ReportFormat` + `impl tatara_lisp::ClosedSet for
511// ReportFormat` are generated by `#[derive(tatara_closed_set::DeriveClosedSet)]`
512// on the enum declaration above — the trait-impl plumbing collapses onto
513// ONE derive line + the `#[closed_set(via = "as_str")]` attribute that
514// names the inherent projection method. Per-implementor content stays
515// at `pub const ALL` + `pub const fn as_str` + the `UnknownReportFormat`
516// carrier below.
517
518// `pub struct UnknownReportFormat(pub String)` is generated by
519// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
520// on the enum declaration above. The auto-derived label `"report format"`
521// matches the prior hand-rolled `#[error("unknown report format: {0}")]`
522// verbatim. Symmetric to [`UnknownChannelKind`],
523// [`UnknownExportTrigger`], [`crate::lifetime::UnknownTeardownPolicy`],
524// [`crate::boundary::UnknownConditionKind`],
525// [`crate::phase::UnknownPhase`].
526
527// ─── VectorChannel ─────────────────────────────────────────────────
528
529/// Where the export bytes flow.
530///
531/// All variants land in the pleme-io Vector + NATS layer — there is
532/// no escape hatch for ad-hoc sinks. Vector's downstream sink graph
533/// (file / Loki / VictoriaLogs / VictoriaMetrics) handles durability
534/// + analytics. This primitive only names the *ingestion* shape.
535#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
536#[serde(rename_all = "camelCase")]
537pub struct VectorChannel {
538    /// HTTP POST to Vector's `http_server` source.
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub http_event: Option<HttpEventChannel>,
541
542    /// Publish to a NATS JetStream subject. Use for tier-1
543    /// guaranteed-delivery events (receipts that MUST survive).
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub nats_subject: Option<NatsSubjectChannel>,
546
547    /// Print to the export worker's stdout. Vector's
548    /// `kubernetes_logs` source picks it up. Lowest-effort channel;
549    /// fine for debug / one-off exports.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub stdout: Option<StdoutChannel>,
552}
553
554#[derive(Clone, Debug)]
555pub enum ChannelVariant<'a> {
556    HttpEvent(&'a HttpEventChannel),
557    NatsSubject(&'a NatsSubjectChannel),
558    Stdout(&'a StdoutChannel),
559}
560
561impl ChannelVariant<'_> {
562    /// Reverse projection — every borrowed channel variant knows its
563    /// `ChannelKind` discriminator. Pairs with [`ChannelKind::select`]
564    /// so `ChannelKind::select(channel).map(|v| v.kind())` round-trips
565    /// the closed set on the populated side; pinned by
566    /// `channel_kind_round_trips_through_variant_kind`. Future
567    /// kind-keyed consumers (metric labels like
568    /// `tatara_exports_total{channel="natsSubject"}`, status-condition
569    /// reason strings, audit-trail classifiers, LSP completion) reach
570    /// through this projection instead of pattern-matching the
571    /// payload-carrying view. Mirrors
572    /// [`ArtifactVariant::kind`] and [`crate::intent::IntentVariant::kind`].
573    pub fn kind(&self) -> ChannelKind {
574        match self {
575            Self::HttpEvent(_) => ChannelKind::HttpEvent,
576            Self::NatsSubject(_) => ChannelKind::NatsSubject,
577            Self::Stdout(_) => ChannelKind::Stdout,
578        }
579    }
580}
581
582impl crate::tagged_union::VariantKind<ChannelKind> for ChannelVariant<'_> {
583    fn variant_kind(&self) -> ChannelKind {
584        self.kind()
585    }
586}
587
588/// Closed-set discriminator over `VectorChannel`'s three tagged-union
589/// slots. Single source of truth that drives `VectorChannel::variant`'s
590/// ambiguity + emptiness resolver, the `ChannelError::Empty` message,
591/// and the reverse `ChannelVariant::kind` projection. Adding a fourth
592/// channel variant lands at one `ALL` entry + one `as_str` arm + one
593/// `select` arm + one `ChannelVariant::kind` arm — exhaustively
594/// checked by the compiler.
595///
596/// Sibling closed-set lifts on the same `ExportSpec` axis:
597/// [`ArtifactKind::ALL`], [`ExportTrigger::ALL`], [`ReportFormat::ALL`],
598/// [`ReportPayloadShape::ALL`].
599#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
600#[closed_set(via = "as_str", generate_unknown)]
601pub enum ChannelKind {
602    HttpEvent,
603    NatsSubject,
604    Stdout,
605}
606
607impl ChannelKind {
608    /// The closed set of channel kinds — single source of truth that
609    /// drives `VectorChannel::variant`'s sweep so a variant added
610    /// without an `ALL` entry never reaches the resolver. The
611    /// `[Self; 3]` array literal forces the arity at compile time.
612    pub const ALL: [Self; 3] = [Self::HttpEvent, Self::NatsSubject, Self::Stdout];
613
614    /// Canonical camelCase wire-format key — matches the serde
615    /// `rename_all = "camelCase"` field name on `VectorChannel`. The
616    /// `ChannelError::Empty` message composes the human-readable list
617    /// from this projection so a new variant lands in the
618    /// operator-facing diagnostic automatically via the `ALL` sweep,
619    /// not via hand-maintained error-string drift. Pinned by
620    /// `channel_kind_as_str_matches_field_name`.
621    pub const fn as_str(self) -> &'static str {
622        match self {
623            Self::HttpEvent => "httpEvent",
624            Self::NatsSubject => "natsSubject",
625            Self::Stdout => "stdout",
626        }
627    }
628
629    /// Project a `VectorChannel` borrow into the optional typed variant
630    /// view for this kind. Returns `None` iff the matching slot is
631    /// `None`. Composes the closed-set sweep `VectorChannel::variant`
632    /// loops over. Mirrors [`ArtifactKind::select`] +
633    /// [`crate::intent::IntentKind::select`] +
634    /// [`crate::lifetime::LifetimeKind::select`].
635    pub fn select<'a>(self, channel: &'a VectorChannel) -> Option<ChannelVariant<'a>> {
636        match self {
637            Self::HttpEvent => channel.http_event.as_ref().map(ChannelVariant::HttpEvent),
638            Self::NatsSubject => channel
639                .nats_subject
640                .as_ref()
641                .map(ChannelVariant::NatsSubject),
642            Self::Stdout => channel.stdout.as_ref().map(ChannelVariant::Stdout),
643        }
644    }
645}
646
647impl fmt::Display for ChannelKind {
648    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
649        f.write_str(self.as_str())
650    }
651}
652
653// `impl FromStr for ChannelKind` + `impl tatara_lisp::ClosedSet for
654// ChannelKind` are generated by `#[derive(tatara_closed_set::DeriveClosedSet)]`
655// on the enum declaration above.
656
657// `pub struct UnknownChannelKind(pub String)` is generated by
658// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
659// on the enum declaration above. The auto-derived label `"channel kind"`
660// matches the prior hand-rolled `#[error("unknown channel kind: {0}")]`
661// verbatim. Symmetric to [`UnknownArtifactKind`],
662// [`UnknownReportFormat`], [`UnknownExportTrigger`],
663// [`crate::lifetime::UnknownTeardownPolicy`],
664// [`crate::boundary::UnknownConditionKind`],
665// [`crate::phase::UnknownPhase`].
666
667crate::declare_tagged_union_error! {
668    pub ChannelError,
669    empty = "vector channel has no variant set (one of {0} required)",
670    ambiguous = "vector channel has multiple variants set; exactly one required",
671}
672
673/// Slash-joined list of every `ChannelKind::as_str()` — composed once
674/// at compile time so `ChannelError::Empty`'s diagnostic carries the
675/// closed-set summary without per-variant string drift. Mirrors
676/// [`ARTIFACT_KIND_LIST`] in shape.
677pub(crate) const CHANNEL_KIND_LIST: &str = "httpEvent/natsSubject/stdout";
678
679crate::declare_tagged_union_impls! {
680    parent = VectorChannel,
681    kind = ChannelKind,
682    variant = ChannelVariant,
683    error = ChannelError,
684    kind_list = CHANNEL_KIND_LIST,
685}
686
687/// HTTP POST channel — Vector `http_server` source.
688#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
689#[serde(rename_all = "camelCase")]
690pub struct HttpEventChannel {
691    /// Vector ingest endpoint. Defaults to the in-cluster Service
692    /// `http://vector.observability.svc.cluster.local:8080` when
693    /// unset.
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    pub endpoint: Option<String>,
696
697    /// `signal_type` tag added to every emitted event. Vector
698    /// transforms + shinryu's analytical schema route by this tag
699    /// (`receipt`, `test-report`, `ephemeral-marker`, …).
700    pub signal_type: String,
701}
702
703/// Default Vector ingest endpoint when `HttpEventChannel.endpoint`
704/// is unset. Single source of truth for downstream tooling.
705pub const DEFAULT_VECTOR_INGEST: &str = "http://vector.observability.svc.cluster.local:8080";
706
707impl HttpEventChannel {
708    /// Resolve the endpoint URL, falling back to the in-cluster default.
709    pub fn resolved_endpoint(&self) -> &str {
710        self.endpoint.as_deref().unwrap_or(DEFAULT_VECTOR_INGEST)
711    }
712}
713
714/// NATS JetStream channel — guaranteed-delivery publish.
715#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
716#[serde(rename_all = "camelCase")]
717pub struct NatsSubjectChannel {
718    /// Subject to publish to. May contain `{{run_id}}` template
719    /// substitution — the worker substitutes the resolved run id at
720    /// publish time.
721    pub subject: String,
722
723    /// JetStream stream the subject belongs to. The stream itself is
724    /// declared by the consumer chart (e.g. tatara-pool-reconciler)
725    /// via the pleme-nats broker-only design.
726    pub stream: String,
727
728    /// Optional NATS URL. Defaults to `nats://nats.observability.svc.cluster.local:4222`.
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub url: Option<String>,
731}
732
733/// Default NATS URL when `NatsSubjectChannel.url` is unset.
734pub const DEFAULT_NATS_URL: &str = "nats://nats.observability.svc.cluster.local:4222";
735
736impl NatsSubjectChannel {
737    /// Resolve the NATS URL, falling back to the in-cluster default.
738    pub fn resolved_url(&self) -> &str {
739        self.url.as_deref().unwrap_or(DEFAULT_NATS_URL)
740    }
741}
742
743/// Stdout channel — worker prints the event; Vector picks up via
744/// `kubernetes_logs`.
745#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
746#[serde(rename_all = "camelCase")]
747pub struct StdoutChannel {
748    /// Pretty-print JSON (multi-line) instead of compact NDJSON.
749    /// Defaults to false — compact NDJSON matches Vector's parser.
750    #[serde(default)]
751    pub pretty: bool,
752}
753
754// ─── ExportTrigger ─────────────────────────────────────────────────
755
756/// When the export fires. Aligns with `ProcessPhase` so the
757/// reconciler's `Releasing` phase can match against the terminal
758/// phase reached directly.
759#[derive(
760    Clone,
761    Copy,
762    Debug,
763    PartialEq,
764    Eq,
765    Hash,
766    Serialize,
767    Deserialize,
768    JsonSchema,
769    Default,
770    tatara_closed_set::DeriveClosedSet,
771)]
772#[serde(rename_all = "PascalCase")]
773#[closed_set(via = "as_str", generate_unknown)]
774pub enum ExportTrigger {
775    /// Fire when the Process reaches `Attested`. Default — matches
776    /// the most common case (capture successful-run artifacts).
777    #[default]
778    OnAttested,
779    /// Fire when the Process reaches `Failed`. Use for failure
780    /// post-mortems (process snapshots, last receipts).
781    OnFailed,
782    /// Fire on every terminal phase (`Attested` or `Failed`). Use
783    /// for run markers that need to surface regardless of outcome.
784    Always,
785}
786
787impl ExportTrigger {
788    /// The closed set of export triggers — single source of truth that
789    /// drives the `as_str` / Display / `FromStr` triad and the typed
790    /// `fires_on` dispatch over `ProcessPhase`. Adding a fourth variant
791    /// lands at one `ALL` entry + one `as_str` arm + one `fires_on` arm
792    /// — exhaustively checked by the compiler (the `[Self; 3]` array
793    /// literal forces the arity).
794    ///
795    /// Sibling closed-set lifts on the same `ProcessSpec` axis:
796    /// [`crate::lifetime::TeardownPolicy::ALL`],
797    /// [`crate::intent::IntentKind::ALL`],
798    /// [`crate::lifetime::LifetimeKind::ALL`],
799    /// [`crate::boundary::ConditionKind::ALL`],
800    /// [`crate::phase::ProcessPhase::ALL`],
801    /// [`crate::signal::ProcessSignal::ALL`].
802    pub const ALL: [Self; 3] = [Self::OnAttested, Self::OnFailed, Self::Always];
803
804    /// Canonical PascalCase wire-format projection — matches the serde
805    /// `rename_all = "PascalCase"` output verbatim. Used by Display
806    /// (single source of truth), by `FromStr` to identify the variant
807    /// from its annotation / status-field representation, and by
808    /// operator-facing reason strings without reaching for `{:?}` Debug
809    /// formatting. Pinned by `export_trigger_as_str_matches_serde`.
810    pub const fn as_str(self) -> &'static str {
811        match self {
812            Self::OnAttested => "OnAttested",
813            Self::OnFailed => "OnFailed",
814            Self::Always => "Always",
815        }
816    }
817
818    /// True iff, given a `ProcessPhase`, this trigger says "fire."
819    /// ONE typed dispatch over the typed phase enum that replaces the
820    /// four hand-rolled `match phase { Attested => fires_on_attested(),
821    /// Failed => fires_on_failed(), _ => false }` sites the reconciler
822    /// and `EphemeralLifetime` previously branched on. Every
823    /// non-terminal phase always returns `false` — exports are a
824    /// terminal-phase decision, now enforced by the closed-set match
825    /// over `ProcessPhase`.
826    ///
827    /// The legacy [`Self::fires_on_attested`] / [`Self::fires_on_failed`]
828    /// predicates remain as thin delegates so existing call sites keep
829    /// their narrow signatures; the truth table is pinned by
830    /// `export_trigger_legacy_predicates_delegate_to_phase_dispatch`.
831    pub const fn fires_on(self, phase: ProcessPhase) -> bool {
832        match phase {
833            ProcessPhase::Attested => matches!(self, Self::OnAttested | Self::Always),
834            ProcessPhase::Failed => matches!(self, Self::OnFailed | Self::Always),
835            ProcessPhase::Pending
836            | ProcessPhase::Forking
837            | ProcessPhase::Execing
838            | ProcessPhase::Running
839            | ProcessPhase::Reconverging
840            | ProcessPhase::Releasing
841            | ProcessPhase::Exiting
842            | ProcessPhase::Zombie
843            | ProcessPhase::Reaped => false,
844        }
845    }
846
847    /// Thin delegate to [`Self::fires_on`] for the `Attested` case —
848    /// kept so existing call sites that already know the gate keep
849    /// their narrow signature without reaching for the typed-phase
850    /// variant.
851    pub const fn fires_on_attested(self) -> bool {
852        self.fires_on(ProcessPhase::Attested)
853    }
854
855    /// Symmetric delegate to [`Self::fires_on`] for the `Failed` case.
856    pub const fn fires_on_failed(self) -> bool {
857        self.fires_on(ProcessPhase::Failed)
858    }
859}
860
861impl fmt::Display for ExportTrigger {
862    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
863        f.write_str(self.as_str())
864    }
865}
866
867// `impl FromStr for ExportTrigger` + `impl tatara_lisp::ClosedSet for
868// ExportTrigger` are generated by `#[derive(tatara_closed_set::DeriveClosedSet)]`
869// on the enum declaration above.
870
871// `pub struct UnknownExportTrigger(pub String)` is generated by
872// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
873// on the enum declaration above. The auto-derived label `"export trigger"`
874// matches the prior hand-rolled `#[error("unknown export trigger: {0}")]`
875// verbatim. Symmetric to [`UnknownChannelKind`],
876// [`UnknownReportFormat`], [`crate::lifetime::UnknownTeardownPolicy`],
877// [`crate::boundary::UnknownConditionKind`], and
878// [`crate::phase::UnknownPhase`].
879
880// ─── Tests ─────────────────────────────────────────────────────────
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885
886    #[test]
887    fn artifact_source_empty_errors() {
888        let s = ArtifactSource::default();
889        match s.variant().unwrap_err() {
890            ArtifactError::Empty(list) => assert_eq!(list, ARTIFACT_KIND_LIST),
891            other => panic!("expected Empty, got {other:?}"),
892        }
893    }
894
895    #[test]
896    fn artifact_source_receipts_resolves() {
897        let s = ArtifactSource {
898            receipts: Some(ReceiptsSource::default()),
899            ..ArtifactSource::default()
900        };
901        assert!(matches!(s.variant().unwrap(), ArtifactVariant::Receipts(_)));
902    }
903
904    #[test]
905    fn artifact_source_two_variants_ambiguous() {
906        let s = ArtifactSource {
907            receipts: Some(ReceiptsSource::default()),
908            test_report: Some(TestReportSource {
909                configmap: "x".into(),
910                key: "y".into(),
911                format: ReportFormat::Junit,
912                namespace: None,
913            }),
914            ..ArtifactSource::default()
915        };
916        assert_eq!(s.variant().unwrap_err(), ArtifactError::Ambiguous);
917    }
918
919    #[test]
920    fn vector_channel_empty_errors() {
921        let c = VectorChannel::default();
922        match c.variant().unwrap_err() {
923            ChannelError::Empty(list) => assert_eq!(list, CHANNEL_KIND_LIST),
924            other => panic!("expected Empty, got {other:?}"),
925        }
926    }
927
928    #[test]
929    fn vector_channel_resolves_http_event() {
930        let c = VectorChannel {
931            http_event: Some(HttpEventChannel {
932                endpoint: None,
933                signal_type: "test-report".into(),
934            }),
935            ..VectorChannel::default()
936        };
937        match c.variant().unwrap() {
938            ChannelVariant::HttpEvent(h) => {
939                assert_eq!(h.signal_type, "test-report");
940                assert_eq!(h.resolved_endpoint(), DEFAULT_VECTOR_INGEST);
941            }
942            other => panic!("expected HttpEvent, got {other:?}"),
943        }
944    }
945
946    #[test]
947    fn vector_channel_resolves_nats_subject() {
948        let c = VectorChannel {
949            nats_subject: Some(NatsSubjectChannel {
950                subject: "pleme.pleme-dev.ephemeral.{{run_id}}.receipt".into(),
951                stream: "EPHEMERAL_RECEIPTS".into(),
952                url: None,
953            }),
954            ..VectorChannel::default()
955        };
956        match c.variant().unwrap() {
957            ChannelVariant::NatsSubject(n) => {
958                assert_eq!(n.stream, "EPHEMERAL_RECEIPTS");
959                assert_eq!(n.resolved_url(), DEFAULT_NATS_URL);
960            }
961            other => panic!("expected NatsSubject, got {other:?}"),
962        }
963    }
964
965    #[test]
966    fn export_trigger_fire_logic() {
967        assert!(ExportTrigger::OnAttested.fires_on_attested());
968        assert!(!ExportTrigger::OnAttested.fires_on_failed());
969        assert!(ExportTrigger::OnFailed.fires_on_failed());
970        assert!(!ExportTrigger::OnFailed.fires_on_attested());
971        assert!(ExportTrigger::Always.fires_on_attested());
972        assert!(ExportTrigger::Always.fires_on_failed());
973    }
974
975    // ── closed-set algebra for ExportTrigger (ALL × as_str × FromStr ×
976    //    fires_on(phase)) ─
977
978    /// `ALL` is the source of truth for the resolver / `FromStr` sweep
979    /// Structural well-formedness of [`ExportTrigger`] as a
980    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
981    /// testkit lift that pins all three structural invariants (`ALL`
982    /// is non-empty, every variant round-trips through
983    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
984    /// outside the closed set) at ONE call site. Replaces the hand-
985    /// derived `export_trigger_all_is_unique_and_complete` +
986    /// `export_trigger_roundtrip_via_as_str` + the empty-input arm of
987    /// `unknown_export_trigger_errors`. `FromStr` delegates to
988    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
989    /// exercises the same code path the reconciler hits when parsing
990    /// a CRD `enum:`-validated value back to the typed trigger.
991    #[test]
992    fn export_trigger_is_well_formed_closed_set() {
993        tatara_closed_set::assert_closed_set_well_formed::<ExportTrigger>();
994    }
995
996    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
997    /// output verbatim for every variant. A future variant rename
998    /// (or an `as_str` arm typo) lands here at one site, instead of
999    /// drifting between the typed surface and the YAML wire format
1000    /// the reconciler / operator both read.
1001    #[test]
1002    fn export_trigger_as_str_matches_serde() {
1003        crate::tagged_union::assert_label_matches_serde_serialization::<ExportTrigger>();
1004    }
1005
1006    /// The Display impl IS `as_str` — pinning this lets future callers
1007    /// reach for either projection without drift. If a reviewer
1008    /// accidentally re-introduces an inline match in Display, this
1009    /// test would fail the moment a variant rename touches one site
1010    /// but not the other.
1011    #[test]
1012    fn export_trigger_display_matches_as_str() {
1013        crate::tagged_union::assert_display_matches_label::<ExportTrigger>();
1014    }
1015
1016    /// `FromStr` rejects strings that aren't in the canonical
1017    /// projection — lowercased / typo / unrelated — and the error
1018    /// echoes the input verbatim so the operator-facing diagnostic
1019    /// carries the offending value, not a normalized form. The
1020    /// empty-input arm is pinned by
1021    /// [`export_trigger_is_well_formed_closed_set`] via the
1022    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1023    /// verbatim-echo contract on the [`UnknownExportTrigger`] newtype,
1024    /// which the trait's `make_unknown` can't see.
1025    #[test]
1026    fn unknown_export_trigger_errors() {
1027        use std::str::FromStr;
1028        for bad in ["onAttested", "ALWAYS", "Never", "OnSuccess"] {
1029            let err = ExportTrigger::from_str(bad).unwrap_err();
1030            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1031        }
1032    }
1033
1034    // `unknown_export_trigger_message_matches_substrate_convention`
1035    // removed — clause (5) of
1036    // `tatara_closed_set::assert_closed_set_well_formed::<ExportTrigger>()`
1037    // verifies the substrate-wide `"unknown {SET_LABEL}: {input}"`
1038    // shape generically (called from `trigger_is_well_formed_closed_set`
1039    // above); the `SET_LABEL` projection is pinned by
1040    // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests`.
1041
1042    /// TRUTH-TABLE CONTRACT: `fires_on(phase)` agrees with the
1043    /// documented (trigger, phase) -> bool table for every (3 × 11)
1044    /// combination. A new variant in either `ExportTrigger` or
1045    /// `ProcessPhase` reaches this test by iteration — adding a phase
1046    /// without extending `fires_on`'s match would be caught by the
1047    /// compiler (the closed-set match over `ProcessPhase` enforces it);
1048    /// adding a trigger without extending its truth row is caught
1049    /// here.
1050    #[test]
1051    fn export_trigger_fires_on_truth_table() {
1052        // ProcessPhase imports are local to the test to keep the
1053        // module's top-level surface minimal.
1054        use crate::phase::ProcessPhase::{
1055            Attested, Execing, Exiting, Failed, Forking, Pending, Reaped, Reconverging, Releasing,
1056            Running, Zombie,
1057        };
1058        let table: &[(ExportTrigger, &[(crate::phase::ProcessPhase, bool)])] = &[
1059            (
1060                ExportTrigger::OnAttested,
1061                &[
1062                    (Attested, true),
1063                    (Failed, false),
1064                    (Pending, false),
1065                    (Forking, false),
1066                    (Execing, false),
1067                    (Running, false),
1068                    (Reconverging, false),
1069                    (Releasing, false),
1070                    (Exiting, false),
1071                    (Zombie, false),
1072                    (Reaped, false),
1073                ],
1074            ),
1075            (
1076                ExportTrigger::OnFailed,
1077                &[
1078                    (Attested, false),
1079                    (Failed, true),
1080                    (Pending, false),
1081                    (Forking, false),
1082                    (Execing, false),
1083                    (Running, false),
1084                    (Reconverging, false),
1085                    (Releasing, false),
1086                    (Exiting, false),
1087                    (Zombie, false),
1088                    (Reaped, false),
1089                ],
1090            ),
1091            (
1092                ExportTrigger::Always,
1093                &[
1094                    (Attested, true),
1095                    (Failed, true),
1096                    (Pending, false),
1097                    (Forking, false),
1098                    (Execing, false),
1099                    (Running, false),
1100                    (Reconverging, false),
1101                    (Releasing, false),
1102                    (Exiting, false),
1103                    (Zombie, false),
1104                    (Reaped, false),
1105                ],
1106            ),
1107        ];
1108        // The truth table must cover every (trigger, phase) pair.
1109        assert_eq!(table.len(), ExportTrigger::ALL.len());
1110        for (_, row) in table {
1111            assert_eq!(row.len(), crate::phase::ProcessPhase::ALL.len());
1112        }
1113        for (trigger, row) in table {
1114            for (phase, expected) in *row {
1115                assert_eq!(
1116                    trigger.fires_on(*phase),
1117                    *expected,
1118                    "fires_on({trigger:?}, {phase:?}) drift"
1119                );
1120            }
1121        }
1122    }
1123
1124    /// DELEGATION CONTRACT: the legacy `fires_on_attested` /
1125    /// `fires_on_failed` predicates agree with the typed
1126    /// `fires_on(phase)` dispatch they delegate to, for every variant
1127    /// in `ALL`. A regression that re-introduces an inline `matches!`
1128    /// in either legacy predicate fails here. `fires_on` is the
1129    /// source of truth.
1130    #[test]
1131    fn export_trigger_legacy_predicates_delegate_to_phase_dispatch() {
1132        for trigger in ExportTrigger::ALL {
1133            assert_eq!(
1134                trigger.fires_on_attested(),
1135                trigger.fires_on(crate::phase::ProcessPhase::Attested),
1136                "legacy fires_on_attested drift for {trigger:?}"
1137            );
1138            assert_eq!(
1139                trigger.fires_on_failed(),
1140                trigger.fires_on(crate::phase::ProcessPhase::Failed),
1141                "legacy fires_on_failed drift for {trigger:?}"
1142            );
1143        }
1144    }
1145
1146    // ── closed-set algebra for ReportFormat (ALL × as_str × FromStr ×
1147    //    payload_shape) ─
1148
1149    /// Structural well-formedness of [`ReportFormat`] as a
1150    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1151    /// testkit lift that pins all three structural invariants (`ALL`
1152    /// is non-empty, every variant round-trips through
1153    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1154    /// outside the closed set) at ONE call site. Replaces the hand-
1155    /// derived `report_format_all_is_unique_and_complete` +
1156    /// `report_format_roundtrip_via_as_str` + the empty-input arm of
1157    /// `unknown_report_format_errors`. `FromStr` delegates to
1158    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1159    /// exercises the same code path the export worker hits when
1160    /// parsing a CRD `enum:`-validated value back to the typed format.
1161    #[test]
1162    fn report_format_is_well_formed_closed_set() {
1163        tatara_closed_set::assert_closed_set_well_formed::<ReportFormat>();
1164    }
1165
1166    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1167    /// output verbatim for every variant. A future variant rename
1168    /// (or an `as_str` arm typo) lands here at one site, instead of
1169    /// drifting between the typed surface and the YAML wire format
1170    /// the reconciler / operator both read.
1171    #[test]
1172    fn report_format_as_str_matches_serde() {
1173        crate::tagged_union::assert_label_matches_serde_serialization::<ReportFormat>();
1174    }
1175
1176    /// The Display impl IS `as_str` — pinning this lets future callers
1177    /// reach for either projection without drift.
1178    #[test]
1179    fn report_format_display_matches_as_str() {
1180        crate::tagged_union::assert_display_matches_label::<ReportFormat>();
1181    }
1182
1183    /// `FromStr` rejects strings that aren't in the canonical
1184    /// projection — lowercased / typo / unrelated — and the error
1185    /// echoes the input verbatim so the operator-facing diagnostic
1186    /// carries the offending value, not a normalized form. The
1187    /// empty-input arm is pinned by
1188    /// [`report_format_is_well_formed_closed_set`] via the
1189    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1190    /// verbatim-echo contract on the [`UnknownReportFormat`] newtype,
1191    /// which the trait's `make_unknown` can't see.
1192    #[test]
1193    fn unknown_report_format_errors() {
1194        use std::str::FromStr;
1195        for bad in ["junit", "JUNIT", "tap", "Yaml", "TomlV1"] {
1196            let err = ReportFormat::from_str(bad).unwrap_err();
1197            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1198        }
1199    }
1200
1201    // `unknown_report_format_message_matches_substrate_convention`
1202    // removed — clause (5) of
1203    // `tatara_closed_set::assert_closed_set_well_formed::<ReportFormat>()`
1204    // verifies the substrate-wide `"unknown {SET_LABEL}: {input}"`
1205    // shape generically (called from `report_format_is_well_formed_closed_set`
1206    // above); the `SET_LABEL` projection is pinned by
1207    // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests`.
1208
1209    /// TRUTH-TABLE CONTRACT: `payload_shape` agrees with the documented
1210    /// shape table for every variant in `ALL`. A new variant whose
1211    /// shape the author forgets to add to `payload_shape`'s match is
1212    /// caught by the compiler at the match site; a regression that
1213    /// reshuffles existing variants (e.g. routing `NdJson` to opaque
1214    /// bytes) is caught here. `payload_shape` is the worker's only
1215    /// dispatch — once this passes, the worker's `match shape { … }`
1216    /// is exhaustive on the 2-variant `ReportPayloadShape` instead of
1217    /// the 4-variant `ReportFormat`, so adding a future format never
1218    /// touches the worker.
1219    #[test]
1220    fn report_format_payload_shape_truth_table() {
1221        let table: &[(ReportFormat, ReportPayloadShape)] = &[
1222            (ReportFormat::Junit, ReportPayloadShape::OpaqueBytes),
1223            (ReportFormat::TapV13, ReportPayloadShape::OpaqueBytes),
1224            (ReportFormat::NdJson, ReportPayloadShape::NdJsonLines),
1225            (ReportFormat::Raw, ReportPayloadShape::OpaqueBytes),
1226        ];
1227        assert_eq!(table.len(), ReportFormat::ALL.len());
1228        for (format, expected) in table {
1229            assert_eq!(
1230                format.payload_shape(),
1231                *expected,
1232                "payload_shape({format:?}) drift"
1233            );
1234        }
1235    }
1236
1237    /// CLOSURE-OF-PROJECTION CONTRACT: every `ReportPayloadShape`
1238    /// variant is the image of at least one `ReportFormat` variant —
1239    /// no shape is stranded. A `Compressed` shape added to
1240    /// `ReportPayloadShape::ALL` without an `ALL → payload_shape`
1241    /// mapping at any `ReportFormat` arm makes the worker's
1242    /// 3-variant dispatch reachable from no input, which would
1243    /// silently dead-code one arm. Caught here.
1244    #[test]
1245    fn report_payload_shape_reachable_from_some_report_format() {
1246        for shape in ReportPayloadShape::ALL {
1247            let reachable = ReportFormat::ALL.iter().any(|f| f.payload_shape() == shape);
1248            assert!(
1249                reachable,
1250                "{shape:?} is in ReportPayloadShape::ALL but no ReportFormat projects to it"
1251            );
1252        }
1253    }
1254
1255    /// CLOSED-SET CONTRACT: `ReportPayloadShape::ALL` enumerates each
1256    /// variant exactly once. The `[Self; 2]` array literal forces
1257    /// the arity at compile time; this test pins per-variant
1258    /// reachability so adding a third shape (`Compressed`) without
1259    /// extending `ALL` fails here rather than silently dropping the
1260    /// new variant from every sweep through `Self::ALL`.
1261    #[test]
1262    fn report_payload_shape_all_enumerates_each_variant_exactly_once() {
1263        let mut seen = std::collections::HashSet::new();
1264        for shape in ReportPayloadShape::ALL {
1265            assert!(seen.insert(shape), "duplicate variant in ALL: {shape:?}");
1266        }
1267        assert_eq!(seen.len(), ReportPayloadShape::ALL.len());
1268        for shape in [
1269            ReportPayloadShape::NdJsonLines,
1270            ReportPayloadShape::OpaqueBytes,
1271        ] {
1272            assert!(
1273                ReportPayloadShape::ALL.contains(&shape),
1274                "{shape:?} declared but not in ALL"
1275            );
1276        }
1277    }
1278
1279    /// CANONICAL-KEY UNIQUENESS: no two shapes alias the same
1280    /// `as_str` identifier. A future rename of one variant to a name
1281    /// that collides with another (e.g. both → `"Lines"`) breaks the
1282    /// shape's identity in operator-facing reason strings and would
1283    /// silently make Display non-injective. Caught here.
1284    #[test]
1285    fn report_payload_shape_as_str_unique_per_variant() {
1286        let mut seen = std::collections::HashSet::new();
1287        for shape in ReportPayloadShape::ALL {
1288            assert!(
1289                seen.insert(shape.as_str()),
1290                "as_str collision: {shape:?} → {:?}",
1291                shape.as_str()
1292            );
1293        }
1294        assert_eq!(seen.len(), ReportPayloadShape::ALL.len());
1295    }
1296
1297    /// DISPLAY-IS-AS_STR: the Display impl IS `as_str` — pinning
1298    /// this lets callers reach for either projection without drift.
1299    /// Sibling to `report_format_display_matches_as_str` and
1300    /// `export_trigger_display_matches_as_str`. Routed through the
1301    /// substrate-wide [`crate::tagged_union::assert_display_matches_label`]
1302    /// primitive so the sweep body lives at ONE substrate site rather
1303    /// than restated per-implementor. Also exercised through the
1304    /// substrate-wide `every_production_display_impl_binds_through_the_testkit_primitive`
1305    /// sweep so a per-crate test-site drop cannot silently disable the
1306    /// check.
1307    #[test]
1308    fn report_payload_shape_display_matches_as_str() {
1309        crate::tagged_union::assert_display_matches_label::<ReportPayloadShape>();
1310    }
1311
1312    /// EMBED-FIELD UNIQUENESS: no two shapes write into the same
1313    /// `payload.<field>` key. The worker's embed site is
1314    /// `payload.insert(shape.payload_field().into(), …)`; if two
1315    /// shapes aliased to the same field name, two different report
1316    /// sources arriving in the same export envelope would silently
1317    /// overwrite each other's bytes. Caught here.
1318    #[test]
1319    fn report_payload_shape_payload_field_unique_per_variant() {
1320        let mut seen = std::collections::HashSet::new();
1321        for shape in ReportPayloadShape::ALL {
1322            assert!(
1323                seen.insert(shape.payload_field()),
1324                "payload_field collision: {shape:?} → {:?}",
1325                shape.payload_field()
1326            );
1327        }
1328        assert_eq!(seen.len(), ReportPayloadShape::ALL.len());
1329    }
1330
1331    /// TRUTH-TABLE: `payload_field` matches the documented
1332    /// `payload.ndjson` / `payload.raw_b64` shinryu schema. A future
1333    /// rename (e.g. `"raw_b64"` → `"raw"`) lands here at one arm
1334    /// rather than drifting between the docstring prose and the
1335    /// worker's embed-site literal. Adding a third shape forces the
1336    /// author to add a row here (driven by `ALL`), so the table
1337    /// width tracks the closed set.
1338    #[test]
1339    fn report_payload_shape_payload_field_truth_table() {
1340        let table: &[(ReportPayloadShape, &str)] = &[
1341            (ReportPayloadShape::NdJsonLines, "ndjson"),
1342            (ReportPayloadShape::OpaqueBytes, "raw_b64"),
1343        ];
1344        assert_eq!(table.len(), ReportPayloadShape::ALL.len());
1345        for (shape, expected) in table {
1346            assert_eq!(
1347                shape.payload_field(),
1348                *expected,
1349                "payload_field({shape:?}) drift"
1350            );
1351        }
1352    }
1353
1354    /// Every variant's `payload_field` is non-empty and contains no
1355    /// JSON-path-separator (`.`) — the worker concatenates
1356    /// `payload.<payload_field>` so an embedded `.` would alias into
1357    /// the parent map and silently flatten the embed. Structural
1358    /// guard for the field-name shape.
1359    #[test]
1360    fn report_payload_shape_payload_field_is_a_single_segment() {
1361        for shape in ReportPayloadShape::ALL {
1362            let field = shape.payload_field();
1363            assert!(
1364                !field.is_empty(),
1365                "payload_field({shape:?}) is empty — embed site has no destination"
1366            );
1367            assert!(
1368                !field.contains('.'),
1369                "payload_field({shape:?}) contains '.' ({field:?}) — would flatten the embed into payload's parent map"
1370            );
1371        }
1372    }
1373
1374    #[test]
1375    fn export_spec_serde_round_trip() {
1376        let spec = ExportSpec {
1377            source: ArtifactSource {
1378                test_report: Some(TestReportSource {
1379                    configmap: "demo-test-results".into(),
1380                    key: "junit.xml".into(),
1381                    format: ReportFormat::Junit,
1382                    namespace: None,
1383                }),
1384                ..ArtifactSource::default()
1385            },
1386            channel: VectorChannel {
1387                http_event: Some(HttpEventChannel {
1388                    endpoint: None,
1389                    signal_type: "test-report".into(),
1390                }),
1391                ..VectorChannel::default()
1392            },
1393            when: ExportTrigger::Always,
1394            experiment_id_override: Some("demo-run-2026-05-20".into()),
1395        };
1396
1397        let yaml = serde_yaml::to_string(&spec).unwrap();
1398        // camelCase wire format — what FluxCD / kubectl users see.
1399        assert!(yaml.contains("source:"));
1400        assert!(yaml.contains("testReport:"));
1401        assert!(yaml.contains("configmap: demo-test-results"));
1402        assert!(yaml.contains("format: Junit"));
1403        assert!(yaml.contains("channel:"));
1404        assert!(yaml.contains("httpEvent:"));
1405        assert!(yaml.contains("signalType: test-report"));
1406        assert!(yaml.contains("when: Always"));
1407        assert!(yaml.contains("experimentIdOverride: demo-run-2026-05-20"));
1408
1409        let back: ExportSpec = serde_yaml::from_str(&yaml).unwrap();
1410        assert!(back.source.test_report.is_some());
1411        assert!(back.channel.http_event.is_some());
1412        assert_eq!(back.when, ExportTrigger::Always);
1413    }
1414
1415    #[test]
1416    fn run_marker_labels_round_trip() {
1417        let mut labels = BTreeMap::new();
1418        labels.insert("run-id".into(), "demo-run-2026-05-20".into());
1419        labels.insert("phase".into(), "end".into());
1420        let spec = ExportSpec {
1421            source: ArtifactSource {
1422                run_marker: Some(RunMarkerSource { labels }),
1423                ..ArtifactSource::default()
1424            },
1425            channel: VectorChannel {
1426                http_event: Some(HttpEventChannel {
1427                    endpoint: None,
1428                    signal_type: "ephemeral-marker".into(),
1429                }),
1430                ..VectorChannel::default()
1431            },
1432            when: ExportTrigger::Always,
1433            experiment_id_override: None,
1434        };
1435        let yaml = serde_yaml::to_string(&spec).unwrap();
1436        assert!(yaml.contains("runMarker:"));
1437        assert!(yaml.contains("run-id: demo-run-2026-05-20"));
1438        let back: ExportSpec = serde_yaml::from_str(&yaml).unwrap();
1439        let rm = back.source.run_marker.unwrap();
1440        assert_eq!(rm.labels["phase"], "end");
1441    }
1442
1443    /// Default endpoints resolve to the canonical in-cluster Service
1444    /// DNS — a single source of truth other tatara crates can
1445    /// re-export instead of duplicating literals.
1446    #[test]
1447    fn default_endpoints_are_stable_constants() {
1448        assert_eq!(
1449            DEFAULT_VECTOR_INGEST,
1450            "http://vector.observability.svc.cluster.local:8080"
1451        );
1452        assert_eq!(
1453            DEFAULT_NATS_URL,
1454            "nats://nats.observability.svc.cluster.local:4222"
1455        );
1456    }
1457
1458    // ── closed-set algebra for ArtifactKind (ALL × as_str × Display ×
1459    //    FromStr × select × ArtifactVariant::kind) ─────────────────────
1460
1461    /// Structural well-formedness of [`ArtifactKind`] as a
1462    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1463    /// testkit lift that pins all three structural invariants (`ALL`
1464    /// is non-empty, every variant round-trips through
1465    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1466    /// outside the closed set) at ONE call site. Replaces the hand-
1467    /// derived `artifact_kind_all_is_unique_and_complete` +
1468    /// `artifact_kind_roundtrip_via_as_str` + the empty-input arm of
1469    /// `unknown_artifact_kind_errors`. `FromStr` delegates to
1470    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1471    /// exercises the same code path the export worker hits when
1472    /// parsing a CRD `enum:`-validated value back to the typed kind.
1473    #[test]
1474    fn artifact_kind_is_well_formed_closed_set() {
1475        tatara_closed_set::assert_closed_set_well_formed::<ArtifactKind>();
1476    }
1477
1478    /// CANONICAL-KEY CONTRACT: every `ArtifactKind::as_str()` matches
1479    /// the serde `rename_all = "camelCase"` field name on the
1480    /// corresponding `Option<…>` slot of `ArtifactSource`. A future
1481    /// rename of either the struct field OR the `as_str` arm lands
1482    /// here at one site, instead of drifting between the typed
1483    /// surface, the YAML wire format, and the `ArtifactError::Empty`
1484    /// diagnostic. The mapping is the table the serde derive produces
1485    /// against the struct field declarations above; reading the YAML
1486    /// output pins it without re-deriving by hand.
1487    ///
1488    /// Routes through the substrate primitive
1489    /// [`crate::tagged_union::assert_single_slot_key_matches_label`],
1490    /// which pins the exactly-one-key + name-equality projection
1491    /// byte-identically for every `<T: TaggedUnion + Serialize>`
1492    /// implementor — the wire-alignment testkit shared with the sibling
1493    /// `intent_kind_as_str_matches_intent_field_name` /
1494    /// `encapsulation_target_as_str_matches_field_name` /
1495    /// `channel_kind_as_str_matches_field_name` sites. Pre-lift this
1496    /// site restated a weaker YAML-substring check (`yaml.contains(&format!("{key}:"))`)
1497    /// which would silently pass on drift where a non-tagged-union
1498    /// field was added to `ArtifactSource`; post-lift the primitive's
1499    /// JSON exactly-one form catches that drift too — at ONE substrate
1500    /// site.
1501    #[test]
1502    fn artifact_kind_as_str_matches_field_name() {
1503        crate::tagged_union::assert_single_slot_key_matches_label::<ArtifactSource, _>(
1504            single_slot_source,
1505        );
1506    }
1507
1508    /// CANONICAL-NAMES PIN: byte-exact camelCase wire-format pin —
1509    /// renaming any of these strings IS a wire-format break that fails
1510    /// this test FIRST so the rename stays a deliberate decision, not
1511    /// a typo. Locks the (variant → operator-facing key) table.
1512    #[test]
1513    fn artifact_kind_canonical_names_pinned() {
1514        assert_eq!(ArtifactKind::Receipts.as_str(), "receipts");
1515        assert_eq!(ArtifactKind::TestReport.as_str(), "testReport");
1516        assert_eq!(ArtifactKind::ProcessSnapshot.as_str(), "processSnapshot");
1517        assert_eq!(ArtifactKind::RunMarker.as_str(), "runMarker");
1518    }
1519
1520    /// The Display impl IS `as_str` — pinning this lets future callers
1521    /// reach for either projection without drift. If a reviewer
1522    /// accidentally re-introduces an inline match in Display, this
1523    /// test would fail the moment a variant rename touches one site
1524    /// but not the other.
1525    #[test]
1526    fn artifact_kind_display_matches_as_str() {
1527        crate::tagged_union::assert_display_matches_label::<ArtifactKind>();
1528    }
1529
1530    /// `FromStr` rejects strings that aren't in the canonical
1531    /// projection — PascalCased / typo / cross-axis-leaked inputs
1532    /// from sibling closed-set enums on the same `ExportSpec` axis
1533    /// (`Junit`, `OnAttested`, …) — and the error echoes the input
1534    /// verbatim so the operator-facing diagnostic carries the
1535    /// offending value, not a normalized form. `ArtifactKind` is its
1536    /// own axis, NOT a transparent reflection of any sibling. The
1537    /// empty-input arm is pinned by
1538    /// [`artifact_kind_is_well_formed_closed_set`] via the
1539    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1540    /// verbatim-echo contract on the [`UnknownArtifactKind`] newtype,
1541    /// which the trait's `make_unknown` can't see.
1542    #[test]
1543    fn unknown_artifact_kind_errors() {
1544        use std::str::FromStr;
1545        for bad in [
1546            "Receipts",
1547            "test_report",
1548            "RECEIPTS",
1549            "snapshot",
1550            "marker",
1551            "Junit",
1552            "OnAttested",
1553            "NdJsonLines",
1554        ] {
1555            let err = ArtifactKind::from_str(bad).unwrap_err();
1556            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1557        }
1558    }
1559
1560    /// ROUND-TRIP CONTRACT: every kind reaches its borrowed-variant
1561    /// view via `select`, and that variant projects back to the same
1562    /// kind via `ArtifactVariant::kind`. A regression that misroutes a
1563    /// select arm (e.g. `Self::Receipts => source.test_report.as_ref()
1564    /// ...`) fails loudly here.
1565    ///
1566    /// Routes through the substrate primitive
1567    /// [`crate::tagged_union::assert_variant_round_trip`] shared with
1568    /// the sibling `intent_kind_round_trips_through_variant_kind` /
1569    /// `channel_kind_round_trips_through_variant_kind` /
1570    /// `encapsulation_target_round_trips_through_variant_target`
1571    /// sites — the projection lives at ONE substrate primitive and
1572    /// every site binds through a single call.
1573    #[test]
1574    fn artifact_kind_round_trips_through_variant_kind() {
1575        crate::tagged_union::assert_variant_round_trip::<ArtifactSource, _>(single_slot_source);
1576    }
1577
1578    /// SELECT-EMPTY CONTRACT: an unpopulated slot returns `None` from
1579    /// `select`, for every kind. Pairs with the resolver's `Empty`
1580    /// path so a future kind's slot defaulting wrong (e.g. accidentally
1581    /// `Some(Default::default())` instead of `None`) is caught here.
1582    #[test]
1583    fn artifact_kind_select_returns_none_for_unset_slot() {
1584        let empty = ArtifactSource::default();
1585        for kind in ArtifactKind::ALL {
1586            assert!(
1587                kind.select(&empty).is_none(),
1588                "{kind:?} reported populated on a default ArtifactSource"
1589            );
1590        }
1591    }
1592
1593    /// EMPTY-DIAGNOSTIC CONTRACT: the closed-set kind list embedded
1594    /// in `ArtifactError::Empty` echoes the canonical join of every
1595    /// `ArtifactKind::as_str()` projection. A variant added without
1596    /// updating `ARTIFACT_KIND_LIST` (or a renamed variant) shows up
1597    /// here as a mismatch. Routes through the substrate primitive
1598    /// [`crate::tagged_union::assert_kind_list_matches_closed_set`]
1599    /// shared with the sibling
1600    /// `intent_error_empty_lists_every_kind_in_canonical_order`
1601    /// / `channel_error_empty_lists_every_kind_in_canonical_order`
1602    /// / `encapsulation_kind_error_empty_lists_every_target_in_canonical_order`
1603    /// sites — the projection lives at ONE substrate primitive and
1604    /// every site binds through a single call.
1605    #[test]
1606    fn artifact_error_empty_lists_every_kind_in_canonical_order() {
1607        crate::tagged_union::assert_kind_list_matches_closed_set::<ArtifactSource>();
1608    }
1609
1610    /// AMBIGUOUS-PATH CONTRACT: when two slots are populated the
1611    /// resolver yields `Ambiguous`, exhaustively across every pair in
1612    /// `ALL × ALL` (excluding the diagonal). A future asymmetry where
1613    /// one slot would silently shadow another (e.g. an `if-let` chain
1614    /// re-introducing first-wins ordering) is caught here. Routes
1615    /// through the substrate primitive
1616    /// [`crate::tagged_union::assert_two_slots_ambiguous`] shared with
1617    /// the sibling
1618    /// `encapsulation_kind_two_slots_is_ambiguous_across_every_pair`
1619    /// / `vector_channel_two_slots_is_ambiguous_across_every_pair`
1620    /// / `intent_two_slots_is_ambiguous_across_every_pair` sites — the
1621    /// nested-`for a in K::ALL { for b in K::ALL { … } }` sweep lives
1622    /// at ONE substrate site.
1623    #[test]
1624    fn artifact_source_two_slots_is_ambiguous_across_every_pair() {
1625        crate::tagged_union::assert_two_slots_ambiguous::<ArtifactSource, _>(two_slot_source);
1626    }
1627
1628    // ── closed-set algebra for ChannelKind (ALL × as_str × Display ×
1629    //    FromStr × select × ChannelVariant::kind) ─────────────────────
1630
1631    /// Structural well-formedness of [`ChannelKind`] as a
1632    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1633    /// testkit lift that pins all three structural invariants (`ALL`
1634    /// is non-empty, every variant round-trips through
1635    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1636    /// outside the closed set) at ONE call site. Replaces the hand-
1637    /// derived `channel_kind_all_is_unique_and_complete` +
1638    /// `channel_kind_roundtrip_via_as_str` + the empty-input arm of
1639    /// `unknown_channel_kind_errors`. `FromStr` delegates to
1640    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1641    /// exercises the same code path the export worker hits when
1642    /// parsing a CRD `enum:`-validated value back to the typed kind.
1643    #[test]
1644    fn channel_kind_is_well_formed_closed_set() {
1645        tatara_closed_set::assert_closed_set_well_formed::<ChannelKind>();
1646    }
1647
1648    /// CANONICAL-KEY CONTRACT: every `ChannelKind::as_str()` matches
1649    /// the serde `rename_all = "camelCase"` field name on the
1650    /// corresponding `Option<…>` slot of `VectorChannel`. A future
1651    /// rename of either the struct field OR the `as_str` arm lands
1652    /// here at one site, instead of drifting between the typed
1653    /// surface, the YAML wire format, and the `ChannelError::Empty`
1654    /// diagnostic.
1655    ///
1656    /// Routes through the substrate primitive
1657    /// [`crate::tagged_union::assert_single_slot_key_matches_label`],
1658    /// which pins the exactly-one-key + name-equality projection
1659    /// byte-identically for every `<T: TaggedUnion + Serialize>`
1660    /// implementor — the wire-alignment testkit shared with the sibling
1661    /// `intent_kind_as_str_matches_intent_field_name` /
1662    /// `encapsulation_target_as_str_matches_field_name` /
1663    /// `artifact_kind_as_str_matches_field_name` sites. Pre-lift this
1664    /// site restated a weaker YAML-substring check (`yaml.contains(&format!("{key}:"))`)
1665    /// which would silently pass on drift where a non-tagged-union
1666    /// field was added to `VectorChannel`; post-lift the primitive's
1667    /// JSON exactly-one form catches that drift too — at ONE substrate
1668    /// site.
1669    #[test]
1670    fn channel_kind_as_str_matches_field_name() {
1671        crate::tagged_union::assert_single_slot_key_matches_label::<VectorChannel, _>(
1672            single_slot_channel,
1673        );
1674    }
1675
1676    /// CANONICAL-NAMES PIN: byte-exact camelCase wire-format pin —
1677    /// renaming any of these strings IS a wire-format break that fails
1678    /// this test FIRST so the rename stays a deliberate decision, not
1679    /// a typo. Locks the (variant → operator-facing key) table.
1680    #[test]
1681    fn channel_kind_canonical_names_pinned() {
1682        assert_eq!(ChannelKind::HttpEvent.as_str(), "httpEvent");
1683        assert_eq!(ChannelKind::NatsSubject.as_str(), "natsSubject");
1684        assert_eq!(ChannelKind::Stdout.as_str(), "stdout");
1685    }
1686
1687    /// The Display impl IS `as_str` — pinning this lets future callers
1688    /// reach for either projection without drift.
1689    #[test]
1690    fn channel_kind_display_matches_as_str() {
1691        crate::tagged_union::assert_display_matches_label::<ChannelKind>();
1692    }
1693
1694    /// `FromStr` rejects strings that aren't in the canonical
1695    /// projection — PascalCased / typo / cross-axis-leaked inputs
1696    /// from sibling closed-set enums on the same `ExportSpec` axis
1697    /// (`Receipts`, `OnAttested`, `Junit`, …) — and the error echoes
1698    /// the input verbatim so the operator-facing diagnostic carries
1699    /// the offending value, not a normalized form. `ChannelKind` is
1700    /// its own axis, NOT a transparent reflection of any sibling. The
1701    /// empty-input arm is pinned by
1702    /// [`channel_kind_is_well_formed_closed_set`] via the
1703    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1704    /// verbatim-echo contract on the [`UnknownChannelKind`] newtype,
1705    /// which the trait's `make_unknown` can't see.
1706    #[test]
1707    fn unknown_channel_kind_errors() {
1708        use std::str::FromStr;
1709        for bad in [
1710            "HttpEvent",
1711            "http_event",
1712            "HTTPEVENT",
1713            "nats",
1714            "STDOUT",
1715            "Receipts",
1716            "OnAttested",
1717            "Junit",
1718            "NdJsonLines",
1719        ] {
1720            let err = ChannelKind::from_str(bad).unwrap_err();
1721            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1722        }
1723    }
1724
1725    // `unknown_channel_kind_message_matches_substrate_convention`
1726    // removed — clause (5) of
1727    // `tatara_closed_set::assert_closed_set_well_formed::<ChannelKind>()`
1728    // verifies the substrate-wide `"unknown {SET_LABEL}: {input}"`
1729    // shape generically (called from `channel_kind_is_well_formed_closed_set`
1730    // above); the `SET_LABEL` projection is pinned by
1731    // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests`.
1732
1733    /// ROUND-TRIP CONTRACT: every kind reaches its borrowed-variant
1734    /// view via `select`, and that variant projects back to the same
1735    /// kind via `ChannelVariant::kind`. A regression that misroutes a
1736    /// select arm (e.g. `Self::HttpEvent => channel.nats_subject ...`)
1737    /// fails loudly here.
1738    ///
1739    /// Routes through the substrate primitive
1740    /// [`crate::tagged_union::assert_variant_round_trip`] shared with
1741    /// the sibling `intent_kind_round_trips_through_variant_kind` /
1742    /// `artifact_kind_round_trips_through_variant_kind` /
1743    /// `encapsulation_target_round_trips_through_variant_target`
1744    /// sites — the projection lives at ONE substrate primitive and
1745    /// every site binds through a single call.
1746    #[test]
1747    fn channel_kind_round_trips_through_variant_kind() {
1748        crate::tagged_union::assert_variant_round_trip::<VectorChannel, _>(single_slot_channel);
1749    }
1750
1751    /// SELECT-EMPTY CONTRACT: an unpopulated slot returns `None` from
1752    /// `select`, for every kind. Pairs with the resolver's `Empty`
1753    /// path so a future kind's slot defaulting wrong (e.g. accidentally
1754    /// `Some(Default::default())` instead of `None`) is caught here.
1755    #[test]
1756    fn channel_kind_select_returns_none_for_unset_slot() {
1757        let empty = VectorChannel::default();
1758        for kind in ChannelKind::ALL {
1759            assert!(
1760                kind.select(&empty).is_none(),
1761                "{kind:?} reported populated on a default VectorChannel"
1762            );
1763        }
1764    }
1765
1766    /// EMPTY-DIAGNOSTIC CONTRACT: the closed-set kind list embedded
1767    /// in `ChannelError::Empty` echoes the canonical join of every
1768    /// `ChannelKind::as_str()` projection. A variant added without
1769    /// updating `CHANNEL_KIND_LIST` (or a renamed variant) shows up
1770    /// here as a mismatch. Routes through the substrate primitive
1771    /// [`crate::tagged_union::assert_kind_list_matches_closed_set`]
1772    /// shared with the sibling `intent_error_empty_lists_every_kind_in_canonical_order`
1773    /// / `artifact_error_empty_lists_every_kind_in_canonical_order`
1774    /// / `encapsulation_kind_error_empty_lists_every_target_in_canonical_order`
1775    /// sites — the projection lives at ONE substrate primitive and
1776    /// every site binds through a single call.
1777    #[test]
1778    fn channel_error_empty_lists_every_kind_in_canonical_order() {
1779        crate::tagged_union::assert_kind_list_matches_closed_set::<VectorChannel>();
1780    }
1781
1782    /// AMBIGUOUS-PATH CONTRACT: when two slots are populated the
1783    /// resolver yields `Ambiguous`, exhaustively across every pair in
1784    /// `ALL × ALL` (excluding the diagonal). A future asymmetry where
1785    /// one slot would silently shadow another (e.g. an `if-let` chain
1786    /// re-introducing first-wins ordering) is caught here. Routes
1787    /// through the substrate primitive
1788    /// [`crate::tagged_union::assert_two_slots_ambiguous`] shared with
1789    /// the sibling
1790    /// `encapsulation_kind_two_slots_is_ambiguous_across_every_pair`
1791    /// / `artifact_source_two_slots_is_ambiguous_across_every_pair`
1792    /// / `intent_two_slots_is_ambiguous_across_every_pair` sites — the
1793    /// nested-`for a in K::ALL { for b in K::ALL { … } }` sweep lives
1794    /// at ONE substrate site.
1795    #[test]
1796    fn vector_channel_two_slots_is_ambiguous_across_every_pair() {
1797        crate::tagged_union::assert_two_slots_ambiguous::<VectorChannel, _>(two_slot_channel);
1798    }
1799
1800    /// Construct a `VectorChannel` with exactly the given kind's slot
1801    /// populated by a minimal valid inner channel. Shared across the
1802    /// closed-set property tests so they each cover every variant
1803    /// without restating the construction table. Mirrors
1804    /// `single_slot_source` in shape.
1805    fn single_slot_channel(kind: ChannelKind) -> VectorChannel {
1806        match kind {
1807            ChannelKind::HttpEvent => VectorChannel {
1808                http_event: Some(HttpEventChannel {
1809                    endpoint: None,
1810                    signal_type: "x".into(),
1811                }),
1812                ..VectorChannel::default()
1813            },
1814            ChannelKind::NatsSubject => VectorChannel {
1815                nats_subject: Some(NatsSubjectChannel {
1816                    subject: "s".into(),
1817                    stream: "S".into(),
1818                    url: None,
1819                }),
1820                ..VectorChannel::default()
1821            },
1822            ChannelKind::Stdout => VectorChannel {
1823                stdout: Some(StdoutChannel::default()),
1824                ..VectorChannel::default()
1825            },
1826        }
1827    }
1828
1829    /// Construct a `VectorChannel` with two slots populated — drives
1830    /// the pairwise `Ambiguous` sweep. Composes the single-slot
1831    /// constructor on top of itself to keep one source of truth for
1832    /// per-variant inner payloads.
1833    fn two_slot_channel(a: ChannelKind, b: ChannelKind) -> VectorChannel {
1834        let ca = single_slot_channel(a);
1835        let cb = single_slot_channel(b);
1836        VectorChannel {
1837            http_event: ca.http_event.or(cb.http_event),
1838            nats_subject: ca.nats_subject.or(cb.nats_subject),
1839            stdout: ca.stdout.or(cb.stdout),
1840        }
1841    }
1842
1843    /// Construct an `ArtifactSource` with exactly the given kind's
1844    /// slot populated by a minimal valid inner source. Shared across
1845    /// the closed-set property tests so they each cover every variant
1846    /// without restating the construction table. Mirrors
1847    /// `single_slot_intent` in shape.
1848    fn single_slot_source(kind: ArtifactKind) -> ArtifactSource {
1849        match kind {
1850            ArtifactKind::Receipts => ArtifactSource {
1851                receipts: Some(ReceiptsSource::default()),
1852                ..ArtifactSource::default()
1853            },
1854            ArtifactKind::TestReport => ArtifactSource {
1855                test_report: Some(TestReportSource {
1856                    configmap: "cm".into(),
1857                    key: "k".into(),
1858                    format: ReportFormat::Junit,
1859                    namespace: None,
1860                }),
1861                ..ArtifactSource::default()
1862            },
1863            ArtifactKind::ProcessSnapshot => ArtifactSource {
1864                process_snapshot: Some(ProcessSnapshotSource::default()),
1865                ..ArtifactSource::default()
1866            },
1867            ArtifactKind::RunMarker => ArtifactSource {
1868                run_marker: Some(RunMarkerSource::default()),
1869                ..ArtifactSource::default()
1870            },
1871        }
1872    }
1873
1874    /// Construct an `ArtifactSource` with two slots populated — drives
1875    /// the pairwise `Ambiguous` sweep. Composes the single-slot
1876    /// constructor on top of itself to keep one source of truth for
1877    /// per-variant inner payloads.
1878    fn two_slot_source(a: ArtifactKind, b: ArtifactKind) -> ArtifactSource {
1879        // Merge by populating each kind's slot from its single-slot view.
1880        let sa = single_slot_source(a);
1881        let sb = single_slot_source(b);
1882        ArtifactSource {
1883            receipts: sa.receipts.or(sb.receipts),
1884            test_report: sa.test_report.or(sb.test_report),
1885            process_snapshot: sa.process_snapshot.or(sb.process_snapshot),
1886            run_marker: sa.run_marker.or(sb.run_marker),
1887        }
1888    }
1889}