1#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
6
7pub mod memory;
8#[cfg(feature = "sqlite-store")]
9pub mod sqlite;
10mod whole_blob_rewrite;
11
12pub use meerkat_core::{HeadCanonicalProvisionalTailAuthority, WholeBlobProvisionalTailAuthority};
13pub use whole_blob_rewrite::{
14 PreparedWholeBlobRewriteBoundary, PreparedWholeBlobRewriteStoreParts,
15 VerifiedCommittedWholeBlobPayload,
16};
17
18use std::collections::{BTreeMap, HashMap, HashSet};
19use std::sync::Arc;
20
21use meerkat_core::lifecycle::core_executor::BoundSessionCommit;
22use meerkat_core::lifecycle::{InputId, RunBoundaryReceipt, RunId};
23use sha2::{Digest, Sha256};
24
25use crate::identifiers::{IdempotencyKey, LogicalRuntimeId};
26use crate::input_state::{InputStatePersistenceRecord, StoredInputState};
27use crate::runtime_state::RuntimeState;
28
29const LEGACY_MACHINE_LIFECYCLE_STORE_RECORD_VERSION: u16 = 1;
30const SUPERVISOR_MACHINE_LIFECYCLE_STORE_RECORD_VERSION: u16 = 2;
31const UNREGISTER_MACHINE_LIFECYCLE_STORE_RECORD_VERSION: u16 = 3;
32pub(crate) const MACHINE_LIFECYCLE_STORE_RECORD_VERSION: u16 = 4;
33
34pub const MAX_INPUT_STATE_BATCH_CAS: usize = 256;
38
39pub const MAX_PENDING_TERMINAL_OWNER_PAGE: usize = 256;
42
43pub(crate) fn input_state_is_pending_terminal_owner(
44 state: &crate::input_state::InputState,
45) -> bool {
46 let owns_pending_completion = state
47 .terminal_completion
48 .as_ref()
49 .is_some_and(|completion| {
50 completion.owner_input_id == state.input_id
51 && matches!(
52 &completion.phase,
53 crate::input_state::InputTerminalCompletionPhase::Pending
54 )
55 });
56 let owns_unpublished_interaction =
57 state
58 .interaction_terminal_outbox
59 .as_ref()
60 .is_some_and(|outbox| {
61 outbox.candidate_owner_input_id == state.input_id
62 && !matches!(
63 &outbox.phase,
64 crate::input_state::InteractionTerminalOutboxPhase::Published { .. }
65 )
66 });
67 owns_pending_completion || owns_unpublished_interaction
68}
69
70pub(crate) fn input_state_is_recovery_nonterminal(state: &StoredInputState) -> bool {
71 !matches!(
72 state.seed.phase,
73 crate::input_state::InputLifecycleState::Consumed
74 | crate::input_state::InputLifecycleState::Superseded
75 | crate::input_state::InputLifecycleState::Coalesced
76 | crate::input_state::InputLifecycleState::Abandoned
77 )
78}
79
80pub(crate) fn input_state_payload_is_retirable(state: &StoredInputState) -> bool {
90 let lifecycle_terminal = matches!(
91 state.seed.phase,
92 crate::input_state::InputLifecycleState::Consumed
93 | crate::input_state::InputLifecycleState::Superseded
94 | crate::input_state::InputLifecycleState::Coalesced
95 | crate::input_state::InputLifecycleState::Abandoned
96 ) && state.seed.terminal_outcome.is_some();
97 let completion_closed = state
98 .state
99 .terminal_completion
100 .as_ref()
101 .is_none_or(|completion| {
102 matches!(
103 completion.phase,
104 crate::input_state::InputTerminalCompletionPhase::Finalized { .. }
105 )
106 });
107 let publication_closed =
108 state
109 .state
110 .interaction_terminal_outbox
111 .as_ref()
112 .is_none_or(|outbox| {
113 matches!(
114 outbox.phase,
115 crate::input_state::InteractionTerminalOutboxPhase::Published { .. }
116 )
117 });
118 let has_unmaterialized_directed_terminal =
125 state.state.persisted_input.as_ref().is_some_and(|input| {
126 crate::input::validated_directed_interaction_id(input)
127 .map(|interaction_id| {
128 interaction_id.is_some() && state.state.interaction_terminal_outbox.is_none()
129 })
130 .unwrap_or(true)
131 });
132 let has_compact_directed_attribution = state
133 .state
134 .interaction_terminal_outbox
135 .as_ref()
136 .is_none_or(|_| {
137 state
138 .state
139 .directed_run_started_attribution
140 .as_ref()
141 .is_some_and(|attribution| !attribution.content_digest().is_empty())
142 });
143 lifecycle_terminal
144 && completion_closed
145 && publication_closed
146 && !has_unmaterialized_directed_terminal
147 && has_compact_directed_attribution
148}
149
150#[cfg(test)]
151mod terminal_payload_retirement_tests {
152 use super::*;
153 use crate::input::{Input, PromptInput};
154 use crate::input_state::{InputLifecycleState, InputTerminalOutcome, StoredInputState};
155
156 fn prompt_payload() -> Input {
157 Input::Prompt(PromptInput::new("large durable prompt", None))
158 }
159
160 fn with_terminal_seed(mut stored: StoredInputState) -> StoredInputState {
161 stored.seed.phase = InputLifecycleState::Consumed;
162 stored.seed.terminal_outcome = Some(InputTerminalOutcome::Consumed);
163 stored.seed.recovery_lane = None;
164 stored
165 }
166
167 #[test]
168 fn payload_retirement_requires_terminal_lifecycle_and_closed_obligations() {
169 let input_id = InputId::new();
170 let mut accepted = StoredInputState::new_accepted(input_id.clone());
171 accepted.state.persisted_input = Some(prompt_payload());
172 assert!(!input_state_payload_is_retirable(&accepted));
173
174 let mut staged = accepted.clone();
175 staged.seed.phase = InputLifecycleState::Staged;
176 assert!(!input_state_payload_is_retirable(&staged));
177
178 let terminal = with_terminal_seed(accepted);
179 assert!(input_state_payload_is_retirable(&terminal));
180
181 let (mut pending, _) = pending_terminal_owner_fixture(input_id.clone(), false);
182 pending.state.persisted_input = Some(prompt_payload());
183 pending = with_terminal_seed(pending);
184 assert!(!input_state_payload_is_retirable(&pending));
185
186 let (mut published, _) = pending_terminal_owner_fixture(input_id, true);
187 published.state.persisted_input = Some(prompt_payload());
188 published = with_terminal_seed(published);
189 assert!(input_state_payload_is_retirable(&published));
190 }
191
192 #[test]
193 fn retired_terminal_wire_image_omits_only_the_payload() {
194 let mut terminal = StoredInputState::new_accepted(InputId::new());
195 terminal.state.persisted_input = Some(prompt_payload());
196 terminal = with_terminal_seed(terminal);
197 let before_seed = terminal.seed.clone();
198
199 assert!(input_state_payload_is_retirable(&terminal));
200 terminal.state.persisted_input = None;
201 let encoded = serde_json::to_value(&terminal).unwrap();
202 let decoded: StoredInputState = serde_json::from_value(encoded.clone()).unwrap();
203
204 assert!(encoded.get("persisted_input").is_none());
205 assert_eq!(decoded.seed, before_seed);
206 assert_eq!(decoded.seed.phase, InputLifecycleState::Consumed);
207 assert_eq!(
208 decoded.seed.terminal_outcome,
209 Some(InputTerminalOutcome::Consumed)
210 );
211 }
212}
213
214pub(crate) fn validate_pending_terminal_owner_page(
215 after: Option<&InputId>,
216 limit: usize,
217 owner_input_ids: &[InputId],
218) -> Result<(), RuntimeStoreError> {
219 if limit == 0 || limit > MAX_PENDING_TERMINAL_OWNER_PAGE {
220 return Err(RuntimeStoreError::InvalidInputStateBatchCas {
221 reason: format!(
222 "pending-terminal owner page limit {limit} is outside 1..={MAX_PENDING_TERMINAL_OWNER_PAGE}"
223 ),
224 });
225 }
226 if owner_input_ids.len() > limit {
227 return Err(RuntimeStoreError::ReadFailed(format!(
228 "pending-terminal owner page returned {} ids for limit {limit}",
229 owner_input_ids.len()
230 )));
231 }
232 if owner_input_ids
233 .windows(2)
234 .any(|window| window[0].0 >= window[1].0)
235 {
236 return Err(RuntimeStoreError::ReadFailed(
237 "pending-terminal owner page is not strictly ordered".to_string(),
238 ));
239 }
240 if let (Some(after), Some(first)) = (after, owner_input_ids.first())
241 && first.0 <= after.0
242 {
243 return Err(RuntimeStoreError::ReadFailed(
244 "pending-terminal owner page did not advance its stable cursor".to_string(),
245 ));
246 }
247 Ok(())
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum InputStateBatchCasOutcome {
253 Swapped,
257 Stale,
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum InputStateBatchCasImplementationProfile {
265 Unsupported,
267 MultiWriter,
270 ExclusiveWriterFenced,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
284pub enum FencedInputStateBatchCasOutcome {
285 Swapped,
288 Stale,
291 FenceConflict { reason: String },
293 FenceBackoff { reason: String },
296}
297
298#[derive(Debug)]
299struct PreparedInputStateBatchCasRow {
300 input_id: InputId,
301 expected_json: Vec<u8>,
302 replacement: StoredInputState,
303 #[cfg_attr(not(feature = "sqlite-store"), allow(dead_code))]
306 replacement_json: Vec<u8>,
307}
308
309fn prepare_input_state_batch_cas(
310 expected: &[StoredInputState],
311 replacements: &[InputStatePersistenceRecord],
312) -> Result<Vec<PreparedInputStateBatchCasRow>, RuntimeStoreError> {
313 if expected.len() != replacements.len() {
314 return Err(RuntimeStoreError::InvalidInputStateBatchCas {
315 reason: format!(
316 "expected row count {} does not match replacement row count {}",
317 expected.len(),
318 replacements.len()
319 ),
320 });
321 }
322 if expected.len() > MAX_INPUT_STATE_BATCH_CAS {
323 return Err(RuntimeStoreError::InvalidInputStateBatchCas {
324 reason: format!(
325 "batch contains {} rows, exceeding the maximum of {MAX_INPUT_STATE_BATCH_CAS}",
326 expected.len()
327 ),
328 });
329 }
330
331 let mut expected_ids = HashSet::with_capacity(expected.len());
332 for row in expected {
333 if !expected_ids.insert(row.state.input_id.clone()) {
334 return Err(RuntimeStoreError::InvalidInputStateBatchCas {
335 reason: format!("expected batch repeats input {}", row.state.input_id),
336 });
337 }
338 }
339
340 let mut replacement_by_id = HashMap::with_capacity(replacements.len());
341 for record in replacements {
342 let replacement = record.clone_stored();
343 let input_id = replacement.state.input_id.clone();
344 let replacement_json = serde_json::to_vec(&replacement)
345 .map_err(|error| RuntimeStoreError::WriteFailed(error.to_string()))?;
346 if replacement_by_id
347 .insert(input_id.clone(), (replacement, replacement_json))
348 .is_some()
349 {
350 return Err(RuntimeStoreError::InvalidInputStateBatchCas {
351 reason: format!("replacement batch repeats input {input_id}"),
352 });
353 }
354 }
355
356 let mut prepared = Vec::with_capacity(expected.len());
357 for expected_row in expected {
358 let input_id = expected_row.state.input_id.clone();
359 let Some((replacement, replacement_json)) = replacement_by_id.remove(&input_id) else {
360 return Err(RuntimeStoreError::InvalidInputStateBatchCas {
361 reason: format!("replacement batch does not contain expected input {input_id}"),
362 });
363 };
364 let expected_json = serde_json::to_vec(expected_row)
365 .map_err(|error| RuntimeStoreError::WriteFailed(error.to_string()))?;
366 prepared.push(PreparedInputStateBatchCasRow {
367 input_id,
368 expected_json,
369 replacement,
370 replacement_json,
371 });
372 }
373 if let Some(extra) = replacement_by_id.keys().next() {
374 return Err(RuntimeStoreError::InvalidInputStateBatchCas {
375 reason: format!("replacement batch contains unexpected input {extra}"),
376 });
377 }
378 Ok(prepared)
379}
380
381pub(crate) fn validate_input_state_batch_read_ids(
382 input_ids: &[InputId],
383) -> Result<(), RuntimeStoreError> {
384 if input_ids.len() > MAX_INPUT_STATE_BATCH_CAS {
385 return Err(RuntimeStoreError::InvalidInputStateBatchCas {
386 reason: format!(
387 "batch read contains {} rows, exceeding the maximum of {MAX_INPUT_STATE_BATCH_CAS}",
388 input_ids.len()
389 ),
390 });
391 }
392 let mut unique = HashSet::with_capacity(input_ids.len());
393 for input_id in input_ids {
394 if !unique.insert(input_id.clone()) {
395 return Err(RuntimeStoreError::InvalidInputStateBatchCas {
396 reason: format!("batch read repeats input {input_id}"),
397 });
398 }
399 }
400 Ok(())
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
410#[serde(rename_all = "snake_case")]
411#[non_exhaustive]
412pub enum RuntimeSessionPersistenceProfile {
413 WholeBlobV1,
415 HeadCanonicalV1,
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
426#[serde(rename_all = "snake_case")]
427pub struct RuntimeSessionCatalogEntry {
428 session_id: meerkat_core::types::SessionId,
429 persistence_profile: RuntimeSessionPersistenceProfile,
430 created_at: meerkat_core::time_compat::SystemTime,
431 updated_at: meerkat_core::time_compat::SystemTime,
432 message_count: usize,
433 total_tokens: u64,
434 labels: BTreeMap<String, String>,
435 lifecycle_terminal: Option<meerkat_core::SessionLifecycleTerminal>,
436 runtime_state: Option<RuntimeState>,
437}
438
439impl RuntimeSessionCatalogEntry {
440 const SESSION_LABELS_KEY: &'static str = "session_labels";
441
442 pub fn from_session(
451 session: &meerkat_core::Session,
452 persistence_profile: RuntimeSessionPersistenceProfile,
453 runtime_state: Option<RuntimeState>,
454 ) -> Result<Self, RuntimeStoreError> {
455 let labels = session
456 .metadata()
457 .get(Self::SESSION_LABELS_KEY)
458 .map(|value| {
459 serde_json::from_value::<BTreeMap<String, String>>(value.clone()).map_err(|error| {
460 RuntimeStoreError::WriteFailed(format!(
461 "session {} has malformed catalog labels: {error}",
462 session.id()
463 ))
464 })
465 })
466 .transpose()?
467 .unwrap_or_default();
468 let lifecycle_terminal = session.try_lifecycle_terminal().map_err(|error| {
469 RuntimeStoreError::WriteFailed(format!(
470 "session {} has malformed lifecycle-terminal metadata: {error}",
471 session.id()
472 ))
473 })?;
474 Ok(Self {
475 session_id: session.id().clone(),
476 persistence_profile,
477 created_at: session.created_at(),
478 updated_at: session.updated_at(),
479 message_count: session.messages().len(),
480 total_tokens: session.total_tokens(),
481 labels,
482 lifecycle_terminal,
483 runtime_state,
484 })
485 }
486
487 pub fn from_head(
495 head: &meerkat_core::session_store::SessionHead,
496 persistence_profile: RuntimeSessionPersistenceProfile,
497 runtime_state: Option<RuntimeState>,
498 ) -> Result<Self, RuntimeStoreError> {
499 let metadata = head.materialized_metadata().map_err(|error| {
500 RuntimeStoreError::WriteFailed(format!(
501 "session {} head has no exact catalog metadata projection: {error}",
502 head.id
503 ))
504 })?;
505 let labels = metadata
506 .get(Self::SESSION_LABELS_KEY)
507 .map(|value| {
508 serde_json::from_value::<BTreeMap<String, String>>(value.clone()).map_err(|error| {
509 RuntimeStoreError::WriteFailed(format!(
510 "session {} head has malformed catalog labels: {error}",
511 head.id
512 ))
513 })
514 })
515 .transpose()?
516 .unwrap_or_default();
517 let lifecycle_terminal =
518 meerkat_core::try_lifecycle_terminal_from_map(&metadata).map_err(|error| {
519 RuntimeStoreError::WriteFailed(format!(
520 "session {} head has malformed lifecycle-terminal metadata: {error}",
521 head.id
522 ))
523 })?;
524 Self::from_head_facts(
525 head,
526 labels,
527 lifecycle_terminal,
528 persistence_profile,
529 runtime_state,
530 )
531 }
532
533 pub(crate) fn from_head_facts(
534 head: &meerkat_core::session_store::SessionHead,
535 labels: BTreeMap<String, String>,
536 lifecycle_terminal: Option<meerkat_core::SessionLifecycleTerminal>,
537 persistence_profile: RuntimeSessionPersistenceProfile,
538 runtime_state: Option<RuntimeState>,
539 ) -> Result<Self, RuntimeStoreError> {
540 Ok(Self {
541 session_id: head.id.clone(),
542 persistence_profile,
543 created_at: head.created_at,
544 updated_at: head.updated_at,
545 message_count: usize::try_from(head.message_count).map_err(|_| {
546 RuntimeStoreError::WriteFailed(format!(
547 "session {} head message count exceeds host range",
548 head.id
549 ))
550 })?,
551 total_tokens: head.usage.total_tokens(),
552 labels,
553 lifecycle_terminal,
554 runtime_state,
555 })
556 }
557
558 #[must_use]
559 pub fn session_id(&self) -> &meerkat_core::types::SessionId {
560 &self.session_id
561 }
562
563 #[must_use]
564 pub const fn persistence_profile(&self) -> RuntimeSessionPersistenceProfile {
565 self.persistence_profile
566 }
567
568 #[must_use]
569 pub const fn created_at(&self) -> meerkat_core::time_compat::SystemTime {
570 self.created_at
571 }
572
573 #[must_use]
574 pub const fn updated_at(&self) -> meerkat_core::time_compat::SystemTime {
575 self.updated_at
576 }
577
578 #[must_use]
579 pub const fn message_count(&self) -> usize {
580 self.message_count
581 }
582
583 #[must_use]
584 pub const fn total_tokens(&self) -> u64 {
585 self.total_tokens
586 }
587
588 #[must_use]
589 pub fn labels(&self) -> &BTreeMap<String, String> {
590 &self.labels
591 }
592
593 #[must_use]
594 pub const fn lifecycle_terminal(&self) -> Option<meerkat_core::SessionLifecycleTerminal> {
595 self.lifecycle_terminal
596 }
597
598 #[must_use]
599 pub const fn runtime_state(&self) -> Option<RuntimeState> {
600 self.runtime_state
601 }
602
603 pub(crate) fn set_runtime_state(&mut self, runtime_state: Option<RuntimeState>) {
604 self.runtime_state = runtime_state;
605 }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
614pub enum RuntimeSessionAuthorityReadCost {
615 Bounded,
617 Unsupported,
619}
620
621#[derive(Debug, Clone, PartialEq, Eq)]
626pub struct WholeBlobStoreAuthority {
627 authority_version: u16,
628 session_id: meerkat_core::types::SessionId,
629 store_revision: u64,
630 blob_sha256: String,
631}
632
633fn is_canonical_row_sha256_token(token: &str) -> bool {
634 let Some(hex) = token.strip_prefix("row-sha256:") else {
635 return false;
636 };
637 hex.len() == 64
638 && hex
639 .bytes()
640 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
641}
642
643impl WholeBlobStoreAuthority {
644 pub const VERSION: u16 = 1;
646
647 pub fn from_store_record(
654 authority_version: u16,
655 session_id: meerkat_core::types::SessionId,
656 store_revision: u64,
657 blob_sha256: String,
658 ) -> Result<Self, RuntimeStoreError> {
659 if authority_version != Self::VERSION
660 || store_revision == 0
661 || !is_canonical_row_sha256_token(&blob_sha256)
662 {
663 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
664 runtime_id: session_id.to_string(),
665 detail: "WholeBlob store authority requires the current version, nonzero \
666 revision, and canonical physical row digest"
667 .to_string(),
668 });
669 }
670 Ok(Self {
671 authority_version,
672 session_id,
673 store_revision,
674 blob_sha256,
675 })
676 }
677
678 pub(crate) fn issued(
679 session_id: meerkat_core::types::SessionId,
680 store_revision: u64,
681 blob_sha256: String,
682 ) -> Result<Self, RuntimeStoreError> {
683 Self::from_store_record(Self::VERSION, session_id, store_revision, blob_sha256)
684 }
685
686 #[must_use]
687 pub const fn authority_version(&self) -> u16 {
688 self.authority_version
689 }
690
691 #[must_use]
692 pub fn session_id(&self) -> &meerkat_core::types::SessionId {
693 &self.session_id
694 }
695
696 #[must_use]
697 pub const fn store_revision(&self) -> u64 {
698 self.store_revision
699 }
700
701 #[must_use]
702 pub fn blob_sha256(&self) -> &str {
703 &self.blob_sha256
704 }
705}
706
707#[derive(Debug, Clone)]
713pub struct CommittedWholeBlobSnapshot {
714 session: Arc<meerkat_core::Session>,
715 bytes: Arc<Vec<u8>>,
716 authority: WholeBlobStoreAuthority,
717}
718
719#[derive(Debug, Clone)]
727pub struct PreparedWholeBlobSnapshotCas {
728 expected_authority: WholeBlobStoreAuthority,
729 candidate_session: Arc<meerkat_core::Session>,
730 candidate_bytes: Arc<Vec<u8>>,
731 candidate_blob_sha256: String,
732}
733
734#[derive(Debug, Clone, PartialEq, Eq)]
736pub enum WholeBlobSnapshotCasOutcome {
737 Committed(WholeBlobStoreAuthority),
740 Conflict,
742}
743
744impl PreparedWholeBlobSnapshotCas {
745 pub fn prepare(
746 expected_authority: WholeBlobStoreAuthority,
747 candidate: BoundSessionCommit,
748 ) -> Result<Self, RuntimeStoreError> {
749 let candidate_session = candidate.into_session_arc().ok_or_else(|| {
750 RuntimeStoreError::SessionPersistenceAuthorityConflict {
751 runtime_id: expected_authority.session_id().to_string(),
752 detail: "WholeBlob snapshot CAS requires a sealed typed Session".to_string(),
753 }
754 })?;
755 if candidate_session.id() != expected_authority.session_id() {
756 return Err(RuntimeStoreError::SessionKeyMismatch {
757 expected: expected_authority.session_id().clone(),
758 actual: candidate_session.id().clone(),
759 });
760 }
761 let (candidate_bytes, candidate_blob_sha256) =
762 encode_whole_blob_session(candidate_session.as_ref())?;
763 Ok(Self {
764 expected_authority,
765 candidate_session,
766 candidate_bytes,
767 candidate_blob_sha256,
768 })
769 }
770
771 #[must_use]
772 pub fn expected_authority(&self) -> &WholeBlobStoreAuthority {
773 &self.expected_authority
774 }
775
776 #[must_use]
777 pub fn candidate_blob_sha256(&self) -> &str {
778 &self.candidate_blob_sha256
779 }
780
781 #[must_use]
783 pub fn accepts_committed_authority(&self, observed: &WholeBlobStoreAuthority) -> bool {
784 if observed.session_id() != self.expected_authority.session_id()
785 || observed.blob_sha256() != self.candidate_blob_sha256
786 {
787 return false;
788 }
789 (observed.store_revision() == self.expected_authority.store_revision()
790 && self.candidate_blob_sha256 == self.expected_authority.blob_sha256())
791 || self
792 .expected_authority
793 .store_revision()
794 .checked_add(1)
795 .is_some_and(|successor| observed.store_revision() == successor)
796 }
797
798 pub(crate) fn into_parts(
799 self,
800 ) -> (
801 WholeBlobStoreAuthority,
802 Arc<meerkat_core::Session>,
803 Arc<Vec<u8>>,
804 String,
805 ) {
806 (
807 self.expected_authority,
808 self.candidate_session,
809 self.candidate_bytes,
810 self.candidate_blob_sha256,
811 )
812 }
813}
814
815struct WholeBlobSessionWriter {
816 bytes: Vec<u8>,
817 hasher: Sha256,
818}
819
820impl std::io::Write for WholeBlobSessionWriter {
821 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
822 self.bytes.extend_from_slice(buffer);
823 self.hasher.update(buffer);
824 Ok(buffer.len())
825 }
826
827 fn flush(&mut self) -> std::io::Result<()> {
828 Ok(())
829 }
830}
831
832fn encode_whole_blob_session(
833 session: &meerkat_core::Session,
834) -> Result<(Arc<Vec<u8>>, String), RuntimeStoreError> {
835 let mut writer = WholeBlobSessionWriter {
836 bytes: Vec::new(),
837 hasher: Sha256::new(),
838 };
839 serde_json::to_writer(&mut writer, session)
840 .map_err(|error| RuntimeStoreError::WriteFailed(error.to_string()))?;
841 let blob_sha256 = format!("row-sha256:{:x}", writer.hasher.finalize());
842 Ok((Arc::new(writer.bytes), blob_sha256))
843}
844
845#[derive(Debug, Clone)]
847pub struct PreparedWholeBlobProvisionalTail {
848 authority: WholeBlobProvisionalTailAuthority,
849 candidate_artifact: meerkat_core::SerializedSessionArtifact,
850 conversation_digest: String,
851 message_count: u64,
852 catalog_entry: RuntimeSessionCatalogEntry,
853 compaction_projection_intents: Vec<meerkat_core::CompactionProjectionIntent>,
854 #[cfg(test)]
855 whole_blob_encode_count: Arc<std::sync::atomic::AtomicUsize>,
856}
857
858impl PreparedWholeBlobProvisionalTail {
859 pub fn prepare(
865 base: WholeBlobStoreAuthority,
866 run_id: RunId,
867 candidate_sequence: u64,
868 candidate: &BoundSessionCommit,
869 ) -> Result<Self, RuntimeStoreError> {
870 let candidate_session = candidate.session_arc_cloned().ok_or_else(|| {
871 RuntimeStoreError::SessionPersistenceAuthorityConflict {
872 runtime_id: base.session_id().to_string(),
873 detail: "WholeBlob provisional candidate requires a sealed typed Session"
874 .to_string(),
875 }
876 })?;
877 let artifact = candidate
878 .whole_blob_artifact()
879 .map_err(|error| {
880 RuntimeStoreError::WriteFailed(format!(
881 "failed to materialize WholeBlob provisional candidate: {error}"
882 ))
883 })?
884 .clone();
885 Self::prepare_from_artifact(
886 base,
887 run_id,
888 candidate_sequence,
889 candidate_session.as_ref(),
890 artifact,
891 #[cfg(test)]
892 Arc::new(std::sync::atomic::AtomicUsize::new(0)),
893 )
894 }
895
896 pub fn prepare_from_session(
903 base: WholeBlobStoreAuthority,
904 run_id: RunId,
905 candidate_sequence: u64,
906 candidate: &meerkat_core::Session,
907 ) -> Result<Self, RuntimeStoreError> {
908 #[cfg(test)]
909 let whole_blob_encode_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
910 let artifact = candidate.to_persisted_artifact().map_err(|error| {
911 RuntimeStoreError::WriteFailed(format!(
912 "failed to materialize WholeBlob provisional candidate: {error}"
913 ))
914 })?;
915 #[cfg(test)]
916 whole_blob_encode_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
917 Self::prepare_from_artifact(
918 base,
919 run_id,
920 candidate_sequence,
921 candidate,
922 artifact,
923 #[cfg(test)]
924 whole_blob_encode_count,
925 )
926 }
927
928 fn prepare_from_artifact(
929 base: WholeBlobStoreAuthority,
930 run_id: RunId,
931 candidate_sequence: u64,
932 candidate_session: &meerkat_core::Session,
933 candidate_artifact: meerkat_core::SerializedSessionArtifact,
934 #[cfg(test)] whole_blob_encode_count: Arc<std::sync::atomic::AtomicUsize>,
935 ) -> Result<Self, RuntimeStoreError> {
936 if candidate_session.id() != base.session_id() {
937 return Err(RuntimeStoreError::SessionKeyMismatch {
938 expected: base.session_id().clone(),
939 actual: candidate_session.id().clone(),
940 });
941 }
942 let authority = WholeBlobProvisionalTailAuthority::issued(
943 base.session_id().clone(),
944 base.store_revision(),
945 base.blob_sha256().to_string(),
946 run_id,
947 candidate_artifact.row_sha256_token().to_string(),
948 candidate_sequence,
949 )
950 .map_err(
951 |error| RuntimeStoreError::SessionPersistenceAuthorityConflict {
952 runtime_id: base.session_id().to_string(),
953 detail: error.to_string(),
954 },
955 )?;
956 let catalog_entry = RuntimeSessionCatalogEntry::from_session(
957 candidate_session,
958 RuntimeSessionPersistenceProfile::WholeBlobV1,
959 None,
960 )?;
961 let conversation_digest =
962 candidate_session
963 .transcript_content_digest()
964 .map_err(
965 |error| RuntimeStoreError::SessionPersistenceAuthorityConflict {
966 runtime_id: base.session_id().to_string(),
967 detail: format!(
968 "failed to derive WholeBlob provisional conversation digest: {error}"
969 ),
970 },
971 )?;
972 let message_count = u64::try_from(candidate_session.messages().len()).map_err(|_| {
973 RuntimeStoreError::SessionPersistenceAuthorityConflict {
974 runtime_id: base.session_id().to_string(),
975 detail: "WholeBlob provisional message count exceeds the durable range".to_string(),
976 }
977 })?;
978 let compaction_projection_intents =
979 validated_compaction_projection_intents(candidate_session)?;
980 Ok(Self {
981 authority,
982 candidate_artifact,
983 conversation_digest,
984 message_count,
985 catalog_entry,
986 compaction_projection_intents,
987 #[cfg(test)]
988 whole_blob_encode_count,
989 })
990 }
991
992 #[must_use]
993 pub fn authority(&self) -> &WholeBlobProvisionalTailAuthority {
994 &self.authority
995 }
996
997 #[must_use]
998 pub fn conversation_digest(&self) -> &str {
999 &self.conversation_digest
1000 }
1001
1002 #[must_use]
1003 pub const fn message_count(&self) -> u64 {
1004 self.message_count
1005 }
1006
1007 #[cfg(test)]
1008 fn whole_blob_encode_count(&self) -> usize {
1009 self.whole_blob_encode_count
1010 .load(std::sync::atomic::Ordering::Relaxed)
1011 }
1012
1013 pub(crate) fn into_parts(
1014 self,
1015 ) -> (
1016 WholeBlobProvisionalTailAuthority,
1017 meerkat_core::SerializedSessionArtifact,
1018 String,
1019 u64,
1020 RuntimeSessionCatalogEntry,
1021 Vec<meerkat_core::CompactionProjectionIntent>,
1022 ) {
1023 (
1024 self.authority,
1025 self.candidate_artifact,
1026 self.conversation_digest,
1027 self.message_count,
1028 self.catalog_entry,
1029 self.compaction_projection_intents,
1030 )
1031 }
1032}
1033
1034#[derive(Debug, Clone)]
1036pub struct CommittedWholeBlobProvisionalTail {
1037 authority: WholeBlobProvisionalTailAuthority,
1038 candidate_bytes: Arc<Vec<u8>>,
1039}
1040
1041#[derive(Debug, Clone)]
1048pub struct PreparedWholeBlobProvisionalPromotion {
1049 authority: WholeBlobProvisionalTailAuthority,
1050 conversation_digest: String,
1051 message_count: u64,
1052}
1053
1054impl PreparedWholeBlobProvisionalPromotion {
1055 pub fn prepare(
1056 checkpoint: meerkat_core::RunCheckpointReceipt,
1057 run_id: &RunId,
1058 ) -> Result<Self, RuntimeStoreError> {
1059 let authority = checkpoint.whole_blob().cloned().ok_or_else(|| {
1060 RuntimeStoreError::SessionPersistenceAuthorityConflict {
1061 runtime_id: checkpoint.session_id().to_string(),
1062 detail: "HeadCanonical checkpoint cannot authorize WholeBlob promotion".to_string(),
1063 }
1064 })?;
1065 if authority.run_id() != run_id {
1066 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
1067 runtime_id: authority.session_id().to_string(),
1068 detail: "WholeBlob promotion run differs from store-issued candidate run"
1069 .to_string(),
1070 });
1071 }
1072 Ok(Self {
1073 authority,
1074 conversation_digest: checkpoint.conversation_digest().to_string(),
1075 message_count: checkpoint.message_count(),
1076 })
1077 }
1078
1079 #[must_use]
1080 pub fn authority(&self) -> &WholeBlobProvisionalTailAuthority {
1081 &self.authority
1082 }
1083
1084 #[must_use]
1085 pub(crate) fn into_parts(self) -> (WholeBlobProvisionalTailAuthority, String, u64) {
1086 (self.authority, self.conversation_digest, self.message_count)
1087 }
1088}
1089
1090#[derive(Debug, Clone)]
1097pub(crate) struct PreparedWholeBlobRecoveryPromotion {
1098 authority: WholeBlobProvisionalTailAuthority,
1099 repaired_snapshot: Option<PreparedWholeBlobSnapshot>,
1100}
1101
1102impl PreparedWholeBlobRecoveryPromotion {
1103 fn prepare(
1104 repaired_document: Option<&BoundSessionCommit>,
1105 evidence: &PreparedRecoveryEvidence,
1106 ) -> Result<Self, RuntimeStoreError> {
1107 let (
1108 base_store_revision,
1109 base_blob_sha256,
1110 candidate_blob_sha256,
1111 candidate_sequence,
1112 recovered_blob_sha256,
1113 ) = evidence.whole_blob_authority_transition().ok_or_else(|| {
1114 RuntimeStoreError::SessionPersistenceAuthorityConflict {
1115 runtime_id: evidence.session_id().to_string(),
1116 detail: "HeadCanonical recovery evidence cannot authorize WholeBlob promotion"
1117 .to_string(),
1118 }
1119 })?;
1120 let authority = WholeBlobProvisionalTailAuthority::issued(
1121 evidence.session_id().clone(),
1122 base_store_revision,
1123 base_blob_sha256.to_string(),
1124 evidence.candidate_run_id().clone(),
1125 candidate_blob_sha256.to_string(),
1126 candidate_sequence,
1127 )
1128 .map_err(
1129 |error| RuntimeStoreError::SessionPersistenceAuthorityConflict {
1130 runtime_id: evidence.session_id().to_string(),
1131 detail: error.to_string(),
1132 },
1133 )?;
1134 let repaired_snapshot = if recovered_blob_sha256 == candidate_blob_sha256 {
1135 if repaired_document.is_some() {
1136 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
1137 runtime_id: evidence.session_id().to_string(),
1138 detail: "completed WholeBlob recovery must not carry a materialized body"
1139 .to_string(),
1140 });
1141 }
1142 None
1143 } else {
1144 let document = repaired_document.ok_or_else(|| {
1145 RuntimeStoreError::SessionPersistenceAuthorityConflict {
1146 runtime_id: evidence.session_id().to_string(),
1147 detail: "WholeBlob repair has no sealed successor artifact".to_string(),
1148 }
1149 })?;
1150 let prepared = prepared_whole_blob_snapshot(document)?;
1151 if prepared.session().id() != evidence.session_id()
1152 || prepared.blob_sha256() != recovered_blob_sha256
1153 {
1154 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
1155 runtime_id: evidence.session_id().to_string(),
1156 detail: "WholeBlob repaired artifact differs from sealed recovery authority"
1157 .to_string(),
1158 });
1159 }
1160 Some(prepared)
1161 };
1162 Ok(Self {
1163 authority,
1164 repaired_snapshot,
1165 })
1166 }
1167
1168 pub(crate) fn into_parts(
1169 self,
1170 ) -> (
1171 WholeBlobProvisionalTailAuthority,
1172 Option<PreparedWholeBlobSnapshot>,
1173 ) {
1174 (self.authority, self.repaired_snapshot)
1175 }
1176}
1177
1178#[derive(Debug, Clone)]
1183pub struct PreparedHeadCanonicalProvisionalPromotion {
1184 checkpoint: meerkat_core::RunCheckpointReceipt,
1185 authority: HeadCanonicalProvisionalTailAuthority,
1186}
1187
1188impl PreparedHeadCanonicalProvisionalPromotion {
1189 pub fn prepare(
1190 checkpoint: meerkat_core::RunCheckpointReceipt,
1191 run_id: &RunId,
1192 ) -> Result<Self, RuntimeStoreError> {
1193 let authority = checkpoint.head_canonical().cloned().ok_or_else(|| {
1194 RuntimeStoreError::SessionPersistenceAuthorityConflict {
1195 runtime_id: checkpoint.session_id().to_string(),
1196 detail: "HeadCanonical promotion received a WholeBlob checkpoint".to_string(),
1197 }
1198 })?;
1199 if authority.run_id() != run_id {
1200 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
1201 runtime_id: authority.session_id().to_string(),
1202 detail: "HeadCanonical promotion run differs from store-issued physical tail"
1203 .to_string(),
1204 });
1205 }
1206 Ok(Self {
1207 checkpoint,
1208 authority,
1209 })
1210 }
1211
1212 #[must_use]
1213 pub fn authority(&self) -> &HeadCanonicalProvisionalTailAuthority {
1214 &self.authority
1215 }
1216
1217 #[must_use]
1218 pub fn checkpoint(&self) -> &meerkat_core::RunCheckpointReceipt {
1219 &self.checkpoint
1220 }
1221
1222 #[must_use]
1223 pub(crate) fn into_parts(
1224 self,
1225 ) -> (
1226 meerkat_core::RunCheckpointReceipt,
1227 HeadCanonicalProvisionalTailAuthority,
1228 ) {
1229 (self.checkpoint, self.authority)
1230 }
1231}
1232
1233impl CommittedWholeBlobProvisionalTail {
1234 pub(crate) fn new(
1235 authority: WholeBlobProvisionalTailAuthority,
1236 candidate_bytes: Arc<Vec<u8>>,
1237 ) -> Self {
1238 Self {
1239 authority,
1240 candidate_bytes,
1241 }
1242 }
1243
1244 #[must_use]
1245 pub fn authority(&self) -> &WholeBlobProvisionalTailAuthority {
1246 &self.authority
1247 }
1248
1249 #[must_use]
1250 pub fn candidate_bytes(&self) -> &[u8] {
1251 self.candidate_bytes.as_ref()
1252 }
1253
1254 #[must_use]
1255 pub fn candidate_bytes_arc(&self) -> Arc<Vec<u8>> {
1256 Arc::clone(&self.candidate_bytes)
1257 }
1258}
1259
1260impl CommittedWholeBlobSnapshot {
1261 pub(crate) fn new(
1262 bytes: Arc<Vec<u8>>,
1263 authority: WholeBlobStoreAuthority,
1264 ) -> Result<Self, RuntimeStoreError> {
1265 let decoded =
1266 meerkat_core::Session::decode_whole_blob_document(bytes.as_ref()).map_err(|error| {
1267 RuntimeStoreError::ReadFailed(format!(
1268 "WholeBlob body is not a valid current Session: {error}"
1269 ))
1270 })?;
1271 if decoded.row_sha256_token() != authority.blob_sha256() {
1272 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
1273 runtime_id: authority.session_id().to_string(),
1274 detail: "WholeBlob body digest differs from store authority".to_string(),
1275 });
1276 }
1277 let session = Arc::new(decoded.into_session());
1278 if session.id() != authority.session_id() {
1279 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
1280 runtime_id: authority.session_id().to_string(),
1281 detail: "WholeBlob body session differs from store authority".to_string(),
1282 });
1283 }
1284 Ok(Self {
1285 session,
1286 bytes,
1287 authority,
1288 })
1289 }
1290
1291 #[must_use]
1293 pub fn session(&self) -> &meerkat_core::Session {
1294 self.session.as_ref()
1295 }
1296
1297 #[must_use]
1299 pub fn session_arc(&self) -> Arc<meerkat_core::Session> {
1300 Arc::clone(&self.session)
1301 }
1302
1303 #[must_use]
1304 pub fn bytes(&self) -> &[u8] {
1305 self.bytes.as_ref()
1306 }
1307
1308 #[must_use]
1309 pub fn bytes_arc(&self) -> Arc<Vec<u8>> {
1310 Arc::clone(&self.bytes)
1311 }
1312
1313 #[must_use]
1314 pub fn authority(&self) -> &WholeBlobStoreAuthority {
1315 &self.authority
1316 }
1317
1318 #[must_use]
1319 pub fn into_parts(
1320 self,
1321 ) -> (
1322 Arc<meerkat_core::Session>,
1323 Arc<Vec<u8>>,
1324 WholeBlobStoreAuthority,
1325 ) {
1326 (self.session, self.bytes, self.authority)
1327 }
1328}
1329
1330impl std::fmt::Display for RuntimeSessionPersistenceProfile {
1331 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1332 match self {
1333 Self::WholeBlobV1 => f.write_str("whole_blob_v1"),
1334 Self::HeadCanonicalV1 => f.write_str("head_canonical_v1"),
1335 }
1336 }
1337}
1338
1339#[derive(Debug, Clone, PartialEq)]
1346pub struct HeadCanonicalStoreAuthority {
1347 authority_version: u16,
1348 session_id: meerkat_core::types::SessionId,
1349 store_revision: u64,
1350 boundary_head: meerkat_core::session_store::SessionHead,
1351 committed_head_token: String,
1352}
1353
1354impl HeadCanonicalStoreAuthority {
1355 pub const VERSION: u16 = 1;
1356
1357 pub fn from_store_record(
1365 authority_version: u16,
1366 session_id: meerkat_core::types::SessionId,
1367 store_revision: u64,
1368 boundary_head: meerkat_core::session_store::SessionHead,
1369 committed_head_token: String,
1370 ) -> Result<Self, RuntimeStoreError> {
1371 let conflict = |detail: String| RuntimeStoreError::SessionPersistenceAuthorityConflict {
1372 runtime_id: session_id.to_string(),
1373 detail,
1374 };
1375 if authority_version != Self::VERSION
1376 || store_revision == 0
1377 || committed_head_token.is_empty()
1378 {
1379 return Err(conflict(
1380 "HeadCanonical authority requires the current version, nonzero store revision, \
1381 and head token"
1382 .to_string(),
1383 ));
1384 }
1385 if boundary_head.id != session_id {
1386 return Err(conflict(format!(
1387 "HeadCanonical boundary belongs to {}, not {session_id}",
1388 boundary_head.id
1389 )));
1390 }
1391 let row_prefix = boundary_head.message_row_prefix.as_ref().ok_or_else(|| {
1392 conflict("HeadCanonical boundary has no exact message-row prefix".to_string())
1393 })?;
1394 if row_prefix.row_count() != boundary_head.message_count {
1395 return Err(conflict(
1396 "HeadCanonical boundary message count and row prefix differ".to_string(),
1397 ));
1398 }
1399 if boundary_head.rewrite_prefix.occurrence_count() != boundary_head.rewrite_count {
1400 return Err(conflict(
1401 "HeadCanonical boundary rewrite count and prefix differ".to_string(),
1402 ));
1403 }
1404 let derived = meerkat_core::session_head_cas_token(&boundary_head)
1405 .map_err(|error| conflict(format!("HeadCanonical head token is invalid: {error}")))?;
1406 if derived != committed_head_token {
1407 return Err(conflict(
1408 "store-issued HeadCanonical token differs from the exact boundary head".to_string(),
1409 ));
1410 }
1411 Ok(Self {
1412 authority_version,
1413 session_id,
1414 store_revision,
1415 boundary_head,
1416 committed_head_token,
1417 })
1418 }
1419
1420 pub(crate) fn issued(
1421 session_id: meerkat_core::types::SessionId,
1422 store_revision: u64,
1423 boundary_head: meerkat_core::session_store::SessionHead,
1424 committed_head_token: String,
1425 ) -> Result<Self, RuntimeStoreError> {
1426 Self::from_store_record(
1427 Self::VERSION,
1428 session_id,
1429 store_revision,
1430 boundary_head,
1431 committed_head_token,
1432 )
1433 }
1434
1435 #[must_use]
1436 pub const fn authority_version(&self) -> u16 {
1437 self.authority_version
1438 }
1439
1440 #[must_use]
1441 pub fn session_id(&self) -> &meerkat_core::types::SessionId {
1442 &self.session_id
1443 }
1444
1445 #[must_use]
1446 pub const fn store_revision(&self) -> u64 {
1447 self.store_revision
1448 }
1449
1450 #[must_use]
1451 pub fn boundary_head(&self) -> &meerkat_core::session_store::SessionHead {
1452 &self.boundary_head
1453 }
1454
1455 #[must_use]
1456 pub fn committed_head_token(&self) -> &str {
1457 &self.committed_head_token
1458 }
1459}
1460
1461#[derive(Debug, Clone)]
1470pub struct PreparedHeadCanonicalProvisionalTail {
1471 committed: HeadCanonicalStoreAuthority,
1472 run_id: RunId,
1473 successor_head: meerkat_core::session_store::SessionHead,
1474 successor_head_token: String,
1475 candidate_message_count: usize,
1476 candidate_conversation_digest: String,
1477 catalog_entry: RuntimeSessionCatalogEntry,
1478 compaction_projection_intents: Vec<meerkat_core::CompactionProjectionIntent>,
1479}
1480
1481impl PreparedHeadCanonicalProvisionalTail {
1482 pub fn prepare(
1483 committed: HeadCanonicalStoreAuthority,
1484 run_id: RunId,
1485 successor_head: &meerkat_core::session_store::SessionHead,
1486 successor_head_token: &str,
1487 candidate_session: &meerkat_core::Session,
1488 ) -> Result<Self, RuntimeStoreError> {
1489 let session_id = committed.session_id().clone();
1490 let conflict = |detail: String| RuntimeStoreError::SessionPersistenceAuthorityConflict {
1491 runtime_id: session_id.to_string(),
1492 detail,
1493 };
1494 if successor_head.id != session_id || successor_head_token.is_empty() {
1495 return Err(conflict(
1496 "HeadCanonical provisional intent names the wrong session or an empty successor"
1497 .to_string(),
1498 ));
1499 }
1500 let derived = meerkat_core::session_head_cas_token(successor_head).map_err(|error| {
1501 conflict(format!(
1502 "HeadCanonical provisional successor is invalid: {error}"
1503 ))
1504 })?;
1505 if derived != successor_head_token
1506 || successor_head_token == committed.committed_head_token()
1507 {
1508 return Err(conflict(
1509 "HeadCanonical provisional successor token is not the exact distinct target head"
1510 .to_string(),
1511 ));
1512 }
1513 if candidate_session.id() != &session_id
1514 || candidate_session.messages().len() as u64 != successor_head.message_count
1515 || candidate_session.version() != successor_head.version
1516 || candidate_session.created_at() != successor_head.created_at
1517 || candidate_session.updated_at() != successor_head.updated_at
1518 || candidate_session.total_usage() != successor_head.usage
1519 || !successor_head
1520 .matches_session_metadata(candidate_session)
1521 .map_err(|error| {
1522 conflict(format!(
1523 "HeadCanonical provisional candidate metadata is invalid: {error}"
1524 ))
1525 })?
1526 {
1527 return Err(conflict(
1528 "HeadCanonical provisional successor does not describe the exact candidate Session"
1529 .to_string(),
1530 ));
1531 }
1532 let candidate_conversation_digest =
1533 candidate_session
1534 .transcript_content_digest()
1535 .map_err(|error| {
1536 conflict(format!(
1537 "HeadCanonical provisional candidate transcript is invalid: {error}"
1538 ))
1539 })?;
1540 if candidate_conversation_digest != successor_head.head_revision {
1541 return Err(conflict(
1542 "HeadCanonical provisional candidate digest differs from its successor head"
1543 .to_string(),
1544 ));
1545 }
1546 let catalog_entry = RuntimeSessionCatalogEntry::from_session(
1547 candidate_session,
1548 RuntimeSessionPersistenceProfile::HeadCanonicalV1,
1549 None,
1550 )?;
1551 let compaction_projection_intents =
1552 validated_compaction_projection_intents(candidate_session)?;
1553 Ok(Self {
1554 committed,
1555 run_id,
1556 successor_head: successor_head.clone(),
1557 successor_head_token: successor_head_token.to_string(),
1558 candidate_message_count: candidate_session.messages().len(),
1559 candidate_conversation_digest,
1560 catalog_entry,
1561 compaction_projection_intents,
1562 })
1563 }
1564
1565 #[must_use]
1566 pub(crate) fn committed(&self) -> &HeadCanonicalStoreAuthority {
1567 &self.committed
1568 }
1569
1570 #[must_use]
1571 pub(crate) fn run_id(&self) -> &RunId {
1572 &self.run_id
1573 }
1574
1575 #[must_use]
1576 pub(crate) fn successor_head(&self) -> &meerkat_core::session_store::SessionHead {
1577 &self.successor_head
1578 }
1579
1580 #[must_use]
1581 pub(crate) fn successor_head_token(&self) -> &str {
1582 &self.successor_head_token
1583 }
1584
1585 #[must_use]
1586 pub(crate) const fn candidate_message_count(&self) -> usize {
1587 self.candidate_message_count
1588 }
1589
1590 #[must_use]
1591 pub(crate) fn candidate_conversation_digest(&self) -> &str {
1592 &self.candidate_conversation_digest
1593 }
1594
1595 #[must_use]
1596 pub(crate) fn catalog_entry(&self) -> &RuntimeSessionCatalogEntry {
1597 &self.catalog_entry
1598 }
1599
1600 #[must_use]
1601 pub(crate) fn compaction_projection_intents(
1602 &self,
1603 ) -> &[meerkat_core::CompactionProjectionIntent] {
1604 &self.compaction_projection_intents
1605 }
1606}
1607
1608#[derive(Debug, Clone, PartialEq)]
1610pub enum RuntimeSessionAuthority {
1611 WholeBlob(WholeBlobStoreAuthority),
1612 HeadCanonical(HeadCanonicalStoreAuthority),
1613}
1614
1615impl RuntimeSessionAuthority {
1616 #[must_use]
1617 pub const fn profile(&self) -> RuntimeSessionPersistenceProfile {
1618 match self {
1619 Self::WholeBlob(_) => RuntimeSessionPersistenceProfile::WholeBlobV1,
1620 Self::HeadCanonical(_) => RuntimeSessionPersistenceProfile::HeadCanonicalV1,
1621 }
1622 }
1623
1624 #[must_use]
1625 pub fn session_id(&self) -> &meerkat_core::types::SessionId {
1626 match self {
1627 Self::WholeBlob(authority) => authority.session_id(),
1628 Self::HeadCanonical(authority) => authority.session_id(),
1629 }
1630 }
1631
1632 #[must_use]
1633 pub fn whole_blob(&self) -> Option<&WholeBlobStoreAuthority> {
1634 match self {
1635 Self::WholeBlob(authority) => Some(authority),
1636 Self::HeadCanonical(_) => None,
1637 }
1638 }
1639
1640 #[must_use]
1641 pub fn head_canonical(&self) -> Option<&HeadCanonicalStoreAuthority> {
1642 match self {
1643 Self::WholeBlob(_) => None,
1644 Self::HeadCanonical(authority) => Some(authority),
1645 }
1646 }
1647}
1648
1649#[derive(Debug, Clone)]
1658pub struct PreparedDurableTailRecoverySource {
1659 runtime_authority: RuntimeSessionAuthority,
1660 provisional_authority: Option<HeadCanonicalProvisionalTailAuthority>,
1661 provisional_target_applied: bool,
1662 committed_session: Arc<meerkat_core::Session>,
1663 physical_head: meerkat_core::session_store::SessionHead,
1664 physical_head_cas_token: String,
1665 physical_session: Arc<meerkat_core::Session>,
1666}
1667
1668impl PreparedDurableTailRecoverySource {
1669 pub(crate) fn new(
1670 runtime_authority: RuntimeSessionAuthority,
1671 provisional_authority: Option<HeadCanonicalProvisionalTailAuthority>,
1672 committed_materialization: meerkat_core::VerifiedSessionHeadMaterialization,
1673 physical_materialization: meerkat_core::VerifiedSessionHeadMaterialization,
1674 ) -> Result<Self, RuntimeStoreError> {
1675 let committed_session = Arc::clone(committed_materialization.session());
1676 let physical_head = physical_materialization.head().clone();
1677 let physical_session = Arc::clone(physical_materialization.session());
1678 let runtime_id = runtime_authority.session_id().to_string();
1679 let conflict = |detail: String| RuntimeStoreError::SessionPersistenceAuthorityConflict {
1680 runtime_id: runtime_id.clone(),
1681 detail,
1682 };
1683 if runtime_authority.profile() != RuntimeSessionPersistenceProfile::HeadCanonicalV1 {
1684 return Err(conflict(
1685 "durable-tail source requires head-canonical session ownership".to_string(),
1686 ));
1687 }
1688 let committed_authority = runtime_authority
1689 .head_canonical()
1690 .ok_or_else(|| conflict("runtime authority is not HeadCanonical".to_string()))?;
1691 let boundary_head = committed_authority.boundary_head();
1692 if committed_materialization.head() != boundary_head {
1693 return Err(conflict(
1694 "verified committed materialization belongs to a different retained boundary head"
1695 .to_string(),
1696 ));
1697 }
1698 let boundary_row_prefix = boundary_head.message_row_prefix.as_ref().ok_or_else(|| {
1699 conflict("runtime boundary has no exact message-row prefix authority".to_string())
1700 })?;
1701 if physical_materialization
1702 .exact_row_prefix_at(boundary_head.message_count)
1703 .as_ref()
1704 != Some(boundary_row_prefix)
1705 {
1706 return Err(conflict(
1707 "physical recovery materialization does not retain the runtime boundary's exact row prefix"
1708 .to_string(),
1709 ));
1710 }
1711 if committed_session.id() != runtime_authority.session_id()
1712 || physical_session.id() != runtime_authority.session_id()
1713 || &physical_head.id != runtime_authority.session_id()
1714 {
1715 return Err(conflict(
1716 "durable-tail source identities do not all match runtime authority".to_string(),
1717 ));
1718 }
1719 let committed_revision = committed_session
1720 .transcript_content_digest()
1721 .map_err(|error| conflict(format!("committed transcript is invalid: {error}")))?;
1722 let committed_metadata_matches = boundary_head
1723 .matches_session_metadata(&committed_session)
1724 .map_err(|error| {
1725 conflict(format!(
1726 "committed recovery metadata identity is invalid: {error}"
1727 ))
1728 })?;
1729 if committed_session.messages().len() as u64 != boundary_head.message_count
1730 || committed_revision != boundary_head.head_revision
1731 || committed_session.version() != boundary_head.version
1732 || committed_session.created_at() != boundary_head.created_at
1733 || committed_session.updated_at() != boundary_head.updated_at
1734 || committed_session.total_usage() != boundary_head.usage
1735 || !committed_metadata_matches
1736 {
1737 return Err(conflict(format!(
1738 "committed recovery materialization differs from the exact retained boundary envelope \
1739 (message_count={}, revision={}, version={}, created_at={}, updated_at={}, usage={}, metadata={})",
1740 committed_session.messages().len() as u64 == boundary_head.message_count,
1741 committed_revision == boundary_head.head_revision,
1742 committed_session.version() == boundary_head.version,
1743 committed_session.created_at() == boundary_head.created_at,
1744 committed_session.updated_at() == boundary_head.updated_at,
1745 committed_session.total_usage() == boundary_head.usage,
1746 committed_metadata_matches,
1747 )));
1748 }
1749 let physical_revision = physical_session
1750 .transcript_content_digest()
1751 .map_err(|error| conflict(format!("physical transcript is invalid: {error}")))?;
1752 let physical_metadata_matches = physical_head
1753 .matches_session_metadata(&physical_session)
1754 .map_err(|error| {
1755 conflict(format!(
1756 "physical recovery metadata identity is invalid: {error}"
1757 ))
1758 })?;
1759 if physical_session.messages().len() as u64 != physical_head.message_count
1760 || physical_revision != physical_head.head_revision
1761 || physical_session.version() != physical_head.version
1762 || physical_session.created_at() != physical_head.created_at
1763 || physical_session.updated_at() != physical_head.updated_at
1764 || physical_session.total_usage() != physical_head.usage
1765 || !physical_metadata_matches
1766 {
1767 return Err(conflict(
1768 "physical recovery materialization differs from the exact current canonical envelope"
1769 .to_string(),
1770 ));
1771 }
1772 let Some(physical_row_prefix) = physical_head.message_row_prefix.as_ref() else {
1773 return Err(conflict(
1774 "physical recovery head has no exact message-row prefix authority".to_string(),
1775 ));
1776 };
1777 if physical_row_prefix.row_count() != physical_head.message_count {
1778 return Err(conflict(
1779 "physical recovery head message count and exact row prefix differ".to_string(),
1780 ));
1781 }
1782 let physical_head_cas_token = meerkat_core::session_head_cas_token(&physical_head)
1783 .map_err(|error| {
1784 conflict(format!("physical recovery head token is invalid: {error}"))
1785 })?;
1786 if committed_authority.committed_head_token()
1787 != meerkat_core::session_head_cas_token(boundary_head)
1788 .map_err(|error| conflict(format!("committed head token is invalid: {error}")))?
1789 {
1790 return Err(conflict(
1791 "committed runtime authority token differs from its retained boundary head"
1792 .to_string(),
1793 ));
1794 }
1795 let provisional_target_applied = match (
1796 &provisional_authority,
1797 physical_head == *boundary_head,
1798 ) {
1799 (None, true) => false,
1800 (None, false) => {
1801 return Err(conflict(
1802 "newer physical head has no store-issued provisional authority".to_string(),
1803 ));
1804 }
1805 (Some(provisional), aligned) => {
1806 let first_provisional_revision = committed_authority
1807 .store_revision()
1808 .checked_add(1)
1809 .ok_or_else(|| {
1810 conflict("HeadCanonical store revision exhausted".to_string())
1811 })?;
1812 let target_applied = provisional.physical_head_token() == physical_head_cas_token;
1813 if provisional.authority_version() != HeadCanonicalProvisionalTailAuthority::VERSION
1814 || provisional.session_id() != runtime_authority.session_id()
1815 || provisional.base_store_revision() != committed_authority.store_revision()
1816 || provisional.base_committed_head_token()
1817 != committed_authority.committed_head_token()
1818 || (aligned
1819 && (physical_head_cas_token != committed_authority.committed_head_token()
1820 || provisional.physical_store_revision() != first_provisional_revision))
1821 || (!aligned
1822 && !target_applied
1823 && provisional.physical_store_revision() <= first_provisional_revision)
1824 {
1825 return Err(conflict(
1826 "provisional authority does not bind the exact committed parent and physical head"
1827 .to_string(),
1828 ));
1829 }
1830 target_applied
1831 }
1832 };
1833 Ok(Self {
1834 runtime_authority,
1835 provisional_authority,
1836 provisional_target_applied,
1837 committed_session,
1838 physical_head,
1839 physical_head_cas_token,
1840 physical_session,
1841 })
1842 }
1843
1844 pub(crate) fn runtime_authority(&self) -> &RuntimeSessionAuthority {
1845 &self.runtime_authority
1846 }
1847
1848 pub(crate) fn committed_session(&self) -> &Arc<meerkat_core::Session> {
1849 &self.committed_session
1850 }
1851
1852 pub(crate) fn provisional_authority(&self) -> Option<&HeadCanonicalProvisionalTailAuthority> {
1853 self.provisional_authority.as_ref()
1854 }
1855
1856 pub(crate) const fn provisional_target_applied(&self) -> bool {
1857 self.provisional_target_applied
1858 }
1859
1860 pub(crate) fn physical_head(&self) -> &meerkat_core::session_store::SessionHead {
1861 &self.physical_head
1862 }
1863
1864 pub(crate) fn physical_head_cas_token(&self) -> &str {
1865 &self.physical_head_cas_token
1866 }
1867
1868 pub(crate) fn physical_session(&self) -> &Arc<meerkat_core::Session> {
1869 &self.physical_session
1870 }
1871}
1872
1873#[derive(Debug, Clone, PartialEq, Eq)]
1875pub struct PreparedRecoveryReceiptSource {
1876 receipt: RunBoundaryReceipt,
1877 exact_row_token: String,
1878}
1879
1880impl PreparedRecoveryReceiptSource {
1881 pub(crate) fn from_serialized_row(bytes: &[u8]) -> Result<Self, RuntimeStoreError> {
1882 let receipt = serde_json::from_slice(bytes).map_err(|error| {
1883 RuntimeStoreError::ReadFailed(format!("invalid durable recovery receipt row: {error}"))
1884 })?;
1885 Ok(Self {
1886 receipt,
1887 exact_row_token: format!("receipt-row-sha256:{:x}", Sha256::digest(bytes)),
1888 })
1889 }
1890
1891 pub(crate) fn receipt(&self) -> &RunBoundaryReceipt {
1892 &self.receipt
1893 }
1894
1895 pub(crate) fn exact_row_token(&self) -> &str {
1896 &self.exact_row_token
1897 }
1898}
1899
1900#[derive(Debug, Clone, PartialEq, Eq)]
1902pub struct PreparedRecoveryReceiptDigestEnrichment {
1903 original_receipt: RunBoundaryReceipt,
1904 original_exact_row_token: String,
1905 derived_conversation_digest: String,
1906}
1907
1908impl PreparedRecoveryReceiptDigestEnrichment {
1909 pub(crate) fn new(
1910 source: &PreparedRecoveryReceiptSource,
1911 derived_conversation_digest: String,
1912 ) -> Result<Self, RuntimeStoreError> {
1913 if source.receipt.conversation_digest.is_some() || derived_conversation_digest.is_empty() {
1914 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
1915 runtime_id: source.receipt.run_id.to_string(),
1916 detail: "receipt enrichment must replace exactly one missing digest".to_string(),
1917 });
1918 }
1919 Ok(Self {
1920 original_receipt: source.receipt.clone(),
1921 original_exact_row_token: source.exact_row_token.clone(),
1922 derived_conversation_digest,
1923 })
1924 }
1925
1926 pub(crate) fn original_receipt(&self) -> &RunBoundaryReceipt {
1927 &self.original_receipt
1928 }
1929
1930 pub(crate) fn original_exact_row_token(&self) -> &str {
1931 &self.original_exact_row_token
1932 }
1933
1934 pub(crate) fn derived_conversation_digest(&self) -> &str {
1935 &self.derived_conversation_digest
1936 }
1937
1938 pub(crate) fn enriched_receipt(&self) -> RunBoundaryReceipt {
1939 let mut receipt = self.original_receipt.clone();
1940 receipt.conversation_digest = Some(self.derived_conversation_digest.clone());
1941 receipt
1942 }
1943}
1944
1945#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1948pub enum PreparedRuntimeSessionCommitOutcome {
1949 Applied,
1951 AlreadyAppliedExact,
1953 AlreadyAppliedReleasedEquivalent,
1959}
1960
1961#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1963pub enum RecoveryCommitStatus {
1964 Committed,
1966 AlreadyCommittedExact,
1968}
1969
1970#[derive(Debug, Clone, PartialEq)]
1972pub struct PreparedRuntimeSessionCommitResult {
1973 profile: RuntimeSessionPersistenceProfile,
1974 outcome: PreparedRuntimeSessionCommitOutcome,
1975 recovery_status: Option<RecoveryCommitStatus>,
1976 downstream_projection_required: bool,
1977 authority: Option<RuntimeSessionAuthority>,
1978}
1979
1980impl PreparedRuntimeSessionCommitResult {
1981 #[must_use]
1983 pub fn committed(authority: RuntimeSessionAuthority) -> Self {
1984 let profile = authority.profile();
1985 Self {
1986 profile,
1987 outcome: PreparedRuntimeSessionCommitOutcome::Applied,
1988 recovery_status: None,
1989 downstream_projection_required: false,
1993 authority: Some(authority),
1994 }
1995 }
1996
1997 #[must_use]
1999 pub const fn receipt_only(profile: RuntimeSessionPersistenceProfile) -> Self {
2000 Self {
2001 profile,
2002 outcome: PreparedRuntimeSessionCommitOutcome::Applied,
2003 recovery_status: None,
2004 downstream_projection_required: false,
2005 authority: None,
2006 }
2007 }
2008
2009 #[must_use]
2011 pub fn recovery(authority: RuntimeSessionAuthority, status: RecoveryCommitStatus) -> Self {
2012 let mut result = Self::committed(authority);
2013 result.recovery_status = Some(status);
2014 if status == RecoveryCommitStatus::AlreadyCommittedExact {
2015 result.outcome = PreparedRuntimeSessionCommitOutcome::AlreadyAppliedExact;
2016 }
2017 result
2018 }
2019
2020 #[must_use]
2022 pub fn already_applied_exact(mut self) -> Self {
2023 self.outcome = PreparedRuntimeSessionCommitOutcome::AlreadyAppliedExact;
2024 self
2025 }
2026
2027 #[must_use]
2030 pub fn already_applied_released_equivalent(mut self) -> Self {
2031 self.outcome = PreparedRuntimeSessionCommitOutcome::AlreadyAppliedReleasedEquivalent;
2032 self
2033 }
2034
2035 #[must_use]
2037 pub const fn profile(&self) -> RuntimeSessionPersistenceProfile {
2038 self.profile
2039 }
2040
2041 #[must_use]
2044 pub const fn outcome(&self) -> PreparedRuntimeSessionCommitOutcome {
2045 self.outcome
2046 }
2047
2048 #[must_use]
2050 pub const fn recovery_status(&self) -> Option<RecoveryCommitStatus> {
2051 self.recovery_status
2052 }
2053
2054 #[must_use]
2057 pub const fn downstream_projection_required(&self) -> bool {
2058 self.downstream_projection_required
2059 }
2060
2061 #[must_use]
2064 pub fn authority(&self) -> Option<&RuntimeSessionAuthority> {
2065 self.authority.as_ref()
2066 }
2067}
2068
2069#[derive(Debug, Clone, thiserror::Error)]
2071#[non_exhaustive]
2072pub enum RuntimeStoreError {
2073 #[error("Store write failed: {0}")]
2075 WriteFailed(String),
2076 #[error("Store read failed: {0}")]
2078 ReadFailed(String),
2079 #[error("Session store key mismatch: expected {expected}, actual {actual}")]
2081 SessionKeyMismatch {
2082 expected: meerkat_core::types::SessionId,
2083 actual: meerkat_core::types::SessionId,
2084 },
2085 #[error("Not found: {0}")]
2087 NotFound(String),
2088 #[error("Unsupported store operation: {0}")]
2090 Unsupported(String),
2091 #[error("runtime store profile '{profile}' must override commit_prepared_session_boundary")]
2094 PreparedSessionBoundaryRequiresOverride {
2095 profile: RuntimeSessionPersistenceProfile,
2096 },
2097 #[error(
2100 "runtime store profile '{profile}' cannot atomically CAS the physical session head for prepared recovery"
2101 )]
2102 PreparedRecoveryRequiresAtomicPhysicalHeadCas {
2103 profile: RuntimeSessionPersistenceProfile,
2104 },
2105 #[error(
2113 "head-canonical profile activation is required for runtime '{runtime_id}' (state: {state})"
2114 )]
2115 HeadCanonicalActivationRequired {
2116 runtime_id: String,
2118 state: String,
2121 },
2122 #[error("session persistence authority conflict for runtime '{runtime_id}': {detail}")]
2125 SessionPersistenceAuthorityConflict { runtime_id: String, detail: String },
2126 #[error("Ops lifecycle epoch {epoch_id} for runtime {runtime_id} is retired")]
2129 OpsLifecycleEpochRetired {
2130 runtime_id: String,
2131 epoch_id: meerkat_core::RuntimeEpochId,
2132 },
2133 #[error("Unregister finalization outcome is unknown: {0}")]
2139 UnregisterFinalizationOutcomeUnknown(String),
2140 #[error("Transcript revision conflict: expected {expected}, actual {actual}")]
2142 TranscriptRevisionConflict { expected: String, actual: String },
2143 #[error("Session snapshot for runtime '{runtime_id}' was superseded by the durable head")]
2147 SessionSnapshotSuperseded { runtime_id: String },
2148 #[error("Invalid input-state batch compare-and-swap: {reason}")]
2150 InvalidInputStateBatchCas { reason: String },
2151 #[error(
2158 "input idempotency index for runtime '{runtime_id}' cannot prove key '{key}' while \
2159 input row '{evidence_input_id}' is unindexable: {reason}"
2160 )]
2161 InputIdempotencyIndexUncertain {
2162 runtime_id: String,
2163 key: String,
2164 evidence_input_id: String,
2165 reason: String,
2166 },
2167 #[error("Machine lifecycle repair is blocked: {detail}")]
2174 MachineLifecycleRepairBlocked {
2175 evidence_digest: Option<String>,
2176 detail: String,
2177 },
2178 #[error(
2182 "schema for domain '{domain}' is from the future: file has version {found}, \
2183 this binary supports up to {supported}"
2184 )]
2185 SchemaFromTheFuture {
2186 domain: String,
2187 found: i64,
2188 supported: i64,
2189 },
2190 #[error("maintenance fence is held for '{path}'; storage is under offline maintenance")]
2193 MaintenanceFenceHeld { path: String },
2194 #[error(
2198 "Input row version conflict for input '{input_id}': the stored row changed since it was observed"
2199 )]
2200 InputRowVersionConflict { input_id: String },
2201 #[error(
2205 "Recovery input-set conflict for runtime '{runtime_id}': the nonterminal input set changed since it was observed"
2206 )]
2207 RecoveryInputSetConflict { runtime_id: String },
2208 #[error(
2212 "Machine lifecycle version conflict for runtime '{runtime_id}': the stored row changed since it was observed"
2213 )]
2214 MachineLifecycleVersionConflict { runtime_id: String },
2215 #[error("Internal error: {0}")]
2217 Internal(String),
2218}
2219
2220pub type AuthOAuthFlowSnapshotUpdate<'a> =
2222 dyn FnMut(Option<&[u8]>) -> Result<Vec<u8>, RuntimeStoreError> + 'a;
2223
2224#[derive(Debug, Clone)]
2226pub struct SerializedSessionSnapshot {
2227 pub session_snapshot: std::sync::Arc<Vec<u8>>,
2233}
2234
2235fn recovery_class_name(
2236 class: crate::meerkat_machine::dsl::DurableTailRecoveryClass,
2237) -> &'static str {
2238 use crate::meerkat_machine::dsl::DurableTailRecoveryClass;
2239 match class {
2240 DurableTailRecoveryClass::CompletedCandidate => "completed_candidate",
2241 DurableTailRecoveryClass::InterruptedRepairableCandidate => {
2242 "interrupted_repairable_candidate"
2243 }
2244 DurableTailRecoveryClass::Ambiguous => "ambiguous",
2245 }
2246}
2247
2248fn recovery_class_from_name(
2249 name: &str,
2250) -> Result<crate::meerkat_machine::dsl::DurableTailRecoveryClass, RuntimeStoreError> {
2251 use crate::meerkat_machine::dsl::DurableTailRecoveryClass;
2252 match name {
2253 "completed_candidate" => Ok(DurableTailRecoveryClass::CompletedCandidate),
2254 "interrupted_repairable_candidate" => {
2255 Ok(DurableTailRecoveryClass::InterruptedRepairableCandidate)
2256 }
2257 "ambiguous" => Ok(DurableTailRecoveryClass::Ambiguous),
2258 other => Err(RuntimeStoreError::ReadFailed(format!(
2259 "unknown committed recovery class '{other}'"
2260 ))),
2261 }
2262}
2263
2264fn recovery_disposition_name(
2265 disposition: crate::meerkat_machine::dsl::DurableTailRecoveryDisposition,
2266) -> &'static str {
2267 use crate::meerkat_machine::dsl::DurableTailRecoveryDisposition;
2268 match disposition {
2269 DurableTailRecoveryDisposition::RefuseRecovery => "refuse_recovery",
2270 DurableTailRecoveryDisposition::CommitCompleted => "commit_completed",
2271 DurableTailRecoveryDisposition::RepairAndCommitInterrupted => {
2272 "repair_and_commit_interrupted"
2273 }
2274 DurableTailRecoveryDisposition::CommitCompletedRetainInputs => {
2275 "commit_completed_retain_inputs"
2276 }
2277 DurableTailRecoveryDisposition::HoldIntact => "hold_intact",
2278 }
2279}
2280
2281fn recovery_disposition_from_name(
2282 name: &str,
2283) -> Result<crate::meerkat_machine::dsl::DurableTailRecoveryDisposition, RuntimeStoreError> {
2284 use crate::meerkat_machine::dsl::DurableTailRecoveryDisposition;
2285 match name {
2286 "refuse_recovery" => Ok(DurableTailRecoveryDisposition::RefuseRecovery),
2287 "commit_completed" => Ok(DurableTailRecoveryDisposition::CommitCompleted),
2288 "repair_and_commit_interrupted" => {
2289 Ok(DurableTailRecoveryDisposition::RepairAndCommitInterrupted)
2290 }
2291 "commit_completed_retain_inputs" => {
2292 Ok(DurableTailRecoveryDisposition::CommitCompletedRetainInputs)
2293 }
2294 "hold_intact" => Ok(DurableTailRecoveryDisposition::HoldIntact),
2295 other => Err(RuntimeStoreError::ReadFailed(format!(
2296 "unknown committed recovery disposition '{other}'"
2297 ))),
2298 }
2299}
2300
2301fn recovery_hash_part(hasher: &mut Sha256, label: &str, bytes: &[u8]) {
2302 hasher.update((label.len() as u64).to_be_bytes());
2303 hasher.update(label.as_bytes());
2304 hasher.update((bytes.len() as u64).to_be_bytes());
2305 hasher.update(bytes);
2306}
2307
2308fn lifecycle_expected_version_token(
2309 lifecycle: &MachineLifecycleCommit,
2310) -> Result<String, RuntimeStoreError> {
2311 match lifecycle.expected_version() {
2312 Some(MachineLifecycleExpectedVersion::Missing) => Ok("missing".to_string()),
2313 Some(MachineLifecycleExpectedVersion::Version(version)) => {
2314 Ok(format!("version:{}", version.as_str()))
2315 }
2316 None => Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
2317 runtime_id: "prepared-recovery".to_string(),
2318 detail: "recovery lifecycle commit is not fenced on an exact observed row".to_string(),
2319 }),
2320 }
2321}
2322
2323fn recovery_sha256_token(bytes: &[u8]) -> String {
2324 format!("sha256:{:x}", Sha256::digest(bytes))
2325}
2326
2327fn is_canonical_sha256_token(token: &str) -> bool {
2328 let Some(hex) = token.strip_prefix("sha256:") else {
2329 return false;
2330 };
2331 hex.len() == 64
2332 && hex
2333 .bytes()
2334 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2335}
2336
2337#[derive(Debug, Clone)]
2339pub struct ExactInputStateObservation {
2340 state: StoredInputState,
2341 exact_row_digest: String,
2342}
2343
2344impl ExactInputStateObservation {
2345 pub fn from_exact_stored_row(
2347 state: StoredInputState,
2348 exact_row_digest: String,
2349 ) -> Result<Self, RuntimeStoreError> {
2350 if !is_canonical_sha256_token(&exact_row_digest) {
2351 return Err(RuntimeStoreError::ReadFailed(format!(
2352 "input {} has a malformed exact-row digest",
2353 state.state.input_id
2354 )));
2355 }
2356 Ok(Self {
2357 state,
2358 exact_row_digest,
2359 })
2360 }
2361
2362 #[must_use]
2364 pub fn state(&self) -> &StoredInputState {
2365 &self.state
2366 }
2367
2368 #[must_use]
2370 pub fn exact_row_digest(&self) -> &str {
2371 &self.exact_row_digest
2372 }
2373
2374 #[must_use]
2376 pub fn into_parts(self) -> (StoredInputState, String) {
2377 (self.state, self.exact_row_digest)
2378 }
2379}
2380
2381#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2389pub struct RecoveryInputSetRevision(u64);
2390
2391impl RecoveryInputSetRevision {
2392 #[must_use]
2394 pub fn from_store_generation(generation: u64) -> Self {
2395 Self(generation)
2396 }
2397
2398 #[must_use]
2400 pub fn store_generation(self) -> u64 {
2401 self.0
2402 }
2403}
2404
2405#[derive(Debug, Clone)]
2422pub struct PreparedRecoveryInputSnapshot {
2423 runtime_id: LogicalRuntimeId,
2424 input_set_revision: RecoveryInputSetRevision,
2425 rows: Vec<(StoredInputState, String)>,
2426 exact_set_token: String,
2427}
2428
2429impl PreparedRecoveryInputSnapshot {
2430 pub fn from_exact_nonterminal_rows(
2442 runtime_id: LogicalRuntimeId,
2443 input_set_revision: RecoveryInputSetRevision,
2444 mut rows: Vec<(StoredInputState, String)>,
2445 ) -> Result<Self, RuntimeStoreError> {
2446 if runtime_id.0.is_empty() {
2447 return Err(RuntimeStoreError::ReadFailed(
2448 "recovery input snapshot has an empty logical runtime id".to_string(),
2449 ));
2450 }
2451 rows.sort_by(|(left, _), (right, _)| {
2452 left.state
2453 .input_id
2454 .to_string()
2455 .cmp(&right.state.input_id.to_string())
2456 });
2457 for (index, (state, row_token)) in rows.iter().enumerate() {
2458 if !input_state_is_recovery_nonterminal(state) {
2459 return Err(RuntimeStoreError::ReadFailed(format!(
2460 "recovery input snapshot includes terminal input {}",
2461 state.state.input_id
2462 )));
2463 }
2464 if !is_canonical_sha256_token(row_token) {
2465 return Err(RuntimeStoreError::ReadFailed(format!(
2466 "recovery input snapshot row {} has a malformed exact-row token",
2467 state.state.input_id
2468 )));
2469 }
2470 if index > 0 && rows[index - 1].0.state.input_id == state.state.input_id {
2471 return Err(RuntimeStoreError::ReadFailed(format!(
2472 "recovery input snapshot repeats input {}",
2473 state.state.input_id
2474 )));
2475 }
2476 }
2477
2478 let mut hasher = Sha256::new();
2479 recovery_hash_part(&mut hasher, "domain", b"meerkat.recovery-input-set.v1");
2480 recovery_hash_part(&mut hasher, "runtime_id", runtime_id.0.as_bytes());
2481 recovery_hash_part(
2482 &mut hasher,
2483 "nonterminal_row_count",
2484 &(rows.len() as u64).to_be_bytes(),
2485 );
2486 for (state, row_token) in &rows {
2487 recovery_hash_part(
2488 &mut hasher,
2489 "input_id",
2490 state.state.input_id.to_string().as_bytes(),
2491 );
2492 recovery_hash_part(&mut hasher, "exact_row_token", row_token.as_bytes());
2493 }
2494 let exact_set_token = format!("sha256:{:x}", hasher.finalize());
2495 Ok(Self {
2496 runtime_id,
2497 input_set_revision,
2498 rows,
2499 exact_set_token,
2500 })
2501 }
2502
2503 #[must_use]
2505 pub fn runtime_id(&self) -> &LogicalRuntimeId {
2506 &self.runtime_id
2507 }
2508
2509 #[must_use]
2511 pub fn input_set_revision(&self) -> RecoveryInputSetRevision {
2512 self.input_set_revision
2513 }
2514
2515 #[must_use]
2517 pub fn exact_set_token(&self) -> &str {
2518 &self.exact_set_token
2519 }
2520
2521 #[must_use]
2524 pub fn into_parts(
2525 self,
2526 ) -> (
2527 Vec<(StoredInputState, String)>,
2528 RecoveryInputSetRevision,
2529 String,
2530 ) {
2531 (self.rows, self.input_set_revision, self.exact_set_token)
2532 }
2533}
2534
2535#[derive(Debug, Clone)]
2541pub struct PreparedRecoveryInputDelete {
2542 input_id: InputId,
2543 expected_row_digest: String,
2544}
2545
2546impl PreparedRecoveryInputDelete {
2547 pub(crate) fn from_exact_observation(
2548 input_id: InputId,
2549 expected_row_digest: String,
2550 ) -> Result<Self, RuntimeStoreError> {
2551 if !is_canonical_sha256_token(&expected_row_digest) {
2552 return Err(RuntimeStoreError::InvalidInputStateBatchCas {
2553 reason: format!(
2554 "recovery delete for input {input_id} has a malformed predecessor digest"
2555 ),
2556 });
2557 }
2558 Ok(Self {
2559 input_id,
2560 expected_row_digest,
2561 })
2562 }
2563
2564 #[must_use]
2566 pub fn input_id(&self) -> &InputId {
2567 &self.input_id
2568 }
2569
2570 #[must_use]
2572 pub fn expected_row_digest(&self) -> &str {
2573 &self.expected_row_digest
2574 }
2575}
2576
2577#[derive(Debug, Clone)]
2579pub enum RecoveryInputStateMutation {
2580 Upsert(InputStatePersistenceRecord),
2582 Delete(PreparedRecoveryInputDelete),
2584}
2585
2586impl RecoveryInputStateMutation {
2587 pub(crate) fn delete(
2591 input_id: InputId,
2592 expected_row_digest: String,
2593 ) -> Result<Self, RuntimeStoreError> {
2594 PreparedRecoveryInputDelete::from_exact_observation(input_id, expected_row_digest)
2595 .map(Self::Delete)
2596 }
2597}
2598
2599#[derive(Debug, Clone)]
2606struct PreparedRecoveryInputUpdate {
2607 record: InputStatePersistenceRecord,
2608 input_id: InputId,
2609 expected_row_digest: String,
2610 target_bytes: Vec<u8>,
2611}
2612
2613impl PartialEq for PreparedRecoveryInputUpdate {
2614 fn eq(&self, other: &Self) -> bool {
2615 self.input_id == other.input_id
2616 && self.expected_row_digest == other.expected_row_digest
2617 && self.target_bytes == other.target_bytes
2618 }
2619}
2620
2621impl Eq for PreparedRecoveryInputUpdate {}
2622
2623impl PreparedRecoveryInputUpdate {
2624 fn seal(record: InputStatePersistenceRecord) -> Result<Self, String> {
2625 let input_id = record.as_stored().state.input_id.clone();
2626 let expected_row_digest = record
2627 .expected_row_digest()
2628 .ok_or_else(|| {
2629 format!("recovery input {input_id} is not fenced on an exact predecessor row")
2630 })?
2631 .to_string();
2632 if !is_canonical_sha256_token(&expected_row_digest) {
2633 return Err(format!(
2634 "recovery input {input_id} has a malformed predecessor digest"
2635 ));
2636 }
2637 let target_bytes = serde_json::to_vec(record.as_stored()).map_err(|error| {
2638 format!("failed to encode exact recovery input target {input_id}: {error}")
2639 })?;
2640 Ok(Self {
2641 record,
2642 input_id,
2643 expected_row_digest,
2644 target_bytes,
2645 })
2646 }
2647
2648 fn decode(
2649 input_id: InputId,
2650 expected_row_digest: String,
2651 target_bytes: Vec<u8>,
2652 ) -> Result<Self, String> {
2653 if !is_canonical_sha256_token(&expected_row_digest) {
2654 return Err(format!(
2655 "recovery input {input_id} has a malformed predecessor digest"
2656 ));
2657 }
2658 if target_bytes.is_empty() {
2659 return Err(format!(
2660 "recovery input {input_id} has an empty serialized target"
2661 ));
2662 }
2663 let bundle: StoredInputState = serde_json::from_slice(&target_bytes)
2664 .map_err(|error| format!("recovery input {input_id} target is invalid: {error}"))?;
2665 if bundle.state.input_id != input_id {
2666 return Err(format!(
2667 "recovery input target {} differs from sealed input {input_id}",
2668 bundle.state.input_id
2669 ));
2670 }
2671 let canonical_target_bytes = serde_json::to_vec(&bundle).map_err(|error| {
2672 format!("failed to canonicalize recovery input target {input_id}: {error}")
2673 })?;
2674 if canonical_target_bytes != target_bytes {
2675 return Err(format!(
2676 "recovery input {input_id} target is not in its canonical serialized form"
2677 ));
2678 }
2679 let record = InputStatePersistenceRecord::from_machine_snapshot(bundle)
2680 .map_err(|error| {
2681 format!("recovery input {input_id} target is not machine-authorized: {error}")
2682 })?
2683 .with_expected_row_digest(expected_row_digest.clone());
2684 Ok(Self {
2685 record,
2686 input_id,
2687 expected_row_digest,
2688 target_bytes,
2689 })
2690 }
2691}
2692
2693#[derive(Debug)]
2694pub(crate) enum PreparedRecoveryInputStateMutation {
2695 Upsert {
2696 replacement: StoredInputState,
2697 expected_row_digest: String,
2698 },
2699 Delete {
2700 input_id: InputId,
2701 expected_row_digest: String,
2702 },
2703}
2704
2705impl PreparedRecoveryInputStateMutation {
2706 pub(crate) fn input_id(&self) -> &InputId {
2707 match self {
2708 Self::Upsert { replacement, .. } => &replacement.state.input_id,
2709 Self::Delete { input_id, .. } => input_id,
2710 }
2711 }
2712
2713 pub(crate) fn expected_row_digest(&self) -> &str {
2714 match self {
2715 Self::Upsert {
2716 expected_row_digest,
2717 ..
2718 }
2719 | Self::Delete {
2720 expected_row_digest,
2721 ..
2722 } => expected_row_digest,
2723 }
2724 }
2725}
2726
2727pub(crate) fn prepare_recovery_input_state_mutations(
2733 mutations: &[RecoveryInputStateMutation],
2734) -> Result<Vec<PreparedRecoveryInputStateMutation>, RuntimeStoreError> {
2735 let mut prepared = mutations
2736 .iter()
2737 .cloned()
2738 .map(|mutation| match mutation {
2739 RecoveryInputStateMutation::Upsert(record) => {
2740 let update = PreparedRecoveryInputUpdate::seal(record)
2741 .map_err(|reason| RuntimeStoreError::InvalidInputStateBatchCas { reason })?;
2742 Ok(PreparedRecoveryInputStateMutation::Upsert {
2743 replacement: update.record.clone_stored(),
2744 expected_row_digest: update.expected_row_digest,
2745 })
2746 }
2747 RecoveryInputStateMutation::Delete(delete) => {
2748 if !is_canonical_sha256_token(&delete.expected_row_digest) {
2749 return Err(RuntimeStoreError::InvalidInputStateBatchCas {
2750 reason: format!(
2751 "recovery delete for input {} has a malformed predecessor digest",
2752 delete.input_id
2753 ),
2754 });
2755 }
2756 Ok(PreparedRecoveryInputStateMutation::Delete {
2757 input_id: delete.input_id,
2758 expected_row_digest: delete.expected_row_digest,
2759 })
2760 }
2761 })
2762 .collect::<Result<Vec<_>, _>>()?;
2763 prepared.sort_by(|left, right| {
2764 left.input_id()
2765 .to_string()
2766 .cmp(&right.input_id().to_string())
2767 });
2768 if prepared
2769 .windows(2)
2770 .any(|pair| pair[0].input_id() == pair[1].input_id())
2771 {
2772 return Err(RuntimeStoreError::InvalidInputStateBatchCas {
2773 reason: "recovery input mutations must have unique input ids".to_string(),
2774 });
2775 }
2776 Ok(prepared)
2777}
2778
2779fn validate_recovery_input_update_order(
2780 input_updates: &[PreparedRecoveryInputUpdate],
2781) -> Result<(), String> {
2782 if input_updates
2783 .windows(2)
2784 .any(|window| window[0].input_id.0 >= window[1].input_id.0)
2785 {
2786 return Err(
2787 "recovery input updates must have unique input ids in canonical order".to_string(),
2788 );
2789 }
2790 Ok(())
2791}
2792
2793#[derive(Debug, Clone, PartialEq, Eq)]
2795enum PreparedRecoverySessionAuthority {
2796 WholeBlob {
2797 base_store_revision: u64,
2798 base_blob_sha256: String,
2799 provisional_candidate_blob_sha256: String,
2800 provisional_candidate_sequence: u64,
2801 recovered_blob_sha256: String,
2802 },
2803 HeadCanonical {
2804 committed_store_revision: u64,
2805 committed_head_token: String,
2806 physical_store_revision: u64,
2807 physical_head_token: String,
2808 recovered_head_token: String,
2809 },
2810}
2811
2812#[derive(Debug, Clone, PartialEq, Eq)]
2820pub struct PreparedRecoveryEvidence {
2821 session_id: meerkat_core::types::SessionId,
2822 candidate_id: String,
2823 candidate_run_id: RunId,
2824 class: crate::meerkat_machine::dsl::DurableTailRecoveryClass,
2825 disposition: crate::meerkat_machine::dsl::DurableTailRecoveryDisposition,
2826 session_authority: PreparedRecoverySessionAuthority,
2827 receipt_digest_enrichments: Vec<PreparedRecoveryReceiptDigestEnrichment>,
2828 predecessor_nonterminal_input_set_revision: RecoveryInputSetRevision,
2829 predecessor_nonterminal_input_set_token: String,
2830 input_updates: Vec<PreparedRecoveryInputUpdate>,
2831 lifecycle_target_token: String,
2832 lifecycle_target_bytes: Vec<u8>,
2833 exact_witness: String,
2834}
2835
2836impl PreparedRecoveryEvidence {
2837 #[allow(clippy::too_many_arguments)]
2838 pub(crate) fn seal_head_canonical(
2839 recovered: &meerkat_core::Session,
2840 document: &BoundSessionCommit,
2841 session_id: meerkat_core::types::SessionId,
2842 candidate_id: String,
2843 candidate_run_id: RunId,
2844 class: crate::meerkat_machine::dsl::DurableTailRecoveryClass,
2845 disposition: crate::meerkat_machine::dsl::DurableTailRecoveryDisposition,
2846 committed_store_revision: u64,
2847 committed_head_token: String,
2848 physical_store_revision: u64,
2849 physical_head_token: String,
2850 recovered_head_token: String,
2851 receipt_digest_enrichments: Vec<PreparedRecoveryReceiptDigestEnrichment>,
2852 predecessor_nonterminal_input_set_revision: RecoveryInputSetRevision,
2853 predecessor_nonterminal_input_set_token: String,
2854 input_updates: Vec<InputStatePersistenceRecord>,
2855 receipt: &RunBoundaryReceipt,
2856 lifecycle: &MachineLifecycleCommit,
2857 ) -> Result<Self, RuntimeStoreError> {
2858 let conflict = |detail: String| RuntimeStoreError::SessionPersistenceAuthorityConflict {
2859 runtime_id: session_id.to_string(),
2860 detail,
2861 };
2862 if candidate_id.is_empty()
2863 || committed_store_revision == 0
2864 || physical_store_revision <= committed_store_revision
2865 || committed_head_token.is_empty()
2866 || physical_head_token.is_empty()
2867 || recovered_head_token.is_empty()
2868 || committed_head_token == physical_head_token
2869 || !is_canonical_sha256_token(&predecessor_nonterminal_input_set_token)
2870 {
2871 return Err(conflict(
2872 "prepared recovery contains an invalid store-issued authority transition"
2873 .to_string(),
2874 ));
2875 }
2876 let valid_disposition = matches!(
2877 (class, disposition),
2878 (
2879 crate::meerkat_machine::dsl::DurableTailRecoveryClass::CompletedCandidate,
2880 crate::meerkat_machine::dsl::DurableTailRecoveryDisposition::CommitCompleted
2881 | crate::meerkat_machine::dsl::DurableTailRecoveryDisposition::CommitCompletedRetainInputs
2882 ) | (
2883 crate::meerkat_machine::dsl::DurableTailRecoveryClass::InterruptedRepairableCandidate,
2884 crate::meerkat_machine::dsl::DurableTailRecoveryDisposition::RepairAndCommitInterrupted
2885 )
2886 );
2887 if !valid_disposition {
2888 return Err(conflict(format!(
2889 "recovery class {} cannot realize disposition {}",
2890 recovery_class_name(class),
2891 recovery_disposition_name(disposition)
2892 )));
2893 }
2894
2895 if recovered.id() != &session_id {
2896 return Err(conflict(format!(
2897 "prepared recovery document belongs to {}, not {session_id}",
2898 recovered.id()
2899 )));
2900 }
2901 let head_boundary = document.head_canonical().ok_or_else(|| {
2902 conflict("prepared recovery has no sealed head-canonical mutation".to_string())
2903 })?;
2904 let successor_head = head_boundary.mutation().successor_head();
2905 let recovered_message_count = u64::try_from(recovered.messages().len()).map_err(|_| {
2906 conflict("recovered document message count exceeds u64 authority".to_string())
2907 })?;
2908 let conversation_digest = recovered.transcript_content_digest().map_err(|error| {
2909 conflict(format!(
2910 "failed to derive recovered conversation digest: {error}"
2911 ))
2912 })?;
2913 let metadata_matches =
2914 successor_head
2915 .matches_session_metadata(recovered)
2916 .map_err(|error| {
2917 conflict(format!(
2918 "failed to compare recovered document metadata authority: {error}"
2919 ))
2920 })?;
2921 if &successor_head.id != recovered.id()
2922 || successor_head.version != recovered.version()
2923 || successor_head.head_revision != conversation_digest
2924 || successor_head.message_count != recovered_message_count
2925 || successor_head.created_at != recovered.created_at()
2926 || successor_head.updated_at != recovered.updated_at()
2927 || successor_head.usage != recovered.total_usage()
2928 || !metadata_matches
2929 {
2930 return Err(conflict(
2931 "prepared recovered head differs from the exact recovered document".to_string(),
2932 ));
2933 }
2934 let derived_recovered_head_token = meerkat_core::session_head_cas_token(successor_head)
2935 .map_err(|error| {
2936 conflict(format!(
2937 "failed to derive recovered HeadCanonical token: {error}"
2938 ))
2939 })?;
2940 if derived_recovered_head_token != recovered_head_token {
2941 return Err(conflict(
2942 "prepared recovered head differs from the sealed successor token".to_string(),
2943 ));
2944 }
2945 if receipt.run_id != candidate_run_id || receipt.message_count != recovered.messages().len()
2946 {
2947 return Err(conflict(
2948 "recovery receipt does not bind the candidate run and exact message count"
2949 .to_string(),
2950 ));
2951 }
2952 if receipt.conversation_digest.as_deref() != Some(conversation_digest.as_str()) {
2953 return Err(conflict(
2954 "recovery receipt does not bind the exact recovered conversation".to_string(),
2955 ));
2956 }
2957 let mut previous_enrichment_sequence = None;
2958 for enrichment in &receipt_digest_enrichments {
2959 let original = enrichment.original_receipt();
2960 if original.run_id != candidate_run_id
2961 || original.conversation_digest.is_some()
2962 || previous_enrichment_sequence
2963 .is_some_and(|previous| original.sequence <= previous)
2964 || original.message_count > recovered.messages().len()
2965 {
2966 return Err(conflict(
2967 "prepared recovery receipt enrichment has an invalid run, sequence, count, or pre-migration shape"
2968 .to_string(),
2969 ));
2970 }
2971 let derived = recovered
2972 .transcript_prefix_digest(original.message_count)
2973 .map_err(|error| {
2974 conflict(format!(
2975 "failed to verify recovery receipt enrichment prefix: {error}"
2976 ))
2977 })?;
2978 if derived != enrichment.derived_conversation_digest()
2979 || enrichment.original_exact_row_token().is_empty()
2980 {
2981 return Err(conflict(
2982 "prepared recovery receipt enrichment differs from the exact recovered transcript prefix"
2983 .to_string(),
2984 ));
2985 }
2986 previous_enrichment_sequence = Some(original.sequence);
2987 }
2988
2989 let input_updates = input_updates
2990 .into_iter()
2991 .map(PreparedRecoveryInputUpdate::seal)
2992 .collect::<Result<Vec<_>, _>>()
2993 .map_err(&conflict)?;
2994 validate_recovery_input_update_order(&input_updates).map_err(&conflict)?;
2995 let lifecycle_target_bytes = lifecycle.store_record().encode()?;
2996 let lifecycle_target_token = recovery_sha256_token(&lifecycle_target_bytes);
2997 let _ = lifecycle_expected_version_token(lifecycle)?;
3002 let session_authority = PreparedRecoverySessionAuthority::HeadCanonical {
3003 committed_store_revision,
3004 committed_head_token,
3005 physical_store_revision,
3006 physical_head_token,
3007 recovered_head_token,
3008 };
3009
3010 let mut evidence = Self {
3011 session_id,
3012 candidate_id,
3013 candidate_run_id,
3014 class,
3015 disposition,
3016 session_authority,
3017 receipt_digest_enrichments,
3018 predecessor_nonterminal_input_set_revision,
3019 predecessor_nonterminal_input_set_token,
3020 input_updates,
3021 lifecycle_target_token,
3022 lifecycle_target_bytes,
3023 exact_witness: String::new(),
3024 };
3025 evidence.exact_witness = evidence.compute_exact_witness(receipt).map_err(|error| {
3026 RuntimeStoreError::WriteFailed(format!(
3027 "failed to encode exact recovery witness: {error}"
3028 ))
3029 })?;
3030 evidence.verify_head_canonical_boundary(document, receipt)?;
3031 Ok(evidence)
3032 }
3033
3034 #[allow(clippy::too_many_arguments)]
3035 pub(crate) fn seal_whole_blob(
3036 recovered: &meerkat_core::Session,
3037 repaired_document: Option<&BoundSessionCommit>,
3038 session_id: meerkat_core::types::SessionId,
3039 candidate_id: String,
3040 candidate_run_id: RunId,
3041 class: crate::meerkat_machine::dsl::DurableTailRecoveryClass,
3042 disposition: crate::meerkat_machine::dsl::DurableTailRecoveryDisposition,
3043 base_store_revision: u64,
3044 base_blob_sha256: String,
3045 provisional_candidate_blob_sha256: String,
3046 provisional_candidate_sequence: u64,
3047 recovered_blob_sha256: String,
3048 receipt_digest_enrichments: Vec<PreparedRecoveryReceiptDigestEnrichment>,
3049 predecessor_nonterminal_input_set_revision: RecoveryInputSetRevision,
3050 predecessor_nonterminal_input_set_token: String,
3051 input_updates: Vec<InputStatePersistenceRecord>,
3052 receipt: &RunBoundaryReceipt,
3053 lifecycle: &MachineLifecycleCommit,
3054 ) -> Result<Self, RuntimeStoreError> {
3055 let conflict = |detail: String| RuntimeStoreError::SessionPersistenceAuthorityConflict {
3056 runtime_id: session_id.to_string(),
3057 detail,
3058 };
3059 if candidate_id.is_empty()
3060 || base_store_revision == 0
3061 || base_blob_sha256.is_empty()
3062 || provisional_candidate_blob_sha256.is_empty()
3063 || provisional_candidate_sequence == 0
3064 || recovered_blob_sha256.is_empty()
3065 || !is_canonical_sha256_token(&predecessor_nonterminal_input_set_token)
3066 {
3067 return Err(conflict(
3068 "prepared WholeBlob recovery contains an invalid store-issued authority transition"
3069 .to_string(),
3070 ));
3071 }
3072 if recovered.id() != &session_id {
3073 return Err(conflict(format!(
3074 "prepared recovery document belongs to {}, not {session_id}",
3075 recovered.id()
3076 )));
3077 }
3078 let valid_disposition = matches!(
3079 (class, disposition),
3080 (
3081 crate::meerkat_machine::dsl::DurableTailRecoveryClass::CompletedCandidate,
3082 crate::meerkat_machine::dsl::DurableTailRecoveryDisposition::CommitCompleted
3083 | crate::meerkat_machine::dsl::DurableTailRecoveryDisposition::CommitCompletedRetainInputs
3084 ) | (
3085 crate::meerkat_machine::dsl::DurableTailRecoveryClass::InterruptedRepairableCandidate,
3086 crate::meerkat_machine::dsl::DurableTailRecoveryDisposition::RepairAndCommitInterrupted
3087 )
3088 );
3089 if !valid_disposition {
3090 return Err(conflict(format!(
3091 "recovery class {} cannot realize disposition {}",
3092 recovery_class_name(class),
3093 recovery_disposition_name(disposition)
3094 )));
3095 }
3096 match class {
3097 crate::meerkat_machine::dsl::DurableTailRecoveryClass::CompletedCandidate => {
3098 if recovered_blob_sha256 != provisional_candidate_blob_sha256
3099 || repaired_document.is_some()
3100 {
3101 return Err(conflict(
3102 "completed WholeBlob recovery must promote the exact provisional candidate without a repaired artifact"
3103 .to_string(),
3104 ));
3105 }
3106 }
3107 crate::meerkat_machine::dsl::DurableTailRecoveryClass::InterruptedRepairableCandidate => {
3108 if recovered_blob_sha256 == provisional_candidate_blob_sha256 {
3109 return Err(conflict(
3110 "interrupted WholeBlob recovery must install a distinct repaired artifact"
3111 .to_string(),
3112 ));
3113 }
3114 let document = repaired_document.ok_or_else(|| {
3115 conflict(
3116 "interrupted WholeBlob recovery has no sealed repaired artifact"
3117 .to_string(),
3118 )
3119 })?;
3120 if document.head_canonical().is_some() {
3121 return Err(conflict(
3122 "WholeBlob recovery unexpectedly carries a HeadCanonical mutation"
3123 .to_string(),
3124 ));
3125 }
3126 let artifact = document.whole_blob_artifact().map_err(|error| {
3127 conflict(format!(
3128 "failed to materialize recovered WholeBlob artifact: {error}"
3129 ))
3130 })?;
3131 if artifact.row_sha256_token() != recovered_blob_sha256 {
3132 return Err(conflict(
3133 "recovered WholeBlob bytes differ from the sealed successor digest"
3134 .to_string(),
3135 ));
3136 }
3137 }
3138 crate::meerkat_machine::dsl::DurableTailRecoveryClass::Ambiguous => {
3139 return Err(conflict(
3140 "ambiguous WholeBlob recovery cannot be sealed".to_string(),
3141 ));
3142 }
3143 }
3144 if receipt.run_id != candidate_run_id || receipt.message_count != recovered.messages().len()
3145 {
3146 return Err(conflict(
3147 "recovery receipt does not bind the candidate run and exact message count"
3148 .to_string(),
3149 ));
3150 }
3151 let conversation_digest = recovered.transcript_content_digest().map_err(|error| {
3152 conflict(format!(
3153 "failed to derive recovered conversation digest: {error}"
3154 ))
3155 })?;
3156 if receipt.conversation_digest.as_deref() != Some(conversation_digest.as_str()) {
3157 return Err(conflict(
3158 "recovery receipt does not bind the exact recovered conversation".to_string(),
3159 ));
3160 }
3161 let mut previous_enrichment_sequence = None;
3162 for enrichment in &receipt_digest_enrichments {
3163 let original = enrichment.original_receipt();
3164 if original.run_id != candidate_run_id
3165 || original.conversation_digest.is_some()
3166 || previous_enrichment_sequence
3167 .is_some_and(|previous| original.sequence <= previous)
3168 || original.message_count > recovered.messages().len()
3169 {
3170 return Err(conflict(
3171 "prepared recovery receipt enrichment has an invalid run, sequence, count, or pre-migration shape"
3172 .to_string(),
3173 ));
3174 }
3175 let derived = recovered
3176 .transcript_prefix_digest(original.message_count)
3177 .map_err(|error| {
3178 conflict(format!(
3179 "failed to verify recovery receipt enrichment prefix: {error}"
3180 ))
3181 })?;
3182 if derived != enrichment.derived_conversation_digest()
3183 || enrichment.original_exact_row_token().is_empty()
3184 {
3185 return Err(conflict(
3186 "prepared recovery receipt enrichment differs from the exact recovered transcript prefix"
3187 .to_string(),
3188 ));
3189 }
3190 previous_enrichment_sequence = Some(original.sequence);
3191 }
3192 let input_updates = input_updates
3193 .into_iter()
3194 .map(PreparedRecoveryInputUpdate::seal)
3195 .collect::<Result<Vec<_>, _>>()
3196 .map_err(&conflict)?;
3197 validate_recovery_input_update_order(&input_updates).map_err(&conflict)?;
3198 let lifecycle_target_bytes = lifecycle.store_record().encode()?;
3199 let lifecycle_target_token = recovery_sha256_token(&lifecycle_target_bytes);
3200 let _ = lifecycle_expected_version_token(lifecycle)?;
3201 let mut evidence = Self {
3202 session_id,
3203 candidate_id,
3204 candidate_run_id,
3205 class,
3206 disposition,
3207 session_authority: PreparedRecoverySessionAuthority::WholeBlob {
3208 base_store_revision,
3209 base_blob_sha256,
3210 provisional_candidate_blob_sha256,
3211 provisional_candidate_sequence,
3212 recovered_blob_sha256,
3213 },
3214 receipt_digest_enrichments,
3215 predecessor_nonterminal_input_set_revision,
3216 predecessor_nonterminal_input_set_token,
3217 input_updates,
3218 lifecycle_target_token,
3219 lifecycle_target_bytes,
3220 exact_witness: String::new(),
3221 };
3222 evidence.exact_witness = evidence.compute_exact_witness(receipt).map_err(|error| {
3223 RuntimeStoreError::WriteFailed(format!(
3224 "failed to encode exact recovery witness: {error}"
3225 ))
3226 })?;
3227 Ok(evidence)
3228 }
3229
3230 fn compute_exact_witness(
3231 &self,
3232 receipt: &RunBoundaryReceipt,
3233 ) -> Result<String, serde_json::Error> {
3234 let receipt_json = serde_json::to_vec(receipt)?;
3235 let mut hasher = Sha256::new();
3236 recovery_hash_part(
3237 &mut hasher,
3238 "domain",
3239 b"meerkat.prepared-recovery-evidence.v6",
3240 );
3241 recovery_hash_part(
3242 &mut hasher,
3243 "session_id",
3244 self.session_id.to_string().as_bytes(),
3245 );
3246 recovery_hash_part(&mut hasher, "candidate_id", self.candidate_id.as_bytes());
3247 recovery_hash_part(
3248 &mut hasher,
3249 "candidate_run_id",
3250 self.candidate_run_id.to_string().as_bytes(),
3251 );
3252 recovery_hash_part(
3253 &mut hasher,
3254 "class",
3255 recovery_class_name(self.class).as_bytes(),
3256 );
3257 recovery_hash_part(
3258 &mut hasher,
3259 "disposition",
3260 recovery_disposition_name(self.disposition).as_bytes(),
3261 );
3262 match &self.session_authority {
3263 PreparedRecoverySessionAuthority::WholeBlob {
3264 base_store_revision,
3265 base_blob_sha256,
3266 provisional_candidate_blob_sha256,
3267 provisional_candidate_sequence,
3268 recovered_blob_sha256,
3269 } => {
3270 recovery_hash_part(&mut hasher, "profile", b"whole_blob_v1");
3271 recovery_hash_part(
3272 &mut hasher,
3273 "base_store_revision",
3274 &base_store_revision.to_be_bytes(),
3275 );
3276 recovery_hash_part(&mut hasher, "base_blob_sha256", base_blob_sha256.as_bytes());
3277 recovery_hash_part(
3278 &mut hasher,
3279 "provisional_candidate_blob_sha256",
3280 provisional_candidate_blob_sha256.as_bytes(),
3281 );
3282 recovery_hash_part(
3283 &mut hasher,
3284 "provisional_candidate_sequence",
3285 &provisional_candidate_sequence.to_be_bytes(),
3286 );
3287 recovery_hash_part(
3288 &mut hasher,
3289 "recovered_blob_sha256",
3290 recovered_blob_sha256.as_bytes(),
3291 );
3292 }
3293 PreparedRecoverySessionAuthority::HeadCanonical {
3294 committed_store_revision,
3295 committed_head_token,
3296 physical_store_revision,
3297 physical_head_token,
3298 recovered_head_token,
3299 } => {
3300 recovery_hash_part(&mut hasher, "profile", b"head_canonical_v1");
3301 recovery_hash_part(
3302 &mut hasher,
3303 "committed_store_revision",
3304 &committed_store_revision.to_be_bytes(),
3305 );
3306 recovery_hash_part(
3307 &mut hasher,
3308 "committed_head_token",
3309 committed_head_token.as_bytes(),
3310 );
3311 recovery_hash_part(
3312 &mut hasher,
3313 "physical_store_revision",
3314 &physical_store_revision.to_be_bytes(),
3315 );
3316 recovery_hash_part(
3317 &mut hasher,
3318 "physical_head_token",
3319 physical_head_token.as_bytes(),
3320 );
3321 recovery_hash_part(
3322 &mut hasher,
3323 "recovered_head_token",
3324 recovered_head_token.as_bytes(),
3325 );
3326 }
3327 }
3328 recovery_hash_part(
3329 &mut hasher,
3330 "receipt_digest_enrichment_count",
3331 &(self.receipt_digest_enrichments.len() as u64).to_be_bytes(),
3332 );
3333 for enrichment in &self.receipt_digest_enrichments {
3334 let original_json = serde_json::to_vec(enrichment.original_receipt())?;
3335 recovery_hash_part(
3336 &mut hasher,
3337 "receipt_digest_enrichment_original",
3338 &original_json,
3339 );
3340 recovery_hash_part(
3341 &mut hasher,
3342 "receipt_digest_enrichment_original_token",
3343 enrichment.original_exact_row_token().as_bytes(),
3344 );
3345 recovery_hash_part(
3346 &mut hasher,
3347 "receipt_digest_enrichment_derived_digest",
3348 enrichment.derived_conversation_digest().as_bytes(),
3349 );
3350 }
3351 recovery_hash_part(
3352 &mut hasher,
3353 "predecessor_nonterminal_input_set_revision",
3354 &self
3355 .predecessor_nonterminal_input_set_revision
3356 .store_generation()
3357 .to_be_bytes(),
3358 );
3359 recovery_hash_part(
3360 &mut hasher,
3361 "predecessor_nonterminal_input_set_token",
3362 self.predecessor_nonterminal_input_set_token.as_bytes(),
3363 );
3364 recovery_hash_part(
3365 &mut hasher,
3366 "input_update_count",
3367 &(self.input_updates.len() as u64).to_be_bytes(),
3368 );
3369 for input_update in &self.input_updates {
3370 recovery_hash_part(
3371 &mut hasher,
3372 "input_update_id",
3373 input_update.input_id.to_string().as_bytes(),
3374 );
3375 recovery_hash_part(
3376 &mut hasher,
3377 "input_update_expected_row_digest",
3378 input_update.expected_row_digest.as_bytes(),
3379 );
3380 recovery_hash_part(
3381 &mut hasher,
3382 "input_update_target",
3383 &input_update.target_bytes,
3384 );
3385 }
3386 recovery_hash_part(&mut hasher, "receipt", &receipt_json);
3387 recovery_hash_part(
3388 &mut hasher,
3389 "lifecycle_target_token",
3390 self.lifecycle_target_token.as_bytes(),
3391 );
3392 recovery_hash_part(
3393 &mut hasher,
3394 "lifecycle_target",
3395 &self.lifecycle_target_bytes,
3396 );
3397 Ok(format!("sha256:{:x}", hasher.finalize()))
3398 }
3399
3400 pub(crate) fn verify_head_canonical_boundary(
3401 &self,
3402 document: &BoundSessionCommit,
3403 receipt: &RunBoundaryReceipt,
3404 ) -> Result<(), RuntimeStoreError> {
3405 let conflict = |detail: String| RuntimeStoreError::SessionPersistenceAuthorityConflict {
3406 runtime_id: self.session_id.to_string(),
3407 detail,
3408 };
3409 let boundary = document.head_canonical().ok_or_else(|| {
3410 conflict("prepared recovery has no sealed head-canonical mutation".to_string())
3411 })?;
3412 let PreparedRecoverySessionAuthority::HeadCanonical {
3413 physical_head_token,
3414 recovered_head_token,
3415 ..
3416 } = &self.session_authority
3417 else {
3418 return Err(conflict(
3419 "WholeBlob recovery evidence cannot authorize a HeadCanonical mutation".to_string(),
3420 ));
3421 };
3422 let mutation = boundary.mutation();
3423 if mutation.session_id() != &self.session_id
3424 || mutation.predecessor_head_token() != Some(physical_head_token.as_str())
3425 {
3426 return Err(conflict(
3427 "prepared recovery head mutation differs from sealed source/successor authority"
3428 .to_string(),
3429 ));
3430 }
3431 let successor = mutation.successor_head();
3432 let successor_token = meerkat_core::session_head_cas_token(successor).map_err(|error| {
3433 conflict(format!(
3434 "prepared recovery successor token is invalid: {error}"
3435 ))
3436 })?;
3437 let receipt_count = u64::try_from(receipt.message_count).map_err(|_| {
3438 conflict("recovery receipt message count does not fit head authority".to_string())
3439 })?;
3440 if successor_token != *recovered_head_token
3441 || successor.message_count != receipt_count
3442 || receipt.conversation_digest.as_deref() != Some(successor.head_revision.as_str())
3443 {
3444 return Err(conflict(
3445 "prepared recovery head does not bind the receipt's exact transcript".to_string(),
3446 ));
3447 }
3448 Ok(())
3449 }
3450
3451 pub(crate) fn verify_input_updates(
3452 &self,
3453 input_updates: &[InputStatePersistenceRecord],
3454 ) -> Result<(), RuntimeStoreError> {
3455 let conflict = |detail: String| RuntimeStoreError::SessionPersistenceAuthorityConflict {
3456 runtime_id: self.session_id.to_string(),
3457 detail,
3458 };
3459 let input_updates = input_updates
3460 .iter()
3461 .cloned()
3462 .map(PreparedRecoveryInputUpdate::seal)
3463 .collect::<Result<Vec<_>, _>>()
3464 .map_err(&conflict)?;
3465 validate_recovery_input_update_order(&input_updates).map_err(&conflict)?;
3466 if input_updates != self.input_updates {
3467 return Err(conflict(
3468 "recovery input effects differ from sealed evidence".to_string(),
3469 ));
3470 }
3471 Ok(())
3472 }
3473
3474 pub(crate) fn verify_request_effects(
3475 &self,
3476 receipt: &RunBoundaryReceipt,
3477 lifecycle: &MachineLifecycleCommit,
3478 ) -> Result<(), RuntimeStoreError> {
3479 let conflict = |detail: String| RuntimeStoreError::SessionPersistenceAuthorityConflict {
3480 runtime_id: self.session_id.to_string(),
3481 detail,
3482 };
3483 let _ = lifecycle_expected_version_token(lifecycle)?;
3486 let lifecycle_target_bytes = lifecycle.store_record().encode()?;
3487 let lifecycle_target_token = recovery_sha256_token(&lifecycle_target_bytes);
3488 if lifecycle_target_token != self.lifecycle_target_token
3489 || lifecycle_target_bytes != self.lifecycle_target_bytes
3490 {
3491 return Err(conflict(
3492 "recovery lifecycle target differs from sealed evidence".to_string(),
3493 ));
3494 }
3495 let exact_witness = self.compute_exact_witness(receipt).map_err(|error| {
3496 RuntimeStoreError::WriteFailed(format!(
3497 "failed to re-encode exact recovery witness: {error}"
3498 ))
3499 })?;
3500 if exact_witness != self.exact_witness {
3501 return Err(conflict(
3502 "recovery receipt or sealed effects differ from exact evidence".to_string(),
3503 ));
3504 }
3505 Ok(())
3506 }
3507
3508 pub(crate) fn cloned_input_updates(&self) -> Vec<InputStatePersistenceRecord> {
3509 self.input_updates
3510 .iter()
3511 .map(|input_update| input_update.record.clone())
3512 .collect()
3513 }
3514
3515 pub(crate) fn session_id(&self) -> &meerkat_core::types::SessionId {
3516 &self.session_id
3517 }
3518
3519 pub(crate) fn candidate_id(&self) -> &str {
3520 &self.candidate_id
3521 }
3522
3523 pub(crate) fn candidate_run_id(&self) -> &RunId {
3524 &self.candidate_run_id
3525 }
3526
3527 pub(crate) fn disposition(
3528 &self,
3529 ) -> crate::meerkat_machine::dsl::DurableTailRecoveryDisposition {
3530 self.disposition
3531 }
3532
3533 pub(crate) fn head_canonical_authority_transition(
3534 &self,
3535 ) -> Option<(u64, &str, u64, &str, &str)> {
3536 match &self.session_authority {
3537 PreparedRecoverySessionAuthority::HeadCanonical {
3538 committed_store_revision,
3539 committed_head_token,
3540 physical_store_revision,
3541 physical_head_token,
3542 recovered_head_token,
3543 } => Some((
3544 *committed_store_revision,
3545 committed_head_token,
3546 *physical_store_revision,
3547 physical_head_token,
3548 recovered_head_token,
3549 )),
3550 PreparedRecoverySessionAuthority::WholeBlob { .. } => None,
3551 }
3552 }
3553
3554 pub(crate) fn whole_blob_authority_transition(&self) -> Option<(u64, &str, &str, u64, &str)> {
3555 match &self.session_authority {
3556 PreparedRecoverySessionAuthority::WholeBlob {
3557 base_store_revision,
3558 base_blob_sha256,
3559 provisional_candidate_blob_sha256,
3560 provisional_candidate_sequence,
3561 recovered_blob_sha256,
3562 } => Some((
3563 *base_store_revision,
3564 base_blob_sha256,
3565 provisional_candidate_blob_sha256,
3566 *provisional_candidate_sequence,
3567 recovered_blob_sha256,
3568 )),
3569 PreparedRecoverySessionAuthority::HeadCanonical { .. } => None,
3570 }
3571 }
3572
3573 pub(crate) fn receipt_digest_enrichments(&self) -> &[PreparedRecoveryReceiptDigestEnrichment] {
3574 &self.receipt_digest_enrichments
3575 }
3576
3577 pub(crate) fn predecessor_nonterminal_input_set_token(&self) -> &str {
3578 &self.predecessor_nonterminal_input_set_token
3579 }
3580
3581 pub(crate) fn predecessor_nonterminal_input_set_revision(&self) -> RecoveryInputSetRevision {
3582 self.predecessor_nonterminal_input_set_revision
3583 }
3584}
3585
3586#[derive(serde::Serialize, serde::Deserialize)]
3587#[serde(deny_unknown_fields)]
3588struct CommittedRecoveryReceiptDigestEnrichmentWire {
3589 original_receipt: RunBoundaryReceipt,
3590 original_exact_row_token: String,
3591 derived_conversation_digest: String,
3592}
3593
3594#[derive(serde::Serialize, serde::Deserialize)]
3595#[serde(deny_unknown_fields)]
3596struct CommittedRecoveryInputUpdateWire {
3597 input_id: InputId,
3598 expected_row_digest: String,
3599 target_bytes: Vec<u8>,
3600}
3601
3602#[derive(serde::Serialize, serde::Deserialize)]
3603#[serde(tag = "profile", rename_all = "snake_case", deny_unknown_fields)]
3604enum CommittedRecoverySessionAuthorityWire {
3605 WholeBlobV1 {
3606 base_store_revision: u64,
3607 base_blob_sha256: String,
3608 provisional_candidate_blob_sha256: String,
3609 provisional_candidate_sequence: u64,
3610 recovered_blob_sha256: String,
3611 },
3612 HeadCanonicalV1 {
3613 committed_store_revision: u64,
3614 committed_head_token: String,
3615 physical_store_revision: u64,
3616 physical_head_token: String,
3617 recovered_head_token: String,
3618 },
3619}
3620
3621#[derive(serde::Serialize, serde::Deserialize)]
3622#[serde(deny_unknown_fields)]
3623struct CommittedRecoveryBoundaryWire {
3624 version: u16,
3625 session_id: meerkat_core::types::SessionId,
3626 candidate_id: String,
3627 candidate_run_id: RunId,
3628 class: String,
3629 disposition: String,
3630 session_authority: CommittedRecoverySessionAuthorityWire,
3631 receipt_digest_enrichments: Vec<CommittedRecoveryReceiptDigestEnrichmentWire>,
3632 predecessor_nonterminal_input_set_revision: u64,
3633 predecessor_nonterminal_input_set_token: String,
3634 input_updates: Vec<CommittedRecoveryInputUpdateWire>,
3635 lifecycle_target_token: String,
3636 lifecycle_target_bytes: Vec<u8>,
3637 exact_witness: String,
3638 receipt: RunBoundaryReceipt,
3639}
3640
3641#[derive(Debug, Clone, PartialEq, Eq)]
3643pub struct CommittedRecoveryBoundary {
3644 evidence: PreparedRecoveryEvidence,
3645 receipt: RunBoundaryReceipt,
3646}
3647
3648impl CommittedRecoveryBoundary {
3649 const VERSION: u16 = 6;
3650
3651 pub(crate) fn from_prepared(
3652 evidence: &PreparedRecoveryEvidence,
3653 receipt: &RunBoundaryReceipt,
3654 ) -> Self {
3655 Self {
3656 evidence: evidence.clone(),
3657 receipt: receipt.clone(),
3658 }
3659 }
3660
3661 pub(crate) fn evidence(&self) -> &PreparedRecoveryEvidence {
3662 &self.evidence
3663 }
3664
3665 pub(crate) fn receipt(&self) -> &RunBoundaryReceipt {
3666 &self.receipt
3667 }
3668
3669 pub(crate) fn encode(&self) -> Result<Vec<u8>, RuntimeStoreError> {
3670 serde_json::to_vec(&CommittedRecoveryBoundaryWire {
3671 version: Self::VERSION,
3672 session_id: self.evidence.session_id.clone(),
3673 candidate_id: self.evidence.candidate_id.clone(),
3674 candidate_run_id: self.evidence.candidate_run_id.clone(),
3675 class: recovery_class_name(self.evidence.class).to_string(),
3676 disposition: recovery_disposition_name(self.evidence.disposition).to_string(),
3677 session_authority: match &self.evidence.session_authority {
3678 PreparedRecoverySessionAuthority::WholeBlob {
3679 base_store_revision,
3680 base_blob_sha256,
3681 provisional_candidate_blob_sha256,
3682 provisional_candidate_sequence,
3683 recovered_blob_sha256,
3684 } => CommittedRecoverySessionAuthorityWire::WholeBlobV1 {
3685 base_store_revision: *base_store_revision,
3686 base_blob_sha256: base_blob_sha256.clone(),
3687 provisional_candidate_blob_sha256: provisional_candidate_blob_sha256.clone(),
3688 provisional_candidate_sequence: *provisional_candidate_sequence,
3689 recovered_blob_sha256: recovered_blob_sha256.clone(),
3690 },
3691 PreparedRecoverySessionAuthority::HeadCanonical {
3692 committed_store_revision,
3693 committed_head_token,
3694 physical_store_revision,
3695 physical_head_token,
3696 recovered_head_token,
3697 } => CommittedRecoverySessionAuthorityWire::HeadCanonicalV1 {
3698 committed_store_revision: *committed_store_revision,
3699 committed_head_token: committed_head_token.clone(),
3700 physical_store_revision: *physical_store_revision,
3701 physical_head_token: physical_head_token.clone(),
3702 recovered_head_token: recovered_head_token.clone(),
3703 },
3704 },
3705 receipt_digest_enrichments: self
3706 .evidence
3707 .receipt_digest_enrichments
3708 .iter()
3709 .map(|enrichment| CommittedRecoveryReceiptDigestEnrichmentWire {
3710 original_receipt: enrichment.original_receipt.clone(),
3711 original_exact_row_token: enrichment.original_exact_row_token.clone(),
3712 derived_conversation_digest: enrichment.derived_conversation_digest.clone(),
3713 })
3714 .collect(),
3715 predecessor_nonterminal_input_set_revision: self
3716 .evidence
3717 .predecessor_nonterminal_input_set_revision
3718 .store_generation(),
3719 predecessor_nonterminal_input_set_token: self
3720 .evidence
3721 .predecessor_nonterminal_input_set_token
3722 .clone(),
3723 input_updates: self
3724 .evidence
3725 .input_updates
3726 .iter()
3727 .map(|input_update| CommittedRecoveryInputUpdateWire {
3728 input_id: input_update.input_id.clone(),
3729 expected_row_digest: input_update.expected_row_digest.clone(),
3730 target_bytes: input_update.target_bytes.clone(),
3731 })
3732 .collect(),
3733 lifecycle_target_token: self.evidence.lifecycle_target_token.clone(),
3734 lifecycle_target_bytes: self.evidence.lifecycle_target_bytes.clone(),
3735 exact_witness: self.evidence.exact_witness.clone(),
3736 receipt: self.receipt.clone(),
3737 })
3738 .map_err(|error| {
3739 RuntimeStoreError::WriteFailed(format!(
3740 "failed to encode committed recovery boundary: {error}"
3741 ))
3742 })
3743 }
3744
3745 pub(crate) fn decode(bytes: &[u8]) -> Result<Self, RuntimeStoreError> {
3746 let wire: CommittedRecoveryBoundaryWire =
3747 serde_json::from_slice(bytes).map_err(|error| {
3748 RuntimeStoreError::ReadFailed(format!(
3749 "invalid committed recovery boundary: {error}"
3750 ))
3751 })?;
3752 if wire.version != Self::VERSION {
3753 return Err(RuntimeStoreError::ReadFailed(format!(
3754 "unsupported committed recovery boundary version {}",
3755 wire.version
3756 )));
3757 }
3758 let CommittedRecoveryBoundaryWire {
3759 version: _,
3760 session_id,
3761 candidate_id,
3762 candidate_run_id,
3763 class,
3764 disposition,
3765 session_authority: session_authority_wire,
3766 receipt_digest_enrichments: receipt_digest_enrichment_wires,
3767 predecessor_nonterminal_input_set_revision,
3768 predecessor_nonterminal_input_set_token,
3769 input_updates: input_update_wires,
3770 lifecycle_target_token,
3771 lifecycle_target_bytes,
3772 exact_witness,
3773 receipt,
3774 } = wire;
3775 let session_authority = match session_authority_wire {
3776 CommittedRecoverySessionAuthorityWire::WholeBlobV1 {
3777 base_store_revision,
3778 base_blob_sha256,
3779 provisional_candidate_blob_sha256,
3780 provisional_candidate_sequence,
3781 recovered_blob_sha256,
3782 } if base_store_revision != 0
3783 && !base_blob_sha256.is_empty()
3784 && !provisional_candidate_blob_sha256.is_empty()
3785 && provisional_candidate_sequence != 0
3786 && !recovered_blob_sha256.is_empty() =>
3787 {
3788 PreparedRecoverySessionAuthority::WholeBlob {
3789 base_store_revision,
3790 base_blob_sha256,
3791 provisional_candidate_blob_sha256,
3792 provisional_candidate_sequence,
3793 recovered_blob_sha256,
3794 }
3795 }
3796 CommittedRecoverySessionAuthorityWire::HeadCanonicalV1 {
3797 committed_store_revision,
3798 committed_head_token,
3799 physical_store_revision,
3800 physical_head_token,
3801 recovered_head_token,
3802 } if committed_store_revision != 0
3803 && physical_store_revision > committed_store_revision
3804 && !committed_head_token.is_empty()
3805 && !physical_head_token.is_empty()
3806 && !recovered_head_token.is_empty()
3807 && committed_head_token != physical_head_token =>
3808 {
3809 PreparedRecoverySessionAuthority::HeadCanonical {
3810 committed_store_revision,
3811 committed_head_token,
3812 physical_store_revision,
3813 physical_head_token,
3814 recovered_head_token,
3815 }
3816 }
3817 _ => {
3818 return Err(RuntimeStoreError::ReadFailed(
3819 "committed recovery boundary contains an invalid store authority transition"
3820 .to_string(),
3821 ));
3822 }
3823 };
3824 if candidate_id.is_empty()
3825 || !is_canonical_sha256_token(&predecessor_nonterminal_input_set_token)
3826 || lifecycle_target_bytes.is_empty()
3827 || !is_canonical_sha256_token(&lifecycle_target_token)
3828 || !is_canonical_sha256_token(&exact_witness)
3829 {
3830 return Err(RuntimeStoreError::ReadFailed(
3831 "committed recovery boundary contains an empty exact identity".to_string(),
3832 ));
3833 }
3834 if recovery_sha256_token(&lifecycle_target_bytes) != lifecycle_target_token {
3835 return Err(RuntimeStoreError::ReadFailed(
3836 "committed recovery lifecycle target token does not match its exact bytes"
3837 .to_string(),
3838 ));
3839 }
3840 let lifecycle_target_snapshot =
3841 decode_machine_lifecycle_store_record(&lifecycle_target_bytes).map_err(|error| {
3842 RuntimeStoreError::ReadFailed(format!(
3843 "committed recovery lifecycle target is invalid: {error}"
3844 ))
3845 })?;
3846 let canonical_lifecycle_target_bytes =
3847 MachineLifecycleStoreRecord::from_snapshot(&lifecycle_target_snapshot)
3848 .encode()
3849 .map_err(|error| {
3850 RuntimeStoreError::ReadFailed(format!(
3851 "failed to canonicalize committed recovery lifecycle target: {error}"
3852 ))
3853 })?;
3854 if canonical_lifecycle_target_bytes != lifecycle_target_bytes {
3855 return Err(RuntimeStoreError::ReadFailed(
3856 "committed recovery lifecycle target is not in canonical serialized form"
3857 .to_string(),
3858 ));
3859 }
3860 if receipt.run_id != candidate_run_id {
3861 return Err(RuntimeStoreError::ReadFailed(
3862 "committed recovery receipt run differs from candidate run".to_string(),
3863 ));
3864 }
3865 let mut previous_enrichment_sequence = None;
3866 let mut receipt_digest_enrichments =
3867 Vec::with_capacity(receipt_digest_enrichment_wires.len());
3868 for enrichment in receipt_digest_enrichment_wires {
3869 if enrichment.original_receipt.run_id != candidate_run_id
3870 || enrichment.original_receipt.conversation_digest.is_some()
3871 || previous_enrichment_sequence
3872 .is_some_and(|previous| enrichment.original_receipt.sequence <= previous)
3873 || enrichment.original_exact_row_token.is_empty()
3874 || enrichment.derived_conversation_digest.is_empty()
3875 {
3876 return Err(RuntimeStoreError::ReadFailed(
3877 "committed recovery receipt enrichment is malformed".to_string(),
3878 ));
3879 }
3880 previous_enrichment_sequence = Some(enrichment.original_receipt.sequence);
3881 receipt_digest_enrichments.push(PreparedRecoveryReceiptDigestEnrichment {
3882 original_receipt: enrichment.original_receipt,
3883 original_exact_row_token: enrichment.original_exact_row_token,
3884 derived_conversation_digest: enrichment.derived_conversation_digest,
3885 });
3886 }
3887 let input_updates = input_update_wires
3888 .into_iter()
3889 .map(|input_update| {
3890 PreparedRecoveryInputUpdate::decode(
3891 input_update.input_id,
3892 input_update.expected_row_digest,
3893 input_update.target_bytes,
3894 )
3895 })
3896 .collect::<Result<Vec<_>, _>>()
3897 .map_err(|detail| {
3898 RuntimeStoreError::ReadFailed(format!(
3899 "committed recovery input update is malformed: {detail}"
3900 ))
3901 })?;
3902 validate_recovery_input_update_order(&input_updates).map_err(|detail| {
3903 RuntimeStoreError::ReadFailed(format!(
3904 "committed recovery input update order is malformed: {detail}"
3905 ))
3906 })?;
3907 let class = recovery_class_from_name(&class)?;
3908 let disposition = recovery_disposition_from_name(&disposition)?;
3909 let mut evidence = PreparedRecoveryEvidence {
3910 session_id,
3911 candidate_id,
3912 candidate_run_id,
3913 class,
3914 disposition,
3915 session_authority,
3916 receipt_digest_enrichments,
3917 predecessor_nonterminal_input_set_revision:
3918 RecoveryInputSetRevision::from_store_generation(
3919 predecessor_nonterminal_input_set_revision,
3920 ),
3921 predecessor_nonterminal_input_set_token,
3922 input_updates,
3923 lifecycle_target_token,
3924 lifecycle_target_bytes,
3925 exact_witness: String::new(),
3926 };
3927 let derived_exact_witness = evidence.compute_exact_witness(&receipt).map_err(|error| {
3928 RuntimeStoreError::ReadFailed(format!(
3929 "failed to verify committed recovery exact witness: {error}"
3930 ))
3931 })?;
3932 if derived_exact_witness != exact_witness {
3933 return Err(RuntimeStoreError::ReadFailed(
3934 "committed recovery exact witness does not match its serialized effects"
3935 .to_string(),
3936 ));
3937 }
3938 evidence.exact_witness = exact_witness;
3939 Ok(Self { evidence, receipt })
3940 }
3941}
3942
3943#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3945pub enum PreparedRuntimeSessionCommitKind {
3946 SnapshotOnly,
3948 Success,
3950 ServiceTurnTerminal,
3952 MachineTerminal,
3954 Recovery,
3956}
3957
3958#[derive(Debug, Clone)]
3959pub(crate) enum PreparedRuntimeSessionCommitPayload {
3960 SnapshotOnly {
3961 session: BoundSessionCommit,
3962 },
3963 Success {
3964 session: Option<BoundSessionCommit>,
3965 receipt: RunBoundaryReceipt,
3966 input_updates: Vec<InputStatePersistenceRecord>,
3967 session_store_key: Option<meerkat_core::types::SessionId>,
3968 },
3969 PromoteWholeBlobSuccess {
3970 promotion: PreparedWholeBlobProvisionalPromotion,
3971 receipt: RunBoundaryReceipt,
3972 input_updates: Vec<InputStatePersistenceRecord>,
3973 session_store_key: meerkat_core::types::SessionId,
3974 },
3975 PromoteHeadCanonicalSuccess {
3976 promotion: PreparedHeadCanonicalProvisionalPromotion,
3977 receipt: RunBoundaryReceipt,
3978 input_updates: Vec<InputStatePersistenceRecord>,
3979 session_store_key: meerkat_core::types::SessionId,
3980 },
3981 ServiceTurnTerminal {
3982 session: BoundSessionCommit,
3983 receipt: RunBoundaryReceipt,
3984 machine_lifecycle: MachineLifecycleCommit,
3985 session_store_key: meerkat_core::types::SessionId,
3986 },
3987 PromoteWholeBlobServiceTurnTerminal {
3988 promotion: PreparedWholeBlobProvisionalPromotion,
3989 receipt: RunBoundaryReceipt,
3990 machine_lifecycle: MachineLifecycleCommit,
3991 session_store_key: meerkat_core::types::SessionId,
3992 },
3993 PromoteHeadCanonicalServiceTurnTerminal {
3994 promotion: PreparedHeadCanonicalProvisionalPromotion,
3995 receipt: RunBoundaryReceipt,
3996 machine_lifecycle: MachineLifecycleCommit,
3997 session_store_key: meerkat_core::types::SessionId,
3998 },
3999 MachineTerminal {
4000 session: BoundSessionCommit,
4001 receipt: RunBoundaryReceipt,
4002 machine_lifecycle: MachineLifecycleCommit,
4003 input_updates: Vec<InputStatePersistenceRecord>,
4004 session_store_key: meerkat_core::types::SessionId,
4005 },
4006 PromoteWholeBlobMachineTerminal {
4007 promotion: PreparedWholeBlobProvisionalPromotion,
4008 receipt: RunBoundaryReceipt,
4009 machine_lifecycle: MachineLifecycleCommit,
4010 input_updates: Vec<InputStatePersistenceRecord>,
4011 session_store_key: meerkat_core::types::SessionId,
4012 },
4013 PromoteHeadCanonicalMachineTerminal {
4014 promotion: PreparedHeadCanonicalProvisionalPromotion,
4015 receipt: RunBoundaryReceipt,
4016 machine_lifecycle: MachineLifecycleCommit,
4017 input_updates: Vec<InputStatePersistenceRecord>,
4018 session_store_key: meerkat_core::types::SessionId,
4019 },
4020 Recovery {
4021 session: BoundSessionCommit,
4022 evidence: PreparedRecoveryEvidence,
4023 receipt: RunBoundaryReceipt,
4024 machine_lifecycle: MachineLifecycleCommit,
4025 input_updates: Vec<InputStatePersistenceRecord>,
4026 session_store_key: meerkat_core::types::SessionId,
4027 },
4028 PromoteWholeBlobRecovery {
4029 promotion: PreparedWholeBlobRecoveryPromotion,
4030 evidence: PreparedRecoveryEvidence,
4031 receipt: RunBoundaryReceipt,
4032 machine_lifecycle: MachineLifecycleCommit,
4033 input_updates: Vec<InputStatePersistenceRecord>,
4034 session_store_key: meerkat_core::types::SessionId,
4035 },
4036}
4037
4038#[derive(Debug, Clone)]
4046pub struct PreparedRuntimeSessionCommit {
4047 payload: PreparedRuntimeSessionCommitPayload,
4048}
4049
4050impl PreparedRuntimeSessionCommit {
4051 fn validate_whole_blob_promotion_binding(
4052 promotion: &PreparedWholeBlobProvisionalPromotion,
4053 receipt: &RunBoundaryReceipt,
4054 session_store_key: &meerkat_core::types::SessionId,
4055 ) -> Result<(), RuntimeStoreError> {
4056 if promotion.authority().run_id() != &receipt.run_id {
4057 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
4058 runtime_id: promotion.authority().session_id().to_string(),
4059 detail: "WholeBlob promotion receipt run differs from provisional authority"
4060 .to_string(),
4061 });
4062 }
4063 if receipt.conversation_digest.as_deref() != Some(promotion.conversation_digest.as_str())
4064 || u64::try_from(receipt.message_count).ok() != Some(promotion.message_count)
4065 {
4066 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
4067 runtime_id: promotion.authority().session_id().to_string(),
4068 detail: "WholeBlob final receipt differs from checkpoint candidate count/digest"
4069 .to_string(),
4070 });
4071 }
4072 if promotion.authority().session_id() != session_store_key {
4073 return Err(RuntimeStoreError::SessionKeyMismatch {
4074 expected: promotion.authority().session_id().clone(),
4075 actual: session_store_key.clone(),
4076 });
4077 }
4078 Ok(())
4079 }
4080
4081 fn validate_head_canonical_promotion_binding(
4082 promotion: &PreparedHeadCanonicalProvisionalPromotion,
4083 receipt: &RunBoundaryReceipt,
4084 session_store_key: &meerkat_core::types::SessionId,
4085 ) -> Result<(), RuntimeStoreError> {
4086 if promotion.authority().run_id() != &receipt.run_id {
4087 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
4088 runtime_id: promotion.authority().session_id().to_string(),
4089 detail: "HeadCanonical promotion receipt run differs from provisional authority"
4090 .to_string(),
4091 });
4092 }
4093 if promotion.authority().session_id() != session_store_key {
4094 return Err(RuntimeStoreError::SessionKeyMismatch {
4095 expected: promotion.authority().session_id().clone(),
4096 actual: session_store_key.clone(),
4097 });
4098 }
4099 if receipt.conversation_digest.as_deref()
4100 != Some(promotion.checkpoint().conversation_digest())
4101 || u64::try_from(receipt.message_count).ok()
4102 != Some(promotion.checkpoint().message_count())
4103 {
4104 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
4105 runtime_id: promotion.authority().session_id().to_string(),
4106 detail:
4107 "HeadCanonical promotion terminal receipt differs from checkpoint digest/count"
4108 .to_string(),
4109 });
4110 }
4111 Ok(())
4112 }
4113
4114 #[must_use]
4116 pub fn snapshot_only(session: BoundSessionCommit) -> Self {
4117 Self {
4118 payload: PreparedRuntimeSessionCommitPayload::SnapshotOnly { session },
4119 }
4120 }
4121
4122 #[must_use]
4124 pub fn success(
4125 session: Option<BoundSessionCommit>,
4126 receipt: RunBoundaryReceipt,
4127 input_updates: Vec<InputStatePersistenceRecord>,
4128 session_store_key: Option<meerkat_core::types::SessionId>,
4129 ) -> Self {
4130 Self {
4131 payload: PreparedRuntimeSessionCommitPayload::Success {
4132 session,
4133 receipt,
4134 input_updates,
4135 session_store_key,
4136 },
4137 }
4138 }
4139
4140 pub fn promote_whole_blob_success(
4143 promotion: PreparedWholeBlobProvisionalPromotion,
4144 receipt: RunBoundaryReceipt,
4145 input_updates: Vec<InputStatePersistenceRecord>,
4146 session_store_key: meerkat_core::types::SessionId,
4147 ) -> Result<Self, RuntimeStoreError> {
4148 Self::validate_whole_blob_promotion_binding(&promotion, &receipt, &session_store_key)?;
4149 Ok(Self {
4150 payload: PreparedRuntimeSessionCommitPayload::PromoteWholeBlobSuccess {
4151 promotion,
4152 receipt,
4153 input_updates,
4154 session_store_key,
4155 },
4156 })
4157 }
4158
4159 pub fn promote_head_canonical_success(
4162 promotion: PreparedHeadCanonicalProvisionalPromotion,
4163 receipt: RunBoundaryReceipt,
4164 input_updates: Vec<InputStatePersistenceRecord>,
4165 session_store_key: meerkat_core::types::SessionId,
4166 ) -> Result<Self, RuntimeStoreError> {
4167 Self::validate_head_canonical_promotion_binding(&promotion, &receipt, &session_store_key)?;
4168 Ok(Self {
4169 payload: PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalSuccess {
4170 promotion,
4171 receipt,
4172 input_updates,
4173 session_store_key,
4174 },
4175 })
4176 }
4177
4178 #[must_use]
4180 pub fn machine_terminal(
4181 session: BoundSessionCommit,
4182 receipt: RunBoundaryReceipt,
4183 machine_lifecycle: MachineLifecycleCommit,
4184 input_updates: Vec<InputStatePersistenceRecord>,
4185 session_store_key: meerkat_core::types::SessionId,
4186 ) -> Self {
4187 Self {
4188 payload: PreparedRuntimeSessionCommitPayload::MachineTerminal {
4189 session,
4190 receipt,
4191 machine_lifecycle,
4192 input_updates,
4193 session_store_key,
4194 },
4195 }
4196 }
4197
4198 pub fn promote_whole_blob_machine_terminal(
4201 promotion: PreparedWholeBlobProvisionalPromotion,
4202 receipt: RunBoundaryReceipt,
4203 machine_lifecycle: MachineLifecycleCommit,
4204 input_updates: Vec<InputStatePersistenceRecord>,
4205 session_store_key: meerkat_core::types::SessionId,
4206 ) -> Result<Self, RuntimeStoreError> {
4207 Self::validate_whole_blob_promotion_binding(&promotion, &receipt, &session_store_key)?;
4208 Ok(Self {
4209 payload: PreparedRuntimeSessionCommitPayload::PromoteWholeBlobMachineTerminal {
4210 promotion,
4211 receipt,
4212 machine_lifecycle,
4213 input_updates,
4214 session_store_key,
4215 },
4216 })
4217 }
4218
4219 pub fn promote_head_canonical_machine_terminal(
4222 promotion: PreparedHeadCanonicalProvisionalPromotion,
4223 receipt: RunBoundaryReceipt,
4224 machine_lifecycle: MachineLifecycleCommit,
4225 input_updates: Vec<InputStatePersistenceRecord>,
4226 session_store_key: meerkat_core::types::SessionId,
4227 ) -> Result<Self, RuntimeStoreError> {
4228 Self::validate_head_canonical_promotion_binding(&promotion, &receipt, &session_store_key)?;
4229 Ok(Self {
4230 payload: PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalMachineTerminal {
4231 promotion,
4232 receipt,
4233 machine_lifecycle,
4234 input_updates,
4235 session_store_key,
4236 },
4237 })
4238 }
4239
4240 #[must_use]
4242 pub fn service_turn_terminal(
4243 session: BoundSessionCommit,
4244 receipt: RunBoundaryReceipt,
4245 machine_lifecycle: MachineLifecycleCommit,
4246 session_store_key: meerkat_core::types::SessionId,
4247 ) -> Self {
4248 Self {
4249 payload: PreparedRuntimeSessionCommitPayload::ServiceTurnTerminal {
4250 session,
4251 receipt,
4252 machine_lifecycle,
4253 session_store_key,
4254 },
4255 }
4256 }
4257
4258 pub fn promote_whole_blob_service_turn_terminal(
4261 promotion: PreparedWholeBlobProvisionalPromotion,
4262 receipt: RunBoundaryReceipt,
4263 machine_lifecycle: MachineLifecycleCommit,
4264 session_store_key: meerkat_core::types::SessionId,
4265 ) -> Result<Self, RuntimeStoreError> {
4266 Self::validate_whole_blob_promotion_binding(&promotion, &receipt, &session_store_key)?;
4267 Ok(Self {
4268 payload: PreparedRuntimeSessionCommitPayload::PromoteWholeBlobServiceTurnTerminal {
4269 promotion,
4270 receipt,
4271 machine_lifecycle,
4272 session_store_key,
4273 },
4274 })
4275 }
4276
4277 pub fn promote_head_canonical_service_turn_terminal(
4280 promotion: PreparedHeadCanonicalProvisionalPromotion,
4281 receipt: RunBoundaryReceipt,
4282 machine_lifecycle: MachineLifecycleCommit,
4283 session_store_key: meerkat_core::types::SessionId,
4284 ) -> Result<Self, RuntimeStoreError> {
4285 Self::validate_head_canonical_promotion_binding(&promotion, &receipt, &session_store_key)?;
4286 Ok(Self {
4287 payload: PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalServiceTurnTerminal {
4288 promotion,
4289 receipt,
4290 machine_lifecycle,
4291 session_store_key,
4292 },
4293 })
4294 }
4295
4296 pub(crate) fn machine_terminal_recovery(
4303 session: BoundSessionCommit,
4304 evidence: PreparedRecoveryEvidence,
4305 receipt: RunBoundaryReceipt,
4306 machine_lifecycle: MachineLifecycleCommit,
4307 session_store_key: meerkat_core::types::SessionId,
4308 ) -> Result<Self, RuntimeStoreError> {
4309 if &session_store_key != evidence.session_id() {
4310 return Err(RuntimeStoreError::SessionKeyMismatch {
4311 expected: evidence.session_id().clone(),
4312 actual: session_store_key,
4313 });
4314 }
4315 if &receipt.run_id != evidence.candidate_run_id() {
4316 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
4317 runtime_id: evidence.session_id().to_string(),
4318 detail: "recovery receipt run differs from sealed candidate run".to_string(),
4319 });
4320 }
4321 evidence.verify_head_canonical_boundary(&session, &receipt)?;
4322 evidence.verify_request_effects(&receipt, &machine_lifecycle)?;
4323 let input_updates = evidence.cloned_input_updates();
4324 Ok(Self {
4325 payload: PreparedRuntimeSessionCommitPayload::Recovery {
4326 session,
4327 evidence,
4328 receipt,
4329 machine_lifecycle,
4330 input_updates,
4331 session_store_key,
4332 },
4333 })
4334 }
4335
4336 pub(crate) fn machine_terminal_whole_blob_recovery(
4342 repaired_document: Option<BoundSessionCommit>,
4343 evidence: PreparedRecoveryEvidence,
4344 receipt: RunBoundaryReceipt,
4345 machine_lifecycle: MachineLifecycleCommit,
4346 session_store_key: meerkat_core::types::SessionId,
4347 ) -> Result<Self, RuntimeStoreError> {
4348 if &session_store_key != evidence.session_id() {
4349 return Err(RuntimeStoreError::SessionKeyMismatch {
4350 expected: evidence.session_id().clone(),
4351 actual: session_store_key,
4352 });
4353 }
4354 if &receipt.run_id != evidence.candidate_run_id() {
4355 return Err(RuntimeStoreError::SessionPersistenceAuthorityConflict {
4356 runtime_id: evidence.session_id().to_string(),
4357 detail: "recovery receipt run differs from sealed candidate run".to_string(),
4358 });
4359 }
4360 evidence.verify_request_effects(&receipt, &machine_lifecycle)?;
4361 let input_updates = evidence.cloned_input_updates();
4362 evidence.verify_input_updates(&input_updates)?;
4363 let promotion =
4364 PreparedWholeBlobRecoveryPromotion::prepare(repaired_document.as_ref(), &evidence)?;
4365 Ok(Self {
4366 payload: PreparedRuntimeSessionCommitPayload::PromoteWholeBlobRecovery {
4367 promotion,
4368 evidence,
4369 receipt,
4370 machine_lifecycle,
4371 input_updates,
4372 session_store_key,
4373 },
4374 })
4375 }
4376
4377 #[must_use]
4379 pub fn kind(&self) -> PreparedRuntimeSessionCommitKind {
4380 match &self.payload {
4381 PreparedRuntimeSessionCommitPayload::SnapshotOnly { .. } => {
4382 PreparedRuntimeSessionCommitKind::SnapshotOnly
4383 }
4384 PreparedRuntimeSessionCommitPayload::Success { .. }
4385 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobSuccess { .. }
4386 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalSuccess { .. } => {
4387 PreparedRuntimeSessionCommitKind::Success
4388 }
4389 PreparedRuntimeSessionCommitPayload::ServiceTurnTerminal { .. }
4390 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobServiceTurnTerminal { .. }
4391 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalServiceTurnTerminal {
4392 ..
4393 } => PreparedRuntimeSessionCommitKind::ServiceTurnTerminal,
4394 PreparedRuntimeSessionCommitPayload::MachineTerminal { .. }
4395 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobMachineTerminal { .. }
4396 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalMachineTerminal { .. } => {
4397 PreparedRuntimeSessionCommitKind::MachineTerminal
4398 }
4399 PreparedRuntimeSessionCommitPayload::Recovery { .. }
4400 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobRecovery { .. } => {
4401 PreparedRuntimeSessionCommitKind::Recovery
4402 }
4403 }
4404 }
4405
4406 #[must_use]
4408 pub fn session(&self) -> Option<&BoundSessionCommit> {
4409 match &self.payload {
4410 PreparedRuntimeSessionCommitPayload::SnapshotOnly { session, .. }
4411 | PreparedRuntimeSessionCommitPayload::ServiceTurnTerminal { session, .. }
4412 | PreparedRuntimeSessionCommitPayload::MachineTerminal { session, .. }
4413 | PreparedRuntimeSessionCommitPayload::Recovery { session, .. } => Some(session),
4414 PreparedRuntimeSessionCommitPayload::Success { session, .. } => session.as_ref(),
4415 PreparedRuntimeSessionCommitPayload::PromoteWholeBlobSuccess { .. }
4416 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobServiceTurnTerminal { .. }
4417 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobMachineTerminal { .. }
4418 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalSuccess { .. }
4419 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalServiceTurnTerminal {
4420 ..
4421 }
4422 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalMachineTerminal { .. } => {
4423 None
4424 }
4425 PreparedRuntimeSessionCommitPayload::PromoteWholeBlobRecovery { .. } => None,
4426 }
4427 }
4428
4429 #[must_use]
4431 pub fn receipt(&self) -> Option<&RunBoundaryReceipt> {
4432 match &self.payload {
4433 PreparedRuntimeSessionCommitPayload::SnapshotOnly { .. } => None,
4434 PreparedRuntimeSessionCommitPayload::Success { receipt, .. }
4435 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobSuccess { receipt, .. }
4436 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalSuccess {
4437 receipt, ..
4438 }
4439 | PreparedRuntimeSessionCommitPayload::ServiceTurnTerminal { receipt, .. }
4440 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobServiceTurnTerminal {
4441 receipt,
4442 ..
4443 }
4444 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalServiceTurnTerminal {
4445 receipt,
4446 ..
4447 }
4448 | PreparedRuntimeSessionCommitPayload::MachineTerminal { receipt, .. }
4449 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobMachineTerminal {
4450 receipt,
4451 ..
4452 }
4453 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalMachineTerminal {
4454 receipt,
4455 ..
4456 }
4457 | PreparedRuntimeSessionCommitPayload::Recovery { receipt, .. }
4458 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobRecovery { receipt, .. } => {
4459 Some(receipt)
4460 }
4461 }
4462 }
4463
4464 #[must_use]
4466 pub fn input_updates(&self) -> Option<&[InputStatePersistenceRecord]> {
4467 match &self.payload {
4468 PreparedRuntimeSessionCommitPayload::SnapshotOnly { .. } => None,
4469 PreparedRuntimeSessionCommitPayload::ServiceTurnTerminal { .. }
4470 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobServiceTurnTerminal { .. }
4471 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalServiceTurnTerminal {
4472 ..
4473 } => Some(&[]),
4474 PreparedRuntimeSessionCommitPayload::Success { input_updates, .. }
4475 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobSuccess {
4476 input_updates, ..
4477 }
4478 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalSuccess {
4479 input_updates,
4480 ..
4481 }
4482 | PreparedRuntimeSessionCommitPayload::MachineTerminal { input_updates, .. }
4483 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobMachineTerminal {
4484 input_updates,
4485 ..
4486 }
4487 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalMachineTerminal {
4488 input_updates,
4489 ..
4490 }
4491 | PreparedRuntimeSessionCommitPayload::Recovery { input_updates, .. }
4492 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobRecovery {
4493 input_updates, ..
4494 } => Some(input_updates),
4495 }
4496 }
4497
4498 #[must_use]
4500 pub fn session_store_key(&self) -> Option<&meerkat_core::types::SessionId> {
4501 match &self.payload {
4502 PreparedRuntimeSessionCommitPayload::SnapshotOnly { .. } => None,
4503 PreparedRuntimeSessionCommitPayload::Success {
4504 session_store_key, ..
4505 } => session_store_key.as_ref(),
4506 PreparedRuntimeSessionCommitPayload::PromoteWholeBlobSuccess {
4507 session_store_key,
4508 ..
4509 }
4510 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalSuccess {
4511 session_store_key,
4512 ..
4513 }
4514 | PreparedRuntimeSessionCommitPayload::ServiceTurnTerminal {
4515 session_store_key, ..
4516 }
4517 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobServiceTurnTerminal {
4518 session_store_key,
4519 ..
4520 }
4521 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalServiceTurnTerminal {
4522 session_store_key,
4523 ..
4524 }
4525 | PreparedRuntimeSessionCommitPayload::MachineTerminal {
4526 session_store_key, ..
4527 }
4528 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobMachineTerminal {
4529 session_store_key,
4530 ..
4531 }
4532 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalMachineTerminal {
4533 session_store_key,
4534 ..
4535 }
4536 | PreparedRuntimeSessionCommitPayload::Recovery {
4537 session_store_key, ..
4538 }
4539 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobRecovery {
4540 session_store_key,
4541 ..
4542 } => Some(session_store_key),
4543 }
4544 }
4545
4546 #[must_use]
4548 pub fn machine_lifecycle(&self) -> Option<&MachineLifecycleCommit> {
4549 match &self.payload {
4550 PreparedRuntimeSessionCommitPayload::ServiceTurnTerminal {
4551 machine_lifecycle, ..
4552 }
4553 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobServiceTurnTerminal {
4554 machine_lifecycle,
4555 ..
4556 }
4557 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalServiceTurnTerminal {
4558 machine_lifecycle,
4559 ..
4560 }
4561 | PreparedRuntimeSessionCommitPayload::MachineTerminal {
4562 machine_lifecycle, ..
4563 }
4564 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobMachineTerminal {
4565 machine_lifecycle,
4566 ..
4567 }
4568 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalMachineTerminal {
4569 machine_lifecycle,
4570 ..
4571 }
4572 | PreparedRuntimeSessionCommitPayload::Recovery {
4573 machine_lifecycle, ..
4574 }
4575 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobRecovery {
4576 machine_lifecycle,
4577 ..
4578 } => Some(machine_lifecycle),
4579 PreparedRuntimeSessionCommitPayload::SnapshotOnly { .. }
4580 | PreparedRuntimeSessionCommitPayload::Success { .. }
4581 | PreparedRuntimeSessionCommitPayload::PromoteWholeBlobSuccess { .. }
4582 | PreparedRuntimeSessionCommitPayload::PromoteHeadCanonicalSuccess { .. } => None,
4583 }
4584 }
4585
4586 pub(crate) fn into_payload(self) -> PreparedRuntimeSessionCommitPayload {
4587 self.payload
4588 }
4589}
4590
4591#[derive(Debug, Clone)]
4596pub(crate) struct PreparedWholeBlobSnapshot {
4597 session: std::sync::Arc<meerkat_core::Session>,
4598 serialized: SerializedSessionSnapshot,
4599 blob_sha256: String,
4600}
4601
4602impl PreparedWholeBlobSnapshot {
4603 #[must_use]
4604 pub(crate) fn session(&self) -> &meerkat_core::Session {
4605 self.session.as_ref()
4606 }
4607
4608 #[must_use]
4609 pub(crate) fn blob_sha256(&self) -> &str {
4610 &self.blob_sha256
4611 }
4612
4613 #[must_use]
4614 pub(crate) fn into_parts(
4615 self,
4616 ) -> (
4617 std::sync::Arc<meerkat_core::Session>,
4618 SerializedSessionSnapshot,
4619 String,
4620 ) {
4621 (self.session, self.serialized, self.blob_sha256)
4622 }
4623}
4624
4625fn prepared_whole_blob_snapshot(
4626 session: &BoundSessionCommit,
4627) -> Result<PreparedWholeBlobSnapshot, RuntimeStoreError> {
4628 let typed_session = session.session_arc_cloned().ok_or_else(|| {
4629 RuntimeStoreError::SessionPersistenceAuthorityConflict {
4630 runtime_id: "<untyped-whole-blob-boundary>".to_string(),
4631 detail: "prepared WholeBlob boundary requires a sealed typed Session".to_string(),
4632 }
4633 })?;
4634 let artifact = session.whole_blob_artifact().map_err(|error| {
4635 RuntimeStoreError::WriteFailed(format!(
4636 "failed to materialize whole-blob session boundary: {error}"
4637 ))
4638 })?;
4639 Ok(PreparedWholeBlobSnapshot {
4640 session: typed_session,
4641 serialized: whole_blob_serialized_snapshot(artifact.bytes_arc()),
4642 blob_sha256: artifact.row_sha256_token().to_string(),
4643 })
4644}
4645
4646fn parsed_whole_blob_snapshot(
4647 serialized: SerializedSessionSnapshot,
4648) -> Result<PreparedWholeBlobSnapshot, RuntimeStoreError> {
4649 let session = std::sync::Arc::new(
4650 meerkat_core::Session::from_persisted_bytes(serialized.session_snapshot.as_ref()).map_err(
4651 |error| {
4652 RuntimeStoreError::WriteFailed(format!(
4653 "whole-blob snapshot is not a valid Session payload: {error}"
4654 ))
4655 },
4656 )?,
4657 );
4658 let blob_sha256 = format!(
4659 "row-sha256:{:x}",
4660 sha2::Sha256::digest(serialized.session_snapshot.as_ref())
4661 );
4662 Ok(PreparedWholeBlobSnapshot {
4663 session,
4664 serialized,
4665 blob_sha256,
4666 })
4667}
4668
4669fn whole_blob_serialized_snapshot(
4670 session_snapshot: std::sync::Arc<Vec<u8>>,
4671) -> SerializedSessionSnapshot {
4672 SerializedSessionSnapshot { session_snapshot }
4673}
4674
4675#[derive(Debug, Clone, PartialEq, Eq)]
4682pub struct RuntimeDeliveryAuthorityRecord {
4683 revision: u64,
4684 state_json: Vec<u8>,
4685}
4686
4687impl RuntimeDeliveryAuthorityRecord {
4688 #[doc(hidden)]
4689 pub fn from_parts(revision: u64, state_json: Vec<u8>) -> Self {
4690 Self {
4691 revision,
4692 state_json,
4693 }
4694 }
4695
4696 pub fn revision(&self) -> u64 {
4697 self.revision
4698 }
4699
4700 pub fn state_json(&self) -> &[u8] {
4701 &self.state_json
4702 }
4703}
4704
4705#[derive(Debug, Clone, PartialEq, Eq)]
4707pub struct RuntimeDeliveryStoreRecord {
4708 delivery_id: String,
4709 sequence: u64,
4710 submission_json: Vec<u8>,
4711}
4712
4713impl RuntimeDeliveryStoreRecord {
4714 #[doc(hidden)]
4715 pub fn from_parts(
4716 delivery_id: impl Into<String>,
4717 sequence: u64,
4718 submission_json: Vec<u8>,
4719 ) -> Self {
4720 Self {
4721 delivery_id: delivery_id.into(),
4722 sequence,
4723 submission_json,
4724 }
4725 }
4726
4727 pub fn delivery_id(&self) -> &str {
4728 &self.delivery_id
4729 }
4730
4731 pub fn sequence(&self) -> u64 {
4732 self.sequence
4733 }
4734
4735 pub fn submission_json(&self) -> &[u8] {
4736 &self.submission_json
4737 }
4738}
4739
4740#[derive(Debug, Clone, PartialEq, Eq)]
4742pub enum RuntimeDeliveryAuthorityCasOutcome {
4743 Applied(RuntimeDeliveryAuthorityRecord),
4744 Conflict(Option<RuntimeDeliveryAuthorityRecord>),
4745}
4746
4747fn validated_compaction_projection_intents(
4748 session: &meerkat_core::Session,
4749) -> Result<Vec<meerkat_core::CompactionProjectionIntent>, RuntimeStoreError> {
4750 session
4751 .validated_compaction_projection_intents()
4752 .map_err(|error| RuntimeStoreError::WriteFailed(error.to_string()))
4753}
4754
4755pub(crate) fn complete_compaction_projection_intent(
4760 session: &mut meerkat_core::Session,
4761 projection: &meerkat_core::CompactionProjectionId,
4762) -> Result<(), RuntimeStoreError> {
4763 session
4764 .complete_compaction_projection_intent(projection)
4765 .map_err(|error| RuntimeStoreError::WriteFailed(error.to_string()))?;
4766 Ok(())
4767}
4768
4769#[derive(Debug, Clone, Default, PartialEq, Eq)]
4776pub struct MachineLifecycleBindingFacts {
4777 agent_runtime_id: Option<String>,
4778 fence_token: Option<u64>,
4779 runtime_generation: Option<u64>,
4780 runtime_epoch_id: Option<String>,
4781}
4782
4783#[derive(Debug, Clone, PartialEq, Eq)]
4790pub struct RevokedSupervisorReceipt {
4791 peer_id: String,
4792 signing_public_key: String,
4793 epoch: u64,
4794}
4795
4796#[derive(Debug, Clone, PartialEq, Eq)]
4799pub struct SupervisorBindingReceipt {
4800 name: String,
4801 peer_id: String,
4802 address: String,
4803 signing_public_key: String,
4804 epoch: u64,
4805}
4806
4807#[derive(Debug, Clone, PartialEq, Eq)]
4814pub struct SupervisorRevocationPendingReceipt {
4815 name: String,
4816 peer_id: String,
4817 address: String,
4818 signing_public_key: String,
4819 epoch: u64,
4820}
4821
4822#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
4823#[serde(rename_all = "snake_case")]
4824pub enum SupervisorRotationPersistencePhase {
4825 PreviousRevokePending,
4826 NextPublishPending,
4827 Completed,
4828 Rejected,
4829}
4830
4831#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
4832#[serde(rename_all = "snake_case")]
4833pub enum SupervisorRotationRejection {
4834 OperationConflict,
4835 NotBound,
4836 SenderMismatch,
4837 TargetEpochNotAdvanced,
4838 InvalidTarget,
4839 UnsupportedProtocolVersion,
4840}
4841
4842#[derive(Debug, Clone, PartialEq, Eq)]
4843pub struct SupervisorRotationReceipt {
4844 operation_id: meerkat_contracts::wire::supervisor_bridge::SupervisorRotationOperationId,
4845 phase: SupervisorRotationPersistencePhase,
4846 rejection: Option<SupervisorRotationRejection>,
4847 previous: SupervisorBindingReceipt,
4848 next: SupervisorBindingReceipt,
4849}
4850
4851impl SupervisorRotationReceipt {
4852 pub(crate) fn new(
4853 operation_id: meerkat_contracts::wire::supervisor_bridge::SupervisorRotationOperationId,
4854 phase: SupervisorRotationPersistencePhase,
4855 rejection: Option<SupervisorRotationRejection>,
4856 previous: SupervisorBindingReceipt,
4857 next: SupervisorBindingReceipt,
4858 ) -> Self {
4859 Self {
4860 operation_id,
4861 phase,
4862 rejection,
4863 previous,
4864 next,
4865 }
4866 }
4867
4868 pub fn operation_id(
4869 &self,
4870 ) -> meerkat_contracts::wire::supervisor_bridge::SupervisorRotationOperationId {
4871 self.operation_id
4872 }
4873
4874 pub fn phase(&self) -> SupervisorRotationPersistencePhase {
4875 self.phase
4876 }
4877
4878 pub fn rejection(&self) -> Option<SupervisorRotationRejection> {
4879 self.rejection
4880 }
4881
4882 pub fn previous(&self) -> &SupervisorBindingReceipt {
4883 &self.previous
4884 }
4885
4886 pub fn next(&self) -> &SupervisorBindingReceipt {
4887 &self.next
4888 }
4889}
4890
4891impl SupervisorBindingReceipt {
4892 pub(crate) fn new(
4893 name: String,
4894 peer_id: String,
4895 address: String,
4896 signing_public_key: String,
4897 epoch: u64,
4898 ) -> Self {
4899 Self {
4900 name,
4901 peer_id,
4902 address,
4903 signing_public_key,
4904 epoch,
4905 }
4906 }
4907
4908 pub fn name(&self) -> &str {
4909 &self.name
4910 }
4911
4912 pub fn peer_id(&self) -> &str {
4913 &self.peer_id
4914 }
4915
4916 pub fn address(&self) -> &str {
4917 &self.address
4918 }
4919
4920 pub fn signing_public_key(&self) -> &str {
4921 &self.signing_public_key
4922 }
4923
4924 pub fn epoch(&self) -> u64 {
4925 self.epoch
4926 }
4927}
4928
4929impl RevokedSupervisorReceipt {
4930 pub(crate) fn new(peer_id: String, signing_public_key: String, epoch: u64) -> Self {
4931 Self {
4932 peer_id,
4933 signing_public_key,
4934 epoch,
4935 }
4936 }
4937
4938 pub fn peer_id(&self) -> &str {
4939 &self.peer_id
4940 }
4941
4942 pub fn signing_public_key(&self) -> &str {
4943 &self.signing_public_key
4944 }
4945
4946 pub fn epoch(&self) -> u64 {
4947 self.epoch
4948 }
4949}
4950
4951impl SupervisorRevocationPendingReceipt {
4952 pub(crate) fn new(
4953 name: String,
4954 peer_id: String,
4955 address: String,
4956 signing_public_key: String,
4957 epoch: u64,
4958 ) -> Self {
4959 Self {
4960 name,
4961 peer_id,
4962 address,
4963 signing_public_key,
4964 epoch,
4965 }
4966 }
4967
4968 pub fn name(&self) -> &str {
4969 &self.name
4970 }
4971
4972 pub fn peer_id(&self) -> &str {
4973 &self.peer_id
4974 }
4975
4976 pub fn address(&self) -> &str {
4977 &self.address
4978 }
4979
4980 pub fn signing_public_key(&self) -> &str {
4981 &self.signing_public_key
4982 }
4983
4984 pub fn epoch(&self) -> u64 {
4985 self.epoch
4986 }
4987}
4988
4989#[derive(Debug, Clone, Default, PartialEq, Eq)]
4993pub enum SupervisorAuthoritySnapshot {
4994 #[default]
4995 UnboundNoReceipt,
4996 Bound(SupervisorBindingReceipt),
4997 RevocationPending(SupervisorRevocationPendingReceipt),
4998 RotationOperation(SupervisorRotationReceipt),
4999 RevokedReceipt(RevokedSupervisorReceipt),
5000 WithRotationHistory {
5001 current: Box<SupervisorAuthoritySnapshot>,
5002 terminal_receipts: std::collections::BTreeMap<
5003 meerkat_contracts::wire::supervisor_bridge::SupervisorRotationOperationId,
5004 SupervisorRotationReceipt,
5005 >,
5006 },
5007}
5008
5009impl MachineLifecycleBindingFacts {
5010 pub(crate) fn new(
5011 agent_runtime_id: Option<String>,
5012 fence_token: Option<u64>,
5013 runtime_generation: Option<u64>,
5014 runtime_epoch_id: Option<String>,
5015 ) -> Self {
5016 Self {
5017 agent_runtime_id,
5018 fence_token,
5019 runtime_generation,
5020 runtime_epoch_id,
5021 }
5022 }
5023
5024 pub fn agent_runtime_id(&self) -> Option<&str> {
5025 self.agent_runtime_id.as_deref()
5026 }
5027
5028 pub fn fence_token(&self) -> Option<u64> {
5029 self.fence_token
5030 }
5031
5032 pub fn runtime_generation(&self) -> Option<u64> {
5033 self.runtime_generation
5034 }
5035
5036 pub fn runtime_epoch_id(&self) -> Option<&str> {
5037 self.runtime_epoch_id.as_deref()
5038 }
5039}
5040
5041#[derive(Debug, Clone, PartialEq, Eq, Hash)]
5047pub struct MachineLifecycleObservationVersion(String);
5048
5049impl MachineLifecycleObservationVersion {
5050 pub fn from_raw_record(bytes: &[u8]) -> Self {
5056 Self(format!("sha256:{:x}", Sha256::digest(bytes)))
5057 }
5058
5059 #[must_use]
5060 pub fn as_str(&self) -> &str {
5061 &self.0
5062 }
5063}
5064
5065#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5071#[serde(rename_all = "snake_case")]
5072pub enum MachineLifecyclePreRunPhase {
5073 Idle,
5074 Attached,
5075 Retired,
5076}
5077
5078#[derive(Debug, Clone, Default, PartialEq, Eq)]
5079pub struct MachineLifecycleRunFacts {
5080 current_run_id: Option<RunId>,
5081 pre_run_phase: Option<MachineLifecyclePreRunPhase>,
5082}
5083
5084impl MachineLifecycleRunFacts {
5085 pub(crate) fn new(
5086 current_run_id: Option<RunId>,
5087 pre_run_phase: Option<MachineLifecyclePreRunPhase>,
5088 ) -> Self {
5089 Self {
5090 current_run_id,
5091 pre_run_phase,
5092 }
5093 }
5094
5095 #[must_use]
5096 pub fn current_run_id(&self) -> Option<&RunId> {
5097 self.current_run_id.as_ref()
5098 }
5099
5100 #[must_use]
5101 pub fn pre_run_phase(&self) -> Option<MachineLifecyclePreRunPhase> {
5102 self.pre_run_phase
5103 }
5104}
5105
5106#[derive(Debug, Clone, PartialEq, Eq)]
5113pub struct DecodedMachineLifecycleObservation {
5114 record_version: u16,
5115 runtime_state: Option<RuntimeState>,
5116 binding: MachineLifecycleBindingFacts,
5117 run: MachineLifecycleRunFacts,
5118 supervisor_authority: SupervisorAuthoritySnapshot,
5119 unregister_progress: Option<MachineUnregisterProgressSnapshot>,
5120}
5121
5122impl DecodedMachineLifecycleObservation {
5123 #[must_use]
5124 pub fn record_version(&self) -> u16 {
5125 self.record_version
5126 }
5127
5128 #[must_use]
5129 pub fn runtime_state(&self) -> Option<RuntimeState> {
5130 self.runtime_state
5131 }
5132
5133 #[must_use]
5134 pub fn binding(&self) -> &MachineLifecycleBindingFacts {
5135 &self.binding
5136 }
5137
5138 #[must_use]
5139 pub fn run(&self) -> &MachineLifecycleRunFacts {
5140 &self.run
5141 }
5142
5143 #[must_use]
5144 pub fn supervisor_authority(&self) -> &SupervisorAuthoritySnapshot {
5145 &self.supervisor_authority
5146 }
5147
5148 #[must_use]
5149 pub fn unregister_progress(&self) -> Option<&MachineUnregisterProgressSnapshot> {
5150 self.unregister_progress.as_ref()
5151 }
5152}
5153
5154#[derive(Debug, Clone, PartialEq, Eq)]
5160pub enum MachineLifecycleObservation {
5161 Missing,
5162 Decoded {
5163 record: DecodedMachineLifecycleObservation,
5164 version: MachineLifecycleObservationVersion,
5165 },
5166 Unsupported {
5167 record_version: u64,
5168 evidence_digest: String,
5169 version: MachineLifecycleObservationVersion,
5170 },
5171 Malformed {
5172 record_version: Option<u64>,
5173 evidence_digest: String,
5174 version: MachineLifecycleObservationVersion,
5175 detail: String,
5176 },
5177}
5178
5179impl MachineLifecycleObservation {
5180 #[must_use]
5186 pub fn from_raw_record(bytes: &[u8]) -> Self {
5187 classify_machine_lifecycle_record(bytes)
5188 }
5189
5190 #[must_use]
5191 pub fn version(&self) -> Option<&MachineLifecycleObservationVersion> {
5192 match self {
5193 Self::Missing => None,
5194 Self::Decoded { version, .. }
5195 | Self::Unsupported { version, .. }
5196 | Self::Malformed { version, .. } => Some(version),
5197 }
5198 }
5199
5200 #[must_use]
5201 pub fn evidence_digest(&self) -> Option<&str> {
5202 match self {
5203 Self::Unsupported {
5204 evidence_digest, ..
5205 }
5206 | Self::Malformed {
5207 evidence_digest, ..
5208 } => Some(evidence_digest),
5209 Self::Missing | Self::Decoded { .. } => None,
5210 }
5211 }
5212}
5213
5214#[derive(Debug, Clone, PartialEq, Eq)]
5216pub enum MachineLifecycleExpectedVersion {
5217 Missing,
5218 Version(MachineLifecycleObservationVersion),
5219}
5220
5221impl MachineLifecycleObservation {
5222 #[must_use]
5228 pub fn expected_version(&self) -> MachineLifecycleExpectedVersion {
5229 self.version()
5230 .map_or(MachineLifecycleExpectedVersion::Missing, |version| {
5231 MachineLifecycleExpectedVersion::Version(version.clone())
5232 })
5233 }
5234}
5235
5236#[derive(Debug, Clone, PartialEq, Eq)]
5242pub enum RuntimeStoreWriteFenceOutcome {
5243 Applied,
5245 Conflict { reason: String },
5247 Backoff { reason: String },
5250}
5251
5252pub trait RuntimeStoreWriteFence: Send + Sync {
5265 fn execute_if_current(
5266 &self,
5267 operation: Box<dyn FnOnce() -> Result<(), RuntimeStoreError> + '_>,
5268 ) -> Result<RuntimeStoreWriteFenceOutcome, RuntimeStoreError>;
5269}
5270
5271pub(crate) fn execute_runtime_store_write_fence(
5272 write_fence: &dyn RuntimeStoreWriteFence,
5273 operation: impl FnOnce() -> Result<(), RuntimeStoreError>,
5274) -> Result<RuntimeStoreWriteFenceOutcome, RuntimeStoreError> {
5275 let invoked = std::cell::Cell::new(false);
5276 let operation_result = std::cell::RefCell::new(None);
5277 let checked_operation = || {
5278 invoked.set(true);
5279 let result = operation();
5280 *operation_result.borrow_mut() = Some(result.clone());
5281 result
5282 };
5283 let outcome = write_fence.execute_if_current(Box::new(checked_operation))?;
5284 if let Some(Err(error)) = operation_result.borrow_mut().take() {
5285 return Err(error);
5286 }
5287 let shape_is_valid = matches!(
5288 (&outcome, invoked.get()),
5289 (RuntimeStoreWriteFenceOutcome::Applied, true)
5290 | (
5291 RuntimeStoreWriteFenceOutcome::Conflict { .. }
5292 | RuntimeStoreWriteFenceOutcome::Backoff { .. },
5293 false,
5294 )
5295 );
5296 if !shape_is_valid {
5297 return Err(RuntimeStoreError::Internal(
5298 "runtime write fence returned an outcome inconsistent with operation execution"
5299 .to_string(),
5300 ));
5301 }
5302 Ok(outcome)
5303}
5304
5305#[derive(Debug, Clone, PartialEq, Eq)]
5311pub enum FencedMachineLifecycleCasOutcome {
5312 Applied {
5313 record: DecodedMachineLifecycleObservation,
5314 version: MachineLifecycleObservationVersion,
5315 },
5316 AlreadyExact {
5317 record: DecodedMachineLifecycleObservation,
5318 version: MachineLifecycleObservationVersion,
5319 },
5320 Conflict {
5321 current: MachineLifecycleObservation,
5322 },
5323 FenceConflict {
5324 reason: String,
5325 },
5326 FenceBackoff {
5327 reason: String,
5328 },
5329}
5330
5331#[derive(Debug, Clone, PartialEq, Eq)]
5333pub enum MachineLifecycleCasOutcome {
5334 Applied {
5335 version: MachineLifecycleObservationVersion,
5336 },
5337 Conflict {
5338 current: MachineLifecycleObservation,
5339 },
5340}
5341
5342#[derive(Debug, Clone, PartialEq, Eq)]
5344pub struct MachineLifecycleSnapshot {
5345 runtime_state: RuntimeState,
5346 binding: MachineLifecycleBindingFacts,
5347 run: MachineLifecycleRunFacts,
5348 supervisor_authority: SupervisorAuthoritySnapshot,
5349 unregister_progress: Option<MachineUnregisterProgressSnapshot>,
5350}
5351
5352#[derive(Debug, Clone, PartialEq, Eq)]
5356pub struct MachineUnregisterProgressSnapshot {
5357 runtime_loop_drain_pending: bool,
5358 comms_drain_exit_pending: bool,
5359 completion_waiter_drain_pending: bool,
5360 runtime_loop_forced_abort: bool,
5361 comms_drain_forced_abort: bool,
5362}
5363
5364impl MachineUnregisterProgressSnapshot {
5365 pub(crate) fn new(
5366 runtime_loop_drain_pending: bool,
5367 comms_drain_exit_pending: bool,
5368 completion_waiter_drain_pending: bool,
5369 runtime_loop_forced_abort: bool,
5370 comms_drain_forced_abort: bool,
5371 ) -> Self {
5372 Self {
5373 runtime_loop_drain_pending,
5374 comms_drain_exit_pending,
5375 completion_waiter_drain_pending,
5376 runtime_loop_forced_abort,
5377 comms_drain_forced_abort,
5378 }
5379 }
5380
5381 pub(crate) fn runtime_loop_drain_pending(&self) -> bool {
5382 self.runtime_loop_drain_pending
5383 }
5384
5385 pub(crate) fn comms_drain_exit_pending(&self) -> bool {
5386 self.comms_drain_exit_pending
5387 }
5388
5389 pub(crate) fn completion_waiter_drain_pending(&self) -> bool {
5390 self.completion_waiter_drain_pending
5391 }
5392
5393 pub(crate) fn runtime_loop_forced_abort(&self) -> bool {
5394 self.runtime_loop_forced_abort
5395 }
5396
5397 pub(crate) fn comms_drain_forced_abort(&self) -> bool {
5398 self.comms_drain_forced_abort
5399 }
5400}
5401
5402impl MachineLifecycleSnapshot {
5403 pub(crate) fn new(
5404 runtime_state: RuntimeState,
5405 binding: MachineLifecycleBindingFacts,
5406 supervisor_authority: SupervisorAuthoritySnapshot,
5407 ) -> Self {
5408 Self::new_with_unregister_progress(runtime_state, binding, supervisor_authority, None)
5409 }
5410
5411 pub(crate) fn new_with_unregister_progress(
5412 runtime_state: RuntimeState,
5413 binding: MachineLifecycleBindingFacts,
5414 supervisor_authority: SupervisorAuthoritySnapshot,
5415 unregister_progress: Option<MachineUnregisterProgressSnapshot>,
5416 ) -> Self {
5417 Self::new_with_run_and_unregister_progress(
5418 runtime_state,
5419 binding,
5420 MachineLifecycleRunFacts::default(),
5421 supervisor_authority,
5422 unregister_progress,
5423 )
5424 }
5425
5426 pub(crate) fn new_with_run_and_unregister_progress(
5427 runtime_state: RuntimeState,
5428 binding: MachineLifecycleBindingFacts,
5429 run: MachineLifecycleRunFacts,
5430 supervisor_authority: SupervisorAuthoritySnapshot,
5431 unregister_progress: Option<MachineUnregisterProgressSnapshot>,
5432 ) -> Self {
5433 Self {
5434 runtime_state,
5435 binding,
5436 run,
5437 supervisor_authority,
5438 unregister_progress,
5439 }
5440 }
5441
5442 pub fn runtime_state(&self) -> RuntimeState {
5444 self.runtime_state
5445 }
5446
5447 pub fn binding(&self) -> &MachineLifecycleBindingFacts {
5449 &self.binding
5450 }
5451
5452 pub fn run(&self) -> &MachineLifecycleRunFacts {
5454 &self.run
5455 }
5456
5457 pub fn supervisor_authority(&self) -> &SupervisorAuthoritySnapshot {
5458 &self.supervisor_authority
5459 }
5460
5461 pub fn unregister_progress(&self) -> Option<&MachineUnregisterProgressSnapshot> {
5462 self.unregister_progress.as_ref()
5463 }
5464}
5465
5466#[allow(
5467 clippy::option_option,
5468 reason = "serde distinguishes missing from explicit null"
5469)]
5470fn deserialize_present_nullable<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
5471where
5472 D: serde::Deserializer<'de>,
5473 T: serde::Deserialize<'de>,
5474{
5475 <Option<T> as serde::Deserialize>::deserialize(deserializer).map(Some)
5476}
5477
5478#[allow(
5479 clippy::option_option,
5480 reason = "serde distinguishes missing from explicit null"
5481)]
5482fn require_present_nullable<T>(
5483 value: Option<Option<T>>,
5484 field: &str,
5485) -> Result<Option<T>, RuntimeStoreError> {
5486 value.ok_or_else(|| {
5487 RuntimeStoreError::ReadFailed(format!(
5488 "machine lifecycle field {field} is required (explicit null is allowed)"
5489 ))
5490 })
5491}
5492
5493#[derive(serde::Serialize, serde::Deserialize)]
5494#[serde(deny_unknown_fields)]
5495struct MachineLifecycleBindingFactsStoreWire {
5496 #[allow(
5497 clippy::option_option,
5498 reason = "serde distinguishes missing from explicit null"
5499 )]
5500 #[serde(default, deserialize_with = "deserialize_present_nullable")]
5501 agent_runtime_id: Option<Option<String>>,
5502 #[allow(
5503 clippy::option_option,
5504 reason = "serde distinguishes missing from explicit null"
5505 )]
5506 #[serde(default, deserialize_with = "deserialize_present_nullable")]
5507 fence_token: Option<Option<u64>>,
5508 #[allow(
5509 clippy::option_option,
5510 reason = "serde distinguishes missing from explicit null"
5511 )]
5512 #[serde(default, deserialize_with = "deserialize_present_nullable")]
5513 runtime_generation: Option<Option<u64>>,
5514 #[allow(
5515 clippy::option_option,
5516 reason = "serde distinguishes missing from explicit null"
5517 )]
5518 #[serde(default, deserialize_with = "deserialize_present_nullable")]
5519 runtime_epoch_id: Option<Option<String>>,
5520}
5521
5522#[derive(serde::Deserialize)]
5523#[serde(deny_unknown_fields)]
5524struct MachineLifecycleBindingFactsStoreWireV1 {
5525 agent_runtime_id: Option<String>,
5526 fence_token: Option<u64>,
5527 runtime_generation: Option<u64>,
5528 runtime_epoch_id: Option<String>,
5529}
5530
5531impl From<&MachineLifecycleBindingFacts> for MachineLifecycleBindingFactsStoreWire {
5532 fn from(binding: &MachineLifecycleBindingFacts) -> Self {
5533 Self {
5534 agent_runtime_id: Some(binding.agent_runtime_id().map(ToOwned::to_owned)),
5535 fence_token: Some(binding.fence_token()),
5536 runtime_generation: Some(binding.runtime_generation()),
5537 runtime_epoch_id: Some(binding.runtime_epoch_id().map(ToOwned::to_owned)),
5538 }
5539 }
5540}
5541
5542impl TryFrom<MachineLifecycleBindingFactsStoreWire> for MachineLifecycleBindingFacts {
5543 type Error = RuntimeStoreError;
5544
5545 fn try_from(binding: MachineLifecycleBindingFactsStoreWire) -> Result<Self, Self::Error> {
5546 Ok(Self::new(
5547 require_present_nullable(binding.agent_runtime_id, "binding.agent_runtime_id")?,
5548 require_present_nullable(binding.fence_token, "binding.fence_token")?,
5549 require_present_nullable(binding.runtime_generation, "binding.runtime_generation")?,
5550 require_present_nullable(binding.runtime_epoch_id, "binding.runtime_epoch_id")?,
5551 ))
5552 }
5553}
5554
5555impl From<MachineLifecycleBindingFactsStoreWireV1> for MachineLifecycleBindingFacts {
5556 fn from(binding: MachineLifecycleBindingFactsStoreWireV1) -> Self {
5557 Self::new(
5558 binding.agent_runtime_id,
5559 binding.fence_token,
5560 binding.runtime_generation,
5561 binding.runtime_epoch_id,
5562 )
5563 }
5564}
5565
5566#[derive(serde::Serialize)]
5567#[serde(deny_unknown_fields)]
5568struct MachineLifecycleSnapshotStoreWire {
5569 record_version: u16,
5570 runtime_state: RuntimeState,
5571 binding: MachineLifecycleBindingFactsStoreWire,
5572 current_run_id: Option<RunId>,
5573 pre_run_phase: Option<MachineLifecyclePreRunPhase>,
5574 supervisor_authority: SupervisorAuthoritySnapshotStoreWire,
5575 unregister_progress: Option<MachineUnregisterProgressSnapshotStoreWire>,
5576}
5577
5578#[derive(serde::Deserialize)]
5579#[serde(deny_unknown_fields)]
5580struct MachineLifecycleObservationStoreWireV4 {
5581 record_version: u16,
5582 #[allow(
5583 clippy::option_option,
5584 reason = "serde distinguishes a missing phase from an explicitly absent observed phase"
5585 )]
5586 #[serde(default, deserialize_with = "deserialize_present_nullable")]
5587 runtime_state: Option<Option<RuntimeState>>,
5588 binding: MachineLifecycleBindingFactsStoreWire,
5589 #[allow(
5590 clippy::option_option,
5591 reason = "serde distinguishes a missing run id from an explicitly absent run id"
5592 )]
5593 #[serde(default, deserialize_with = "deserialize_present_nullable")]
5594 current_run_id: Option<Option<RunId>>,
5595 #[allow(
5596 clippy::option_option,
5597 reason = "serde distinguishes a missing pre-run phase from an explicitly absent phase"
5598 )]
5599 #[serde(default, deserialize_with = "deserialize_present_nullable")]
5600 pre_run_phase: Option<Option<MachineLifecyclePreRunPhase>>,
5601 supervisor_authority: SupervisorAuthoritySnapshotStoreWire,
5602 #[allow(
5603 clippy::option_option,
5604 reason = "serde distinguishes a missing v4 field from explicit null progress"
5605 )]
5606 #[serde(default, deserialize_with = "deserialize_present_nullable")]
5607 unregister_progress: Option<Option<MachineUnregisterProgressSnapshotStoreWire>>,
5608}
5609
5610#[derive(serde::Deserialize)]
5611#[serde(deny_unknown_fields)]
5612struct MachineLifecycleSnapshotStoreWireV3 {
5613 record_version: u16,
5614 runtime_state: RuntimeState,
5615 binding: MachineLifecycleBindingFactsStoreWire,
5616 supervisor_authority: SupervisorAuthoritySnapshotStoreWire,
5617 #[allow(
5618 clippy::option_option,
5619 reason = "serde distinguishes a missing v3 field from explicit null progress"
5620 )]
5621 #[serde(default, deserialize_with = "deserialize_present_nullable")]
5622 unregister_progress: Option<Option<MachineUnregisterProgressSnapshotStoreWire>>,
5623}
5624
5625#[derive(serde::Deserialize)]
5626#[serde(deny_unknown_fields)]
5627struct MachineLifecycleSnapshotStoreWireV2 {
5628 record_version: u16,
5629 runtime_state: RuntimeState,
5630 binding: MachineLifecycleBindingFactsStoreWire,
5631 supervisor_authority: SupervisorAuthoritySnapshotStoreWire,
5632}
5633
5634#[derive(serde::Serialize, serde::Deserialize)]
5635#[serde(deny_unknown_fields)]
5636struct MachineUnregisterProgressSnapshotStoreWire {
5637 runtime_loop_drain_pending: bool,
5638 comms_drain_exit_pending: bool,
5639 completion_waiter_drain_pending: bool,
5640 runtime_loop_forced_abort: bool,
5641 comms_drain_forced_abort: bool,
5642}
5643
5644impl From<&MachineUnregisterProgressSnapshot> for MachineUnregisterProgressSnapshotStoreWire {
5645 fn from(snapshot: &MachineUnregisterProgressSnapshot) -> Self {
5646 Self {
5647 runtime_loop_drain_pending: snapshot.runtime_loop_drain_pending(),
5648 comms_drain_exit_pending: snapshot.comms_drain_exit_pending(),
5649 completion_waiter_drain_pending: snapshot.completion_waiter_drain_pending(),
5650 runtime_loop_forced_abort: snapshot.runtime_loop_forced_abort(),
5651 comms_drain_forced_abort: snapshot.comms_drain_forced_abort(),
5652 }
5653 }
5654}
5655
5656impl From<MachineUnregisterProgressSnapshotStoreWire> for MachineUnregisterProgressSnapshot {
5657 fn from(snapshot: MachineUnregisterProgressSnapshotStoreWire) -> Self {
5658 Self::new(
5659 snapshot.runtime_loop_drain_pending,
5660 snapshot.comms_drain_exit_pending,
5661 snapshot.completion_waiter_drain_pending,
5662 snapshot.runtime_loop_forced_abort,
5663 snapshot.comms_drain_forced_abort,
5664 )
5665 }
5666}
5667
5668#[derive(serde::Deserialize)]
5672#[serde(deny_unknown_fields)]
5673struct MachineLifecycleSnapshotStoreWireV1 {
5674 record_version: u16,
5675 runtime_state: RuntimeState,
5676 binding: MachineLifecycleBindingFactsStoreWireV1,
5677}
5678
5679#[derive(serde::Deserialize)]
5680struct MachineLifecycleSnapshotStoreVersionProbe {
5681 record_version: u16,
5682}
5683
5684#[derive(Default, serde::Serialize, serde::Deserialize)]
5685#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
5686enum SupervisorAuthoritySnapshotStoreWire {
5687 #[default]
5688 UnboundNoReceipt,
5689 Bound {
5690 binding: SupervisorBindingReceiptStoreWire,
5691 },
5692 RevocationPending {
5693 pending: SupervisorRevocationPendingReceiptStoreWire,
5694 },
5695 RotationOperation {
5696 rotation: SupervisorRotationReceiptStoreWire,
5697 },
5698 RevokedReceipt {
5699 receipt: RevokedSupervisorReceiptStoreWire,
5700 },
5701 WithRotationHistory {
5702 current: Box<SupervisorAuthoritySnapshotStoreWire>,
5703 terminal_receipts: Vec<SupervisorRotationReceiptStoreWire>,
5704 },
5705}
5706
5707#[derive(serde::Serialize, serde::Deserialize)]
5708#[serde(deny_unknown_fields)]
5709struct SupervisorBindingReceiptStoreWire {
5710 name: String,
5711 peer_id: String,
5712 address: String,
5713 signing_public_key: String,
5714 epoch: u64,
5715}
5716
5717impl From<&SupervisorBindingReceipt> for SupervisorBindingReceiptStoreWire {
5718 fn from(receipt: &SupervisorBindingReceipt) -> Self {
5719 Self {
5720 name: receipt.name().to_owned(),
5721 peer_id: receipt.peer_id().to_owned(),
5722 address: receipt.address().to_owned(),
5723 signing_public_key: receipt.signing_public_key().to_owned(),
5724 epoch: receipt.epoch(),
5725 }
5726 }
5727}
5728
5729impl From<SupervisorBindingReceiptStoreWire> for SupervisorBindingReceipt {
5730 fn from(receipt: SupervisorBindingReceiptStoreWire) -> Self {
5731 Self::new(
5732 receipt.name,
5733 receipt.peer_id,
5734 receipt.address,
5735 receipt.signing_public_key,
5736 receipt.epoch,
5737 )
5738 }
5739}
5740
5741#[derive(serde::Serialize, serde::Deserialize)]
5742#[serde(deny_unknown_fields)]
5743struct RevokedSupervisorReceiptStoreWire {
5744 peer_id: String,
5745 signing_public_key: String,
5746 epoch: u64,
5747}
5748
5749#[derive(serde::Serialize, serde::Deserialize)]
5750#[serde(deny_unknown_fields)]
5751struct SupervisorRevocationPendingReceiptStoreWire {
5752 name: String,
5753 peer_id: String,
5754 address: String,
5755 signing_public_key: String,
5756 epoch: u64,
5757}
5758
5759#[derive(serde::Serialize, serde::Deserialize)]
5760#[serde(deny_unknown_fields)]
5761struct SupervisorRotationReceiptStoreWire {
5762 operation_id: meerkat_contracts::wire::supervisor_bridge::SupervisorRotationOperationId,
5763 phase: SupervisorRotationPersistencePhase,
5764 #[allow(
5765 clippy::option_option,
5766 reason = "serde distinguishes missing from explicit null"
5767 )]
5768 #[serde(default, deserialize_with = "deserialize_present_nullable")]
5769 rejection: Option<Option<SupervisorRotationRejection>>,
5770 previous: SupervisorBindingReceiptStoreWire,
5771 next: SupervisorBindingReceiptStoreWire,
5772}
5773
5774impl From<&SupervisorRotationReceipt> for SupervisorRotationReceiptStoreWire {
5775 fn from(receipt: &SupervisorRotationReceipt) -> Self {
5776 Self {
5777 operation_id: receipt.operation_id(),
5778 phase: receipt.phase(),
5779 rejection: Some(receipt.rejection()),
5780 previous: receipt.previous().into(),
5781 next: receipt.next().into(),
5782 }
5783 }
5784}
5785
5786impl TryFrom<SupervisorRotationReceiptStoreWire> for SupervisorRotationReceipt {
5787 type Error = RuntimeStoreError;
5788
5789 fn try_from(receipt: SupervisorRotationReceiptStoreWire) -> Result<Self, Self::Error> {
5790 Ok(Self::new(
5791 receipt.operation_id,
5792 receipt.phase,
5793 require_present_nullable(receipt.rejection, "supervisor_authority.rotation.rejection")?,
5794 receipt.previous.into(),
5795 receipt.next.into(),
5796 ))
5797 }
5798}
5799
5800impl From<&SupervisorRevocationPendingReceipt> for SupervisorRevocationPendingReceiptStoreWire {
5801 fn from(receipt: &SupervisorRevocationPendingReceipt) -> Self {
5802 Self {
5803 name: receipt.name().to_owned(),
5804 peer_id: receipt.peer_id().to_owned(),
5805 address: receipt.address().to_owned(),
5806 signing_public_key: receipt.signing_public_key().to_owned(),
5807 epoch: receipt.epoch(),
5808 }
5809 }
5810}
5811
5812impl From<SupervisorRevocationPendingReceiptStoreWire> for SupervisorRevocationPendingReceipt {
5813 fn from(receipt: SupervisorRevocationPendingReceiptStoreWire) -> Self {
5814 Self::new(
5815 receipt.name,
5816 receipt.peer_id,
5817 receipt.address,
5818 receipt.signing_public_key,
5819 receipt.epoch,
5820 )
5821 }
5822}
5823
5824impl From<&RevokedSupervisorReceipt> for RevokedSupervisorReceiptStoreWire {
5825 fn from(receipt: &RevokedSupervisorReceipt) -> Self {
5826 Self {
5827 peer_id: receipt.peer_id().to_owned(),
5828 signing_public_key: receipt.signing_public_key().to_owned(),
5829 epoch: receipt.epoch(),
5830 }
5831 }
5832}
5833
5834impl From<RevokedSupervisorReceiptStoreWire> for RevokedSupervisorReceipt {
5835 fn from(receipt: RevokedSupervisorReceiptStoreWire) -> Self {
5836 Self::new(receipt.peer_id, receipt.signing_public_key, receipt.epoch)
5837 }
5838}
5839
5840impl From<&SupervisorAuthoritySnapshot> for SupervisorAuthoritySnapshotStoreWire {
5841 fn from(snapshot: &SupervisorAuthoritySnapshot) -> Self {
5842 match snapshot {
5843 SupervisorAuthoritySnapshot::UnboundNoReceipt => Self::UnboundNoReceipt,
5844 SupervisorAuthoritySnapshot::Bound(binding) => Self::Bound {
5845 binding: binding.into(),
5846 },
5847 SupervisorAuthoritySnapshot::RevocationPending(pending) => Self::RevocationPending {
5848 pending: pending.into(),
5849 },
5850 SupervisorAuthoritySnapshot::RotationOperation(rotation) => Self::RotationOperation {
5851 rotation: rotation.into(),
5852 },
5853 SupervisorAuthoritySnapshot::RevokedReceipt(receipt) => Self::RevokedReceipt {
5854 receipt: receipt.into(),
5855 },
5856 SupervisorAuthoritySnapshot::WithRotationHistory {
5857 current,
5858 terminal_receipts,
5859 } => Self::WithRotationHistory {
5860 current: Box::new(current.as_ref().into()),
5861 terminal_receipts: terminal_receipts.values().map(Into::into).collect(),
5862 },
5863 }
5864 }
5865}
5866
5867fn supervisor_authority_read_error(
5868 context: &str,
5869 detail: impl std::fmt::Display,
5870) -> RuntimeStoreError {
5871 RuntimeStoreError::ReadFailed(format!("{context}: {detail}"))
5872}
5873
5874fn validate_supervisor_descriptor(
5875 name: &str,
5876 peer_id: &str,
5877 address: &str,
5878 signing_public_key: &str,
5879 context: &str,
5880) -> Result<(), RuntimeStoreError> {
5881 let pubkey = crate::comms_drain::decode_supervisor_signing_public_key(signing_public_key)
5882 .map_err(|error| supervisor_authority_read_error(context, error))?;
5883 let spec = meerkat_contracts::wire::supervisor_bridge::BridgePeerSpec {
5884 name: name.to_owned(),
5885 peer_id: peer_id.to_owned(),
5886 address: address.to_owned(),
5887 pubkey,
5888 };
5889 meerkat_core::comms::TrustedPeerDescriptor::try_from(&spec)
5890 .map(|_| ())
5891 .map_err(|error| supervisor_authority_read_error(context, error))
5892}
5893
5894fn validate_supervisor_binding_receipt(
5895 receipt: &SupervisorBindingReceipt,
5896 context: &str,
5897) -> Result<(), RuntimeStoreError> {
5898 validate_supervisor_descriptor(
5899 receipt.name(),
5900 receipt.peer_id(),
5901 receipt.address(),
5902 receipt.signing_public_key(),
5903 context,
5904 )
5905}
5906
5907fn validate_revoked_supervisor_receipt(
5908 receipt: &RevokedSupervisorReceipt,
5909 context: &str,
5910) -> Result<(), RuntimeStoreError> {
5911 let pubkey =
5912 crate::comms_drain::decode_supervisor_signing_public_key(receipt.signing_public_key())
5913 .map_err(|error| supervisor_authority_read_error(context, error))?;
5914 if pubkey.iter().all(|byte| *byte == 0) {
5915 return Err(supervisor_authority_read_error(
5916 context,
5917 "supervisor signing public key must be non-zero",
5918 ));
5919 }
5920 let peer_id = meerkat_core::comms::PeerId::parse(receipt.peer_id())
5921 .map_err(|error| supervisor_authority_read_error(context, error))?;
5922 let derived = meerkat_core::comms::PeerId::from_ed25519_pubkey(&pubkey);
5923 if peer_id != derived {
5924 return Err(supervisor_authority_read_error(
5925 context,
5926 format!("peer id {peer_id} does not match signing-key-derived id {derived}"),
5927 ));
5928 }
5929 Ok(())
5930}
5931
5932fn validate_supervisor_rotation_receipt(
5933 receipt: &SupervisorRotationReceipt,
5934 terminal_history: bool,
5935) -> Result<(), RuntimeStoreError> {
5936 let operation_id = receipt.operation_id();
5937 if operation_id.as_uuid().is_nil() {
5938 return Err(supervisor_authority_read_error(
5939 "supervisor rotation operation",
5940 "operation id must not be the nil UUID",
5941 ));
5942 }
5943 validate_supervisor_binding_receipt(
5944 receipt.previous(),
5945 &format!("supervisor rotation {operation_id} previous authority is invalid"),
5946 )?;
5947
5948 let rejection_matches = matches!(
5949 (receipt.phase(), receipt.rejection()),
5950 (
5951 SupervisorRotationPersistencePhase::PreviousRevokePending
5952 | SupervisorRotationPersistencePhase::NextPublishPending
5953 | SupervisorRotationPersistencePhase::Completed,
5954 None
5955 ) | (SupervisorRotationPersistencePhase::Rejected, Some(_))
5956 );
5957 if !rejection_matches {
5958 return Err(supervisor_authority_read_error(
5959 "supervisor rotation operation",
5960 format!("{operation_id} has inconsistent rejection state"),
5961 ));
5962 }
5963 if terminal_history
5964 && !matches!(
5965 receipt.phase(),
5966 SupervisorRotationPersistencePhase::Completed
5967 | SupervisorRotationPersistencePhase::Rejected
5968 )
5969 {
5970 return Err(supervisor_authority_read_error(
5971 "supervisor rotation history",
5972 format!("{operation_id} is not terminal"),
5973 ));
5974 }
5975
5976 match receipt.phase() {
5977 SupervisorRotationPersistencePhase::PreviousRevokePending
5978 | SupervisorRotationPersistencePhase::NextPublishPending => {
5979 validate_supervisor_binding_receipt(
5980 receipt.next(),
5981 &format!("supervisor rotation {operation_id} target is invalid"),
5982 )?;
5983 if receipt.next().epoch() <= receipt.previous().epoch() {
5984 return Err(supervisor_authority_read_error(
5985 "supervisor rotation operation",
5986 format!(
5987 "{operation_id} target epoch {} does not advance previous epoch {}",
5988 receipt.next().epoch(),
5989 receipt.previous().epoch()
5990 ),
5991 ));
5992 }
5993 }
5994 SupervisorRotationPersistencePhase::Completed => {
5995 validate_supervisor_binding_receipt(
5996 receipt.next(),
5997 &format!("supervisor rotation {operation_id} target is invalid"),
5998 )?;
5999 let exact_current_adoption = receipt.previous() == receipt.next();
6003 if !exact_current_adoption && receipt.next().epoch() <= receipt.previous().epoch() {
6004 return Err(supervisor_authority_read_error(
6005 "supervisor rotation operation",
6006 format!(
6007 "{operation_id} completed target epoch {} does not advance previous epoch {}",
6008 receipt.next().epoch(),
6009 receipt.previous().epoch()
6010 ),
6011 ));
6012 }
6013 }
6014 SupervisorRotationPersistencePhase::Rejected => {
6015 let Some(rejection) = receipt.rejection() else {
6016 return Err(supervisor_authority_read_error(
6017 "supervisor rotation operation",
6018 format!("{operation_id} rejected without a rejection class"),
6019 ));
6020 };
6021 match rejection {
6022 SupervisorRotationRejection::InvalidTarget
6023 | SupervisorRotationRejection::UnsupportedProtocolVersion => {
6024 }
6028 SupervisorRotationRejection::TargetEpochNotAdvanced => {
6029 validate_supervisor_binding_receipt(
6030 receipt.next(),
6031 &format!("supervisor rotation {operation_id} rejected target is invalid"),
6032 )?;
6033 if receipt.next().epoch() > receipt.previous().epoch() {
6034 return Err(supervisor_authority_read_error(
6035 "supervisor rotation operation",
6036 format!(
6037 "{operation_id} rejected as non-advancing but target epoch {} advances previous epoch {}",
6038 receipt.next().epoch(),
6039 receipt.previous().epoch()
6040 ),
6041 ));
6042 }
6043 }
6044 SupervisorRotationRejection::OperationConflict
6045 | SupervisorRotationRejection::NotBound
6046 | SupervisorRotationRejection::SenderMismatch => {
6047 return Err(supervisor_authority_read_error(
6048 "supervisor rotation operation",
6049 format!(
6050 "{operation_id} transient rejection {rejection:?} must not be persisted as a durable receipt"
6051 ),
6052 ));
6053 }
6054 }
6055 }
6056 }
6057 Ok(())
6058}
6059
6060type SupervisorEpochKeyIndex = std::collections::BTreeMap<u64, [u8; 32]>;
6061
6062fn record_supervisor_epoch_key(
6063 epochs: &mut SupervisorEpochKeyIndex,
6064 epoch: u64,
6065 signing_public_key: &str,
6066 context: &str,
6067) -> Result<(), RuntimeStoreError> {
6068 let key = crate::comms_drain::decode_supervisor_signing_public_key(signing_public_key)
6069 .map_err(|error| supervisor_authority_read_error(context, error))?;
6070 if let Some(existing) = epochs.get(&epoch) {
6071 if existing != &key {
6072 return Err(supervisor_authority_read_error(
6073 context,
6074 format!("epoch {epoch} is bound to conflicting supervisor signing keys"),
6075 ));
6076 }
6077 } else {
6078 epochs.insert(epoch, key);
6079 }
6080 Ok(())
6081}
6082
6083fn record_supervisor_binding_epoch(
6084 epochs: &mut SupervisorEpochKeyIndex,
6085 receipt: &SupervisorBindingReceipt,
6086 context: &str,
6087) -> Result<(), RuntimeStoreError> {
6088 record_supervisor_epoch_key(
6089 epochs,
6090 receipt.epoch(),
6091 receipt.signing_public_key(),
6092 context,
6093 )
6094}
6095
6096fn record_rotation_authoritative_epochs(
6097 epochs: &mut SupervisorEpochKeyIndex,
6098 receipt: &SupervisorRotationReceipt,
6099 context: &str,
6100) -> Result<(), RuntimeStoreError> {
6101 record_supervisor_binding_epoch(epochs, receipt.previous(), context)?;
6102 if matches!(
6103 receipt.phase(),
6104 SupervisorRotationPersistencePhase::PreviousRevokePending
6105 | SupervisorRotationPersistencePhase::NextPublishPending
6106 | SupervisorRotationPersistencePhase::Completed
6107 ) {
6108 record_supervisor_binding_epoch(epochs, receipt.next(), context)?;
6109 }
6110 Ok(())
6111}
6112
6113fn record_current_authoritative_epochs(
6114 epochs: &mut SupervisorEpochKeyIndex,
6115 current: &SupervisorAuthoritySnapshot,
6116) -> Result<(), RuntimeStoreError> {
6117 match current {
6118 SupervisorAuthoritySnapshot::UnboundNoReceipt => Ok(()),
6119 SupervisorAuthoritySnapshot::Bound(binding) => {
6120 record_supervisor_binding_epoch(epochs, binding, "current supervisor authority")
6121 }
6122 SupervisorAuthoritySnapshot::RevocationPending(pending) => record_supervisor_epoch_key(
6123 epochs,
6124 pending.epoch(),
6125 pending.signing_public_key(),
6126 "current pending supervisor revocation authority",
6127 ),
6128 SupervisorAuthoritySnapshot::RotationOperation(rotation) => {
6129 record_rotation_authoritative_epochs(
6130 epochs,
6131 rotation,
6132 "current supervisor rotation authority",
6133 )
6134 }
6135 SupervisorAuthoritySnapshot::RevokedReceipt(receipt) => record_supervisor_epoch_key(
6136 epochs,
6137 receipt.epoch(),
6138 receipt.signing_public_key(),
6139 "current revoked supervisor authority",
6140 ),
6141 SupervisorAuthoritySnapshot::WithRotationHistory { .. } => {
6142 Err(RuntimeStoreError::ReadFailed(
6143 "nested supervisor rotation history is not canonical".to_string(),
6144 ))
6145 }
6146 }
6147}
6148
6149fn current_supervisor_epoch(current: &SupervisorAuthoritySnapshot) -> Option<u64> {
6150 match current {
6151 SupervisorAuthoritySnapshot::UnboundNoReceipt => None,
6152 SupervisorAuthoritySnapshot::Bound(binding) => Some(binding.epoch()),
6153 SupervisorAuthoritySnapshot::RevocationPending(pending) => Some(pending.epoch()),
6154 SupervisorAuthoritySnapshot::RotationOperation(rotation) => Some(match rotation.phase() {
6155 SupervisorRotationPersistencePhase::PreviousRevokePending
6156 | SupervisorRotationPersistencePhase::Rejected => rotation.previous().epoch(),
6157 SupervisorRotationPersistencePhase::NextPublishPending
6158 | SupervisorRotationPersistencePhase::Completed => rotation.next().epoch(),
6159 }),
6160 SupervisorAuthoritySnapshot::RevokedReceipt(receipt) => Some(receipt.epoch()),
6161 SupervisorAuthoritySnapshot::WithRotationHistory { .. } => None,
6162 }
6163}
6164
6165fn terminal_rotation_authority_epoch(receipt: &SupervisorRotationReceipt) -> u64 {
6166 match receipt.phase() {
6167 SupervisorRotationPersistencePhase::Completed => receipt.next().epoch(),
6168 SupervisorRotationPersistencePhase::Rejected => receipt.previous().epoch(),
6169 SupervisorRotationPersistencePhase::PreviousRevokePending
6170 | SupervisorRotationPersistencePhase::NextPublishPending => receipt.previous().epoch(),
6171 }
6172}
6173
6174fn validate_supervisor_rotation_history_coherence(
6175 current: &SupervisorAuthoritySnapshot,
6176 terminal_receipts: &std::collections::BTreeMap<
6177 meerkat_contracts::wire::supervisor_bridge::SupervisorRotationOperationId,
6178 SupervisorRotationReceipt,
6179 >,
6180) -> Result<(), RuntimeStoreError> {
6181 let Some(current_epoch) = current_supervisor_epoch(current) else {
6182 return Err(RuntimeStoreError::ReadFailed(
6183 "supervisor rotation history requires a current authority epoch".to_string(),
6184 ));
6185 };
6186
6187 let mut epochs = SupervisorEpochKeyIndex::new();
6188 record_current_authoritative_epochs(&mut epochs, current)?;
6189 let mut history_high_water = 0;
6190 for receipt in terminal_receipts.values() {
6191 record_rotation_authoritative_epochs(
6192 &mut epochs,
6193 receipt,
6194 "supervisor rotation history authority",
6195 )?;
6196 history_high_water = history_high_water.max(terminal_rotation_authority_epoch(receipt));
6197 }
6198 if current_epoch < history_high_water {
6199 return Err(RuntimeStoreError::ReadFailed(format!(
6200 "current supervisor epoch {current_epoch} is below terminal rotation history high-water {history_high_water}"
6201 )));
6202 }
6203 Ok(())
6204}
6205
6206fn validate_supervisor_authority_snapshot(
6207 snapshot: &SupervisorAuthoritySnapshot,
6208) -> Result<(), RuntimeStoreError> {
6209 match snapshot {
6210 SupervisorAuthoritySnapshot::UnboundNoReceipt => Ok(()),
6211 SupervisorAuthoritySnapshot::Bound(binding) => {
6212 validate_supervisor_binding_receipt(binding, "bound supervisor is invalid")
6213 }
6214 SupervisorAuthoritySnapshot::RevocationPending(pending) => validate_supervisor_descriptor(
6215 pending.name(),
6216 pending.peer_id(),
6217 pending.address(),
6218 pending.signing_public_key(),
6219 "pending supervisor revocation authority is invalid",
6220 ),
6221 SupervisorAuthoritySnapshot::RotationOperation(rotation) => {
6222 validate_supervisor_rotation_receipt(rotation, false)
6223 }
6224 SupervisorAuthoritySnapshot::RevokedReceipt(receipt) => {
6225 validate_revoked_supervisor_receipt(receipt, "revoked supervisor receipt is invalid")
6226 }
6227 SupervisorAuthoritySnapshot::WithRotationHistory {
6228 current,
6229 terminal_receipts,
6230 } => {
6231 if matches!(
6232 current.as_ref(),
6233 SupervisorAuthoritySnapshot::WithRotationHistory { .. }
6234 ) {
6235 return Err(RuntimeStoreError::ReadFailed(
6236 "nested supervisor rotation history is not canonical".to_string(),
6237 ));
6238 }
6239 if terminal_receipts.is_empty() {
6240 return Err(RuntimeStoreError::ReadFailed(
6241 "empty supervisor rotation history wrapper is not canonical".to_string(),
6242 ));
6243 }
6244 validate_supervisor_authority_snapshot(current)?;
6245 for (operation_id, receipt) in terminal_receipts {
6246 if operation_id != &receipt.operation_id() {
6247 return Err(RuntimeStoreError::ReadFailed(format!(
6248 "supervisor rotation history key {operation_id} does not match receipt id {}",
6249 receipt.operation_id()
6250 )));
6251 }
6252 validate_supervisor_rotation_receipt(receipt, true)?;
6253 }
6254 if let SupervisorAuthoritySnapshot::RotationOperation(active) = current.as_ref()
6255 && terminal_receipts.contains_key(&active.operation_id())
6256 {
6257 return Err(RuntimeStoreError::ReadFailed(
6258 "active supervisor rotation is duplicated in terminal history".to_string(),
6259 ));
6260 }
6261 validate_supervisor_rotation_history_coherence(current, terminal_receipts)
6262 }
6263 }
6264}
6265
6266impl TryFrom<SupervisorAuthoritySnapshotStoreWire> for SupervisorAuthoritySnapshot {
6267 type Error = RuntimeStoreError;
6268
6269 fn try_from(snapshot: SupervisorAuthoritySnapshotStoreWire) -> Result<Self, Self::Error> {
6270 match snapshot {
6271 SupervisorAuthoritySnapshotStoreWire::UnboundNoReceipt => Ok(Self::UnboundNoReceipt),
6272 SupervisorAuthoritySnapshotStoreWire::Bound { binding } => {
6273 let binding = binding.into();
6274 validate_supervisor_binding_receipt(&binding, "bound supervisor is invalid")?;
6275 Ok(Self::Bound(binding))
6276 }
6277 SupervisorAuthoritySnapshotStoreWire::RevocationPending { pending } => {
6278 let pending: SupervisorRevocationPendingReceipt = pending.into();
6279 validate_supervisor_descriptor(
6280 pending.name(),
6281 pending.peer_id(),
6282 pending.address(),
6283 pending.signing_public_key(),
6284 "pending supervisor revocation authority is invalid",
6285 )?;
6286 Ok(Self::RevocationPending(pending))
6287 }
6288 SupervisorAuthoritySnapshotStoreWire::RotationOperation { rotation } => {
6289 let receipt: SupervisorRotationReceipt = rotation.try_into()?;
6290 validate_supervisor_rotation_receipt(&receipt, false)?;
6291 Ok(Self::RotationOperation(receipt))
6292 }
6293 SupervisorAuthoritySnapshotStoreWire::RevokedReceipt { receipt } => {
6294 let receipt = receipt.into();
6295 validate_revoked_supervisor_receipt(
6296 &receipt,
6297 "revoked supervisor receipt is invalid",
6298 )?;
6299 Ok(Self::RevokedReceipt(receipt))
6300 }
6301 SupervisorAuthoritySnapshotStoreWire::WithRotationHistory {
6302 current,
6303 terminal_receipts,
6304 } => {
6305 if terminal_receipts.is_empty() {
6306 return Err(RuntimeStoreError::ReadFailed(
6307 "empty supervisor rotation history wrapper is not canonical".to_string(),
6308 ));
6309 }
6310 let current = Self::try_from(*current)?;
6311 if matches!(current, Self::WithRotationHistory { .. }) {
6312 return Err(RuntimeStoreError::ReadFailed(
6313 "nested supervisor rotation history is not canonical".to_string(),
6314 ));
6315 }
6316 let mut receipts = std::collections::BTreeMap::new();
6317 for wire in terminal_receipts {
6318 let receipt: SupervisorRotationReceipt = wire.try_into()?;
6319 validate_supervisor_rotation_receipt(&receipt, true)?;
6320 if receipts.insert(receipt.operation_id(), receipt).is_some() {
6321 return Err(RuntimeStoreError::ReadFailed(
6322 "supervisor rotation history contains a duplicate operation id"
6323 .to_string(),
6324 ));
6325 }
6326 }
6327 if let Self::RotationOperation(active) = ¤t
6328 && receipts.contains_key(&active.operation_id())
6329 {
6330 return Err(RuntimeStoreError::ReadFailed(
6331 "active supervisor rotation is duplicated in terminal history".to_string(),
6332 ));
6333 }
6334 let snapshot = Self::WithRotationHistory {
6335 current: Box::new(current),
6336 terminal_receipts: receipts,
6337 };
6338 validate_supervisor_authority_snapshot(&snapshot)?;
6339 Ok(snapshot)
6340 }
6341 }
6342 }
6343}
6344
6345impl From<&MachineLifecycleSnapshot> for MachineLifecycleSnapshotStoreWire {
6346 fn from(snapshot: &MachineLifecycleSnapshot) -> Self {
6347 Self {
6348 record_version: MACHINE_LIFECYCLE_STORE_RECORD_VERSION,
6349 runtime_state: snapshot.runtime_state(),
6350 binding: snapshot.binding().into(),
6351 current_run_id: snapshot.run().current_run_id().cloned(),
6352 pre_run_phase: snapshot.run().pre_run_phase(),
6353 supervisor_authority: snapshot.supervisor_authority().into(),
6354 unregister_progress: snapshot.unregister_progress().map(Into::into),
6355 }
6356 }
6357}
6358
6359fn validate_unregister_progress_snapshot(
6360 progress: Option<&MachineUnregisterProgressSnapshot>,
6361) -> Result<(), RuntimeStoreError> {
6362 if let Some(progress) = progress {
6363 if progress.runtime_loop_drain_pending() && progress.runtime_loop_forced_abort() {
6364 return Err(RuntimeStoreError::ReadFailed(
6365 "unregister runtime-loop forced disposition cannot precede obligation closure"
6366 .into(),
6367 ));
6368 }
6369 if progress.comms_drain_exit_pending() && progress.comms_drain_forced_abort() {
6370 return Err(RuntimeStoreError::ReadFailed(
6371 "unregister comms-drain forced disposition cannot precede obligation closure"
6372 .into(),
6373 ));
6374 }
6375 }
6376 Ok(())
6377}
6378
6379impl TryFrom<MachineLifecycleSnapshotStoreWireV3> for MachineLifecycleSnapshot {
6380 type Error = RuntimeStoreError;
6381
6382 fn try_from(record: MachineLifecycleSnapshotStoreWireV3) -> Result<Self, Self::Error> {
6383 if record.record_version != UNREGISTER_MACHINE_LIFECYCLE_STORE_RECORD_VERSION {
6384 return Err(RuntimeStoreError::ReadFailed(format!(
6385 "unsupported machine lifecycle store record version {}",
6386 record.record_version
6387 )));
6388 }
6389 let unregister_progress =
6390 require_present_nullable(record.unregister_progress, "unregister_progress")?
6391 .map(Into::into);
6392 validate_unregister_progress_snapshot(unregister_progress.as_ref())?;
6393 Ok(Self::new_with_unregister_progress(
6394 record.runtime_state,
6395 record.binding.try_into()?,
6396 record.supervisor_authority.try_into()?,
6397 unregister_progress,
6398 ))
6399 }
6400}
6401
6402fn decode_machine_lifecycle_observation_v4(
6403 bytes: &[u8],
6404) -> Result<DecodedMachineLifecycleObservation, RuntimeStoreError> {
6405 let record = serde_json::from_slice::<MachineLifecycleObservationStoreWireV4>(bytes)
6406 .map_err(|err| RuntimeStoreError::ReadFailed(err.to_string()))?;
6407 if record.record_version != MACHINE_LIFECYCLE_STORE_RECORD_VERSION {
6408 return Err(RuntimeStoreError::ReadFailed(format!(
6409 "unsupported machine lifecycle store record version {}",
6410 record.record_version
6411 )));
6412 }
6413 let runtime_state = require_present_nullable(record.runtime_state, "runtime_state")?;
6414 let current_run_id = require_present_nullable(record.current_run_id, "current_run_id")?;
6415 let pre_run_phase = require_present_nullable(record.pre_run_phase, "pre_run_phase")?;
6416 let unregister_progress =
6417 require_present_nullable(record.unregister_progress, "unregister_progress")?
6418 .map(Into::into);
6419 validate_unregister_progress_snapshot(unregister_progress.as_ref())?;
6420 Ok(DecodedMachineLifecycleObservation {
6421 record_version: record.record_version,
6422 runtime_state,
6423 binding: record.binding.try_into()?,
6424 run: MachineLifecycleRunFacts::new(current_run_id, pre_run_phase),
6425 supervisor_authority: record.supervisor_authority.try_into()?,
6426 unregister_progress,
6427 })
6428}
6429
6430fn decoded_machine_lifecycle_from_snapshot(
6431 record_version: u16,
6432 snapshot: MachineLifecycleSnapshot,
6433) -> DecodedMachineLifecycleObservation {
6434 DecodedMachineLifecycleObservation {
6435 record_version,
6436 runtime_state: Some(snapshot.runtime_state),
6437 binding: snapshot.binding,
6438 run: snapshot.run,
6439 supervisor_authority: snapshot.supervisor_authority,
6440 unregister_progress: snapshot.unregister_progress,
6441 }
6442}
6443
6444fn decode_machine_lifecycle_store_record(
6445 bytes: &[u8],
6446) -> Result<MachineLifecycleSnapshot, RuntimeStoreError> {
6447 let version = serde_json::from_slice::<MachineLifecycleSnapshotStoreVersionProbe>(bytes)
6448 .map_err(|err| RuntimeStoreError::ReadFailed(err.to_string()))?;
6449 match version.record_version {
6450 LEGACY_MACHINE_LIFECYCLE_STORE_RECORD_VERSION => {
6451 let record = serde_json::from_slice::<MachineLifecycleSnapshotStoreWireV1>(bytes)
6452 .map_err(|err| RuntimeStoreError::ReadFailed(err.to_string()))?;
6453 if record.record_version != LEGACY_MACHINE_LIFECYCLE_STORE_RECORD_VERSION {
6454 return Err(RuntimeStoreError::ReadFailed(format!(
6455 "unsupported machine lifecycle store record version {}",
6456 record.record_version
6457 )));
6458 }
6459 Ok(MachineLifecycleSnapshot::new(
6460 record.runtime_state,
6461 record.binding.into(),
6462 SupervisorAuthoritySnapshot::UnboundNoReceipt,
6463 ))
6464 }
6465 SUPERVISOR_MACHINE_LIFECYCLE_STORE_RECORD_VERSION => {
6466 let record = serde_json::from_slice::<MachineLifecycleSnapshotStoreWireV2>(bytes)
6467 .map_err(|err| RuntimeStoreError::ReadFailed(err.to_string()))?;
6468 if record.record_version != SUPERVISOR_MACHINE_LIFECYCLE_STORE_RECORD_VERSION {
6469 return Err(RuntimeStoreError::ReadFailed(format!(
6470 "unsupported machine lifecycle store record version {}",
6471 record.record_version
6472 )));
6473 }
6474 Ok(MachineLifecycleSnapshot::new(
6475 record.runtime_state,
6476 record.binding.try_into()?,
6477 record.supervisor_authority.try_into()?,
6478 ))
6479 }
6480 UNREGISTER_MACHINE_LIFECYCLE_STORE_RECORD_VERSION => {
6481 let record = serde_json::from_slice::<MachineLifecycleSnapshotStoreWireV3>(bytes)
6482 .map_err(|err| RuntimeStoreError::ReadFailed(err.to_string()))?;
6483 MachineLifecycleSnapshot::try_from(record)
6484 }
6485 MACHINE_LIFECYCLE_STORE_RECORD_VERSION => {
6486 let record = decode_machine_lifecycle_observation_v4(bytes)?;
6487 let runtime_state = record.runtime_state.ok_or_else(|| {
6488 RuntimeStoreError::ReadFailed(
6489 "machine lifecycle runtime_state cannot be null for strict recovery".into(),
6490 )
6491 })?;
6492 Ok(
6493 MachineLifecycleSnapshot::new_with_run_and_unregister_progress(
6494 runtime_state,
6495 record.binding,
6496 record.run,
6497 record.supervisor_authority,
6498 record.unregister_progress,
6499 ),
6500 )
6501 }
6502 unsupported => Err(RuntimeStoreError::ReadFailed(format!(
6503 "unsupported machine lifecycle store record version {unsupported}"
6504 ))),
6505 }
6506}
6507
6508#[derive(serde::Deserialize)]
6509struct MachineLifecycleRawVersionProbe {
6510 record_version: u64,
6511}
6512
6513fn machine_lifecycle_record_version(bytes: &[u8]) -> Result<u64, String> {
6514 serde_json::from_slice::<MachineLifecycleRawVersionProbe>(bytes)
6515 .map(|probe| probe.record_version)
6516 .map_err(|error| {
6517 format!("machine lifecycle record_version is not uniquely readable: {error}")
6518 })
6519}
6520
6521fn classify_machine_lifecycle_record(bytes: &[u8]) -> MachineLifecycleObservation {
6522 let version = MachineLifecycleObservationVersion::from_raw_record(bytes);
6523 let evidence_digest = version.as_str().to_owned();
6524 let record_version = match machine_lifecycle_record_version(bytes) {
6525 Ok(record_version) => record_version,
6526 Err(detail) => {
6527 return MachineLifecycleObservation::Malformed {
6528 record_version: None,
6529 evidence_digest,
6530 version,
6531 detail,
6532 };
6533 }
6534 };
6535
6536 let supported = [
6537 u64::from(LEGACY_MACHINE_LIFECYCLE_STORE_RECORD_VERSION),
6538 u64::from(SUPERVISOR_MACHINE_LIFECYCLE_STORE_RECORD_VERSION),
6539 u64::from(UNREGISTER_MACHINE_LIFECYCLE_STORE_RECORD_VERSION),
6540 u64::from(MACHINE_LIFECYCLE_STORE_RECORD_VERSION),
6541 ];
6542 if !supported.contains(&record_version) {
6543 return MachineLifecycleObservation::Unsupported {
6544 record_version,
6545 evidence_digest,
6546 version,
6547 };
6548 }
6549
6550 let decoded = if record_version == u64::from(MACHINE_LIFECYCLE_STORE_RECORD_VERSION) {
6551 decode_machine_lifecycle_observation_v4(bytes)
6552 } else {
6553 decode_machine_lifecycle_store_record(bytes).map(|snapshot| {
6554 decoded_machine_lifecycle_from_snapshot(record_version as u16, snapshot)
6555 })
6556 };
6557 match decoded {
6558 Ok(record) => MachineLifecycleObservation::Decoded { record, version },
6559 Err(error) => MachineLifecycleObservation::Malformed {
6560 record_version: Some(record_version),
6561 evidence_digest,
6562 version,
6563 detail: error.to_string(),
6564 },
6565 }
6566}
6567
6568#[cfg(test)]
6569pub(crate) async fn assert_input_idempotency_final_image_contract(store: &dyn RuntimeStore) {
6570 fn state_with_key(input_id: InputId, key: &str) -> StoredInputState {
6571 let mut state = StoredInputState::new_accepted(input_id);
6572 state.state.idempotency_key = Some(IdempotencyKey::new(key));
6573 state
6574 }
6575
6576 fn record(state: StoredInputState) -> InputStatePersistenceRecord {
6577 InputStatePersistenceRecord::from_machine_snapshot(state).unwrap()
6578 }
6579
6580 let runtime_id =
6581 LogicalRuntimeId::new(format!("idempotency-final-image-{}", uuid::Uuid::now_v7()));
6582 let left_id = InputId::new();
6583 let right_id = InputId::new();
6584 let left = state_with_key(left_id.clone(), "left-key");
6585 let right = state_with_key(right_id.clone(), "right-key");
6586 store
6587 .persist_input_states_atomically(
6588 &runtime_id,
6589 &[record(left.clone()), record(right.clone())],
6590 )
6591 .await
6592 .unwrap();
6593
6594 let swapped_left = state_with_key(left_id.clone(), "right-key");
6595 let swapped_right = state_with_key(right_id.clone(), "left-key");
6596 store
6597 .persist_input_states_atomically(
6598 &runtime_id,
6599 &[record(swapped_left.clone()), record(swapped_right.clone())],
6600 )
6601 .await
6602 .expect("complete-final-image persistence must permit a key swap");
6603 let left_key_owner = store
6604 .load_input_state_by_idempotency_key(&runtime_id, &IdempotencyKey::new("left-key"))
6605 .await
6606 .unwrap()
6607 .expect("left key after swap");
6608 let right_key_owner = store
6609 .load_input_state_by_idempotency_key(&runtime_id, &IdempotencyKey::new("right-key"))
6610 .await
6611 .unwrap()
6612 .expect("right key after swap");
6613 assert_eq!(left_key_owner.state().state.input_id, right_id);
6614 assert_eq!(right_key_owner.state().state.input_id, left_id);
6615
6616 assert_eq!(
6617 store
6618 .compare_and_swap_input_states_atomically(
6619 &runtime_id,
6620 &[swapped_left, swapped_right],
6621 &[record(left), record(right)],
6622 )
6623 .await
6624 .unwrap(),
6625 InputStateBatchCasOutcome::Swapped,
6626 "complete-final-image CAS must permit the reverse key swap"
6627 );
6628}
6629
6630#[cfg(test)]
6631pub(crate) fn pending_terminal_owner_fixture(
6632 input_id: InputId,
6633 published: bool,
6634) -> (StoredInputState, InputStatePersistenceRecord) {
6635 use crate::input_state::{
6636 InputState, InputStateSeed, InteractionTerminalBatchKey, InteractionTerminalCandidate,
6637 InteractionTerminalOutbox, InteractionTerminalOutboxPhase, InteractionTerminalPublication,
6638 interaction_terminal_payload_digest,
6639 };
6640
6641 let candidate = InteractionTerminalCandidate::RuntimeTerminated {
6642 reason: "indexed terminal recovery fixture".to_string(),
6643 };
6644 let recipients = vec![input_id.clone()];
6645 let candidate_digest = interaction_terminal_payload_digest(&candidate).unwrap();
6646 let completion_input_ids_digest = interaction_terminal_payload_digest(&recipients).unwrap();
6647 let phase = if published {
6648 InteractionTerminalOutboxPhase::Published {
6649 finalization_failed: false,
6650 publication: InteractionTerminalPublication {
6651 terminal_seq: 1,
6652 payload_digest: "published-payload".to_string(),
6653 },
6654 }
6655 } else {
6656 InteractionTerminalOutboxPhase::Candidate
6657 };
6658 let outbox = InteractionTerminalOutbox {
6659 interaction_id: meerkat_core::interaction::InteractionId(input_id.0),
6660 input_id: input_id.clone(),
6661 batch_ordinal: 0,
6662 batch_key: InteractionTerminalBatchKey::RuntimeTermination {
6663 candidate_owner_input_id: input_id.clone(),
6664 },
6665 owner_session_id: meerkat_core::types::SessionId::new(),
6666 owner_agent_runtime_id: Some("indexed-runtime".to_string()),
6667 owner_fence_token: Some(1),
6668 owner_runtime_generation: Some(1),
6669 owner_runtime_epoch_id: Some("indexed-epoch".to_string()),
6670 candidate_owner_input_id: input_id.clone(),
6671 candidate: (!published).then_some(candidate),
6672 candidate_digest,
6673 completion_input_ids: (!published).then_some(recipients),
6674 completion_input_ids_digest,
6675 phase,
6676 };
6677 outbox.validate().unwrap();
6678 let directed_input = crate::mob_adapter::create_tracked_flow_step_input(
6679 "fixture-step",
6680 meerkat_core::types::ContentInput::Text("fixture-directed-input".to_string()),
6681 "fixture-run",
6682 None,
6683 &input_id.to_string(),
6684 )
6685 .unwrap();
6686 let mut state = InputState::new_accepted(input_id);
6687 state.directed_run_started_attribution =
6688 crate::input_state::DirectedRunStartedAttribution::from_input(&directed_input).unwrap();
6689 state.interaction_terminal_outbox = Some(outbox);
6690 let stored = StoredInputState {
6691 state,
6692 seed: InputStateSeed::new_accepted(),
6693 };
6694 let record = InputStatePersistenceRecord::from_machine_snapshot(stored.clone()).unwrap();
6695 (stored, record)
6696}
6697
6698#[cfg(test)]
6699pub(crate) async fn assert_pending_terminal_owner_index_contract(store: &dyn RuntimeStore) {
6700 let runtime_id =
6701 LogicalRuntimeId::new(format!("pending-terminal-index-{}", uuid::Uuid::now_v7()));
6702 let mut ids = [InputId::new(), InputId::new(), InputId::new()];
6703 ids.sort_by_key(|input_id| input_id.0);
6704 let fixtures = ids
6705 .iter()
6706 .cloned()
6707 .map(|input_id| pending_terminal_owner_fixture(input_id, false))
6708 .collect::<Vec<_>>();
6709 for (_, record) in &fixtures {
6710 store
6711 .persist_input_state(&runtime_id, record)
6712 .await
6713 .unwrap();
6714 }
6715
6716 let first_page = store
6717 .load_pending_terminal_owner_ids_page(&runtime_id, None, 2)
6718 .await
6719 .unwrap();
6720 assert_eq!(first_page, ids[..2]);
6721 let second_page = store
6722 .load_pending_terminal_owner_ids_page(&runtime_id, first_page.last(), 2)
6723 .await
6724 .unwrap();
6725 assert_eq!(second_page, ids[2..]);
6726
6727 let (_, published) = pending_terminal_owner_fixture(ids[1].clone(), true);
6728 assert_eq!(
6729 store
6730 .compare_and_swap_input_states_atomically(
6731 &runtime_id,
6732 std::slice::from_ref(&fixtures[1].0),
6733 std::slice::from_ref(&published),
6734 )
6735 .await
6736 .unwrap(),
6737 InputStateBatchCasOutcome::Swapped
6738 );
6739 assert_eq!(
6740 store
6741 .load_pending_terminal_owner_ids_page(&runtime_id, None, 3)
6742 .await
6743 .unwrap(),
6744 vec![ids[0].clone(), ids[2].clone()]
6745 );
6746}
6747
6748fn replacement_repair_blocked(
6749 evidence_digest: Option<String>,
6750 detail: impl Into<String>,
6751) -> RuntimeStoreError {
6752 RuntimeStoreError::MachineLifecycleRepairBlocked {
6753 evidence_digest,
6754 detail: detail.into(),
6755 }
6756}
6757
6758fn validate_machine_lifecycle_replacement(
6766 current: &MachineLifecycleObservation,
6767 _current_raw: Option<&[u8]>,
6768 _replacement: &MachineLifecycleSnapshot,
6769) -> Result<(), RuntimeStoreError> {
6770 match current {
6771 MachineLifecycleObservation::Missing | MachineLifecycleObservation::Decoded { .. } => {
6772 Ok(())
6773 }
6774 MachineLifecycleObservation::Unsupported {
6775 evidence_digest,
6776 record_version,
6777 ..
6778 } => Err(replacement_repair_blocked(
6779 Some(evidence_digest.clone()),
6780 format!(
6781 "unsupported lifecycle record version {record_version} cannot prove fencing semantics"
6782 ),
6783 )),
6784 MachineLifecycleObservation::Malformed {
6785 evidence_digest,
6786 detail,
6787 ..
6788 } => Err(replacement_repair_blocked(
6789 Some(evidence_digest.clone()),
6790 format!("malformed lifecycle evidence is not reclaimable: {detail}"),
6791 )),
6792 }
6793}
6794
6795struct PreparedMachineLifecycleReplacement {
6796 snapshot: MachineLifecycleSnapshot,
6797 bytes: Vec<u8>,
6798 version: MachineLifecycleObservationVersion,
6799}
6800
6801impl PreparedMachineLifecycleReplacement {
6802 fn preserve_observed_custody(
6806 mut self,
6807 current: &MachineLifecycleObservation,
6808 ) -> Result<Self, RuntimeStoreError> {
6809 if let MachineLifecycleObservation::Decoded { record, .. } = current {
6810 self.snapshot.supervisor_authority = record.supervisor_authority().clone();
6811 self.snapshot.unregister_progress = record.unregister_progress().cloned();
6812 self.bytes = MachineLifecycleStoreRecord::from_snapshot(&self.snapshot).encode()?;
6813 self.version = MachineLifecycleObservationVersion::from_raw_record(&self.bytes);
6814 }
6815 Ok(self)
6816 }
6817}
6818
6819fn prepare_machine_lifecycle_replacement(
6820 commit: MachineLifecycleCommit,
6821) -> Result<PreparedMachineLifecycleReplacement, RuntimeStoreError> {
6822 let bytes = commit.store_record().encode()?;
6823 let version = MachineLifecycleObservationVersion::from_raw_record(&bytes);
6824 Ok(PreparedMachineLifecycleReplacement {
6825 snapshot: commit.into_snapshot(),
6826 bytes,
6827 version,
6828 })
6829}
6830
6831fn decoded_prepared_machine_lifecycle_replacement(
6832 replacement: &PreparedMachineLifecycleReplacement,
6833) -> Result<DecodedMachineLifecycleObservation, RuntimeStoreError> {
6834 match classify_machine_lifecycle_record(&replacement.bytes) {
6835 MachineLifecycleObservation::Decoded { record, .. } => Ok(record),
6836 other => Err(RuntimeStoreError::Internal(format!(
6837 "machine-authorized lifecycle replacement did not decode: {other:?}"
6838 ))),
6839 }
6840}
6841
6842pub async fn load_runtime_state(
6850 store: &dyn RuntimeStore,
6851 runtime_id: &LogicalRuntimeId,
6852) -> Result<Option<RuntimeState>, RuntimeStoreError> {
6853 Ok(load_machine_lifecycle(store, runtime_id)
6854 .await?
6855 .map(|snapshot| snapshot.runtime_state()))
6856}
6857
6858pub(crate) async fn load_machine_lifecycle(
6859 store: &dyn RuntimeStore,
6860 runtime_id: &LogicalRuntimeId,
6861) -> Result<Option<MachineLifecycleSnapshot>, RuntimeStoreError> {
6862 store
6863 .load_machine_lifecycle_record(runtime_id)
6864 .await?
6865 .map(|bytes| decode_machine_lifecycle_store_record(&bytes))
6866 .transpose()
6867}
6868
6869#[derive(Debug, Clone, PartialEq, Eq)]
6875pub struct MachineLifecycleStoreRecord {
6876 snapshot: MachineLifecycleSnapshot,
6877}
6878
6879impl MachineLifecycleStoreRecord {
6880 pub(crate) fn from_snapshot(snapshot: &MachineLifecycleSnapshot) -> Self {
6881 Self {
6882 snapshot: snapshot.clone(),
6883 }
6884 }
6885
6886 #[must_use]
6891 pub fn runtime_state(&self) -> RuntimeState {
6892 self.snapshot.runtime_state()
6893 }
6894
6895 pub fn encode(&self) -> Result<Vec<u8>, RuntimeStoreError> {
6896 validate_supervisor_authority_snapshot(self.snapshot.supervisor_authority())
6897 .map_err(|error| RuntimeStoreError::WriteFailed(error.to_string()))?;
6898 validate_unregister_progress_snapshot(self.snapshot.unregister_progress())
6899 .map_err(|error| RuntimeStoreError::WriteFailed(error.to_string()))?;
6900 let wire = MachineLifecycleSnapshotStoreWire::from(&self.snapshot);
6901 serde_json::to_vec(&wire).map_err(|err| RuntimeStoreError::WriteFailed(err.to_string()))
6902 }
6903}
6904
6905#[derive(Debug, Clone, PartialEq, Eq)]
6911pub struct MachineLifecycleCommit {
6912 snapshot: MachineLifecycleSnapshot,
6913 expected_version: Option<MachineLifecycleExpectedVersion>,
6921}
6922
6923impl MachineLifecycleCommit {
6924 #[cfg(test)]
6925 pub(crate) fn new_with_binding(
6926 runtime_state: RuntimeState,
6927 binding: MachineLifecycleBindingFacts,
6928 supervisor_authority: SupervisorAuthoritySnapshot,
6929 ) -> Self {
6930 Self::new_with_binding_and_unregister_progress(
6931 runtime_state,
6932 binding,
6933 supervisor_authority,
6934 None,
6935 )
6936 }
6937
6938 pub(crate) fn new_with_binding_and_unregister_progress(
6939 runtime_state: RuntimeState,
6940 binding: MachineLifecycleBindingFacts,
6941 supervisor_authority: SupervisorAuthoritySnapshot,
6942 unregister_progress: Option<MachineUnregisterProgressSnapshot>,
6943 ) -> Self {
6944 Self::new_with_binding_run_and_unregister_progress(
6945 runtime_state,
6946 binding,
6947 MachineLifecycleRunFacts::default(),
6948 supervisor_authority,
6949 unregister_progress,
6950 )
6951 }
6952
6953 pub(crate) fn new_with_binding_run_and_unregister_progress(
6954 runtime_state: RuntimeState,
6955 binding: MachineLifecycleBindingFacts,
6956 run: MachineLifecycleRunFacts,
6957 supervisor_authority: SupervisorAuthoritySnapshot,
6958 unregister_progress: Option<MachineUnregisterProgressSnapshot>,
6959 ) -> Self {
6960 Self {
6961 snapshot: MachineLifecycleSnapshot::new_with_run_and_unregister_progress(
6962 runtime_state,
6963 binding,
6964 run,
6965 supervisor_authority,
6966 unregister_progress,
6967 ),
6968 expected_version: None,
6969 }
6970 }
6971
6972 pub(crate) fn with_expected_version(
6977 mut self,
6978 expected: MachineLifecycleExpectedVersion,
6979 ) -> Self {
6980 self.expected_version = Some(expected);
6981 self
6982 }
6983
6984 pub fn runtime_state(&self) -> RuntimeState {
6986 self.snapshot.runtime_state()
6987 }
6988
6989 pub fn snapshot(&self) -> &MachineLifecycleSnapshot {
6991 &self.snapshot
6992 }
6993
6994 pub fn store_record(&self) -> MachineLifecycleStoreRecord {
6996 MachineLifecycleStoreRecord::from_snapshot(&self.snapshot)
6997 }
6998
6999 pub fn expected_version(&self) -> Option<&MachineLifecycleExpectedVersion> {
7003 self.expected_version.as_ref()
7004 }
7005
7006 pub(crate) fn into_snapshot(self) -> MachineLifecycleSnapshot {
7007 self.snapshot
7008 }
7009}
7010
7011#[derive(Debug, Clone)]
7018pub struct UnregisterFinalizationCommit {
7019 machine_lifecycle: MachineLifecycleCommit,
7020 input_states: Vec<InputStatePersistenceRecord>,
7021 retired_ops_epoch: meerkat_core::RuntimeEpochId,
7022}
7023
7024impl UnregisterFinalizationCommit {
7025 pub(crate) fn new(
7026 machine_lifecycle: MachineLifecycleCommit,
7027 input_states: Vec<InputStatePersistenceRecord>,
7028 retired_ops_epoch: meerkat_core::RuntimeEpochId,
7029 _authority: crate::meerkat_machine::DeleteOpsFinalizationAuthority,
7030 ) -> Self {
7031 Self {
7032 machine_lifecycle,
7033 input_states,
7034 retired_ops_epoch,
7035 }
7036 }
7037
7038 pub(crate) fn into_parts(
7039 self,
7040 ) -> (
7041 MachineLifecycleSnapshot,
7042 Vec<InputStatePersistenceRecord>,
7043 meerkat_core::RuntimeEpochId,
7044 ) {
7045 (
7046 self.machine_lifecycle.into_snapshot(),
7047 self.input_states,
7048 self.retired_ops_epoch,
7049 )
7050 }
7051
7052 pub fn lifecycle_store_record(&self) -> MachineLifecycleStoreRecord {
7056 self.machine_lifecycle.store_record()
7057 }
7058
7059 pub fn input_states(&self) -> &[InputStatePersistenceRecord] {
7061 &self.input_states
7062 }
7063
7064 pub fn retired_ops_epoch(&self) -> &meerkat_core::RuntimeEpochId {
7066 &self.retired_ops_epoch
7067 }
7068}
7069
7070#[derive(Debug, Clone)]
7072pub enum InputStateRow {
7073 Decoded(Box<StoredInputState>),
7075 Corrupt {
7079 input_id: String,
7082 detail: String,
7084 },
7085}
7086
7087pub async fn load_input_states_for_recovery(
7092 store: &dyn RuntimeStore,
7093 runtime_id: &LogicalRuntimeId,
7094) -> Result<Vec<StoredInputState>, RuntimeStoreError> {
7095 let mut states = Vec::new();
7096 for row in store.load_input_states(runtime_id).await? {
7097 match row {
7098 InputStateRow::Decoded(state) => states.push(*state),
7099 InputStateRow::Corrupt { input_id, detail } => {
7100 tracing::error!(
7101 runtime_id = %runtime_id.0,
7102 input_id = %input_id,
7103 detail = %detail,
7104 "durable input row no longer decodes; recovering the runtime's remaining inputs without it"
7105 );
7106 }
7107 }
7108 }
7109 Ok(states)
7110}
7111
7112#[doc(hidden)]
7139#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
7140#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
7141pub trait RuntimeSessionAuthorityOps: Send + Sync {
7142 fn session_persistence_profile(&self) -> RuntimeSessionPersistenceProfile;
7143
7144 fn session_boundary_authority_read_cost(&self) -> RuntimeSessionAuthorityReadCost;
7145
7146 async fn commit_prepared_session_boundary(
7147 &self,
7148 runtime_id: &LogicalRuntimeId,
7149 request: PreparedRuntimeSessionCommit,
7150 ) -> Result<PreparedRuntimeSessionCommitResult, RuntimeStoreError>;
7151
7152 async fn load_session_boundary_authority(
7153 &self,
7154 runtime_id: &LogicalRuntimeId,
7155 ) -> Result<Option<RuntimeSessionAuthority>, RuntimeStoreError>;
7156
7157 async fn load_whole_blob_store_authority(
7158 &self,
7159 runtime_id: &LogicalRuntimeId,
7160 ) -> Result<Option<WholeBlobStoreAuthority>, RuntimeStoreError>;
7161
7162 async fn load_committed_whole_blob_snapshot(
7163 &self,
7164 runtime_id: &LogicalRuntimeId,
7165 ) -> Result<Option<CommittedWholeBlobSnapshot>, RuntimeStoreError>;
7166
7167 async fn commit_prepared_whole_blob_snapshot_cas(
7168 &self,
7169 runtime_id: &LogicalRuntimeId,
7170 prepared: PreparedWholeBlobSnapshotCas,
7171 ) -> Result<WholeBlobSnapshotCasOutcome, RuntimeStoreError>;
7172
7173 async fn delete_runtime_session_catalog_entry(
7174 &self,
7175 runtime_id: &LogicalRuntimeId,
7176 ) -> Result<(), RuntimeStoreError>;
7177
7178 async fn load_runtime_session_catalog_entry(
7179 &self,
7180 runtime_id: &LogicalRuntimeId,
7181 ) -> Result<Option<RuntimeSessionCatalogEntry>, RuntimeStoreError>;
7182
7183 async fn list_runtime_session_catalog_entries(
7184 &self,
7185 filter: meerkat_core::SessionFilter,
7186 ) -> Result<Vec<RuntimeSessionCatalogEntry>, RuntimeStoreError>;
7187
7188 async fn write_prepared_whole_blob_provisional_tail(
7189 &self,
7190 runtime_id: &LogicalRuntimeId,
7191 prepared: PreparedWholeBlobProvisionalTail,
7192 ) -> Result<WholeBlobProvisionalTailAuthority, RuntimeStoreError>;
7193
7194 async fn load_whole_blob_provisional_tail(
7195 &self,
7196 runtime_id: &LogicalRuntimeId,
7197 ) -> Result<Option<CommittedWholeBlobProvisionalTail>, RuntimeStoreError>;
7198
7199 async fn discard_whole_blob_provisional_tail(
7200 &self,
7201 runtime_id: &LogicalRuntimeId,
7202 expected: &WholeBlobProvisionalTailAuthority,
7203 ) -> Result<bool, RuntimeStoreError>;
7204
7205 async fn write_prepared_head_canonical_provisional_tail(
7206 &self,
7207 runtime_id: &LogicalRuntimeId,
7208 prepared: PreparedHeadCanonicalProvisionalTail,
7209 ) -> Result<HeadCanonicalProvisionalTailAuthority, RuntimeStoreError>;
7210
7211 async fn load_head_canonical_provisional_tail(
7212 &self,
7213 runtime_id: &LogicalRuntimeId,
7214 ) -> Result<Option<HeadCanonicalProvisionalTailAuthority>, RuntimeStoreError>;
7215
7216 async fn discard_head_canonical_provisional_tail(
7217 &self,
7218 runtime_id: &LogicalRuntimeId,
7219 expected: &HeadCanonicalProvisionalTailAuthority,
7220 ) -> Result<bool, RuntimeStoreError>;
7221
7222 async fn load_durable_tail_recovery_source(
7223 &self,
7224 runtime_id: &LogicalRuntimeId,
7225 ) -> Result<Option<PreparedDurableTailRecoverySource>, RuntimeStoreError>;
7226
7227 async fn load_durable_tail_recovery_receipts(
7228 &self,
7229 runtime_id: &LogicalRuntimeId,
7230 run_id: &RunId,
7231 ) -> Result<Vec<PreparedRecoveryReceiptSource>, RuntimeStoreError>;
7232
7233 async fn load_committed_recovery_boundary(
7234 &self,
7235 runtime_id: &LogicalRuntimeId,
7236 candidate_id: &str,
7237 ) -> Result<Option<CommittedRecoveryBoundary>, RuntimeStoreError>;
7238}
7239
7240#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
7241#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
7242pub trait RuntimeStore: Send + Sync {
7243 #[doc(hidden)]
7250 fn session_authority_ops(&self) -> &dyn RuntimeSessionAuthorityOps;
7251
7252 fn session_persistence_profile(&self) -> RuntimeSessionPersistenceProfile {
7263 self.session_authority_ops().session_persistence_profile()
7264 }
7265
7266 fn session_boundary_authority_read_cost(&self) -> RuntimeSessionAuthorityReadCost {
7271 self.session_authority_ops()
7272 .session_boundary_authority_read_cost()
7273 }
7274
7275 async fn commit_prepared_session_boundary(
7283 &self,
7284 runtime_id: &LogicalRuntimeId,
7285 request: PreparedRuntimeSessionCommit,
7286 ) -> Result<PreparedRuntimeSessionCommitResult, RuntimeStoreError> {
7287 self.session_authority_ops()
7288 .commit_prepared_session_boundary(runtime_id, request)
7289 .await
7290 }
7291
7292 async fn load_session_boundary_authority(
7300 &self,
7301 runtime_id: &LogicalRuntimeId,
7302 ) -> Result<Option<RuntimeSessionAuthority>, RuntimeStoreError> {
7303 self.session_authority_ops()
7304 .load_session_boundary_authority(runtime_id)
7305 .await
7306 }
7307
7308 async fn load_whole_blob_store_authority(
7310 &self,
7311 runtime_id: &LogicalRuntimeId,
7312 ) -> Result<Option<WholeBlobStoreAuthority>, RuntimeStoreError> {
7313 self.session_authority_ops()
7314 .load_whole_blob_store_authority(runtime_id)
7315 .await
7316 }
7317
7318 async fn load_committed_whole_blob_snapshot(
7323 &self,
7324 runtime_id: &LogicalRuntimeId,
7325 ) -> Result<Option<CommittedWholeBlobSnapshot>, RuntimeStoreError> {
7326 self.session_authority_ops()
7327 .load_committed_whole_blob_snapshot(runtime_id)
7328 .await
7329 }
7330
7331 async fn commit_prepared_whole_blob_snapshot_cas(
7338 &self,
7339 runtime_id: &LogicalRuntimeId,
7340 prepared: PreparedWholeBlobSnapshotCas,
7341 ) -> Result<WholeBlobSnapshotCasOutcome, RuntimeStoreError> {
7342 self.session_authority_ops()
7343 .commit_prepared_whole_blob_snapshot_cas(runtime_id, prepared)
7344 .await
7345 }
7346
7347 async fn delete_runtime_session_catalog_entry(
7349 &self,
7350 runtime_id: &LogicalRuntimeId,
7351 ) -> Result<(), RuntimeStoreError> {
7352 self.session_authority_ops()
7353 .delete_runtime_session_catalog_entry(runtime_id)
7354 .await
7355 }
7356
7357 async fn load_runtime_session_catalog_entry(
7359 &self,
7360 runtime_id: &LogicalRuntimeId,
7361 ) -> Result<Option<RuntimeSessionCatalogEntry>, RuntimeStoreError> {
7362 self.session_authority_ops()
7363 .load_runtime_session_catalog_entry(runtime_id)
7364 .await
7365 }
7366
7367 async fn list_runtime_session_catalog_entries(
7370 &self,
7371 filter: meerkat_core::SessionFilter,
7372 ) -> Result<Vec<RuntimeSessionCatalogEntry>, RuntimeStoreError> {
7373 self.session_authority_ops()
7374 .list_runtime_session_catalog_entries(filter)
7375 .await
7376 }
7377
7378 async fn write_prepared_whole_blob_provisional_tail(
7380 &self,
7381 runtime_id: &LogicalRuntimeId,
7382 prepared: PreparedWholeBlobProvisionalTail,
7383 ) -> Result<WholeBlobProvisionalTailAuthority, RuntimeStoreError> {
7384 self.session_authority_ops()
7385 .write_prepared_whole_blob_provisional_tail(runtime_id, prepared)
7386 .await
7387 }
7388
7389 async fn load_whole_blob_provisional_tail(
7391 &self,
7392 runtime_id: &LogicalRuntimeId,
7393 ) -> Result<Option<CommittedWholeBlobProvisionalTail>, RuntimeStoreError> {
7394 self.session_authority_ops()
7395 .load_whole_blob_provisional_tail(runtime_id)
7396 .await
7397 }
7398
7399 async fn discard_whole_blob_provisional_tail(
7401 &self,
7402 runtime_id: &LogicalRuntimeId,
7403 expected: &WholeBlobProvisionalTailAuthority,
7404 ) -> Result<bool, RuntimeStoreError> {
7405 self.session_authority_ops()
7406 .discard_whole_blob_provisional_tail(runtime_id, expected)
7407 .await
7408 }
7409
7410 async fn write_prepared_head_canonical_provisional_tail(
7413 &self,
7414 runtime_id: &LogicalRuntimeId,
7415 prepared: PreparedHeadCanonicalProvisionalTail,
7416 ) -> Result<HeadCanonicalProvisionalTailAuthority, RuntimeStoreError> {
7417 self.session_authority_ops()
7418 .write_prepared_head_canonical_provisional_tail(runtime_id, prepared)
7419 .await
7420 }
7421
7422 async fn load_head_canonical_provisional_tail(
7424 &self,
7425 runtime_id: &LogicalRuntimeId,
7426 ) -> Result<Option<HeadCanonicalProvisionalTailAuthority>, RuntimeStoreError> {
7427 self.session_authority_ops()
7428 .load_head_canonical_provisional_tail(runtime_id)
7429 .await
7430 }
7431
7432 async fn discard_head_canonical_provisional_tail(
7434 &self,
7435 runtime_id: &LogicalRuntimeId,
7436 expected: &HeadCanonicalProvisionalTailAuthority,
7437 ) -> Result<bool, RuntimeStoreError> {
7438 self.session_authority_ops()
7439 .discard_head_canonical_provisional_tail(runtime_id, expected)
7440 .await
7441 }
7442
7443 async fn load_durable_tail_recovery_source(
7450 &self,
7451 runtime_id: &LogicalRuntimeId,
7452 ) -> Result<Option<PreparedDurableTailRecoverySource>, RuntimeStoreError> {
7453 self.session_authority_ops()
7454 .load_durable_tail_recovery_source(runtime_id)
7455 .await
7456 }
7457
7458 async fn load_durable_tail_recovery_receipts(
7464 &self,
7465 runtime_id: &LogicalRuntimeId,
7466 run_id: &RunId,
7467 ) -> Result<Vec<PreparedRecoveryReceiptSource>, RuntimeStoreError> {
7468 self.session_authority_ops()
7469 .load_durable_tail_recovery_receipts(runtime_id, run_id)
7470 .await
7471 }
7472
7473 async fn load_committed_recovery_boundary(
7480 &self,
7481 runtime_id: &LogicalRuntimeId,
7482 candidate_id: &str,
7483 ) -> Result<Option<CommittedRecoveryBoundary>, RuntimeStoreError> {
7484 self.session_authority_ops()
7485 .load_committed_recovery_boundary(runtime_id, candidate_id)
7486 .await
7487 }
7488
7489 fn supports_compaction_projection_outbox(&self) -> bool {
7493 false
7494 }
7495
7496 fn auth_authority_key(&self) -> Option<String> {
7499 None
7500 }
7501
7502 async fn load_runtime_delivery_authority(
7504 &self,
7505 runtime_id: &LogicalRuntimeId,
7506 ) -> Result<Option<RuntimeDeliveryAuthorityRecord>, RuntimeStoreError> {
7507 let _ = runtime_id;
7508 Err(RuntimeStoreError::Unsupported(
7509 "load_runtime_delivery_authority".into(),
7510 ))
7511 }
7512
7513 async fn load_runtime_delivery_record(
7515 &self,
7516 runtime_id: &LogicalRuntimeId,
7517 delivery_id: &str,
7518 ) -> Result<Option<RuntimeDeliveryStoreRecord>, RuntimeStoreError> {
7519 let _ = (runtime_id, delivery_id);
7520 Err(RuntimeStoreError::Unsupported(
7521 "load_runtime_delivery_record".into(),
7522 ))
7523 }
7524
7525 async fn compare_and_swap_runtime_delivery_authority(
7532 &self,
7533 runtime_id: &LogicalRuntimeId,
7534 expected_revision: Option<u64>,
7535 replacement: RuntimeDeliveryAuthorityRecord,
7536 inserted_delivery: Option<RuntimeDeliveryStoreRecord>,
7537 ) -> Result<RuntimeDeliveryAuthorityCasOutcome, RuntimeStoreError> {
7538 let _ = (
7539 runtime_id,
7540 expected_revision,
7541 replacement,
7542 inserted_delivery,
7543 );
7544 Err(RuntimeStoreError::Unsupported(
7545 "compare_and_swap_runtime_delivery_authority".into(),
7546 ))
7547 }
7548
7549 async fn list_runtime_delivery_records(
7551 &self,
7552 runtime_id: &LogicalRuntimeId,
7553 after_sequence: u64,
7554 limit: usize,
7555 ) -> Result<Vec<RuntimeDeliveryStoreRecord>, RuntimeStoreError> {
7556 let _ = (runtime_id, after_sequence, limit);
7557 Err(RuntimeStoreError::Unsupported(
7558 "list_runtime_delivery_records".into(),
7559 ))
7560 }
7561
7562 fn persist_auth_oauth_flow_snapshot(
7568 &self,
7569 snapshot_json: &[u8],
7570 ) -> Result<(), RuntimeStoreError> {
7571 let _ = snapshot_json;
7572 Err(RuntimeStoreError::Unsupported(
7573 "persist_auth_oauth_flow_snapshot".into(),
7574 ))
7575 }
7576
7577 fn load_auth_oauth_flow_snapshot(&self) -> Result<Option<Vec<u8>>, RuntimeStoreError> {
7579 Err(RuntimeStoreError::Unsupported(
7580 "load_auth_oauth_flow_snapshot".into(),
7581 ))
7582 }
7583
7584 fn update_auth_oauth_flow_snapshot(
7590 &self,
7591 _update: &mut AuthOAuthFlowSnapshotUpdate<'_>,
7592 ) -> Result<(), RuntimeStoreError> {
7593 Err(RuntimeStoreError::Unsupported(
7594 "update_auth_oauth_flow_snapshot".into(),
7595 ))
7596 }
7597
7598 async fn commit_session_snapshot(
7603 &self,
7604 runtime_id: &LogicalRuntimeId,
7605 session_delta: SerializedSessionSnapshot,
7606 ) -> Result<(), RuntimeStoreError>;
7607
7608 async fn commit_prepared_whole_blob_rewrite_boundary(
7619 &self,
7620 runtime_id: &LogicalRuntimeId,
7621 boundary: PreparedWholeBlobRewriteStoreParts,
7622 ) -> Result<WholeBlobStoreAuthority, RuntimeStoreError>;
7623
7624 async fn atomic_apply(
7637 &self,
7638 runtime_id: &LogicalRuntimeId,
7639 session_delta: Option<SerializedSessionSnapshot>,
7640 receipt: RunBoundaryReceipt,
7641 input_updates: Vec<InputStatePersistenceRecord>,
7642 session_store_key: Option<meerkat_core::types::SessionId>,
7643 ) -> Result<(), RuntimeStoreError>;
7644
7645 async fn load_pending_compaction_projections(
7648 &self,
7649 runtime_id: &LogicalRuntimeId,
7650 ) -> Result<Vec<meerkat_core::CompactionProjectionIntent>, RuntimeStoreError> {
7651 let _ = runtime_id;
7652 Err(RuntimeStoreError::Unsupported(
7653 "load_pending_compaction_projections".to_string(),
7654 ))
7655 }
7656
7657 async fn mark_compaction_projection_finalized(
7664 &self,
7665 runtime_id: &LogicalRuntimeId,
7666 projection: &meerkat_core::CompactionProjectionId,
7667 ) -> Result<(), RuntimeStoreError> {
7668 let _ = (runtime_id, projection);
7669 Err(RuntimeStoreError::Unsupported(
7670 "mark_compaction_projection_finalized".to_string(),
7671 ))
7672 }
7673
7674 async fn atomic_apply_with_machine_lifecycle(
7682 &self,
7683 runtime_id: &LogicalRuntimeId,
7684 session_delta: SerializedSessionSnapshot,
7685 receipt: RunBoundaryReceipt,
7686 machine_lifecycle: MachineLifecycleCommit,
7687 input_updates: Vec<InputStatePersistenceRecord>,
7688 session_store_key: meerkat_core::types::SessionId,
7689 ) -> Result<(), RuntimeStoreError> {
7690 let _ = (
7691 runtime_id,
7692 session_delta,
7693 receipt,
7694 machine_lifecycle,
7695 input_updates,
7696 session_store_key,
7697 );
7698 Err(RuntimeStoreError::Unsupported(
7699 "atomic_apply_with_machine_lifecycle".to_string(),
7700 ))
7701 }
7702
7703 async fn load_input_states(
7713 &self,
7714 runtime_id: &LogicalRuntimeId,
7715 ) -> Result<Vec<InputStateRow>, RuntimeStoreError>;
7716
7717 async fn load_input_states_strict(
7721 &self,
7722 runtime_id: &LogicalRuntimeId,
7723 ) -> Result<Vec<StoredInputState>, RuntimeStoreError> {
7724 let mut states = Vec::new();
7725 for row in self.load_input_states(runtime_id).await? {
7726 match row {
7727 InputStateRow::Decoded(state) => states.push(*state),
7728 InputStateRow::Corrupt { input_id, detail } => {
7729 return Err(RuntimeStoreError::ReadFailed(format!(
7730 "input state row `{input_id}` failed to decode: {detail}"
7731 )));
7732 }
7733 }
7734 }
7735 Ok(states)
7736 }
7737
7738 async fn load_boundary_receipt(
7740 &self,
7741 runtime_id: &LogicalRuntimeId,
7742 run_id: &RunId,
7743 sequence: u64,
7744 ) -> Result<Option<RunBoundaryReceipt>, RuntimeStoreError>;
7745
7746 async fn load_committed_boundary_receipts(
7757 &self,
7758 runtime_id: &LogicalRuntimeId,
7759 run_id: &RunId,
7760 ) -> Result<Vec<RunBoundaryReceipt>, RuntimeStoreError> {
7761 const PROBE_CAP: u64 = 100_000;
7765 let mut receipts = Vec::new();
7766 for sequence in 1..=PROBE_CAP {
7767 match self
7768 .load_boundary_receipt(runtime_id, run_id, sequence)
7769 .await?
7770 {
7771 Some(receipt) => receipts.push(receipt),
7772 None => return Ok(receipts),
7773 }
7774 }
7775 Err(RuntimeStoreError::ReadFailed(format!(
7776 "run {run_id} has more than {PROBE_CAP} boundary receipts; refusing to probe further"
7777 )))
7778 }
7779
7780 async fn load_input_states_with_versions(
7806 &self,
7807 _runtime_id: &LogicalRuntimeId,
7808 ) -> Result<PreparedRecoveryInputSnapshot, RuntimeStoreError> {
7809 Err(RuntimeStoreError::Unsupported(
7810 "load_input_states_with_versions requires exact stored-row and complete-set tokens"
7811 .to_string(),
7812 ))
7813 }
7814
7815 async fn load_session_snapshot(
7822 &self,
7823 runtime_id: &LogicalRuntimeId,
7824 ) -> Result<Option<std::sync::Arc<Vec<u8>>>, RuntimeStoreError>;
7825
7826 async fn clear_session_snapshot(
7835 &self,
7836 runtime_id: &LogicalRuntimeId,
7837 ) -> Result<(), RuntimeStoreError>;
7838
7839 async fn replace_session_snapshot_if_current(
7847 &self,
7848 runtime_id: &LogicalRuntimeId,
7849 expected_current: &[u8],
7850 replacement: Vec<u8>,
7851 ) -> Result<bool, RuntimeStoreError>;
7852
7853 async fn clear_session_snapshot_if_current(
7859 &self,
7860 runtime_id: &LogicalRuntimeId,
7861 expected_current: &[u8],
7862 ) -> Result<bool, RuntimeStoreError>;
7863
7864 async fn is_runtime_projection_quarantined(
7876 &self,
7877 runtime_id: &LogicalRuntimeId,
7878 ) -> Result<bool, RuntimeStoreError> {
7879 let _ = runtime_id;
7880 Ok(false)
7881 }
7882
7883 async fn persist_input_state(
7885 &self,
7886 runtime_id: &LogicalRuntimeId,
7887 state: &InputStatePersistenceRecord,
7888 ) -> Result<(), RuntimeStoreError>;
7889
7890 async fn persist_input_states_atomically(
7896 &self,
7897 _runtime_id: &LogicalRuntimeId,
7898 states: &[InputStatePersistenceRecord],
7899 ) -> Result<(), RuntimeStoreError> {
7900 if states.is_empty() {
7901 return Ok(());
7902 }
7903 Err(RuntimeStoreError::Unsupported(
7904 "persist_input_states_atomically".to_string(),
7905 ))
7906 }
7907
7908 fn input_state_batch_cas_implementation_profile(
7911 &self,
7912 ) -> InputStateBatchCasImplementationProfile {
7913 InputStateBatchCasImplementationProfile::Unsupported
7914 }
7915
7916 async fn compare_and_swap_input_states_atomically(
7933 &self,
7934 _runtime_id: &LogicalRuntimeId,
7935 expected: &[StoredInputState],
7936 replacements: &[InputStatePersistenceRecord],
7937 ) -> Result<InputStateBatchCasOutcome, RuntimeStoreError> {
7938 let prepared = prepare_input_state_batch_cas(expected, replacements)?;
7939 if prepared.is_empty() {
7940 return Ok(InputStateBatchCasOutcome::Swapped);
7941 }
7942 Err(RuntimeStoreError::Unsupported(
7943 "compare_and_swap_input_states_atomically".to_string(),
7944 ))
7945 }
7946
7947 async fn compare_and_swap_input_states_atomically_with_fence(
7956 &self,
7957 runtime_id: &LogicalRuntimeId,
7958 expected: &[StoredInputState],
7959 replacements: &[InputStatePersistenceRecord],
7960 write_fence: std::sync::Arc<dyn RuntimeStoreWriteFence>,
7961 ) -> Result<FencedInputStateBatchCasOutcome, RuntimeStoreError> {
7962 let prepared = prepare_input_state_batch_cas(expected, replacements)?;
7963 if prepared.is_empty() {
7964 return Ok(FencedInputStateBatchCasOutcome::Swapped);
7965 }
7966 let _ = (runtime_id, write_fence);
7967 Err(RuntimeStoreError::Unsupported(
7968 "compare_and_swap_input_states_atomically_with_fence".to_string(),
7969 ))
7970 }
7971
7972 async fn compare_and_swap_recovery_input_states_atomically(
7983 &self,
7984 runtime_id: &LogicalRuntimeId,
7985 expected_revision: RecoveryInputSetRevision,
7986 mutations: &[RecoveryInputStateMutation],
7987 ) -> Result<InputStateBatchCasOutcome, RuntimeStoreError> {
7988 let _ = prepare_recovery_input_state_mutations(mutations)?;
7989 let _ = (runtime_id, expected_revision);
7990 Err(RuntimeStoreError::Unsupported(
7991 "compare_and_swap_recovery_input_states_atomically".to_string(),
7992 ))
7993 }
7994
7995 async fn compare_and_swap_recovery_input_states_atomically_with_fence(
8003 &self,
8004 runtime_id: &LogicalRuntimeId,
8005 expected_revision: RecoveryInputSetRevision,
8006 mutations: &[RecoveryInputStateMutation],
8007 write_fence: std::sync::Arc<dyn RuntimeStoreWriteFence>,
8008 ) -> Result<FencedInputStateBatchCasOutcome, RuntimeStoreError> {
8009 let _ = prepare_recovery_input_state_mutations(mutations)?;
8010 let _ = (runtime_id, expected_revision, write_fence);
8011 Err(RuntimeStoreError::Unsupported(
8012 "compare_and_swap_recovery_input_states_atomically_with_fence".to_string(),
8013 ))
8014 }
8015
8016 async fn load_input_state(
8018 &self,
8019 runtime_id: &LogicalRuntimeId,
8020 input_id: &InputId,
8021 ) -> Result<Option<StoredInputState>, RuntimeStoreError>;
8022
8023 async fn load_input_state_by_idempotency_key(
8038 &self,
8039 _runtime_id: &LogicalRuntimeId,
8040 _key: &IdempotencyKey,
8041 ) -> Result<Option<ExactInputStateObservation>, RuntimeStoreError> {
8042 Err(RuntimeStoreError::Unsupported(
8043 "load_input_state_by_idempotency_key requires an exact maintained index".to_string(),
8044 ))
8045 }
8046
8047 async fn load_input_states_by_ids(
8055 &self,
8056 _runtime_id: &LogicalRuntimeId,
8057 input_ids: &[InputId],
8058 ) -> Result<Vec<Option<StoredInputState>>, RuntimeStoreError> {
8059 validate_input_state_batch_read_ids(input_ids)?;
8060 if input_ids.is_empty() {
8061 return Ok(Vec::new());
8062 }
8063 Err(RuntimeStoreError::Unsupported(
8064 "load_input_states_by_ids".to_string(),
8065 ))
8066 }
8067
8068 async fn load_pending_terminal_owner_ids_page(
8080 &self,
8081 _runtime_id: &LogicalRuntimeId,
8082 after: Option<&InputId>,
8083 limit: usize,
8084 ) -> Result<Vec<InputId>, RuntimeStoreError> {
8085 validate_pending_terminal_owner_page(after, limit, &[])?;
8086 Err(RuntimeStoreError::Unsupported(
8087 "load_pending_terminal_owner_ids_page".to_string(),
8088 ))
8089 }
8090
8091 async fn observe_machine_lifecycle(
8098 &self,
8099 runtime_id: &LogicalRuntimeId,
8100 ) -> Result<MachineLifecycleObservation, RuntimeStoreError> {
8101 let _ = runtime_id;
8102 Err(RuntimeStoreError::Unsupported(
8103 "observe_machine_lifecycle".to_string(),
8104 ))
8105 }
8106
8107 async fn compare_and_swap_machine_lifecycle(
8122 &self,
8123 runtime_id: &LogicalRuntimeId,
8124 expected: MachineLifecycleExpectedVersion,
8125 replacement: MachineLifecycleCommit,
8126 ) -> Result<MachineLifecycleCasOutcome, RuntimeStoreError> {
8127 let _ = (runtime_id, expected, replacement);
8128 Err(RuntimeStoreError::Unsupported(
8129 "compare_and_swap_machine_lifecycle".to_string(),
8130 ))
8131 }
8132
8133 async fn compare_and_swap_machine_lifecycle_with_fence(
8146 &self,
8147 runtime_id: &LogicalRuntimeId,
8148 expected: MachineLifecycleExpectedVersion,
8149 replacement: MachineLifecycleCommit,
8150 write_fence: std::sync::Arc<dyn RuntimeStoreWriteFence>,
8151 ) -> Result<FencedMachineLifecycleCasOutcome, RuntimeStoreError> {
8152 let _ = (runtime_id, expected, replacement, write_fence);
8153 Err(RuntimeStoreError::Unsupported(
8154 "compare_and_swap_machine_lifecycle_with_fence".to_string(),
8155 ))
8156 }
8157
8158 async fn load_machine_lifecycle_record(
8166 &self,
8167 runtime_id: &LogicalRuntimeId,
8168 ) -> Result<Option<Vec<u8>>, RuntimeStoreError>;
8169
8170 async fn commit_machine_lifecycle(
8179 &self,
8180 runtime_id: &LogicalRuntimeId,
8181 commit: MachineLifecycleCommit,
8182 input_states: &[InputStatePersistenceRecord],
8183 ) -> Result<(), RuntimeStoreError>;
8184
8185 async fn commit_unregister_finalization(
8220 &self,
8221 runtime_id: &LogicalRuntimeId,
8222 finalization: UnregisterFinalizationCommit,
8223 ) -> Result<(), RuntimeStoreError> {
8224 let _ = (runtime_id, finalization);
8225 Err(RuntimeStoreError::Unsupported(
8226 "commit_unregister_finalization".into(),
8227 ))
8228 }
8229
8230 async fn initialize_ops_lifecycle_if_absent(
8252 &self,
8253 runtime_id: &LogicalRuntimeId,
8254 candidate: &crate::ops_lifecycle::PersistedOpsSnapshot,
8255 ) -> Result<crate::ops_lifecycle::PersistedOpsSnapshot, RuntimeStoreError> {
8256 let _ = (runtime_id, candidate);
8257 Err(RuntimeStoreError::Unsupported(
8258 "initialize_ops_lifecycle_if_absent".into(),
8259 ))
8260 }
8261
8262 async fn persist_ops_lifecycle(
8264 &self,
8265 runtime_id: &LogicalRuntimeId,
8266 snapshot: &crate::ops_lifecycle::PersistedOpsSnapshot,
8267 ) -> Result<(), RuntimeStoreError> {
8268 let _ = (runtime_id, snapshot);
8269 Err(RuntimeStoreError::Unsupported(
8270 "persist_ops_lifecycle".into(),
8271 ))
8272 }
8273
8274 async fn load_ops_lifecycle(
8276 &self,
8277 runtime_id: &LogicalRuntimeId,
8278 ) -> Result<Option<crate::ops_lifecycle::PersistedOpsSnapshot>, RuntimeStoreError> {
8279 let _ = runtime_id;
8280 Err(RuntimeStoreError::Unsupported("load_ops_lifecycle".into()))
8281 }
8282
8283 async fn delete_ops_lifecycle(
8285 &self,
8286 runtime_id: &LogicalRuntimeId,
8287 ) -> Result<(), RuntimeStoreError> {
8288 let _ = runtime_id;
8289 Err(RuntimeStoreError::Unsupported(
8290 "delete_ops_lifecycle".into(),
8291 ))
8292 }
8293
8294 async fn load_mob_host_binding(
8306 &self,
8307 mob_id: &str,
8308 ) -> Result<Option<Vec<u8>>, RuntimeStoreError> {
8309 let _ = mob_id;
8310 Err(RuntimeStoreError::Unsupported(
8311 "load_mob_host_binding".into(),
8312 ))
8313 }
8314
8315 async fn list_mob_host_bindings(&self) -> Result<Vec<(String, Vec<u8>)>, RuntimeStoreError> {
8317 Err(RuntimeStoreError::Unsupported(
8318 "list_mob_host_bindings".into(),
8319 ))
8320 }
8321
8322 async fn put_mob_host_binding_if_absent(
8325 &self,
8326 mob_id: &str,
8327 record_json: &[u8],
8328 ) -> Result<bool, RuntimeStoreError> {
8329 let _ = (mob_id, record_json);
8330 Err(RuntimeStoreError::Unsupported(
8331 "put_mob_host_binding_if_absent".into(),
8332 ))
8333 }
8334
8335 async fn compare_and_put_mob_host_binding(
8338 &self,
8339 mob_id: &str,
8340 expected_json: &[u8],
8341 next_json: &[u8],
8342 ) -> Result<bool, RuntimeStoreError> {
8343 let _ = (mob_id, expected_json, next_json);
8344 Err(RuntimeStoreError::Unsupported(
8345 "compare_and_put_mob_host_binding".into(),
8346 ))
8347 }
8348
8349 async fn delete_mob_host_binding(
8352 &self,
8353 mob_id: &str,
8354 expected_json: &[u8],
8355 ) -> Result<bool, RuntimeStoreError> {
8356 let _ = (mob_id, expected_json);
8357 Err(RuntimeStoreError::Unsupported(
8358 "delete_mob_host_binding".into(),
8359 ))
8360 }
8361
8362 async fn load_mob_host_revocation(
8370 &self,
8371 mob_id: &str,
8372 ) -> Result<Option<Vec<u8>>, RuntimeStoreError> {
8373 let _ = mob_id;
8374 Err(RuntimeStoreError::Unsupported(
8375 "load_mob_host_revocation".into(),
8376 ))
8377 }
8378
8379 async fn list_mob_host_revocations(&self) -> Result<Vec<(String, Vec<u8>)>, RuntimeStoreError> {
8383 Err(RuntimeStoreError::Unsupported(
8384 "list_mob_host_revocations".into(),
8385 ))
8386 }
8387
8388 async fn revoke_mob_host_binding(
8396 &self,
8397 mob_id: &str,
8398 expected_binding_json: &[u8],
8399 receipt_json: &[u8],
8400 ) -> Result<bool, RuntimeStoreError> {
8401 let _ = (mob_id, expected_binding_json, receipt_json);
8402 Err(RuntimeStoreError::Unsupported(
8403 "revoke_mob_host_binding".into(),
8404 ))
8405 }
8406}
8407
8408pub use memory::InMemoryRuntimeStore;
8409#[cfg(feature = "sqlite-store")]
8410pub use sqlite::SqliteRuntimeStore;
8411
8412#[cfg(test)]
8413mod store_authority_record_tests {
8414 use super::*;
8415 use meerkat_core::session_store::PreparedHeadCanonicalMutation;
8416 use meerkat_core::types::{Message, UserMessage};
8417
8418 fn row_digest(byte: char) -> String {
8419 format!("row-sha256:{}", byte.to_string().repeat(64))
8420 }
8421
8422 fn head_record() -> (
8423 meerkat_core::types::SessionId,
8424 meerkat_core::session_store::SessionHead,
8425 String,
8426 ) {
8427 let mut session = meerkat_core::Session::new();
8428 session.push(Message::User(UserMessage::text("canonical head")));
8429 let session_id = session.id().clone();
8430 let mutation =
8431 PreparedHeadCanonicalMutation::prepare(&session, None).expect("prepare canonical head");
8432 (
8433 session_id,
8434 mutation.successor_head().clone(),
8435 mutation.successor_head_token().to_string(),
8436 )
8437 }
8438
8439 #[test]
8440 fn borrowed_whole_blob_provisional_prepare_encodes_once_and_retains_no_session() {
8441 let mut session = meerkat_core::Session::new();
8442 session.push(Message::User(UserMessage::text("candidate")));
8443 let session_id = session.id().clone();
8444 let base = WholeBlobStoreAuthority::issued(session_id.clone(), 7, row_digest('b'))
8445 .expect("valid base authority");
8446 let prepared =
8447 PreparedWholeBlobProvisionalTail::prepare_from_session(base, RunId::new(), 1, &session)
8448 .expect("prepare borrowed WholeBlob candidate");
8449 assert_eq!(
8450 prepared.whole_blob_encode_count(),
8451 1,
8452 "borrowed preparation must stream the Session exactly once"
8453 );
8454 let retained = prepared.clone();
8455 drop(prepared);
8456 drop(session);
8457 assert_eq!(
8458 retained.whole_blob_encode_count(),
8459 1,
8460 "cloning the bounded carrier must share bytes, not re-encode"
8461 );
8462
8463 let (authority, artifact, digest, message_count, catalog, intents) = retained.into_parts();
8464 assert_eq!(authority.session_id(), &session_id);
8465 assert_eq!(
8466 authority.candidate_blob_sha256(),
8467 artifact.row_sha256_token()
8468 );
8469 assert_eq!(catalog.session_id(), &session_id);
8470 assert_eq!(catalog.message_count(), 1);
8471 assert_eq!(message_count, 1);
8472 assert!(!digest.is_empty());
8473 assert!(intents.is_empty());
8474 let decoded = meerkat_core::Session::from_persisted_bytes(artifact.bytes())
8475 .expect("carrier bytes remain independently usable after the Session is dropped");
8476 assert_eq!(decoded.id(), &session_id);
8477 assert_eq!(decoded.messages().len(), 1);
8478 }
8479
8480 #[test]
8481 fn committed_whole_blob_decode_installs_store_owned_rewrite_lineage() {
8482 let mut session = meerkat_core::Session::new();
8483 session.push(Message::User(UserMessage::text("original")));
8484 let artifact = session
8485 .to_persisted_artifact()
8486 .expect("serialize WholeBlob document");
8487 let authority = WholeBlobStoreAuthority::issued(
8488 session.id().clone(),
8489 1,
8490 artifact.row_sha256_token().to_string(),
8491 )
8492 .expect("issue exact WholeBlob authority");
8493 let committed = CommittedWholeBlobSnapshot::new(artifact.bytes_arc(), authority)
8494 .expect("decode store-owned WholeBlob document");
8495
8496 let mut decoded = committed.session().clone();
8497 let parent_revision = decoded.transcript_revision().expect("read parent revision");
8498 decoded
8499 .commit_transcript_rewrite(
8500 meerkat_core::TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8501 vec![Message::User(UserMessage::text("edited"))],
8502 meerkat_core::TranscriptRewriteReason::new("test"),
8503 Some("runtime-store-test".to_string()),
8504 Some(parent_revision),
8505 )
8506 .expect("store-owned WholeBlob decode must carry exact rewrite lineage");
8507 }
8508
8509 #[test]
8510 fn whole_blob_store_record_constructor_validates_every_fixed_field() {
8511 let session_id = meerkat_core::types::SessionId::new();
8512 let valid = WholeBlobStoreAuthority::from_store_record(
8513 WholeBlobStoreAuthority::VERSION,
8514 session_id.clone(),
8515 7,
8516 row_digest('a'),
8517 )
8518 .expect("valid WholeBlob record");
8519 assert_eq!(valid.authority_version(), WholeBlobStoreAuthority::VERSION);
8520 assert_eq!(valid.session_id(), &session_id);
8521 assert_eq!(valid.store_revision(), 7);
8522 assert_eq!(valid.blob_sha256(), row_digest('a'));
8523
8524 for (version, revision, digest) in [
8525 (WholeBlobStoreAuthority::VERSION + 1, 7, row_digest('a')),
8526 (WholeBlobStoreAuthority::VERSION, 0, row_digest('a')),
8527 (WholeBlobStoreAuthority::VERSION, 7, String::new()),
8528 (
8529 WholeBlobStoreAuthority::VERSION,
8530 7,
8531 format!("sha256:{}", "a".repeat(64)),
8532 ),
8533 (WholeBlobStoreAuthority::VERSION, 7, row_digest('A')),
8534 (
8535 WholeBlobStoreAuthority::VERSION,
8536 7,
8537 format!("row-sha256:{}", "a".repeat(63)),
8538 ),
8539 ] {
8540 assert!(
8541 WholeBlobStoreAuthority::from_store_record(
8542 version,
8543 session_id.clone(),
8544 revision,
8545 digest,
8546 )
8547 .is_err()
8548 );
8549 }
8550 }
8551
8552 #[test]
8553 fn head_canonical_store_record_constructor_validates_every_bound_fact() {
8554 let (session_id, head, token) = head_record();
8555 let valid = HeadCanonicalStoreAuthority::from_store_record(
8556 HeadCanonicalStoreAuthority::VERSION,
8557 session_id.clone(),
8558 11,
8559 head.clone(),
8560 token.clone(),
8561 )
8562 .expect("valid HeadCanonical record");
8563 assert_eq!(
8564 valid.authority_version(),
8565 HeadCanonicalStoreAuthority::VERSION
8566 );
8567 assert_eq!(valid.session_id(), &session_id);
8568 assert_eq!(valid.store_revision(), 11);
8569 assert_eq!(valid.boundary_head(), &head);
8570 assert_eq!(valid.committed_head_token(), token);
8571
8572 assert!(
8573 HeadCanonicalStoreAuthority::from_store_record(
8574 HeadCanonicalStoreAuthority::VERSION + 1,
8575 session_id.clone(),
8576 11,
8577 head.clone(),
8578 token.clone(),
8579 )
8580 .is_err()
8581 );
8582 assert!(
8583 HeadCanonicalStoreAuthority::from_store_record(
8584 HeadCanonicalStoreAuthority::VERSION,
8585 session_id.clone(),
8586 0,
8587 head.clone(),
8588 token.clone(),
8589 )
8590 .is_err()
8591 );
8592 assert!(
8593 HeadCanonicalStoreAuthority::from_store_record(
8594 HeadCanonicalStoreAuthority::VERSION,
8595 session_id.clone(),
8596 11,
8597 head.clone(),
8598 String::new(),
8599 )
8600 .is_err()
8601 );
8602 assert!(
8603 HeadCanonicalStoreAuthority::from_store_record(
8604 HeadCanonicalStoreAuthority::VERSION,
8605 session_id.clone(),
8606 11,
8607 head.clone(),
8608 "head-cas:different".to_string(),
8609 )
8610 .is_err()
8611 );
8612
8613 let mut wrong_session = head.clone();
8614 wrong_session.id = meerkat_core::types::SessionId::new();
8615 assert!(
8616 HeadCanonicalStoreAuthority::from_store_record(
8617 HeadCanonicalStoreAuthority::VERSION,
8618 session_id.clone(),
8619 11,
8620 wrong_session,
8621 token.clone(),
8622 )
8623 .is_err()
8624 );
8625
8626 let mut missing_row_prefix = head.clone();
8627 missing_row_prefix.message_row_prefix = None;
8628 assert!(
8629 HeadCanonicalStoreAuthority::from_store_record(
8630 HeadCanonicalStoreAuthority::VERSION,
8631 session_id.clone(),
8632 11,
8633 missing_row_prefix,
8634 token.clone(),
8635 )
8636 .is_err()
8637 );
8638
8639 let mut wrong_row_count = head.clone();
8640 wrong_row_count.message_count = wrong_row_count.message_count.saturating_add(1);
8641 assert!(
8642 HeadCanonicalStoreAuthority::from_store_record(
8643 HeadCanonicalStoreAuthority::VERSION,
8644 session_id.clone(),
8645 11,
8646 wrong_row_count,
8647 token.clone(),
8648 )
8649 .is_err()
8650 );
8651
8652 let mut wrong_rewrite_count = head;
8653 wrong_rewrite_count.rewrite_count = wrong_rewrite_count.rewrite_count.saturating_add(1);
8654 assert!(
8655 HeadCanonicalStoreAuthority::from_store_record(
8656 HeadCanonicalStoreAuthority::VERSION,
8657 session_id,
8658 11,
8659 wrong_rewrite_count,
8660 token,
8661 )
8662 .is_err()
8663 );
8664 }
8665}
8666
8667#[cfg(test)]
8668mod runtime_session_catalog_entry_tests {
8669 use super::*;
8670 use meerkat_core::session_store::PreparedHeadCanonicalMutation;
8671 use meerkat_core::types::{Message, UserMessage};
8672
8673 fn labeled_session() -> meerkat_core::Session {
8674 let mut session = meerkat_core::Session::new();
8675 session.push(Message::User(UserMessage::text(
8676 "transcript body must not enter the catalog",
8677 )));
8678 session.set_metadata(
8679 RuntimeSessionCatalogEntry::SESSION_LABELS_KEY,
8680 serde_json::json!({
8681 "owner": "operations",
8682 "tier": "production"
8683 }),
8684 );
8685 session
8686 }
8687
8688 #[test]
8689 fn public_session_projection_is_validated_and_body_free() {
8690 let session = labeled_session();
8691 let entry = RuntimeSessionCatalogEntry::from_session(
8692 &session,
8693 RuntimeSessionPersistenceProfile::WholeBlobV1,
8694 Some(RuntimeState::Idle),
8695 )
8696 .expect("typed Session projects to bounded catalog metadata");
8697
8698 assert_eq!(entry.session_id(), session.id());
8699 assert_eq!(
8700 entry.persistence_profile(),
8701 RuntimeSessionPersistenceProfile::WholeBlobV1
8702 );
8703 assert_eq!(entry.created_at(), session.created_at());
8704 assert_eq!(entry.updated_at(), session.updated_at());
8705 assert_eq!(entry.message_count(), session.messages().len());
8706 assert_eq!(entry.total_tokens(), session.total_tokens());
8707 assert_eq!(
8708 entry.labels(),
8709 &BTreeMap::from([
8710 ("owner".to_string(), "operations".to_string()),
8711 ("tier".to_string(), "production".to_string()),
8712 ])
8713 );
8714 assert_eq!(entry.runtime_state(), Some(RuntimeState::Idle));
8715
8716 let encoded = serde_json::to_string(&entry).expect("catalog entry serializes");
8717 assert!(
8718 !encoded.contains("transcript body must not enter the catalog"),
8719 "catalog projection must never carry transcript body data"
8720 );
8721 }
8722
8723 #[test]
8724 fn public_head_projection_matches_session_catalog_facts() {
8725 let session = labeled_session();
8726 let mutation =
8727 PreparedHeadCanonicalMutation::prepare(&session, None).expect("prepare canonical head");
8728 let from_session = RuntimeSessionCatalogEntry::from_session(
8729 &session,
8730 RuntimeSessionPersistenceProfile::HeadCanonicalV1,
8731 None,
8732 )
8733 .expect("Session catalog projection");
8734 let from_head = RuntimeSessionCatalogEntry::from_head(
8735 mutation.successor_head(),
8736 RuntimeSessionPersistenceProfile::HeadCanonicalV1,
8737 None,
8738 )
8739 .expect("SessionHead catalog projection");
8740
8741 assert_eq!(from_head, from_session);
8742 }
8743
8744 #[test]
8745 fn public_catalog_projections_reject_malformed_labels() {
8746 let mut session = labeled_session();
8747 session.set_metadata(
8748 RuntimeSessionCatalogEntry::SESSION_LABELS_KEY,
8749 serde_json::json!(["not", "a", "label", "map"]),
8750 );
8751
8752 assert!(matches!(
8753 RuntimeSessionCatalogEntry::from_session(
8754 &session,
8755 RuntimeSessionPersistenceProfile::WholeBlobV1,
8756 None,
8757 ),
8758 Err(RuntimeStoreError::WriteFailed(detail))
8759 if detail.contains("malformed catalog labels")
8760 ));
8761
8762 let mutation =
8763 PreparedHeadCanonicalMutation::prepare(&session, None).expect("prepare canonical head");
8764 assert!(matches!(
8765 RuntimeSessionCatalogEntry::from_head(
8766 mutation.successor_head(),
8767 RuntimeSessionPersistenceProfile::HeadCanonicalV1,
8768 None,
8769 ),
8770 Err(RuntimeStoreError::WriteFailed(detail))
8771 if detail.contains("malformed catalog labels")
8772 ));
8773 }
8774}
8775
8776#[cfg(test)]
8777mod lifecycle_record_compatibility_tests {
8778 use super::*;
8779
8780 fn operation_id(
8781 value: u128,
8782 ) -> meerkat_contracts::wire::supervisor_bridge::SupervisorRotationOperationId {
8783 meerkat_contracts::wire::supervisor_bridge::SupervisorRotationOperationId::from_uuid(
8784 uuid::Uuid::from_u128(value),
8785 )
8786 }
8787
8788 fn binding(seed: u8, name: &str, epoch: u64) -> SupervisorBindingReceipt {
8789 let pubkey = [seed; 32];
8790 SupervisorBindingReceipt::new(
8791 name.to_string(),
8792 meerkat_core::comms::PeerId::from_ed25519_pubkey(&pubkey).as_str(),
8793 format!("inproc://{name}"),
8794 crate::comms_drain::encode_supervisor_signing_public_key(pubkey),
8795 epoch,
8796 )
8797 }
8798
8799 fn rotation(
8800 operation_id: meerkat_contracts::wire::supervisor_bridge::SupervisorRotationOperationId,
8801 phase: SupervisorRotationPersistencePhase,
8802 rejection: Option<SupervisorRotationRejection>,
8803 previous: SupervisorBindingReceipt,
8804 next: SupervisorBindingReceipt,
8805 ) -> SupervisorRotationReceipt {
8806 SupervisorRotationReceipt::new(operation_id, phase, rejection, previous, next)
8807 }
8808
8809 fn snapshot(authority: SupervisorAuthoritySnapshot) -> MachineLifecycleSnapshot {
8810 MachineLifecycleSnapshot::new(
8811 RuntimeState::Idle,
8812 MachineLifecycleBindingFacts::new(None, None, None, None),
8813 authority,
8814 )
8815 }
8816
8817 fn encode_snapshot(snapshot: &MachineLifecycleSnapshot) -> Vec<u8> {
8818 MachineLifecycleStoreRecord::from_snapshot(snapshot)
8819 .encode()
8820 .expect("encode lifecycle snapshot")
8821 }
8822
8823 fn encode_unvalidated_snapshot(snapshot: &MachineLifecycleSnapshot) -> Vec<u8> {
8824 serde_json::to_vec(&MachineLifecycleSnapshotStoreWire::from(snapshot))
8825 .expect("serialize deliberately corrupt lifecycle snapshot")
8826 }
8827
8828 fn encoded_value(snapshot: &MachineLifecycleSnapshot) -> serde_json::Value {
8829 serde_json::from_slice(&encode_snapshot(snapshot)).expect("decode encoded snapshot as JSON")
8830 }
8831
8832 fn assert_decode_fails(value: serde_json::Value) {
8833 let bytes = serde_json::to_vec(&value).expect("serialize corrupt lifecycle record");
8834 assert!(
8835 decode_machine_lifecycle_store_record(&bytes).is_err(),
8836 "corrupt lifecycle record must fail closed: {value}"
8837 );
8838 }
8839
8840 #[test]
8841 fn version_one_record_without_supervisor_authority_migrates_explicitly_to_unbound() {
8842 let bytes = serde_json::to_vec(&serde_json::json!({
8843 "record_version": LEGACY_MACHINE_LIFECYCLE_STORE_RECORD_VERSION,
8844 "runtime_state": RuntimeState::Retired,
8845 "binding": {
8846 "agent_runtime_id": "rt:session:legacy-v1",
8847 "fence_token": 19,
8848 "runtime_generation": 4,
8849 "runtime_epoch_id": "epoch-legacy-v1"
8850 }
8851 }))
8852 .expect("serialize legacy v1 lifecycle record");
8853
8854 let decoded = decode_machine_lifecycle_store_record(&bytes)
8855 .expect("valid v1 record without the additive field must decode");
8856 assert_eq!(decoded.runtime_state(), RuntimeState::Retired);
8857 assert_eq!(
8858 decoded.supervisor_authority(),
8859 &SupervisorAuthoritySnapshot::UnboundNoReceipt
8860 );
8861 }
8862
8863 #[test]
8864 fn current_record_requires_supervisor_authority_and_unregister_progress_presence() {
8865 assert_decode_fails(serde_json::json!({
8866 "record_version": MACHINE_LIFECYCLE_STORE_RECORD_VERSION,
8867 "runtime_state": RuntimeState::Idle,
8868 "binding": {
8869 "agent_runtime_id": null,
8870 "fence_token": null,
8871 "runtime_generation": null,
8872 "runtime_epoch_id": null
8873 },
8874 "unregister_progress": null
8875 }));
8876 }
8877
8878 #[test]
8879 fn current_nullable_fields_require_presence_but_accept_explicit_null() {
8880 let unbound = snapshot(SupervisorAuthoritySnapshot::UnboundNoReceipt);
8881 let encoded = encoded_value(&unbound);
8882 assert_eq!(
8883 decode_machine_lifecycle_store_record(
8884 &serde_json::to_vec(&encoded).expect("serialize valid current record")
8885 )
8886 .expect("explicit-null current binding fields must decode"),
8887 unbound
8888 );
8889 let mut missing_progress = encoded.clone();
8890 missing_progress
8891 .as_object_mut()
8892 .expect("lifecycle record object")
8893 .remove("unregister_progress");
8894 assert_decode_fails(missing_progress);
8895
8896 for field in [
8897 "agent_runtime_id",
8898 "fence_token",
8899 "runtime_generation",
8900 "runtime_epoch_id",
8901 ] {
8902 let mut partial = encoded.clone();
8903 partial["binding"]
8904 .as_object_mut()
8905 .expect("binding object")
8906 .remove(field);
8907 assert_decode_fails(partial);
8908 }
8909 for field in ["current_run_id", "pre_run_phase"] {
8910 let mut partial = encoded.clone();
8911 partial
8912 .as_object_mut()
8913 .expect("lifecycle record object")
8914 .remove(field);
8915 assert_decode_fails(partial);
8916 }
8917
8918 let completed = snapshot(SupervisorAuthoritySnapshot::RotationOperation(rotation(
8919 operation_id(101),
8920 SupervisorRotationPersistencePhase::Completed,
8921 None,
8922 binding(30, "required-null-previous", 4),
8923 binding(31, "required-null-next", 5),
8924 )));
8925 let mut missing_rejection = encoded_value(&completed);
8926 assert!(missing_rejection["supervisor_authority"]["rotation"]["rejection"].is_null());
8927 missing_rejection["supervisor_authority"]["rotation"]
8928 .as_object_mut()
8929 .expect("rotation object")
8930 .remove("rejection");
8931 assert_decode_fails(missing_rejection);
8932 }
8933
8934 #[test]
8935 fn lossless_observation_preserves_partial_run_pair_and_nullable_lifecycle() {
8936 let mut value = encoded_value(&snapshot(SupervisorAuthoritySnapshot::UnboundNoReceipt));
8937 let run_id = RunId::new();
8938 value["runtime_state"] = serde_json::Value::Null;
8939 value["current_run_id"] = serde_json::to_value(&run_id).expect("serialize run id");
8940 value["pre_run_phase"] = serde_json::Value::Null;
8941 let bytes = serde_json::to_vec(&value).expect("serialize partial lifecycle row");
8942
8943 let MachineLifecycleObservation::Decoded { record, version } =
8944 classify_machine_lifecycle_record(&bytes)
8945 else {
8946 panic!("explicitly nullable partial runtime tuple must remain decoded");
8947 };
8948 assert_eq!(
8949 record.record_version(),
8950 MACHINE_LIFECYCLE_STORE_RECORD_VERSION
8951 );
8952 assert_eq!(record.runtime_state(), None);
8953 assert_eq!(record.run().current_run_id(), Some(&run_id));
8954 assert_eq!(record.run().pre_run_phase(), None);
8955 assert_eq!(
8956 version.as_str(),
8957 format!("sha256:{:x}", Sha256::digest(&bytes))
8958 );
8959 assert!(decode_machine_lifecycle_store_record(&bytes).is_err());
8960 }
8961
8962 #[test]
8963 fn lifecycle_observation_distinguishes_unsupported_and_malformed_raw_rows() {
8964 let unsupported = br#"{"record_version":99,"opaque":"future"}"#;
8965 assert!(matches!(
8966 classify_machine_lifecycle_record(unsupported),
8967 MachineLifecycleObservation::Unsupported {
8968 record_version: 99,
8969 ..
8970 }
8971 ));
8972
8973 let malformed = br#"{"record_version":4,"binding":"torn"}"#;
8974 assert!(matches!(
8975 classify_machine_lifecycle_record(malformed),
8976 MachineLifecycleObservation::Malformed {
8977 record_version: Some(4),
8978 ..
8979 }
8980 ));
8981
8982 let undecodable = b"not-json";
8983 assert!(matches!(
8984 classify_machine_lifecycle_record(undecodable),
8985 MachineLifecycleObservation::Malformed {
8986 record_version: None,
8987 ..
8988 }
8989 ));
8990 }
8991
8992 #[test]
8993 fn version_three_unregister_record_migrates_without_run_binding() {
8994 let expected = snapshot(SupervisorAuthoritySnapshot::UnboundNoReceipt);
8995 let mut value = encoded_value(&expected);
8996 value["record_version"] =
8997 serde_json::json!(UNREGISTER_MACHINE_LIFECYCLE_STORE_RECORD_VERSION);
8998 value
8999 .as_object_mut()
9000 .expect("lifecycle record object")
9001 .remove("current_run_id");
9002 value
9003 .as_object_mut()
9004 .expect("lifecycle record object")
9005 .remove("pre_run_phase");
9006 let bytes = serde_json::to_vec(&value).expect("serialize v3 row");
9007 let decoded = decode_machine_lifecycle_store_record(&bytes).expect("decode v3 row");
9008 assert_eq!(decoded, expected);
9009 assert_eq!(decoded.run(), &MachineLifecycleRunFacts::default());
9010 }
9011
9012 #[test]
9013 fn version_two_supervisor_record_migrates_with_no_unregister_progress() {
9014 let bytes = serde_json::to_vec(&serde_json::json!({
9015 "record_version": SUPERVISOR_MACHINE_LIFECYCLE_STORE_RECORD_VERSION,
9016 "runtime_state": RuntimeState::Retired,
9017 "binding": {
9018 "agent_runtime_id": "rt:session:legacy-v2",
9019 "fence_token": 23,
9020 "runtime_generation": 5,
9021 "runtime_epoch_id": "epoch-legacy-v2"
9022 },
9023 "supervisor_authority": { "kind": "unbound_no_receipt" }
9024 }))
9025 .expect("serialize v2 lifecycle record");
9026
9027 let decoded = decode_machine_lifecycle_store_record(&bytes)
9028 .expect("valid v2 supervisor record must migrate");
9029 assert_eq!(decoded.runtime_state(), RuntimeState::Retired);
9030 assert_eq!(decoded.unregister_progress(), None);
9031 }
9032
9033 #[test]
9034 fn current_unregister_progress_rejects_forced_disposition_before_feedback() {
9035 let mut value = encoded_value(&snapshot(SupervisorAuthoritySnapshot::UnboundNoReceipt));
9036 value["unregister_progress"] = serde_json::json!({
9037 "runtime_loop_drain_pending": true,
9038 "comms_drain_exit_pending": false,
9039 "completion_waiter_drain_pending": true,
9040 "runtime_loop_forced_abort": true,
9041 "comms_drain_forced_abort": false
9042 });
9043 assert_decode_fails(value);
9044 }
9045
9046 #[test]
9047 fn version_one_migration_rejects_current_authority_fields() {
9048 assert_decode_fails(serde_json::json!({
9049 "record_version": LEGACY_MACHINE_LIFECYCLE_STORE_RECORD_VERSION,
9050 "runtime_state": RuntimeState::Idle,
9051 "binding": {
9052 "agent_runtime_id": null,
9053 "fence_token": null,
9054 "runtime_generation": null,
9055 "runtime_epoch_id": null
9056 },
9057 "supervisor_authority": { "kind": "unbound_no_receipt" }
9058 }));
9059 }
9060
9061 #[test]
9062 fn mixed_or_unknown_supervisor_authority_fields_fail_closed() {
9063 let current = binding(1, "current-supervisor", 7);
9064 let mut value = encoded_value(&snapshot(SupervisorAuthoritySnapshot::Bound(current)));
9065 value["supervisor_authority"]["rotation"] = serde_json::json!({});
9066 assert_decode_fails(value);
9067 }
9068
9069 #[test]
9070 fn completed_rotation_operation_receipt_round_trips_for_cold_observation() {
9071 let snapshot = snapshot(SupervisorAuthoritySnapshot::RotationOperation(rotation(
9072 operation_id(1),
9073 SupervisorRotationPersistencePhase::Completed,
9074 None,
9075 binding(1, "previous-supervisor", 7),
9076 binding(2, "next-supervisor", 8),
9077 )));
9078
9079 let encoded = encode_snapshot(&snapshot);
9080 let decoded = decode_machine_lifecycle_store_record(&encoded)
9081 .expect("decode completed rotation receipt");
9082
9083 assert_eq!(decoded, snapshot);
9084 }
9085
9086 #[test]
9087 fn exact_current_completed_adoption_round_trips_but_other_equal_epoch_completion_fails() {
9088 let current = binding(3, "already-rotated-supervisor", 9);
9089 let adoption = snapshot(SupervisorAuthoritySnapshot::RotationOperation(rotation(
9090 operation_id(2),
9091 SupervisorRotationPersistencePhase::Completed,
9092 None,
9093 current.clone(),
9094 current,
9095 )));
9096 assert_eq!(
9097 decode_machine_lifecycle_store_record(&encode_snapshot(&adoption))
9098 .expect("exact-current legacy adoption receipt must decode"),
9099 adoption
9100 );
9101
9102 let non_advancing = snapshot(SupervisorAuthoritySnapshot::RotationOperation(rotation(
9103 operation_id(3),
9104 SupervisorRotationPersistencePhase::Completed,
9105 None,
9106 binding(3, "previous-supervisor", 9),
9107 binding(4, "different-supervisor", 9),
9108 )));
9109 assert!(
9110 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(&non_advancing))
9111 .is_err()
9112 );
9113 }
9114
9115 #[test]
9116 fn malformed_rotation_descriptors_epochs_and_operation_ids_fail_closed() {
9117 let invalid_previous = SupervisorBindingReceipt::new(
9118 String::new(),
9119 "not-a-uuid".to_string(),
9120 "not-an-address".to_string(),
9121 "not-a-key".to_string(),
9122 1,
9123 );
9124 let invalid_previous_receipt =
9125 snapshot(SupervisorAuthoritySnapshot::RotationOperation(rotation(
9126 operation_id(4),
9127 SupervisorRotationPersistencePhase::Rejected,
9128 Some(SupervisorRotationRejection::InvalidTarget),
9129 invalid_previous,
9130 binding(5, "raw-target", 2),
9131 )));
9132 assert!(
9133 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(
9134 &invalid_previous_receipt,
9135 ))
9136 .is_err()
9137 );
9138
9139 let invalid_next = SupervisorBindingReceipt::new(
9140 "invalid-target".to_string(),
9141 "not-a-uuid".to_string(),
9142 "not-an-address".to_string(),
9143 "not-a-key".to_string(),
9144 2,
9145 );
9146 let invalid_completed_target =
9147 snapshot(SupervisorAuthoritySnapshot::RotationOperation(rotation(
9148 operation_id(5),
9149 SupervisorRotationPersistencePhase::Completed,
9150 None,
9151 binding(6, "previous-supervisor", 1),
9152 invalid_next,
9153 )));
9154 assert!(
9155 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(
9156 &invalid_completed_target,
9157 ))
9158 .is_err()
9159 );
9160
9161 let mut invalid_id = encoded_value(&snapshot(
9162 SupervisorAuthoritySnapshot::RotationOperation(rotation(
9163 operation_id(6),
9164 SupervisorRotationPersistencePhase::PreviousRevokePending,
9165 None,
9166 binding(7, "previous-supervisor", 1),
9167 binding(8, "next-supervisor", 2),
9168 )),
9169 ));
9170 invalid_id["supervisor_authority"]["rotation"]["operation_id"] =
9171 serde_json::json!("not-a-uuid");
9172 assert_decode_fails(invalid_id);
9173
9174 let nil_id = snapshot(SupervisorAuthoritySnapshot::RotationOperation(rotation(
9175 operation_id(0),
9176 SupervisorRotationPersistencePhase::PreviousRevokePending,
9177 None,
9178 binding(7, "previous-supervisor", 1),
9179 binding(8, "next-supervisor", 2),
9180 )));
9181 assert!(
9182 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(&nil_id)).is_err()
9183 );
9184
9185 let non_advancing_pending =
9186 snapshot(SupervisorAuthoritySnapshot::RotationOperation(rotation(
9187 operation_id(13),
9188 SupervisorRotationPersistencePhase::PreviousRevokePending,
9189 None,
9190 binding(7, "previous-supervisor", 4),
9191 binding(8, "next-supervisor", 4),
9192 )));
9193 assert!(
9194 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(
9195 &non_advancing_pending,
9196 ))
9197 .is_err()
9198 );
9199 }
9200
9201 #[test]
9202 fn rejected_invalid_or_unsupported_target_preserves_raw_evidence() {
9203 for (id, rejection) in [
9204 (7, SupervisorRotationRejection::InvalidTarget),
9205 (14, SupervisorRotationRejection::UnsupportedProtocolVersion),
9206 ] {
9207 let raw_invalid_target = SupervisorBindingReceipt::new(
9208 "".to_string(),
9209 "not-a-peer-id".to_string(),
9210 "not-an-address".to_string(),
9211 "not-a-signing-key".to_string(),
9212 0,
9213 );
9214 let snapshot = snapshot(SupervisorAuthoritySnapshot::RotationOperation(rotation(
9215 operation_id(id),
9216 SupervisorRotationPersistencePhase::Rejected,
9217 Some(rejection),
9218 binding(9, "retained-supervisor", 11),
9219 raw_invalid_target,
9220 )));
9221 assert_eq!(
9222 decode_machine_lifecycle_store_record(&encode_snapshot(&snapshot))
9223 .expect("rejected raw target evidence must remain durable"),
9224 snapshot
9225 );
9226 }
9227 }
9228
9229 #[test]
9230 fn only_raw_target_rejections_are_durable_and_epoch_rejection_must_be_genuine() {
9231 for (id, rejection) in [
9232 (102, SupervisorRotationRejection::OperationConflict),
9233 (103, SupervisorRotationRejection::NotBound),
9234 (104, SupervisorRotationRejection::SenderMismatch),
9235 ] {
9236 let impossible = snapshot(SupervisorAuthoritySnapshot::RotationOperation(rotation(
9237 operation_id(id),
9238 SupervisorRotationPersistencePhase::Rejected,
9239 Some(rejection),
9240 binding(32, "retained-supervisor", 7),
9241 binding(33, "requested-supervisor", 8),
9242 )));
9243 assert!(
9244 MachineLifecycleStoreRecord::from_snapshot(&impossible)
9245 .encode()
9246 .is_err()
9247 );
9248 assert!(
9249 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(&impossible))
9250 .is_err()
9251 );
9252 }
9253
9254 let advancing = snapshot(SupervisorAuthoritySnapshot::RotationOperation(rotation(
9255 operation_id(105),
9256 SupervisorRotationPersistencePhase::Rejected,
9257 Some(SupervisorRotationRejection::TargetEpochNotAdvanced),
9258 binding(34, "retained-supervisor", 9),
9259 binding(35, "advancing-target", 10),
9260 )));
9261 assert!(
9262 MachineLifecycleStoreRecord::from_snapshot(&advancing)
9263 .encode()
9264 .is_err()
9265 );
9266 assert!(
9267 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(&advancing))
9268 .is_err()
9269 );
9270
9271 let non_advancing = snapshot(SupervisorAuthoritySnapshot::RotationOperation(rotation(
9272 operation_id(106),
9273 SupervisorRotationPersistencePhase::Rejected,
9274 Some(SupervisorRotationRejection::TargetEpochNotAdvanced),
9275 binding(36, "retained-supervisor", 11),
9276 binding(37, "non-advancing-target", 11),
9277 )));
9278 assert_eq!(
9279 decode_machine_lifecycle_store_record(&encode_snapshot(&non_advancing))
9280 .expect("genuine target-epoch rejection must remain durable"),
9281 non_advancing
9282 );
9283 }
9284
9285 #[test]
9286 fn malformed_current_authority_variants_fail_closed() {
9287 let malformed = SupervisorBindingReceipt::new(
9288 String::new(),
9289 "not-a-peer-id".to_string(),
9290 "not-an-address".to_string(),
9291 "not-a-signing-key".to_string(),
9292 1,
9293 );
9294 let bound = snapshot(SupervisorAuthoritySnapshot::Bound(malformed.clone()));
9295 assert!(
9296 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(&bound)).is_err()
9297 );
9298
9299 let pending = snapshot(SupervisorAuthoritySnapshot::RevocationPending(
9300 SupervisorRevocationPendingReceipt::new(
9301 malformed.name().to_owned(),
9302 malformed.peer_id().to_owned(),
9303 malformed.address().to_owned(),
9304 malformed.signing_public_key().to_owned(),
9305 malformed.epoch(),
9306 ),
9307 ));
9308 assert!(
9309 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(&pending)).is_err()
9310 );
9311
9312 let revoked = snapshot(SupervisorAuthoritySnapshot::RevokedReceipt(
9313 RevokedSupervisorReceipt::new(
9314 malformed.peer_id().to_owned(),
9315 malformed.signing_public_key().to_owned(),
9316 malformed.epoch(),
9317 ),
9318 ));
9319 assert!(
9320 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(&revoked)).is_err()
9321 );
9322 }
9323
9324 #[test]
9325 fn partial_and_nonterminal_history_records_fail_closed() {
9326 let receipt = rotation(
9327 operation_id(8),
9328 SupervisorRotationPersistencePhase::Completed,
9329 None,
9330 binding(10, "history-previous", 1),
9331 binding(11, "history-next", 2),
9332 );
9333 let history = std::collections::BTreeMap::from([(receipt.operation_id(), receipt)]);
9334 let snapshot = snapshot(SupervisorAuthoritySnapshot::WithRotationHistory {
9335 current: Box::new(SupervisorAuthoritySnapshot::Bound(binding(
9336 12,
9337 "current-supervisor",
9338 3,
9339 ))),
9340 terminal_receipts: history,
9341 });
9342
9343 let mut partial = encoded_value(&snapshot);
9344 partial["supervisor_authority"]["terminal_receipts"][0]
9345 .as_object_mut()
9346 .expect("history receipt object")
9347 .remove("next");
9348 assert_decode_fails(partial);
9349
9350 let mut nonterminal = encoded_value(&snapshot);
9351 nonterminal["supervisor_authority"]["terminal_receipts"][0]["phase"] =
9352 serde_json::json!("next_publish_pending");
9353 assert_decode_fails(nonterminal);
9354 }
9355
9356 #[test]
9357 fn duplicate_nested_and_active_history_conflicts_fail_closed() {
9358 let history_receipt = rotation(
9359 operation_id(9),
9360 SupervisorRotationPersistencePhase::Completed,
9361 None,
9362 binding(13, "history-previous", 1),
9363 binding(14, "history-next", 2),
9364 );
9365 let history = std::collections::BTreeMap::from([(
9366 history_receipt.operation_id(),
9367 history_receipt.clone(),
9368 )]);
9369 let wrapper = snapshot(SupervisorAuthoritySnapshot::WithRotationHistory {
9370 current: Box::new(SupervisorAuthoritySnapshot::Bound(binding(
9371 15,
9372 "current-supervisor",
9373 3,
9374 ))),
9375 terminal_receipts: history,
9376 });
9377
9378 let mut duplicate = encoded_value(&wrapper);
9379 let receipt = duplicate["supervisor_authority"]["terminal_receipts"][0].clone();
9380 duplicate["supervisor_authority"]["terminal_receipts"]
9381 .as_array_mut()
9382 .expect("history receipt array")
9383 .push(receipt);
9384 assert_decode_fails(duplicate);
9385
9386 let mut nested = encoded_value(&wrapper);
9387 let nested_current = nested["supervisor_authority"].clone();
9388 nested["supervisor_authority"]["current"] = nested_current;
9389 assert_decode_fails(nested);
9390
9391 let active_conflict = snapshot(SupervisorAuthoritySnapshot::WithRotationHistory {
9392 current: Box::new(SupervisorAuthoritySnapshot::RotationOperation(
9393 history_receipt.clone(),
9394 )),
9395 terminal_receipts: std::collections::BTreeMap::from([(
9396 history_receipt.operation_id(),
9397 history_receipt,
9398 )]),
9399 });
9400 assert!(
9401 MachineLifecycleStoreRecord::from_snapshot(&active_conflict)
9402 .encode()
9403 .is_err()
9404 );
9405
9406 let empty_history = snapshot(SupervisorAuthoritySnapshot::WithRotationHistory {
9407 current: Box::new(SupervisorAuthoritySnapshot::Bound(binding(
9408 20,
9409 "current-supervisor",
9410 4,
9411 ))),
9412 terminal_receipts: std::collections::BTreeMap::new(),
9413 });
9414 assert!(
9415 MachineLifecycleStoreRecord::from_snapshot(&empty_history)
9416 .encode()
9417 .is_err()
9418 );
9419 assert!(
9420 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(&empty_history))
9421 .is_err()
9422 );
9423
9424 let mismatched_key = snapshot(SupervisorAuthoritySnapshot::WithRotationHistory {
9425 current: Box::new(SupervisorAuthoritySnapshot::Bound(binding(
9426 21,
9427 "current-supervisor",
9428 4,
9429 ))),
9430 terminal_receipts: std::collections::BTreeMap::from([(
9431 operation_id(99),
9432 rotation(
9433 operation_id(98),
9434 SupervisorRotationPersistencePhase::Completed,
9435 None,
9436 binding(22, "history-previous", 2),
9437 binding(23, "history-next", 3),
9438 ),
9439 )]),
9440 });
9441 assert!(
9442 MachineLifecycleStoreRecord::from_snapshot(&mismatched_key)
9443 .encode()
9444 .is_err()
9445 );
9446 }
9447
9448 #[test]
9449 fn history_current_epoch_and_same_epoch_identity_must_cohere() {
9450 let previous = binding(38, "history-previous", 12);
9451 let next = binding(39, "history-next", 13);
9452 let completed = rotation(
9453 operation_id(107),
9454 SupervisorRotationPersistencePhase::Completed,
9455 None,
9456 previous.clone(),
9457 next.clone(),
9458 );
9459 let history =
9460 std::collections::BTreeMap::from([(completed.operation_id(), completed.clone())]);
9461
9462 let stale_current = snapshot(SupervisorAuthoritySnapshot::WithRotationHistory {
9463 current: Box::new(SupervisorAuthoritySnapshot::Bound(binding(
9464 38,
9465 "refreshed-history-previous",
9466 12,
9467 ))),
9468 terminal_receipts: history.clone(),
9469 });
9470 assert!(
9471 MachineLifecycleStoreRecord::from_snapshot(&stale_current)
9472 .encode()
9473 .is_err()
9474 );
9475 assert!(
9476 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(&stale_current))
9477 .is_err()
9478 );
9479
9480 let conflicting_current = snapshot(SupervisorAuthoritySnapshot::WithRotationHistory {
9481 current: Box::new(SupervisorAuthoritySnapshot::Bound(binding(
9482 40,
9483 "conflicting-current",
9484 13,
9485 ))),
9486 terminal_receipts: history.clone(),
9487 });
9488 assert!(
9489 MachineLifecycleStoreRecord::from_snapshot(&conflicting_current)
9490 .encode()
9491 .is_err()
9492 );
9493 assert!(
9494 decode_machine_lifecycle_store_record(&encode_unvalidated_snapshot(
9495 &conflicting_current,
9496 ))
9497 .is_err()
9498 );
9499
9500 let route_refreshed_current = snapshot(SupervisorAuthoritySnapshot::WithRotationHistory {
9501 current: Box::new(SupervisorAuthoritySnapshot::Bound(binding(
9502 39,
9503 "route-refreshed-history-next",
9504 13,
9505 ))),
9506 terminal_receipts: history,
9507 });
9508 assert_eq!(
9509 decode_machine_lifecycle_store_record(&encode_snapshot(&route_refreshed_current))
9510 .expect("same identity may refresh route metadata within one epoch"),
9511 route_refreshed_current
9512 );
9513 }
9514
9515 #[test]
9516 fn terminal_history_survives_later_rotation_and_recovery() {
9517 let first = rotation(
9518 operation_id(10),
9519 SupervisorRotationPersistencePhase::Completed,
9520 None,
9521 binding(16, "first-supervisor", 1),
9522 binding(17, "second-supervisor", 2),
9523 );
9524 let rejected = rotation(
9525 operation_id(11),
9526 SupervisorRotationPersistencePhase::Rejected,
9527 Some(SupervisorRotationRejection::TargetEpochNotAdvanced),
9528 binding(17, "second-supervisor", 2),
9529 binding(18, "rejected-supervisor", 2),
9530 );
9531 let later = rotation(
9532 operation_id(12),
9533 SupervisorRotationPersistencePhase::Completed,
9534 None,
9535 binding(17, "second-supervisor", 2),
9536 binding(19, "current-supervisor", 3),
9537 );
9538 let snapshot = snapshot(SupervisorAuthoritySnapshot::WithRotationHistory {
9539 current: Box::new(SupervisorAuthoritySnapshot::RotationOperation(later)),
9540 terminal_receipts: std::collections::BTreeMap::from([
9541 (first.operation_id(), first),
9542 (rejected.operation_id(), rejected),
9543 ]),
9544 });
9545
9546 let decoded = decode_machine_lifecycle_store_record(&encode_snapshot(&snapshot))
9547 .expect("later rotation and old terminal history must recover together");
9548 assert_eq!(decoded, snapshot);
9549 }
9550}