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