Skip to main content

acyclic_fs/
source.rs

1//! Native directory sources over the canonical checkout and watcher engines.
2
3use crate::foundation::{Digest, Epoch, Head, OperationId, ProposedCommit, Sequence};
4use crate::kernel::{
5    DurableSourceMode, DurableSourceState, PublishGenerationRequest, RebaseDecision, SourceFact,
6    SourceInvalidation, contextual_publication_fingerprint, decode_published_generation,
7    decode_source_fact, encode_source_fact, source_authority_id, volume_authority_id,
8};
9use crate::model::{CheckoutMode, GenerationSelector};
10use crate::path::PortablePath;
11use crate::{
12    AppendOutcome, AsyncAuthorityStore, AsyncObjectStore, CancellationToken, CaptureOptions,
13    CapturePolicy, Checkout, CheckoutCommitOutcome, CreateAuthorityOutcome, Fs, Generation,
14    IdempotencyKey, NativeWatch, NativeWatchOptions, ReplayLimit, WatchBatch,
15    WatchInvalidationReason, WorkBudget, Workspace,
16};
17use bytes::Bytes;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use thiserror::Error;
21use tokio::sync::Mutex;
22
23/// Whether an attached directory is fixed or continuously reconciled.
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum SourceMode {
26    /// Capture one exact baseline and require explicit rescans thereafter.
27    Pinned,
28    /// Track bounded native hints and fail closed when continuity is lost.
29    Tracking,
30}
31
32/// Exact bounded native-source configuration.
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct SourceOptions {
35    /// Source advancement behavior.
36    pub mode: SourceMode,
37    /// Maximum paths in one authenticated baseline or reconciliation batch.
38    pub maximum_paths: u32,
39    /// Maximum sparse data spans admitted per regular file.
40    pub maximum_extent_spans: u32,
41    /// Maximum pending native hints before continuity is invalidated.
42    pub maximum_queued_changes: u32,
43    /// Canonical portable prefixes omitted from capture and deletion inference.
44    pub excluded_paths: Vec<PortablePath>,
45}
46
47impl Default for SourceOptions {
48    fn default() -> Self {
49        Self {
50            mode: SourceMode::Tracking,
51            maximum_paths: 262_144,
52            maximum_extent_spans: 65_536,
53            maximum_queued_changes: 65_536,
54            excluded_paths: Vec::new(),
55        }
56    }
57}
58
59/// Durable semantic source state exposed to callers.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum SourceState {
62    /// Workspace and attached directory agree at the acknowledged cursor.
63    Clean,
64    /// A bounded host interval is being authenticated and captured.
65    PendingCapture,
66    /// Native continuity was lost; automatic advancement is stopped.
67    NeedsRescan(WatchInvalidationReason),
68    /// Source changes overlap independent workspace publication; reattachment
69    /// is required to establish a new acknowledged base.
70    Conflict,
71    /// The returned generation is independent of this source.
72    Sealed,
73}
74
75/// Terminal source reconciliation result.
76pub enum ReconcileOutcome<A, O> {
77    /// Source changes, if any, are durably represented by this generation.
78    Clean(Generation<A, O>),
79    /// Continuity is lost and an explicit full rescan is required.
80    NeedsRescan(WatchInvalidationReason),
81    /// Concurrent workspace changes overlap source capture; the source remains
82    /// fail-closed until it is reattached.
83    Conflict,
84}
85
86/// Fail-closed attached-source failure.
87#[derive(Debug, Error)]
88pub enum SourceError {
89    /// Source configuration is empty or exceeds workspace bounds.
90    #[error("source options are invalid")]
91    InvalidOptions,
92    /// Pinned sources do not admit automatic reconciliation.
93    #[error("pinned source requires an explicit rescan")]
94    Pinned,
95    /// An existing durable source is bound to different semantics or identity.
96    #[error("source binding does not match the durable workspace source")]
97    BindingMismatch,
98    /// Another process changed source ownership or state first.
99    #[error("source state changed concurrently")]
100    Concurrent,
101    /// Source and workspace histories conflict; reattachment is required.
102    #[error("source conflicts with the workspace and must be reattached")]
103    Conflict,
104    /// A retry identity is already bound to another source operation.
105    #[error("source operation identity is already bound to another request")]
106    IdempotencyConflict,
107    /// Native watcher, capture, authority, or immutable storage failed.
108    #[error("source operation failed: {0}")]
109    Engine(String),
110}
111
112#[derive(Clone, Copy)]
113enum SourceOperation {
114    Reconcile,
115    Rescan,
116    Seal,
117}
118
119#[derive(Clone, Copy)]
120enum SourceOperationStage {
121    Begin,
122    Terminal,
123}
124
125struct RecoveredSourceOperation {
126    fact: SourceFact,
127    head: Head,
128}
129
130struct SourceSession<A, O> {
131    workspace: Workspace<A, O>,
132    checkout: Checkout<A, O>,
133    watcher: NativeWatch,
134    capture: CaptureOptions,
135    capture_policy: CapturePolicy,
136    mode: SourceMode,
137    maximum_queued_changes: u32,
138    state: SourceState,
139    authority_head: Head,
140}
141
142/// Cloneable handle to one exact attached native directory.
143pub struct Source<A, O> {
144    inner: Arc<Mutex<SourceSession<A, O>>>,
145}
146
147impl<A, O> Clone for Source<A, O> {
148    fn clone(&self) -> Self {
149        Self {
150            inner: Arc::clone(&self.inner),
151        }
152    }
153}
154
155impl<A: AsyncAuthorityStore, O: AsyncObjectStore> Source<A, O> {
156    /// Returns the current fail-closed semantic state.
157    pub async fn state(&self) -> SourceState {
158        self.inner.lock().await.state
159    }
160
161    /// Re-establishes a clean source baseline after the core materializer has
162    /// installed a published workspace generation into the attached root.
163    ///
164    /// A fresh checkout is opened at authenticated workspace HEAD and a full
165    /// bounded rescan proves the resulting host tree. Any concurrent host
166    /// change is captured as a later generation instead of being discarded.
167    pub async fn acknowledge_materialization(
168        &self,
169        key: IdempotencyKey,
170    ) -> Result<ReconcileOutcome<A, O>, SourceError> {
171        let mut session = self.inner.lock().await;
172        refresh_source_authority(&mut session).await?;
173        session.checkout = session
174            .workspace
175            .volume
176            .checkout(
177                GenerationSelector::Head,
178                CheckoutMode::tracking_transaction(),
179                WorkBudget::UNBOUNDED,
180                &CancellationToken::new(),
181            )
182            .await
183            .map_err(engine)?
184            .value;
185        session.state = SourceState::NeedsRescan(WatchInvalidationReason::InitialSnapshotRequired);
186        begin_source_operation(&mut session, key, SourceOperation::Rescan).await?;
187        rescan_session(
188            &mut session,
189            key,
190            SourceOperation::Rescan,
191            SourceState::Clean,
192        )
193        .await
194    }
195
196    /// Captures one bounded contiguous watcher interval and publishes it.
197    /// A clean interval still advances authority ordering to atomically prove
198    /// that its acknowledged workspace generation was current.
199    ///
200    /// # Errors
201    ///
202    /// Rejects pinned sources and propagates exact native/canonical failures.
203    pub async fn reconcile(&self) -> Result<ReconcileOutcome<A, O>, SourceError> {
204        self.reconcile_with_key(IdempotencyKey::new()).await
205    }
206
207    /// Captures and publishes one watcher interval under a stable retry key.
208    ///
209    /// An exact retry returns the original durable outcome, including after a
210    /// process restart. Reusing the key for another lifecycle operation is an
211    /// idempotency conflict.
212    ///
213    /// # Errors
214    ///
215    /// Rejects pinned sources, conflicting retry identities, and exact
216    /// native/canonical failures.
217    pub async fn reconcile_with_key(
218        &self,
219        key: IdempotencyKey,
220    ) -> Result<ReconcileOutcome<A, O>, SourceError> {
221        let mut session = self.inner.lock().await;
222        if let Some(outcome) =
223            replay_reconcile_operation(&mut session, key, SourceOperation::Reconcile).await?
224        {
225            return Ok(outcome);
226        }
227        if session.state == SourceState::Conflict {
228            return Ok(ReconcileOutcome::Conflict);
229        }
230        if session.mode == SourceMode::Pinned {
231            return Err(SourceError::Pinned);
232        }
233        begin_source_operation(&mut session, key, SourceOperation::Reconcile).await?;
234        let maximum_paths = session.capture.maximum_paths;
235        let batch = session
236            .watcher
237            .poll(
238                maximum_paths,
239                WorkBudget::UNBOUNDED,
240                &CancellationToken::new(),
241            )
242            .map_err(engine)?
243            .value;
244        if let WatchBatch::RescanRequired { reason, .. } = batch {
245            let fact = persist_source_operation(
246                &mut session,
247                SourceState::NeedsRescan(reason),
248                key,
249                SourceOperation::Reconcile,
250            )
251            .await?;
252            return reconcile_outcome_from_fact(&session, fact);
253        }
254        let capture = session.capture.clone();
255        let policy = session.capture_policy.clone();
256        crate::capture_watch_batch_with_policy(
257            &mut session.checkout,
258            batch,
259            &capture,
260            &policy,
261            WorkBudget::UNBOUNDED,
262            &CancellationToken::new(),
263        )
264        .await
265        .map_err(engine)?;
266        publish_session(
267            &mut session,
268            key,
269            SourceOperation::Reconcile,
270            SourceState::Clean,
271        )
272        .await
273    }
274
275    /// Rebuilds one complete authenticated baseline while preserving events
276    /// that arrive concurrently with the scan.
277    ///
278    /// # Errors
279    ///
280    /// Returns native, capture, authority, or storage failures without
281    /// acknowledging an incomplete interval.
282    pub async fn rescan(&self) -> Result<ReconcileOutcome<A, O>, SourceError> {
283        self.rescan_with_key(IdempotencyKey::new()).await
284    }
285
286    /// Rebuilds a complete baseline under a stable retry key.
287    ///
288    /// # Errors
289    ///
290    /// Returns conflicting retry identities and exact native/canonical
291    /// failures.
292    pub async fn rescan_with_key(
293        &self,
294        key: IdempotencyKey,
295    ) -> Result<ReconcileOutcome<A, O>, SourceError> {
296        let mut session = self.inner.lock().await;
297        if let Some(outcome) =
298            replay_reconcile_operation(&mut session, key, SourceOperation::Rescan).await?
299        {
300            return Ok(outcome);
301        }
302        if session.state == SourceState::Conflict {
303            return Ok(ReconcileOutcome::Conflict);
304        }
305        begin_source_operation(&mut session, key, SourceOperation::Rescan).await?;
306        rescan_session(
307            &mut session,
308            key,
309            SourceOperation::Rescan,
310            SourceState::Clean,
311        )
312        .await
313    }
314
315    /// Produces an immutable generation whose bytes no longer depend on the
316    /// attached directory.
317    ///
318    /// # Errors
319    ///
320    /// Fails unless a complete authenticated rescan and publication succeeds.
321    pub async fn seal(&self) -> Result<Generation<A, O>, SourceError> {
322        self.seal_with_key(IdempotencyKey::new()).await
323    }
324
325    /// Seals the source under a stable retry key.
326    ///
327    /// # Errors
328    ///
329    /// Returns conflicting retry identities and fails unless the original or
330    /// recovered operation reached a clean sealed generation.
331    pub async fn seal_with_key(
332        &self,
333        key: IdempotencyKey,
334    ) -> Result<Generation<A, O>, SourceError> {
335        let mut session = self.inner.lock().await;
336        if let Some(generation) = replay_seal_operation(&mut session, key).await? {
337            return Ok(generation);
338        }
339        if session.state == SourceState::Conflict {
340            return Err(SourceError::Conflict);
341        }
342        begin_source_operation(&mut session, key, SourceOperation::Seal).await?;
343        let generation = match rescan_session(
344            &mut session,
345            key,
346            SourceOperation::Seal,
347            SourceState::Sealed,
348        )
349        .await?
350        {
351            ReconcileOutcome::Clean(generation) => generation,
352            ReconcileOutcome::Conflict => return Err(SourceError::Conflict),
353            ReconcileOutcome::NeedsRescan(_) => {
354                return Err(SourceError::Engine(
355                    "source could not reach a clean sealed state".to_owned(),
356                ));
357            }
358        };
359        Ok(generation)
360    }
361}
362
363async fn rescan_session<A: AsyncAuthorityStore, O: AsyncObjectStore>(
364    session: &mut SourceSession<A, O>,
365    key: IdempotencyKey,
366    operation: SourceOperation,
367    terminal_state: SourceState,
368) -> Result<ReconcileOutcome<A, O>, SourceError> {
369    let trailing = {
370        let mut attempt = 0;
371        loop {
372            session.watcher.begin_rescan().map_err(engine)?;
373            let policy = session.capture_policy.clone();
374            let capture = crate::capture_baseline_with_policy(
375                &mut session.checkout,
376                &session.capture,
377                &policy,
378                WorkBudget::UNBOUNDED,
379                &CancellationToken::new(),
380            )
381            .await;
382            if let Err(error) = capture {
383                let _ = session
384                    .watcher
385                    .abort_rescan(WatchInvalidationReason::BackendError);
386                let _ = persist_session_state(
387                    session,
388                    SourceState::NeedsRescan(WatchInvalidationReason::BackendError),
389                )
390                .await;
391                discard_unpublished_capture(session).await?;
392                return Err(engine(error));
393            }
394            let batch = session.watcher.finish_rescan().map_err(engine)?;
395            if matches!(
396                batch,
397                WatchBatch::RescanRequired {
398                    reason: WatchInvalidationReason::NativeRescanRequired,
399                    ..
400                }
401            ) && attempt < 3
402            {
403                // Native backends may deliver a coalesced hint from before
404                // this scan after it completes. Never publish the partial
405                // checkout; retry the baseline under the same operation key.
406                discard_unpublished_capture(session).await?;
407                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
408                attempt += 1;
409                continue;
410            }
411            break batch;
412        }
413    };
414    if let WatchBatch::RescanRequired { reason, .. } = trailing {
415        let fact =
416            persist_source_operation(session, SourceState::NeedsRescan(reason), key, operation)
417                .await?;
418        discard_unpublished_capture(session).await?;
419        return reconcile_outcome_from_fact(session, fact);
420    }
421    let capture = session.capture.clone();
422    let policy = session.capture_policy.clone();
423    crate::capture_watch_batch_with_policy(
424        &mut session.checkout,
425        trailing,
426        &capture,
427        &policy,
428        WorkBudget::UNBOUNDED,
429        &CancellationToken::new(),
430    )
431    .await
432    .map_err(engine)?;
433    publish_session(session, key, operation, terminal_state).await
434}
435
436async fn discard_unpublished_capture<A: AsyncAuthorityStore, O: AsyncObjectStore>(
437    session: &mut SourceSession<A, O>,
438) -> Result<(), SourceError> {
439    let base = session.checkout.generation_id();
440    session.checkout = session
441        .workspace
442        .volume
443        .checkout(
444            GenerationSelector::Exact(base),
445            CheckoutMode::tracking_transaction(),
446            WorkBudget::UNBOUNDED,
447            &CancellationToken::new(),
448        )
449        .await
450        .map_err(engine)?
451        .value;
452    Ok(())
453}
454
455async fn publish_session<A: AsyncAuthorityStore, O: AsyncObjectStore>(
456    session: &mut SourceSession<A, O>,
457    key: IdempotencyKey,
458    operation: SourceOperation,
459    terminal_state: SourceState,
460) -> Result<ReconcileOutcome<A, O>, SourceError> {
461    let had_pending_mutations = session.checkout.has_pending_mutations();
462    let operation_id = source_volume_operation_id(key, operation);
463    let operation_context = source_volume_operation_context(key, operation);
464    let outcome = session
465        .checkout
466        .commit_with_context(
467            operation_id,
468            operation_context,
469            WorkBudget::UNBOUNDED,
470            &CancellationToken::new(),
471        )
472        .await
473        .map_err(engine)?
474        .value;
475    match outcome {
476        CheckoutCommitOutcome::Committed { generation_id, .. }
477        | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => {
478            persist_generation_outcome(session, terminal_state, generation_id, key, operation).await
479        }
480        CheckoutCommitOutcome::Conflict { .. } if !had_pending_mutations => {
481            persist_state_outcome(session, SourceState::Conflict, key, operation).await
482        }
483        CheckoutCommitOutcome::Conflict { actual } => {
484            if conflict_head_reuses_checkout_base(
485                &session.workspace,
486                session.checkout.generation_id(),
487                actual,
488            )
489            .await?
490            {
491                return persist_state_outcome(session, SourceState::Conflict, key, operation).await;
492            }
493            let rebased = rebase_source_checkout(session).await?;
494            if matches!(rebased, RebaseDecision::Conflicted { .. }) {
495                return persist_state_outcome(session, SourceState::Conflict, key, operation).await;
496            }
497            let retried = session
498                .checkout
499                .commit_with_context(
500                    operation_id,
501                    operation_context,
502                    WorkBudget::UNBOUNDED,
503                    &CancellationToken::new(),
504                )
505                .await
506                .map_err(engine)?
507                .value;
508            match retried {
509                CheckoutCommitOutcome::Committed { generation_id, .. }
510                | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => {
511                    persist_generation_outcome(
512                        session,
513                        terminal_state,
514                        generation_id,
515                        key,
516                        operation,
517                    )
518                    .await
519                }
520                CheckoutCommitOutcome::Conflict { .. } | CheckoutCommitOutcome::Fenced { .. } => {
521                    persist_state_outcome(session, SourceState::Conflict, key, operation).await
522                }
523                CheckoutCommitOutcome::IdempotencyConflict { .. } => {
524                    Err(SourceError::IdempotencyConflict)
525                }
526            }
527        }
528        CheckoutCommitOutcome::Fenced { .. } => {
529            persist_state_outcome(session, SourceState::Conflict, key, operation).await
530        }
531        CheckoutCommitOutcome::IdempotencyConflict { .. } => Err(SourceError::IdempotencyConflict),
532    }
533}
534
535async fn conflict_head_reuses_checkout_base<A: AsyncAuthorityStore, O: AsyncObjectStore>(
536    workspace: &Workspace<A, O>,
537    checkout_generation: crate::GenerationId,
538    actual: Head,
539) -> Result<bool, SourceError> {
540    let previous_sequence = actual.sequence.get().checked_sub(1).ok_or_else(|| {
541        SourceError::Engine("conflicting volume head has no durable record".to_owned())
542    })?;
543    let records = workspace
544        .volume
545        .fs
546        .authority()
547        .replay(
548            volume_authority_id(workspace.volume.id()),
549            Sequence::new(previous_sequence),
550            ReplayLimit {
551                records: 1,
552                payload_bytes: 4096,
553            },
554            WorkBudget::UNBOUNDED,
555            &CancellationToken::new(),
556        )
557        .await
558        .map_err(engine)?
559        .value;
560    let record = records.first().ok_or_else(|| {
561        SourceError::Engine("conflicting volume head has no durable record".to_owned())
562    })?;
563    if record.epoch != actual.epoch
564        || record.sequence != actual.sequence
565        || record.digest != actual.digest
566    {
567        return Err(SourceError::Engine(
568            "conflicting volume head does not match durable history".to_owned(),
569        ));
570    }
571    let published = decode_published_generation(&record.payload, 4096).map_err(engine)?;
572    if published.volume_id != workspace.volume.id() {
573        return Err(SourceError::Engine(
574            "conflicting volume publication belongs to another volume".to_owned(),
575        ));
576    }
577    Ok(published.generation_root.digest == checkout_generation.digest())
578}
579
580async fn rebase_source_checkout<A: AsyncAuthorityStore, O: AsyncObjectStore>(
581    session: &mut SourceSession<A, O>,
582) -> Result<RebaseDecision, SourceError> {
583    session
584        .checkout
585        .rebase_head(
586            session
587                .workspace
588                .volume
589                .config()
590                .limits
591                .maximum_checkout_dependencies,
592            WorkBudget::UNBOUNDED,
593            &CancellationToken::new(),
594        )
595        .await
596        .map(|receipt| receipt.value)
597        .map_err(engine)
598}
599
600async fn persist_generation_outcome<A: AsyncAuthorityStore, O: AsyncObjectStore>(
601    session: &mut SourceSession<A, O>,
602    state: SourceState,
603    generation_id: crate::GenerationId,
604    key: IdempotencyKey,
605    operation: SourceOperation,
606) -> Result<ReconcileOutcome<A, O>, SourceError> {
607    let fact =
608        persist_source_operation_with_generation(session, state, generation_id, key, operation)
609            .await?;
610    reconcile_outcome_from_fact(session, fact)
611}
612
613async fn persist_state_outcome<A: AsyncAuthorityStore, O: AsyncObjectStore>(
614    session: &mut SourceSession<A, O>,
615    state: SourceState,
616    key: IdempotencyKey,
617    operation: SourceOperation,
618) -> Result<ReconcileOutcome<A, O>, SourceError> {
619    let fact = persist_source_operation(session, state, key, operation).await?;
620    reconcile_outcome_from_fact(session, fact)
621}
622
623fn reconcile_outcome_from_fact<A, O>(
624    session: &SourceSession<A, O>,
625    fact: SourceFact,
626) -> Result<ReconcileOutcome<A, O>, SourceError> {
627    match fact.state {
628        DurableSourceState::Clean | DurableSourceState::Sealed => {
629            Ok(ReconcileOutcome::Clean(Generation {
630                workspace: session.workspace.clone(),
631                id: fact.generation_id,
632            }))
633        }
634        DurableSourceState::NeedsRescan(reason) => {
635            Ok(ReconcileOutcome::NeedsRescan(watch_invalidation(reason)))
636        }
637        DurableSourceState::Conflict => Ok(ReconcileOutcome::Conflict),
638        DurableSourceState::PendingCapture => Err(SourceError::Engine(
639            "source operation has no terminal outcome".to_owned(),
640        )),
641    }
642}
643
644impl<A: AsyncAuthorityStore, O: AsyncObjectStore> Fs<A, O> {
645    /// Creates or opens one workspace and attaches an exact native directory.
646    ///
647    /// # Errors
648    ///
649    /// Rejects invalid bounds, unsafe roots, unsupported host semantics, or a
650    /// baseline that cannot be atomically authenticated and published.
651    pub async fn attach_directory(
652        &self,
653        name: impl AsRef<str>,
654        path: impl AsRef<Path>,
655        options: SourceOptions,
656    ) -> Result<Workspace<A, O>, SourceError> {
657        if options.maximum_paths == 0
658            || options.maximum_extent_spans == 0
659            || options.maximum_queued_changes == 0
660        {
661            return Err(SourceError::InvalidOptions);
662        }
663        let workspace = self.create_workspace(name).await.map_err(engine)?;
664        let watcher = NativeWatch::open_with_profile(
665            path.as_ref(),
666            workspace.volume.config().profile,
667            NativeWatchOptions {
668                limits: workspace.volume.config().limits,
669                maximum_queued_changes: options.maximum_queued_changes,
670                recursive: true,
671            },
672        )
673        .map_err(engine)?;
674        let capture = CaptureOptions {
675            source_root: PathBuf::from(path.as_ref()),
676            expected_root_identity: watcher.root_identity(),
677            maximum_paths: options.maximum_paths,
678            maximum_extent_spans: options.maximum_extent_spans,
679        };
680        let capture_policy = CapturePolicy::excluding(
681            options
682                .excluded_paths
683                .iter()
684                .map(|path| {
685                    crate::kernel::NamespacePath::from_portable_in_profile(
686                        path,
687                        workspace.volume.config().profile,
688                        workspace.volume.config().limits,
689                    )
690                    .map_err(engine)
691                })
692                .collect::<Result<Vec<_>, _>>()?,
693        )
694        .map_err(engine)?;
695        let generation = workspace.head().await.map_err(engine)?;
696        let authority_head = attach_source_authority(
697            &workspace,
698            watcher.root_identity(),
699            &options,
700            capture_policy.fingerprint(),
701            generation.id,
702        )
703        .await?;
704        let checkout = workspace
705            .volume
706            .checkout(
707                GenerationSelector::Head,
708                CheckoutMode::tracking_transaction(),
709                WorkBudget::UNBOUNDED,
710                &CancellationToken::new(),
711            )
712            .await
713            .map_err(engine)?
714            .value;
715        let source = Source {
716            inner: Arc::new(Mutex::new(SourceSession {
717                workspace: workspace.clone(),
718                checkout,
719                watcher,
720                capture,
721                capture_policy,
722                mode: options.mode,
723                maximum_queued_changes: options.maximum_queued_changes,
724                state: SourceState::NeedsRescan(WatchInvalidationReason::InitialSnapshotRequired),
725                authority_head,
726            })),
727        };
728        if !matches!(source.rescan().await?, ReconcileOutcome::Clean(_)) {
729            return Err(SourceError::Engine(
730                "initial source baseline did not become clean".to_owned(),
731            ));
732        }
733        Ok(Workspace {
734            source: Some(source),
735            ..workspace
736        })
737    }
738}
739
740async fn attach_source_authority<A: AsyncAuthorityStore, O: AsyncObjectStore>(
741    workspace: &Workspace<A, O>,
742    root_identity: crate::NativeRootIdentity,
743    options: &SourceOptions,
744    capture_policy: Digest,
745    generation_id: crate::GenerationId,
746) -> Result<Head, SourceError> {
747    let authority_id = source_authority_id(workspace.volume.id());
748    let created = workspace
749        .volume
750        .fs
751        .authority()
752        .create_authority(
753            authority_id,
754            Epoch::GENESIS,
755            WorkBudget::UNBOUNDED,
756            &CancellationToken::new(),
757        )
758        .await
759        .map_err(engine)?
760        .value;
761    let head = match created {
762        CreateAuthorityOutcome::Created(head) | CreateAuthorityOutcome::Existing(head) => head,
763    };
764    if head.sequence != Sequence::GENESIS {
765        let latest = replay_source_fact(workspace, head).await?;
766        if latest.volume_id != workspace.volume.id()
767            || latest.root_identity != root_identity.to_bytes()
768            || latest.mode != durable_mode(options.mode)
769            || latest.maximum_paths != options.maximum_paths
770            || latest.maximum_extent_spans != options.maximum_extent_spans
771            || latest.maximum_queued_changes != options.maximum_queued_changes
772            || (latest.schema_version == 1 && !options.excluded_paths.is_empty())
773            || (latest.schema_version >= 2 && latest.capture_policy != capture_policy)
774        {
775            return Err(SourceError::BindingMismatch);
776        }
777    }
778    append_source_fact(
779        workspace,
780        head,
781        SourceFact {
782            schema_version: 2,
783            volume_id: workspace.volume.id(),
784            root_identity: root_identity.to_bytes(),
785            mode: durable_mode(options.mode),
786            maximum_paths: options.maximum_paths,
787            maximum_extent_spans: options.maximum_extent_spans,
788            maximum_queued_changes: options.maximum_queued_changes,
789            capture_policy,
790            state: DurableSourceState::NeedsRescan(SourceInvalidation::InitialSnapshotRequired),
791            generation_id,
792        },
793    )
794    .await
795}
796
797async fn replay_source_fact<A: AsyncAuthorityStore, O: AsyncObjectStore>(
798    workspace: &Workspace<A, O>,
799    head: Head,
800) -> Result<SourceFact, SourceError> {
801    let after = Sequence::new(head.sequence.get().saturating_sub(1));
802    let records = workspace
803        .volume
804        .fs
805        .authority()
806        .replay(
807            source_authority_id(workspace.volume.id()),
808            after,
809            ReplayLimit {
810                records: 1,
811                payload_bytes: 4096,
812            },
813            WorkBudget::UNBOUNDED,
814            &CancellationToken::new(),
815        )
816        .await
817        .map_err(engine)?
818        .value;
819    let record = records.first().ok_or_else(|| {
820        SourceError::Engine("source authority head has no terminal fact".to_owned())
821    })?;
822    decode_source_fact(&record.payload, 4096).map_err(engine)
823}
824
825async fn refresh_source_authority<A: AsyncAuthorityStore, O: AsyncObjectStore>(
826    session: &mut SourceSession<A, O>,
827) -> Result<(), SourceError> {
828    let head = session
829        .workspace
830        .volume
831        .fs
832        .authority()
833        .head(
834            source_authority_id(session.workspace.volume.id()),
835            WorkBudget::UNBOUNDED,
836            &CancellationToken::new(),
837        )
838        .await
839        .map_err(engine)?
840        .value;
841    if head == session.authority_head {
842        return Ok(());
843    }
844    let fact = replay_source_fact(&session.workspace, head).await?;
845    validate_source_fact_binding(session, &fact)?;
846    session.authority_head = head;
847    let state = source_state(fact.state);
848    session.state = if fact.generation_id == session.checkout.generation_id() {
849        state
850    } else {
851        SourceState::Conflict
852    };
853    Ok(())
854}
855
856async fn recover_published_operation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
857    session: &mut SourceSession<A, O>,
858    state: SourceState,
859    generation_id: crate::GenerationId,
860    begin_head: Head,
861    key: IdempotencyKey,
862    operation: SourceOperation,
863) -> Result<SourceFact, SourceError> {
864    refresh_source_authority(session).await?;
865    let operation_still_current = (session.state == SourceState::PendingCapture
866        && session.authority_head == begin_head)
867        || (session.state == SourceState::Clean
868            && session.checkout.generation_id() == generation_id);
869    let recovered_state = if operation_still_current {
870        state
871    } else {
872        SourceState::Conflict
873    };
874    persist_source_operation_with_generation(
875        session,
876        recovered_state,
877        generation_id,
878        key,
879        operation,
880    )
881    .await
882}
883
884async fn replay_reconcile_operation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
885    session: &mut SourceSession<A, O>,
886    key: IdempotencyKey,
887    operation: SourceOperation,
888) -> Result<Option<ReconcileOutcome<A, O>>, SourceError> {
889    let fact = if let Some(recovered) =
890        find_source_operation(session, key, operation, SourceOperationStage::Terminal).await?
891    {
892        let fact = recovered.fact;
893        refresh_source_authority(session).await?;
894        fact
895    } else {
896        let Some(begin) =
897            find_source_operation(session, key, operation, SourceOperationStage::Begin).await?
898        else {
899            if published_generation_for_key(session, key, operation)
900                .await?
901                .is_some()
902            {
903                return Err(SourceError::IdempotencyConflict);
904            }
905            return Ok(None);
906        };
907        let Some(generation_id) = published_generation_for_key(session, key, operation).await?
908        else {
909            return Ok(None);
910        };
911        recover_published_operation(
912            session,
913            SourceState::Clean,
914            generation_id,
915            begin.head,
916            key,
917            operation,
918        )
919        .await?
920    };
921    let outcome = match fact.state {
922        DurableSourceState::Clean => ReconcileOutcome::Clean(Generation {
923            workspace: session.workspace.clone(),
924            id: fact.generation_id,
925        }),
926        DurableSourceState::NeedsRescan(reason) => {
927            ReconcileOutcome::NeedsRescan(watch_invalidation(reason))
928        }
929        DurableSourceState::Conflict => ReconcileOutcome::Conflict,
930        DurableSourceState::PendingCapture | DurableSourceState::Sealed => {
931            return Err(SourceError::Engine(
932                "durable source operation has an invalid terminal state".to_owned(),
933            ));
934        }
935    };
936    Ok(Some(outcome))
937}
938
939async fn replay_seal_operation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
940    session: &mut SourceSession<A, O>,
941    key: IdempotencyKey,
942) -> Result<Option<Generation<A, O>>, SourceError> {
943    let fact = if let Some(recovered) = find_source_operation(
944        session,
945        key,
946        SourceOperation::Seal,
947        SourceOperationStage::Terminal,
948    )
949    .await?
950    {
951        let fact = recovered.fact;
952        refresh_source_authority(session).await?;
953        fact
954    } else {
955        let Some(begin) = find_source_operation(
956            session,
957            key,
958            SourceOperation::Seal,
959            SourceOperationStage::Begin,
960        )
961        .await?
962        else {
963            if published_generation_for_key(session, key, SourceOperation::Seal)
964                .await?
965                .is_some()
966            {
967                return Err(SourceError::IdempotencyConflict);
968            }
969            return Ok(None);
970        };
971        let Some(generation_id) =
972            published_generation_for_key(session, key, SourceOperation::Seal).await?
973        else {
974            return Ok(None);
975        };
976        recover_published_operation(
977            session,
978            SourceState::Sealed,
979            generation_id,
980            begin.head,
981            key,
982            SourceOperation::Seal,
983        )
984        .await?
985    };
986    match fact.state {
987        DurableSourceState::Sealed => Ok(Some(Generation {
988            workspace: session.workspace.clone(),
989            id: fact.generation_id,
990        })),
991        DurableSourceState::Conflict => Err(SourceError::Conflict),
992        DurableSourceState::NeedsRescan(_) => Err(SourceError::Engine(
993            "source could not reach a clean sealed state".to_owned(),
994        )),
995        DurableSourceState::Clean | DurableSourceState::PendingCapture => Err(SourceError::Engine(
996            "durable seal operation has an invalid terminal state".to_owned(),
997        )),
998    }
999}
1000
1001async fn find_source_operation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1002    session: &mut SourceSession<A, O>,
1003    key: IdempotencyKey,
1004    operation: SourceOperation,
1005    stage: SourceOperationStage,
1006) -> Result<Option<RecoveredSourceOperation>, SourceError> {
1007    let authority_id = source_authority_id(session.workspace.volume.id());
1008    let recovered = session
1009        .workspace
1010        .volume
1011        .fs
1012        .authority()
1013        .find_operation(
1014            authority_id,
1015            source_stage_operation_id(key, stage),
1016            WorkBudget::UNBOUNDED,
1017            &CancellationToken::new(),
1018        )
1019        .await
1020        .map_err(engine)?
1021        .value;
1022    let Some(commit) = recovered else {
1023        return Ok(None);
1024    };
1025    let fact = decode_source_fact(&commit.payload, 4096).map_err(engine)?;
1026    if commit.fingerprint != source_operation_fingerprint(operation, &fact)? {
1027        return Err(SourceError::IdempotencyConflict);
1028    }
1029    validate_source_fact_binding(session, &fact)?;
1030    let head = Head {
1031        epoch: commit.epoch,
1032        sequence: commit.sequence,
1033        digest: commit.digest,
1034    };
1035    if head.epoch == session.authority_head.epoch
1036        && head.sequence >= session.authority_head.sequence
1037    {
1038        session.authority_head = head;
1039        session.state = source_state(fact.state);
1040    }
1041    Ok(Some(RecoveredSourceOperation { fact, head }))
1042}
1043
1044fn validate_source_fact_binding<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1045    session: &SourceSession<A, O>,
1046    fact: &SourceFact,
1047) -> Result<(), SourceError> {
1048    if fact.volume_id != session.workspace.volume.id()
1049        || fact.root_identity != session.watcher.root_identity().to_bytes()
1050        || fact.mode != durable_mode(session.mode)
1051        || fact.maximum_paths != session.capture.maximum_paths
1052        || fact.maximum_extent_spans != session.capture.maximum_extent_spans
1053        || fact.maximum_queued_changes != session.maximum_queued_changes
1054        || (fact.schema_version == 1 && session.capture_policy != CapturePolicy::allow_all())
1055        || (fact.schema_version >= 2 && fact.capture_policy != session.capture_policy.fingerprint())
1056    {
1057        return Err(SourceError::BindingMismatch);
1058    }
1059    Ok(())
1060}
1061
1062async fn published_generation_for_key<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1063    session: &mut SourceSession<A, O>,
1064    key: IdempotencyKey,
1065    operation: SourceOperation,
1066) -> Result<Option<crate::GenerationId>, SourceError> {
1067    let receipt = session
1068        .workspace
1069        .volume
1070        .fs
1071        .observe_volume_operation(
1072            session.workspace.volume.id(),
1073            source_volume_operation_id(key, operation),
1074            WorkBudget::UNBOUNDED,
1075            &CancellationToken::new(),
1076        )
1077        .await
1078        .map_err(engine)?;
1079    let Some(commit) = receipt.value else {
1080        return Ok(None);
1081    };
1082    let published = decode_published_generation(&commit.payload, 4096).map_err(engine)?;
1083    if published.volume_id != session.workspace.volume.id() {
1084        return Err(SourceError::IdempotencyConflict);
1085    }
1086    let Some(previous_sequence) = commit.sequence.get().checked_sub(1) else {
1087        return Err(SourceError::IdempotencyConflict);
1088    };
1089    let expected = Head {
1090        epoch: commit.epoch,
1091        sequence: Sequence::new(previous_sequence),
1092        digest: commit.previous_digest,
1093    };
1094    let request = PublishGenerationRequest {
1095        authority_id: volume_authority_id(session.workspace.volume.id()),
1096        volume_id: session.workspace.volume.id(),
1097        epoch: commit.epoch,
1098        expected,
1099        operation_id: source_volume_operation_id(key, operation),
1100        generation_root: published.generation_root,
1101    };
1102    if commit.fingerprint
1103        != contextual_publication_fingerprint(
1104            request,
1105            source_volume_operation_context(key, operation),
1106        )
1107    {
1108        return Err(SourceError::IdempotencyConflict);
1109    }
1110    Ok(Some(crate::GenerationId::new(
1111        published.generation_root.digest,
1112    )))
1113}
1114
1115async fn begin_source_operation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1116    session: &mut SourceSession<A, O>,
1117    key: IdempotencyKey,
1118    operation: SourceOperation,
1119) -> Result<(), SourceError> {
1120    if find_source_operation(session, key, operation, SourceOperationStage::Begin)
1121        .await?
1122        .is_some()
1123    {
1124        return Ok(());
1125    }
1126    if published_generation_for_key(session, key, operation)
1127        .await?
1128        .is_some()
1129    {
1130        return Err(SourceError::IdempotencyConflict);
1131    }
1132    let fact = source_fact(
1133        session,
1134        SourceState::PendingCapture,
1135        session.checkout.generation_id(),
1136    );
1137    let fingerprint = source_operation_fingerprint(operation, &fact)?;
1138    session.authority_head = append_source_fact_with_identity(
1139        &session.workspace,
1140        session.authority_head,
1141        fact,
1142        source_stage_operation_id(key, SourceOperationStage::Begin),
1143        fingerprint,
1144    )
1145    .await?;
1146    session.state = SourceState::PendingCapture;
1147    Ok(())
1148}
1149
1150async fn persist_session_state<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1151    session: &mut SourceSession<A, O>,
1152    state: SourceState,
1153) -> Result<(), SourceError> {
1154    let generation_id = session.checkout.generation_id();
1155    persist_source_fact(session, state, generation_id).await
1156}
1157
1158async fn persist_source_operation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1159    session: &mut SourceSession<A, O>,
1160    state: SourceState,
1161    key: IdempotencyKey,
1162    operation: SourceOperation,
1163) -> Result<SourceFact, SourceError> {
1164    let generation_id = session.checkout.generation_id();
1165    persist_source_operation_with_generation(session, state, generation_id, key, operation).await
1166}
1167
1168async fn persist_source_operation_with_generation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1169    session: &mut SourceSession<A, O>,
1170    state: SourceState,
1171    generation_id: crate::GenerationId,
1172    key: IdempotencyKey,
1173    operation: SourceOperation,
1174) -> Result<SourceFact, SourceError> {
1175    let fact = source_fact(session, state, generation_id);
1176    let fingerprint = source_operation_fingerprint(operation, &fact)?;
1177    let appended = append_source_fact_with_identity(
1178        &session.workspace,
1179        session.authority_head,
1180        fact,
1181        source_stage_operation_id(key, SourceOperationStage::Terminal),
1182        fingerprint,
1183    )
1184    .await;
1185    match appended {
1186        Ok(head) => {
1187            session.authority_head = head;
1188            session.state = state;
1189            Ok(fact)
1190        }
1191        Err(SourceError::IdempotencyConflict) => {
1192            let recovered =
1193                find_source_operation(session, key, operation, SourceOperationStage::Terminal)
1194                    .await?
1195                    .ok_or(SourceError::IdempotencyConflict)?;
1196            Ok(recovered.fact)
1197        }
1198        Err(error) => Err(error),
1199    }
1200}
1201
1202async fn persist_source_fact<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1203    session: &mut SourceSession<A, O>,
1204    state: SourceState,
1205    generation_id: crate::GenerationId,
1206) -> Result<(), SourceError> {
1207    let fact = source_fact(session, state, generation_id);
1208    session.authority_head =
1209        append_source_fact(&session.workspace, session.authority_head, fact).await?;
1210    session.state = state;
1211    Ok(())
1212}
1213
1214fn source_fact<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1215    session: &SourceSession<A, O>,
1216    state: SourceState,
1217    generation_id: crate::GenerationId,
1218) -> SourceFact {
1219    SourceFact {
1220        schema_version: 2,
1221        volume_id: session.workspace.volume.id(),
1222        root_identity: session.watcher.root_identity().to_bytes(),
1223        mode: durable_mode(session.mode),
1224        maximum_paths: session.capture.maximum_paths,
1225        maximum_extent_spans: session.capture.maximum_extent_spans,
1226        maximum_queued_changes: session.maximum_queued_changes,
1227        capture_policy: session.capture_policy.fingerprint(),
1228        state: durable_state(state),
1229        generation_id,
1230    }
1231}
1232
1233async fn append_source_fact<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1234    workspace: &Workspace<A, O>,
1235    expected: Head,
1236    fact: SourceFact,
1237) -> Result<Head, SourceError> {
1238    let authority_id = source_authority_id(workspace.volume.id());
1239    let payload = encode_source_fact(fact).map_err(engine)?;
1240    let mut fingerprint_hasher = blake3::Hasher::new();
1241    fingerprint_hasher.update(b"acyclic-fs-source-fingerprint-v1\0");
1242    fingerprint_hasher.update(&payload);
1243    let fingerprint = Digest::from_bytes(*fingerprint_hasher.finalize().as_bytes());
1244    let mut operation_hasher = blake3::Hasher::new();
1245    operation_hasher.update(b"acyclic-fs-source-operation-v1\0");
1246    operation_hasher.update(&authority_id.into_bytes());
1247    operation_hasher.update(&expected.epoch.get().to_le_bytes());
1248    operation_hasher.update(&expected.sequence.get().to_le_bytes());
1249    operation_hasher.update(expected.digest.as_bytes());
1250    operation_hasher.update(fingerprint.as_bytes());
1251    let mut operation_bytes = [0_u8; 16];
1252    operation_bytes.copy_from_slice(&operation_hasher.finalize().as_bytes()[..16]);
1253    append_source_fact_with_identity(
1254        workspace,
1255        expected,
1256        fact,
1257        OperationId::from_bytes(operation_bytes),
1258        fingerprint,
1259    )
1260    .await
1261}
1262
1263async fn append_source_fact_with_identity<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1264    workspace: &Workspace<A, O>,
1265    expected: Head,
1266    fact: SourceFact,
1267    operation_id: OperationId,
1268    fingerprint: Digest,
1269) -> Result<Head, SourceError> {
1270    let authority_id = source_authority_id(workspace.volume.id());
1271    let payload = encode_source_fact(fact).map_err(engine)?;
1272    let outcome = workspace
1273        .volume
1274        .fs
1275        .authority()
1276        .compare_and_append(
1277            authority_id,
1278            expected.epoch,
1279            expected,
1280            ProposedCommit {
1281                operation_id,
1282                fingerprint,
1283                payload: Bytes::from(payload),
1284            },
1285            WorkBudget::UNBOUNDED,
1286            &CancellationToken::new(),
1287        )
1288        .await
1289        .map_err(engine)?
1290        .value;
1291    match outcome {
1292        AppendOutcome::Committed(commit) | AppendOutcome::AlreadyCommitted(commit) => Ok(Head {
1293            epoch: commit.epoch,
1294            sequence: commit.sequence,
1295            digest: commit.digest,
1296        }),
1297        AppendOutcome::Conflict { .. } | AppendOutcome::Fenced { .. } => {
1298            Err(SourceError::Concurrent)
1299        }
1300        AppendOutcome::IdempotencyConflict { .. } => Err(SourceError::IdempotencyConflict),
1301    }
1302}
1303
1304fn source_operation_fingerprint(
1305    operation: SourceOperation,
1306    fact: &SourceFact,
1307) -> Result<Digest, SourceError> {
1308    let mut hasher = blake3::Hasher::new();
1309    hasher.update(b"acyclic-fs-source-command-v1\0");
1310    hasher.update(&[match operation {
1311        SourceOperation::Reconcile => 1,
1312        SourceOperation::Rescan => 2,
1313        SourceOperation::Seal => 3,
1314    }]);
1315    hasher.update(&encode_source_fact(*fact).map_err(engine)?);
1316    Ok(Digest::from_bytes(*hasher.finalize().as_bytes()))
1317}
1318
1319fn source_stage_operation_id(key: IdempotencyKey, stage: SourceOperationStage) -> OperationId {
1320    let mut hasher = blake3::Hasher::new();
1321    hasher.update(b"acyclic-fs-source-stage-v1\0");
1322    hasher.update(&key.into_bytes());
1323    hasher.update(&[match stage {
1324        SourceOperationStage::Begin => 1,
1325        SourceOperationStage::Terminal => 2,
1326    }]);
1327    let mut bytes = [0_u8; 16];
1328    bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
1329    OperationId::from_bytes(bytes)
1330}
1331
1332fn source_volume_operation_id(key: IdempotencyKey, operation: SourceOperation) -> OperationId {
1333    let mut hasher = blake3::Hasher::new();
1334    hasher.update(b"acyclic-fs-source-volume-publication-v1\0");
1335    hasher.update(&key.into_bytes());
1336    hasher.update(&[match operation {
1337        SourceOperation::Reconcile => 1,
1338        SourceOperation::Rescan => 2,
1339        SourceOperation::Seal => 3,
1340    }]);
1341    let mut bytes = [0_u8; 16];
1342    bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
1343    OperationId::from_bytes(bytes)
1344}
1345
1346fn source_volume_operation_context(key: IdempotencyKey, operation: SourceOperation) -> Digest {
1347    let mut hasher = blake3::Hasher::new();
1348    hasher.update(b"acyclic-fs-source-volume-context-v1\0");
1349    hasher.update(&key.into_bytes());
1350    hasher.update(&[match operation {
1351        SourceOperation::Reconcile => 1,
1352        SourceOperation::Rescan => 2,
1353        SourceOperation::Seal => 3,
1354    }]);
1355    Digest::from_bytes(*hasher.finalize().as_bytes())
1356}
1357
1358const fn durable_mode(mode: SourceMode) -> DurableSourceMode {
1359    match mode {
1360        SourceMode::Pinned => DurableSourceMode::Pinned,
1361        SourceMode::Tracking => DurableSourceMode::Tracking,
1362    }
1363}
1364
1365const fn durable_state(state: SourceState) -> DurableSourceState {
1366    match state {
1367        SourceState::Clean => DurableSourceState::Clean,
1368        SourceState::PendingCapture => DurableSourceState::PendingCapture,
1369        SourceState::NeedsRescan(reason) => DurableSourceState::NeedsRescan(match reason {
1370            WatchInvalidationReason::InitialSnapshotRequired => {
1371                SourceInvalidation::InitialSnapshotRequired
1372            }
1373            WatchInvalidationReason::QueueOverflow => SourceInvalidation::QueueOverflow,
1374            WatchInvalidationReason::NativeRescanRequired => {
1375                SourceInvalidation::NativeRescanRequired
1376            }
1377            WatchInvalidationReason::BackendError => SourceInvalidation::BackendError,
1378            WatchInvalidationReason::UnrepresentablePath => SourceInvalidation::UnrepresentablePath,
1379            WatchInvalidationReason::AmbiguousRename => SourceInvalidation::AmbiguousRename,
1380            WatchInvalidationReason::RootChanged => SourceInvalidation::RootChanged,
1381        }),
1382        SourceState::Conflict => DurableSourceState::Conflict,
1383        SourceState::Sealed => DurableSourceState::Sealed,
1384    }
1385}
1386
1387const fn source_state(state: DurableSourceState) -> SourceState {
1388    match state {
1389        DurableSourceState::Clean => SourceState::Clean,
1390        DurableSourceState::PendingCapture => SourceState::PendingCapture,
1391        DurableSourceState::NeedsRescan(reason) => {
1392            SourceState::NeedsRescan(watch_invalidation(reason))
1393        }
1394        DurableSourceState::Conflict => SourceState::Conflict,
1395        DurableSourceState::Sealed => SourceState::Sealed,
1396    }
1397}
1398
1399const fn watch_invalidation(reason: SourceInvalidation) -> WatchInvalidationReason {
1400    match reason {
1401        SourceInvalidation::InitialSnapshotRequired => {
1402            WatchInvalidationReason::InitialSnapshotRequired
1403        }
1404        SourceInvalidation::QueueOverflow => WatchInvalidationReason::QueueOverflow,
1405        SourceInvalidation::NativeRescanRequired => WatchInvalidationReason::NativeRescanRequired,
1406        SourceInvalidation::BackendError => WatchInvalidationReason::BackendError,
1407        SourceInvalidation::UnrepresentablePath => WatchInvalidationReason::UnrepresentablePath,
1408        SourceInvalidation::AmbiguousRename => WatchInvalidationReason::AmbiguousRename,
1409        SourceInvalidation::RootChanged => WatchInvalidationReason::RootChanged,
1410    }
1411}
1412
1413fn engine(error: impl std::fmt::Display) -> SourceError {
1414    SourceError::Engine(error.to_string())
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419    use super::*;
1420    use bytes::Bytes;
1421    use std::sync::atomic::{AtomicU8, Ordering};
1422
1423    #[derive(Clone, Copy, Eq, PartialEq)]
1424    enum AppendGatePhase {
1425        Before = 1,
1426        After = 2,
1427    }
1428
1429    struct AppendGate {
1430        target: std::sync::Mutex<Option<OperationId>>,
1431        phase: AtomicU8,
1432        arrived: tokio::sync::watch::Sender<usize>,
1433        open: tokio::sync::watch::Sender<bool>,
1434    }
1435
1436    impl AppendGate {
1437        fn new() -> Self {
1438            let (arrived, _) = tokio::sync::watch::channel(0);
1439            let (open, _) = tokio::sync::watch::channel(true);
1440            Self {
1441                target: std::sync::Mutex::new(None),
1442                phase: AtomicU8::new(0),
1443                arrived,
1444                open,
1445            }
1446        }
1447
1448        fn arm(&self, target: OperationId, phase: AppendGatePhase) {
1449            let mut configured = match self.target.lock() {
1450                Ok(configured) => configured,
1451                Err(poisoned) => poisoned.into_inner(),
1452            };
1453            *configured = Some(target);
1454            self.phase.store(phase as u8, Ordering::Release);
1455            self.arrived.send_replace(0);
1456            self.open.send_replace(false);
1457        }
1458
1459        fn matches(&self, operation_id: OperationId, phase: AppendGatePhase) -> bool {
1460            let target = match self.target.lock() {
1461                Ok(target) => target,
1462                Err(poisoned) => poisoned.into_inner(),
1463            };
1464            !*self.open.borrow()
1465                && self.phase.load(Ordering::Acquire) == phase as u8
1466                && *target == Some(operation_id)
1467        }
1468
1469        async fn wait(&self) {
1470            let mut open = self.open.subscribe();
1471            self.arrived.send_modify(|arrived| *arrived += 1);
1472            while !*open.borrow_and_update() {
1473                if open.changed().await.is_err() {
1474                    return;
1475                }
1476            }
1477        }
1478
1479        async fn wait_for_arrivals(&self, expected: usize) {
1480            let mut arrived = self.arrived.subscribe();
1481            while *arrived.borrow_and_update() < expected {
1482                if arrived.changed().await.is_err() {
1483                    return;
1484                }
1485            }
1486        }
1487
1488        fn release(&self) {
1489            self.open.send_replace(true);
1490        }
1491    }
1492
1493    #[derive(Clone)]
1494    struct GatedAuthorityStore {
1495        inner: Arc<crate::memory::MemoryAuthorityStore>,
1496        gate: Arc<AppendGate>,
1497    }
1498
1499    impl AsyncAuthorityStore for GatedAuthorityStore {
1500        async fn fork_generation_authority(
1501            &self,
1502            source: crate::GenerationForkSource,
1503            destination_authority: crate::foundation::AuthorityId,
1504            operation_id: crate::foundation::OperationId,
1505            budget: WorkBudget,
1506            cancellation: &CancellationToken,
1507        ) -> crate::AuthorityResult<CreateAuthorityOutcome> {
1508            self.inner
1509                .fork_generation_authority(
1510                    source,
1511                    destination_authority,
1512                    operation_id,
1513                    budget,
1514                    cancellation,
1515                )
1516                .await
1517        }
1518
1519        async fn create_authority(
1520            &self,
1521            authority_id: crate::foundation::AuthorityId,
1522            genesis_epoch: Epoch,
1523            budget: WorkBudget,
1524            cancellation: &CancellationToken,
1525        ) -> crate::AuthorityResult<CreateAuthorityOutcome> {
1526            self.inner
1527                .create_authority(authority_id, genesis_epoch, budget, cancellation)
1528                .await
1529        }
1530
1531        async fn head(
1532            &self,
1533            authority_id: crate::foundation::AuthorityId,
1534            budget: WorkBudget,
1535            cancellation: &CancellationToken,
1536        ) -> crate::AuthorityResult<Head> {
1537            self.inner.head(authority_id, budget, cancellation).await
1538        }
1539
1540        async fn compare_and_append(
1541            &self,
1542            authority_id: crate::foundation::AuthorityId,
1543            epoch: Epoch,
1544            expected: Head,
1545            commit: ProposedCommit,
1546            budget: WorkBudget,
1547            cancellation: &CancellationToken,
1548        ) -> crate::AuthorityResult<AppendOutcome> {
1549            if self
1550                .gate
1551                .matches(commit.operation_id, AppendGatePhase::Before)
1552            {
1553                self.gate.wait().await;
1554            }
1555            let operation_id = commit.operation_id;
1556            let result = self
1557                .inner
1558                .compare_and_append(authority_id, epoch, expected, commit, budget, cancellation)
1559                .await;
1560            if self.gate.matches(operation_id, AppendGatePhase::After) {
1561                self.gate.wait().await;
1562            }
1563            result
1564        }
1565
1566        async fn replay(
1567            &self,
1568            authority_id: crate::foundation::AuthorityId,
1569            after: Sequence,
1570            limit: ReplayLimit,
1571            budget: WorkBudget,
1572            cancellation: &CancellationToken,
1573        ) -> crate::AuthorityResult<Vec<crate::foundation::DurableCommit>> {
1574            self.inner
1575                .replay(authority_id, after, limit, budget, cancellation)
1576                .await
1577        }
1578
1579        async fn fence(
1580            &self,
1581            authority_id: crate::foundation::AuthorityId,
1582            expected: Head,
1583            budget: WorkBudget,
1584            cancellation: &CancellationToken,
1585        ) -> crate::AuthorityResult<crate::FenceOutcome> {
1586            self.inner
1587                .fence(authority_id, expected, budget, cancellation)
1588                .await
1589        }
1590
1591        async fn find_operation(
1592            &self,
1593            authority_id: crate::foundation::AuthorityId,
1594            operation_id: OperationId,
1595            budget: WorkBudget,
1596            cancellation: &CancellationToken,
1597        ) -> crate::AuthorityResult<Option<crate::foundation::DurableCommit>> {
1598            self.inner
1599                .find_operation(authority_id, operation_id, budget, cancellation)
1600                .await
1601        }
1602    }
1603
1604    fn next_authority_append<A, O>(
1605        simulation: &crate::Simulation<A, O>,
1606    ) -> Result<u64, crate::SimulationError> {
1607        Ok(simulation
1608            .trace()?
1609            .iter()
1610            .filter(|entry| entry.operation == crate::SimulationOperation::AuthorityAppend)
1611            .map(|entry| entry.occurrence)
1612            .max()
1613            .unwrap_or(0)
1614            .saturating_add(1))
1615    }
1616
1617    fn clean_generation<A, O>(
1618        outcome: ReconcileOutcome<A, O>,
1619    ) -> Result<Generation<A, O>, &'static str> {
1620        match outcome {
1621            ReconcileOutcome::Clean(generation) => Ok(generation),
1622            ReconcileOutcome::NeedsRescan(_) | ReconcileOutcome::Conflict => {
1623                Err("source did not become clean")
1624            }
1625        }
1626    }
1627
1628    async fn publish_without_terminal<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1629        source: &Source<A, O>,
1630        key: IdempotencyKey,
1631        operation: SourceOperation,
1632    ) -> Result<crate::GenerationId, Box<dyn std::error::Error>> {
1633        let mut session = source.inner.lock().await;
1634        begin_source_operation(&mut session, key, operation).await?;
1635        session.watcher.begin_rescan()?;
1636        let capture = session.capture.clone();
1637        let policy = session.capture_policy.clone();
1638        crate::capture_baseline_with_policy(
1639            &mut session.checkout,
1640            &capture,
1641            &policy,
1642            WorkBudget::UNBOUNDED,
1643            &CancellationToken::new(),
1644        )
1645        .await?;
1646        let trailing = session.watcher.finish_rescan()?;
1647        crate::capture_watch_batch_with_policy(
1648            &mut session.checkout,
1649            trailing,
1650            &capture,
1651            &policy,
1652            WorkBudget::UNBOUNDED,
1653            &CancellationToken::new(),
1654        )
1655        .await?;
1656        match session
1657            .checkout
1658            .commit_with_context(
1659                source_volume_operation_id(key, operation),
1660                source_volume_operation_context(key, operation),
1661                WorkBudget::UNBOUNDED,
1662                &CancellationToken::new(),
1663            )
1664            .await?
1665            .value
1666        {
1667            CheckoutCommitOutcome::Committed { generation_id, .. }
1668            | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => Ok(generation_id),
1669            _ => Err("source publication did not commit".into()),
1670        }
1671    }
1672
1673    #[tokio::test]
1674    async fn attached_directory_rescans_and_seals_through_one_workspace()
1675    -> Result<(), Box<dyn std::error::Error>> {
1676        let root = tempfile::tempdir()?;
1677        std::fs::write(root.path().join("before.txt"), b"before")?;
1678        let workspace =
1679            Box::pin(Fs::memory().attach_directory("repo", root.path(), SourceOptions::default()))
1680                .await?;
1681        assert_eq!(
1682            workspace.read("/before.txt", 16).await?,
1683            Bytes::from_static(b"before")
1684        );
1685        let source = workspace.source().ok_or("source handle missing")?;
1686        assert_eq!(source.state().await, SourceState::Clean);
1687
1688        std::fs::write(root.path().join("before.txt"), b"after")?;
1689        std::fs::write(root.path().join("added.txt"), b"added")?;
1690        assert!(matches!(
1691            Box::pin(source.rescan()).await?,
1692            ReconcileOutcome::Clean(_)
1693        ));
1694        assert_eq!(
1695            workspace.read("/before.txt", 16).await?,
1696            Bytes::from_static(b"after")
1697        );
1698        assert_eq!(
1699            workspace.read("/added.txt", 16).await?,
1700            Bytes::from_static(b"added")
1701        );
1702        let sealed = Box::pin(workspace.seal()).await?;
1703        assert_eq!(source.state().await, SourceState::Sealed);
1704        std::fs::remove_dir_all(root.path())?;
1705        assert_eq!(
1706            sealed.read("/before.txt", 16).await?,
1707            Bytes::from_static(b"after")
1708        );
1709        Ok(())
1710    }
1711
1712    #[tokio::test]
1713    async fn attached_directory_excludes_private_prefixes_from_all_rescans()
1714    -> Result<(), Box<dyn std::error::Error>> {
1715        let root = tempfile::tempdir()?;
1716        std::fs::create_dir(root.path().join(".git"))?;
1717        std::fs::write(root.path().join(".git").join("config"), b"private")?;
1718        std::fs::write(root.path().join("visible.txt"), b"visible")?;
1719        let options = SourceOptions {
1720            excluded_paths: vec![PortablePath::parse(
1721                "/.git",
1722                crate::model::VolumeLimits::default(),
1723            )?],
1724            ..SourceOptions::default()
1725        };
1726        let workspace =
1727            Box::pin(Fs::memory().attach_directory("filtered", root.path(), options)).await?;
1728        assert_eq!(
1729            workspace.read("/visible.txt", 16).await?,
1730            Bytes::from_static(b"visible")
1731        );
1732        assert!(workspace.read("/.git/config", 16).await.is_err());
1733
1734        std::fs::write(root.path().join(".git").join("config"), b"changed")?;
1735        let source = workspace.source().ok_or("source handle missing")?;
1736        assert!(matches!(
1737            Box::pin(source.rescan()).await?,
1738            ReconcileOutcome::Clean(_)
1739        ));
1740        assert!(workspace.read("/.git/config", 16).await.is_err());
1741        Ok(())
1742    }
1743
1744    #[cfg(feature = "local")]
1745    #[tokio::test]
1746    async fn durable_source_restarts_fail_closed_and_remains_gc_classifiable()
1747    -> Result<(), Box<dyn std::error::Error>> {
1748        let storage = tempfile::tempdir()?;
1749        let source_root = tempfile::tempdir()?;
1750        std::fs::write(source_root.path().join("durable.txt"), b"one")?;
1751        let local_options = crate::LocalOptions::new(storage.path());
1752
1753        {
1754            let fs = Fs::local(local_options.clone()).await?;
1755            let workspace = Box::pin(fs.attach_directory(
1756                "durable",
1757                source_root.path(),
1758                SourceOptions::default(),
1759            ))
1760            .await?;
1761            let source = workspace.source().ok_or("source missing")?;
1762            assert_eq!(source.state().await, SourceState::Clean);
1763        }
1764
1765        std::fs::write(source_root.path().join("durable.txt"), b"two")?;
1766        {
1767            let fs = Fs::local(local_options.clone()).await?;
1768            let workspace = Box::pin(fs.attach_directory(
1769                "durable",
1770                source_root.path(),
1771                SourceOptions::default(),
1772            ))
1773            .await?;
1774            assert_eq!(
1775                Box::pin(workspace.read("/durable.txt", 8)).await?,
1776                Bytes::from_static(b"two")
1777            );
1778            let wrong_root = tempfile::tempdir()?;
1779            assert!(matches!(
1780                Box::pin(fs.attach_directory(
1781                    "durable",
1782                    wrong_root.path(),
1783                    SourceOptions::default()
1784                ))
1785                .await,
1786                Err(SourceError::BindingMismatch)
1787            ));
1788        }
1789
1790        Ok(())
1791    }
1792
1793    #[tokio::test]
1794    async fn keyed_source_retry_recovers_exact_outcome_and_rejects_another_kind()
1795    -> Result<(), Box<dyn std::error::Error>> {
1796        let root = tempfile::tempdir()?;
1797        std::fs::write(root.path().join("value.txt"), b"one")?;
1798        let workspace =
1799            Box::pin(Fs::memory().attach_directory("keyed", root.path(), SourceOptions::default()))
1800                .await?;
1801        let source = workspace.source().ok_or("source missing")?;
1802        std::fs::write(root.path().join("value.txt"), b"two")?;
1803        let key = IdempotencyKey::from_bytes([41; 16]);
1804        let first = clean_generation(Box::pin(source.rescan_with_key(key)).await?)?;
1805        std::fs::write(root.path().join("value.txt"), b"three")?;
1806        let retried = clean_generation(Box::pin(source.rescan_with_key(key)).await?)?;
1807        assert_eq!(retried.id(), first.id());
1808        assert_eq!(
1809            retried.read("/value.txt", 8).await?,
1810            Bytes::from_static(b"two")
1811        );
1812        assert!(matches!(
1813            Box::pin(source.seal_with_key(key)).await,
1814            Err(SourceError::IdempotencyConflict)
1815        ));
1816        Ok(())
1817    }
1818
1819    #[tokio::test]
1820    async fn no_op_reconcile_conflicts_after_an_independent_workspace_advance()
1821    -> Result<(), Box<dyn std::error::Error>> {
1822        let root = tempfile::tempdir()?;
1823        let workspace = Box::pin(Fs::memory().attach_directory(
1824            "no-op-stale",
1825            root.path(),
1826            SourceOptions::default(),
1827        ))
1828        .await?;
1829        let source = workspace.source().ok_or("source missing")?;
1830        let acknowledged = source.inner.lock().await.checkout.generation_id();
1831
1832        let mut unrelated = workspace
1833            .volume
1834            .checkout(
1835                GenerationSelector::Head,
1836                CheckoutMode::tracking_transaction(),
1837                WorkBudget::UNBOUNDED,
1838                &CancellationToken::new(),
1839            )
1840            .await?
1841            .value;
1842        let advanced = match unrelated
1843            .commit_with_context(
1844                IdempotencyKey::from_bytes([62; 16]).operation_id(),
1845                Digest::from_bytes([62; 32]),
1846                WorkBudget::UNBOUNDED,
1847                &CancellationToken::new(),
1848            )
1849            .await?
1850            .value
1851        {
1852            CheckoutCommitOutcome::Committed { generation_id, .. }
1853            | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => generation_id,
1854            CheckoutCommitOutcome::Conflict { .. }
1855            | CheckoutCommitOutcome::Fenced { .. }
1856            | CheckoutCommitOutcome::IdempotencyConflict { .. } => {
1857                return Err("independent workspace authority advance was rejected".into());
1858            }
1859        };
1860        assert_eq!(advanced, acknowledged);
1861
1862        let key = IdempotencyKey::from_bytes([63; 16]);
1863        match Box::pin(source.reconcile_with_key(key)).await? {
1864            ReconcileOutcome::Conflict => assert!(matches!(
1865                Box::pin(source.reconcile_with_key(key)).await?,
1866                ReconcileOutcome::Conflict
1867            )),
1868            ReconcileOutcome::NeedsRescan(WatchInvalidationReason::NativeRescanRequired)
1869                if cfg!(target_os = "macos") =>
1870            {
1871                assert!(matches!(
1872                    Box::pin(source.reconcile_with_key(key)).await?,
1873                    ReconcileOutcome::NeedsRescan(WatchInvalidationReason::NativeRescanRequired)
1874                ));
1875                assert!(matches!(
1876                    Box::pin(source.rescan_with_key(IdempotencyKey::from_bytes([71; 16]))).await?,
1877                    ReconcileOutcome::Conflict
1878                ));
1879            }
1880            _ => return Err("source did not preserve conflict or rescan state".into()),
1881        }
1882        assert!(matches!(
1883            Box::pin(source.reconcile_with_key(IdempotencyKey::from_bytes([72; 16]))).await?,
1884            ReconcileOutcome::Conflict
1885        ));
1886        assert!(matches!(
1887            Box::pin(source.rescan_with_key(IdempotencyKey::from_bytes([73; 16]))).await?,
1888            ReconcileOutcome::Conflict
1889        ));
1890        assert!(matches!(
1891            Box::pin(source.seal_with_key(IdempotencyKey::from_bytes([74; 16]))).await,
1892            Err(SourceError::Conflict)
1893        ));
1894        assert_eq!(source.state().await, SourceState::Conflict);
1895        assert_eq!(workspace.head().await?.id(), advanced);
1896        Ok(())
1897    }
1898
1899    #[tokio::test]
1900    async fn pending_source_capture_conflicts_after_an_authority_only_advance()
1901    -> Result<(), Box<dyn std::error::Error>> {
1902        let root = tempfile::tempdir()?;
1903        std::fs::write(root.path().join("source.txt"), b"source")?;
1904        let workspace = Box::pin(Fs::memory().attach_directory(
1905            "pending-source-authority-fence",
1906            root.path(),
1907            SourceOptions::default(),
1908        ))
1909        .await?;
1910        let source = workspace.source().ok_or("source missing")?;
1911        let key = IdempotencyKey::from_bytes([77; 16]);
1912        {
1913            let mut session = source.inner.lock().await;
1914            begin_source_operation(&mut session, key, SourceOperation::Reconcile).await?;
1915            let limits = session.checkout.volume_config().limits;
1916            let portable = crate::path::PortablePath::parse("/source.txt", limits)?;
1917            let path = crate::kernel::NamespacePath::from_portable(&portable, limits)?;
1918            session
1919                .checkout
1920                .write_file(
1921                    path,
1922                    0,
1923                    Bytes::from_static(b"captured"),
1924                    WorkBudget::UNBOUNDED,
1925                    &CancellationToken::new(),
1926                )
1927                .await?;
1928            assert!(session.checkout.has_pending_mutations());
1929        }
1930
1931        let mut unrelated = workspace
1932            .volume
1933            .checkout(
1934                GenerationSelector::Head,
1935                CheckoutMode::tracking_transaction(),
1936                WorkBudget::UNBOUNDED,
1937                &CancellationToken::new(),
1938            )
1939            .await?
1940            .value;
1941        let advanced = match unrelated
1942            .commit_with_context(
1943                IdempotencyKey::from_bytes([78; 16]).operation_id(),
1944                Digest::from_bytes([78; 32]),
1945                WorkBudget::UNBOUNDED,
1946                &CancellationToken::new(),
1947            )
1948            .await?
1949            .value
1950        {
1951            CheckoutCommitOutcome::Committed { generation_id, .. }
1952            | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => generation_id,
1953            CheckoutCommitOutcome::Conflict { .. }
1954            | CheckoutCommitOutcome::Fenced { .. }
1955            | CheckoutCommitOutcome::IdempotencyConflict { .. } => {
1956                return Err("independent workspace authority advance was rejected".into());
1957            }
1958        };
1959
1960        let outcome = {
1961            let mut session = source.inner.lock().await;
1962            assert_eq!(advanced, session.checkout.generation_id());
1963            publish_session(
1964                &mut session,
1965                key,
1966                SourceOperation::Reconcile,
1967                SourceState::Clean,
1968            )
1969            .await?
1970        };
1971        assert!(matches!(outcome, ReconcileOutcome::Conflict));
1972        assert!(matches!(
1973            Box::pin(source.reconcile_with_key(key)).await?,
1974            ReconcileOutcome::Conflict
1975        ));
1976        assert_eq!(source.state().await, SourceState::Conflict);
1977        assert_eq!(workspace.head().await?.id(), advanced);
1978        Ok(())
1979    }
1980
1981    #[tokio::test]
1982    async fn pending_source_capture_rebases_over_a_disjoint_generation()
1983    -> Result<(), Box<dyn std::error::Error>> {
1984        let root = tempfile::tempdir()?;
1985        std::fs::write(root.path().join("source.txt"), b"source")?;
1986        let workspace = Box::pin(Fs::memory().attach_directory(
1987            "pending-source-disjoint-rebase",
1988            root.path(),
1989            SourceOptions::default(),
1990        ))
1991        .await?;
1992        let source = workspace.source().ok_or("source missing")?;
1993        let key = IdempotencyKey::from_bytes([79; 16]);
1994        let acknowledged = {
1995            let mut session = source.inner.lock().await;
1996            begin_source_operation(&mut session, key, SourceOperation::Reconcile).await?;
1997            let acknowledged = session.checkout.generation_id();
1998            let limits = session.checkout.volume_config().limits;
1999            let portable = crate::path::PortablePath::parse("/source.txt", limits)?;
2000            let path = crate::kernel::NamespacePath::from_portable(&portable, limits)?;
2001            session
2002                .checkout
2003                .write_file(
2004                    path,
2005                    0,
2006                    Bytes::from_static(b"captured"),
2007                    WorkBudget::UNBOUNDED,
2008                    &CancellationToken::new(),
2009                )
2010                .await?;
2011            assert!(session.checkout.has_pending_mutations());
2012            acknowledged
2013        };
2014
2015        let mut unrelated = workspace
2016            .begin_transaction(IdempotencyKey::from_bytes([80; 16]))
2017            .await?;
2018        unrelated
2019            .write("/unrelated.txt", Bytes::from_static(b"unrelated"))
2020            .await?;
2021        let advanced = match unrelated.commit().await? {
2022            crate::TransactionCommit::Committed(generation)
2023            | crate::TransactionCommit::AlreadyCommitted(generation) => generation,
2024            crate::TransactionCommit::Conflict { .. }
2025            | crate::TransactionCommit::Fenced
2026            | crate::TransactionCommit::IdempotencyConflict => {
2027                return Err("disjoint workspace advance was rejected".into());
2028            }
2029        };
2030        assert_ne!(advanced.id(), acknowledged);
2031
2032        let reconciled = {
2033            let mut session = source.inner.lock().await;
2034            clean_generation(
2035                publish_session(
2036                    &mut session,
2037                    key,
2038                    SourceOperation::Reconcile,
2039                    SourceState::Clean,
2040                )
2041                .await?,
2042            )?
2043        };
2044        assert_eq!(
2045            reconciled.read("/source.txt", 16).await?,
2046            Bytes::from_static(b"captured")
2047        );
2048        assert_eq!(
2049            reconciled.read("/unrelated.txt", 16).await?,
2050            Bytes::from_static(b"unrelated")
2051        );
2052        assert_eq!(
2053            clean_generation(Box::pin(source.reconcile_with_key(key)).await?)?.id(),
2054            reconciled.id()
2055        );
2056        assert_eq!(source.state().await, SourceState::Clean);
2057        Ok(())
2058    }
2059
2060    #[tokio::test]
2061    async fn no_op_reconcile_conflicts_when_an_advance_wins_before_its_fence()
2062    -> Result<(), Box<dyn std::error::Error>> {
2063        let root = tempfile::tempdir()?;
2064        let gate = Arc::new(AppendGate::new());
2065        let authority = GatedAuthorityStore {
2066            inner: Arc::new(crate::memory::MemoryAuthorityStore::default()),
2067            gate: Arc::clone(&gate),
2068        };
2069        let fs = Fs::new(
2070            authority,
2071            Arc::new(crate::memory::MemoryObjectStore::default()),
2072            crate::EmbeddedCapabilities::MEMORY,
2073        );
2074        let workspace = Box::pin(fs.attach_directory(
2075            "no-op-before-fence",
2076            root.path(),
2077            SourceOptions::default(),
2078        ))
2079        .await?;
2080        let source_key = IdempotencyKey::from_bytes([66; 16]);
2081        let acknowledged = workspace
2082            .source()
2083            .ok_or("source missing")?
2084            .inner
2085            .lock()
2086            .await
2087            .checkout
2088            .generation_id();
2089        gate.arm(
2090            source_volume_operation_id(source_key, SourceOperation::Reconcile),
2091            AppendGatePhase::Before,
2092        );
2093        let source = workspace.source().ok_or("source missing")?.clone();
2094        let operation = tokio::spawn(async move { source.reconcile_with_key(source_key).await });
2095        gate.wait_for_arrivals(1).await;
2096
2097        let mut unrelated = workspace
2098            .begin_transaction(IdempotencyKey::from_bytes([67; 16]))
2099            .await?;
2100        unrelated
2101            .write("/unrelated.txt", Bytes::from_static(b"unrelated"))
2102            .await?;
2103        let advanced = match unrelated.commit().await? {
2104            crate::TransactionCommit::Committed(generation)
2105            | crate::TransactionCommit::AlreadyCommitted(generation) => generation,
2106            crate::TransactionCommit::Conflict { .. }
2107            | crate::TransactionCommit::Fenced
2108            | crate::TransactionCommit::IdempotencyConflict => {
2109                return Err("concurrent workspace advance was rejected".into());
2110            }
2111        };
2112        assert_ne!(advanced.id(), acknowledged);
2113        gate.release();
2114
2115        assert!(matches!(operation.await??, ReconcileOutcome::Conflict));
2116        let source = workspace.source().ok_or("source missing")?;
2117        assert!(matches!(
2118            Box::pin(source.reconcile_with_key(source_key)).await?,
2119            ReconcileOutcome::Conflict
2120        ));
2121        assert_eq!(source.state().await, SourceState::Conflict);
2122        assert_eq!(workspace.head().await?.id(), advanced.id());
2123        Ok(())
2124    }
2125
2126    #[tokio::test]
2127    async fn no_op_reconcile_linearizes_before_an_advance_between_fence_and_terminal()
2128    -> Result<(), Box<dyn std::error::Error>> {
2129        let root = tempfile::tempdir()?;
2130        let gate = Arc::new(AppendGate::new());
2131        let authority = GatedAuthorityStore {
2132            inner: Arc::new(crate::memory::MemoryAuthorityStore::default()),
2133            gate: Arc::clone(&gate),
2134        };
2135        let fs = Fs::new(
2136            authority,
2137            Arc::new(crate::memory::MemoryObjectStore::default()),
2138            crate::EmbeddedCapabilities::MEMORY,
2139        );
2140        let workspace = Box::pin(fs.attach_directory(
2141            "no-op-concurrent",
2142            root.path(),
2143            SourceOptions::default(),
2144        ))
2145        .await?;
2146        let source_key = IdempotencyKey::from_bytes([64; 16]);
2147        let acknowledged = workspace
2148            .source()
2149            .ok_or("source missing")?
2150            .inner
2151            .lock()
2152            .await
2153            .checkout
2154            .generation_id();
2155        gate.arm(
2156            source_stage_operation_id(source_key, SourceOperationStage::Terminal),
2157            AppendGatePhase::Before,
2158        );
2159        let source = workspace.source().ok_or("source missing")?.clone();
2160        let operation = tokio::spawn(async move { source.reconcile_with_key(source_key).await });
2161        gate.wait_for_arrivals(1).await;
2162
2163        let mut unrelated = workspace
2164            .begin_transaction(IdempotencyKey::from_bytes([65; 16]))
2165            .await?;
2166        unrelated
2167            .write("/unrelated.txt", Bytes::from_static(b"unrelated"))
2168            .await?;
2169        let advanced = match unrelated.commit().await? {
2170            crate::TransactionCommit::Committed(generation)
2171            | crate::TransactionCommit::AlreadyCommitted(generation) => generation,
2172            crate::TransactionCommit::Conflict { .. }
2173            | crate::TransactionCommit::Fenced
2174            | crate::TransactionCommit::IdempotencyConflict => {
2175                return Err("concurrent workspace advance was rejected".into());
2176            }
2177        };
2178        assert_ne!(advanced.id(), acknowledged);
2179        gate.release();
2180
2181        let reconciled = clean_generation(operation.await??)?;
2182        assert_eq!(reconciled.id(), acknowledged);
2183        let source = workspace.source().ok_or("source missing")?;
2184        assert_eq!(
2185            clean_generation(Box::pin(source.reconcile_with_key(source_key)).await?)?.id(),
2186            acknowledged
2187        );
2188        assert_eq!(source.state().await, SourceState::Clean);
2189        assert_eq!(workspace.head().await?.id(), advanced.id());
2190        Ok(())
2191    }
2192
2193    #[tokio::test]
2194    async fn published_retry_preserves_a_later_fail_closed_state()
2195    -> Result<(), Box<dyn std::error::Error>> {
2196        let root = tempfile::tempdir()?;
2197        std::fs::write(root.path().join("source.txt"), b"source")?;
2198        let workspace = Box::pin(Fs::memory().attach_directory(
2199            "published-retry-terminal-race",
2200            root.path(),
2201            SourceOptions::default(),
2202        ))
2203        .await?;
2204        let source = workspace.source().ok_or("source missing")?;
2205        let published_key = IdempotencyKey::from_bytes([68; 16]);
2206        let other_key = IdempotencyKey::from_bytes([69; 16]);
2207
2208        let (published_generation, published_begin_head, other_begin, other_terminal) = {
2209            let mut session = source.inner.lock().await;
2210            begin_source_operation(&mut session, published_key, SourceOperation::Reconcile).await?;
2211            let published_begin_head = session.authority_head;
2212            let publication = session
2213                .checkout
2214                .commit_with_context(
2215                    source_volume_operation_id(published_key, SourceOperation::Reconcile),
2216                    source_volume_operation_context(published_key, SourceOperation::Reconcile),
2217                    WorkBudget::UNBOUNDED,
2218                    &CancellationToken::new(),
2219                )
2220                .await?
2221                .value;
2222            let published_generation = match publication {
2223                CheckoutCommitOutcome::Committed { generation_id, .. }
2224                | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => generation_id,
2225                CheckoutCommitOutcome::Conflict { .. }
2226                | CheckoutCommitOutcome::Fenced { .. }
2227                | CheckoutCommitOutcome::IdempotencyConflict { .. } => {
2228                    return Err("source volume publication was rejected".into());
2229                }
2230            };
2231            (
2232                published_generation,
2233                published_begin_head,
2234                source_fact(&session, SourceState::PendingCapture, published_generation),
2235                source_fact(
2236                    &session,
2237                    SourceState::NeedsRescan(WatchInvalidationReason::BackendError),
2238                    published_generation,
2239                ),
2240            )
2241        };
2242
2243        let other_begin_head = append_source_fact_with_identity(
2244            &workspace,
2245            published_begin_head,
2246            other_begin,
2247            source_stage_operation_id(other_key, SourceOperationStage::Begin),
2248            source_operation_fingerprint(SourceOperation::Reconcile, &other_begin)?,
2249        )
2250        .await?;
2251        let other_terminal_head = append_source_fact_with_identity(
2252            &workspace,
2253            other_begin_head,
2254            other_terminal,
2255            source_stage_operation_id(other_key, SourceOperationStage::Terminal),
2256            source_operation_fingerprint(SourceOperation::Reconcile, &other_terminal)?,
2257        )
2258        .await?;
2259
2260        assert!(matches!(
2261            Box::pin(source.reconcile_with_key(published_key)).await?,
2262            ReconcileOutcome::Conflict
2263        ));
2264        assert!(matches!(
2265            Box::pin(source.reconcile_with_key(published_key)).await?,
2266            ReconcileOutcome::Conflict
2267        ));
2268        let mut session = source.inner.lock().await;
2269        let recovered = find_source_operation(
2270            &mut session,
2271            published_key,
2272            SourceOperation::Reconcile,
2273            SourceOperationStage::Terminal,
2274        )
2275        .await?
2276        .ok_or("recovered terminal missing")?;
2277        let recovered_head = session.authority_head;
2278        assert_eq!(recovered.fact.generation_id, published_generation);
2279        assert_eq!(recovered.fact.state, DurableSourceState::Conflict);
2280        assert!(recovered_head.sequence > other_terminal_head.sequence);
2281        assert_eq!(session.state, SourceState::Conflict);
2282        Ok(())
2283    }
2284
2285    #[tokio::test]
2286    async fn published_retry_conflicts_after_another_source_generation()
2287    -> Result<(), Box<dyn std::error::Error>> {
2288        let root = tempfile::tempdir()?;
2289        std::fs::write(root.path().join("source.txt"), b"one")?;
2290        let fs = Fs::memory();
2291        let first_workspace = Box::pin(fs.attach_directory(
2292            "published-retry-generation-race",
2293            root.path(),
2294            SourceOptions::default(),
2295        ))
2296        .await?;
2297        let first = first_workspace.source().ok_or("first source missing")?;
2298        let published_key = IdempotencyKey::from_bytes([75; 16]);
2299        std::fs::write(root.path().join("source.txt"), b"two")?;
2300        let published_generation =
2301            publish_without_terminal(first, published_key, SourceOperation::Rescan).await?;
2302
2303        std::fs::write(root.path().join("source.txt"), b"three")?;
2304        let second_workspace = Box::pin(fs.attach_directory(
2305            "published-retry-generation-race",
2306            root.path(),
2307            SourceOptions::default(),
2308        ))
2309        .await?;
2310        let second = second_workspace.source().ok_or("second source missing")?;
2311        let advanced = second_workspace.head().await?;
2312        assert_ne!(advanced.id(), published_generation);
2313        assert_eq!(second.state().await, SourceState::Clean);
2314
2315        assert!(matches!(
2316            Box::pin(first.rescan_with_key(published_key)).await?,
2317            ReconcileOutcome::Conflict
2318        ));
2319        {
2320            let mut session = first.inner.lock().await;
2321            let recovered = find_source_operation(
2322                &mut session,
2323                published_key,
2324                SourceOperation::Rescan,
2325                SourceOperationStage::Terminal,
2326            )
2327            .await?
2328            .ok_or("conflict terminal missing")?;
2329            assert_eq!(recovered.fact.generation_id, published_generation);
2330            assert_eq!(recovered.fact.state, DurableSourceState::Conflict);
2331            assert_eq!(session.state, SourceState::Conflict);
2332        }
2333        assert_eq!(first.state().await, SourceState::Conflict);
2334        assert!(matches!(
2335            Box::pin(first.seal_with_key(IdempotencyKey::from_bytes([76; 16]))).await,
2336            Err(SourceError::Conflict)
2337        ));
2338        Ok(())
2339    }
2340
2341    #[tokio::test]
2342    async fn terminal_retry_refreshes_a_later_source_state()
2343    -> Result<(), Box<dyn std::error::Error>> {
2344        let root = tempfile::tempdir()?;
2345        std::fs::write(root.path().join("source.txt"), b"source")?;
2346        let workspace = Box::pin(Fs::memory().attach_directory(
2347            "terminal-retry-refresh",
2348            root.path(),
2349            SourceOptions::default(),
2350        ))
2351        .await?;
2352        let source = workspace.source().ok_or("source missing")?;
2353        let completed_key = IdempotencyKey::from_bytes([70; 16]);
2354        let later_key = IdempotencyKey::from_bytes([71; 16]);
2355
2356        let (completed_generation, completed_terminal_head, later_begin, later_terminal) = {
2357            let mut session = source.inner.lock().await;
2358            begin_source_operation(&mut session, completed_key, SourceOperation::Reconcile).await?;
2359            let publication = session
2360                .checkout
2361                .commit_with_context(
2362                    source_volume_operation_id(completed_key, SourceOperation::Reconcile),
2363                    source_volume_operation_context(completed_key, SourceOperation::Reconcile),
2364                    WorkBudget::UNBOUNDED,
2365                    &CancellationToken::new(),
2366                )
2367                .await?
2368                .value;
2369            let completed_generation = match publication {
2370                CheckoutCommitOutcome::Committed { generation_id, .. }
2371                | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => generation_id,
2372                CheckoutCommitOutcome::Conflict { .. }
2373                | CheckoutCommitOutcome::Fenced { .. }
2374                | CheckoutCommitOutcome::IdempotencyConflict { .. } => {
2375                    return Err("source volume publication was rejected".into());
2376                }
2377            };
2378            persist_source_operation_with_generation(
2379                &mut session,
2380                SourceState::Clean,
2381                completed_generation,
2382                completed_key,
2383                SourceOperation::Reconcile,
2384            )
2385            .await?;
2386            (
2387                completed_generation,
2388                session.authority_head,
2389                source_fact(&session, SourceState::PendingCapture, completed_generation),
2390                source_fact(&session, SourceState::Conflict, completed_generation),
2391            )
2392        };
2393
2394        let later_begin_head = append_source_fact_with_identity(
2395            &workspace,
2396            completed_terminal_head,
2397            later_begin,
2398            source_stage_operation_id(later_key, SourceOperationStage::Begin),
2399            source_operation_fingerprint(SourceOperation::Reconcile, &later_begin)?,
2400        )
2401        .await?;
2402        let later_terminal_head = append_source_fact_with_identity(
2403            &workspace,
2404            later_begin_head,
2405            later_terminal,
2406            source_stage_operation_id(later_key, SourceOperationStage::Terminal),
2407            source_operation_fingerprint(SourceOperation::Reconcile, &later_terminal)?,
2408        )
2409        .await?;
2410
2411        assert_eq!(
2412            clean_generation(Box::pin(source.reconcile_with_key(completed_key)).await?)?.id(),
2413            completed_generation
2414        );
2415        let session = source.inner.lock().await;
2416        assert_eq!(session.authority_head, later_terminal_head);
2417        assert_eq!(session.state, SourceState::Conflict);
2418        Ok(())
2419    }
2420
2421    #[tokio::test]
2422    async fn two_source_sessions_replay_one_canonical_terminal_outcome()
2423    -> Result<(), Box<dyn std::error::Error>> {
2424        let root = tempfile::tempdir()?;
2425        std::fs::write(root.path().join("value.txt"), b"one")?;
2426        let gate = Arc::new(AppendGate::new());
2427        let authority = GatedAuthorityStore {
2428            inner: Arc::new(crate::memory::MemoryAuthorityStore::default()),
2429            gate: Arc::clone(&gate),
2430        };
2431        let fs = Fs::new(
2432            authority,
2433            Arc::new(crate::memory::MemoryObjectStore::default()),
2434            crate::EmbeddedCapabilities::MEMORY,
2435        );
2436        let first_workspace =
2437            Box::pin(fs.attach_directory("two-sessions", root.path(), SourceOptions::default()))
2438                .await?;
2439        let second_workspace =
2440            Box::pin(fs.attach_directory("two-sessions", root.path(), SourceOptions::default()))
2441                .await?;
2442        let first = first_workspace.source().ok_or("first source missing")?;
2443        let second = second_workspace.source().ok_or("second source missing")?;
2444        let key = IdempotencyKey::from_bytes([52; 16]);
2445        {
2446            let mut session = second.inner.lock().await;
2447            begin_source_operation(&mut session, key, SourceOperation::Rescan).await?;
2448        }
2449        {
2450            let mut session = first.inner.lock().await;
2451            begin_source_operation(&mut session, key, SourceOperation::Rescan).await?;
2452        }
2453        gate.arm(
2454            source_stage_operation_id(key, SourceOperationStage::Terminal),
2455            AppendGatePhase::Before,
2456        );
2457        let first = first.clone();
2458        let first_terminal = tokio::spawn(async move {
2459            let mut session = first.inner.lock().await;
2460            let generation_id = session.checkout.generation_id();
2461            persist_source_operation_with_generation(
2462                &mut session,
2463                SourceState::Clean,
2464                generation_id,
2465                key,
2466                SourceOperation::Rescan,
2467            )
2468            .await
2469        });
2470        let second = second.clone();
2471        let second_terminal = tokio::spawn(async move {
2472            let mut session = second.inner.lock().await;
2473            let generation_id = session.checkout.generation_id();
2474            persist_source_operation_with_generation(
2475                &mut session,
2476                SourceState::Conflict,
2477                generation_id,
2478                key,
2479                SourceOperation::Rescan,
2480            )
2481            .await
2482        });
2483        gate.wait_for_arrivals(2).await;
2484        gate.release();
2485        let first_fact = first_terminal.await??;
2486        let second_fact = second_terminal.await??;
2487        assert_eq!(first_fact, second_fact);
2488        let first_session = first_workspace
2489            .source()
2490            .ok_or("first source missing")?
2491            .inner
2492            .lock()
2493            .await;
2494        let second_session = second_workspace
2495            .source()
2496            .ok_or("second source missing")?
2497            .inner
2498            .lock()
2499            .await;
2500        assert_eq!(first_session.authority_head, second_session.authority_head);
2501        assert_eq!(first_session.state, second_session.state);
2502        assert_eq!(first_session.state, source_state(first_fact.state));
2503        Ok(())
2504    }
2505
2506    #[tokio::test]
2507    async fn dropped_source_future_recovers_after_each_durable_append_boundary()
2508    -> Result<(), Box<dyn std::error::Error>> {
2509        {
2510            let root = tempfile::tempdir()?;
2511            std::fs::write(root.path().join("value.txt"), b"one")?;
2512            let gate = Arc::new(AppendGate::new());
2513            let authority = GatedAuthorityStore {
2514                inner: Arc::new(crate::memory::MemoryAuthorityStore::default()),
2515                gate: Arc::clone(&gate),
2516            };
2517            let fs = Fs::new(
2518                authority.clone(),
2519                Arc::new(crate::memory::MemoryObjectStore::default()),
2520                crate::EmbeddedCapabilities::MEMORY,
2521            );
2522            let workspace = Box::pin(fs.attach_directory(
2523                "drop-after-begin",
2524                root.path(),
2525                SourceOptions::default(),
2526            ))
2527            .await?;
2528            let key = IdempotencyKey::from_bytes([59; 16]);
2529            gate.arm(
2530                source_stage_operation_id(key, SourceOperationStage::Begin),
2531                AppendGatePhase::After,
2532            );
2533            std::fs::write(root.path().join("value.txt"), b"two")?;
2534            let source = workspace.source().ok_or("source missing")?.clone();
2535            let operation = tokio::spawn(async move { source.rescan_with_key(key).await });
2536            gate.wait_for_arrivals(1).await;
2537            operation.abort();
2538            assert!(operation.await.is_err_and(|error| error.is_cancelled()));
2539            gate.release();
2540
2541            let source = workspace.source().ok_or("source missing")?;
2542            let recovered = clean_generation(Box::pin(source.rescan_with_key(key)).await?)?;
2543            assert_eq!(
2544                recovered.read("/value.txt", 8).await?,
2545                Bytes::from_static(b"two")
2546            );
2547            std::fs::write(root.path().join("value.txt"), b"three")?;
2548            let current = clean_generation(Box::pin(source.rescan()).await?)?;
2549            let current_head = workspace.head().await?;
2550            assert_eq!(current_head.id(), current.id());
2551            assert_eq!(
2552                current.read("/value.txt", 8).await?,
2553                Bytes::from_static(b"three")
2554            );
2555            assert_eq!(
2556                clean_generation(Box::pin(source.rescan_with_key(key)).await?)?.id(),
2557                recovered.id()
2558            );
2559            assert_eq!(workspace.head().await?.id(), current.id());
2560            assert_eq!(source.state().await, SourceState::Clean);
2561            let session = source.inner.lock().await;
2562            let durable_head = authority
2563                .inner
2564                .head(
2565                    source_authority_id(workspace.volume.id()),
2566                    WorkBudget::UNBOUNDED,
2567                    &CancellationToken::new(),
2568                )
2569                .await?
2570                .value;
2571            assert_eq!(session.authority_head, durable_head);
2572        }
2573
2574        for terminal_append in [false, true] {
2575            let root = tempfile::tempdir()?;
2576            std::fs::write(root.path().join("value.txt"), b"one")?;
2577            let gate = Arc::new(AppendGate::new());
2578            let authority = GatedAuthorityStore {
2579                inner: Arc::new(crate::memory::MemoryAuthorityStore::default()),
2580                gate: Arc::clone(&gate),
2581            };
2582            let objects = Arc::new(crate::memory::MemoryObjectStore::default());
2583            let name = if terminal_append {
2584                "drop-after-terminal"
2585            } else {
2586                "drop-after-volume"
2587            };
2588            let fs = Fs::new(
2589                authority.clone(),
2590                Arc::clone(&objects),
2591                crate::EmbeddedCapabilities::MEMORY,
2592            );
2593            let workspace =
2594                Box::pin(fs.attach_directory(name, root.path(), SourceOptions::default())).await?;
2595            let key = IdempotencyKey::from_bytes([60 + u8::from(terminal_append); 16]);
2596            let target = if terminal_append {
2597                source_stage_operation_id(key, SourceOperationStage::Terminal)
2598            } else {
2599                source_volume_operation_id(key, SourceOperation::Rescan)
2600            };
2601            gate.arm(target, AppendGatePhase::After);
2602            std::fs::write(root.path().join("value.txt"), b"two")?;
2603            let source = workspace.source().ok_or("source missing")?.clone();
2604            let operation = tokio::spawn(async move { source.rescan_with_key(key).await });
2605            gate.wait_for_arrivals(1).await;
2606            operation.abort();
2607            assert!(operation.await.is_err_and(|error| error.is_cancelled()));
2608            gate.release();
2609
2610            std::fs::write(root.path().join("value.txt"), b"three")?;
2611            drop(workspace);
2612            drop(fs);
2613            let fs = Fs::new(
2614                authority.clone(),
2615                objects,
2616                crate::EmbeddedCapabilities::MEMORY,
2617            );
2618            let workspace =
2619                Box::pin(fs.attach_directory(name, root.path(), SourceOptions::default())).await?;
2620            let source = workspace.source().ok_or("source missing")?;
2621            let current = workspace.head().await?;
2622            assert_eq!(
2623                current.read("/value.txt", 8).await?,
2624                Bytes::from_static(b"three")
2625            );
2626            let outcome = Box::pin(source.rescan_with_key(key)).await?;
2627            if terminal_append {
2628                assert_eq!(
2629                    clean_generation(outcome)?.read("/value.txt", 8).await?,
2630                    Bytes::from_static(b"two")
2631                );
2632                assert_eq!(source.state().await, SourceState::Clean);
2633            } else {
2634                assert!(matches!(outcome, ReconcileOutcome::Conflict));
2635                assert_eq!(source.state().await, SourceState::Conflict);
2636            }
2637            assert_eq!(workspace.head().await?.id(), current.id());
2638            let session = source.inner.lock().await;
2639            let durable_head = authority
2640                .inner
2641                .head(
2642                    source_authority_id(workspace.volume.id()),
2643                    WorkBudget::UNBOUNDED,
2644                    &CancellationToken::new(),
2645                )
2646                .await?
2647                .value;
2648            assert_eq!(session.authority_head, durable_head);
2649        }
2650        Ok(())
2651    }
2652
2653    #[tokio::test]
2654    async fn source_volume_identity_rejects_a_deliberately_aliased_generic_operation()
2655    -> Result<(), Box<dyn std::error::Error>> {
2656        let root = tempfile::tempdir()?;
2657        std::fs::write(root.path().join("source.txt"), b"one")?;
2658        let fs = Fs::memory();
2659        let workspace = Box::pin(fs.attach_directory(
2660            "domain-separated",
2661            root.path(),
2662            SourceOptions::default(),
2663        ))
2664        .await?;
2665        let source = workspace.source().ok_or("source missing")?;
2666        let key = IdempotencyKey::from_bytes([42; 16]);
2667        {
2668            let mut session = source.inner.lock().await;
2669            begin_source_operation(&mut session, key, SourceOperation::Rescan).await?;
2670        }
2671        let forged_key = IdempotencyKey::from_bytes(
2672            source_volume_operation_id(key, SourceOperation::Rescan).into_bytes(),
2673        );
2674        let mut unrelated = workspace.begin_transaction(forged_key).await?;
2675        unrelated
2676            .write("/unrelated.txt", Bytes::from_static(b"generic"))
2677            .await?;
2678        let unrelated = match unrelated.commit().await? {
2679            crate::TransactionCommit::Committed(generation)
2680            | crate::TransactionCommit::AlreadyCommitted(generation) => generation,
2681            crate::TransactionCommit::Conflict { .. }
2682            | crate::TransactionCommit::Fenced
2683            | crate::TransactionCommit::IdempotencyConflict => {
2684                return Err("unexpected generic transaction rejection".into());
2685            }
2686        };
2687        std::fs::write(root.path().join("source.txt"), b"two")?;
2688        assert!(matches!(
2689            Box::pin(source.rescan_with_key(key)).await,
2690            Err(SourceError::IdempotencyConflict)
2691        ));
2692        assert_eq!(workspace.head().await?.id(), unrelated.id());
2693        assert_eq!(source.state().await, SourceState::PendingCapture);
2694        Ok(())
2695    }
2696
2697    #[tokio::test]
2698    async fn ambiguous_volume_and_terminal_appends_recover_the_exact_source_generation()
2699    -> Result<(), Box<dyn std::error::Error>> {
2700        for terminal_fault in [false, true] {
2701            let simulation = crate::Simulation::default();
2702            let (authority, objects) = simulation.stores();
2703            let fs = Fs::new(
2704                authority.clone(),
2705                objects.clone(),
2706                crate::EmbeddedCapabilities::MEMORY,
2707            );
2708            let root = tempfile::tempdir()?;
2709            std::fs::write(root.path().join("value.txt"), b"one")?;
2710            let workspace = Box::pin(fs.attach_directory(
2711                if terminal_fault {
2712                    "ambiguous-terminal"
2713                } else {
2714                    "ambiguous-volume"
2715                },
2716                root.path(),
2717                SourceOptions::default(),
2718            ))
2719            .await?;
2720            let key = IdempotencyKey::from_bytes([50 + u8::from(terminal_fault); 16]);
2721            {
2722                let source = workspace.source().ok_or("source missing")?;
2723                let mut session = source.inner.lock().await;
2724                begin_source_operation(&mut session, key, SourceOperation::Rescan).await?;
2725            }
2726            std::fs::write(root.path().join("value.txt"), b"two")?;
2727            simulation.schedule(crate::ScheduledSimulationFault {
2728                operation: crate::SimulationOperation::AuthorityAppend,
2729                occurrence: next_authority_append(&simulation)? + u64::from(terminal_fault),
2730                fault: crate::SimulationFault::AmbiguousAuthorityAppend,
2731            })?;
2732            {
2733                let source = workspace.source().ok_or("source missing")?;
2734                assert!(matches!(
2735                    Box::pin(source.rescan_with_key(key)).await,
2736                    Err(SourceError::Engine(_))
2737                ));
2738            }
2739            std::fs::write(root.path().join("value.txt"), b"three")?;
2740            drop(workspace);
2741            drop(fs);
2742            let fs = Fs::new(authority, objects, crate::EmbeddedCapabilities::MEMORY);
2743            let workspace = Box::pin(fs.attach_directory(
2744                if terminal_fault {
2745                    "ambiguous-terminal"
2746                } else {
2747                    "ambiguous-volume"
2748                },
2749                root.path(),
2750                SourceOptions::default(),
2751            ))
2752            .await?;
2753            let source = workspace.source().ok_or("source missing")?;
2754            let current = workspace.head().await?;
2755            assert_eq!(
2756                current.read("/value.txt", 8).await?,
2757                Bytes::from_static(b"three")
2758            );
2759            let outcome = Box::pin(source.rescan_with_key(key)).await?;
2760            if terminal_fault {
2761                assert_eq!(
2762                    clean_generation(outcome)?.read("/value.txt", 8).await?,
2763                    Bytes::from_static(b"two")
2764                );
2765                assert_eq!(source.state().await, SourceState::Clean);
2766            } else {
2767                assert!(matches!(outcome, ReconcileOutcome::Conflict));
2768                assert_eq!(source.state().await, SourceState::Conflict);
2769            }
2770            assert_eq!(workspace.head().await?.id(), current.id());
2771        }
2772        Ok(())
2773    }
2774
2775    #[cfg(feature = "local")]
2776    #[tokio::test]
2777    async fn restart_recovers_publication_missing_its_terminal_source_record()
2778    -> Result<(), Box<dyn std::error::Error>> {
2779        let storage = tempfile::tempdir()?;
2780        let root = tempfile::tempdir()?;
2781        std::fs::write(root.path().join("value.txt"), b"one")?;
2782        let options = crate::LocalOptions::new(storage.path());
2783        let key = IdempotencyKey::from_bytes([43; 16]);
2784        {
2785            let fs = Fs::local(options.clone()).await?;
2786            let workspace =
2787                Box::pin(fs.attach_directory("recover", root.path(), SourceOptions::default()))
2788                    .await?;
2789            let source = workspace.source().ok_or("source missing")?;
2790            std::fs::write(root.path().join("value.txt"), b"two")?;
2791            publish_without_terminal(source, key, SourceOperation::Rescan).await?;
2792        }
2793
2794        std::fs::write(root.path().join("value.txt"), b"three")?;
2795        {
2796            let fs = Fs::local(options.clone()).await?;
2797            let workspace =
2798                Box::pin(fs.attach_directory("recover", root.path(), SourceOptions::default()))
2799                    .await?;
2800            let source = workspace.source().ok_or("source missing")?;
2801            let current = workspace.head().await?;
2802            assert_eq!(
2803                current.read("/value.txt", 8).await?,
2804                Bytes::from_static(b"three")
2805            );
2806            assert!(matches!(
2807                Box::pin(source.rescan_with_key(key)).await?,
2808                ReconcileOutcome::Conflict
2809            ));
2810            assert_eq!(workspace.head().await?.id(), current.id());
2811            assert_eq!(source.state().await, SourceState::Conflict);
2812        }
2813
2814        let seal_key = IdempotencyKey::from_bytes([44; 16]);
2815        {
2816            let fs = Fs::local(options.clone()).await?;
2817            let workspace =
2818                Box::pin(fs.attach_directory("recover", root.path(), SourceOptions::default()))
2819                    .await?;
2820            let source = workspace.source().ok_or("source missing")?;
2821            std::fs::write(root.path().join("value.txt"), b"four")?;
2822            publish_without_terminal(source, seal_key, SourceOperation::Seal).await?;
2823        }
2824
2825        std::fs::write(root.path().join("value.txt"), b"five")?;
2826        {
2827            let fs = Fs::local(options.clone()).await?;
2828            let workspace =
2829                Box::pin(fs.attach_directory("recover", root.path(), SourceOptions::default()))
2830                    .await?;
2831            let source = workspace.source().ok_or("source missing")?;
2832            let current = workspace.head().await?;
2833            assert!(matches!(
2834                Box::pin(source.seal_with_key(seal_key)).await,
2835                Err(SourceError::Conflict)
2836            ));
2837            assert_eq!(workspace.head().await?.id(), current.id());
2838            assert_eq!(source.state().await, SourceState::Conflict);
2839        }
2840
2841        let fs = Fs::local(options).await?;
2842        let workspace =
2843            Box::pin(fs.attach_directory("recover", root.path(), SourceOptions::default())).await?;
2844        let source = workspace.source().ok_or("source missing")?;
2845        assert_eq!(
2846            workspace.read("/value.txt", 8).await?,
2847            Bytes::from_static(b"five")
2848        );
2849        assert!(matches!(
2850            Box::pin(source.rescan_with_key(key)).await?,
2851            ReconcileOutcome::Conflict
2852        ));
2853        assert!(matches!(
2854            Box::pin(source.seal_with_key(seal_key)).await,
2855            Err(SourceError::Conflict)
2856        ));
2857        assert_eq!(
2858            workspace.read("/value.txt", 8).await?,
2859            Bytes::from_static(b"five")
2860        );
2861        assert_eq!(source.state().await, SourceState::Clean);
2862        Ok(())
2863    }
2864}