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