Skip to main content

harn_vm/orchestration/
lifecycle_receipts.rs

1//! Replay-deterministic lifecycle receipts (#1861, epic #1853 P-08).
2//!
3//! Suspend / resume / drain are journal entries. Per SOTA replay-engine
4//! research (Temporal, Restate, Inngest, Azure Durable, Cadence), on
5//! replay these lifecycle events must return memoized values rather than
6//! re-execute. This module is the source-of-truth for the persisted
7//! receipt shapes and the helpers used by `replay_oracle.rs` and the
8//! conformance suite to assert determinism end-to-end.
9//!
10//! Design notes:
11//!
12//! * **Signed timestamps.** Every receipt carries a [`SignedLifecycleTimestamp`]
13//!   that pins `at_ms` to the recorded wall time at the original run. The
14//!   signature is HMAC-SHA256 over `(kind, at_ms, subject_id, initiator_id)`
15//!   under a per-process salt. Replay must use the journal timestamp, not
16//!   the current wall clock — `verify_signed_timestamp` lets the oracle
17//!   prove the on-disk timestamp came from the original run before
18//!   accepting it as ground truth.
19//! * **Cached resume inputs.** [`ResumptionReceipt`] stores both `input`
20//!   and `input_hash`. On replay, the runtime feeds the cached `input`
21//!   back into the suspended worker instead of re-prompting and asserts
22//!   that any fresh input it would otherwise compute matches
23//!   `input_hash`. Mismatch surfaces `HARN-SUS-011`.
24//! * **Privacy.** The full `input` value is journaled (so replay is
25//!   self-contained), but [`record_resumption_receipt`] applies the
26//!   configured redactor before persisting. Callers that hand off a
27//!   secret-bearing payload pass a `RedactionPolicy` that nulls the
28//!   sensitive paths; the hash is still computed against the *original*
29//!   payload so determinism checks survive redaction.
30//! * **Memoized drain decisions.** [`DrainDecisionReceipt`] captures the
31//!   settlement agent's chosen `action` per drain item. On replay, the
32//!   oracle reads the receipt instead of re-spawning the settlement
33//!   agent's LLM call — changing the prompt invalidates the replay
34//!   precisely because the recorded hash no longer matches.
35//!
36//! The module is intentionally JSON-shaped at the boundary so the
37//! existing `run_record_*` and event-log persistence works without
38//! schema changes. The replay oracle reads back the topic-scoped journal
39//! entries and feeds them into [`ReplayTraceRun::approval_interactions`]
40//! / [`lifecycle_audit_log`].
41
42use std::cell::RefCell;
43use std::collections::BTreeMap;
44use std::sync::OnceLock;
45
46use serde::{Deserialize, Serialize};
47use serde_json::Value as JsonValue;
48use sha2::{Digest, Sha256};
49use time::format_description::well_known::Rfc3339;
50
51use crate::event_log::{active_event_log, EventLog, LogEvent, Topic};
52
53/// Event-log topic that carries the persisted lifecycle receipts. One
54/// topic for all three shapes keeps the replay oracle's cursor logic
55/// simple — kind discriminates suspension / resumption / drain.
56pub const LIFECYCLE_RECEIPT_TOPIC: &str = "agent.lifecycle.receipts";
57
58pub const SUSPENSION_RECEIPT_KIND: &str = "suspension_receipt";
59pub const RESUMPTION_RECEIPT_KIND: &str = "resumption_receipt";
60pub const DRAIN_DECISION_RECEIPT_KIND: &str = "drain_decision_receipt";
61
62pub const SIGNED_TIMESTAMP_ALGORITHM: &str = "hmac-sha256";
63pub const SIGNED_TIMESTAMP_KEY_ID: &str = "local-session";
64
65/// Per-process signing salt for lifecycle timestamps. Mirrors the
66/// channels-module pattern (`SIGNING_SALT` in `channels.rs`): a fresh
67/// salt per process so signatures cannot replay across runs, but stable
68/// for the duration of one run so the in-process replay oracle can
69/// verify what it recorded a moment earlier.
70static LIFECYCLE_SIGNING_SALT: OnceLock<Vec<u8>> = OnceLock::new();
71
72fn lifecycle_signing_salt() -> &'static [u8] {
73    LIFECYCLE_SIGNING_SALT
74        .get_or_init(|| {
75            format!(
76                "harn-lifecycle-signing-salt:{}:{}",
77                std::process::id(),
78                uuid::Uuid::now_v7()
79            )
80            .into_bytes()
81        })
82        .as_slice()
83}
84
85/// Signed wall-clock timestamp carried on every lifecycle receipt.
86///
87/// `at_ms` is the canonical replay-time value; the human-readable RFC3339
88/// `at` mirror exists for log inspection only. `signature` proves the
89/// `(kind, at_ms, subject_id, initiator_id)` tuple was produced by this
90/// process — corruption of any of those fields after the fact will fail
91/// [`verify_signed_timestamp`] and the replay oracle will reject the
92/// receipt.
93#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
94pub struct SignedLifecycleTimestamp {
95    pub at_ms: i64,
96    pub at: String,
97    pub algorithm: String,
98    pub key_id: String,
99    pub signature: String,
100}
101
102impl SignedLifecycleTimestamp {
103    /// Build a fresh signed timestamp pinned to the current mock-aware
104    /// wall clock. The signature material binds the four
105    /// receipt-identifying fields so a journal entry that gets its
106    /// `at_ms` rewritten on disk fails verification.
107    pub fn now_for(kind: &str, subject_id: &str, initiator_id: &str) -> Self {
108        let at = crate::clock_mock::now_utc();
109        let at_ms = harn_clock::offset_datetime_to_ms(at);
110        let at_text = at.format(&Rfc3339).unwrap_or_else(|_| at.to_string());
111        let signature = sign_timestamp_material(kind, at_ms, subject_id, initiator_id);
112        Self {
113            at_ms,
114            at: at_text,
115            algorithm: SIGNED_TIMESTAMP_ALGORITHM.to_string(),
116            key_id: SIGNED_TIMESTAMP_KEY_ID.to_string(),
117            signature,
118        }
119    }
120}
121
122fn sign_timestamp_material(kind: &str, at_ms: i64, subject_id: &str, initiator_id: &str) -> String {
123    let material = format!(
124        "harn.lifecycle.timestamp.v1\nkind={kind}\nat_ms={at_ms}\nsubject={subject_id}\ninitiator={initiator_id}\n"
125    );
126    let mac = crate::connectors::hmac::hmac_sha256(lifecycle_signing_salt(), material.as_bytes());
127    format!("sha256:{}", hex::encode(mac))
128}
129
130/// Verify that a [`SignedLifecycleTimestamp`] was minted by this process
131/// for the given `(kind, subject_id, initiator_id)` tuple. Returns `Err`
132/// if the algorithm/key changed (forward compatibility), the signature
133/// is malformed, or the recomputed material disagrees with the stored
134/// signature. Used by the replay oracle before it accepts an on-disk
135/// receipt as ground truth.
136pub fn verify_signed_timestamp(
137    stamp: &SignedLifecycleTimestamp,
138    kind: &str,
139    subject_id: &str,
140    initiator_id: &str,
141) -> Result<(), LifecycleReceiptError> {
142    if stamp.algorithm != SIGNED_TIMESTAMP_ALGORITHM {
143        return Err(LifecycleReceiptError::SignatureAlgorithmMismatch {
144            expected: SIGNED_TIMESTAMP_ALGORITHM.to_string(),
145            found: stamp.algorithm.clone(),
146        });
147    }
148    if stamp.key_id != SIGNED_TIMESTAMP_KEY_ID {
149        return Err(LifecycleReceiptError::SignatureKeyMismatch {
150            expected: SIGNED_TIMESTAMP_KEY_ID.to_string(),
151            found: stamp.key_id.clone(),
152        });
153    }
154    let expected = sign_timestamp_material(kind, stamp.at_ms, subject_id, initiator_id);
155    if expected != stamp.signature {
156        return Err(LifecycleReceiptError::SignatureMismatch {
157            expected,
158            found: stamp.signature.clone(),
159        });
160    }
161    Ok(())
162}
163
164/// Origin of a recorded suspension. Mirrors
165/// `WorkerSuspension::initiator` and the `Suspension`-shape transcript
166/// constructor so the on-disk shape is unified across the lifecycle stack.
167#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
168#[serde(rename_all = "snake_case")]
169pub enum SuspendInitiator {
170    #[serde(rename = "self")]
171    SelfInitiated,
172    Parent,
173    #[default]
174    Operator,
175    Triggered,
176}
177
178impl SuspendInitiator {
179    pub fn as_str(self) -> &'static str {
180        match self {
181            Self::SelfInitiated => "self",
182            Self::Parent => "parent",
183            Self::Operator => "operator",
184            Self::Triggered => "triggered",
185        }
186    }
187}
188
189/// Origin of a recorded resumption. Aligned with
190/// `helpers::transcript::ResumptionInitiator`.
191#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
192#[serde(rename_all = "snake_case")]
193pub enum ResumeInitiator {
194    #[default]
195    Parent,
196    Operator,
197    Triggered,
198    DrainAgent,
199    Timeout,
200}
201
202impl ResumeInitiator {
203    pub fn as_str(self) -> &'static str {
204        match self {
205            Self::Parent => "parent",
206            Self::Operator => "operator",
207            Self::Triggered => "triggered",
208            Self::DrainAgent => "drain_agent",
209            Self::Timeout => "timeout",
210        }
211    }
212
213    pub fn parse(value: &str) -> Self {
214        match value.trim() {
215            "operator" => Self::Operator,
216            "triggered" | "trigger" => Self::Triggered,
217            "drain_agent" | "drain-agent" | "settlement" => Self::DrainAgent,
218            "timeout" => Self::Timeout,
219            _ => Self::Parent,
220        }
221    }
222}
223
224/// What an auto-resume trigger matched against. Carried on a
225/// [`ResumptionReceipt`] when a trigger drove the resume so replay can
226/// reproduce the exact match without re-firing the connector.
227#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
228pub struct TriggerMatchInfo {
229    pub source: String,
230    pub event_id: String,
231    pub filter_summary: String,
232}
233
234/// Drain decision categories. Mirrors
235/// `DrainDecisionItemCategory` in `helpers::transcript`.
236#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
237#[serde(rename_all = "snake_case")]
238pub enum DrainItemCategory {
239    #[default]
240    SuspendedSubagent,
241    QueuedTrigger,
242    PartialHandoff,
243    InFlightLlmCall,
244}
245
246#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
247pub struct DrainItem {
248    pub category: DrainItemCategory,
249    pub id: String,
250    pub summary: String,
251}
252
253/// Action the settlement agent chose for a drain item.
254#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
255#[serde(rename_all = "snake_case")]
256pub enum DrainAction {
257    #[default]
258    Resume,
259    Cancel,
260    Handoff,
261    Acknowledge,
262    Defer,
263    Wait,
264    Finalize,
265}
266
267/// Persisted suspension event. Recorded at the moment a worker enters
268/// the `suspended` state, replayed verbatim from the journal during a
269/// second run.
270#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
271pub struct SuspensionReceipt {
272    pub handle: String,
273    pub session_id: Option<String>,
274    pub initiator: SuspendInitiator,
275    pub initiator_id: String,
276    pub reason: String,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub conditions: Option<JsonValue>,
279    pub suspended_at: SignedLifecycleTimestamp,
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub span_id: Option<String>,
282}
283
284impl SuspensionReceipt {
285    /// Build a fresh receipt and sign its timestamp.
286    pub fn new(
287        handle: impl Into<String>,
288        session_id: Option<String>,
289        initiator: SuspendInitiator,
290        initiator_id: impl Into<String>,
291        reason: impl Into<String>,
292        conditions: Option<JsonValue>,
293        span_id: Option<String>,
294    ) -> Self {
295        let handle = handle.into();
296        let initiator_id = initiator_id.into();
297        let suspended_at =
298            SignedLifecycleTimestamp::now_for(SUSPENSION_RECEIPT_KIND, &handle, &initiator_id);
299        Self {
300            handle,
301            session_id,
302            initiator,
303            initiator_id,
304            reason: reason.into(),
305            conditions,
306            suspended_at,
307            span_id,
308        }
309    }
310
311    /// Verify the receipt's signed timestamp. Wraps
312    /// [`verify_signed_timestamp`] with the receipt-specific subject /
313    /// initiator binding.
314    pub fn verify_signature(&self) -> Result<(), LifecycleReceiptError> {
315        verify_signed_timestamp(
316            &self.suspended_at,
317            SUSPENSION_RECEIPT_KIND,
318            &self.handle,
319            &self.initiator_id,
320        )
321    }
322}
323
324/// Persisted resumption event. The `input` field is the cached resume
325/// input (post-redaction); `input_hash` is the deterministic
326/// fingerprint of the *unredacted* payload so replay can detect drift
327/// even when the journaled value was scrubbed.
328#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
329pub struct ResumptionReceipt {
330    pub handle: String,
331    pub session_id: Option<String>,
332    pub initiator: ResumeInitiator,
333    pub initiator_id: String,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub input: Option<JsonValue>,
336    pub input_hash: String,
337    pub continue_transcript: bool,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub linked_suspension_span_id: Option<String>,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub trigger_match: Option<TriggerMatchInfo>,
342    pub resumed_at: SignedLifecycleTimestamp,
343}
344
345impl ResumptionReceipt {
346    /// Build a fresh resumption receipt. The hash is computed against
347    /// `original_input` so post-redaction values still validate against
348    /// the unredacted fingerprint. `journaled_input` may be the
349    /// post-redaction value (or `None` if the caller does not want to
350    /// persist the payload at all).
351    #[allow(clippy::too_many_arguments)]
352    pub fn new(
353        handle: impl Into<String>,
354        session_id: Option<String>,
355        initiator: ResumeInitiator,
356        initiator_id: impl Into<String>,
357        original_input: Option<&JsonValue>,
358        journaled_input: Option<JsonValue>,
359        continue_transcript: bool,
360        linked_suspension_span_id: Option<String>,
361        trigger_match: Option<TriggerMatchInfo>,
362    ) -> Self {
363        let handle = handle.into();
364        let initiator_id = initiator_id.into();
365        let resumed_at =
366            SignedLifecycleTimestamp::now_for(RESUMPTION_RECEIPT_KIND, &handle, &initiator_id);
367        let input_hash = hash_resume_input(original_input);
368        Self {
369            handle,
370            session_id,
371            initiator,
372            initiator_id,
373            input: journaled_input,
374            input_hash,
375            continue_transcript,
376            linked_suspension_span_id,
377            trigger_match,
378            resumed_at,
379        }
380    }
381
382    pub fn verify_signature(&self) -> Result<(), LifecycleReceiptError> {
383        verify_signed_timestamp(
384            &self.resumed_at,
385            RESUMPTION_RECEIPT_KIND,
386            &self.handle,
387            &self.initiator_id,
388        )
389    }
390}
391
392/// Persisted drain decision. Recorded by the settlement agent so replay
393/// can short-circuit the LLM call that produced `action`.
394#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
395pub struct DrainDecisionReceipt {
396    pub pipeline_id: String,
397    pub item: DrainItem,
398    pub action: DrainAction,
399    pub reason: String,
400    pub decided_by: String,
401    pub decided_at: SignedLifecycleTimestamp,
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub prompt_hash: Option<String>,
404}
405
406impl DrainDecisionReceipt {
407    pub fn new(
408        pipeline_id: impl Into<String>,
409        item: DrainItem,
410        action: DrainAction,
411        reason: impl Into<String>,
412        decided_by: impl Into<String>,
413        prompt_hash: Option<String>,
414    ) -> Self {
415        let pipeline_id = pipeline_id.into();
416        let decided_by = decided_by.into();
417        let decided_at = SignedLifecycleTimestamp::now_for(
418            DRAIN_DECISION_RECEIPT_KIND,
419            &pipeline_id,
420            &decided_by,
421        );
422        Self {
423            pipeline_id,
424            item,
425            action,
426            reason: reason.into(),
427            decided_by,
428            decided_at,
429            prompt_hash,
430        }
431    }
432
433    pub fn verify_signature(&self) -> Result<(), LifecycleReceiptError> {
434        verify_signed_timestamp(
435            &self.decided_at,
436            DRAIN_DECISION_RECEIPT_KIND,
437            &self.pipeline_id,
438            &self.decided_by,
439        )
440    }
441}
442
443/// Errors surfaced when minting / verifying / replaying receipts.
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub enum LifecycleReceiptError {
446    SignatureAlgorithmMismatch {
447        expected: String,
448        found: String,
449    },
450    SignatureKeyMismatch {
451        expected: String,
452        found: String,
453    },
454    SignatureMismatch {
455        expected: String,
456        found: String,
457    },
458    ResumeInputHashMismatch {
459        handle: String,
460        expected_hash: String,
461        actual_hash: String,
462    },
463    DrainDecisionPromptHashMismatch {
464        item_id: String,
465        expected_hash: String,
466        actual_hash: String,
467    },
468    Persistence(String),
469}
470
471impl std::fmt::Display for LifecycleReceiptError {
472    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
473        match self {
474            Self::SignatureAlgorithmMismatch { expected, found } => write!(
475                f,
476                "HARN-SUS-013 lifecycle signature algorithm mismatch (expected {expected}, found {found})"
477            ),
478            Self::SignatureKeyMismatch { expected, found } => write!(
479                f,
480                "HARN-SUS-013 lifecycle signature key mismatch (expected {expected}, found {found})"
481            ),
482            Self::SignatureMismatch { expected, found } => write!(
483                f,
484                "HARN-SUS-013 lifecycle signature mismatch (expected {expected}, found {found})"
485            ),
486            Self::ResumeInputHashMismatch {
487                handle,
488                expected_hash,
489                actual_hash,
490            } => write!(
491                f,
492                "HARN-SUS-011 replay resume input hash mismatch for {handle} (expected {expected_hash}, got {actual_hash})"
493            ),
494            Self::DrainDecisionPromptHashMismatch {
495                item_id,
496                expected_hash,
497                actual_hash,
498            } => write!(
499                f,
500                "HARN-SUS-012 replay drain decision prompt hash mismatch for {item_id} (expected {expected_hash}, got {actual_hash})"
501            ),
502            Self::Persistence(message) => write!(f, "lifecycle receipt persistence: {message}"),
503        }
504    }
505}
506
507impl std::error::Error for LifecycleReceiptError {}
508
509/// Deterministic hash used by [`ResumptionReceipt::input_hash`] and
510/// the replay-oracle comparator. We hash canonical JSON so map-key order
511/// doesn't drift between record and replay.
512pub fn hash_resume_input(input: Option<&JsonValue>) -> String {
513    let canonical = crate::canonical_json::to_vec(input.unwrap_or(&JsonValue::Null));
514    let digest = Sha256::digest(&canonical);
515    format!("sha256:{}", hex::encode(digest))
516}
517
518/// Hash a settlement-agent prompt fingerprint. The settlement agent
519/// records this before its LLM call so replay can detect prompt drift
520/// without re-running the model.
521pub fn hash_drain_decision_prompt(prompt: &str) -> String {
522    let digest = Sha256::digest(prompt.as_bytes());
523    format!("sha256:{}", hex::encode(digest))
524}
525
526/// Lightweight policy for redacting persisted `ResumptionReceipt.input`
527/// before journaling. Each entry is a JSON-pointer path that gets
528/// replaced with a sentinel `{"$harn_redacted": "<reason>"}` object.
529/// The hash is always computed against the *unredacted* original payload
530/// so determinism survives redaction.
531#[derive(Clone, Debug, Default)]
532pub struct RedactionPolicy {
533    pub paths: Vec<RedactionPath>,
534}
535
536#[derive(Clone, Debug)]
537pub struct RedactionPath {
538    pub pointer: String,
539    pub reason: String,
540}
541
542impl RedactionPolicy {
543    pub fn redact(&self, value: &JsonValue) -> JsonValue {
544        let mut working = value.clone();
545        for rule in &self.paths {
546            apply_redaction(&mut working, &rule.pointer, &rule.reason);
547        }
548        working
549    }
550}
551
552fn apply_redaction(value: &mut JsonValue, pointer: &str, reason: &str) {
553    if pointer.is_empty() || pointer == "/" {
554        *value = serde_json::json!({"$harn_redacted": reason});
555        return;
556    }
557    let segments: Vec<&str> = pointer
558        .strip_prefix('/')
559        .unwrap_or(pointer)
560        .split('/')
561        .collect();
562    redact_segments(value, &segments, reason);
563}
564
565fn redact_segments(value: &mut JsonValue, segments: &[&str], reason: &str) {
566    if segments.is_empty() {
567        *value = serde_json::json!({"$harn_redacted": reason});
568        return;
569    }
570    let head = segments[0];
571    let tail = &segments[1..];
572    match value {
573        JsonValue::Object(map) => {
574            if let Some(child) = map.get_mut(head) {
575                redact_segments(child, tail, reason);
576            }
577        }
578        JsonValue::Array(items) => {
579            if let Ok(index) = head.parse::<usize>() {
580                if let Some(child) = items.get_mut(index) {
581                    redact_segments(child, tail, reason);
582                }
583            }
584        }
585        _ => {}
586    }
587}
588
589thread_local! {
590    static LIFECYCLE_RECEIPT_LOG: RefCell<Vec<LifecycleReceiptEntry>> = const { RefCell::new(Vec::new()) };
591    static LIFECYCLE_RECEIPT_SEQ: RefCell<u64> = const { RefCell::new(0) };
592}
593
594/// Reset the in-memory receipt registry. Called from `reset_thread_local_state`
595/// so test harnesses do not carry receipts between runs.
596pub fn reset_lifecycle_receipt_registry() {
597    LIFECYCLE_RECEIPT_LOG.with(|log| log.borrow_mut().clear());
598    LIFECYCLE_RECEIPT_SEQ.with(|seq| *seq.borrow_mut() = 0);
599}
600
601/// A receipt + its monotonic seq + its on-disk kind. The seq is stable
602/// across recordings within a single thread, which is what conformance
603/// fixtures need for deterministic byte-identical comparison.
604#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
605pub struct LifecycleReceiptEntry {
606    pub seq: u64,
607    pub kind: String,
608    pub payload: JsonValue,
609}
610
611impl LifecycleReceiptEntry {
612    pub fn to_json(&self) -> JsonValue {
613        serde_json::json!({
614            "seq": self.seq,
615            "kind": &self.kind,
616            "payload": &self.payload,
617        })
618    }
619}
620
621fn next_seq() -> u64 {
622    LIFECYCLE_RECEIPT_SEQ.with(|seq| {
623        let mut slot = seq.borrow_mut();
624        *slot += 1;
625        *slot
626    })
627}
628
629fn record_entry(kind: &str, payload: JsonValue) -> LifecycleReceiptEntry {
630    let entry = LifecycleReceiptEntry {
631        seq: next_seq(),
632        kind: kind.to_string(),
633        payload,
634    };
635    LIFECYCLE_RECEIPT_LOG.with(|log| log.borrow_mut().push(entry.clone()));
636    persist_entry(&entry);
637    entry
638}
639
640fn persist_entry(entry: &LifecycleReceiptEntry) {
641    let Some(log) = active_event_log() else {
642        return;
643    };
644    let Ok(topic) = Topic::new(LIFECYCLE_RECEIPT_TOPIC) else {
645        return;
646    };
647    let mut headers = BTreeMap::new();
648    headers.insert("kind".to_string(), entry.kind.clone());
649    headers.insert("seq".to_string(), entry.seq.to_string());
650    let event = LogEvent::new(entry.kind.clone(), entry.payload.clone()).with_headers(headers);
651    let _ = futures::executor::block_on(log.append(&topic, event));
652}
653
654/// Persist a suspension receipt and return the journaled entry.
655pub fn record_suspension_receipt(receipt: &SuspensionReceipt) -> LifecycleReceiptEntry {
656    let payload = serde_json::to_value(receipt).unwrap_or(JsonValue::Null);
657    record_entry(SUSPENSION_RECEIPT_KIND, payload)
658}
659
660/// Persist a resumption receipt and return the journaled entry.
661pub fn record_resumption_receipt(receipt: &ResumptionReceipt) -> LifecycleReceiptEntry {
662    let payload = serde_json::to_value(receipt).unwrap_or(JsonValue::Null);
663    record_entry(RESUMPTION_RECEIPT_KIND, payload)
664}
665
666/// Persist a drain decision receipt and return the journaled entry.
667pub fn record_drain_decision_receipt(receipt: &DrainDecisionReceipt) -> LifecycleReceiptEntry {
668    let payload = serde_json::to_value(receipt).unwrap_or(JsonValue::Null);
669    record_entry(DRAIN_DECISION_RECEIPT_KIND, payload)
670}
671
672/// Snapshot every recorded receipt in seq order. Used by the replay
673/// oracle (and conformance fixtures) to read back the journal.
674pub fn lifecycle_receipts_snapshot() -> Vec<LifecycleReceiptEntry> {
675    LIFECYCLE_RECEIPT_LOG.with(|log| log.borrow().clone())
676}
677
678/// Verify the [`ResumptionReceipt`] input hash matches a fresh
679/// candidate. Used during replay: the runtime computes the hash of the
680/// resume input it would otherwise apply and asks the receipt to
681/// confirm. Returns the cached input on success so the caller can feed
682/// it back into the suspended worker.
683pub fn replay_resume_input(
684    receipt: &ResumptionReceipt,
685    candidate_input: Option<&JsonValue>,
686) -> Result<Option<JsonValue>, LifecycleReceiptError> {
687    receipt.verify_signature()?;
688    let actual = hash_resume_input(candidate_input);
689    if actual != receipt.input_hash {
690        return Err(LifecycleReceiptError::ResumeInputHashMismatch {
691            handle: receipt.handle.clone(),
692            expected_hash: receipt.input_hash.clone(),
693            actual_hash: actual,
694        });
695    }
696    Ok(receipt.input.clone())
697}
698
699/// Verify the cached drain decision prompt matches the candidate
700/// settlement-agent prompt at replay time. Returns the recorded
701/// decision so the replay path can skip re-spawning the agent.
702pub fn replay_drain_decision(
703    receipt: &DrainDecisionReceipt,
704    candidate_prompt: Option<&str>,
705) -> Result<DrainAction, LifecycleReceiptError> {
706    receipt.verify_signature()?;
707    if let (Some(prompt), Some(expected)) = (candidate_prompt, receipt.prompt_hash.as_ref()) {
708        let actual = hash_drain_decision_prompt(prompt);
709        if &actual != expected {
710            return Err(LifecycleReceiptError::DrainDecisionPromptHashMismatch {
711                item_id: receipt.item.id.clone(),
712                expected_hash: expected.clone(),
713                actual_hash: actual,
714            });
715        }
716    }
717    Ok(receipt.action)
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use serde_json::json;
724
725    fn fresh() {
726        reset_lifecycle_receipt_registry();
727    }
728
729    #[test]
730    fn suspension_receipt_signs_and_verifies() {
731        fresh();
732        let receipt = SuspensionReceipt::new(
733            "worker://triage/42",
734            Some("session-1".to_string()),
735            SuspendInitiator::Operator,
736            "operator-1",
737            "waiting for human approval",
738            Some(json!({"kind": "approval"})),
739            Some("span-1".to_string()),
740        );
741        receipt
742            .verify_signature()
743            .expect("signed timestamp verifies");
744
745        let mut tampered = receipt;
746        tampered.suspended_at.at_ms += 1;
747        assert!(matches!(
748            tampered.verify_signature(),
749            Err(LifecycleReceiptError::SignatureMismatch { .. })
750        ));
751    }
752
753    #[test]
754    fn resumption_receipt_round_trips_input_hash() {
755        fresh();
756        let original = json!({"approved": true, "comment": "ship it"});
757        let receipt = ResumptionReceipt::new(
758            "worker://triage/42",
759            Some("session-1".to_string()),
760            ResumeInitiator::Operator,
761            "operator-1",
762            Some(&original),
763            Some(original.clone()),
764            true,
765            None,
766            None,
767        );
768
769        let cached = replay_resume_input(&receipt, Some(&original)).expect("matches");
770        assert_eq!(cached, Some(original));
771
772        let drift = json!({"approved": false, "comment": "ship it"});
773        let mismatch = replay_resume_input(&receipt, Some(&drift));
774        assert!(matches!(
775            mismatch,
776            Err(LifecycleReceiptError::ResumeInputHashMismatch { .. })
777        ));
778    }
779
780    #[test]
781    fn resumption_hash_is_canonical_across_map_key_order() {
782        let a = json!({"a": 1, "b": 2});
783        let b = json!({"b": 2, "a": 1});
784        assert_eq!(hash_resume_input(Some(&a)), hash_resume_input(Some(&b)));
785    }
786
787    #[test]
788    fn redaction_policy_preserves_hash() {
789        let original = json!({
790            "user": "alice",
791            "secret_token": "very-secret",
792            "approved": true,
793        });
794        let policy = RedactionPolicy {
795            paths: vec![RedactionPath {
796                pointer: "/secret_token".to_string(),
797                reason: "auth_token".to_string(),
798            }],
799        };
800        let redacted = policy.redact(&original);
801        assert_ne!(redacted, original);
802        assert_eq!(
803            redacted["secret_token"],
804            json!({"$harn_redacted": "auth_token"})
805        );
806        // Hash is computed against the original — replay still works.
807        let receipt = ResumptionReceipt::new(
808            "worker://x",
809            None,
810            ResumeInitiator::Operator,
811            "op-1",
812            Some(&original),
813            Some(redacted.clone()),
814            true,
815            None,
816            None,
817        );
818        let cached = replay_resume_input(&receipt, Some(&original)).expect("matches");
819        assert_eq!(cached, Some(redacted));
820    }
821
822    #[test]
823    fn drain_decision_receipt_memoizes_action() {
824        fresh();
825        let prompt = "settle this drain item";
826        let receipt = DrainDecisionReceipt::new(
827            "pipeline-1",
828            DrainItem {
829                category: DrainItemCategory::SuspendedSubagent,
830                id: "worker://triage/42".to_string(),
831                summary: "worker is suspended".to_string(),
832            },
833            DrainAction::Resume,
834            "settlement agent picked resume".to_string(),
835            "settlement-session-1",
836            Some(hash_drain_decision_prompt(prompt)),
837        );
838
839        let action = replay_drain_decision(&receipt, Some(prompt)).expect("matches");
840        assert_eq!(action, DrainAction::Resume);
841
842        let drift = replay_drain_decision(&receipt, Some("a different prompt"));
843        assert!(matches!(
844            drift,
845            Err(LifecycleReceiptError::DrainDecisionPromptHashMismatch { .. })
846        ));
847    }
848
849    #[test]
850    fn record_then_snapshot_byte_identical_across_runs() {
851        fresh();
852        let s = SuspensionReceipt::new(
853            "worker://x",
854            None,
855            SuspendInitiator::Operator,
856            "op-1",
857            "reason",
858            None,
859            None,
860        );
861        let entry_a = record_suspension_receipt(&s);
862        fresh();
863        let entry_b = record_suspension_receipt(&s);
864        // Both runs assign seq=1 after reset; payloads differ only by the
865        // resigned `at_ms` window. The deterministic *contract* is that a
866        // single recorded receipt round-trips byte-identical through the
867        // log — that's the case the replay oracle relies on.
868        assert_eq!(entry_a.seq, entry_b.seq);
869        assert_eq!(entry_a.kind, entry_b.kind);
870    }
871
872    #[test]
873    fn snapshot_returns_recorded_entries_in_seq_order() {
874        fresh();
875        let s = SuspensionReceipt::new(
876            "worker://x",
877            None,
878            SuspendInitiator::Operator,
879            "op-1",
880            "reason",
881            None,
882            None,
883        );
884        record_suspension_receipt(&s);
885        let r = ResumptionReceipt::new(
886            "worker://x",
887            None,
888            ResumeInitiator::Operator,
889            "op-1",
890            None,
891            None,
892            true,
893            None,
894            None,
895        );
896        record_resumption_receipt(&r);
897        let snapshot = lifecycle_receipts_snapshot();
898        assert_eq!(snapshot.len(), 2);
899        assert_eq!(snapshot[0].seq, 1);
900        assert_eq!(snapshot[0].kind, SUSPENSION_RECEIPT_KIND);
901        assert_eq!(snapshot[1].seq, 2);
902        assert_eq!(snapshot[1].kind, RESUMPTION_RECEIPT_KIND);
903    }
904}