@@ -4784,15 +4784,18 @@ func (s *Syncer) applyRemoteSnapshotDeletes(remotePaths map[string]struct{}, con
47844784// 1. Refuse the destructive pass entirely when the cloud-error circuit
47854785// is open (read-only mirror remains; the next healthy cycle catches
47864786// up). Layout materialization is still safe to run.
4787- // 2. Refuse when the observedRevision does not strictly advance past
4787+ // 2. Treat the first complete bootstrap observation with no revision as a
4788+ // non-destructive no-op. This lets the caller persist bootstrap completion
4789+ // without creating or confirming tombstones.
4790+ // 3. Refuse when the observedRevision does not strictly advance past
47884791// state.LastAppliedRevision — an older or equal listing must not
47894792// authorize deletes.
4790- // 3 . For every tracked path missing from the fresh listing, write or
4793+ // 4 . For every tracked path missing from the fresh listing, write or
47914794// confirm a tombstone under .relay/pending-deletes. Only the second
47924795// consecutive confirmation actually deletes; the first observation
47934796// is recorded and skipped.
4794- // 4 . After the pass, prune tombstones for paths that have reappeared.
4795- // 5 . On a clean pass, advance state.LastAppliedRevision.
4797+ // 5 . After the pass, prune tombstones for paths that have reappeared.
4798+ // 6 . On a clean pass, advance state.LastAppliedRevision.
47964799func (s * Syncer ) applyRemoteSnapshotDeletesRev (remotePaths map [string ]struct {}, conflicted map [string ]struct {}, observedRevision string ) error {
47974800 if err := s .materializeProviderLayoutsFromPaths (remotePaths ); err != nil {
47984801 return err
@@ -4805,10 +4808,21 @@ func (s *Syncer) applyRemoteSnapshotDeletesRev(remotePaths map[string]struct{},
48054808 return nil
48064809 }
48074810
4811+ // The first completed bootstrap traversal may legitimately have no
4812+ // revision. Let the caller record that completion, but do not allow this
4813+ // unversioned observation to enter tombstone creation or confirmation.
4814+ // BootstrapComplete is persisted immediately after the successful
4815+ // traversal, so later unversioned observations fall through to the normal
4816+ // revision gate and are counted as blocked.
4817+ if strings .TrimSpace (observedRevision ) == "" &&
4818+ strings .TrimSpace (s .state .LastAppliedRevision ) == "" &&
4819+ ! s .state .BootstrapComplete {
4820+ s .logf ("snapshot delete pass skipped: first bootstrap observation has no revision; destructive reconciliation deferred" )
4821+ return nil
4822+ }
4823+
48084824 // Revision gate: refuse to act on a listing that does not strictly
4809- // advance the highest-applied revision. revisionAdvances treats an
4810- // empty observedRevision as "unknown" — which is also refused. An
4811- // empty stored LastAppliedRevision allows the first advancement.
4825+ // advance the highest-applied revision.
48124826 if ! revisionAdvances (s .state .LastAppliedRevision , observedRevision ) {
48134827 s .state .Counters .SnapshotDeleteBlocked ++
48144828 s .logf ("snapshot delete pass refused: observed revision %q does not advance past last applied %q" ,
@@ -4870,7 +4884,10 @@ func (s *Syncer) applyRemoteSnapshotDeletesRev(remotePaths map[string]struct{},
48704884// Revisions in this codebase look like "rev_<int>" (see fakeClient) but
48714885// real cloud revisions may be opaque; we compare numerically when both
48724886// match the rev_<int> shape, and lexicographically otherwise. An empty
4873- // observed is never an advancement.
4887+ // observed is never an advancement. Bootstrap callers that need to accept a
4888+ // first unversioned observation must keep that exception local to their
4889+ // persisted bootstrap-completion gate; this generic predicate is also used by
4890+ // strict candidate ordering.
48744891func revisionAdvances (last , observed string ) bool {
48754892 observed = strings .TrimSpace (observed )
48764893 last = strings .TrimSpace (last )
0 commit comments