Skip to main content

flint_lean/
gated.rs

1//! Gated manifest advance: durability split from visibility
2//! (boundary-verbs plan §2.4 — D6, D7, D8, D13).
3//!
4//! In `hybrid` (the default) uploads and citations coincide, so the
5//! uncited-object set is exactly today's crash-window set. `gated`
6//! splits them:
7//!
8//! - **Upload lane (durability), every floor tick.** Renew, verify
9//!   epoch, consume the inbox, scan, stage-diff, guarded uploads — but
10//!   the PUTs land **in place, as new object versions**, and no
11//!   manifest CAS runs. RPO for bytes stays at the floor.
12//! - **Citation lane (visibility), at coherent points only.** ONE CAS
13//!   installing the entire pending set — the versions already exist, so
14//!   there is no copy phase — then version-scoped GC of superseded
15//!   generations and the baseline rewrite.
16//!
17//! **Why versions and not a staging prefix.** The copy design rested on
18//! one premise: `upload_one` PUTs to `files/<path>`, so a gated eager
19//! upload landing in place would DESTROY the cited object. On a
20//! versioned bucket that premise is false — an in-place PUT destroys
21//! nothing, the cited generation survives as a version, and a citation
22//! becomes one CAS naming versions that already exist: O(1) requests
23//! per boundary instead of O(dirty files), atomic by construction
24//! rather than by a recovery protocol. What it deletes is machinery:
25//! the `eager/` namespace, per-citation `CopyObject` (and
26//! `UploadPartCopy` for >5 GiB files), the citation-intent document and
27//! successor roll-forward, the stage-NotFound arm, and the transient
28//! copy-phase reader window.
29//!
30//! **What it costs, stated rather than hidden.** Uncited generations
31//! are the CURRENT version of the real key, so any reader that does not
32//! resolve through the manifest — an import tool, `aws s3 cp`, a human —
33//! sees mid-logical-change bytes where it previously saw the last
34//! boundary. Flint's own readers qualify as manifest-resolving under
35//! D13, so the guarantee holds where it is promised, but the promise is
36//! scoped to manifest-resolving readers (§3 residual 11).
37//!
38//! **The lane opens NO HITL window.** The window exists to fence HITL
39//! out of the upload→CAS span; a lane with no CAS that opened a
40//! 180 s-deadline window every 60 s would refuse HITL admission
41//! essentially forever between citations — and it protects nothing,
42//! because a lane PUT creates a new version and destroys nothing HITL
43//! wrote. Window open/clear belong exclusively to the citation lane.
44
45use std::collections::{BTreeMap, BTreeSet};
46
47use serde::{Deserialize, Serialize};
48
49use flint_store::{crc64_nvme, crc64_to_b64, GenerationStamps, ListedVersion, StoreError};
50
51use super::manifest::{self, LeanEntry};
52use super::state::{BaselineEntry, ConflictRecord};
53use super::{inbox, now_unix, scan, BoundaryMode, LeanError, LeanResult, Syncer};
54
55const PENDING: &str = "pending.json";
56
57/// One staged-but-uncited generation.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct PendingEntry {
60    pub key: String,
61    pub etag: String,
62    /// The publisher's CRC over the bytes it staged (see
63    /// `LeanEntry::crc64_b64`).
64    pub crc64_b64: String,
65    pub size: u64,
66    pub mode: u32,
67    pub mtime_unix: i64,
68    pub generation: u64,
69    pub epoch: u64,
70    /// The version this staging PUT created.
71    pub version_id: Option<String>,
72    /// The version the BASELINE cited when we staged (D7's
73    /// re-validation guard). If the baseline's version has moved by
74    /// citation time, a HITL consume or sync landed after staging, and
75    /// installing our staged version would UNCITE the foreign bytes.
76    pub base_version_id: Option<String>,
77    pub staged_unix: u64,
78}
79
80/// The gated lane's durable bookkeeping — its own file, so
81/// `clear_intent_keys` cannot take it with the intent journal.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct PendingStage {
84    pub entries: BTreeMap<String, PendingEntry>,
85    /// Delete classifications WITHHELD from the manifest until a
86    /// citation pass (§2.4.2): a rename r→s must not become
87    /// reader-visible as r-gone/s-absent at an undeclared point.
88    #[serde(default)]
89    pub withheld_deletes: BTreeSet<String>,
90    pub last_citation_unix: u64,
91    /// Scan-to-scan stability tracking (§2.4.1's quiescence, as
92    /// corrected: "no scan diff vs baseline" could never fire once
93    /// anything was pending, since pending paths stay
94    /// classified-changed until citation — quiescence would have been
95    /// dead code and every citation would ride the lag cap).
96    pub last_scan_fingerprint: Option<String>,
97    pub stable_since_unix: u64,
98    /// Legacy-cited paths (no `version_id`) whose cited etag matched no
99    /// surviving version, so the backfill below could not make them
100    /// addressable. Remembered so one unresolvable entry does not buy a
101    /// whole version listing at every subsequent citation.
102    #[serde(default)]
103    pub legacy_unresolved: BTreeSet<String>,
104    /// Staged paths the last lane pass did not see on disk. A staged
105    /// path that was never CITED has no baseline entry, so
106    /// `scan::classify` cannot call it a delete — it cannot see it at
107    /// all — and the stage would carry its version into a boundary for
108    /// a file the agent removed. This is that path's own
109    /// two-observations memory, the rename-vs-walk guard applied where
110    /// classify cannot reach.
111    #[serde(default)]
112    pub staged_absent_once: BTreeSet<String>,
113    /// Versions this workspace superseded or cancelled between
114    /// citations, per path — RECORDED by the lane, DELETED only by the
115    /// guarded citation-time reaper.
116    ///
117    /// The lane used to delete these itself, with one guard ("not the
118    /// version I just wrote") and no reference to the installed
119    /// manifest. `citation_pass` clears the stage LAST — after the CAS,
120    /// the reaper, the baseline save and the intent clear — and four
121    /// ordinary `?` returns sit in that window, so one transient store
122    /// error leaves a stage naming versions the boundary now CITES. The
123    /// next lane pass then deleted the cited version. One guarded
124    /// delete site was the fix; this ledger is what keeps the
125    /// intermediate versions NAMEABLE while it is the only one.
126    ///
127    /// Without it, `base_version_id` is pinned to the cited generation
128    /// (it comes from the baseline, which gated mode advances only at
129    /// citation), so a path re-staged N times between citations leaves
130    /// versions 2..N named by nothing at all — falling through to
131    /// `noncurrentRetentionDays`, which D8 is explicit is a crash-window
132    /// backstop and not a GC policy, because it runs its clock against
133    /// live cited data.
134    #[serde(default)]
135    pub pending_reclaims: BTreeMap<String, Vec<String>>,
136}
137
138/// Which coherent point fired.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum CitationSource {
141    Sentinel,
142    Quiescence,
143    ForcedLagCap,
144    ForcedBacklogCap,
145    Cadence,
146    Drain,
147    Recovered,
148    /// A repair-only pass: nothing of the agent's is staged, but the
149    /// manifest cites an older object than the one this syncer has
150    /// already INTEGRATED (a consumed HITL write, checkout's S3-wins
151    /// arm). §2.4.2 exempts these from gating, and D13's promise to
152    /// pinned readers — "within one floor" — is this source.
153    Repair,
154}
155
156impl CitationSource {
157    pub fn as_str(&self) -> &'static str {
158        match self {
159            CitationSource::Sentinel => "sentinel",
160            CitationSource::Quiescence => "quiescence",
161            CitationSource::ForcedLagCap => "forced-lag-cap",
162            CitationSource::ForcedBacklogCap => "forced-backlog-cap",
163            CitationSource::Cadence => "cadence",
164            CitationSource::Drain => "drain",
165            CitationSource::Recovered => "recovered",
166            CitationSource::Repair => "repair",
167        }
168    }
169}
170
171/// Re-verify the lease every N paths inside the reaper. The loop is
172/// O(cited) sequential round trips with no renewal arm behind it, so a
173/// long sweep both starves the lease and outlives the holder's right to
174/// delete. 8 keeps the added epoch reads under ~12% of the LISTs the
175/// sweep already pays for.
176const RECLAIM_FENCE_EVERY: usize = 8;
177
178#[derive(Debug, Default)]
179pub struct LaneReport {
180    pub staged: Vec<String>,
181    pub parked: Vec<String>,
182    pub consumed: usize,
183    pub staged_bytes: u64,
184    /// Superseded uncited versions reclaimed by the lane itself (free,
185    /// version-scoped): uncited work holds at most one version per path
186    /// plus crash remnants.
187    /// Versions the lane RECORDED for the reaper to reclaim at the
188    /// next citation. The lane deletes nothing.
189    pub superseded_recorded: usize,
190    pub withheld_deletes: usize,
191    /// Paths this lane pass saw absent for the FIRST time — withheld to
192    /// the next scan by the rename-vs-walk guard, and so NOT part of
193    /// the boundary a citation from this pass would install.
194    pub first_absence: Vec<String>,
195    /// First-absence paths a DECLARED lane pass confirmed gone by
196    /// direct lstat and staged as ordinary withheld deletes.
197    pub absences_confirmed: usize,
198}
199
200#[derive(Debug, Default)]
201pub struct CitationReport {
202    pub seq: Option<u64>,
203    pub source: Option<String>,
204    pub cited: usize,
205    pub dropped_stale_base: Vec<String>,
206    /// Paths dropped because a HITL write landed between the lane's
207    /// consume and this citation's window. Unlike the stale-base drop,
208    /// the agent's latest bytes are NOT in the installed boundary — so
209    /// an ack for a declared point that names one of these is a lie
210    /// (D1's at-least guarantee).
211    pub dropped_inflight: Vec<String>,
212    /// Paths re-cited onto bytes this syncer had already integrated —
213    /// no data moved (§2.4.2's ungated repair).
214    pub repaired: Vec<String>,
215    /// Inherited entries this boundary made version-addressable before
216    /// stamping `pinned_reads` over them.
217    pub backfilled: Vec<String>,
218    pub deleted: Vec<String>,
219    /// Versions reaped by flint's EXACT per-citation GC — the reaper.
220    /// Lifecycle is only the backstop (D8), and on `files/` it cannot
221    /// tell cited from uncited at all.
222    pub versions_reclaimed: usize,
223    pub no_change: bool,
224}
225
226/// What one `recover-staged` pass found and installed (D9).
227#[derive(Debug, Default)]
228pub struct RecoverReport {
229    /// Whether D9's durable summary drove this pass (the cheap path) or
230    /// it fell back to the prefix-wide version LIST.
231    pub from_summary: bool,
232    /// Paths re-cited onto their surviving current version.
233    pub recited: Vec<String>,
234    /// Paths whose CITED version no longer exists — the D8
235    /// abandoned-mid-stage endgame, where the noncurrent backstop
236    /// reaped live cited data. A subset of `recited` when a newer
237    /// version survives to roll forward onto.
238    pub dangling: Vec<String>,
239    /// Dangling with NOTHING left to cite: the backstop reaped the
240    /// cited version and no newer generation exists. Named loudly
241    /// because no verb can fix it — the bytes are gone.
242    pub unrecoverable: Vec<String>,
243    pub seq: Option<u64>,
244    pub no_change: bool,
245}
246
247/// D9's durable orphan summary — the bucket-side answer to "is there
248/// work here that no manifest cites?", readable by the operator, a
249/// sibling cluster, or a human, long after the pod that staged it.
250#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
251pub struct OrphanDoc {
252    pub written_unix: u64,
253    /// The epoch that staged them — a summary from a deposed holder is
254    /// still true about the bytes, and saying whose it was lets a
255    /// reader tell a live backlog from a dead one.
256    pub epoch: u64,
257    pub boundary_mode: String,
258    pub candidates: Vec<OrphanCandidate>,
259}
260
261#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
262pub struct OrphanCandidate {
263    pub path: String,
264    pub version_id: Option<String>,
265    pub size: u64,
266    pub generation: u64,
267    pub epoch: u64,
268    pub staged_unix: u64,
269}
270
271impl Syncer {
272    fn stage_path(&self) -> std::path::PathBuf {
273        self.cfg.state_dir().join(PENDING)
274    }
275
276    pub fn load_stage(&self) -> LeanResult<PendingStage> {
277        let p = self.stage_path();
278        if !p.exists() {
279            return Ok(PendingStage::default());
280        }
281        let bytes = std::fs::read(&p)?;
282        Ok(serde_json::from_slice(&bytes).unwrap_or_default())
283    }
284
285    pub fn save_stage(&self, s: &PendingStage) -> LeanResult<()> {
286        let bytes =
287            serde_json::to_vec_pretty(s).map_err(|e| LeanError::State(format!("pending: {e}")))?;
288        super::control::write_atomic(&self.stage_path(), &bytes)
289    }
290
291    /// The key D9's durable orphan summary lives at.
292    pub fn orphans_key(&self) -> String {
293        format!("{}/{}/orphans.json", self.cfg.prefix, super::LEAN_DIR)
294    }
295
296    /// Surface the staged-but-uncited set into the BUCKET (D9).
297    ///
298    /// `pending.json` lives on the emptyDir, which is exactly the thing
299    /// a pure-spot replacement destroys — so on the routine failure of
300    /// this fleet the pending record can name nothing, and the operator
301    /// (a different process, often a different cluster) has no way to
302    /// know that durable work is sitting uncited. This is that summary,
303    /// and it is written where it survives cluster loss.
304    ///
305    /// Written only when the candidate set CHANGES: a re-PUT per tick
306    /// would be a request the design does not need, and an empty doc
307    /// rewritten forever would be worse than none. The clearing write
308    /// matters as much as the surfacing one — a stale summary claiming
309    /// candidates pages an operator about work that was cited minutes
310    /// ago.
311    pub async fn surface_orphans(&self, stage: &PendingStage) -> LeanResult<bool> {
312        let epoch = self.lease.as_ref().map(|l| l.epoch).unwrap_or(0);
313        let candidates: Vec<OrphanCandidate> = stage
314            .entries
315            .iter()
316            .map(|(path, e)| OrphanCandidate {
317                path: path.clone(),
318                version_id: e.version_id.clone(),
319                size: e.size,
320                generation: e.generation,
321                epoch: e.epoch,
322                staged_unix: e.staged_unix,
323            })
324            .collect();
325        let fingerprint = {
326            let mut h = flint_store::Crc64Nvme::new();
327            for c in &candidates {
328                h.update(c.path.as_bytes());
329                h.update(c.version_id.as_deref().unwrap_or("").as_bytes());
330            }
331            format!("{:016x}", h.finalize())
332        };
333        let marker = self.cfg.state_dir().join("orphans-fingerprint");
334        if std::fs::read_to_string(&marker).ok().as_deref() == Some(fingerprint.as_str()) {
335            return Ok(false);
336        }
337        let doc = OrphanDoc {
338            written_unix: super::now_unix(),
339            epoch,
340            boundary_mode: self.cfg.boundary_mode.as_str().to_string(),
341            candidates,
342        };
343        let bytes = bytes::Bytes::from(
344            serde_json::to_vec_pretty(&doc).map_err(|e| LeanError::State(format!("orphans: {e}")))?,
345        );
346        let crc = flint_store::crc64_nvme(&bytes);
347        let stamps = GenerationStamps {
348            generation: 0,
349            epoch,
350            flush_uuid: "orphan-summary".into(),
351            boundary_source: None,
352            posix: None,
353        };
354        // Unconditional: this is a summary, not a protocol cell. A
355        // successor's summary must overwrite a dead holder's, and a
356        // 412-parked orphan doc would strand the very information the
357        // successor needs.
358        let key = self.orphans_key();
359        let cond = match self.store.head(&key).await {
360            Ok(m) => flint_store::PutCondition::IfMatch(m.etag),
361            Err(_) => flint_store::PutCondition::IfNoneMatchAny,
362        };
363        match self.store.put_whole(&key, bytes, &cond, &stamps, crc).await {
364            Ok(_) => {}
365            // Losing the race is fine: whoever won wrote a summary at
366            // least as new as ours, and the next change re-runs this.
367            Err(StoreError::PreconditionFailed(_)) => return Ok(false),
368            Err(e) => return Err(e.into()),
369        }
370        let _ = super::control::write_atomic(&marker, fingerprint.as_bytes());
371        Ok(true)
372    }
373
374    /// The upload lane: durability, every floor tick, no citation.
375    pub async fn upload_lane(&mut self) -> LeanResult<LaneReport> {
376        self.lane_inner(false).await
377    }
378
379    /// The lane pass behind a DECLARED boundary — a publish sentinel
380    /// (D1 × D6) or the preStop drain (D10). The citation that follows
381    /// is going to be acked, so a delete the agent made before the
382    /// touch may not sit out the boundary waiting for a second walk:
383    /// `confirm_absences` takes that observation by lstat instead.
384    pub async fn declared_lane(&mut self) -> LeanResult<LaneReport> {
385        self.lane_inner(true).await
386    }
387
388    async fn lane_inner(&mut self, declared: bool) -> LeanResult<LaneReport> {
389        let epoch = self
390            .lease
391            .as_ref()
392            .map(|l| l.epoch)
393            .ok_or_else(|| LeanError::State("upload lane without a held lease".into()))?;
394        let mut report = LaneReport::default();
395        self.verify_not_deposed_pub().await?;
396
397        // Inbound HITL adoption stays exactly today's behavior: a HITL
398        // write is already a coherent, whole-object act by a foreign
399        // author, so it is not gated.
400        let consumed = self.consume_inbox().await?;
401        report.consumed = consumed.len();
402        if !consumed.is_empty() {
403            inbox::drop_entries(self.store.as_ref(), &self.cfg, epoch, &consumed).await?;
404        }
405
406        let mut baseline = self.state.load_baseline()?;
407        let scanned = scan::scan(&self.cfg.root)?;
408        let mut classified = scan::classify(&scanned, &baseline);
409        if declared {
410            report.absences_confirmed = self.confirm_absences(&mut classified)?;
411        }
412        report.first_absence = classified.first_absence.iter().cloned().collect();
413        let mut stage = self.load_stage()?;
414
415        // Stage-diff base is baseline ∪ PENDING. Diffing against the
416        // baseline alone — which gated mode leaves un-advanced until
417        // citation — would re-PUT every staged-but-quiet file every
418        // tick: 50 quiet files × 60 ticks = 3,000 PUTs/hour vs today's
419        // 50, which would falsify the plan's own economics.
420        let to_stage: Vec<String> = classified
421            .uploads
422            .iter()
423            .filter(|p| match stage.entries.get(*p) {
424                None => true,
425                Some(pe) => {
426                    let s = &scanned[*p];
427                    pe.size != s.size || pe.mtime_unix != s.mtime_unix
428                }
429            })
430            .cloned()
431            .collect();
432
433        let flush_uuid = uuid::Uuid::new_v4().to_string();
434        let mut intent = self.state.load_intent()?;
435        let prior_uuids = {
436            let mut v = intent.recent_uuids.clone();
437            if !intent.flush_uuid.is_empty() {
438                v.push(intent.flush_uuid.clone());
439            }
440            v
441        };
442        intent = super::state::IntentJournal {
443            flush_uuid: flush_uuid.clone(),
444            keys: to_stage.iter().map(|p| self.cfg.file_key(p)).collect(),
445            recent_uuids: prior_uuids.clone(),
446            installed_etag: intent.installed_etag.clone(),
447        };
448        self.state.save_intent(&intent)?;
449
450        for path in &to_stage {
451            let scanned_entry = &scanned[path];
452            let base = baseline.entries.get(path).cloned();
453            let superseded = stage.entries.get(path).and_then(|p| p.version_id.clone());
454            let outcome = self
455                .stage_one(path, scanned_entry, base.as_ref(), epoch, &flush_uuid, &prior_uuids)
456                .await?;
457            match outcome {
458                Some(entry) => {
459                    report.staged_bytes += entry.size;
460                    report.staged.push(path.clone());
461                    // The lane reclaims the version it just superseded:
462                    // free, version-scoped, and it keeps uncited work at
463                    // one version per path plus crash remnants.
464                    // RECORD, never delete. The lane cannot prove a
465                    // version is uncited: it does not read the installed
466                    // manifest, and its own stage may have outlived a
467                    // citation. The reaper can, and does, under four
468                    // guards.
469                    if let Some(vid) = superseded {
470                        if Some(&vid) != entry.version_id.as_ref() {
471                            stage
472                                .pending_reclaims
473                                .entry(path.clone())
474                                .or_default()
475                                .push(vid);
476                            report.superseded_recorded += 1;
477                        }
478                    }
479                    // The path is back. A tombstone standing from an
480                    // earlier tick would otherwise ride into the same
481                    // citation as this upsert, and merge applies
482                    // deletes last: delete-then-recreate inside one
483                    // citation interval would install a boundary that
484                    // omits a file present on disk and staged this
485                    // pass.
486                    stage.withheld_deletes.remove(path);
487                    stage.entries.insert(path.clone(), entry);
488                }
489                None => report.parked.push(path.clone()),
490            }
491        }
492
493        // A staged version is cancelled by the file's departure, the
494        // mirror of the tombstone cancellation above. The lane has now
495        // seen the path GONE, later than it saw it present; a citation
496        // carrying both the stage entry and the tombstone would install
497        // one of them by accident of merge order, and merge has no way
498        // to know which came last. Reclaiming the version costs
499        // nothing — it was never cited.
500        let mut absent_now: BTreeSet<String> = BTreeSet::new();
501        let staged_paths: Vec<String> = stage.entries.keys().cloned().collect();
502        let mut cancel: Vec<String> = vec![];
503        for path in staged_paths {
504            if scanned.contains_key(&path) {
505                continue;
506            }
507            if classified.deletes.contains(&path) {
508                cancel.push(path);
509            } else if stage.staged_absent_once.contains(&path)
510                || (declared
511                    && std::fs::symlink_metadata(self.cfg.root.join(&path)).is_err())
512            {
513                // Never cited, so classify cannot see it: two lane
514                // observations, or one direct lstat on a DECLARED
515                // boundary, stand in for the two-scan rule.
516                cancel.push(path);
517            } else {
518                absent_now.insert(path);
519            }
520        }
521        for path in cancel {
522            if let Some(stale) = stage.entries.remove(&path) {
523                // Same rule as the supersede site above: a cancelled
524                // path's staged version can be the CITED one if this
525                // stage outlived a citation, and this site had no
526                // manifest reference at all.
527                if let Some(vid) = stale.version_id {
528                    stage.pending_reclaims.entry(path.clone()).or_default().push(vid);
529                    report.superseded_recorded += 1;
530                }
531            }
532        }
533        stage.staged_absent_once = absent_now;
534
535        // Deletes are WITHHELD until a citation pass (unchanged rule).
536        for p in &classified.deletes {
537            stage.withheld_deletes.insert(p.clone());
538        }
539        report.withheld_deletes = stage.withheld_deletes.len();
540
541        // Quiescence = scan-to-scan STABILITY: no path's (size, mtime)
542        // changed between consecutive lane ticks and nothing new
543        // appeared.
544        let fingerprint = scan_fingerprint(&scanned);
545        if stage.last_scan_fingerprint.as_deref() == Some(fingerprint.as_str()) {
546            if stage.stable_since_unix == 0 {
547                stage.stable_since_unix = now_unix();
548            }
549        } else {
550            stage.last_scan_fingerprint = Some(fingerprint);
551            stage.stable_since_unix = 0;
552        }
553        if stage.last_citation_unix == 0 {
554            stage.last_citation_unix = now_unix();
555        }
556
557        // Mirror the cadence path's guard (barrier.rs, the no-change
558        // arm): `prev_scan` advances the two-consecutive-scans clock,
559        // but the baseline DOCUMENT is O(files) and this lane rewrote
560        // the whole thing every tick whether or not the scan set had
561        // moved — ~46 MB of emptyDir writeback per tick at the 250k cap,
562        // for an idle workspace. `prev_scan` is the ONLY field this
563        // function mutates, so the save is guarded on it alone.
564        // Both sides iterate in sorted order, so the sets compare
565        // without building one.
566        if scanned.len() != baseline.prev_scan.len()
567            || !scanned.keys().eq(baseline.prev_scan.iter())
568        {
569            baseline.prev_scan = scanned.keys().cloned().collect();
570            self.state.save_baseline(&baseline)?;
571        }
572        self.save_stage(&stage)?;
573        // D9: the same set, surfaced where a pure-spot replacement
574        // cannot destroy it. Best-effort by construction — a failed
575        // summary must never fail the lane that made the bytes durable.
576        if let Err(e) = self.surface_orphans(&stage).await {
577            eprintln!("flint-sync: orphan summary not written (retrying next tick): {e}");
578        }
579        // Durability moved even though visibility did not — the whole
580        // point of the split, and the number `rpo_secs` must track.
581        if !report.staged.is_empty() {
582            self.note_durable()?;
583        }
584        Ok(report)
585    }
586
587    /// One in-place staging PUT. The guard chain is unchanged (If-Match
588    /// on the recognized baseline etag, AdoptOwn, park on foreign); what
589    /// changes is that the response's version id is KEPT.
590    async fn stage_one(
591        &self,
592        path: &str,
593        scanned: &scan::ScanEntry,
594        base: Option<&BaselineEntry>,
595        epoch: u64,
596        flush_uuid: &str,
597        prior_uuids: &[String],
598    ) -> LeanResult<Option<PendingEntry>> {
599        let key = self.cfg.file_key(path);
600        match self.upload_one_pub(path, scanned, base, epoch, flush_uuid, prior_uuids).await? {
601            Some((entry, _be)) => Ok(Some(PendingEntry {
602                key,
603                etag: entry.etag,
604                crc64_b64: entry.crc64_b64,
605                size: entry.size,
606                mode: entry.mode,
607                mtime_unix: entry.mtime_unix,
608                generation: entry.generation,
609                epoch: entry.epoch,
610                version_id: entry.version_id,
611                base_version_id: base.and_then(|b| b.version_id.clone()),
612                staged_unix: now_unix(),
613            })),
614            None => {
615                self.state.append_conflict(&ConflictRecord {
616                    path: path.to_string(),
617                    foreign_etag: String::new(),
618                    preserved_key: None,
619                    kind: "stage-412-parked".into(),
620                    at_unix: now_unix(),
621                })?;
622                Ok(None)
623            }
624        }
625    }
626
627    /// Paths whose INTEGRATED object (the baseline) differs from the
628    /// manifest's citation: a consumed HITL write, or checkout's
629    /// S3-wins adoption. The fused barrier has carried this repair
630    /// since the amputation leg; gated mode never runs the fused
631    /// barrier, so without the same pass here an acked HITL write is
632    /// adopted into the tree, scans clean forever after, and stays
633    /// cited at its PREVIOUS version — invisible to exactly the
634    /// pinned readers D13 governs.
635    pub fn repair_candidates(&self) -> LeanResult<Vec<String>> {
636        let baseline = self.state.load_baseline()?;
637        let stage = self.load_stage()?;
638        Ok(baseline
639            .entries
640            .iter()
641            .filter(|(p, be)| {
642                be.size != u64::MAX // consume-dirty sentinel: publishes via the lane
643                    && !stage.entries.contains_key(*p)
644                    && !stage.withheld_deletes.contains(*p)
645                    && baseline.inst_base.get(*p) != Some(&be.etag)
646            })
647            .map(|(p, _)| p.clone())
648            .collect())
649    }
650
651    /// `(key, etag)` -> version id, over this subtree's whole version
652    /// history. One listing answers for every legacy entry at once,
653    /// including paths whose CURRENT version has already moved past
654    /// the citation — which a HEAD, by construction, cannot.
655    async fn version_index(&self) -> LeanResult<BTreeMap<(String, String), String>> {
656        let files_prefix = format!("{}/files/", self.cfg.prefix);
657        let mut ix: BTreeMap<(String, String), String> = BTreeMap::new();
658        // A backend that cannot list versions leaves every legacy entry
659        // unresolved, which the reader rule then refuses rather than
660        // adopts. Gated already refuses such a backend at startup.
661        for v in self.store.list_versions(&files_prefix).await.unwrap_or_default() {
662            if v.is_delete_marker {
663                continue;
664            }
665            ix.entry((v.key, v.etag)).or_insert(v.version_id);
666        }
667        Ok(ix)
668    }
669
670    /// Which coherent point, if any, is due right now.
671    pub fn citation_due(&self, sentinel_pending: bool) -> LeanResult<Option<CitationSource>> {
672        let stage = self.load_stage()?;
673        let now = now_unix();
674        if stage.entries.is_empty() && stage.withheld_deletes.is_empty() {
675            // Nothing of the agent's is staged — but a repair still
676            // owes the readers a boundary. Not a forced cap: nothing is
677            // late, and stamping these "forced" would misread the
678            // operator's own tell.
679            if !self.repair_candidates()?.is_empty()
680                && now.saturating_sub(stage.last_citation_unix) >= self.cfg.floor_secs
681            {
682                return Ok(Some(CitationSource::Repair));
683            }
684            return Ok(None);
685        }
686        if sentinel_pending {
687            return Ok(Some(CitationSource::Sentinel));
688        }
689        // The lag cap forces one even mid-change: unbounded citation
690        // staleness is impossible by construction, which is why `gated`
691        // is REFUSED without a bound rather than defaulted.
692        if let Some(bound) = self.cfg.visibility_lag_bound_secs {
693            if now.saturating_sub(stage.last_citation_unix) >= bound {
694                return Ok(Some(CitationSource::ForcedLagCap));
695            }
696        }
697        // The backlog cap bounds the preStop drain BY CONSTRUCTION.
698        let bytes: u64 = stage.entries.values().map(|e| e.size).sum();
699        // Recorded reclaims are counted as OBJECTS, because they are
700        // exactly the drain work this cap exists to bound: the citation
701        // the drain runs issues one `delete_version` per recorded id.
702        // Counting only staged PATHS would let one hot file rewritten
703        // every tick accumulate a version per tick forever — one stage
704        // entry, one size, and a cap that never fires — while
705        // `drain_need_secs` sized the grace from that same cap.
706        let recorded: u64 =
707            stage.pending_reclaims.values().map(|v| v.len() as u64).sum();
708        if stage.entries.len() as u64 + recorded >= self.cfg.staged_backlog_cap_objects
709            || bytes >= self.cfg.staged_backlog_cap_bytes
710        {
711            return Ok(Some(CitationSource::ForcedBacklogCap));
712        }
713        if stage.stable_since_unix > 0
714            && now.saturating_sub(stage.stable_since_unix) >= self.cfg.quiesce_bound_secs
715        {
716            return Ok(Some(CitationSource::Quiescence));
717        }
718        Ok(None)
719    }
720
721    /// The citation lane: ONE CAS installing the entire pending set.
722    ///
723    /// The versions already exist, so there is no copy phase — hence no
724    /// half-boundary, no intent document, no roll-forward, and no
725    /// interval in which a version-resolving reader can see
726    /// boundary-new bytes under old citations. `Inv_BoundaryAtomic`
727    /// holds by construction.
728    /// EXACT version reclamation (D8) — the reaper, not lifecycle.
729    ///
730    /// Reclaims ONLY the generations this workspace itself superseded:
731    /// for each cited path, the version its own pending record names as
732    /// `base_version_id`. Everything else — crash remnants, foreign
733    /// writes, a successor's work — is left to the noncurrent backstop.
734    ///
735    /// THE RULE USED TO BE "delete every version of a touched key that
736    /// is neither `keep` nor `is_current`", and that destroys committed
737    /// data. The `is_current` guard protects exactly ONE version, and
738    /// its reasoning ("a foreign write landed between the lane and this
739    /// citation") assumes at most one foreign generation. A successor in
740    /// gated mode does not stop at one: its cadence is stage → cite →
741    /// stage. So a straggler whose CAS landed and which then stalled in
742    /// this loop would find, on resuming, the successor's CITED version
743    /// sitting noncurrent-and-not-`keep` — and delete it. Every cited
744    /// version surviving a deposed straggler is the premise §8 Q2 chose
745    /// versioned staging on ("they destroy nothing"), so the old rule
746    /// falsified the reason the mode exists.
747    ///
748    /// Lifecycle cannot do this job on `files/`: gated staging makes the
749    /// CITED version noncurrent the moment a newer generation is staged,
750    /// so a `NoncurrentVersionExpiration` rule runs a clock against live
751    /// cited data and never reaches the newest uncited bytes, which are
752    /// current. That inversion is why the standing retention is a long
753    /// BACKSTOP and a shorter fleet-wide rule is a refusal condition.
754    pub(crate) async fn reclaim_superseded(
755        &mut self,
756        upserts: &BTreeMap<String, LeanEntry>,
757        installed: &super::manifest::LeanManifest,
758        stage: &PendingStage,
759        report: &mut CitationReport,
760    ) -> LeanResult<()> {
761        for (i, path) in upserts.keys().enumerate() {
762            // The loop is O(cited) round trips and the run loop's
763            // renewal arm cannot fire while it runs (one `select!`, the
764            // chosen branch awaited inline). Renew on our own account,
765            // and re-verify: a straggler that lost the lease mid-loop
766            // must stop deleting, not finish the sweep.
767            if i % RECLAIM_FENCE_EVERY == 0 {
768                self.renew_if_due().await?;
769                self.verify_not_deposed_pub().await?;
770            }
771            // The ONLY version this pass is entitled to reclaim: the one
772            // our own pending record says we superseded. If we cannot
773            // name it we reclaim nothing — the backstop exists for
774            // exactly the remnants we cannot name.
775            let Some(superseded) =
776                stage.entries.get(path).and_then(|pe| pe.base_version_id.clone())
777            else {
778                continue;
779            };
780            if superseded.is_empty() {
781                continue;
782            }
783            // FAIL CLOSED. If the installed manifest does not name a
784            // version for this path — an unversioned backend, a proxy
785            // that stripped the header, a merge that resolved
786            // foreign-wins — then we do not know what is cited, and
787            // reclaiming against it is guesswork.
788            let Some(keep) = installed
789                .entries
790                .get(path)
791                .and_then(|e| e.version_id.clone())
792                .filter(|v| !v.is_empty())
793            else {
794                continue;
795            };
796            // The merge may have resolved foreign-wins onto the very
797            // version we were about to reclaim.
798            if superseded == keep {
799                continue;
800            }
801            let key = self.cfg.file_key(path);
802            let Ok(versions) = self.store.list_versions(&key).await else { continue };
803            let Some(v) = versions
804                .iter()
805                .find(|v| v.key == key && !v.is_delete_marker && v.version_id == superseded)
806            else {
807                continue;
808            };
809            // NEVER the current version. If what we superseded is
810            // somehow current again, a foreign writer restored it and
811            // those are live bytes somebody is about to read.
812            if v.is_current {
813                continue;
814            }
815            let _ = self.store.delete_version(&key, &superseded).await;
816            report.versions_reclaimed += 1;
817        }
818
819        // Then everything the LANE recorded and did not delete. Same
820        // four guards; the only difference is where the candidate came
821        // from. `base_version_id` names exactly one version — the one
822        // superseded relative to the CITED generation — so without this
823        // pass every intermediate generation between two citations
824        // would be named by nothing and left to the retention backstop.
825        let recorded: Vec<(String, Vec<String>)> =
826            stage.pending_reclaims.iter().map(|(p, v)| (p.clone(), v.clone())).collect();
827        for (i, (path, vids)) in recorded.iter().enumerate() {
828            if i % RECLAIM_FENCE_EVERY == 0 {
829                self.renew_if_due().await?;
830                self.verify_not_deposed_pub().await?;
831            }
832            // Two distinct "no keep" cases, and they are NOT the same:
833            //
834            // - the installed manifest does not cite this path AT ALL
835            //   (a withheld delete installed, a path that never made a
836            //   boundary) — there is no cited version to protect, so
837            //   the recorded ids are reclaimable;
838            // - it cites the path but names no version — an unversioned
839            //   backend, a stripping proxy, a foreign-wins merge. We do
840            //   not know what is cited, so FAIL CLOSED, exactly as the
841            //   pass above does.
842            let keep = match installed.entries.get(path) {
843                None => None,
844                Some(e) => match e.version_id.clone().filter(|v| !v.is_empty()) {
845                    Some(v) => Some(v),
846                    None => continue,
847                },
848            };
849            let key = self.cfg.file_key(path);
850            let Ok(versions) = self.store.list_versions(&key).await else { continue };
851            for vid in vids {
852                if vid.is_empty() || Some(vid) == keep.as_ref() {
853                    continue;
854                }
855                let Some(v) = versions
856                    .iter()
857                    .find(|v| v.key == key && !v.is_delete_marker && &v.version_id == vid)
858                else {
859                    continue;
860                };
861                // The `is_current` guard is CONDITIONAL here, and the
862                // asymmetry is deliberate. Where the boundary cites this
863                // path, a recorded id that is current again means a
864                // foreign writer restored it and those are live bytes —
865                // skip, exactly as the pass above does. Where the
866                // boundary does not cite the path AT ALL — a withheld
867                // delete that installed, a scratch file that never made
868                // a boundary — current is the ORDINARY state of the last
869                // version this workspace staged before the file went
870                // away, and skipping it would leave precisely the litter
871                // this pass exists to collect. We delete by exact
872                // version id, so a foreign write is still safe: it would
873                // be a different id, and this one would not be current.
874                if keep.is_some() && v.is_current {
875                    continue;
876                }
877                let _ = self.store.delete_version(&key, vid).await;
878                report.versions_reclaimed += 1;
879            }
880        }
881        Ok(())
882    }
883
884    pub async fn citation_pass(&mut self, source: CitationSource) -> LeanResult<CitationReport> {
885        let epoch = self
886            .lease
887            .as_ref()
888            .map(|l| l.epoch)
889            .ok_or_else(|| LeanError::State("citation without a held lease".into()))?;
890        let mut report = CitationReport { source: Some(source.as_str().into()), ..Default::default() };
891        self.verify_not_deposed_pub().await?;
892
893        let mut stage = self.load_stage()?;
894        let mut baseline = self.state.load_baseline()?;
895        let repairs = self.repair_candidates()?;
896        if stage.entries.is_empty() && stage.withheld_deletes.is_empty() && repairs.is_empty() {
897            report.no_change = true;
898            return Ok(report);
899        }
900
901        // Base-version re-validation (D7). If the baseline's cited
902        // version moved between staging and now, a HITL consume or sync
903        // landed in between; installing our staged version would UNCITE
904        // the foreign bytes. The damage class is softer than the copy
905        // design's — there the amputation DESTROYED the foreign object,
906        // here it only hides it and the version stays fetchable — but
907        // the rule stands.
908        let scanned = scan::scan(&self.cfg.root)?;
909        let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
910        let mut drop_paths: Vec<String> = vec![];
911        for (path, pe) in &stage.entries {
912            let cited_now = baseline.entries.get(path).and_then(|b| b.version_id.clone());
913            let stale = pe.base_version_id.is_some() && cited_now != pe.base_version_id;
914            if stale {
915                let still_differs = match (scanned.get(path), baseline.entries.get(path)) {
916                    (Some(s), Some(b)) => s.size != b.size || s.mtime_unix != b.mtime_unix,
917                    (Some(_), None) => true,
918                    _ => false,
919                };
920                self.state.append_conflict(&ConflictRecord {
921                    path: path.clone(),
922                    foreign_etag: baseline
923                        .entries
924                        .get(path)
925                        .map(|b| b.etag.clone())
926                        .unwrap_or_default(),
927                    preserved_key: None,
928                    kind: format!(
929                        "citation-stale-base (superseded generation {}, still-differs={})",
930                        pe.generation, still_differs
931                    ),
932                    at_unix: now_unix(),
933                })?;
934                if !still_differs {
935                    // The foreign bytes ARE the local bytes now: drop
936                    // the staged citation rather than uncite them. The
937                    // next lane tick re-stages if the tree moves again.
938                    drop_paths.push(path.clone());
939                    report.dropped_stale_base.push(path.clone());
940                    continue;
941                }
942            }
943            upserts.insert(
944                path.clone(),
945                LeanEntry {
946                    key: pe.key.clone(),
947                    etag: pe.etag.clone(),
948                    crc64_b64: pe.crc64_b64.clone(),
949                    size: pe.size,
950                    mode: pe.mode,
951                    mtime_unix: pe.mtime_unix,
952                    generation: pe.generation,
953                    epoch: pe.epoch,
954                    version_id: pe.version_id.clone(),
955                },
956            );
957        }
958        for p in &drop_paths {
959            stage.entries.remove(p);
960        }
961
962        // The ungated repair (§2.4.2). No bytes move: the object IS
963        // what this syncer already integrated, and the citation is
964        // catching up to it. The HEAD is also what supplies the VERSION
965        // id — a consume records none, and under `pinned_reads` a
966        // citation without one is unreadable by the very readers this
967        // pass exists for.
968        for path in &repairs {
969            if upserts.contains_key(path) {
970                continue;
971            }
972            let Some(be) = baseline.entries.get(path).cloned() else { continue };
973            let key = self.cfg.file_key(path);
974            let meta = match self.store.head(&key).await {
975                Ok(m) if m.etag == be.etag => m,
976                // Moved again or gone: citing a version this workspace
977                // has not integrated would be the lie the repair exists
978                // to prevent. The next consume reconciles it.
979                _ => continue,
980            };
981            let crc = super::barrier::repair_crc(path, &be, &meta)?;
982            let stamps = GenerationStamps::from_meta(&meta.meta);
983            let scan_entry = scanned.get(path);
984            upserts.insert(
985                path.clone(),
986                LeanEntry {
987                    key,
988                    etag: meta.etag.clone(),
989                    crc64_b64: crc,
990                    size: meta.size,
991                    mode: stamps
992                        .as_ref()
993                        .and_then(|s| s.posix)
994                        .map(|p| p.mode)
995                        .or(scan_entry.map(|s| s.mode))
996                        .unwrap_or(0o644),
997                    mtime_unix: scan_entry.map(|s| s.mtime_unix).unwrap_or(be.mtime_unix),
998                    generation: stamps.map(|s| s.generation).unwrap_or(be.generation),
999                    epoch,
1000                    version_id: meta.version_id.clone(),
1001                },
1002            );
1003            report.repaired.push(path.clone());
1004        }
1005
1006        // Window open/clear belong to THIS lane only.
1007        let deadline = now_unix() + self.cfg.window_slack_secs;
1008        let opened =
1009            inbox::open_window(self.store.as_ref(), &self.cfg, epoch, deadline).await?;
1010
1011        // A HITL write that landed AFTER the lane staged this path.
1012        //
1013        // The base-version check above cannot see it: it reads the
1014        // BASELINE, and the citation lane consumes nothing, so the
1015        // baseline has not moved. And the upload lane opens no window —
1016        // deliberately, because a lane that fenced HITL out every floor
1017        // tick would refuse admission essentially forever between
1018        // citations. So the gap is real and it is the mode's own doing.
1019        //
1020        // The inbox is what can see it, and the window CAS just loaded
1021        // it: zero added requests. Drop the path rather than cite bytes
1022        // that PREDATE the user's write over it. The entry stays queued,
1023        // so the next lane consumes it the ordinary way.
1024        //
1025        // (Found by the formal model — tranche 3 product 2. Before the
1026        // companion rule below, the reaper then DELETED the user's
1027        // version, because it was not the one the manifest cited.)
1028        let inflight: BTreeSet<String> =
1029            opened.doc.entries.iter().map(|e| e.path.clone()).collect();
1030        for path in &inflight {
1031            if upserts.remove(path).is_some() {
1032                stage.entries.remove(path);
1033                // Kept separate from the stale-base drop on purpose:
1034                // this one, and only this one, removes the agent's
1035                // LATEST bytes from a boundary it may be acked for.
1036                // The stale-base drop fires only when the foreign bytes
1037                // ARE the local bytes, so the point still carries the
1038                // agent's content.
1039                report.dropped_inflight.push(path.clone());
1040                self.state.append_conflict(&ConflictRecord {
1041                    path: path.clone(),
1042                    foreign_etag: String::new(),
1043                    preserved_key: None,
1044                    kind: "citation-hitl-inflight".into(),
1045                    at_unix: now_unix(),
1046                })?;
1047            }
1048        }
1049
1050        let deletes = stage.withheld_deletes.clone();
1051        let parked: BTreeSet<String> = BTreeSet::new();
1052        let flush_uuid = uuid::Uuid::new_v4().to_string();
1053        let mut intent = self.state.load_intent()?;
1054        let prev_installed = intent.installed_etag.clone();
1055        let mut attempt = 0;
1056        let mut version_index: Option<BTreeMap<(String, String), String>> = None;
1057        let mut backfilled: Vec<String> = vec![];
1058        let mut unresolved: BTreeSet<String> = BTreeSet::new();
1059        let (installed, installed_etag, foreign) = loop {
1060            attempt += 1;
1061            if attempt > 4 {
1062                return Err(LeanError::State(
1063                    "citation CAS lost 4 merge races — refusing this boundary".into(),
1064                ));
1065            }
1066            let current = manifest::load(self.store.as_ref(), &self.cfg).await?;
1067            let (theirs, expected) = match &current {
1068                // The handle carries the layout as well as the tag —
1069                // see the fused barrier's copy of this.
1070                Some(l) => (l.manifest.clone(), Some(l.handle())),
1071                None => (Default::default(), None),
1072            };
1073            // Same rule as the fused barrier: if the bucket is still at
1074            // the document we installed, that document IS the merge
1075            // base (`IntentJournal::installed_etag`). A citation pass
1076            // that crashed between its CAS and the baseline rewrite
1077            // would otherwise read its own boundary as foreign.
1078            let own_base;
1079            let base: &BTreeMap<String, String> =
1080                if prev_installed.is_some()
1081                    && prev_installed.as_deref() == expected.as_ref().map(|h| h.etag.as_str())
1082                {
1083                    own_base = theirs
1084                        .entries
1085                        .iter()
1086                        .map(|(p, e)| (p.clone(), e.etag.clone()))
1087                        .collect();
1088                    &own_base
1089                } else {
1090                    &baseline.inst_base
1091                };
1092            let (mut merged, foreign) =
1093                manifest::merge(base, &theirs, &upserts, &deletes, &parked);
1094            // D13: this citation's readers resolve by version, never by
1095            // S3-wins adoption of the current version.
1096            merged.pinned_reads = true;
1097            // Independent of D13 and cleared by `merge`, so it is
1098            // restated here for the same reason the cadence barrier
1099            // restates it: it describes the deployment, not the
1100            // citation.
1101            merged.sole_writer = self.cfg.sole_writer;
1102            // The mixed-manifest cell, where D7's entry schema and D13
1103            // collide. Stamping `pinned_reads` over an entry a pre-D7
1104            // writer cited leaves it addressable only by etag — and the
1105            // moment the lane stages that path, its current version is
1106            // uncited mid-change bytes that checkout's fallback arm
1107            // would adopt. Resolve the CITED etag in the bucket's own
1108            // version history before installing: one listing, and it
1109            // answers even where the current version has already moved.
1110            let legacy: Vec<String> = merged
1111                .entries
1112                .iter()
1113                .filter(|(p, e)| {
1114                    e.version_id.is_none() && !stage.legacy_unresolved.contains(*p)
1115                })
1116                .map(|(p, _)| p.clone())
1117                .collect();
1118            if !legacy.is_empty() {
1119                if version_index.is_none() {
1120                    version_index = Some(self.version_index().await?);
1121                }
1122                let ix = version_index.as_ref().expect("just built");
1123                for path in legacy {
1124                    let Some(e) = merged.entries.get_mut(&path) else { continue };
1125                    match ix.get(&(e.key.clone(), e.etag.clone())) {
1126                        Some(vid) => {
1127                            e.version_id = Some(vid.clone());
1128                            backfilled.push(path);
1129                        }
1130                        None => {
1131                            unresolved.insert(path);
1132                        }
1133                    }
1134                }
1135            }
1136            match manifest::cas_write_stamped(
1137                self.store.as_ref(),
1138                &self.cfg,
1139                &merged,
1140                expected.as_ref(),
1141                epoch,
1142                &flush_uuid,
1143                Some(source.as_str()),
1144            )
1145            .await
1146            {
1147                Ok(meta) => {
1148                    intent.installed_etag = Some(meta.etag.clone());
1149                    self.state.save_intent(&intent)?;
1150                    break (merged, meta.etag, foreign);
1151                }
1152                Err(LeanError::Store(StoreError::PreconditionFailed(_)))
1153                | Err(LeanError::Store(StoreError::Conflict(_))) => {
1154                    self.verify_not_deposed_pub().await?;
1155                    continue;
1156                }
1157                Err(e) => return Err(e),
1158            }
1159        };
1160        report.seq = Some(installed.seq);
1161        report.cited = upserts.len();
1162        backfilled.sort();
1163        backfilled.dedup();
1164        report.backfilled = backfilled;
1165        stage.legacy_unresolved.extend(unresolved);
1166        self.note_boundary(source.as_str(), installed.seq)?;
1167
1168        // GC of keys the NEW manifest no longer references — deletes
1169        // LAST, HEAD-guarded on the recognized ETag, unchanged.
1170        for path in &deletes {
1171            if installed.entries.contains_key(path) {
1172                continue;
1173            }
1174            let key = self.cfg.file_key(path);
1175            let recognized = baseline.entries.get(path).map(|b| b.etag.clone());
1176            match self.store.head(&key).await {
1177                Err(StoreError::NotFound(_)) => report.deleted.push(path.clone()),
1178                Ok(meta) if Some(&meta.etag) == recognized.as_ref() => {
1179                    self.store.delete(&key).await?;
1180                    report.deleted.push(path.clone());
1181                }
1182                Ok(meta) => {
1183                    self.state.append_conflict(&ConflictRecord {
1184                        path: path.clone(),
1185                        foreign_etag: meta.etag,
1186                        preserved_key: None,
1187                        kind: "gc-skip".into(),
1188                        at_unix: now_unix(),
1189                    })?;
1190                }
1191                Err(e) => return Err(e.into()),
1192            }
1193        }
1194
1195        // EXACT version reclamation (D8) — this is the reaper, not
1196        // lifecycle. After the CAS, every version of a touched key that
1197        // neither the installed manifest nor the (now empty) pending set
1198        // names is deleted, version-scoped and free. Steady state ⇒ one
1199        // version per key.
1200        //
1201        // Lifecycle cannot do this job on `files/`: gated staging makes
1202        // the CITED version noncurrent the moment a newer generation is
1203        // staged, so a `NoncurrentVersionExpiration` rule runs a clock
1204        // against live cited data and never reaches the newest uncited
1205        // bytes, which are current. That inversion is why the standing
1206        // retention is a long BACKSTOP and a shorter fleet-wide rule is
1207        // a refusal condition.
1208        self.reclaim_superseded(&upserts, &installed, &stage, &mut report).await?;
1209
1210        // Baseline rewrite.
1211        for (path, e) in &installed.entries {
1212            // A repaired path keeps its integrated stat; only the
1213            // version id it is now cited by is new.
1214            if report.repaired.contains(path) {
1215                if let Some(be) = baseline.entries.get_mut(path) {
1216                    be.version_id = e.version_id.clone();
1217                }
1218            }
1219            if let Some(pe) = stage.entries.get(path) {
1220                baseline.entries.insert(
1221                    path.clone(),
1222                    BaselineEntry {
1223                        etag: e.etag.clone(),
1224                        generation: e.generation,
1225                        size: pe.size,
1226                        mtime_unix: pe.mtime_unix,
1227                        version_id: e.version_id.clone(),
1228                        crc64_b64: Some(e.crc64_b64.clone()),
1229                    },
1230                );
1231            }
1232        }
1233        for path in &report.deleted {
1234            baseline.entries.remove(path);
1235        }
1236        baseline.inst_base =
1237            installed.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
1238        baseline.seq = installed.seq;
1239        baseline.manifest_etag = Some(installed_etag);
1240        self.state.save_baseline(&baseline)?;
1241        self.state.clear_intent_keys()?;
1242
1243        stage.entries.clear();
1244        stage.withheld_deletes.clear();
1245        stage.pending_reclaims.clear();
1246        stage.last_citation_unix = now_unix();
1247        stage.stable_since_unix = 0;
1248        self.save_stage(&stage)?;
1249        // The CLEARING write matters as much as the surfacing one: a
1250        // stale summary claiming candidates pages an operator about
1251        // work that was cited minutes ago (D9).
1252        if let Err(e) = self.surface_orphans(&stage).await {
1253            eprintln!("flint-sync: orphan summary not cleared (retrying next tick): {e}");
1254        }
1255
1256        let queue: Vec<inbox::InboxEntry> = foreign
1257            .into_iter()
1258            .map(|(path, e)| inbox::InboxEntry {
1259                path,
1260                etag: e.etag,
1261                author: "merge-preserved".into(),
1262                added_unix: now_unix(),
1263                crc64_b64: Some(e.crc64_b64),
1264            })
1265            .collect();
1266        inbox::clear_window(self.store.as_ref(), &self.cfg, epoch, &queue).await?;
1267        Ok(report)
1268    }
1269
1270    /// The gated floor tick: lane, then a citation if one is due.
1271    ///
1272    /// Both halves are reported because the floor arm has to describe
1273    /// them separately — a tick that staged 40 files and cited nothing
1274    /// is the mode working, and a tick that staged nothing and cited
1275    /// nothing is idle. Collapsing them into one "no change" would make
1276    /// gated mode indistinguishable from a wedged one in the log.
1277    pub async fn gated_tick(
1278        &mut self,
1279        sentinel_pending: bool,
1280    ) -> LeanResult<(LaneReport, CitationReport)> {
1281        let lane = self.upload_lane().await?;
1282        let cite = match self.citation_due(sentinel_pending)? {
1283            Some(source) => self.citation_pass(source).await?,
1284            None => CitationReport { no_change: true, ..Default::default() },
1285        };
1286        Ok((lane, cite))
1287    }
1288
1289    /// The startup gate for gated mode (D8, D11): probe the version
1290    /// surface BEFORE a single byte is staged, and refuse rather than
1291    /// degrade. Inert in `cadence`/`hybrid`, which need no version
1292    /// surface at all — a gate that took every default workspace down
1293    /// with it would be worse than the hazard.
1294    pub async fn gated_startup_check(&mut self) -> LeanResult<()> {
1295        if !self.is_gated() {
1296            return Ok(());
1297        }
1298        self.versioning_conformance().await
1299    }
1300
1301    /// `flint-sync recover-staged` (D9): re-cite durable-but-uncited
1302    /// work as ONE flagged boundary — a manifest CAS with no data
1303    /// movement, because the versions already exist.
1304    ///
1305    /// The routine path into this verb is a pure-spot pod replacement:
1306    /// the emptyDir that held `pending.json` is gone, so the pending
1307    /// record can name nothing and **the bucket is the only source of
1308    /// truth**. Two shapes are recovered, and they overlap:
1309    ///
1310    /// - *uncited work* — the key's current version is not the one the
1311    ///   manifest cites (or the key is not cited at all: a brand-new
1312    ///   file staged and never installed);
1313    /// - *a dangling citation* — the cited version no longer exists,
1314    ///   because gated staging made it NONCURRENT and the retention
1315    ///   backstop ran its clock against live cited data (D8's
1316    ///   inversion). Checkout refuses on this rather than serving a
1317    ///   hole, so recovery is what makes the workspace usable again.
1318    ///
1319    /// Recovery rolls **forward**, onto the newer bytes, and says so:
1320    /// every re-citation writes a conflict record. It is deliberately
1321    /// NOT a three-way merge — a replacement pod has no merge base, and
1322    /// merging against an empty one would classify the entire existing
1323    /// manifest as foreign and queue the whole tree into the HITL
1324    /// inbox.
1325    pub async fn recover_staged(&mut self) -> LeanResult<RecoverReport> {
1326        let epoch = self
1327            .lease
1328            .as_ref()
1329            .map(|l| l.epoch)
1330            .ok_or_else(|| LeanError::State("recover-staged without a held lease".into()))?;
1331        let mut report = RecoverReport::default();
1332        let files_prefix = format!("{}/files/", self.cfg.prefix);
1333
1334        // D9's durable summary is the MECHANISM; the prefix-wide version
1335        // LIST is the fallback (review: U1). `flint-store`'s own trait
1336        // doc has said so since the version surface landed — "the
1337        // claim-time/DR fallback when `orphans.json` is missing or
1338        // stale — the expensive path, which is why the durable summary
1339        // is written eagerly" — but nothing in the syncer read the
1340        // summary, so recovery always took the expensive path and the
1341        // eager write bought nothing here.
1342        //
1343        // The summary narrows WHICH keys we list, never what we
1344        // conclude about them: every candidate is still resolved
1345        // against the bucket below. A stale or partial summary
1346        // therefore costs coverage, not correctness — so a missing one,
1347        // or one written under a different boundary mode, falls back to
1348        // the full sweep rather than trusting a narrower answer.
1349        let summary = match self.store.get_whole(&self.orphans_key(), None).await {
1350            Ok((_, bytes)) => serde_json::from_slice::<OrphanDoc>(&bytes).ok(),
1351            Err(_) => None,
1352        };
1353        // The manifest is read BEFORE the narrowing, because the
1354        // narrowed key set is the union of the summary's candidates and
1355        // everything the manifest already CITES.
1356        //
1357        // Getting that wrong is a data-loss-shaped bug, and leg B11b
1358        // caught it: `orphans.json` names only the STAGED candidates, so
1359        // narrowing to those alone left every cited-but-not-staged path
1360        // with no listed version at all — and recovery classifies a path
1361        // with no surviving version as UNRECOVERABLE. A workspace with
1362        // one quiet cited file would have been told its data was gone.
1363        let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
1364        let cited_now = loaded.as_ref().map(|l| l.manifest.clone()).unwrap_or_default();
1365
1366        let narrowed: Option<BTreeSet<String>> = summary.as_ref().and_then(|d| {
1367            if d.boundary_mode != self.cfg.boundary_mode.as_str() || d.candidates.is_empty() {
1368                return None;
1369            }
1370            let mut keys: BTreeSet<String> =
1371                d.candidates.iter().map(|c| self.cfg.file_key(&c.path)).collect();
1372            keys.extend(cited_now.entries.keys().map(|p| self.cfg.file_key(p)));
1373            Some(keys)
1374        });
1375        report.from_summary = narrowed.is_some();
1376
1377        let listed: Vec<ListedVersion> = match &narrowed {
1378            Some(keys) => {
1379                let mut out = vec![];
1380                for key in keys {
1381                    out.extend(self.store.list_versions(key).await.unwrap_or_default());
1382                }
1383                out
1384            }
1385            None => self.store.list_versions(&files_prefix).await?,
1386        };
1387        let mut current: BTreeMap<String, ListedVersion> = BTreeMap::new();
1388        let mut known: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1389        for v in listed {
1390            known.entry(v.key.clone()).or_default().insert(v.version_id.clone());
1391            if v.is_current {
1392                current.insert(v.key.clone(), v);
1393            }
1394        }
1395
1396        let mut paths: BTreeSet<String> = cited_now.entries.keys().cloned().collect();
1397        for key in current.keys() {
1398            if let Some(p) = key.strip_prefix(&files_prefix) {
1399                // D0.2 again: a legacy `files/.flint/` citation must not
1400                // be recovered INTO the control namespace.
1401                if !p.is_empty() && !scan::is_control_path(p) {
1402                    paths.insert(p.to_string());
1403                }
1404            }
1405        }
1406
1407        let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
1408        for path in paths {
1409            let key = self.cfg.file_key(&path);
1410            let cited = cited_now.entries.get(&path).cloned();
1411            let live = current.get(&key).filter(|v| !v.is_delete_marker).cloned();
1412
1413            // A citation dangles only if it names a version BY ID that
1414            // the bucket no longer holds. A legacy entry citing no
1415            // version resolves by etag and cannot dangle this way.
1416            let dangles = match &cited {
1417                Some(e) => match &e.version_id {
1418                    Some(v) => !known.get(&key).map(|s| s.contains(v)).unwrap_or(false),
1419                    None => false,
1420                },
1421                None => false,
1422            };
1423            if dangles {
1424                report.dangling.push(path.clone());
1425            }
1426            let Some(live) = live else {
1427                if dangles {
1428                    report.unrecoverable.push(path.clone());
1429                }
1430                continue;
1431            };
1432            let already = match &cited {
1433                None => false,
1434                Some(e) => match &e.version_id {
1435                    Some(v) => v == &live.version_id,
1436                    None => e.etag == live.etag,
1437                },
1438            };
1439            if already {
1440                continue;
1441            }
1442
1443            // One HEAD per re-cited path to recover the stamps the
1444            // manifest entry needs. A HEAD is not data movement.
1445            let meta = self.store.head_version(&key, &live.version_id).await?;
1446            // The CRC the re-citation carries: the backend's attestation
1447            // when it offers one. A replacement pod has no baseline to
1448            // ask, so on a backend that attests nothing (Ozone; a HITL
1449            // upload sent without one) the bytes are the only source —
1450            // the one recovery step that moves data, and only there.
1451            let crc = match meta.crc64_b64.clone() {
1452                Some(c) => c,
1453                None => {
1454                    let (_, body) = self.store.get_version(&key, &live.version_id).await?;
1455                    crc64_to_b64(crc64_nvme(&body))
1456                }
1457            };
1458            let stamps = GenerationStamps::from_meta(&meta.meta);
1459            let posix = stamps.as_ref().and_then(|s| s.posix);
1460            upserts.insert(
1461                path.clone(),
1462                LeanEntry {
1463                    key: key.clone(),
1464                    etag: meta.etag.clone(),
1465                    crc64_b64: crc,
1466                    size: meta.size,
1467                    mode: posix
1468                        .map(|p| p.mode)
1469                        .or_else(|| cited.as_ref().map(|e| e.mode))
1470                        .unwrap_or(0o100_644),
1471                    mtime_unix: posix
1472                        .map(|p| p.mtime_unix)
1473                        .or_else(|| meta.last_modified_unix.map(|u| u as i64))
1474                        .unwrap_or(0),
1475                    generation: stamps
1476                        .as_ref()
1477                        .map(|s| s.generation)
1478                        .or_else(|| cited.as_ref().map(|e| e.generation + 1))
1479                        .unwrap_or(1),
1480                    epoch: stamps.as_ref().map(|s| s.epoch).unwrap_or(epoch),
1481                    version_id: Some(live.version_id.clone()),
1482                },
1483            );
1484            self.state.append_conflict(&ConflictRecord {
1485                path: path.clone(),
1486                foreign_etag: cited.as_ref().map(|e| e.etag.clone()).unwrap_or_default(),
1487                preserved_key: None,
1488                kind: if dangles {
1489                    "recovered-staged (dangling citation rolled forward)".into()
1490                } else {
1491                    "recovered-staged".to_string()
1492                },
1493                at_unix: now_unix(),
1494            })?;
1495            report.recited.push(path.clone());
1496        }
1497
1498        if upserts.is_empty() {
1499            report.no_change = true;
1500            report.seq = loaded.map(|l| l.manifest.seq);
1501            return Ok(report);
1502        }
1503
1504        // The upserts are ABSOLUTE — derived from bucket versions, not
1505        // from a diff — so a lost CAS re-applies them onto whatever is
1506        // current without recomputing anything.
1507        let flush_uuid = uuid::Uuid::new_v4().to_string();
1508        let mut fresh = loaded;
1509        for attempt in 0..4u32 {
1510            let (mut m, expected) = match &fresh {
1511                Some(l) => (l.manifest.clone(), Some(l.handle())),
1512                None => (manifest::LeanManifest::default(), None),
1513            };
1514            for (p, e) in &upserts {
1515                m.entries.insert(p.clone(), e.clone());
1516            }
1517            m.seq += 1;
1518            m.pinned_reads = self.is_gated();
1519            match manifest::cas_write_stamped(
1520                self.store.as_ref(),
1521                &self.cfg,
1522                &m,
1523                expected.as_ref(),
1524                epoch,
1525                &flush_uuid,
1526                Some(CitationSource::Recovered.as_str()),
1527            )
1528            .await
1529            {
1530                Ok(_) => {
1531                    report.seq = Some(m.seq);
1532                    self.note_boundary(CitationSource::Recovered.as_str(), m.seq)?;
1533                    return Ok(report);
1534                }
1535                Err(LeanError::Store(StoreError::PreconditionFailed(_)))
1536                | Err(LeanError::Store(StoreError::Conflict(_))) if attempt < 3 => {
1537                    self.verify_not_deposed_pub().await?;
1538                    fresh = manifest::load(self.store.as_ref(), &self.cfg).await?;
1539                }
1540                Err(e) => return Err(e),
1541            }
1542        }
1543        Err(LeanError::State(
1544            "recover-staged lost 4 CAS races — a live writer is still publishing".into(),
1545        ))
1546    }
1547
1548    /// Is this workspace gated?
1549    pub fn is_gated(&self) -> bool {
1550        self.cfg.boundary_mode == BoundaryMode::Gated
1551    }
1552
1553    /// The versioning conformance probe (D8) — the SHARED
1554    /// implementation in `flint_store::probe`, because the operator
1555    /// runs the same probe on its reconcile cadence and the two
1556    /// verdicts must never disagree. A workspace the operator calls
1557    /// conformant and the syncer refuses to start is worse than
1558    /// either answer alone.
1559    ///
1560    /// A project-scoped proxy that strips `x-amz-version-id` degrades
1561    /// gated mode SILENTLY — pending entries would carry `None`, and
1562    /// citation would fall back to etag semantics on a key whose
1563    /// current version is uncited, which is precisely the torn view the
1564    /// mode exists to prevent. So the probe REFUSES rather than
1565    /// degrading, and is re-run at startup and on the operator cadence:
1566    /// proxies upgrade, and a bucket's posture changes under you.
1567    pub async fn versioning_conformance(&self) -> LeanResult<()> {
1568        let key = format!("{}/{}/probe/versioning", self.cfg.prefix, super::LEAN_DIR);
1569        flint_store::probe::probe_version_surface(self.store.as_ref(), &key)
1570            .await
1571            .map_err(|m| LeanError::State(format!("versioning conformance probe FAILED: {m}")))
1572    }
1573}
1574
1575/// A stable fingerprint of a scan: quiescence is scan-to-scan
1576/// STABILITY, so what matters is that this value stops changing.
1577fn scan_fingerprint(scanned: &BTreeMap<String, scan::ScanEntry>) -> String {
1578    let mut h = flint_store::Crc64Nvme::new();
1579    for (p, e) in scanned {
1580        h.update(p.as_bytes());
1581        h.update(&e.size.to_be_bytes());
1582        h.update(&e.mtime_unix.to_be_bytes());
1583    }
1584    format!("{:016x}", h.finalize())
1585}