Skip to main content

tatara_process/
export.rs

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