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}