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