Skip to main content

lodestone_client/
exact_workflows.rs

1//! Recovery-correct native mutation workflows.
2//!
3//! The server remains the effect authority. This module owns only the
4//! caller-side identity, deadline, and resolver state needed to avoid minting
5//! a replacement effect after response loss.
6
7use std::error::Error;
8use std::fmt::{self, Display, Formatter};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use aws_lc_rs::digest;
14use lodestone_wire::{
15    Digest, ErrorCode, GetIndexRequest, Identity, IndexResourceSpec, Locator, ObserveAppendCommittedRequest,
16    PrepareAppendBulkRequest, PrepareCreateIndexRequest, PrepareDocumentMutationRequest, ResolveAppendBulkRequest,
17    ResolveCatalogActionRequest, ResolveDocumentMutationRequest, Resource, RetainedAppendBulkOutcome,
18    RetainedAppendItem, RetainedAppendItemOutcome, RetainedAppendObservationOutcome, RetainedAppendObservationState,
19    RetainedAppendRetrySafety, RetainedAppendState, SubmitAppendBulkRequest, SubmitCatalogActionRequest,
20    SubmitDocumentMutationRequest, U64,
21};
22use serde::{Deserialize, Serialize};
23
24use crate::workflow_journal::sealed;
25use crate::{
26    CallCancellation, CallOptions, Client, ClientError, LocalOperatorWorkflowKind, SubmissionDisposition,
27    WorkflowJournal, WorkflowJournalError, WorkflowJournalIntent, WorkflowJournalTransition,
28};
29
30pub const EXACT_MUTATION_FORMAT_VERSION: u16 = 1;
31const MAX_EFFECT_CALL_MILLIS: u64 = 599_000;
32
33/// Returns the catalog submit discriminator for a prepared index creation.
34///
35/// The preparation authority selects retained-append creation whenever the
36/// caller explicitly requests retained event identity, so recovery must
37/// preserve that same discriminator at submit time.
38#[must_use]
39pub const fn create_index_action_kind(retained_event_identity: Option<bool>) -> &'static str {
40    if matches!(retained_event_identity, Some(true)) {
41        "create_retained_append_log_index"
42    } else {
43        "create_index"
44    }
45}
46
47/// Semantic owner of one recovery record.
48#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
49#[serde(rename_all = "snake_case")]
50pub enum ExactMutationKindV1 {
51    Append,
52    Document,
53    Index,
54}
55
56/// Closed result vocabulary shared by all exact mutation helpers.
57#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
58#[serde(rename_all = "snake_case")]
59pub enum ExactMutationOutcomeV1 {
60    AlreadySatisfied,
61    Applied,
62    Resolved,
63    Conflict,
64    NotSubmitted,
65    Indeterminate,
66    KnownRetired,
67    EffectDeadlineExpired,
68    ClockRegression,
69}
70
71#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
72#[serde(rename_all = "snake_case")]
73enum ExactSubmissionDispositionV1 {
74    NotSubmitted,
75    Submitted,
76    Resolved,
77    KnownRetired,
78    Indeterminate,
79}
80
81impl From<SubmissionDisposition> for ExactSubmissionDispositionV1 {
82    fn from(value: SubmissionDisposition) -> Self {
83        match value {
84            SubmissionDisposition::NotSubmitted => Self::NotSubmitted,
85            SubmissionDisposition::Submitted => Self::Submitted,
86            SubmissionDisposition::Resolved => Self::Resolved,
87            SubmissionDisposition::KnownRetired => Self::KnownRetired,
88            SubmissionDisposition::Indeterminate => Self::Indeterminate,
89        }
90    }
91}
92
93/// Redacted retained-append item observation. Server error messages are never
94/// persisted in a recovery record.
95#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
96#[serde(deny_unknown_fields)]
97pub struct AppendExactItemResultV1 {
98    pub item_action_id: Digest,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub locator: Option<Locator>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub semantic_digest: Option<Digest>,
103    pub state: RetainedAppendState,
104    pub retry_safety: RetainedAppendRetrySafety,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub sequence: Option<U64>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub primary_term: Option<U64>,
109}
110
111#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
112#[serde(deny_unknown_fields)]
113pub struct AppendExactResultV1 {
114    pub outcome: ExactMutationOutcomeV1,
115    pub batch_id: Digest,
116    pub index: Resource,
117    pub items: Vec<AppendExactItemResultV1>,
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub visibility: Vec<RetainedAppendObservationOutcome>,
120}
121
122#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
123#[serde(deny_unknown_fields)]
124pub struct DocumentMutationExactResultV1 {
125    pub outcome: ExactMutationOutcomeV1,
126    pub index: Resource,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub locator: Option<Locator>,
129    pub state: String,
130    pub submitted: bool,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub sequence: Option<U64>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub mapping_generation: Option<U64>,
135    pub mapping_adopted: bool,
136}
137
138#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
139#[serde(deny_unknown_fields)]
140pub struct ReconcileIndexExactResultV1 {
141    pub outcome: ExactMutationOutcomeV1,
142    pub name: String,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub locator: Option<Locator>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub resource: Option<Resource>,
147    pub state: String,
148}
149
150#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
151#[serde(tag = "kind", content = "result", rename_all = "snake_case", deny_unknown_fields)]
152enum ExactTerminalResultV1 {
153    Append(AppendExactResultV1),
154    Document(DocumentMutationExactResultV1),
155    Index(ReconcileIndexExactResultV1),
156}
157
158#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
159#[serde(tag = "kind", content = "value", rename_all = "snake_case", deny_unknown_fields)]
160enum ExactPreparationV1 {
161    Append(RetainedAppendBulkOutcome),
162    Document {
163        locator: Locator,
164        semantic_digest: Digest,
165    },
166    Index {
167        locator: Locator,
168        expires_at: U64,
169        semantic_digest: Digest,
170        resource_spec: Box<IndexResourceSpec>,
171    },
172}
173
174/// Non-secret state sufficient to resume the same effect. Callers that do not
175/// use the filesystem adapter must persist this value after an unresolved
176/// return.
177#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
178#[serde(deny_unknown_fields)]
179pub struct ExactWorkflowRecoveryV1 {
180    pub format_version: u16,
181    pub mutation_kind: ExactMutationKindV1,
182    pub request_sha256: Digest,
183    pub cluster_id: Identity,
184    pub collection_id: Identity,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub index: Option<Resource>,
187    pub caller_action_id: Digest,
188    pub effect_deadline_unix_millis: U64,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub last_observed_unix_millis: Option<U64>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    preparation: Option<ExactPreparationV1>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    submission_disposition: Option<ExactSubmissionDispositionV1>,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    terminal: Option<ExactTerminalResultV1>,
197}
198
199/// One result plus the exact non-secret recovery state observed at return.
200#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
201#[serde(deny_unknown_fields)]
202pub struct ExactExecutionV1<T> {
203    pub result: T,
204    pub recovery: ExactWorkflowRecoveryV1,
205}
206
207/// Filesystem-backed adapter over Lodestone's sealed create-new workflow
208/// journal. The directory is exclusive to one exact request intent.
209#[derive(Clone, Debug, Eq, PartialEq)]
210pub struct ExactWorkflowStore {
211    path: PathBuf,
212}
213
214impl ExactWorkflowStore {
215    #[must_use]
216    pub fn new(path: impl Into<PathBuf>) -> Self {
217        Self { path: path.into() }
218    }
219
220    #[must_use]
221    pub fn path(&self) -> &Path {
222        &self.path
223    }
224}
225
226/// Injectable wall clock. Time is a liveness budget, never ordering or commit
227/// evidence.
228pub trait ExactWorkflowClock: Send + Sync {
229    fn unix_millis(&self) -> Result<u64, ExactWorkflowError>;
230}
231
232#[derive(Debug)]
233struct SystemExactWorkflowClock;
234
235impl ExactWorkflowClock for SystemExactWorkflowClock {
236    fn unix_millis(&self) -> Result<u64, ExactWorkflowError> {
237        let duration = SystemTime::now()
238            .duration_since(UNIX_EPOCH)
239            .map_err(|_| ExactWorkflowError::ClockUnavailable)?;
240        u64::try_from(duration.as_millis()).map_err(|_| ExactWorkflowError::ClockUnavailable)
241    }
242}
243
244/// Immutable budgets and optional recovery storage for one exact invocation.
245#[derive(Clone)]
246pub struct ExactWorkflowOptions {
247    effect_deadline_unix_millis: u64,
248    observation_deadline_unix_millis: Option<u64>,
249    store: Option<ExactWorkflowStore>,
250    recovery: Option<ExactWorkflowRecoveryV1>,
251    clock: Arc<dyn ExactWorkflowClock>,
252    cancellation: CallCancellation,
253}
254
255impl ExactWorkflowOptions {
256    #[must_use]
257    pub fn new(effect_deadline_unix_millis: u64) -> Self {
258        Self {
259            effect_deadline_unix_millis,
260            observation_deadline_unix_millis: None,
261            store: None,
262            recovery: None,
263            clock: Arc::new(SystemExactWorkflowClock),
264            cancellation: CallCancellation::new(),
265        }
266    }
267
268    #[must_use]
269    pub fn with_observation_deadline_unix_millis(mut self, value: u64) -> Self {
270        self.observation_deadline_unix_millis = Some(value);
271        self
272    }
273
274    #[must_use]
275    pub fn with_store(mut self, store: ExactWorkflowStore) -> Self {
276        self.store = Some(store);
277        self
278    }
279
280    #[must_use]
281    pub fn with_recovery(mut self, recovery: ExactWorkflowRecoveryV1) -> Self {
282        self.recovery = Some(recovery);
283        self
284    }
285
286    #[must_use]
287    pub fn with_clock(mut self, clock: Arc<dyn ExactWorkflowClock>) -> Self {
288        self.clock = clock;
289        self
290    }
291
292    #[must_use]
293    pub fn with_cancellation(mut self, cancellation: CallCancellation) -> Self {
294        self.cancellation = cancellation;
295        self
296    }
297}
298
299#[derive(Debug)]
300pub enum ExactWorkflowError {
301    InvalidInput,
302    InvalidRecovery,
303    RequestCodec,
304    CoreShape,
305    ClockUnavailable,
306    Journal(WorkflowJournalError),
307    Client(ClientError),
308}
309
310impl Display for ExactWorkflowError {
311    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
312        formatter.write_str(match self {
313            Self::InvalidInput => "the exact mutation input is invalid",
314            Self::InvalidRecovery => "the exact mutation recovery state does not match this request",
315            Self::RequestCodec => "the exact mutation request cannot be canonically encoded",
316            Self::CoreShape => "Core changed an exact mutation identity or outcome shape",
317            Self::ClockUnavailable => "the exact mutation wall clock is unavailable",
318            Self::Journal(_) => "the exact mutation journal could not be verified or updated",
319            Self::Client(_) => "the exact mutation client call failed",
320        })
321    }
322}
323
324impl Error for ExactWorkflowError {}
325
326impl From<WorkflowJournalError> for ExactWorkflowError {
327    fn from(value: WorkflowJournalError) -> Self {
328        Self::Journal(value)
329    }
330}
331
332/// Append request plus the optional visibility observation required by the
333/// caller. Its canonical encoding is bound into recovery state.
334#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
335#[serde(deny_unknown_fields)]
336pub struct AppendExactRequestV1 {
337    pub batch_id: Digest,
338    pub index: Resource,
339    pub items: Vec<RetainedAppendItem>,
340    #[serde(default)]
341    pub observe_committed: bool,
342}
343
344#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
345#[serde(deny_unknown_fields)]
346struct ExactMutationIntent {
347    format_version: u16,
348    mutation_kind: ExactMutationKindV1,
349    request_sha256: Digest,
350    cluster_id: Identity,
351    collection_id: Identity,
352    index: Option<Resource>,
353    caller_action_id: Digest,
354}
355
356impl sealed::Sealed for ExactMutationIntent {}
357
358impl WorkflowJournalIntent for ExactMutationIntent {
359    type Transition = ExactMutationTransition;
360
361    const KIND: LocalOperatorWorkflowKind = LocalOperatorWorkflowKind::ExactMutation;
362
363    fn validate(&self) -> Result<(), WorkflowJournalError> {
364        if self.format_version != EXACT_MUTATION_FORMAT_VERSION
365            || self.request_sha256.validate().is_err()
366            || self.cluster_id.validate().is_err()
367            || self.collection_id.validate().is_err()
368            || self.caller_action_id.validate().is_err()
369            || self.index.as_ref().is_some_and(|value| !valid_index(value))
370        {
371            return Err(WorkflowJournalError::InvalidIntent);
372        }
373        Ok(())
374    }
375}
376
377#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
378#[serde(tag = "transition", rename_all = "snake_case", deny_unknown_fields)]
379enum ExactMutationTransition {
380    DeadlineEstablished { unix_millis: U64 },
381    ClockObserved { unix_millis: U64 },
382    Prepared { preparation: ExactPreparationV1 },
383    SubmissionObserved { disposition: ExactSubmissionDispositionV1 },
384    Terminal { result: ExactTerminalResultV1 },
385}
386
387impl sealed::Sealed for ExactMutationTransition {}
388
389impl WorkflowJournalTransition for ExactMutationTransition {
390    fn validate(&self) -> Result<(), WorkflowJournalError> {
391        match self {
392            Self::DeadlineEstablished { unix_millis } if parse_u64(unix_millis).is_some_and(|value| value > 0) => {
393                Ok(())
394            }
395            Self::ClockObserved { unix_millis } if parse_u64(unix_millis).is_some() => Ok(()),
396            Self::Prepared { preparation } if valid_preparation(preparation) => Ok(()),
397            Self::SubmissionObserved { .. } => Ok(()),
398            Self::Terminal { result } if valid_terminal(result) => Ok(()),
399            _ => Err(WorkflowJournalError::InvalidTransition),
400        }
401    }
402}
403
404enum ExactSession {
405    Durable(WorkflowJournal<ExactMutationIntent>),
406    Ephemeral,
407}
408
409impl ExactSession {
410    async fn record(
411        &self,
412        state: &mut ExactWorkflowRecoveryV1,
413        transition: ExactMutationTransition,
414    ) -> Result<(), ExactWorkflowError> {
415        let mut next = state.clone();
416        reduce_transition(&mut next, &transition)?;
417        if let Self::Durable(journal) = self {
418            journal.append(transition).await?;
419        }
420        *state = next;
421        Ok(())
422    }
423}
424
425impl Client {
426    /// Runs retained append through prepare, submit, and resolver without ever
427    /// replacing the caller's batch or item action identities.
428    pub async fn append_exact(
429        &self,
430        request: AppendExactRequestV1,
431        options: ExactWorkflowOptions,
432    ) -> Result<ExactExecutionV1<AppendExactResultV1>, ExactWorkflowError> {
433        validate_append_request(&request)?;
434        let request_sha256 = canonical_sha256(&request)?;
435        let (cluster_id, collection_id) = self.exact_profile_scope();
436        let intent = exact_intent(
437            ExactMutationKindV1::Append,
438            request_sha256,
439            cluster_id,
440            collection_id,
441            Some(request.index.clone()),
442            request.batch_id.clone(),
443        );
444        let (session, mut recovery) = open_session(&intent, &options).await?;
445        let mut options = options;
446        options.effect_deadline_unix_millis = recovery_effect_deadline(&recovery)?;
447        if let Some(ExactTerminalResultV1::Append(result)) = &recovery.terminal {
448            return Ok(execution(result.clone(), recovery));
449        }
450        let now = observe_clock(&session, &mut recovery, &options).await?;
451        if clock_regressed(&recovery, now) {
452            return Ok(execution(
453                append_status(&request, &recovery, ExactMutationOutcomeV1::ClockRegression),
454                recovery,
455            ));
456        }
457
458        if recovery.preparation.is_none() {
459            if now >= options.effect_deadline_unix_millis {
460                let result = append_status(&request, &recovery, ExactMutationOutcomeV1::EffectDeadlineExpired);
461                return finish_append(&session, &mut recovery, result).await;
462            }
463            let prepared = self
464                .prepare_append_bulk(
465                    PrepareAppendBulkRequest {
466                        batch_id: request.batch_id.clone(),
467                        index: request.index.clone(),
468                        items: request.items.clone(),
469                    },
470                    effect_options(now, options.effect_deadline_unix_millis, &options)?,
471                )
472                .await
473                .map_err(ExactWorkflowError::Client)?
474                .outcome;
475            validate_append_outcome(&request, &prepared, true)?;
476            session
477                .record(
478                    &mut recovery,
479                    ExactMutationTransition::Prepared {
480                        preparation: ExactPreparationV1::Append(redact_append_outcome(prepared)),
481                    },
482                )
483                .await?;
484        }
485
486        let preparations = append_preparation(&recovery)?.items.clone();
487        let may_submit = !matches!(
488            recovery.submission_disposition,
489            Some(
490                ExactSubmissionDispositionV1::Submitted
491                    | ExactSubmissionDispositionV1::Resolved
492                    | ExactSubmissionDispositionV1::KnownRetired
493                    | ExactSubmissionDispositionV1::Indeterminate
494            )
495        );
496        if may_submit && now < options.effect_deadline_unix_millis {
497            match self
498                .submit_append_bulk(
499                    SubmitAppendBulkRequest {
500                        batch_id: request.batch_id.clone(),
501                        index: request.index.clone(),
502                        items: request.items.clone(),
503                        preparations,
504                    },
505                    effect_options(now, options.effect_deadline_unix_millis, &options)?,
506                )
507                .await
508            {
509                Ok(submitted) => {
510                    validate_append_outcome(&request, &submitted.outcome, false)?;
511                    let disposition = if submitted.outcome.items.iter().any(|item| {
512                        matches!(
513                            item.state,
514                            RetainedAppendState::Committed
515                                | RetainedAppendState::Replayed
516                                | RetainedAppendState::SubmittedIndeterminate
517                        )
518                    }) {
519                        ExactSubmissionDispositionV1::Submitted
520                    } else {
521                        ExactSubmissionDispositionV1::NotSubmitted
522                    };
523                    session
524                        .record(
525                            &mut recovery,
526                            ExactMutationTransition::SubmissionObserved { disposition },
527                        )
528                        .await?;
529                    if let Some(mut result) = append_terminal_result(&request, &submitted.outcome, false) {
530                        if !terminal_outcome(result.outcome) {
531                            return Ok(execution(result, recovery));
532                        }
533                        if request.observe_committed
534                            && matches!(
535                                result.outcome,
536                                ExactMutationOutcomeV1::Applied | ExactMutationOutcomeV1::Resolved
537                            )
538                        {
539                            let Some(observe_options) = resolution_options(now, &options)? else {
540                                result.outcome = ExactMutationOutcomeV1::Indeterminate;
541                                return Ok(execution(result, recovery));
542                            };
543                            result = observe_append_visibility(self, &request, result, observe_options).await?;
544                            if result.outcome == ExactMutationOutcomeV1::Indeterminate {
545                                return Ok(execution(result, recovery));
546                            }
547                        }
548                        return finish_append(&session, &mut recovery, result).await;
549                    }
550                }
551                Err(error) => {
552                    let disposition = ExactSubmissionDispositionV1::from(error.submission_disposition());
553                    session
554                        .record(
555                            &mut recovery,
556                            ExactMutationTransition::SubmissionObserved { disposition },
557                        )
558                        .await?;
559                    if disposition == ExactSubmissionDispositionV1::NotSubmitted {
560                        return Ok(execution(
561                            append_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
562                            recovery,
563                        ));
564                    }
565                }
566            }
567        }
568
569        let Some(resolve_options) = resolution_options(now, &options)? else {
570            return Ok(execution(
571                append_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
572                recovery,
573            ));
574        };
575        let resolved = self
576            .resolve_append_bulk(
577                ResolveAppendBulkRequest {
578                    batch_id: request.batch_id.clone(),
579                    index: request.index.clone(),
580                    items: append_preparation(&recovery)?.items.clone(),
581                },
582                resolve_options,
583            )
584            .await;
585        let outcome = match resolved {
586            Ok(value) => value.outcome,
587            Err(_) => {
588                return Ok(execution(
589                    append_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
590                    recovery,
591                ));
592            }
593        };
594        validate_append_outcome(&request, &outcome, false)?;
595        let Some(mut result) = append_terminal_result(&request, &outcome, true) else {
596            return Ok(execution(
597                append_result(&request, &outcome, ExactMutationOutcomeV1::Indeterminate),
598                recovery,
599            ));
600        };
601        if request.observe_committed
602            && matches!(
603                result.outcome,
604                ExactMutationOutcomeV1::Applied | ExactMutationOutcomeV1::Resolved
605            )
606        {
607            let Some(observe_options) = resolution_options(now, &options)? else {
608                result.outcome = ExactMutationOutcomeV1::Indeterminate;
609                return Ok(execution(result, recovery));
610            };
611            result = observe_append_visibility(self, &request, result, observe_options).await?;
612            if result.outcome == ExactMutationOutcomeV1::Indeterminate {
613                return Ok(execution(result, recovery));
614            }
615        }
616        finish_append(&session, &mut recovery, result).await
617    }
618
619    /// Runs one document mutation with a stable prepared locator and resolves
620    /// any possibly submitted result before returning a terminal claim.
621    pub async fn mutate_document_exact(
622        &self,
623        request: PrepareDocumentMutationRequest,
624        options: ExactWorkflowOptions,
625    ) -> Result<ExactExecutionV1<DocumentMutationExactResultV1>, ExactWorkflowError> {
626        if !valid_index(&request.index) || request.document_id.is_empty() {
627            return Err(ExactWorkflowError::InvalidInput);
628        }
629        let request_sha256 = canonical_sha256(&request)?;
630        let (cluster_id, collection_id) = self.exact_profile_scope();
631        let intent = exact_intent(
632            ExactMutationKindV1::Document,
633            request_sha256.clone(),
634            cluster_id,
635            collection_id,
636            Some(request.index.clone()),
637            request_sha256,
638        );
639        let (session, mut recovery) = open_session(&intent, &options).await?;
640        let mut options = options;
641        options.effect_deadline_unix_millis = recovery_effect_deadline(&recovery)?;
642        if let Some(ExactTerminalResultV1::Document(result)) = &recovery.terminal {
643            return Ok(execution(result.clone(), recovery));
644        }
645        let now = observe_clock(&session, &mut recovery, &options).await?;
646        if clock_regressed(&recovery, now) {
647            return Ok(execution(
648                document_status(&request, &recovery, ExactMutationOutcomeV1::ClockRegression),
649                recovery,
650            ));
651        }
652        if recovery.preparation.is_none() {
653            if now >= options.effect_deadline_unix_millis {
654                let result = document_status(&request, &recovery, ExactMutationOutcomeV1::EffectDeadlineExpired);
655                return finish_document(&session, &mut recovery, result).await;
656            }
657            let prepared = self
658                .prepare_document_mutation(
659                    request.clone(),
660                    effect_options(now, options.effect_deadline_unix_millis, &options)?,
661                )
662                .await
663                .map_err(ExactWorkflowError::Client)?;
664            if prepared.locator.authority.is_empty() || prepared.semantic_digest.validate().is_err() {
665                return Err(ExactWorkflowError::CoreShape);
666            }
667            session
668                .record(
669                    &mut recovery,
670                    ExactMutationTransition::Prepared {
671                        preparation: ExactPreparationV1::Document {
672                            locator: prepared.locator,
673                            semantic_digest: prepared.semantic_digest,
674                        },
675                    },
676                )
677                .await?;
678        }
679        let (locator, semantic_digest) = document_preparation(&recovery)?;
680        let may_submit = !matches!(
681            recovery.submission_disposition,
682            Some(
683                ExactSubmissionDispositionV1::Submitted
684                    | ExactSubmissionDispositionV1::Resolved
685                    | ExactSubmissionDispositionV1::KnownRetired
686                    | ExactSubmissionDispositionV1::Indeterminate
687            )
688        );
689        if may_submit && now < options.effect_deadline_unix_millis {
690            match self
691                .submit_document_mutation(
692                    SubmitDocumentMutationRequest {
693                        locator: locator.clone(),
694                        semantic_digest,
695                        index: request.index.clone(),
696                        document_id: request.document_id.clone(),
697                        operation: request.operation.clone(),
698                        source: request.source.clone(),
699                        routing: request.routing.clone(),
700                        condition: request.condition.clone(),
701                        pipeline: request.pipeline.clone(),
702                    },
703                    effect_options(now, options.effect_deadline_unix_millis, &options)?,
704                )
705                .await
706            {
707                Ok(value) => {
708                    if value.locator != locator {
709                        return Err(ExactWorkflowError::CoreShape);
710                    }
711                    session
712                        .record(
713                            &mut recovery,
714                            ExactMutationTransition::SubmissionObserved {
715                                disposition: if value.submitted {
716                                    ExactSubmissionDispositionV1::Submitted
717                                } else {
718                                    ExactSubmissionDispositionV1::NotSubmitted
719                                },
720                            },
721                        )
722                        .await?;
723                    if value.state == "applied" {
724                        return finish_document(
725                            &session,
726                            &mut recovery,
727                            DocumentMutationExactResultV1 {
728                                outcome: ExactMutationOutcomeV1::Applied,
729                                index: request.index,
730                                locator: Some(value.locator),
731                                state: value.state,
732                                submitted: value.submitted,
733                                sequence: value.sequence,
734                                mapping_generation: value.mapping_generation,
735                                mapping_adopted: value.mapping_adopted,
736                            },
737                        )
738                        .await;
739                    }
740                }
741                Err(error) => {
742                    let disposition = ExactSubmissionDispositionV1::from(error.submission_disposition());
743                    session
744                        .record(
745                            &mut recovery,
746                            ExactMutationTransition::SubmissionObserved { disposition },
747                        )
748                        .await?;
749                    if disposition == ExactSubmissionDispositionV1::NotSubmitted {
750                        return Ok(execution(
751                            document_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
752                            recovery,
753                        ));
754                    }
755                }
756            }
757        }
758        let Some(resolve_options) = resolution_options(now, &options)? else {
759            return Ok(execution(
760                document_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
761                recovery,
762            ));
763        };
764        let resolve_options = resolve_options
765            .with_index_name(request.index.name.clone())
766            .map_err(ExactWorkflowError::Client)?;
767        let value = match self
768            .resolve_document_mutation(
769                ResolveDocumentMutationRequest {
770                    locator: locator.clone(),
771                },
772                resolve_options,
773            )
774            .await
775        {
776            Ok(value) => value,
777            Err(_) => {
778                return Ok(execution(
779                    document_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
780                    recovery,
781                ));
782            }
783        };
784        if value.locator != locator || value.source.is_some() {
785            return Err(ExactWorkflowError::CoreShape);
786        }
787        let outcome = match value.state.as_str() {
788            "applied" => ExactMutationOutcomeV1::Resolved,
789            "failed" => ExactMutationOutcomeV1::Resolved,
790            "expired_unknown" => ExactMutationOutcomeV1::KnownRetired,
791            "prepared" => ExactMutationOutcomeV1::NotSubmitted,
792            "pending" => ExactMutationOutcomeV1::Indeterminate,
793            _ => return Err(ExactWorkflowError::CoreShape),
794        };
795        let result = DocumentMutationExactResultV1 {
796            outcome,
797            index: request.index,
798            locator: Some(value.locator),
799            state: value.state,
800            submitted: !matches!(outcome, ExactMutationOutcomeV1::NotSubmitted),
801            sequence: value.sequence,
802            mapping_generation: value.mapping_generation,
803            mapping_adopted: value.mapping_adopted,
804        };
805        if matches!(
806            outcome,
807            ExactMutationOutcomeV1::Indeterminate | ExactMutationOutcomeV1::NotSubmitted
808        ) {
809            Ok(execution(result, recovery))
810        } else {
811            finish_document(&session, &mut recovery, result).await
812        }
813    }
814
815    /// Reconciles only an absent index to one exact desired definition. An
816    /// existing different definition is a conflict; this helper never updates.
817    pub async fn reconcile_index_exact(
818        &self,
819        request: PrepareCreateIndexRequest,
820        options: ExactWorkflowOptions,
821    ) -> Result<ExactExecutionV1<ReconcileIndexExactResultV1>, ExactWorkflowError> {
822        if request.name.is_empty() || request.resource_spec.is_none() {
823            return Err(ExactWorkflowError::InvalidInput);
824        }
825        let request_sha256 = canonical_sha256(&request)?;
826        let (cluster_id, collection_id) = self.exact_profile_scope();
827        let intent = exact_intent(
828            ExactMutationKindV1::Index,
829            request_sha256.clone(),
830            cluster_id,
831            collection_id,
832            None,
833            request_sha256,
834        );
835        let (session, mut recovery) = open_session(&intent, &options).await?;
836        let mut options = options;
837        options.effect_deadline_unix_millis = recovery_effect_deadline(&recovery)?;
838        if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
839            return Ok(execution(result.clone(), recovery));
840        }
841        let now = observe_clock(&session, &mut recovery, &options).await?;
842        if clock_regressed(&recovery, now) {
843            return Ok(execution(
844                index_status(&request, &recovery, ExactMutationOutcomeV1::ClockRegression),
845                recovery,
846            ));
847        }
848
849        if recovery.preparation.is_none() {
850            let Some(lookup_options) = resolution_options(now, &options)? else {
851                let result = index_status(&request, &recovery, ExactMutationOutcomeV1::EffectDeadlineExpired);
852                return finish_index(&session, &mut recovery, result).await;
853            };
854            let lookup = self
855                .get_index(
856                    GetIndexRequest {
857                        name: request.name.clone(),
858                        expected_incarnation: None,
859                    },
860                    lookup_options,
861                )
862                .await;
863            match lookup {
864                Ok(existing) => {
865                    let exact = existing.resource.current_mapping == request.mapping
866                        && request.resource_spec.as_ref() == Some(&existing.resource.resource_spec)
867                        && existing.resource.summary.retained_event_identity
868                            == request.retained_event_identity.unwrap_or(false);
869                    let outcome = if exact && existing.resource.summary.state == "active" {
870                        ExactMutationOutcomeV1::AlreadySatisfied
871                    } else if exact {
872                        ExactMutationOutcomeV1::Indeterminate
873                    } else {
874                        ExactMutationOutcomeV1::Conflict
875                    };
876                    let result = ReconcileIndexExactResultV1 {
877                        outcome,
878                        name: request.name,
879                        locator: None,
880                        resource: Some(Resource {
881                            kind: "index".to_owned(),
882                            name: existing.resource.summary.name,
883                            incarnation: existing.resource.summary.incarnation,
884                        }),
885                        state: existing.resource.summary.state,
886                    };
887                    if outcome == ExactMutationOutcomeV1::Indeterminate {
888                        return Ok(execution(result, recovery));
889                    }
890                    return finish_index(&session, &mut recovery, result).await;
891                }
892                Err(ClientError::Server(error)) if error.code == ErrorCode::NotFound => {}
893                Err(error) => return Err(ExactWorkflowError::Client(error)),
894            }
895            if now >= options.effect_deadline_unix_millis {
896                let result = index_status(&request, &recovery, ExactMutationOutcomeV1::EffectDeadlineExpired);
897                return finish_index(&session, &mut recovery, result).await;
898            }
899            let prepared = self
900                .prepare_create_index(
901                    request.clone(),
902                    effect_options(now, options.effect_deadline_unix_millis, &options)?,
903                )
904                .await
905                .map_err(ExactWorkflowError::Client)?;
906            if prepared.locator.authority.is_empty()
907                || prepared.semantic_digest.validate().is_err()
908                || prepared.resource_spec != *request.resource_spec.as_ref().ok_or(ExactWorkflowError::InvalidInput)?
909            {
910                return Err(ExactWorkflowError::CoreShape);
911            }
912            session
913                .record(
914                    &mut recovery,
915                    ExactMutationTransition::Prepared {
916                        preparation: ExactPreparationV1::Index {
917                            locator: prepared.locator,
918                            expires_at: prepared.expires_at,
919                            semantic_digest: prepared.semantic_digest,
920                            resource_spec: Box::new(prepared.resource_spec),
921                        },
922                    },
923                )
924                .await?;
925        }
926        let (locator, expires_at, semantic_digest, resource_spec) = index_preparation(&recovery)?;
927        let expiry = parse_u64(&expires_at).ok_or(ExactWorkflowError::InvalidRecovery)?;
928        let submit_deadline = options.effect_deadline_unix_millis.min(expiry);
929        let may_submit = !matches!(
930            recovery.submission_disposition,
931            Some(
932                ExactSubmissionDispositionV1::Submitted
933                    | ExactSubmissionDispositionV1::Resolved
934                    | ExactSubmissionDispositionV1::KnownRetired
935                    | ExactSubmissionDispositionV1::Indeterminate
936            )
937        );
938        if may_submit && now < submit_deadline {
939            match self
940                .submit_catalog_action(
941                    SubmitCatalogActionRequest {
942                        locator: locator.clone(),
943                        semantic_digest,
944                        action_kind: create_index_action_kind(request.retained_event_identity).to_owned(),
945                        name: request.name.clone(),
946                        mapping: Some(request.mapping.clone()),
947                        resource_spec: Some(resource_spec),
948                        expected_incarnation: None,
949                    },
950                    effect_options(now, submit_deadline, &options)?,
951                )
952                .await
953            {
954                Ok(value) => {
955                    if value.locator != locator {
956                        return Err(ExactWorkflowError::CoreShape);
957                    }
958                    session
959                        .record(
960                            &mut recovery,
961                            ExactMutationTransition::SubmissionObserved {
962                                disposition: if value.submitted {
963                                    ExactSubmissionDispositionV1::Submitted
964                                } else {
965                                    ExactSubmissionDispositionV1::NotSubmitted
966                                },
967                            },
968                        )
969                        .await?;
970                }
971                Err(error) => {
972                    let disposition = ExactSubmissionDispositionV1::from(error.submission_disposition());
973                    session
974                        .record(
975                            &mut recovery,
976                            ExactMutationTransition::SubmissionObserved { disposition },
977                        )
978                        .await?;
979                    if disposition == ExactSubmissionDispositionV1::NotSubmitted {
980                        return Ok(execution(
981                            index_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
982                            recovery,
983                        ));
984                    }
985                }
986            }
987        }
988        let Some(resolve_options) = resolution_options(now, &options)? else {
989            return Ok(execution(
990                index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
991                recovery,
992            ));
993        };
994        let resolve_options = resolve_options
995            .with_index_name(request.name.clone())
996            .map_err(ExactWorkflowError::Client)?;
997        let value = match self
998            .resolve_catalog_action(
999                ResolveCatalogActionRequest {
1000                    locator: locator.clone(),
1001                },
1002                resolve_options,
1003            )
1004            .await
1005        {
1006            Ok(value) => value,
1007            Err(_) => {
1008                return Ok(execution(
1009                    index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1010                    recovery,
1011                ));
1012            }
1013        };
1014        if value.locator != locator {
1015            return Err(ExactWorkflowError::CoreShape);
1016        }
1017        let outcome = match value.state.as_str() {
1018            "applied" => ExactMutationOutcomeV1::Resolved,
1019            "failed" => ExactMutationOutcomeV1::Conflict,
1020            "expired_unknown" => ExactMutationOutcomeV1::KnownRetired,
1021            "prepared" => ExactMutationOutcomeV1::NotSubmitted,
1022            "pending" => ExactMutationOutcomeV1::Indeterminate,
1023            _ => return Err(ExactWorkflowError::CoreShape),
1024        };
1025        let mut result = ReconcileIndexExactResultV1 {
1026            outcome,
1027            name: request.name,
1028            locator: Some(value.locator),
1029            resource: value.resource,
1030            state: value.state,
1031        };
1032        if outcome == ExactMutationOutcomeV1::Resolved {
1033            let Some(verify_options) = resolution_options(now, &options)? else {
1034                result.outcome = ExactMutationOutcomeV1::Indeterminate;
1035                return Ok(execution(result, recovery));
1036            };
1037            let existing = match self
1038                .get_index(
1039                    GetIndexRequest {
1040                        name: result.name.clone(),
1041                        expected_incarnation: result.resource.as_ref().map(|value| value.incarnation.clone()),
1042                    },
1043                    verify_options,
1044                )
1045                .await
1046            {
1047                Ok(value) => value.resource,
1048                Err(_) => {
1049                    result.outcome = ExactMutationOutcomeV1::Indeterminate;
1050                    return Ok(execution(result, recovery));
1051                }
1052            };
1053            if existing.current_mapping != request.mapping
1054                || request.resource_spec.as_ref() != Some(&existing.resource_spec)
1055                || existing.summary.retained_event_identity != request.retained_event_identity.unwrap_or(false)
1056            {
1057                result.outcome = ExactMutationOutcomeV1::Conflict;
1058                result.state = "definition_conflict".to_owned();
1059            } else if existing.summary.state != "active" {
1060                result.outcome = ExactMutationOutcomeV1::Indeterminate;
1061                result.state = existing.summary.state;
1062                return Ok(execution(result, recovery));
1063            } else {
1064                result.resource = Some(Resource {
1065                    kind: "index".to_owned(),
1066                    name: existing.summary.name,
1067                    incarnation: existing.summary.incarnation,
1068                });
1069                result.state = "active".to_owned();
1070            }
1071        }
1072        if matches!(
1073            outcome,
1074            ExactMutationOutcomeV1::Indeterminate | ExactMutationOutcomeV1::NotSubmitted
1075        ) {
1076            Ok(execution(result, recovery))
1077        } else {
1078            finish_index(&session, &mut recovery, result).await
1079        }
1080    }
1081
1082    fn exact_profile_scope(&self) -> (Identity, Identity) {
1083        let profile = self.connection_profile().as_wire();
1084        (
1085            profile.expected_cluster_id.clone(),
1086            profile.expected_collection_id.clone(),
1087        )
1088    }
1089}
1090
1091async fn observe_append_visibility(
1092    client: &Client,
1093    request: &AppendExactRequestV1,
1094    mut result: AppendExactResultV1,
1095    options: CallOptions,
1096) -> Result<AppendExactResultV1, ExactWorkflowError> {
1097    let mut visibility = Vec::with_capacity(result.items.len());
1098    for item in &result.items {
1099        let Some(locator) = item.locator.clone() else {
1100            return Err(ExactWorkflowError::CoreShape);
1101        };
1102        match client
1103            .observe_append_committed(
1104                ObserveAppendCommittedRequest {
1105                    index: request.index.clone(),
1106                    locator,
1107                },
1108                options.clone(),
1109            )
1110            .await
1111        {
1112            Ok(value) => visibility.push(value.outcome),
1113            Err(_) => {
1114                result.outcome = ExactMutationOutcomeV1::Indeterminate;
1115                result.visibility = visibility;
1116                return Ok(result);
1117            }
1118        }
1119    }
1120    if visibility
1121        .iter()
1122        .any(|value| value.state != RetainedAppendObservationState::Visible)
1123    {
1124        result.outcome = ExactMutationOutcomeV1::Indeterminate;
1125    }
1126    result.visibility = visibility;
1127    Ok(result)
1128}
1129
1130fn exact_intent(
1131    mutation_kind: ExactMutationKindV1,
1132    request_sha256: Digest,
1133    cluster_id: Identity,
1134    collection_id: Identity,
1135    index: Option<Resource>,
1136    caller_action_id: Digest,
1137) -> ExactMutationIntent {
1138    ExactMutationIntent {
1139        format_version: EXACT_MUTATION_FORMAT_VERSION,
1140        mutation_kind,
1141        request_sha256,
1142        cluster_id,
1143        collection_id,
1144        index,
1145        caller_action_id,
1146    }
1147}
1148
1149async fn open_session(
1150    intent: &ExactMutationIntent,
1151    options: &ExactWorkflowOptions,
1152) -> Result<(ExactSession, ExactWorkflowRecoveryV1), ExactWorkflowError> {
1153    intent.validate()?;
1154    if options.effect_deadline_unix_millis == 0 || options.store.is_some() && options.recovery.is_some() {
1155        return Err(ExactWorkflowError::InvalidRecovery);
1156    }
1157    let (session, mut recovery) = if let Some(store) = &options.store {
1158        let replay = WorkflowJournal::open_or_create(store.path(), intent.clone()).await?;
1159        let (journal, transitions) = replay.into_parts();
1160        let mut recovery = recovery_from_intent(intent);
1161        for transition in transitions {
1162            reduce_transition(&mut recovery, &transition)?;
1163        }
1164        (ExactSession::Durable(journal), recovery)
1165    } else {
1166        let recovery = match &options.recovery {
1167            Some(value) => {
1168                validate_recovery_matches(value, intent)?;
1169                value.clone()
1170            }
1171            None => recovery_from_intent(intent),
1172        };
1173        (ExactSession::Ephemeral, recovery)
1174    };
1175    if parse_u64(&recovery.effect_deadline_unix_millis) == Some(0) {
1176        session
1177            .record(
1178                &mut recovery,
1179                ExactMutationTransition::DeadlineEstablished {
1180                    unix_millis: wire_u64(options.effect_deadline_unix_millis),
1181                },
1182            )
1183            .await?;
1184    }
1185    Ok((session, recovery))
1186}
1187
1188fn recovery_from_intent(intent: &ExactMutationIntent) -> ExactWorkflowRecoveryV1 {
1189    ExactWorkflowRecoveryV1 {
1190        format_version: intent.format_version,
1191        mutation_kind: intent.mutation_kind,
1192        request_sha256: intent.request_sha256.clone(),
1193        cluster_id: intent.cluster_id.clone(),
1194        collection_id: intent.collection_id.clone(),
1195        index: intent.index.clone(),
1196        caller_action_id: intent.caller_action_id.clone(),
1197        effect_deadline_unix_millis: wire_u64(0),
1198        last_observed_unix_millis: None,
1199        preparation: None,
1200        submission_disposition: None,
1201        terminal: None,
1202    }
1203}
1204
1205fn validate_recovery_matches(
1206    recovery: &ExactWorkflowRecoveryV1,
1207    intent: &ExactMutationIntent,
1208) -> Result<(), ExactWorkflowError> {
1209    let expected = recovery_from_intent(intent);
1210    if recovery.format_version != expected.format_version
1211        || recovery.mutation_kind != expected.mutation_kind
1212        || recovery.request_sha256 != expected.request_sha256
1213        || recovery.cluster_id != expected.cluster_id
1214        || recovery.collection_id != expected.collection_id
1215        || recovery.index != expected.index
1216        || recovery.caller_action_id != expected.caller_action_id
1217        || parse_u64(&recovery.effect_deadline_unix_millis).is_none_or(|value| value == 0)
1218        || recovery
1219            .last_observed_unix_millis
1220            .as_ref()
1221            .is_some_and(|value| parse_u64(value).is_none())
1222        || recovery
1223            .preparation
1224            .as_ref()
1225            .is_some_and(|value| !valid_preparation(value))
1226        || recovery
1227            .preparation
1228            .as_ref()
1229            .is_some_and(|value| preparation_kind(value) != recovery.mutation_kind)
1230        || recovery.terminal.as_ref().is_some_and(|value| !valid_terminal(value))
1231        || recovery
1232            .terminal
1233            .as_ref()
1234            .is_some_and(|value| terminal_kind(value) != recovery.mutation_kind)
1235        || recovery.submission_disposition.is_some() && recovery.preparation.is_none()
1236    {
1237        return Err(ExactWorkflowError::InvalidRecovery);
1238    }
1239    Ok(())
1240}
1241
1242fn reduce_transition(
1243    state: &mut ExactWorkflowRecoveryV1,
1244    transition: &ExactMutationTransition,
1245) -> Result<(), ExactWorkflowError> {
1246    match transition {
1247        ExactMutationTransition::DeadlineEstablished { unix_millis }
1248            if parse_u64(&state.effect_deadline_unix_millis) == Some(0)
1249                && parse_u64(unix_millis).is_some_and(|value| value > 0) =>
1250        {
1251            state.effect_deadline_unix_millis = unix_millis.clone();
1252        }
1253        ExactMutationTransition::ClockObserved { unix_millis } => {
1254            let next = parse_u64(unix_millis).ok_or(ExactWorkflowError::InvalidRecovery)?;
1255            if state
1256                .last_observed_unix_millis
1257                .as_ref()
1258                .and_then(parse_u64)
1259                .is_some_and(|prior| next < prior)
1260            {
1261                return Err(ExactWorkflowError::InvalidRecovery);
1262            }
1263            state.last_observed_unix_millis = Some(unix_millis.clone());
1264        }
1265        ExactMutationTransition::Prepared { preparation }
1266            if state.preparation.is_none()
1267                && state.terminal.is_none()
1268                && preparation_kind(preparation) == state.mutation_kind =>
1269        {
1270            state.preparation = Some(preparation.clone());
1271        }
1272        ExactMutationTransition::SubmissionObserved { disposition }
1273            if state.preparation.is_some()
1274                && state.terminal.is_none()
1275                && !matches!(
1276                    state.submission_disposition,
1277                    Some(
1278                        ExactSubmissionDispositionV1::Submitted
1279                            | ExactSubmissionDispositionV1::Resolved
1280                            | ExactSubmissionDispositionV1::KnownRetired
1281                            | ExactSubmissionDispositionV1::Indeterminate
1282                    )
1283                ) =>
1284        {
1285            state.submission_disposition = Some(*disposition);
1286        }
1287        ExactMutationTransition::Terminal { result }
1288            if state.terminal.is_none() && terminal_kind(result) == state.mutation_kind =>
1289        {
1290            state.terminal = Some(result.clone());
1291        }
1292        _ => return Err(ExactWorkflowError::InvalidRecovery),
1293    }
1294    Ok(())
1295}
1296
1297async fn observe_clock(
1298    session: &ExactSession,
1299    recovery: &mut ExactWorkflowRecoveryV1,
1300    options: &ExactWorkflowOptions,
1301) -> Result<u64, ExactWorkflowError> {
1302    let now = options.clock.unix_millis()?;
1303    if recovery
1304        .last_observed_unix_millis
1305        .as_ref()
1306        .and_then(parse_u64)
1307        .is_some_and(|prior| now < prior)
1308    {
1309        return Ok(now);
1310    }
1311    session
1312        .record(
1313            recovery,
1314            ExactMutationTransition::ClockObserved {
1315                unix_millis: wire_u64(now),
1316            },
1317        )
1318        .await?;
1319    Ok(now)
1320}
1321
1322fn clock_regressed(recovery: &ExactWorkflowRecoveryV1, now: u64) -> bool {
1323    recovery
1324        .last_observed_unix_millis
1325        .as_ref()
1326        .and_then(parse_u64)
1327        .is_some_and(|observed| now < observed)
1328}
1329
1330fn recovery_effect_deadline(recovery: &ExactWorkflowRecoveryV1) -> Result<u64, ExactWorkflowError> {
1331    parse_u64(&recovery.effect_deadline_unix_millis)
1332        .filter(|value| *value > 0)
1333        .ok_or(ExactWorkflowError::InvalidRecovery)
1334}
1335
1336fn effect_options(now: u64, deadline: u64, options: &ExactWorkflowOptions) -> Result<CallOptions, ExactWorkflowError> {
1337    bounded_options(now, deadline, options)
1338}
1339
1340fn resolution_options(now: u64, options: &ExactWorkflowOptions) -> Result<Option<CallOptions>, ExactWorkflowError> {
1341    let deadline = if now < options.effect_deadline_unix_millis {
1342        options.effect_deadline_unix_millis
1343    } else {
1344        match options.observation_deadline_unix_millis {
1345            Some(value) if value > now => value,
1346            _ => return Ok(None),
1347        }
1348    };
1349    bounded_options(now, deadline, options).map(Some)
1350}
1351
1352fn bounded_options(now: u64, deadline: u64, options: &ExactWorkflowOptions) -> Result<CallOptions, ExactWorkflowError> {
1353    if deadline <= now {
1354        return Err(ExactWorkflowError::InvalidInput);
1355    }
1356    let bounded = deadline.min(now.saturating_add(MAX_EFFECT_CALL_MILLIS));
1357    CallOptions::from_absolute_deadline_unix_millis(bounded)
1358        .map(|value| value.with_cancellation(options.cancellation.clone()))
1359        .map_err(ExactWorkflowError::Client)
1360}
1361
1362fn validate_append_request(request: &AppendExactRequestV1) -> Result<(), ExactWorkflowError> {
1363    if request.batch_id.validate().is_err()
1364        || !valid_index(&request.index)
1365        || request.items.is_empty()
1366        || request.items.len() > 1_000
1367        || request.items.iter().any(|item| {
1368            item.item_action_id.validate().is_err() || item.document_id.is_empty() || item.document_id.len() > 512
1369        })
1370    {
1371        return Err(ExactWorkflowError::InvalidInput);
1372    }
1373    let mut identities = std::collections::BTreeSet::new();
1374    if request
1375        .items
1376        .iter()
1377        .any(|item| !identities.insert(item.item_action_id.value.as_str()))
1378    {
1379        return Err(ExactWorkflowError::InvalidInput);
1380    }
1381    Ok(())
1382}
1383
1384fn validate_append_outcome(
1385    request: &AppendExactRequestV1,
1386    outcome: &RetainedAppendBulkOutcome,
1387    prepared: bool,
1388) -> Result<(), ExactWorkflowError> {
1389    if outcome.batch_id != request.batch_id
1390        || outcome.index != request.index
1391        || outcome.items.len() != request.items.len()
1392    {
1393        return Err(ExactWorkflowError::CoreShape);
1394    }
1395    for (expected, observed) in request.items.iter().zip(&outcome.items) {
1396        if observed.item_action_id != expected.item_action_id
1397            || observed
1398                .locator
1399                .as_ref()
1400                .is_some_and(|value| value.authority.is_empty() || value.value.is_empty())
1401            || observed
1402                .semantic_digest
1403                .as_ref()
1404                .is_some_and(|value| value.validate().is_err())
1405            || prepared
1406                && (observed.state != RetainedAppendState::Prepared
1407                    || observed.retry_safety != RetainedAppendRetrySafety::Safe
1408                    || observed.locator.is_none()
1409                    || observed.semantic_digest.is_none()
1410                    || observed.sequence.is_some()
1411                    || observed.primary_term.is_some()
1412                    || observed.error.is_some())
1413        {
1414            return Err(ExactWorkflowError::CoreShape);
1415        }
1416    }
1417    Ok(())
1418}
1419
1420fn redact_append_outcome(mut outcome: RetainedAppendBulkOutcome) -> RetainedAppendBulkOutcome {
1421    for item in &mut outcome.items {
1422        item.error = None;
1423    }
1424    outcome
1425}
1426
1427fn append_preparation(recovery: &ExactWorkflowRecoveryV1) -> Result<&RetainedAppendBulkOutcome, ExactWorkflowError> {
1428    match &recovery.preparation {
1429        Some(ExactPreparationV1::Append(value)) => Ok(value),
1430        _ => Err(ExactWorkflowError::InvalidRecovery),
1431    }
1432}
1433
1434fn document_preparation(recovery: &ExactWorkflowRecoveryV1) -> Result<(Locator, Digest), ExactWorkflowError> {
1435    match &recovery.preparation {
1436        Some(ExactPreparationV1::Document {
1437            locator,
1438            semantic_digest,
1439        }) => Ok((locator.clone(), semantic_digest.clone())),
1440        _ => Err(ExactWorkflowError::InvalidRecovery),
1441    }
1442}
1443
1444fn index_preparation(
1445    recovery: &ExactWorkflowRecoveryV1,
1446) -> Result<(Locator, U64, Digest, IndexResourceSpec), ExactWorkflowError> {
1447    match &recovery.preparation {
1448        Some(ExactPreparationV1::Index {
1449            locator,
1450            expires_at,
1451            semantic_digest,
1452            resource_spec,
1453        }) => Ok((
1454            locator.clone(),
1455            expires_at.clone(),
1456            semantic_digest.clone(),
1457            resource_spec.as_ref().clone(),
1458        )),
1459        _ => Err(ExactWorkflowError::InvalidRecovery),
1460    }
1461}
1462
1463fn append_terminal_result(
1464    request: &AppendExactRequestV1,
1465    outcome: &RetainedAppendBulkOutcome,
1466    recovered: bool,
1467) -> Option<AppendExactResultV1> {
1468    let state = if outcome
1469        .items
1470        .iter()
1471        .any(|item| item.state == RetainedAppendState::SubmittedIndeterminate)
1472    {
1473        return None;
1474    } else if outcome
1475        .items
1476        .iter()
1477        .any(|item| item.state == RetainedAppendState::Conflict)
1478    {
1479        ExactMutationOutcomeV1::Conflict
1480    } else if outcome
1481        .items
1482        .iter()
1483        .any(|item| item.state == RetainedAppendState::Retired)
1484    {
1485        ExactMutationOutcomeV1::KnownRetired
1486    } else if outcome.items.iter().any(|item| {
1487        matches!(
1488            item.state,
1489            RetainedAppendState::NotSubmittedRetryable | RetainedAppendState::NotSubmittedTerminal
1490        )
1491    }) {
1492        ExactMutationOutcomeV1::NotSubmitted
1493    } else if outcome.items.iter().all(|item| {
1494        matches!(
1495            item.state,
1496            RetainedAppendState::Committed | RetainedAppendState::Replayed
1497        )
1498    }) {
1499        if recovered {
1500            ExactMutationOutcomeV1::Resolved
1501        } else {
1502            ExactMutationOutcomeV1::Applied
1503        }
1504    } else {
1505        return None;
1506    };
1507    Some(append_result(request, outcome, state))
1508}
1509
1510fn append_result(
1511    request: &AppendExactRequestV1,
1512    outcome: &RetainedAppendBulkOutcome,
1513    state: ExactMutationOutcomeV1,
1514) -> AppendExactResultV1 {
1515    AppendExactResultV1 {
1516        outcome: state,
1517        batch_id: request.batch_id.clone(),
1518        index: request.index.clone(),
1519        items: outcome.items.iter().map(redact_append_item).collect(),
1520        visibility: Vec::new(),
1521    }
1522}
1523
1524fn append_status(
1525    request: &AppendExactRequestV1,
1526    recovery: &ExactWorkflowRecoveryV1,
1527    state: ExactMutationOutcomeV1,
1528) -> AppendExactResultV1 {
1529    let items = append_preparation(recovery)
1530        .map(|value| value.items.iter().map(redact_append_item).collect())
1531        .unwrap_or_default();
1532    AppendExactResultV1 {
1533        outcome: state,
1534        batch_id: request.batch_id.clone(),
1535        index: request.index.clone(),
1536        items,
1537        visibility: Vec::new(),
1538    }
1539}
1540
1541fn redact_append_item(item: &RetainedAppendItemOutcome) -> AppendExactItemResultV1 {
1542    AppendExactItemResultV1 {
1543        item_action_id: item.item_action_id.clone(),
1544        locator: item.locator.clone(),
1545        semantic_digest: item.semantic_digest.clone(),
1546        state: item.state,
1547        retry_safety: item.retry_safety,
1548        sequence: item.sequence.clone(),
1549        primary_term: item.primary_term.clone(),
1550    }
1551}
1552
1553fn document_status(
1554    request: &PrepareDocumentMutationRequest,
1555    recovery: &ExactWorkflowRecoveryV1,
1556    outcome: ExactMutationOutcomeV1,
1557) -> DocumentMutationExactResultV1 {
1558    let locator = document_preparation(recovery).map(|value| value.0).ok();
1559    DocumentMutationExactResultV1 {
1560        outcome,
1561        index: request.index.clone(),
1562        locator,
1563        state: outcome_name(outcome).to_owned(),
1564        submitted: recovery
1565            .submission_disposition
1566            .is_some_and(|value| !matches!(value, ExactSubmissionDispositionV1::NotSubmitted)),
1567        sequence: None,
1568        mapping_generation: None,
1569        mapping_adopted: false,
1570    }
1571}
1572
1573fn index_status(
1574    request: &PrepareCreateIndexRequest,
1575    recovery: &ExactWorkflowRecoveryV1,
1576    outcome: ExactMutationOutcomeV1,
1577) -> ReconcileIndexExactResultV1 {
1578    let locator = match &recovery.preparation {
1579        Some(ExactPreparationV1::Index { locator, .. }) => Some(locator.clone()),
1580        _ => None,
1581    };
1582    ReconcileIndexExactResultV1 {
1583        outcome,
1584        name: request.name.clone(),
1585        locator,
1586        resource: None,
1587        state: outcome_name(outcome).to_owned(),
1588    }
1589}
1590
1591const fn outcome_name(outcome: ExactMutationOutcomeV1) -> &'static str {
1592    match outcome {
1593        ExactMutationOutcomeV1::AlreadySatisfied => "already_satisfied",
1594        ExactMutationOutcomeV1::Applied => "applied",
1595        ExactMutationOutcomeV1::Resolved => "resolved",
1596        ExactMutationOutcomeV1::Conflict => "conflict",
1597        ExactMutationOutcomeV1::NotSubmitted => "not_submitted",
1598        ExactMutationOutcomeV1::Indeterminate => "indeterminate",
1599        ExactMutationOutcomeV1::KnownRetired => "known_retired",
1600        ExactMutationOutcomeV1::EffectDeadlineExpired => "effect_deadline_expired",
1601        ExactMutationOutcomeV1::ClockRegression => "clock_regression",
1602    }
1603}
1604
1605async fn finish_append(
1606    session: &ExactSession,
1607    recovery: &mut ExactWorkflowRecoveryV1,
1608    result: AppendExactResultV1,
1609) -> Result<ExactExecutionV1<AppendExactResultV1>, ExactWorkflowError> {
1610    session
1611        .record(
1612            recovery,
1613            ExactMutationTransition::Terminal {
1614                result: ExactTerminalResultV1::Append(result.clone()),
1615            },
1616        )
1617        .await?;
1618    Ok(execution(result, recovery.clone()))
1619}
1620
1621async fn finish_document(
1622    session: &ExactSession,
1623    recovery: &mut ExactWorkflowRecoveryV1,
1624    result: DocumentMutationExactResultV1,
1625) -> Result<ExactExecutionV1<DocumentMutationExactResultV1>, ExactWorkflowError> {
1626    session
1627        .record(
1628            recovery,
1629            ExactMutationTransition::Terminal {
1630                result: ExactTerminalResultV1::Document(result.clone()),
1631            },
1632        )
1633        .await?;
1634    Ok(execution(result, recovery.clone()))
1635}
1636
1637async fn finish_index(
1638    session: &ExactSession,
1639    recovery: &mut ExactWorkflowRecoveryV1,
1640    result: ReconcileIndexExactResultV1,
1641) -> Result<ExactExecutionV1<ReconcileIndexExactResultV1>, ExactWorkflowError> {
1642    session
1643        .record(
1644            recovery,
1645            ExactMutationTransition::Terminal {
1646                result: ExactTerminalResultV1::Index(result.clone()),
1647            },
1648        )
1649        .await?;
1650    Ok(execution(result, recovery.clone()))
1651}
1652
1653fn execution<T>(result: T, recovery: ExactWorkflowRecoveryV1) -> ExactExecutionV1<T> {
1654    ExactExecutionV1 { result, recovery }
1655}
1656
1657fn valid_preparation(value: &ExactPreparationV1) -> bool {
1658    match value {
1659        ExactPreparationV1::Append(outcome) => {
1660            !outcome.items.is_empty()
1661                && outcome.items.len() <= 1_000
1662                && outcome.items.iter().all(|item| {
1663                    item.item_action_id.validate().is_ok()
1664                        && item
1665                            .locator
1666                            .as_ref()
1667                            .is_some_and(|locator| !locator.authority.is_empty() && !locator.value.is_empty())
1668                        && item
1669                            .semantic_digest
1670                            .as_ref()
1671                            .is_some_and(|digest| digest.validate().is_ok())
1672                        && item.state == RetainedAppendState::Prepared
1673                        && item.retry_safety == RetainedAppendRetrySafety::Safe
1674                        && item.error.is_none()
1675                })
1676        }
1677        ExactPreparationV1::Document {
1678            locator,
1679            semantic_digest,
1680        } => !locator.authority.is_empty() && !locator.value.is_empty() && semantic_digest.validate().is_ok(),
1681        ExactPreparationV1::Index {
1682            locator,
1683            expires_at,
1684            semantic_digest,
1685            ..
1686        } => {
1687            !locator.authority.is_empty()
1688                && !locator.value.is_empty()
1689                && parse_u64(expires_at).is_some_and(|value| value > 0)
1690                && semantic_digest.validate().is_ok()
1691        }
1692    }
1693}
1694
1695fn valid_terminal(value: &ExactTerminalResultV1) -> bool {
1696    match value {
1697        ExactTerminalResultV1::Append(result) => {
1698            result.batch_id.validate().is_ok()
1699                && valid_index(&result.index)
1700                && terminal_outcome(result.outcome)
1701                && result.items.iter().all(|item| item.item_action_id.validate().is_ok())
1702        }
1703        ExactTerminalResultV1::Document(result) => {
1704            valid_index(&result.index)
1705                && result
1706                    .locator
1707                    .as_ref()
1708                    .is_none_or(|locator| !locator.authority.is_empty() && !locator.value.is_empty())
1709                && terminal_outcome(result.outcome)
1710        }
1711        ExactTerminalResultV1::Index(result) => !result.name.is_empty() && terminal_outcome(result.outcome),
1712    }
1713}
1714
1715const fn terminal_outcome(value: ExactMutationOutcomeV1) -> bool {
1716    matches!(
1717        value,
1718        ExactMutationOutcomeV1::AlreadySatisfied
1719            | ExactMutationOutcomeV1::Applied
1720            | ExactMutationOutcomeV1::Resolved
1721            | ExactMutationOutcomeV1::Conflict
1722            | ExactMutationOutcomeV1::KnownRetired
1723            | ExactMutationOutcomeV1::EffectDeadlineExpired
1724    )
1725}
1726
1727const fn preparation_kind(value: &ExactPreparationV1) -> ExactMutationKindV1 {
1728    match value {
1729        ExactPreparationV1::Append(_) => ExactMutationKindV1::Append,
1730        ExactPreparationV1::Document { .. } => ExactMutationKindV1::Document,
1731        ExactPreparationV1::Index { .. } => ExactMutationKindV1::Index,
1732    }
1733}
1734
1735const fn terminal_kind(value: &ExactTerminalResultV1) -> ExactMutationKindV1 {
1736    match value {
1737        ExactTerminalResultV1::Append(_) => ExactMutationKindV1::Append,
1738        ExactTerminalResultV1::Document(_) => ExactMutationKindV1::Document,
1739        ExactTerminalResultV1::Index(_) => ExactMutationKindV1::Index,
1740    }
1741}
1742
1743fn valid_index(value: &Resource) -> bool {
1744    value.kind == "index" && !value.name.is_empty() && value.name.len() <= 255 && value.incarnation.validate().is_ok()
1745}
1746
1747fn canonical_sha256<T: Serialize>(value: &T) -> Result<Digest, ExactWorkflowError> {
1748    let bytes = serde_json::to_vec(value).map_err(|_| ExactWorkflowError::RequestCodec)?;
1749    let observed = digest::digest(&digest::SHA256, &bytes);
1750    Ok(Digest {
1751        value: lower_hex(observed.as_ref()),
1752    })
1753}
1754
1755fn parse_u64(value: &U64) -> Option<u64> {
1756    if value.value.is_empty() || value.value.len() > 20 || value.value.starts_with('0') && value.value != "0" {
1757        return None;
1758    }
1759    value.value.parse().ok()
1760}
1761
1762fn wire_u64(value: u64) -> U64 {
1763    U64 {
1764        value: value.to_string(),
1765    }
1766}
1767
1768fn lower_hex(bytes: &[u8]) -> String {
1769    const HEX: &[u8; 16] = b"0123456789abcdef";
1770    let mut output = String::with_capacity(bytes.len().saturating_mul(2));
1771    for byte in bytes {
1772        output.push(char::from(HEX[usize::from(byte >> 4)]));
1773        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1774    }
1775    output
1776}
1777
1778#[cfg(test)]
1779mod tests {
1780    use std::os::unix::fs::PermissionsExt as _;
1781
1782    use super::*;
1783
1784    fn identity(character: char) -> Identity {
1785        Identity {
1786            value: character.to_string().repeat(32),
1787        }
1788    }
1789
1790    fn digest(character: char) -> Digest {
1791        Digest {
1792            value: character.to_string().repeat(64),
1793        }
1794    }
1795
1796    fn resource() -> Resource {
1797        Resource {
1798            kind: "index".to_owned(),
1799            name: "events".to_owned(),
1800            incarnation: identity('a'),
1801        }
1802    }
1803
1804    #[test]
1805    fn retained_event_identity_preserves_the_catalog_submit_kind() {
1806        assert_eq!(create_index_action_kind(None), "create_index");
1807        assert_eq!(create_index_action_kind(Some(false)), "create_index");
1808        assert_eq!(create_index_action_kind(Some(true)), "create_retained_append_log_index");
1809    }
1810
1811    #[test]
1812    fn recovery_refuses_changed_request_or_scope_and_retains_first_deadline() -> Result<(), Box<dyn Error>> {
1813        let intent = exact_intent(
1814            ExactMutationKindV1::Append,
1815            digest('b'),
1816            identity('c'),
1817            identity('d'),
1818            Some(resource()),
1819            digest('e'),
1820        );
1821        let mut recovery = recovery_from_intent(&intent);
1822        reduce_transition(
1823            &mut recovery,
1824            &ExactMutationTransition::DeadlineEstablished {
1825                unix_millis: wire_u64(100),
1826            },
1827        )?;
1828        validate_recovery_matches(&recovery, &intent)?;
1829        assert_eq!(recovery_effect_deadline(&recovery)?, 100);
1830        let mut changed = intent.clone();
1831        changed.collection_id = identity('f');
1832        assert!(matches!(
1833            validate_recovery_matches(&recovery, &changed),
1834            Err(ExactWorkflowError::InvalidRecovery)
1835        ));
1836        changed = intent.clone();
1837        changed.request_sha256 = digest('f');
1838        assert!(matches!(
1839            validate_recovery_matches(&recovery, &changed),
1840            Err(ExactWorkflowError::InvalidRecovery)
1841        ));
1842        Ok(())
1843    }
1844
1845    #[test]
1846    fn reducer_never_regresses_clock_or_replaces_possible_submission() -> Result<(), Box<dyn Error>> {
1847        let intent = exact_intent(
1848            ExactMutationKindV1::Append,
1849            digest('b'),
1850            identity('c'),
1851            identity('d'),
1852            Some(resource()),
1853            digest('e'),
1854        );
1855        let mut state = recovery_from_intent(&intent);
1856        reduce_transition(
1857            &mut state,
1858            &ExactMutationTransition::DeadlineEstablished {
1859                unix_millis: wire_u64(100),
1860            },
1861        )?;
1862        reduce_transition(
1863            &mut state,
1864            &ExactMutationTransition::ClockObserved {
1865                unix_millis: wire_u64(10),
1866            },
1867        )?;
1868        assert!(
1869            reduce_transition(
1870                &mut state,
1871                &ExactMutationTransition::ClockObserved {
1872                    unix_millis: wire_u64(9),
1873                },
1874            )
1875            .is_err()
1876        );
1877        state.preparation = Some(ExactPreparationV1::Append(RetainedAppendBulkOutcome {
1878            batch_id: digest('e'),
1879            index: resource(),
1880            items: vec![RetainedAppendItemOutcome {
1881                item_action_id: digest('f'),
1882                locator: Some(Locator {
1883                    authority: "retained_append".to_owned(),
1884                    value: "01".to_owned(),
1885                }),
1886                semantic_digest: Some(digest('1')),
1887                state: RetainedAppendState::Prepared,
1888                retry_safety: RetainedAppendRetrySafety::Safe,
1889                sequence: None,
1890                primary_term: None,
1891                error: None,
1892            }],
1893            complete: true,
1894        }));
1895        reduce_transition(
1896            &mut state,
1897            &ExactMutationTransition::SubmissionObserved {
1898                disposition: ExactSubmissionDispositionV1::Indeterminate,
1899            },
1900        )?;
1901        assert!(
1902            reduce_transition(
1903                &mut state,
1904                &ExactMutationTransition::SubmissionObserved {
1905                    disposition: ExactSubmissionDispositionV1::NotSubmitted,
1906                },
1907            )
1908            .is_err()
1909        );
1910        Ok(())
1911    }
1912
1913    #[test]
1914    fn not_submitted_append_outcome_remains_recovery_resumable() -> Result<(), Box<dyn Error>> {
1915        let request = AppendExactRequestV1 {
1916            batch_id: digest('a'),
1917            index: resource(),
1918            items: vec![RetainedAppendItem {
1919                item_action_id: digest('b'),
1920                document_id: "event-1".to_owned(),
1921                source: lodestone_wire::RawJson {
1922                    bytes_base64: "e30=".to_owned(),
1923                },
1924            }],
1925            observe_committed: true,
1926        };
1927        let outcome = RetainedAppendBulkOutcome {
1928            batch_id: request.batch_id.clone(),
1929            index: request.index.clone(),
1930            items: vec![RetainedAppendItemOutcome {
1931                item_action_id: request.items[0].item_action_id.clone(),
1932                locator: Some(Locator {
1933                    authority: "retained_append".to_owned(),
1934                    value: "01".to_owned(),
1935                }),
1936                semantic_digest: Some(digest('c')),
1937                state: RetainedAppendState::NotSubmittedRetryable,
1938                retry_safety: RetainedAppendRetrySafety::Safe,
1939                sequence: None,
1940                primary_term: None,
1941                error: None,
1942            }],
1943            complete: false,
1944        };
1945
1946        let result = append_terminal_result(&request, &outcome, false).ok_or("not-submitted observation absent")?;
1947        assert_eq!(result.outcome, ExactMutationOutcomeV1::NotSubmitted);
1948        assert!(!terminal_outcome(result.outcome));
1949        Ok(())
1950    }
1951
1952    #[test]
1953    fn append_recovery_redacts_server_error_messages() {
1954        let outcome = RetainedAppendBulkOutcome {
1955            batch_id: digest('a'),
1956            index: resource(),
1957            items: vec![RetainedAppendItemOutcome {
1958                item_action_id: digest('b'),
1959                locator: Some(Locator {
1960                    authority: "retained_append".to_owned(),
1961                    value: "01".to_owned(),
1962                }),
1963                semantic_digest: Some(digest('c')),
1964                state: RetainedAppendState::Prepared,
1965                retry_safety: RetainedAppendRetrySafety::Safe,
1966                sequence: None,
1967                primary_term: None,
1968                error: None,
1969            }],
1970            complete: true,
1971        };
1972        assert!(redact_append_outcome(outcome).items[0].error.is_none());
1973    }
1974
1975    #[tokio::test]
1976    async fn durable_resume_retains_the_first_effect_deadline() -> Result<(), Box<dyn Error>> {
1977        let mut suffix = [0_u8; 8];
1978        getrandom::fill(&mut suffix)?;
1979        let parent =
1980            std::fs::canonicalize(std::env::temp_dir())?.join(format!("lodestone-exact-{}", lower_hex(&suffix)));
1981        std::fs::create_dir(&parent)?;
1982        std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700))?;
1983        let store = ExactWorkflowStore::new(parent.join("journal"));
1984        let intent = exact_intent(
1985            ExactMutationKindV1::Append,
1986            digest('b'),
1987            identity('c'),
1988            identity('d'),
1989            Some(resource()),
1990            digest('e'),
1991        );
1992        let first = ExactWorkflowOptions::new(100).with_store(store.clone());
1993        let (session, recovery) = open_session(&intent, &first).await?;
1994        assert_eq!(recovery_effect_deadline(&recovery)?, 100);
1995        drop(session);
1996
1997        let resumed = ExactWorkflowOptions::new(999).with_store(store);
1998        let (session, recovery) = open_session(&intent, &resumed).await?;
1999        assert_eq!(recovery_effect_deadline(&recovery)?, 100);
2000        drop(session);
2001        std::fs::remove_dir_all(&parent)?;
2002        Ok(())
2003    }
2004}