1use 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
46pub 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
74pub fn resolve_subject(channel: &NatsSubjectChannel, run_id: &str) -> String {
80 channel.subject.replace("{{run_id}}", run_id)
81}
82
83#[derive(Clone, Debug, Serialize, Deserialize)]
88pub struct ExportEvent {
89 pub signal_type: String,
94
95 pub run_id: String,
98
99 pub timestamp: DateTime<Utc>,
101
102 pub labels: BTreeMap<String, String>,
106
107 pub payload: serde_json::Value,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
117 pub format: Option<ReportFormat>,
118}
119
120pub 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 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 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
204pub 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "camelCase")]
228pub enum ExportOutcome {
229 Shipped,
232 Rejected(String),
236 Failed(String),
239}
240
241impl ExportOutcome {
242 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 pub fn is_shipped(&self) -> bool {
253 matches!(self, Self::Shipped)
254 }
255}
256
257pub 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 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
333mod 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#[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 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 #[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 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 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 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 assert_ne!(r1.composed_root, r2.composed_root);
579 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"); 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}