1use 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
53pub 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
65static 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#[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 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
130pub 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#[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#[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#[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#[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#[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#[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 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 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#[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 #[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#[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#[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
509pub 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
518pub 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#[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
594pub 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#[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
654pub 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
660pub 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
666pub 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
672pub fn lifecycle_receipts_snapshot() -> Vec<LifecycleReceiptEntry> {
675 LIFECYCLE_RECEIPT_LOG.with(|log| log.borrow().clone())
676}
677
678pub 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
699pub 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 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 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}