Skip to main content

tatara_export_worker/
lib.rs

1//! Pure decision logic for `tatara-export-worker` — the binary that
2//! ships one declared `ExportSpec` from an ephemeral Process to its
3//! Vector-native channel.
4//!
5//! The compounding move: every function in this module is pure (no
6//! HTTP, no NATS, no kube client, no clock), takes its inputs by
7//! reference, and returns a typed value the I/O layer in `main.rs`
8//! then consumes. That means the whole worker is unit-testable
9//! without standing up infrastructure — and any new artifact source
10//! or channel can be added by extending this module first, then the
11//! I/O glue mechanically follows.
12//!
13//! Three substrate primitives live here:
14//!
15//! 1. [`prepare_event_payload`] — given an [`ArtifactVariant`] + raw
16//!    artifact bytes + run id + signal_type, produces the JSON event
17//!    the channel will ship. Encoded once, shared by all channels.
18//!
19//! 2. [`resolve_run_id`] / [`resolve_subject`] — string-template
20//!    substitution for `{{run_id}}` in NATS subjects + event labels.
21//!    Single source of truth so the worker, the reconciler, and any
22//!    downstream cohort-correlation logic agree on what the run id
23//!    means.
24//!
25//! 3. [`compose_export_receipt`] — builds a typed `ReceiptEnvelope`
26//!    of the export action itself, with the three BLAKE3 pillars
27//!    derived from the ExportSpec (intent), the shipped payload
28//!    bytes (artifact), and the outcome (control). The receipt
29//!    chains into the Process's attestation tree, so the act of
30//!    exporting is itself attested.
31//!
32//! The I/O glue in `main.rs` is thin — argv → ExportSpec → call
33//! these functions → ship to channel → write receipt.
34
35use std::collections::BTreeMap;
36
37use chrono::{DateTime, Utc};
38use serde::{Deserialize, Serialize};
39
40use tatara_process::export::{
41    ArtifactVariant, ExportSpec, NatsSubjectChannel, ReportFormat, ReportPayloadShape,
42    RunMarkerSource,
43};
44use tatara_process::receipt::ReceiptEnvelope;
45
46// ─── Run id resolution ─────────────────────────────────────────────
47
48/// Resolve the run id used in event labels + subject templates.
49///
50/// Precedence:
51/// 1. `spec.experiment_id_override` when set
52/// 2. `{process_namespace}/{process_name}` otherwise
53///
54/// Single source of truth so every channel (HTTP, NATS, stdout) and
55/// every downstream consumer (shinryu cohort math, Vector
56/// transforms) agree on what "run id" means for a given export.
57pub fn resolve_run_id(spec: &ExportSpec, namespace: &str, name: &str) -> String {
58    if let Some(o) = &spec.experiment_id_override {
59        if !o.is_empty() {
60            return o.clone();
61        }
62    }
63    format!("{namespace}/{name}")
64}
65
66/// Substitute `{{run_id}}` placeholders in a NATS subject template.
67///
68/// The chart's subject template (e.g.
69/// `pleme.pleme-dev.ephemeral.{{run_id}}.receipt`) gets expanded
70/// once, here, before the NATS publish call.
71pub fn resolve_subject(channel: &NatsSubjectChannel, run_id: &str) -> String {
72    channel.subject.replace("{{run_id}}", run_id)
73}
74
75// ─── Event payload preparation ─────────────────────────────────────
76
77/// JSON event shape shipped through every `VectorChannel`. Stable
78/// schema — shinryu's analytical SQL plane reads from it directly.
79#[derive(Clone, Debug, Serialize, Deserialize)]
80pub struct ExportEvent {
81    /// One of "receipt" / "test-report" / "process-snapshot" /
82    /// "run-marker". Carried both in the body (for downstream SQL)
83    /// and in the channel metadata (HttpEventChannel.signal_type or
84    /// NATS subject path).
85    pub signal_type: String,
86
87    /// Resolved run id — `{process_namespace}/{process_name}` by
88    /// default, or `spec.experiment_id_override` when set.
89    pub run_id: String,
90
91    /// Timestamp the export was prepared (RFC 3339 UTC).
92    pub timestamp: DateTime<Utc>,
93
94    /// Artifact-source-specific labels — empty for Receipts /
95    /// ProcessSnapshot, ConfigMap reference for TestReport, free-form
96    /// for RunMarker.
97    pub labels: BTreeMap<String, String>,
98
99    /// The shipped artifact bytes, embedded as the `payload` field.
100    /// For JSON sources (receipts, snapshots) this is a JSON Value;
101    /// for opaque bytes (TestReport with format=Raw) it's a base64
102    /// string under the `raw` key.
103    pub payload: serde_json::Value,
104
105    /// Format hint copied from `TestReportSource.format` when
106    /// applicable. Lets downstream parsers branch by JUnit / TAP /
107    /// NDJSON / Raw without inspecting the bytes.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub format: Option<ReportFormat>,
110}
111
112/// Build the JSON event the channel ships.
113///
114/// `artifact_bytes` is the raw artifact (ConfigMap value, snapshot
115/// JSON, or empty for run-markers). The function dispatches on
116/// `source` to embed the bytes the right way:
117///
118/// - **Receipts** — `artifact_bytes` is a JSON array of receipt
119///   envelopes; embedded under `payload.receipts`.
120/// - **TestReport** — `artifact_bytes` is the report file's bytes;
121///   embedded as either a parsed JSON value (when `format=NdJson`)
122///   or a base64 string (every other format).
123/// - **ProcessSnapshot** — `artifact_bytes` is the Process JSON;
124///   embedded under `payload.snapshot`.
125/// - **RunMarker** — `artifact_bytes` is ignored; labels come from
126///   `RunMarkerSource.labels`.
127pub fn prepare_event_payload(
128    source: ArtifactVariant<'_>,
129    artifact_bytes: &[u8],
130    run_id: &str,
131    signal_type: &str,
132    now: DateTime<Utc>,
133) -> ExportEvent {
134    let mut labels = BTreeMap::new();
135    labels.insert("run_id".into(), run_id.to_string());
136
137    let (payload, format) = match source {
138        ArtifactVariant::Receipts(_) => {
139            // Receipts are JSON; the worker pre-parses them into an array.
140            let parsed: serde_json::Value =
141                serde_json::from_slice(artifact_bytes).unwrap_or(serde_json::Value::Array(vec![]));
142            (serde_json::json!({ "receipts": parsed }), None)
143        }
144        ArtifactVariant::TestReport(tr) => {
145            labels.insert("configmap".into(), tr.configmap.clone());
146            labels.insert("key".into(), tr.key.clone());
147            // Closed-set dispatch via `ReportFormat::payload_shape` — the
148            // 2-arm match over `ReportPayloadShape` is exhaustive, so
149            // adding a future `ReportFormat` variant lands at one
150            // `payload_shape` arm in tatara-process and never touches
151            // the worker. Replaces the prior `_ => base64` silent
152            // default that quietly swallowed new variants.
153            let p = match tr.format.payload_shape() {
154                ReportPayloadShape::NdJsonLines => {
155                    let lines: Vec<serde_json::Value> = artifact_bytes
156                        .split(|b| *b == b'\n')
157                        .filter(|l| !l.is_empty())
158                        .filter_map(|l| serde_json::from_slice(l).ok())
159                        .collect();
160                    serde_json::json!({ "ndjson": lines })
161                }
162                ReportPayloadShape::OpaqueBytes => {
163                    use base64_inline as base64;
164                    serde_json::json!({ "raw_b64": base64::encode(artifact_bytes) })
165                }
166            };
167            (p, Some(tr.format))
168        }
169        ArtifactVariant::ProcessSnapshot(_) => {
170            let parsed: serde_json::Value =
171                serde_json::from_slice(artifact_bytes).unwrap_or(serde_json::Value::Null);
172            (serde_json::json!({ "snapshot": parsed }), None)
173        }
174        ArtifactVariant::RunMarker(rm) => {
175            merge_labels(&mut labels, &rm.labels);
176            (serde_json::Value::Null, None)
177        }
178    };
179
180    ExportEvent {
181        signal_type: signal_type.to_string(),
182        run_id: run_id.to_string(),
183        timestamp: now,
184        labels,
185        payload,
186        format,
187    }
188}
189
190fn merge_labels(into: &mut BTreeMap<String, String>, from: &BTreeMap<String, String>) {
191    for (k, v) in from {
192        into.insert(k.clone(), v.clone());
193    }
194}
195
196/// Convenience for the worker — calls the right run marker
197/// preparation when no artifact bytes exist (e.g. start/end markers
198/// the worker synthesizes itself).
199pub fn run_marker_event(
200    rm: &RunMarkerSource,
201    run_id: &str,
202    signal_type: &str,
203    now: DateTime<Utc>,
204) -> ExportEvent {
205    prepare_event_payload(
206        ArtifactVariant::RunMarker(rm),
207        &[],
208        run_id,
209        signal_type,
210        now,
211    )
212}
213
214// ─── Outcome + receipt composition ─────────────────────────────────
215
216/// Final state of the export action — feeds the `control_hash`
217/// pillar of the typed receipt.
218#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub enum ExportOutcome {
221    /// The shipment succeeded — the destination acknowledged
222    /// (HTTP 2xx, NATS publish ack, stdout written).
223    Shipped,
224    /// The destination explicitly rejected the shipment (HTTP 4xx,
225    /// NATS no-stream-match). Worker emits a receipt of the failure;
226    /// the Process advances to Zombie via Releasing.
227    Rejected(String),
228    /// The shipment timed out / connection refused / network error.
229    /// Same Zombie path; the receipt records the error type.
230    Failed(String),
231}
232
233impl ExportOutcome {
234    /// One short token used in the receipt's `kind` field.
235    pub fn kind(&self) -> &'static str {
236        match self {
237            Self::Shipped => "Shipped",
238            Self::Rejected(_) => "Rejected",
239            Self::Failed(_) => "Failed",
240        }
241    }
242
243    /// True iff the outcome is a successful shipment.
244    pub fn is_shipped(&self) -> bool {
245        matches!(self, Self::Shipped)
246    }
247}
248
249/// Build a typed `ReceiptEnvelope` of the export action via the
250/// existing `ReceiptEnvelope::build()` constructor (single source of
251/// truth for the three-pillar composition + BLAKE3 root).
252///
253/// Three BLAKE3 pillars (each fed to `build()` as a hex string):
254/// - **intent_hash**  ← canonical JSON of the `ExportSpec`
255/// - **artifact_hash** ← the shipped event bytes (post-`prepare_event_payload`)
256/// - **control_hash**  ← canonical JSON of the `ExportOutcome`
257///
258/// Returned envelope has `kind = "tatara.export"`, `process_ref`
259/// stamped as `{namespace}/{name}`, and structured `evidence`
260/// carrying the run id, outcome kind, and any error string. The
261/// composed root + version + generated_at are set by `build()`.
262///
263/// tatara-reconciler's `JobAttested` evaluator reads this envelope
264/// from the worker's ConfigMap and verifies the root before
265/// advancing the Process out of `Releasing`.
266pub fn compose_export_receipt(
267    spec: &ExportSpec,
268    shipped_event_bytes: &[u8],
269    outcome: &ExportOutcome,
270    previous_root: Option<&str>,
271    run_id: &str,
272    process_ref: Option<&str>,
273) -> anyhow::Result<ReceiptEnvelope> {
274    let intent_hash = hex_blake3(&canonical_json(spec)?);
275    let artifact_hash = hex_blake3(shipped_event_bytes);
276    let control_hash = hex_blake3(&canonical_json(outcome)?);
277
278    let mut env = ReceiptEnvelope::build(
279        "tatara.export",
280        intent_hash,
281        artifact_hash,
282        control_hash,
283        previous_root,
284    );
285    env.process_ref = process_ref.map(String::from);
286
287    let mut evidence = serde_json::Map::new();
288    evidence.insert(
289        "run_id".into(),
290        serde_json::Value::String(run_id.to_string()),
291    );
292    evidence.insert(
293        "outcome".into(),
294        serde_json::Value::String(outcome.kind().to_string()),
295    );
296    if let ExportOutcome::Rejected(m) | ExportOutcome::Failed(m) = outcome {
297        evidence.insert("error".into(), serde_json::Value::String(m.clone()));
298    }
299    evidence.insert(
300        "shipped_bytes_len".into(),
301        serde_json::Value::Number(shipped_event_bytes.len().into()),
302    );
303    env.evidence = serde_json::Value::Object(evidence);
304
305    Ok(env)
306}
307
308fn canonical_json<T: Serialize>(value: &T) -> anyhow::Result<Vec<u8>> {
309    // Canonical = serde_json through to_value then to_vec. Stable
310    // across runs of the same struct because serde_json::Value
311    // preserves field-emission order from the source serializer's
312    // declaration order (Rust struct field order).
313    let v = serde_json::to_value(value)?;
314    Ok(serde_json::to_vec(&v)?)
315}
316
317fn hex_blake3(bytes: &[u8]) -> String {
318    blake3::hash(bytes).to_hex().to_string()
319}
320
321// ─── Minimal inline base64 (no extra dep) ──────────────────────────
322
323mod base64_inline {
324    const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
325    pub fn encode(input: &[u8]) -> String {
326        let mut out = String::with_capacity((input.len() + 2) / 3 * 4);
327        let mut chunks = input.chunks_exact(3);
328        for chunk in chunks.by_ref() {
329            let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | (chunk[2] as u32);
330            out.push(ALPHA[((n >> 18) & 0x3F) as usize] as char);
331            out.push(ALPHA[((n >> 12) & 0x3F) as usize] as char);
332            out.push(ALPHA[((n >> 6) & 0x3F) as usize] as char);
333            out.push(ALPHA[(n & 0x3F) as usize] as char);
334        }
335        let rem = chunks.remainder();
336        match rem.len() {
337            1 => {
338                let n = (rem[0] as u32) << 16;
339                out.push(ALPHA[((n >> 18) & 0x3F) as usize] as char);
340                out.push(ALPHA[((n >> 12) & 0x3F) as usize] as char);
341                out.push('=');
342                out.push('=');
343            }
344            2 => {
345                let n = ((rem[0] as u32) << 16) | ((rem[1] as u32) << 8);
346                out.push(ALPHA[((n >> 18) & 0x3F) as usize] as char);
347                out.push(ALPHA[((n >> 12) & 0x3F) as usize] as char);
348                out.push(ALPHA[((n >> 6) & 0x3F) as usize] as char);
349                out.push('=');
350            }
351            _ => {}
352        }
353        out
354    }
355}
356
357// ─── Tests ─────────────────────────────────────────────────────────
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use tatara_process::export::{
363        ArtifactSource, HttpEventChannel, ProcessSnapshotSource, ReceiptsSource, RunMarkerSource,
364        TestReportSource, VectorChannel,
365    };
366
367    fn http_spec(signal_type: &str) -> ExportSpec {
368        ExportSpec {
369            source: ArtifactSource {
370                run_marker: Some(RunMarkerSource::default()),
371                ..ArtifactSource::default()
372            },
373            channel: VectorChannel {
374                http_event: Some(HttpEventChannel {
375                    endpoint: None,
376                    signal_type: signal_type.to_string(),
377                }),
378                ..VectorChannel::default()
379            },
380            when: Default::default(),
381            experiment_id_override: None,
382        }
383    }
384
385    #[test]
386    fn run_id_falls_back_to_ns_slash_name() {
387        let s = http_spec("x");
388        assert_eq!(resolve_run_id(&s, "akeyless-test", "r1"), "akeyless-test/r1");
389    }
390
391    #[test]
392    fn run_id_uses_override_when_set() {
393        let mut s = http_spec("x");
394        s.experiment_id_override = Some("akeyless-run-2026-05-20".into());
395        assert_eq!(resolve_run_id(&s, "ns", "n"), "akeyless-run-2026-05-20");
396    }
397
398    #[test]
399    fn run_id_ignores_empty_override() {
400        let mut s = http_spec("x");
401        s.experiment_id_override = Some(String::new());
402        assert_eq!(resolve_run_id(&s, "ns", "n"), "ns/n");
403    }
404
405    #[test]
406    fn subject_substitutes_run_id_template() {
407        let ch = NatsSubjectChannel {
408            subject: "pleme.pleme-dev.ephemeral.{{run_id}}.receipt".into(),
409            stream: "EPHEMERAL_RECEIPTS".into(),
410            url: None,
411        };
412        assert_eq!(
413            resolve_subject(&ch, "ns/n"),
414            "pleme.pleme-dev.ephemeral.ns/n.receipt"
415        );
416    }
417
418    #[test]
419    fn subject_passthrough_when_no_template() {
420        let ch = NatsSubjectChannel {
421            subject: "pleme.fixed.subject".into(),
422            stream: "S".into(),
423            url: None,
424        };
425        assert_eq!(resolve_subject(&ch, "ignored"), "pleme.fixed.subject");
426    }
427
428    #[test]
429    fn run_marker_event_has_labels_and_run_id() {
430        let mut labels = BTreeMap::new();
431        labels.insert("phase".into(), "end".into());
432        let rm = RunMarkerSource { labels };
433        let now = chrono::Utc::now();
434        let ev = run_marker_event(&rm, "ns/n", "ephemeral-marker", now);
435        assert_eq!(ev.signal_type, "ephemeral-marker");
436        assert_eq!(ev.run_id, "ns/n");
437        assert_eq!(ev.labels["phase"], "end");
438        assert_eq!(ev.labels["run_id"], "ns/n");
439        assert_eq!(ev.payload, serde_json::Value::Null);
440    }
441
442    #[test]
443    fn test_report_ndjson_parses_into_array() {
444        let tr = TestReportSource {
445            configmap: "cm".into(),
446            key: "out.ndjson".into(),
447            format: ReportFormat::NdJson,
448            namespace: None,
449        };
450        let bytes = b"{\"a\":1}\n{\"b\":2}\n\n{\"c\":3}\n";
451        let now = chrono::Utc::now();
452        let ev = prepare_event_payload(
453            ArtifactVariant::TestReport(&tr),
454            bytes,
455            "ns/n",
456            "test-report",
457            now,
458        );
459        let arr = ev.payload["ndjson"].as_array().unwrap();
460        assert_eq!(arr.len(), 3);
461        assert_eq!(arr[0]["a"], 1);
462        assert_eq!(arr[2]["c"], 3);
463        assert_eq!(ev.labels["configmap"], "cm");
464        assert_eq!(ev.format, Some(ReportFormat::NdJson));
465    }
466
467    #[test]
468    fn test_report_raw_format_base64_encodes() {
469        let tr = TestReportSource {
470            configmap: "cm".into(),
471            key: "report.bin".into(),
472            format: ReportFormat::Raw,
473            namespace: None,
474        };
475        let bytes = b"<<binary>>";
476        let now = chrono::Utc::now();
477        let ev = prepare_event_payload(
478            ArtifactVariant::TestReport(&tr),
479            bytes,
480            "ns/n",
481            "test-report",
482            now,
483        );
484        let b64 = ev.payload["raw_b64"].as_str().unwrap();
485        // sanity — base64 length is ceil(N/3)*4
486        assert_eq!(b64.len(), ((bytes.len() + 2) / 3) * 4);
487        assert_eq!(ev.format, Some(ReportFormat::Raw));
488    }
489
490    #[test]
491    fn receipts_source_embeds_parsed_json() {
492        let r = ReceiptsSource::default();
493        let raw = serde_json::to_vec(&serde_json::json!([
494            { "kind": "tatara.processed.run", "composed_root": "abc" },
495            { "kind": "tatara.processed.run", "composed_root": "def" },
496        ]))
497        .unwrap();
498        let now = chrono::Utc::now();
499        let ev = prepare_event_payload(
500            ArtifactVariant::Receipts(&r),
501            &raw,
502            "ns/n",
503            "receipt",
504            now,
505        );
506        let arr = ev.payload["receipts"].as_array().unwrap();
507        assert_eq!(arr.len(), 2);
508        assert_eq!(arr[1]["composed_root"], "def");
509    }
510
511    #[test]
512    fn process_snapshot_embeds_parsed_json() {
513        let p = ProcessSnapshotSource::default();
514        let raw = serde_json::to_vec(&serde_json::json!({ "phase": "Attested" })).unwrap();
515        let now = chrono::Utc::now();
516        let ev = prepare_event_payload(
517            ArtifactVariant::ProcessSnapshot(&p),
518            &raw,
519            "ns/n",
520            "process-snapshot",
521            now,
522        );
523        assert_eq!(ev.payload["snapshot"]["phase"], "Attested");
524    }
525
526    // ─── Receipt composition ───────────────────────────────────────
527
528    #[test]
529    fn outcome_kind_is_stable() {
530        assert_eq!(ExportOutcome::Shipped.kind(), "Shipped");
531        assert_eq!(ExportOutcome::Rejected("x".into()).kind(), "Rejected");
532        assert_eq!(ExportOutcome::Failed("y".into()).kind(), "Failed");
533    }
534
535    #[test]
536    fn export_receipt_chains_three_pillars() {
537        use tatara_process::receipt::RECEIPT_VERSION;
538        let s = http_spec("test-report");
539        let event_bytes = b"{\"signalType\":\"test-report\"}";
540        let r = compose_export_receipt(
541            &s,
542            event_bytes,
543            &ExportOutcome::Shipped,
544            None,
545            "ns/n",
546            Some("akeyless-test/r1"),
547        )
548        .expect("receipt");
549        assert_eq!(r.version, RECEIPT_VERSION);
550        assert_eq!(r.kind, "tatara.export");
551        // Each pillar is a 64-char BLAKE3 hex digest.
552        assert_eq!(r.intent_hash.len(), 64);
553        assert_eq!(r.artifact_hash.len(), 64);
554        assert_eq!(r.control_hash.len(), 64);
555        assert_eq!(r.composed_root.len(), 64);
556        // Process ref + evidence stamped through.
557        assert_eq!(r.process_ref.as_deref(), Some("akeyless-test/r1"));
558        assert_eq!(r.evidence["run_id"], "ns/n");
559        assert_eq!(r.evidence["outcome"], "Shipped");
560        // verify_root() agrees the composed_root was built correctly
561        // — same guarantee tatara-reconciler's evaluator checks.
562        assert!(r.verify_root(None));
563    }
564
565    #[test]
566    fn export_receipt_chains_prev_root() {
567        let s = http_spec("test-report");
568        let ev = b"x";
569        let r1 =
570            compose_export_receipt(&s, ev, &ExportOutcome::Shipped, None, "r", None).unwrap();
571        let r2 = compose_export_receipt(
572            &s,
573            ev,
574            &ExportOutcome::Shipped,
575            Some(&r1.composed_root),
576            "r",
577            None,
578        )
579        .unwrap();
580        // Same inputs but chained prev_root → different composed_root.
581        assert_ne!(r1.composed_root, r2.composed_root);
582        // verify_root checks the chain.
583        assert!(r2.verify_root(Some(&r1.composed_root)));
584    }
585
586    #[test]
587    fn export_receipt_failure_carries_error_text() {
588        let s = http_spec("x");
589        let r = compose_export_receipt(
590            &s,
591            b"",
592            &ExportOutcome::Failed("connection refused".into()),
593            None,
594            "ns/n",
595            None,
596        )
597        .unwrap();
598        assert_eq!(r.evidence["error"], "connection refused");
599        assert_eq!(r.evidence["outcome"], "Failed");
600    }
601
602    #[test]
603    fn export_receipt_intent_hash_changes_with_spec() {
604        let s1 = http_spec("a");
605        let s2 = http_spec("b"); // different signal_type → different intent
606        let r1 =
607            compose_export_receipt(&s1, b"", &ExportOutcome::Shipped, None, "ns/n", None).unwrap();
608        let r2 =
609            compose_export_receipt(&s2, b"", &ExportOutcome::Shipped, None, "ns/n", None).unwrap();
610        assert_ne!(r1.intent_hash, r2.intent_hash);
611    }
612
613    #[test]
614    fn export_receipt_artifact_hash_changes_with_payload() {
615        let s = http_spec("x");
616        let r1 =
617            compose_export_receipt(&s, b"payload-1", &ExportOutcome::Shipped, None, "ns/n", None)
618                .unwrap();
619        let r2 =
620            compose_export_receipt(&s, b"payload-2", &ExportOutcome::Shipped, None, "ns/n", None)
621                .unwrap();
622        assert_ne!(r1.artifact_hash, r2.artifact_hash);
623    }
624}