Skip to main content

meerkat_runtime/store/
mod.rs

1//! RuntimeStore — atomic persistence for runtime state.
2//!
3//! Machine-owned runtime commands durably persist [`RunBoundaryReceipt`] values
4//! atomically with their session and input-state effects.
5#![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
34/// Maximum number of exact input-state rows admitted by one compare-and-swap
35/// boundary. Directed-terminal outbox batches share the same 256-row bound as
36/// their publication seam.
37pub const MAX_INPUT_STATE_BATCH_CAS: usize = 256;
38
39/// Maximum number of canonical pending-terminal owner ids returned by one
40/// discovery page.
41pub 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
80/// Whether an input row has enough durable terminal evidence to retire its
81/// original ingress payload.
82///
83/// The payload is crash-redelivery and durable-tail-attribution material, not
84/// terminal history. Keep it until the generated lifecycle is terminal and
85/// every row-local completion/publication saga is closed. Callers must apply
86/// the resulting omission in the same transaction that commits the closing
87/// evidence (or in a later exact-CAS compaction); serialize-time projection
88/// alone would leave the live shell and durable row divergent.
89pub(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    // A directed input's payload is itself the only pre-outbox carrier of
119    // the Interaction identity. A terminal row without an outbox therefore
120    // has an unmaterialized publication obligation and must retain content.
121    // Malformed payloads also fail closed here; their validation error must
122    // remain observable rather than being erased as if they were ordinary
123    // terminal inputs.
124    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/// Result of an exact input-state batch compare-and-swap.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum InputStateBatchCasOutcome {
253    /// Every expected durable row matched and every replacement committed, or
254    /// every durable row was already byte-identical to its replacement from an
255    /// earlier invocation whose acknowledgement was lost.
256    Swapped,
257    /// At least one expected row was missing or no longer byte-identical; no
258    /// replacement was written.
259    Stale,
260}
261
262/// Backend realization profile for logical exact-batch input-state CAS.
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum InputStateBatchCasImplementationProfile {
265    /// The store does not provide the exact atomic batch contract.
266    Unsupported,
267    /// Per-row comparison and the complete replacement set commit in one
268    /// durable multi-writer transaction.
269    MultiWriter,
270    /// A whole-batch backend write is safe only while every write validates a
271    /// durable exclusive-writer fence epoch.
272    ///
273    /// A process-local mutex alone never satisfies this profile. The epoch
274    /// token must be conditionally checked by the backing store, the complete
275    /// batch must persist in one conditional write, and publication must wait
276    /// for that durable success. A crash leaves the receipt Pending for cold
277    /// deterministic retry.
278    ExclusiveWriterFenced,
279}
280
281/// Result of an exact input-state batch compare-and-swap performed under an
282/// external authority fence.
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub enum FencedInputStateBatchCasOutcome {
285    /// The target rows matched and the replacements committed, or were already
286    /// byte-identical, while the external authority was current.
287    Swapped,
288    /// At least one target row no longer matched. The external fence was not
289    /// consulted and no replacement was written.
290    Stale,
291    /// The external authority was superseded. No replacement was written.
292    FenceConflict { reason: String },
293    /// The external authority could not be checked temporarily. No replacement
294    /// was written.
295    FenceBackoff { reason: String },
296}
297
298#[derive(Debug)]
299struct PreparedInputStateBatchCasRow {
300    input_id: InputId,
301    expected_json: Vec<u8>,
302    replacement: StoredInputState,
303    // SQLite writes the already-validated bytes; the in-memory implementation
304    // stores the typed replacement directly.
305    #[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/// Durable representation selected for runtime-owned session authority.
404///
405/// The profile is explicit because the physical commit is materially
406/// different: a whole-blob store owns one lazy body encoding plus its exact
407/// row authority, while a head-canonical store owns delta rows and the small
408/// head inside the runtime transaction itself.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
410#[serde(rename_all = "snake_case")]
411#[non_exhaustive]
412pub enum RuntimeSessionPersistenceProfile {
413    /// RuntimeStore retains an authoritative serialized Session document.
414    WholeBlobV1,
415    /// RuntimeStore atomically commits canonical strand rows and a small head.
416    HeadCanonicalV1,
417}
418
419/// Small RuntimeStore-owned catalog projection for session listing and
420/// lifecycle discovery.
421///
422/// This is intentionally incapable of carrying transcript, graph, component,
423/// or serialized Session body data. WholeBlob and HeadCanonical stores update
424/// it in the same atomic commit as their physical session authority.
425#[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    /// Derive a validated, body-free catalog projection from a typed Session.
443    ///
444    /// This value is listing metadata only. It does not certify the Session,
445    /// issue store authority, authorize adoption, or prove that any physical
446    /// row is current. A custom [`RuntimeStore`] must commit the returned entry
447    /// atomically with the physical body and its own store-issued authority.
448    ///
449    /// Malformed catalog-owned metadata is rejected rather than omitted.
450    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    /// Derive a validated, body-free catalog projection from a canonical head.
488    ///
489    /// This is the HeadCanonical counterpart of [`Self::from_session`]. It
490    /// materializes only the head's bounded catalog metadata projection and
491    /// does not mint CAS authority or authorize a store transition. The custom
492    /// backend remains responsible for atomically committing this projection
493    /// with its exact physical head and store-issued authority.
494    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/// Upper bound for observing the currently committed session authority.
609///
610/// Reconciliation and health probes may poll this seam. A store must therefore
611/// state whether the observation is a bounded metadata read; silently parsing
612/// an accumulated WholeBlob document here recreates an O(document) hot loop.
613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
614pub enum RuntimeSessionAuthorityReadCost {
615    /// Reads only a fixed-size authority row or in-memory authority value.
616    Bounded,
617    /// This implementation has no bounded authority observation.
618    Unsupported,
619}
620
621/// Store-issued physical identity of one committed WholeBlob row.
622///
623/// The Session is ordinary domain payload. Currentness is owned only by the
624/// store revision and digest of the exact bytes in the row.
625#[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    /// Current fixed-size ledger format.
645    pub const VERSION: u16 = 1;
646
647    /// Validate the fixed-size fields decoded from one backend store record.
648    ///
649    /// This constructs a value carrier only. Calling it does not make the
650    /// record current and does not mint authority for a caller. The value is
651    /// authoritative only when an honest [`RuntimeStore`] returns it from the
652    /// same atomic observation or commit that proved the named physical row.
653    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/// One atomically observed WholeBlob row and its store-issued identity.
708///
709/// Backends construct this under one lock/transaction so rewrite preparation
710/// cannot accidentally pair bytes from one revision with another revision's
711/// digest.
712#[derive(Debug, Clone)]
713pub struct CommittedWholeBlobSnapshot {
714    session: Arc<meerkat_core::Session>,
715    bytes: Arc<Vec<u8>>,
716    authority: WholeBlobStoreAuthority,
717}
718
719/// One typed whole-document successor bound to an exact store-issued
720/// predecessor.
721///
722/// This carrier is intentionally free of Session checkpoint facts. The
723/// predecessor is identified only by the store revision and exact physical
724/// digest, while the successor is encoded and hashed once inside this
725/// WholeBlob preparation boundary.
726#[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/// Result of one exact-authority WholeBlob snapshot compare-and-swap.
735#[derive(Debug, Clone, PartialEq, Eq)]
736pub enum WholeBlobSnapshotCasOutcome {
737    /// The candidate is current, either after this write or as an exact
738    /// idempotent observation.
739    Committed(WholeBlobStoreAuthority),
740    /// The expected store-issued predecessor is no longer current.
741    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    /// Whether a bounded store observation proves this candidate current.
782    #[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/// Opaque typed candidate prepared for one provisional WholeBlob body write.
846#[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    /// Prepare from an existing sealed boundary.
860    ///
861    /// Compatibility callers may already own a `BoundSessionCommit`; the
862    /// artifact's single-assignment cache is reused without retaining the
863    /// candidate `Session` in this carrier.
864    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    /// Borrow one live candidate while deriving the exact persisted artifact
897    /// and every bounded projection.
898    ///
899    /// JSON bytes and SHA-256 are produced in one streaming pass. The returned
900    /// carrier owns only the sealed artifact and bounded metadata; it does not
901    /// clone or retain the accumulated `Session`.
902    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/// One atomically observed provisional authority and its exact candidate body.
1035#[derive(Debug, Clone)]
1036pub struct CommittedWholeBlobProvisionalTail {
1037    authority: WholeBlobProvisionalTailAuthority,
1038    candidate_bytes: Arc<Vec<u8>>,
1039}
1040
1041/// Exact metadata-only request to promote one store-owned WholeBlob candidate.
1042///
1043/// The candidate body, digest, catalog facts, and compaction intents were
1044/// already committed by [`PreparedWholeBlobProvisionalTail`]. This carrier
1045/// deliberately has no `Session` or serialized artifact, so the final boundary
1046/// cannot encode or hash the accumulated document a second time.
1047#[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/// Exact WholeBlob recovery action bound to one store-owned provisional row.
1091///
1092/// A completed candidate carries no body: the store promotes its existing
1093/// candidate allocation with metadata-only writes. An interrupted repair
1094/// carries the one sealed successor artifact produced during classification;
1095/// the backend installs those exact bytes without encoding or hashing again.
1096#[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/// Opaque metadata-only promotion of an already-applied HeadCanonical tail.
1179///
1180/// The physical rows were committed by the checkpoint CAS named by
1181/// `authority`; final runtime commit consumes only this fixed-size receipt.
1182#[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    /// Typed domain document decoded from these exact committed bytes.
1292    #[must_use]
1293    pub fn session(&self) -> &meerkat_core::Session {
1294        self.session.as_ref()
1295    }
1296
1297    /// Shared typed domain document decoded from these exact committed bytes.
1298    #[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/// Store-issued identity of one committed HeadCanonical boundary.
1340///
1341/// The small head carries the exact row, rewrite, graph, component, and
1342/// metadata-prefix facts. Currentness is the store revision plus the exact
1343/// committed head token; no fact inside a materialized `Session` participates
1344/// in authority.
1345#[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    /// Validate one fixed-size authority record and its exact canonical head.
1358    ///
1359    /// Construction validates representation and identity only; it does not
1360    /// certify that the record is current or authorize a transition. The value
1361    /// becomes authority only when an honest [`RuntimeStore`] returns it from
1362    /// the atomic observation or commit that proved the matching physical
1363    /// head and store revision.
1364    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/// Opaque intent written before a HeadCanonical physical-head CAS.
1462///
1463/// The carrier binds the exact committed parent, explicit run identity, and
1464/// exact successor head/token. It also captures the bounded catalog,
1465/// compaction, conversation-digest, and message-count projections from that
1466/// same live successor. Backends persist those facts with the fixed-size
1467/// authority; the physical SessionStore CAS realizes the already-bound head
1468/// separately.
1469#[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/// Singular store-issued committed authority for a runtime session.
1609#[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/// Store-owned source for one durable-tail recovery pass.
1650///
1651/// The public recovery API accepts only a session identity. A backend that
1652/// owns both runtime authority and canonical session rows constructs this
1653/// opaque carrier from one transactional snapshot after checking the exact
1654/// retained boundary head, current physical head, and both complete
1655/// materializations. Callers cannot substitute a hand-authored Session or
1656/// head row.
1657#[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/// One exact durable receipt row admitted to recovery classification.
1874#[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/// Sealed one-time enrichment of a supported-floor digestless receipt.
1901#[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/// Whether a prepared boundary was newly written or proven from durable store
1946/// authority.
1947#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1948pub enum PreparedRuntimeSessionCommitOutcome {
1949    /// This call installed the boundary.
1950    Applied,
1951    /// All durable authority and receipt witnesses already matched exactly.
1952    AlreadyAppliedExact,
1953    /// The exact released 0.8.10 receipt and every still-observable committed
1954    /// effect matched, and the v1 -> v2 migration marker authorized minting the
1955    /// first current request witness. The released schema did not retain prior
1956    /// CAS preconditions, so this is deliberately not reported as an exact
1957    /// retry of the original request.
1958    AlreadyAppliedReleasedEquivalent,
1959}
1960
1961/// Exact convergence result for a machine-authorized recovery boundary.
1962#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1963pub enum RecoveryCommitStatus {
1964    /// This invocation installed the recovery boundary.
1965    Committed,
1966    /// A prior invocation installed byte-exact evidence and receipt state.
1967    AlreadyCommittedExact,
1968}
1969
1970/// Result of an atomic prepared session-boundary commit.
1971#[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    /// Construct the result of a boundary that committed session authority.
1982    #[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            // RuntimeStore is the sole full-body authority for WholeBlob and
1990            // owns the small catalog projection for both profiles. No boundary
1991            // requires a downstream SessionStore body mirror.
1992            downstream_projection_required: false,
1993            authority: Some(authority),
1994        }
1995    }
1996
1997    /// Construct the result of a receipt/input-only boundary.
1998    #[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    /// Construct the exact result of an atomic recovery boundary.
2010    #[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    /// Reclassify a result after the store proved an exact durable retry.
2021    #[must_use]
2022    pub fn already_applied_exact(mut self) -> Self {
2023        self.outcome = PreparedRuntimeSessionCommitOutcome::AlreadyAppliedExact;
2024        self
2025    }
2026
2027    /// Reclassify a result after one migration-authorized released-boundary
2028    /// adoption. Subsequent retries of the newly witnessed request are exact.
2029    #[must_use]
2030    pub fn already_applied_released_equivalent(mut self) -> Self {
2031        self.outcome = PreparedRuntimeSessionCommitOutcome::AlreadyAppliedReleasedEquivalent;
2032        self
2033    }
2034
2035    /// Representation that became authoritative in the atomic boundary.
2036    #[must_use]
2037    pub const fn profile(&self) -> RuntimeSessionPersistenceProfile {
2038        self.profile
2039    }
2040
2041    /// Whether this invocation installed the boundary, proved an exact retry,
2042    /// or adopted an effect-equivalent released boundary.
2043    #[must_use]
2044    pub const fn outcome(&self) -> PreparedRuntimeSessionCommitOutcome {
2045        self.outcome
2046    }
2047
2048    /// Exact recovery convergence, when this was a recovery boundary.
2049    #[must_use]
2050    pub const fn recovery_status(&self) -> Option<RecoveryCommitStatus> {
2051        self.recovery_status
2052    }
2053
2054    /// Whether the caller must publish a separate compatibility projection
2055    /// after this commit.
2056    #[must_use]
2057    pub const fn downstream_projection_required(&self) -> bool {
2058        self.downstream_projection_required
2059    }
2060
2061    /// Exact session authority committed in this boundary, or `None` when the
2062    /// successful boundary carried receipt/input state only.
2063    #[must_use]
2064    pub fn authority(&self) -> Option<&RuntimeSessionAuthority> {
2065        self.authority.as_ref()
2066    }
2067}
2068
2069/// Errors from RuntimeStore operations.
2070#[derive(Debug, Clone, thiserror::Error)]
2071#[non_exhaustive]
2072pub enum RuntimeStoreError {
2073    /// Write failed.
2074    #[error("Store write failed: {0}")]
2075    WriteFailed(String),
2076    /// Read failed.
2077    #[error("Store read failed: {0}")]
2078    ReadFailed(String),
2079    /// The explicit session-store key does not match the serialized session.
2080    #[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    /// Not found.
2086    #[error("Not found: {0}")]
2087    NotFound(String),
2088    /// Operation is not supported by this store implementation.
2089    #[error("Unsupported store operation: {0}")]
2090    Unsupported(String),
2091    /// A non-whole-blob store declared a profile but did not implement the
2092    /// prepared boundary method required to commit that representation.
2093    #[error("runtime store profile '{profile}' must override commit_prepared_session_boundary")]
2094    PreparedSessionBoundaryRequiresOverride {
2095        profile: RuntimeSessionPersistenceProfile,
2096    },
2097    /// Recovery needs a backend that can CAS both runtime authority and the
2098    /// independently observed physical session head in one transaction.
2099    #[error(
2100        "runtime store profile '{profile}' cannot atomically CAS the physical session head for prepared recovery"
2101    )]
2102    PreparedRecoveryRequiresAtomicPhysicalHeadCas {
2103        profile: RuntimeSessionPersistenceProfile,
2104    },
2105    /// A head-canonical store encountered durable whole-blob state that has
2106    /// not completed its explicit, observable profile-activation conversion.
2107    ///
2108    /// Ordinary boundary application must never perform this conversion
2109    /// implicitly: it can be O(document) and may take long enough to require
2110    /// deploy-facing progress reporting. Open the store through its explicit
2111    /// activation seam and retry only after that seam reports completion.
2112    #[error(
2113        "head-canonical profile activation is required for runtime '{runtime_id}' (state: {state})"
2114    )]
2115    HeadCanonicalActivationRequired {
2116        /// Logical runtime whose frozen predecessor is not activated.
2117        runtime_id: String,
2118        /// Durable activation state (`not_started`, `in_progress`, or a
2119        /// backend-specific refusal detail).
2120        state: String,
2121    },
2122    /// Persisted session authority conflicts with the configured profile,
2123    /// checkpoint, canonical head, frozen legacy BLOB, or mutation shape.
2124    #[error("session persistence authority conflict for runtime '{runtime_id}': {detail}")]
2125    SessionPersistenceAuthorityConflict { runtime_id: String, detail: String },
2126    /// A detached producer attempted to persist an ops snapshot after the
2127    /// matching epoch was atomically retired by unregister.
2128    #[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    /// An unregister-finalization commit may have become durable, but the
2134    /// backend could not authoritatively classify its outcome.
2135    ///
2136    /// Callers must retry the idempotent atomic finalization and must not
2137    /// publish a compensating lifecycle rollback for this error.
2138    #[error("Unregister finalization outcome is unknown: {0}")]
2139    UnregisterFinalizationOutcomeUnknown(String),
2140    /// Runtime snapshot CAS rejected a stale transcript rewrite.
2141    #[error("Transcript revision conflict: expected {expected}, actual {actual}")]
2142    TranscriptRevisionConflict { expected: String, actual: String },
2143    /// An atomic boundary commit carried a session snapshot that was already
2144    /// superseded by the durable append-only head. Callers must observe this
2145    /// as a failed commit rather than mistaking a no-op for publication.
2146    #[error("Session snapshot for runtime '{runtime_id}' was superseded by the durable head")]
2147    SessionSnapshotSuperseded { runtime_id: String },
2148    /// The requested exact input-state batch CAS has an invalid row/key shape.
2149    #[error("Invalid input-state batch compare-and-swap: {reason}")]
2150    InvalidInputStateBatchCas { reason: String },
2151    /// The maintained idempotency-key index cannot prove a unique answer while
2152    /// a source input row's key identity is unindexable.
2153    ///
2154    /// This is durable corruption evidence, not an authoritative miss and not
2155    /// a transient read failure. Callers must fail closed until the named row
2156    /// is repaired or quarantined through an operator-authorized workflow.
2157    #[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    /// A lifecycle record was observed exactly, but replacing it would risk
2168    /// lowering or fabricating durable runtime fencing authority.
2169    ///
2170    /// This is a permanent reconciliation result for the observed row, not a
2171    /// transport retry. Callers should project RepairBlocked while retaining
2172    /// the evidence digest for operator repair.
2173    #[error("Machine lifecycle repair is blocked: {detail}")]
2174    MachineLifecycleRepairBlocked {
2175        evidence_digest: Option<String>,
2176        detail: String,
2177    },
2178    /// The file's schema ledger records a version newer than this binary
2179    /// supports: a newer binary migrated the file and this one must refuse
2180    /// it (typed, health-visible refusal — never a crash loop).
2181    #[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    /// The exclusive maintenance fence is held for this database; storage is
2191    /// under offline maintenance.
2192    #[error("maintenance fence is held for '{path}'; storage is under offline maintenance")]
2193    MaintenanceFenceHeld { path: String },
2194    /// An input-state update carried an expected prior row version that no
2195    /// longer matches the stored row. The whole atomic boundary fails stale;
2196    /// nothing is written.
2197    #[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    /// The complete set of nonterminal input rows changed after recovery
2202    /// classified it. The whole recovery boundary fails stale; nothing is
2203    /// written.
2204    #[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    /// A machine-lifecycle commit carried an expected prior row version that
2209    /// no longer matches the stored row. The whole atomic boundary fails
2210    /// stale; nothing is written.
2211    #[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    /// Internal error.
2216    #[error("Internal error: {0}")]
2217    Internal(String),
2218}
2219
2220/// Transactional updater for the runtime-owned OAuth login-flow payload snapshot.
2221pub type AuthOAuthFlowSnapshotUpdate<'a> =
2222    dyn FnMut(Option<&[u8]>) -> Result<Vec<u8>, RuntimeStoreError> + 'a;
2223
2224/// Describes a serialized session snapshot for boundary and snapshot-only commits.
2225#[derive(Debug, Clone)]
2226pub struct SerializedSessionSnapshot {
2227    /// Immutable serialized session snapshot (opaque to RuntimeStore).
2228    ///
2229    /// The shared owner is part of the WholeBlob cost contract: a prepared
2230    /// boundary must carry the one materialized document through the atomic
2231    /// store verb without allocating and copying a second full buffer.
2232    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/// Exact stored input row resolved through the durable idempotency-key index.
2338#[derive(Debug, Clone)]
2339pub struct ExactInputStateObservation {
2340    state: StoredInputState,
2341    exact_row_digest: String,
2342}
2343
2344impl ExactInputStateObservation {
2345    /// Bind a decoded state to the exact bytes the store resolved.
2346    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    /// Decoded stored input state.
2363    #[must_use]
2364    pub fn state(&self) -> &StoredInputState {
2365        &self.state
2366    }
2367
2368    /// Exact digest of the backend row bytes observed with the lookup.
2369    #[must_use]
2370    pub fn exact_row_digest(&self) -> &str {
2371        &self.exact_row_digest
2372    }
2373
2374    /// Consume the observation into decoded state and exact row digest.
2375    #[must_use]
2376    pub fn into_parts(self) -> (StoredInputState, String) {
2377        (self.state, self.exact_row_digest)
2378    }
2379}
2380
2381/// Store-owned monotonic revision of one logical runtime's input-row set.
2382///
2383/// The value is opaque to recovery callers. A store mints it from its own
2384/// transactionally maintained generation and MUST advance that generation for
2385/// every insert, update, or delete of an input row for the runtime. Revision
2386/// zero is the canonical generation for a runtime that has never owned an
2387/// input row; it is still a real absence fence.
2388#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2389pub struct RecoveryInputSetRevision(u64);
2390
2391impl RecoveryInputSetRevision {
2392    /// Mint a revision from a store-owned monotonic generation.
2393    #[must_use]
2394    pub fn from_store_generation(generation: u64) -> Self {
2395        Self(generation)
2396    }
2397
2398    /// Return the opaque generation for an exact store-side comparison.
2399    #[must_use]
2400    pub fn store_generation(self) -> u64 {
2401        self.0
2402    }
2403}
2404
2405/// Exact, store-owned observation of every nonterminal input row that can
2406/// affect durable-tail recovery for one logical runtime.
2407///
2408/// Runtime-store implementations construct this value from an authoritative,
2409/// complete read of their persisted nonterminal-input set. Every row token
2410/// must be the canonical `sha256:<lowercase hex>` digest of the exact stored
2411/// row representation that the backend will compare in its atomic recovery
2412/// commit. An empty row vector is not an unproved absence: it produces a
2413/// domain-separated absence token scoped to `runtime_id`.
2414///
2415/// The exact set token is durable evidence of what was classified. A
2416/// recovery-capable backend MUST also compare [`Self::input_set_revision`]
2417/// against its current store-owned generation inside the transaction that
2418/// applies recovery. A row inserted, removed, terminalized, reopened, or
2419/// byte-modified after this observation must make that transaction fail with
2420/// [`RuntimeStoreError::RecoveryInputSetConflict`] without rescanning the set.
2421#[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    /// Seal an authoritative complete set of exact nonterminal input rows.
2431    ///
2432    /// The constructor canonicalizes row order by [`InputId`], rejects
2433    /// duplicates, terminal rows, and non-canonical exact-row tokens, then
2434    /// hashes the runtime id, row count, and every `(input_id, row_token)`
2435    /// pair with length framing under `meerkat.recovery-input-set.v1`.
2436    ///
2437    /// This constructor validates representation, not completeness. The
2438    /// [`RuntimeStore`] implementation owns the obligation to select all and
2439    /// only persisted nonterminal rows for the supplied runtime and to observe
2440    /// `input_set_revision` in the same backend snapshot as those rows.
2441    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    /// Logical runtime whose complete nonterminal set was observed.
2504    #[must_use]
2505    pub fn runtime_id(&self) -> &LogicalRuntimeId {
2506        &self.runtime_id
2507    }
2508
2509    /// Store-owned input-set revision observed with the exact rows.
2510    #[must_use]
2511    pub fn input_set_revision(&self) -> RecoveryInputSetRevision {
2512        self.input_set_revision
2513    }
2514
2515    /// Exact set/absence token sealed into prepared recovery evidence.
2516    #[must_use]
2517    pub fn exact_set_token(&self) -> &str {
2518        &self.exact_set_token
2519    }
2520
2521    /// Consume the snapshot into canonical rows, store revision, and exact
2522    /// set/absence token.
2523    #[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/// Exact predecessor authority for deleting one recovery-discarded input row.
2536///
2537/// Construction is crate-owned by the generated recovery path. Public store
2538/// implementations can inspect the values but callers cannot turn omission
2539/// from a target image into delete authority.
2540#[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    /// Input row removed by the machine-authorized recovery disposition.
2565    #[must_use]
2566    pub fn input_id(&self) -> &InputId {
2567        &self.input_id
2568    }
2569
2570    /// Exact digest of the predecessor bytes the delete must match.
2571    #[must_use]
2572    pub fn expected_row_digest(&self) -> &str {
2573        &self.expected_row_digest
2574    }
2575}
2576
2577/// One machine-authorized mutation in a cold-recovery input boundary.
2578#[derive(Debug, Clone)]
2579pub enum RecoveryInputStateMutation {
2580    /// Retained input row normalized to its recovered machine image.
2581    Upsert(InputStatePersistenceRecord),
2582    /// Ephemeral/discarded input row removed under exact predecessor authority.
2583    Delete(PreparedRecoveryInputDelete),
2584}
2585
2586impl RecoveryInputStateMutation {
2587    /// Prepare an exact delete from the row digest returned by the recovery
2588    /// snapshot. Crate-owned because only the generated recovery classifier
2589    /// can authorize the discard disposition.
2590    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/// One recovery input mutation paired with the exact serialized target and
2600/// predecessor digest that authorize it.
2601///
2602/// `InputStatePersistenceRecord` intentionally does not implement equality:
2603/// equality here is defined by the exact durable bytes and CAS predecessor
2604/// sealed into the recovery witness.
2605#[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
2727/// Prepare an unbounded recovery input mutation set in canonical key order.
2728///
2729/// Recovery is scoped by the store-owned input-set revision rather than the
2730/// ordinary directed-terminal batch limit. Every target still carries an
2731/// exact predecessor-row digest, and duplicate identities are rejected.
2732pub(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/// Exact store-issued authority transition sealed into one recovery witness.
2794#[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/// Machine-authorized recovery evidence sealed to one exact recovered
2813/// document, receipt, lifecycle target, complete predecessor nonterminal
2814/// input-set/absence token, and canonically ordered input-update set.
2815///
2816/// Fields are private and construction is crate-only. Store implementations
2817/// may inspect the paired values but cannot mint a recovery classification or
2818/// replace any one proof independently.
2819#[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        // The expected version is a first-apply fence, not outcome identity:
2998        // after a successful commit the exact lifecycle target has a new row
2999        // version. Require the fence to exist, but do not bake that transient
3000        // predecessor token into the durable retry witness.
3001        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        // The predecessor version remains a first-apply fence. The target
3484        // bytes, not that transient predecessor token, define retry identity.
3485        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/// Durable exact-retry witness for one recovery candidate.
3642#[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/// Kind of atomic boundary carried by [`PreparedRuntimeSessionCommit`].
3944#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3945pub enum PreparedRuntimeSessionCommitKind {
3946    /// Session-control snapshot without a run receipt.
3947    SnapshotOnly,
3948    /// Successfully applied run boundary.
3949    Success,
3950    /// Direct service-turn terminal with machine lifecycle authority.
3951    ServiceTurnTerminal,
3952    /// Failed-but-applied run boundary with machine lifecycle authority.
3953    MachineTerminal,
3954    /// Machine-authorized durable-tail recovery with exact physical-head CAS.
3955    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/// Opaque, valid-by-construction request for one runtime-owned session
4039/// boundary.
4040///
4041/// The constructors prevent receipt, lifecycle, and session-key values from
4042/// being combined into a boundary shape that no
4043/// [`RuntimeStore`] verb can commit. The sealed session remains typed and lazy
4044/// until the selected persistence profile consumes it.
4045#[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    /// Prepare a session-control snapshot without a run receipt.
4115    #[must_use]
4116    pub fn snapshot_only(session: BoundSessionCommit) -> Self {
4117        Self {
4118            payload: PreparedRuntimeSessionCommitPayload::SnapshotOnly { session },
4119        }
4120    }
4121
4122    /// Prepare a successful run boundary.
4123    #[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    /// Prepare a successful final boundary that promotes an already-written
4141    /// WholeBlob candidate without carrying or materializing its Session body.
4142    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    /// Prepare a successful final boundary that promotes an already-applied
4160    /// HeadCanonical physical checkpoint without reapplying its rows.
4161    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    /// Prepare a failed-but-applied run boundary.
4179    #[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    /// Prepare a failed-but-applied final boundary that promotes the exact
4199    /// store-owned WholeBlob candidate.
4200    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    /// Prepare a failed-but-applied boundary that promotes the exact applied
4220    /// HeadCanonical provisional checkpoint.
4221    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    /// Prepare a direct service-turn terminal boundary.
4241    #[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    /// Prepare a direct service-turn terminal boundary that promotes the exact
4259    /// store-owned WholeBlob candidate.
4260    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    /// Prepare a service-turn terminal boundary that promotes the exact
4278    /// applied HeadCanonical provisional checkpoint.
4279    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    /// Prepare the only boundary allowed to realize machine-authorized
4297    /// durable-tail recovery.
4298    ///
4299    /// Crate-only because the recovery classifier and generated machine must
4300    /// seal [`PreparedRecoveryEvidence`]; a caller cannot select a recovery
4301    /// disposition or physical-head proof.
4302    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    /// Prepare a WholeBlob recovery without routing the recovered document
4337    /// through the ordinary whole-document boundary.
4338    ///
4339    /// Completed candidates become metadata-only promotions. Interrupted
4340    /// candidates retain the one already-materialized repaired artifact.
4341    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    /// Boundary shape selected by the constructor.
4378    #[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    /// Prepared session document, when this boundary carries one.
4407    #[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    /// Boundary receipt, absent only for snapshot-only commits.
4430    #[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    /// Input-state mutations committed with a run boundary.
4465    #[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    /// Explicit SessionStore identity carried by a run boundary.
4499    #[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    /// Machine lifecycle authority, present only for a machine-terminal commit.
4547    #[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/// Store-internal exact pairing produced only from a sealed typed WholeBlob
4592/// boundary. Backends consume the typed Session for guards and compaction
4593/// intents while writing the already-materialized shared bytes and authority.
4594/// This prevents a prepared boundary from reparsing its own JSON.
4595#[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/// Opaque generated runtime-delivery authority persisted by a
4676/// [`RuntimeStore`].
4677///
4678/// Stores compare the mechanical revision and retain the bytes exactly. They
4679/// do not interpret delivery lifecycle, sequence assignment, or cursor
4680/// semantics.
4681#[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/// Opaque runtime-inbox row committed alongside generated delivery authority.
4706#[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/// Mechanical compare-and-swap result for runtime-delivery authority.
4741#[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
4755/// Clear one finalized compaction intent from the ordinary session document.
4756///
4757/// Physical currentness remains store-owned; mutating this domain payload
4758/// neither consumes nor mints persistence authority.
4759pub(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/// Runtime binding facts selected by generated MeerkatMachine authority.
4770///
4771/// RuntimeStore implementations persist and read these facts as part of a
4772/// machine lifecycle snapshot. The commit token that writes these facts stays
4773/// crate-private so compatibility callers cannot mint replacement lifecycle
4774/// truth.
4775#[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/// Durable identity receipt for the last completed supervisor revoke.
4784///
4785/// This is not a live supervisor binding and carries no route address. It is
4786/// only the identity/key/epoch witness needed to authorize an exact duplicate
4787/// revoke response after a cold restart; the current authenticated request
4788/// supplies its current route.
4789#[derive(Debug, Clone, PartialEq, Eq)]
4790pub struct RevokedSupervisorReceipt {
4791    peer_id: String,
4792    signing_public_key: String,
4793    epoch: u64,
4794}
4795
4796/// Durable current supervisor binding used to authenticate terminal retry
4797/// traffic after a cold runtime restart.
4798#[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/// Durable in-flight supervisor revocation receipt.
4808///
4809/// This is the closed-world hand-off between generated machine authority and
4810/// the concrete router mutation.  It deliberately retains the complete prior
4811/// route so a cold runtime can authenticate an exact retry and re-materialize
4812/// the generated remove obligation without resurrecting a live binding.
4813#[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/// Closed durable supervisor authority state. Each variant owns one complete
4990/// recovery shape; terminal rotation receipts retain their exact operation and
4991/// participant descriptors for idempotent submission and later observation.
4992#[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/// Exact content version of one observed machine-lifecycle row.
5042///
5043/// The version is the domain-prefixed SHA-256 digest of the raw stored bytes,
5044/// not a decoded projection. It therefore remains a valid target-local CAS
5045/// witness for unsupported and malformed rows as well as current records.
5046#[derive(Debug, Clone, PartialEq, Eq, Hash)]
5047pub struct MachineLifecycleObservationVersion(String);
5048
5049impl MachineLifecycleObservationVersion {
5050    /// Derive the exact target-local compare token for opaque stored bytes.
5051    ///
5052    /// Custom [`RuntimeStore`] implementations use the same constructor for
5053    /// both observations and successful CAS receipts; no decoded lifecycle
5054    /// shape is allowed to stand in for the physical row version.
5055    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/// Independently observed run-binding atoms.
5066///
5067/// A torn row may contain exactly one side of this pair. The store preserves
5068/// that shape; the generated reconciler, not the decoder, decides whether it
5069/// can be normalized.
5070#[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/// Decoded runtime-lifecycle observation.
5107///
5108/// The lifecycle phase, four binding atoms, and two run atoms remain
5109/// independently optional. This type deliberately represents partial tuples
5110/// such as `current_run_id = Some` with `pre_run_phase = None` instead of
5111/// rejecting them as an impossible transition shape.
5112#[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/// Lossless classification of one physical machine-lifecycle row.
5155///
5156/// Transport failures remain [`RuntimeStoreError`] values. Every successfully
5157/// read row is classified without collapsing unsupported or corrupt bytes into
5158/// absence.
5159#[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    /// Losslessly classify one successfully read physical lifecycle row.
5181    ///
5182    /// This is the canonical adapter seam for custom stores. Transport
5183    /// failure stays an outer [`RuntimeStoreError`]; every byte sequence read
5184    /// successfully becomes Decoded, Unsupported, or Malformed here.
5185    #[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/// Target-local precondition for lifecycle normalization.
5215#[derive(Debug, Clone, PartialEq, Eq)]
5216pub enum MachineLifecycleExpectedVersion {
5217    Missing,
5218    Version(MachineLifecycleObservationVersion),
5219}
5220
5221impl MachineLifecycleObservation {
5222    /// Exact target-local precondition represented by this observation.
5223    ///
5224    /// Missing is a first-class compare value. Every present row, including
5225    /// unsupported and malformed bytes, is compared by its raw-content
5226    /// version rather than by a decoded projection.
5227    #[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/// Result of executing one synchronous target write under an external fence.
5237///
5238/// The fence is deliberately runtime-generic. A caller may back it with a
5239/// lease, process-incarnation lock, or another authority source without the
5240/// runtime store depending on that owner's domain types.
5241#[derive(Debug, Clone, PartialEq, Eq)]
5242pub enum RuntimeStoreWriteFenceOutcome {
5243    /// The fence was current and invoked the supplied operation exactly once.
5244    Applied,
5245    /// Durable authority was superseded. The operation was not invoked.
5246    Conflict { reason: String },
5247    /// Authority could not be checked temporarily. The operation was not
5248    /// invoked and the caller should retry after re-observation.
5249    Backoff { reason: String },
5250}
5251
5252/// Synchronous authority guard for a RuntimeStore target write.
5253///
5254/// Implementations MUST retain their authority serialization guard for the
5255/// full duration of `operation`, invoke it exactly once only when authority is
5256/// current, and never invoke it for Conflict or Backoff. The operation is
5257/// synchronous by design so built-in stores can call it inside their own lock
5258/// or transaction immediately before the target write. Time-bounded authority
5259/// must be evaluated using the authority store's own clock while that guard is
5260/// held, never a caller-supplied observation timestamp. Once a successful
5261/// operation returns, the fence must return Applied without a new fallible
5262/// boundary. Implementations must not re-enter the same RuntimeStore from this
5263/// callback.
5264pub 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/// Result of a target-local lifecycle CAS performed under an external fence.
5306///
5307/// Applied and AlreadyExact carry the exact decoded row used to construct the
5308/// fresh process-local registration. Callers never receive or construct a
5309/// MachineLifecycleCommit.
5310#[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/// Result of a target-local lifecycle compare-and-swap.
5332#[derive(Debug, Clone, PartialEq, Eq)]
5333pub enum MachineLifecycleCasOutcome {
5334    Applied {
5335        version: MachineLifecycleObservationVersion,
5336    },
5337    Conflict {
5338        current: MachineLifecycleObservation,
5339    },
5340}
5341
5342/// Durable read-back shape for machine-owned lifecycle state.
5343#[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/// Durable generated unregister-saga progress needed to resume an interrupted
5353/// Draining epoch without reconstructing missing producer outcomes in shell
5354/// code.
5355#[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    /// Runtime state selected by the owning MeerkatMachine transition.
5443    pub fn runtime_state(&self) -> RuntimeState {
5444        self.runtime_state
5445    }
5446
5447    /// Runtime binding facts selected by the owning MeerkatMachine transition.
5448    pub fn binding(&self) -> &MachineLifecycleBindingFacts {
5449        &self.binding
5450    }
5451
5452    /// Independently persisted run-binding atoms.
5453    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/// Exact pre-supervisor-authority lifecycle shape. Version 1 is decoded only
5669/// through this migration carrier so a missing authority on a current record
5670/// cannot be confused with legacy data.
5671#[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            // A legacy member may already have the exact target installed
6000            // before the operation protocol assigns an id. Its adoption
6001            // receipt is Completed with an exact previous == next witness.
6002            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                    // These two rejection classes retain the undecodable target
6025                    // fields as raw evidence. They are deliberately exempt from
6026                    // target descriptor validation and epoch comparison.
6027                }
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) = &current
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
6758/// Validate whether an exact lifecycle observation may be normalized.
6759///
6760/// Binding, fence, generation, and run atoms describe the dead process that
6761/// authored the observed row; they are not a durable high-water authority and
6762/// may be cleared by an exact-version cold-normalization CAS. Unsupported and
6763/// malformed rows remain fail-closed because this slice cannot prove their
6764/// custody fields safe to preserve.
6765fn 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    /// A runtime-authority normalization owns only lifecycle, binding, and run
6803    /// atoms. Preserve independent supervisor and unregister custody from the
6804    /// exact decoded row rather than copying it through the reconciler.
6805    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
6842/// Load the last persisted runtime-state projection from a generated lifecycle
6843/// record.
6844///
6845/// This is a projection of [`MachineLifecycleCommit`] authority. Store
6846/// implementations provide only opaque record bytes; the runtime crate owns the
6847/// decoding and rejects compatibility rows that are not machine lifecycle
6848/// records.
6849pub 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/// Declared durable store record for generated machine lifecycle truth.
6870///
6871/// Stores receive this record from [`MachineLifecycleCommit`] and may persist
6872/// its encoded form. Loading must decode this exact record shape; compatibility
6873/// runtime-state projections are not lifecycle authority.
6874#[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    /// Runtime state carried by this exact machine-authorized store record.
6887    ///
6888    /// Custom stores use this bounded fact to advance an existing session
6889    /// catalog projection in the same transaction as the lifecycle row.
6890    #[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/// Machine-owned lifecycle commit token.
6906///
6907/// This token has no public constructor. RuntimeStore implementors can persist
6908/// the selected state and binding facts, but callers outside the machine/driver
6909/// commit path cannot select arbitrary lifecycle truth.
6910#[derive(Debug, Clone, PartialEq, Eq)]
6911pub struct MachineLifecycleCommit {
6912    snapshot: MachineLifecycleSnapshot,
6913    /// When set, the store must verify the CURRENT persisted lifecycle row
6914    /// still matches this exact version inside the same transaction that
6915    /// writes the commit, and fail the whole boundary with
6916    /// [`RuntimeStoreError::MachineLifecycleVersionConflict`] otherwise.
6917    /// `None` preserves the historical last-writer semantics of the live
6918    /// driver commit path, whose exclusive in-process authority already
6919    /// serializes writers.
6920    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    /// Fence this commit on the exact lifecycle row version it was derived
6973    /// from. Used by cold recovery: between observing the persisted row and
6974    /// committing the recovered boundary another process may register or
6975    /// advance the runtime, and a blind upsert would stomp its truth.
6976    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    /// Runtime state selected by the owning MeerkatMachine transition.
6985    pub fn runtime_state(&self) -> RuntimeState {
6986        self.snapshot.runtime_state()
6987    }
6988
6989    /// Durable lifecycle snapshot selected by the owning MeerkatMachine transition.
6990    pub fn snapshot(&self) -> &MachineLifecycleSnapshot {
6991        &self.snapshot
6992    }
6993
6994    /// Durable record selected by the owning MeerkatMachine transition.
6995    pub fn store_record(&self) -> MachineLifecycleStoreRecord {
6996        MachineLifecycleStoreRecord::from_snapshot(&self.snapshot)
6997    }
6998
6999    /// Exact prior row version this commit is fenced on, when the producer
7000    /// demanded compare-and-swap semantics. Store implementations MUST
7001    /// enforce it inside the same transaction that writes the commit.
7002    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/// Machine-authorized final-unregister persistence token.
7012///
7013/// The token bundles terminal lifecycle truth with the exact authorized input
7014/// snapshot. It has no public constructor and can only be minted by consuming
7015/// the private-field delete witness derived from the generated
7016/// `DeleteSnapshot` unregister verdict.
7017#[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    /// Opaque encoded lifecycle record selected by final-unregister machine
7053    /// authority. External stores can persist this without gaining a way to
7054    /// construct or alter the authority token.
7055    pub fn lifecycle_store_record(&self) -> MachineLifecycleStoreRecord {
7056        self.machine_lifecycle.store_record()
7057    }
7058
7059    /// Authorized input-state rows that must commit in the same transaction.
7060    pub fn input_states(&self) -> &[InputStatePersistenceRecord] {
7061        &self.input_states
7062    }
7063
7064    /// Exact ops epoch retired by this finalization transaction.
7065    pub fn retired_ops_epoch(&self) -> &meerkat_core::RuntimeEpochId {
7066        &self.retired_ops_epoch
7067    }
7068}
7069
7070/// One durable input row observed by [`RuntimeStore::load_input_states`].
7071#[derive(Debug, Clone)]
7072pub enum InputStateRow {
7073    /// The row decoded under this binary's persisted contract.
7074    Decoded(Box<StoredInputState>),
7075    /// The row's persisted bytes no longer decode. The row stays on disk
7076    /// untouched (forensics), and is reported typed so one damaged row does
7077    /// not make the runtime's other durable inputs unreadable.
7078    Corrupt {
7079        /// Row key as stored (the row's JSON no longer parses, so the typed
7080        /// `InputId` cannot be recovered from it).
7081        input_id: String,
7082        /// Decode failure detail.
7083        detail: String,
7084    },
7085}
7086
7087/// Recovery projection of [`RuntimeStore::load_input_states`]: corrupt rows
7088/// are reported loudly and skipped so one damaged row cannot make the whole
7089/// runtime unrecoverable (the v0.8.7 failure mode). The damaged rows stay on
7090/// disk untouched for forensics.
7091pub 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/// Atomic persistence interface for runtime state.
7113///
7114/// Implementations:
7115/// - `InMemoryRuntimeStore` — in-memory, no durability (ephemeral/testing)
7116/// - `SqliteRuntimeStore` — SQLite-backed durable runtime state
7117///
7118/// A store may contain many logical runtime ids, but each id is controlled by
7119/// one live `MeerkatMachine` authority. Store transactions provide durable
7120/// atomicity; they are not a distributed lease for two machines concurrently
7121/// controlling the same logical runtime.
7122///
7123/// Every operation that mutates more than one input row evaluates the unique
7124/// idempotency-key constraint against the batch's complete final image. Target
7125/// rows relinquish their old keys as one logical set before any target claims
7126/// its successor key, so a valid key swap is accepted regardless of mutation
7127/// order. A duplicate final claim or a claim held by a row outside the mutation
7128/// set rejects the entire operation without exposing any sibling effect.
7129///
7130/// This object-safe carrier is implemented only by real persistence backends.
7131/// Its methods have the same contracts as the corresponding forwarding
7132/// methods on [`RuntimeStore`]. Every method is required: profile-specific
7133/// capability refusals are explicit backend behavior, never inherited
7134/// defaults.
7135///
7136/// This is an implementor seam. Operational callers use [`RuntimeStore`] so a
7137/// decorator's intentional per-operation overrides remain observable.
7138#[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    /// Required carrier for the complete store-owned session-authority seam.
7244    ///
7245    /// Decorators forward this one accessor. A fault-injection decorator may
7246    /// still override the individual forwarding method it intentionally
7247    /// perturbs. Omitting the carrier is therefore a compile error instead of
7248    /// a runtime `Unsupported` surprise.
7249    #[doc(hidden)]
7250    fn session_authority_ops(&self) -> &dyn RuntimeSessionAuthorityOps;
7251
7252    /// Durable session representation owned by this store.
7253    ///
7254    /// Every backend carrier must choose explicitly. `WholeBlobV1`
7255    /// materializes and writes the accumulated session document at each
7256    /// boundary, so its ordinary persistence cost is O(document).
7257    /// `HeadCanonicalV1` commits the prepared head/suffix mutation and small
7258    /// runtime authority incrementally. Every profile must implement
7259    /// [`RuntimeSessionAuthorityOps::commit_prepared_session_boundary`]
7260    /// directly; there is no checkpoint-derived or whole-blob compatibility
7261    /// bridge.
7262    fn session_persistence_profile(&self) -> RuntimeSessionPersistenceProfile {
7263        self.session_authority_ops().session_persistence_profile()
7264    }
7265
7266    /// Declared cost of [`Self::load_session_boundary_authority`].
7267    ///
7268    /// The carrier default is deliberately unsupported. Backends opt in only
7269    /// after maintaining authority separately from the document body.
7270    fn session_boundary_authority_read_cost(&self) -> RuntimeSessionAuthorityReadCost {
7271        self.session_authority_ops()
7272            .session_boundary_authority_read_cost()
7273    }
7274
7275    /// Commit one valid-by-construction prepared session boundary.
7276    ///
7277    /// Every backend carrier must override this operation. Only the backend
7278    /// can allocate the next physical revision and atomically bind it to the
7279    /// exact body/head, catalog projection, receipts, input rows, and lifecycle
7280    /// effects. A generic implementation cannot honestly mint store-issued
7281    /// authority.
7282    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    /// Load the versioned session authority for a runtime.
7293    ///
7294    /// Implementations may expose this only as a bounded authority-row read.
7295    /// There is intentionally no default fallback through
7296    /// [`Self::load_session_snapshot`]: callers poll this seam during
7297    /// reconciliation, so parsing a WholeBlob body here would turn degraded
7298    /// operation into an invisible O(document) loop.
7299    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    /// Observe only the fixed-size store-issued WholeBlob identity.
7309    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    /// Atomically pair the WholeBlob body with its store-issued identity.
7319    ///
7320    /// This is the source for resume/rewrite payload verification. Polling
7321    /// callers must use [`Self::load_whole_blob_store_authority`] instead.
7322    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    /// Commit one typed WholeBlob successor only while its exact store-issued
7332    /// predecessor remains current.
7333    ///
7334    /// Implementations compare only [`WholeBlobStoreAuthority`]. They must not
7335    /// derive currentness from Session checkpoint metadata or reread/compare a
7336    /// whole document.
7337    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    /// Delete one exact runtime's catalog projection.
7348    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    /// Load one bounded, body-free runtime session catalog entry.
7358    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    /// List body-free catalog entries in deterministic updated-descending,
7368    /// session-id-ascending order.
7369    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    /// Write one typed provisional WholeBlob candidate exactly once.
7379    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    /// Atomically load one provisional authority and its candidate body.
7390    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    /// Discard only the exact provisional candidate named by `expected`.
7400    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    /// Persist one exact HeadCanonical provisional intent before the physical
7411    /// SessionStore CAS it authorizes.
7412    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    /// Load only the fixed-size HeadCanonical provisional authority.
7423    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    /// Discard only the exact HeadCanonical provisional authority supplied.
7433    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    /// Load one store-owned durable-tail source from a single verified
7444    /// authority/physical-head snapshot.
7445    ///
7446    /// Only a backend that atomically owns runtime authority and canonical
7447    /// session rows can implement this. The default refuses instead of
7448    /// accepting caller-supplied session/head facts.
7449    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    /// Load every exact original receipt row for one store-derived recovery
7459    /// candidate run, ordered by boundary sequence.
7460    ///
7461    /// The row token in each opaque result lets a supported-floor missing
7462    /// conversation digest be enriched in the same transaction as recovery.
7463    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    /// Load the durable exact-retry witness for one recovery candidate.
7474    ///
7475    /// Only a backend that owns runtime authority and the physical session
7476    /// head in one atomic resource may implement this. The generic WholeBlob
7477    /// profile has no way to recheck an external SessionStore row and therefore
7478    /// refuses rather than presenting a partial commit as converged recovery.
7479    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    /// Whether [`RuntimeStore::atomic_apply`] durably records typed compaction
7490    /// projection intents in the same boundary as the session rewrite.
7491    /// Unknown/custom stores fail closed by default.
7492    fn supports_compaction_projection_outbox(&self) -> bool {
7493        false
7494    }
7495
7496    /// Stable key for process-local auth/OAuth authority reuse across reopened
7497    /// handles for the same durable store.
7498    fn auth_authority_key(&self) -> Option<String> {
7499        None
7500    }
7501
7502    /// Load the exact generated runtime-delivery authority record.
7503    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    /// Load one durable runtime-delivery inbox row by stable identity.
7514    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    /// Compare-and-swap generated delivery authority and optionally insert one
7526    /// inbox row in the same atomic boundary.
7527    ///
7528    /// `expected_revision = None` means the authority row must be absent.
7529    /// Stores enforce only exact CAS, row uniqueness, and atomicity; the
7530    /// generated machine decides sequence allocation and application order.
7531    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    /// List durable inbox rows in generated sequence order.
7550    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    /// Persist the runtime-owned OAuth login-flow payload snapshot.
7563    ///
7564    /// The AuthMachine owns admission/consume semantics; this payload snapshot
7565    /// carries the PKCE verifier and device-code correlation data needed to
7566    /// rehydrate active flows after a persistent runtime process restart.
7567    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    /// Load the runtime-owned OAuth login-flow payload snapshot, if present.
7578    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    /// Atomically update the runtime-owned OAuth login-flow payload snapshot.
7585    ///
7586    /// Stores that support OAuth snapshots must override this with a lock,
7587    /// transaction, or compare-and-swap boundary. A load/compute/persist
7588    /// fallback is not safe for admission, capacity, or consume claims.
7589    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    /// Atomically persist a session snapshot that is not a run boundary.
7599    ///
7600    /// Session-control snapshots update durable session authority without
7601    /// producing a [`RunBoundaryReceipt`].
7602    async fn commit_session_snapshot(
7603        &self,
7604        runtime_id: &LogicalRuntimeId,
7605        session_delta: SerializedSessionSnapshot,
7606    ) -> Result<(), RuntimeStoreError>;
7607
7608    /// Commit one valid-by-construction WholeBlob transcript-rewrite boundary.
7609    ///
7610    /// Implementations compare the exact current authority with
7611    /// `boundary.expected_authority()` and write the already-materialized
7612    /// successor bytes once. If the exact successor authority is already
7613    /// current, return it without another physical write. Any other current
7614    /// authority conflicts. Stores must not decode the Session or reconstruct
7615    /// rewrite semantics; those proofs are sealed before this mechanical CAS.
7616    /// Exact successor compaction intents must match already-committed,
7617    /// non-finalized outbox rows inside the same lock or transaction.
7618    async fn commit_prepared_whole_blob_rewrite_boundary(
7619        &self,
7620        runtime_id: &LogicalRuntimeId,
7621        boundary: PreparedWholeBlobRewriteStoreParts,
7622    ) -> Result<WholeBlobStoreAuthority, RuntimeStoreError>;
7623
7624    /// Atomically persist session delta + receipt + input state updates.
7625    ///
7626    /// All writes MUST commit in a single atomic operation.
7627    /// If `session_store_key` is `Some`, validates that the snapshot belongs
7628    /// to that session and, for stores that physically share a `SessionStore`
7629    /// table, writes that table in the same transaction. Runtime snapshot
7630    /// authority remains keyed only by `runtime_id`; `session_store_key` must
7631    /// not create a raw session UUID runtime alias.
7632    /// Compaction intents must be inserted as pending outbox rows in this same
7633    /// boundary. An intent whose exact outbox identity is already finalized is
7634    /// a stale snapshot replay and must be rejected without mutating any part
7635    /// of the boundary.
7636    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    /// Load exact compaction projection intents committed by atomic_apply but
7646    /// not yet acknowledged as finalized by the memory store.
7647    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    /// Idempotently acknowledge post-commit memory finalization.
7658    ///
7659    /// The acknowledgement and removal of this exact intent from the
7660    /// authoritative persisted session snapshot MUST occur in one atomic
7661    /// boundary. The finalized outbox row remains as a tombstone so later
7662    /// snapshot writes can reject stale metadata replay.
7663    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    /// Atomically persist a failed-but-applied runtime turn.
7675    ///
7676    /// This is the machine-terminal counterpart to [`Self::atomic_apply`]:
7677    /// the mutated session snapshot, boundary receipt, generated machine
7678    /// lifecycle record, and input/outbox state must become visible in one
7679    /// transaction. Implementations must never compose this from separate
7680    /// `atomic_apply` and `commit_machine_lifecycle` calls.
7681    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    /// Load all input states for a runtime, one row outcome per stored row.
7704    ///
7705    /// A row whose persisted bytes no longer decode under this binary's
7706    /// contract is surfaced as [`InputStateRow::Corrupt`] instead of failing
7707    /// the whole load: one damaged row must not make every other durable
7708    /// input unreadable. The store never drops or rewrites the damaged row;
7709    /// the caller owns the per-row skip/fail policy
7710    /// ([`RuntimeStore::load_input_states_strict`] is the fail-on-any
7711    /// projection).
7712    async fn load_input_states(
7713        &self,
7714        runtime_id: &LogicalRuntimeId,
7715    ) -> Result<Vec<InputStateRow>, RuntimeStoreError>;
7716
7717    /// Strict projection of [`RuntimeStore::load_input_states`]: every row
7718    /// must decode; the first corrupt row fails the whole load with its row
7719    /// identity in the typed error.
7720    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    /// Load a specific boundary receipt.
7739    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    /// Load every durably committed boundary receipt for one run, in
7747    /// ascending sequence order.
7748    ///
7749    /// Recovery reads these to (a) derive the next boundary sequence for a
7750    /// recovered commit (an interrupted tool loop can already have committed
7751    /// `BoundaryContinue` receipts before losing only its final boundary)
7752    /// and (b) recover the exact contributing input identities the run
7753    /// already bound durably. The default probes ascending sequences through
7754    /// [`RuntimeStore::load_boundary_receipt`]; backends with range reads
7755    /// should override it.
7756    async fn load_committed_boundary_receipts(
7757        &self,
7758        runtime_id: &LogicalRuntimeId,
7759        run_id: &RunId,
7760    ) -> Result<Vec<RunBoundaryReceipt>, RuntimeStoreError> {
7761        // Receipt sequences are minted densely from 1 by the generated
7762        // machine; a gap therefore terminates the probe. The cap is a
7763        // corruption backstop, far above any real per-run boundary count.
7764        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    /// Load one authoritative snapshot of all and only nonterminal input-state
7781    /// rows, with the exact domain-prefixed SHA-256 digest of each row's
7782    /// stored bytes and a set/absence token over the complete ordered set.
7783    ///
7784    /// Each row digest is a target-local compare token: recovery carries it
7785    /// back on fenced [`InputStatePersistenceRecord`]s. The snapshot's set
7786    /// token additionally fences inserts, removals, and terminality changes,
7787    /// including the empty-set case where there are no per-row tokens to CAS.
7788    ///
7789    /// Implementations that apply recovery MUST recompute the snapshot from
7790    /// the same complete, runtime-scoped nonterminal index/set inside the
7791    /// transaction that writes the boundary. A different set token fails the
7792    /// whole boundary with [`RuntimeStoreError::RecoveryInputSetConflict`];
7793    /// implementations MUST also enforce every
7794    /// [`InputStatePersistenceRecord::expected_row_digest`] in that
7795    /// transaction, failing with
7796    /// [`RuntimeStoreError::InputRowVersionConflict`] on mismatch.
7797    ///
7798    /// There is deliberately no compatibility derivation from decoded rows:
7799    /// reserializing a bundle proves only the current serializer's canonical
7800    /// representation, not the exact bytes the backend observed and will CAS.
7801    /// A backend must override this method only when it can return and enforce
7802    /// tokens for its actual stored-row representation. Wrappers and custom
7803    /// stores that cannot do so fail closed, and durable-tail recovery maps
7804    /// this typed absence of fencing capability to `Unfenceable`.
7805    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    /// Load the latest committed whole-blob session snapshot for a runtime.
7816    ///
7817    /// Compatibility-only. A head-canonical implementation must return
7818    /// [`RuntimeStoreError::SessionPersistenceAuthorityConflict`] once a
7819    /// canonical authority row exists; returning a frozen migration BLOB would
7820    /// resurrect its predecessor as current truth.
7821    async fn load_session_snapshot(
7822        &self,
7823        runtime_id: &LogicalRuntimeId,
7824    ) -> Result<Option<std::sync::Arc<Vec<u8>>>, RuntimeStoreError>;
7825
7826    /// Remove the latest committed session snapshot for a runtime.
7827    ///
7828    /// This is used only as a fail-closed quarantine path when transcript
7829    /// rewrite audit failure makes the runtime snapshot itself invalid recovery
7830    /// authority and the service cannot restore the previous snapshot. An
7831    /// ordinary downstream compatibility-projection failure must retain the
7832    /// already-committed runtime snapshot for retry.
7833    /// Head-canonical stores must refuse this whole-document mutation.
7834    async fn clear_session_snapshot(
7835        &self,
7836        runtime_id: &LogicalRuntimeId,
7837    ) -> Result<(), RuntimeStoreError>;
7838
7839    /// Replace the latest committed session snapshot only if it still matches
7840    /// `expected_current`.
7841    ///
7842    /// Used by fail-closed recovery when a rejected transcript-rewrite snapshot
7843    /// must be restored to its prior audited value. Implementations must compare
7844    /// and write atomically so recovery cannot overwrite newer runtime authority.
7845    /// Head-canonical stores must refuse this whole-document mutation.
7846    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    /// Remove the latest committed session snapshot only if it still matches
7854    /// `expected_current`.
7855    ///
7856    /// This is the conditional variant of the fail-closed quarantine path.
7857    /// Head-canonical stores must refuse this whole-document mutation.
7858    async fn clear_session_snapshot_if_current(
7859        &self,
7860        runtime_id: &LogicalRuntimeId,
7861        expected_current: &[u8],
7862    ) -> Result<bool, RuntimeStoreError>;
7863
7864    /// Report whether the runtime-projection fallback for `runtime_id` is
7865    /// quarantined.
7866    ///
7867    /// This is a durable single-owner fact: when
7868    /// [`clear_session_snapshot_if_current`](Self::clear_session_snapshot_if_current)
7869    /// matches and DELETEs a rejected runtime snapshot, the same atomic boundary
7870    /// records the quarantine marker. A subsequent live snapshot write clears it.
7871    /// Recovery reads this to decide whether a store-only projection may stand in
7872    /// for an absent runtime snapshot. The default is fail-safe (`false`): stores
7873    /// that cannot record the marker durably never claim a snapshot is
7874    /// quarantined.
7875    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    /// Persist a single input state (for durable-before-ack).
7884    async fn persist_input_state(
7885        &self,
7886        runtime_id: &LogicalRuntimeId,
7887        state: &InputStatePersistenceRecord,
7888    ) -> Result<(), RuntimeStoreError>;
7889
7890    /// Atomically persist a batch of machine-authorized input shell updates.
7891    /// Used by per-input terminal outboxes so an N-input batch can never
7892    /// expose a mixed provisional/finalized or finalized/published phase.
7893    /// Idempotency-key ownership follows the trait's complete-final-image
7894    /// contract, including valid swaps between rows in this batch.
7895    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    /// Durable realization profile for
7909    /// [`Self::compare_and_swap_input_states_atomically`].
7910    fn input_state_batch_cas_implementation_profile(
7911        &self,
7912    ) -> InputStateBatchCasImplementationProfile {
7913        InputStateBatchCasImplementationProfile::Unsupported
7914    }
7915
7916    /// Atomically replace an exact set of input-state rows only when every
7917    /// currently persisted row is byte-identical to its expected
7918    /// [`StoredInputState`] serialization.
7919    ///
7920    /// Expected and replacement batches must contain the same unique keys and
7921    /// at most [`MAX_INPUT_STATE_BATCH_CAS`] rows. If every current row already
7922    /// equals its replacement, implementations return
7923    /// [`InputStateBatchCasOutcome::Swapped`] without rewriting it; this makes
7924    /// a committed store-first transaction retryable after caller
7925    /// cancellation or acknowledgement loss. Missing rows, mixed
7926    /// expected/replacement images, and any other changed durable rows return
7927    /// [`InputStateBatchCasOutcome::Stale`] without writing a replacement.
7928    /// Implementations must hold one lock/transaction across the complete
7929    /// comparison and write set. Replacement idempotency-key ownership follows
7930    /// the trait's complete-final-image contract, including valid swaps between
7931    /// rows in this batch.
7932    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    /// Atomically replace an exact input-state batch while an external
7948    /// authority fence is held across the target write.
7949    ///
7950    /// Implementations must compare the target rows first, then retain both
7951    /// the target transaction and the external authority guard until every
7952    /// replacement is committed. This is the cold-registration recovery seam:
7953    /// a process whose lease expires or is superseded must never overwrite
7954    /// input work recovered by its successor.
7955    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    /// Atomically publish machine-normalized recovery input rows only while
7973    /// the exact store-owned input-set revision observed with the source rows
7974    /// remains current.
7975    ///
7976    /// Unlike ordinary bounded input CAS, this seam has no total-row cap and
7977    /// MUST compare `expected_revision` even when `replacements` is empty. The
7978    /// empty case is the absence-fence path: a concurrent first insert must
7979    /// make it stale. Each replacement additionally carries the exact
7980    /// predecessor-row digest returned in
7981    /// [`Self::load_input_states_with_versions`].
7982    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    /// Revision-fenced recovery input publication while an external runtime
7996    /// authority fence is held across the target transaction.
7997    ///
7998    /// Implementations MUST execute the external fence even for an empty
7999    /// replacement set, after comparing the store-owned revision and before
8000    /// committing. This prevents a zero-row bootstrap from bypassing either
8001    /// the absence witness or its runtime-authority lease.
8002    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    /// Load a single input state.
8017    async fn load_input_state(
8018        &self,
8019        runtime_id: &LogicalRuntimeId,
8020        input_id: &InputId,
8021    ) -> Result<Option<StoredInputState>, RuntimeStoreError>;
8022
8023    /// Resolve one historical or live input through the store-owned
8024    /// idempotency-key index.
8025    ///
8026    /// Implementations MUST maintain a unique `(runtime_id, key) -> input_id`
8027    /// mapping atomically with every input-row insert, update, and delete.
8028    /// Presence and absence are authoritative only when the store proves, in
8029    /// the same backend snapshot as the keyed lookup, that every source row for
8030    /// the runtime has an unambiguous indexable key identity. A corrupt or
8031    /// otherwise unindexable row must return
8032    /// [`RuntimeStoreError::InputIdempotencyIndexUncertain`] for both hits and
8033    /// misses; it must never be treated as absence or ignored behind another
8034    /// indexed owner. A full input-history scan is not a conforming
8035    /// implementation. The returned digest binds the exact stored row bytes
8036    /// observed with the index lookup.
8037    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    /// Load an exact bounded set of input rows from one backend snapshot.
8048    ///
8049    /// Results have exactly the request's cardinality and order; a missing key
8050    /// occupies its corresponding `None` slot. Duplicate keys and batches
8051    /// larger than [`MAX_INPUT_STATE_BATCH_CAS`] are rejected. Implementations
8052    /// must perform one bounded backend read rather than repeatedly
8053    /// materializing a whole-blob ledger.
8054    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    /// Discover canonical owner ids for unfinished terminal work.
8069    ///
8070    /// Results are strictly ordered by [`InputId`], contain only ids greater
8071    /// than the stable exclusive `after` cursor, and contain at most `limit`
8072    /// entries. Implementations must maintain a store-owned index
8073    /// transactionally with input-state writes; scanning or decoding the
8074    /// accumulated input ledger inside this method violates the contract.
8075    ///
8076    /// The result is discovery only. Callers must hydrate and validate each
8077    /// owner's exact declared recipient batch through
8078    /// [`Self::load_input_states_by_ids`].
8079    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    /// Observe one physical machine-lifecycle row without collapsing corrupt
8092    /// or future-version bytes into absence.
8093    ///
8094    /// This is the recovery/reconciliation read surface. Custom stores must
8095    /// implement it explicitly; the default is capability-unavailable rather
8096    /// than inferring a total observation from the older strict decoder.
8097    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    /// Replace exactly one machine-lifecycle row when it is absent or still
8108    /// has the observed raw-content version.
8109    ///
8110    /// Built-in stores atomically compare the raw-content version and publish
8111    /// the machine-authorized replacement. Binding, generation, fence, and
8112    /// run atoms belong to the dead process that wrote the observed row; they
8113    /// are not durable high-waters and may be cleared by an exact-version
8114    /// cold-normalization CAS. The caller retains the prior raw digest for
8115    /// output-only diagnostics. Conflicts are ordinary level-triggered
8116    /// re-observation; unsupported or malformed bytes return
8117    /// [`RuntimeStoreError::MachineLifecycleRepairBlocked`].
8118    ///
8119    /// When a runtime session catalog entry already exists, an applied CAS
8120    /// must advance its runtime-state projection in the same atomic operation.
8121    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    /// Replace exactly one lifecycle row while an external authority fence is
8134    /// held across the target write.
8135    ///
8136    /// This is the conditional-registration store seam. Built-in stores call
8137    /// `write_fence` inside the row lock/transaction after the exact raw-row
8138    /// comparison and immediately before publication. Custom stores must opt
8139    /// in explicitly; the default is capability-unavailable rather than an
8140    /// unfenced fallback to compare_and_swap_machine_lifecycle. An applied
8141    /// fence must also advance an existing runtime session catalog entry to
8142    /// the replacement state in that same operation. This includes an
8143    /// already-exact lifecycle row: the applied fence heals a stale catalog
8144    /// projection before returning [`FencedMachineLifecycleCasOutcome::AlreadyExact`].
8145    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    /// Load the last persisted machine lifecycle record bytes, if any.
8159    ///
8160    /// Implementations return only the opaque bytes previously obtained from
8161    /// [`MachineLifecycleCommit::store_record`]. The runtime crate decodes
8162    /// these bytes through `load_runtime_state` or internal recovery helpers;
8163    /// stores must not promote compatibility rows or bare runtime states into
8164    /// lifecycle authority.
8165    async fn load_machine_lifecycle_record(
8166        &self,
8167        runtime_id: &LogicalRuntimeId,
8168    ) -> Result<Option<Vec<u8>>, RuntimeStoreError>;
8169
8170    /// Atomically commit machine-owned lifecycle state changes.
8171    ///
8172    /// Writes runtime state, generated runtime binding facts, and all input
8173    /// state updates in a single atomic operation. `MachineLifecycleCommit` has
8174    /// no public constructor, so this cannot be used by compatibility callers
8175    /// to pick runtime truth. If a runtime session catalog entry exists, its
8176    /// runtime-state projection must advance to [`MachineLifecycleCommit::runtime_state`]
8177    /// in the same operation.
8178    async fn commit_machine_lifecycle(
8179        &self,
8180        runtime_id: &LogicalRuntimeId,
8181        commit: MachineLifecycleCommit,
8182        input_states: &[InputStatePersistenceRecord],
8183    ) -> Result<(), RuntimeStoreError>;
8184
8185    /// Atomically publish final unregister lifecycle truth and retire the
8186    /// matching ops-lifecycle epoch.
8187    ///
8188    /// The lifecycle record, input-state updates, and ops snapshot deletion
8189    /// MUST commit in one store transaction (or one indivisible in-memory
8190    /// critical section). A terminal lifecycle record with the old ops epoch
8191    /// still present is forbidden: recovery would otherwise resurrect stale
8192    /// operation/cursor authority after unregister. The commit also carries
8193    /// the exact retired ops epoch; implementations MUST atomically retain a
8194    /// durable deletion-wins fence for it, and every later
8195    /// `persist_ops_lifecycle` for that epoch must return
8196    /// [`RuntimeStoreError::OpsLifecycleEpochRetired`] rather than recreate the
8197    /// row. Implementations must also be idempotent so retry after a process
8198    /// crash following commit converges on the same terminal lifecycle with no
8199    /// ops snapshot and the same epoch fence.
8200    ///
8201    /// `Ok(())` means the whole finalization is visible. Every error except
8202    /// [`RuntimeStoreError::UnregisterFinalizationOutcomeUnknown`] MUST mean
8203    /// none of it is visible. A backend with an ambiguous commit
8204    /// acknowledgement must first resolve that ambiguity internally by
8205    /// reading its transaction authority. It may use the typed unknown error
8206    /// only when it cannot prove either the exact final state or the exact
8207    /// pre-transaction state; callers then retry without a durable rollback.
8208    /// The opaque token also proves the generated `DeleteSnapshot` verdict and
8209    /// bundles the exact lifecycle and input rows selected by the machine.
8210    ///
8211    /// The returned future is also a cancellation boundary: after it is
8212    /// dropped, no mutation from that invocation may become visible later.
8213    /// An implementation may leave the prior pair untouched or finish the
8214    /// entire atomic commit before cancellation is observable, but it must not
8215    /// detach a background write that can cross a same-runtime-ID replacement.
8216    /// If a runtime session catalog entry exists, its runtime-state projection
8217    /// is part of this same atomic finalization and is selected from
8218    /// [`UnregisterFinalizationCommit::lifecycle_store_record`].
8219    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    /// Atomically initialize the ops lifecycle row if it is absent and return
8231    /// the canonical durable snapshot.
8232    ///
8233    /// The absence check, optional insert, and canonical read MUST share one
8234    /// store transaction (or one indivisible in-memory critical section).
8235    /// Concurrent initializer calls for the same runtime must therefore all
8236    /// observe the same epoch: exactly one candidate may become durable and
8237    /// every losing caller receives that winner's snapshot. The machine's
8238    /// stable registration transaction separately spans this store call
8239    /// through map publication/removal; this method is not a distributed
8240    /// machine lease. Implementations must also reject a candidate whose epoch
8241    /// is already covered by the unregister deletion-wins fence.
8242    ///
8243    /// Cancellation may leave the candidate as the canonical empty row: no
8244    /// bindings escape before this await completes, and the next registrar
8245    /// adopts the returned durable epoch. A cancelled invocation must never
8246    /// overwrite a row that was already present.
8247    ///
8248    /// There is intentionally no load-then-persist default. Custom stores
8249    /// that support durable ops lifecycle state must implement this atomic
8250    /// boundary or fail closed with [`RuntimeStoreError::Unsupported`].
8251    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    /// Persist a snapshot of the ops lifecycle registry state.
8263    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    /// Load a previously persisted ops lifecycle snapshot.
8275    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    /// Delete a previously persisted ops lifecycle snapshot.
8284    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    // -----------------------------------------------------------------------
8295    // Mob host binding rows (`runtime_mob_host_bindings`, multi-host mobs R8)
8296    // -----------------------------------------------------------------------
8297    //
8298    // Raw record-JSON accessors only: the TYPED record and the
8299    // transition-derived persistence authorities live mob-side
8300    // (`meerkat-mob/src/runtime/host_actor.rs`); this store never interprets
8301    // the blob. CAS compares the full serialized record, mirroring the
8302    // `mob_runtime_supervisors` mechanics.
8303
8304    /// Load the persisted host-binding record blob for `mob_id`, if any.
8305    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    /// List every persisted host-binding row (boot recovery).
8316    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    /// Insert the host-binding row for `mob_id` iff absent. Returns whether
8323    /// the row was inserted.
8324    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    /// Replace the host-binding row for `mob_id` iff the stored blob equals
8336    /// `expected_json`. Returns whether the swap applied.
8337    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    /// Delete the host-binding row for `mob_id` iff the stored blob equals
8350    /// `expected_json`. Returns whether a row was deleted.
8351    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    /// Load the durable receipt for an already-completed host revocation.
8363    ///
8364    /// The blob is deliberately separate from `runtime_mob_host_bindings`:
8365    /// boot recovery must never mistake a revoke retry receipt for a live
8366    /// binding or revive the materialized-member rows that the revoke
8367    /// removed. The typed receipt and its transition witness live mob-side;
8368    /// this store treats it as opaque bytes.
8369    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    /// List durable host-revocation receipts for boot recovery of exact
8380    /// reply-loss retries. Receipts are not bindings and carry no member
8381    /// revival rows.
8382    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    /// Atomically delete the expected active binding and publish its revoke
8389    /// receipt. Returns `false` when the expected binding did not match; in
8390    /// that case neither write is visible.
8391    ///
8392    /// This is the durable terminal boundary for host revocation. A crash
8393    /// before it leaves the binding retryable; a crash after it leaves no
8394    /// binding/member rows to revive and an exact receipt to replay.
8395    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}