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 inbox_doc = inbox::load(self.store.as_ref(), &self.cfg).await?.doc;
401        let consumed = self.consume_inbox_doc(&inbox_doc).await?;
402        report.consumed = consumed.len();
403        // NOT dropped here (see `clear_window_settling`): a consumed
404        // entry leaves the cell at the citation that cites it, so a
405        // pod replacement between the two cannot orphan it.
406        // DECLARED removals (delete/rename design): unlinked here and
407        // WITHHELD like every other delete under gating, so they become
408        // reader-visible at a citation and nowhere else. Refusals are
409        // settled now; a performed removal stays in the cell until the
410        // citation that cites it out (`cite_pass` settles it), so a
411        // listing keeps hiding the path meanwhile. The pass is
412        // idempotent tick to tick: an already-unlinked path just
413        // re-declares.
414        let removals = self.apply_removals(&inbox_doc).await?;
415        inbox::settle_removals(self.store.as_ref(), &self.cfg, epoch, &[], &removals.refused)
416            .await?;
417
418        let mut baseline = self.state.load_baseline()?;
419        let scanned = scan::scan(&self.cfg.root)?;
420        let mut classified = scan::classify(&scanned, &baseline);
421        if declared {
422            report.absences_confirmed = self.confirm_absences(&mut classified)?;
423        }
424        for p in &removals.declared {
425            classified.first_absence.remove(p);
426            classified.deletes.insert(p.clone());
427        }
428        report.first_absence = classified.first_absence.iter().cloned().collect();
429        let mut stage = self.load_stage()?;
430
431        // Stage-diff base is baseline ∪ PENDING. Diffing against the
432        // baseline alone — which gated mode leaves un-advanced until
433        // citation — would re-PUT every staged-but-quiet file every
434        // tick: 50 quiet files × 60 ticks = 3,000 PUTs/hour vs today's
435        // 50, which would falsify the plan's own economics.
436        let to_stage: Vec<String> = classified
437            .uploads
438            .iter()
439            .filter(|p| match stage.entries.get(*p) {
440                None => true,
441                Some(pe) => {
442                    let s = &scanned[*p];
443                    pe.size != s.size || pe.mtime_unix != s.mtime_unix
444                }
445            })
446            .cloned()
447            .collect();
448
449        let flush_uuid = uuid::Uuid::new_v4().to_string();
450        let mut intent = self.state.load_intent()?;
451        let prior_uuids = {
452            let mut v = intent.recent_uuids.clone();
453            if !intent.flush_uuid.is_empty() {
454                v.push(intent.flush_uuid.clone());
455            }
456            v
457        };
458        intent = super::state::IntentJournal {
459            flush_uuid: flush_uuid.clone(),
460            keys: to_stage.iter().map(|p| self.cfg.file_key(p)).collect(),
461            recent_uuids: prior_uuids.clone(),
462            installed_etag: intent.installed_etag.clone(),
463            // The lane's declared deletes live in the persisted stage
464            // (`withheld_deletes`), not here.
465            declared_deletes: Vec::new(),
466        };
467        self.state.save_intent(&intent)?;
468
469        for path in &to_stage {
470            let scanned_entry = &scanned[path];
471            let base = baseline.entries.get(path).cloned();
472            let superseded = stage.entries.get(path).and_then(|p| p.version_id.clone());
473            let outcome = self
474                .stage_one(path, scanned_entry, base.as_ref(), epoch, &flush_uuid, &prior_uuids)
475                .await?;
476            match outcome {
477                Some(entry) => {
478                    report.staged_bytes += entry.size;
479                    report.staged.push(path.clone());
480                    // The lane reclaims the version it just superseded:
481                    // free, version-scoped, and it keeps uncited work at
482                    // one version per path plus crash remnants.
483                    // RECORD, never delete. The lane cannot prove a
484                    // version is uncited: it does not read the installed
485                    // manifest, and its own stage may have outlived a
486                    // citation. The reaper can, and does, under four
487                    // guards.
488                    if let Some(vid) = superseded {
489                        if Some(&vid) != entry.version_id.as_ref() {
490                            stage
491                                .pending_reclaims
492                                .entry(path.clone())
493                                .or_default()
494                                .push(vid);
495                            report.superseded_recorded += 1;
496                        }
497                    }
498                    // The path is back. A tombstone standing from an
499                    // earlier tick would otherwise ride into the same
500                    // citation as this upsert, and merge applies
501                    // deletes last: delete-then-recreate inside one
502                    // citation interval would install a boundary that
503                    // omits a file present on disk and staged this
504                    // pass.
505                    stage.withheld_deletes.remove(path);
506                    stage.entries.insert(path.clone(), entry);
507                }
508                None => report.parked.push(path.clone()),
509            }
510        }
511
512        // A staged version is cancelled by the file's departure, the
513        // mirror of the tombstone cancellation above. The lane has now
514        // seen the path GONE, later than it saw it present; a citation
515        // carrying both the stage entry and the tombstone would install
516        // one of them by accident of merge order, and merge has no way
517        // to know which came last. Reclaiming the version costs
518        // nothing — it was never cited.
519        let mut absent_now: BTreeSet<String> = BTreeSet::new();
520        let staged_paths: Vec<String> = stage.entries.keys().cloned().collect();
521        let mut cancel: Vec<String> = vec![];
522        for path in staged_paths {
523            if scanned.contains_key(&path) {
524                continue;
525            }
526            if classified.deletes.contains(&path) {
527                cancel.push(path);
528            } else if stage.staged_absent_once.contains(&path)
529                || (declared
530                    && std::fs::symlink_metadata(self.cfg.root.join(&path)).is_err())
531            {
532                // Never cited, so classify cannot see it: two lane
533                // observations, or one direct lstat on a DECLARED
534                // boundary, stand in for the two-scan rule.
535                cancel.push(path);
536            } else {
537                absent_now.insert(path);
538            }
539        }
540        for path in cancel {
541            if let Some(stale) = stage.entries.remove(&path) {
542                // Same rule as the supersede site above: a cancelled
543                // path's staged version can be the CITED one if this
544                // stage outlived a citation, and this site had no
545                // manifest reference at all.
546                if let Some(vid) = stale.version_id {
547                    stage.pending_reclaims.entry(path.clone()).or_default().push(vid);
548                    report.superseded_recorded += 1;
549                }
550            }
551        }
552        stage.staged_absent_once = absent_now;
553
554        // Deletes are WITHHELD until a citation pass (unchanged rule).
555        for p in &classified.deletes {
556            stage.withheld_deletes.insert(p.clone());
557        }
558        report.withheld_deletes = stage.withheld_deletes.len();
559
560        // Quiescence = scan-to-scan STABILITY: no path's (size, mtime)
561        // changed between consecutive lane ticks and nothing new
562        // appeared.
563        let fingerprint = scan_fingerprint(&scanned);
564        if stage.last_scan_fingerprint.as_deref() == Some(fingerprint.as_str()) {
565            if stage.stable_since_unix == 0 {
566                stage.stable_since_unix = now_unix();
567            }
568        } else {
569            stage.last_scan_fingerprint = Some(fingerprint);
570            stage.stable_since_unix = 0;
571        }
572        if stage.last_citation_unix == 0 {
573            stage.last_citation_unix = now_unix();
574        }
575
576        // Mirror the cadence path's guard (barrier.rs, the no-change
577        // arm): `prev_scan` advances the two-consecutive-scans clock,
578        // but the baseline DOCUMENT is O(files) and this lane rewrote
579        // the whole thing every tick whether or not the scan set had
580        // moved — ~46 MB of emptyDir writeback per tick at the 250k cap,
581        // for an idle workspace. `prev_scan` is the ONLY field this
582        // function mutates, so the save is guarded on it alone.
583        // Both sides iterate in sorted order, so the sets compare
584        // without building one.
585        if scanned.len() != baseline.prev_scan.len()
586            || !scanned.keys().eq(baseline.prev_scan.iter())
587        {
588            baseline.prev_scan = scanned.keys().cloned().collect();
589            self.state.save_baseline(&baseline)?;
590        }
591        self.save_stage(&stage)?;
592        // D9: the same set, surfaced where a pure-spot replacement
593        // cannot destroy it. Best-effort by construction — a failed
594        // summary must never fail the lane that made the bytes durable.
595        if let Err(e) = self.surface_orphans(&stage).await {
596            eprintln!("flint-sync: orphan summary not written (retrying next tick): {e}");
597        }
598        // Durability moved even though visibility did not — the whole
599        // point of the split, and the number `rpo_secs` must track.
600        if !report.staged.is_empty() {
601            self.note_durable()?;
602        }
603        Ok(report)
604    }
605
606    /// One in-place staging PUT. The guard chain is unchanged (If-Match
607    /// on the recognized baseline etag, AdoptOwn, park on foreign); what
608    /// changes is that the response's version id is KEPT.
609    async fn stage_one(
610        &self,
611        path: &str,
612        scanned: &scan::ScanEntry,
613        base: Option<&BaselineEntry>,
614        epoch: u64,
615        flush_uuid: &str,
616        prior_uuids: &[String],
617    ) -> LeanResult<Option<PendingEntry>> {
618        let key = self.cfg.file_key(path);
619        match self.upload_one_pub(path, scanned, base, epoch, flush_uuid, prior_uuids).await? {
620            Some((entry, _be)) => Ok(Some(PendingEntry {
621                key,
622                etag: entry.etag,
623                crc64_b64: entry.crc64_b64,
624                size: entry.size,
625                mode: entry.mode,
626                mtime_unix: entry.mtime_unix,
627                generation: entry.generation,
628                epoch: entry.epoch,
629                version_id: entry.version_id,
630                base_version_id: base.and_then(|b| b.version_id.clone()),
631                staged_unix: now_unix(),
632            })),
633            None => {
634                self.state.append_conflict(&ConflictRecord {
635                    path: path.to_string(),
636                    foreign_etag: String::new(),
637                    preserved_key: None,
638                    kind: "stage-412-parked".into(),
639                    at_unix: now_unix(),
640                })?;
641                Ok(None)
642            }
643        }
644    }
645
646    /// Paths whose INTEGRATED object (the baseline) differs from the
647    /// manifest's citation: a consumed HITL write, or checkout's
648    /// S3-wins adoption. The fused barrier has carried this repair
649    /// since the amputation leg; gated mode never runs the fused
650    /// barrier, so without the same pass here an acked HITL write is
651    /// adopted into the tree, scans clean forever after, and stays
652    /// cited at its PREVIOUS version — invisible to exactly the
653    /// pinned readers D13 governs.
654    pub fn repair_candidates(&self) -> LeanResult<Vec<String>> {
655        let baseline = self.state.load_baseline()?;
656        let stage = self.load_stage()?;
657        Ok(baseline
658            .entries
659            .iter()
660            .filter(|(p, be)| {
661                be.size != u64::MAX // consume-dirty sentinel: publishes via the lane
662                    && !stage.entries.contains_key(*p)
663                    && !stage.withheld_deletes.contains(*p)
664                    && baseline.inst_base.get(*p) != Some(&be.etag)
665            })
666            .map(|(p, _)| p.clone())
667            .collect())
668    }
669
670    /// `(key, etag)` -> version id, over this subtree's whole version
671    /// history. One listing answers for every legacy entry at once,
672    /// including paths whose CURRENT version has already moved past
673    /// the citation — which a HEAD, by construction, cannot.
674    async fn version_index(&self) -> LeanResult<BTreeMap<(String, String), String>> {
675        let files_prefix = format!("{}/files/", self.cfg.prefix);
676        let mut ix: BTreeMap<(String, String), String> = BTreeMap::new();
677        // A backend that cannot list versions leaves every legacy entry
678        // unresolved, which the reader rule then refuses rather than
679        // adopts. Gated already refuses such a backend at startup.
680        for v in self.store.list_versions(&files_prefix).await.unwrap_or_default() {
681            if v.is_delete_marker {
682                continue;
683            }
684            ix.entry((v.key, v.etag)).or_insert(v.version_id);
685        }
686        Ok(ix)
687    }
688
689    /// Which coherent point, if any, is due right now.
690    pub fn citation_due(&self, sentinel_pending: bool) -> LeanResult<Option<CitationSource>> {
691        let stage = self.load_stage()?;
692        let now = now_unix();
693        if stage.entries.is_empty() && stage.withheld_deletes.is_empty() {
694            // Nothing of the agent's is staged — but a repair still
695            // owes the readers a boundary. Not a forced cap: nothing is
696            // late, and stamping these "forced" would misread the
697            // operator's own tell.
698            if !self.repair_candidates()?.is_empty()
699                && now.saturating_sub(stage.last_citation_unix) >= self.cfg.floor_secs
700            {
701                return Ok(Some(CitationSource::Repair));
702            }
703            return Ok(None);
704        }
705        if sentinel_pending {
706            return Ok(Some(CitationSource::Sentinel));
707        }
708        // The lag cap forces one even mid-change: unbounded citation
709        // staleness is impossible by construction, which is why `gated`
710        // is REFUSED without a bound rather than defaulted.
711        if let Some(bound) = self.cfg.visibility_lag_bound_secs {
712            if now.saturating_sub(stage.last_citation_unix) >= bound {
713                return Ok(Some(CitationSource::ForcedLagCap));
714            }
715        }
716        // The backlog cap bounds the preStop drain BY CONSTRUCTION.
717        let bytes: u64 = stage.entries.values().map(|e| e.size).sum();
718        // Recorded reclaims are counted as OBJECTS, because they are
719        // exactly the drain work this cap exists to bound: the citation
720        // the drain runs issues one `delete_version` per recorded id.
721        // Counting only staged PATHS would let one hot file rewritten
722        // every tick accumulate a version per tick forever — one stage
723        // entry, one size, and a cap that never fires — while
724        // `drain_need_secs` sized the grace from that same cap.
725        let recorded: u64 =
726            stage.pending_reclaims.values().map(|v| v.len() as u64).sum();
727        if stage.entries.len() as u64 + recorded >= self.cfg.staged_backlog_cap_objects
728            || bytes >= self.cfg.staged_backlog_cap_bytes
729        {
730            return Ok(Some(CitationSource::ForcedBacklogCap));
731        }
732        if stage.stable_since_unix > 0
733            && now.saturating_sub(stage.stable_since_unix) >= self.cfg.quiesce_bound_secs
734        {
735            return Ok(Some(CitationSource::Quiescence));
736        }
737        Ok(None)
738    }
739
740    /// The citation lane: ONE CAS installing the entire pending set.
741    ///
742    /// The versions already exist, so there is no copy phase — hence no
743    /// half-boundary, no intent document, no roll-forward, and no
744    /// interval in which a version-resolving reader can see
745    /// boundary-new bytes under old citations. `Inv_BoundaryAtomic`
746    /// holds by construction.
747    /// EXACT version reclamation (D8) — the reaper, not lifecycle.
748    ///
749    /// Reclaims ONLY the generations this workspace itself superseded:
750    /// for each cited path, the version its own pending record names as
751    /// `base_version_id`. Everything else — crash remnants, foreign
752    /// writes, a successor's work — is left to the noncurrent backstop.
753    ///
754    /// THE RULE USED TO BE "delete every version of a touched key that
755    /// is neither `keep` nor `is_current`", and that destroys committed
756    /// data. The `is_current` guard protects exactly ONE version, and
757    /// its reasoning ("a foreign write landed between the lane and this
758    /// citation") assumes at most one foreign generation. A successor in
759    /// gated mode does not stop at one: its cadence is stage → cite →
760    /// stage. So a straggler whose CAS landed and which then stalled in
761    /// this loop would find, on resuming, the successor's CITED version
762    /// sitting noncurrent-and-not-`keep` — and delete it. Every cited
763    /// version surviving a deposed straggler is the premise §8 Q2 chose
764    /// versioned staging on ("they destroy nothing"), so the old rule
765    /// falsified the reason the mode exists.
766    ///
767    /// Lifecycle cannot do this job on `files/`: gated staging makes the
768    /// CITED version noncurrent the moment a newer generation is staged,
769    /// so a `NoncurrentVersionExpiration` rule runs a clock against live
770    /// cited data and never reaches the newest uncited bytes, which are
771    /// current. That inversion is why the standing retention is a long
772    /// BACKSTOP and a shorter fleet-wide rule is a refusal condition.
773    pub(crate) async fn reclaim_superseded(
774        &mut self,
775        upserts: &BTreeMap<String, LeanEntry>,
776        installed: &super::manifest::LeanManifest,
777        stage: &PendingStage,
778        report: &mut CitationReport,
779    ) -> LeanResult<()> {
780        for (i, path) in upserts.keys().enumerate() {
781            // The loop is O(cited) round trips and the run loop's
782            // renewal arm cannot fire while it runs (one `select!`, the
783            // chosen branch awaited inline). Renew on our own account,
784            // and re-verify: a straggler that lost the lease mid-loop
785            // must stop deleting, not finish the sweep.
786            if i % RECLAIM_FENCE_EVERY == 0 {
787                self.renew_if_due().await?;
788                self.verify_not_deposed_pub().await?;
789            }
790            // The ONLY version this pass is entitled to reclaim: the one
791            // our own pending record says we superseded. If we cannot
792            // name it we reclaim nothing — the backstop exists for
793            // exactly the remnants we cannot name.
794            let Some(superseded) =
795                stage.entries.get(path).and_then(|pe| pe.base_version_id.clone())
796            else {
797                continue;
798            };
799            if superseded.is_empty() {
800                continue;
801            }
802            // FAIL CLOSED. If the installed manifest does not name a
803            // version for this path — an unversioned backend, a proxy
804            // that stripped the header, a merge that resolved
805            // foreign-wins — then we do not know what is cited, and
806            // reclaiming against it is guesswork.
807            let Some(keep) = installed
808                .entries
809                .get(path)
810                .and_then(|e| e.version_id.clone())
811                .filter(|v| !v.is_empty())
812            else {
813                continue;
814            };
815            // The merge may have resolved foreign-wins onto the very
816            // version we were about to reclaim.
817            if superseded == keep {
818                continue;
819            }
820            let key = self.cfg.file_key(path);
821            let Ok(versions) = self.store.list_versions(&key).await else { continue };
822            let Some(v) = versions
823                .iter()
824                .find(|v| v.key == key && !v.is_delete_marker && v.version_id == superseded)
825            else {
826                continue;
827            };
828            // NEVER the current version. If what we superseded is
829            // somehow current again, a foreign writer restored it and
830            // those are live bytes somebody is about to read.
831            if v.is_current {
832                continue;
833            }
834            let _ = self.store.delete_version(&key, &superseded).await;
835            report.versions_reclaimed += 1;
836        }
837
838        // Then everything the LANE recorded and did not delete. Same
839        // four guards; the only difference is where the candidate came
840        // from. `base_version_id` names exactly one version — the one
841        // superseded relative to the CITED generation — so without this
842        // pass every intermediate generation between two citations
843        // would be named by nothing and left to the retention backstop.
844        let recorded: Vec<(String, Vec<String>)> =
845            stage.pending_reclaims.iter().map(|(p, v)| (p.clone(), v.clone())).collect();
846        for (i, (path, vids)) in recorded.iter().enumerate() {
847            if i % RECLAIM_FENCE_EVERY == 0 {
848                self.renew_if_due().await?;
849                self.verify_not_deposed_pub().await?;
850            }
851            // Two distinct "no keep" cases, and they are NOT the same:
852            //
853            // - the installed manifest does not cite this path AT ALL
854            //   (a withheld delete installed, a path that never made a
855            //   boundary) — there is no cited version to protect, so
856            //   the recorded ids are reclaimable;
857            // - it cites the path but names no version — an unversioned
858            //   backend, a stripping proxy, a foreign-wins merge. We do
859            //   not know what is cited, so FAIL CLOSED, exactly as the
860            //   pass above does.
861            let keep = match installed.entries.get(path) {
862                None => None,
863                Some(e) => match e.version_id.clone().filter(|v| !v.is_empty()) {
864                    Some(v) => Some(v),
865                    None => continue,
866                },
867            };
868            let key = self.cfg.file_key(path);
869            let Ok(versions) = self.store.list_versions(&key).await else { continue };
870            for vid in vids {
871                if vid.is_empty() || Some(vid) == keep.as_ref() {
872                    continue;
873                }
874                let Some(v) = versions
875                    .iter()
876                    .find(|v| v.key == key && !v.is_delete_marker && &v.version_id == vid)
877                else {
878                    continue;
879                };
880                // The `is_current` guard is CONDITIONAL here, and the
881                // asymmetry is deliberate. Where the boundary cites this
882                // path, a recorded id that is current again means a
883                // foreign writer restored it and those are live bytes —
884                // skip, exactly as the pass above does. Where the
885                // boundary does not cite the path AT ALL — a withheld
886                // delete that installed, a scratch file that never made
887                // a boundary — current is the ORDINARY state of the last
888                // version this workspace staged before the file went
889                // away, and skipping it would leave precisely the litter
890                // this pass exists to collect. We delete by exact
891                // version id, so a foreign write is still safe: it would
892                // be a different id, and this one would not be current.
893                if keep.is_some() && v.is_current {
894                    continue;
895                }
896                let _ = self.store.delete_version(&key, vid).await;
897                report.versions_reclaimed += 1;
898            }
899        }
900        Ok(())
901    }
902
903    pub async fn citation_pass(&mut self, source: CitationSource) -> LeanResult<CitationReport> {
904        let epoch = self
905            .lease
906            .as_ref()
907            .map(|l| l.epoch)
908            .ok_or_else(|| LeanError::State("citation without a held lease".into()))?;
909        let mut report = CitationReport { source: Some(source.as_str().into()), ..Default::default() };
910        self.verify_not_deposed_pub().await?;
911
912        let mut stage = self.load_stage()?;
913        let mut baseline = self.state.load_baseline()?;
914        let repairs = self.repair_candidates()?;
915        if stage.entries.is_empty() && stage.withheld_deletes.is_empty() && repairs.is_empty() {
916            report.no_change = true;
917            return Ok(report);
918        }
919
920        // Base-version re-validation (D7). If the baseline's cited
921        // version moved between staging and now, a HITL consume or sync
922        // landed in between; installing our staged version would UNCITE
923        // the foreign bytes. The damage class is softer than the copy
924        // design's — there the amputation DESTROYED the foreign object,
925        // here it only hides it and the version stays fetchable — but
926        // the rule stands.
927        let scanned = scan::scan(&self.cfg.root)?;
928        let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
929        let mut drop_paths: Vec<String> = vec![];
930        for (path, pe) in &stage.entries {
931            let cited_now = baseline.entries.get(path).and_then(|b| b.version_id.clone());
932            let stale = pe.base_version_id.is_some() && cited_now != pe.base_version_id;
933            if stale {
934                let still_differs = match (scanned.get(path), baseline.entries.get(path)) {
935                    (Some(s), Some(b)) => s.size != b.size || s.mtime_unix != b.mtime_unix,
936                    (Some(_), None) => true,
937                    _ => false,
938                };
939                self.state.append_conflict(&ConflictRecord {
940                    path: path.clone(),
941                    foreign_etag: baseline
942                        .entries
943                        .get(path)
944                        .map(|b| b.etag.clone())
945                        .unwrap_or_default(),
946                    preserved_key: None,
947                    kind: format!(
948                        "citation-stale-base (superseded generation {}, still-differs={})",
949                        pe.generation, still_differs
950                    ),
951                    at_unix: now_unix(),
952                })?;
953                if !still_differs {
954                    // The foreign bytes ARE the local bytes now: drop
955                    // the staged citation rather than uncite them. The
956                    // next lane tick re-stages if the tree moves again.
957                    drop_paths.push(path.clone());
958                    report.dropped_stale_base.push(path.clone());
959                    continue;
960                }
961            }
962            upserts.insert(
963                path.clone(),
964                LeanEntry {
965                    key: pe.key.clone(),
966                    etag: pe.etag.clone(),
967                    crc64_b64: pe.crc64_b64.clone(),
968                    size: pe.size,
969                    mode: pe.mode,
970                    mtime_unix: pe.mtime_unix,
971                    generation: pe.generation,
972                    epoch: pe.epoch,
973                    version_id: pe.version_id.clone(),
974                },
975            );
976        }
977        for p in &drop_paths {
978            stage.entries.remove(p);
979        }
980
981        // The ungated repair (§2.4.2). No bytes move: the object IS
982        // what this syncer already integrated, and the citation is
983        // catching up to it. The HEAD is also what supplies the VERSION
984        // id — a consume records none, and under `pinned_reads` a
985        // citation without one is unreadable by the very readers this
986        // pass exists for.
987        for path in &repairs {
988            if upserts.contains_key(path) {
989                continue;
990            }
991            let Some(be) = baseline.entries.get(path).cloned() else { continue };
992            let key = self.cfg.file_key(path);
993            let meta = match self.store.head(&key).await {
994                Ok(m) if m.etag == be.etag => m,
995                // Moved again or gone: citing a version this workspace
996                // has not integrated would be the lie the repair exists
997                // to prevent. The next consume reconciles it.
998                _ => continue,
999            };
1000            let crc = super::barrier::repair_crc(path, &be, &meta)?;
1001            let stamps = GenerationStamps::from_meta(&meta.meta);
1002            let scan_entry = scanned.get(path);
1003            upserts.insert(
1004                path.clone(),
1005                LeanEntry {
1006                    key,
1007                    etag: meta.etag.clone(),
1008                    crc64_b64: crc,
1009                    size: meta.size,
1010                    mode: stamps
1011                        .as_ref()
1012                        .and_then(|s| s.posix)
1013                        .map(|p| p.mode)
1014                        .or(scan_entry.map(|s| s.mode))
1015                        .unwrap_or(0o644),
1016                    mtime_unix: scan_entry.map(|s| s.mtime_unix).unwrap_or(be.mtime_unix),
1017                    generation: stamps.map(|s| s.generation).unwrap_or(be.generation),
1018                    epoch,
1019                    version_id: meta.version_id.clone(),
1020                },
1021            );
1022            report.repaired.push(path.clone());
1023        }
1024
1025        // Window open/clear belong to THIS lane only.
1026        let deadline = now_unix() + self.cfg.window_slack_secs;
1027        let opened =
1028            inbox::open_window(self.store.as_ref(), &self.cfg, epoch, deadline).await?;
1029
1030        // A HITL write that landed AFTER the lane staged this path.
1031        //
1032        // The base-version check above cannot see it: it reads the
1033        // BASELINE, and the citation lane consumes nothing, so the
1034        // baseline has not moved. And the upload lane opens no window —
1035        // deliberately, because a lane that fenced HITL out every floor
1036        // tick would refuse admission essentially forever between
1037        // citations. So the gap is real and it is the mode's own doing.
1038        //
1039        // The inbox is what can see it, and the window CAS just loaded
1040        // it: zero added requests. Drop the path rather than cite bytes
1041        // that PREDATE the user's write over it. The entry stays queued,
1042        // so the next lane consumes it the ordinary way.
1043        //
1044        // (Found by the formal model — tranche 3 product 2. Before the
1045        // companion rule below, the reaper then DELETED the user's
1046        // version, because it was not the one the manifest cited.)
1047        //
1048        // NOT every entry in the cell: since consumed entries leave the
1049        // cell at the citation rather than at the consume, an entry
1050        // this lane has already integrated — the baseline holds its
1051        // etag, or the consume-dirty sentinel for it — is the citation's
1052        // own business, not a write that landed after the stage.
1053        let inflight: BTreeSet<String> = opened
1054            .doc
1055            .entries
1056            .iter()
1057            .filter(|e| {
1058                !baseline
1059                    .entries
1060                    .get(&e.path)
1061                    .map(|b| b.etag == e.etag || b.size == u64::MAX)
1062                    .unwrap_or(false)
1063            })
1064            .map(|e| e.path.clone())
1065            .collect();
1066        for path in &inflight {
1067            if upserts.remove(path).is_some() {
1068                stage.entries.remove(path);
1069                // Kept separate from the stale-base drop on purpose:
1070                // this one, and only this one, removes the agent's
1071                // LATEST bytes from a boundary it may be acked for.
1072                // The stale-base drop fires only when the foreign bytes
1073                // ARE the local bytes, so the point still carries the
1074                // agent's content.
1075                report.dropped_inflight.push(path.clone());
1076                self.state.append_conflict(&ConflictRecord {
1077                    path: path.clone(),
1078                    foreign_etag: String::new(),
1079                    preserved_key: None,
1080                    kind: "citation-hitl-inflight".into(),
1081                    at_unix: now_unix(),
1082                })?;
1083            }
1084        }
1085
1086        let deletes = stage.withheld_deletes.clone();
1087        let parked: BTreeSet<String> = BTreeSet::new();
1088        let flush_uuid = uuid::Uuid::new_v4().to_string();
1089        let mut intent = self.state.load_intent()?;
1090        let prev_installed = intent.installed_etag.clone();
1091        let mut attempt = 0;
1092        let mut version_index: Option<BTreeMap<(String, String), String>> = None;
1093        let mut backfilled: Vec<String> = vec![];
1094        let mut unresolved: BTreeSet<String> = BTreeSet::new();
1095        let (installed, installed_etag, foreign) = loop {
1096            attempt += 1;
1097            if attempt > 4 {
1098                return Err(LeanError::State(
1099                    "citation CAS lost 4 merge races — refusing this boundary".into(),
1100                ));
1101            }
1102            let current = manifest::load(self.store.as_ref(), &self.cfg).await?;
1103            let (theirs, expected) = match &current {
1104                // The handle carries the layout as well as the tag —
1105                // see the fused barrier's copy of this.
1106                Some(l) => (l.manifest.clone(), Some(l.handle())),
1107                None => (Default::default(), None),
1108            };
1109            // Same rule as the fused barrier: if the bucket is still at
1110            // the document we installed, that document IS the merge
1111            // base (`IntentJournal::installed_etag`). A citation pass
1112            // that crashed between its CAS and the baseline rewrite
1113            // would otherwise read its own boundary as foreign.
1114            let own_base;
1115            let base: &BTreeMap<String, String> =
1116                if prev_installed.is_some()
1117                    && prev_installed.as_deref() == expected.as_ref().map(|h| h.etag.as_str())
1118                {
1119                    own_base = theirs
1120                        .entries
1121                        .iter()
1122                        .map(|(p, e)| (p.clone(), e.etag.clone()))
1123                        .collect();
1124                    &own_base
1125                } else {
1126                    &baseline.inst_base
1127                };
1128            let (mut merged, foreign) =
1129                manifest::merge(base, &theirs, &upserts, &deletes, &parked);
1130            // D13: this citation's readers resolve by version, never by
1131            // S3-wins adoption of the current version.
1132            merged.pinned_reads = true;
1133            // Independent of D13 and cleared by `merge`, so it is
1134            // restated here for the same reason the cadence barrier
1135            // restates it: it describes the deployment, not the
1136            // citation.
1137            merged.sole_writer = self.cfg.sole_writer;
1138            // The mixed-manifest cell, where D7's entry schema and D13
1139            // collide. Stamping `pinned_reads` over an entry a pre-D7
1140            // writer cited leaves it addressable only by etag — and the
1141            // moment the lane stages that path, its current version is
1142            // uncited mid-change bytes that checkout's fallback arm
1143            // would adopt. Resolve the CITED etag in the bucket's own
1144            // version history before installing: one listing, and it
1145            // answers even where the current version has already moved.
1146            let legacy: Vec<String> = merged
1147                .entries
1148                .iter()
1149                .filter(|(p, e)| {
1150                    e.version_id.is_none() && !stage.legacy_unresolved.contains(*p)
1151                })
1152                .map(|(p, _)| p.clone())
1153                .collect();
1154            if !legacy.is_empty() {
1155                if version_index.is_none() {
1156                    version_index = Some(self.version_index().await?);
1157                }
1158                let ix = version_index.as_ref().expect("just built");
1159                for path in legacy {
1160                    let Some(e) = merged.entries.get_mut(&path) else { continue };
1161                    match ix.get(&(e.key.clone(), e.etag.clone())) {
1162                        Some(vid) => {
1163                            e.version_id = Some(vid.clone());
1164                            backfilled.push(path);
1165                        }
1166                        None => {
1167                            unresolved.insert(path);
1168                        }
1169                    }
1170                }
1171            }
1172            match manifest::cas_write_stamped(
1173                self.store.as_ref(),
1174                &self.cfg,
1175                &merged,
1176                expected.as_ref(),
1177                epoch,
1178                &flush_uuid,
1179                Some(source.as_str()),
1180            )
1181            .await
1182            {
1183                Ok(meta) => {
1184                    intent.installed_etag = Some(meta.etag.clone());
1185                    self.state.save_intent(&intent)?;
1186                    break (merged, meta.etag, foreign);
1187                }
1188                Err(LeanError::Store(StoreError::PreconditionFailed(_)))
1189                | Err(LeanError::Store(StoreError::Conflict(_))) => {
1190                    self.verify_not_deposed_pub().await?;
1191                    continue;
1192                }
1193                Err(e) => return Err(e),
1194            }
1195        };
1196        report.seq = Some(installed.seq);
1197        report.cited = upserts.len();
1198        backfilled.sort();
1199        backfilled.dedup();
1200        report.backfilled = backfilled;
1201        stage.legacy_unresolved.extend(unresolved);
1202        self.note_boundary(source.as_str(), installed.seq)?;
1203
1204        // GC of keys the NEW manifest no longer references — deletes
1205        // LAST, HEAD-guarded on the recognized ETag, unchanged.
1206        for path in &deletes {
1207            if installed.entries.contains_key(path) {
1208                continue;
1209            }
1210            let key = self.cfg.file_key(path);
1211            let recognized = baseline.entries.get(path).map(|b| b.etag.clone());
1212            match self.store.head(&key).await {
1213                Err(StoreError::NotFound(_)) => report.deleted.push(path.clone()),
1214                Ok(meta) if Some(&meta.etag) == recognized.as_ref() => {
1215                    self.store.delete(&key).await?;
1216                    report.deleted.push(path.clone());
1217                }
1218                Ok(meta) => {
1219                    self.state.append_conflict(&ConflictRecord {
1220                        path: path.clone(),
1221                        foreign_etag: meta.etag,
1222                        preserved_key: None,
1223                        kind: "gc-skip".into(),
1224                        at_unix: now_unix(),
1225                    })?;
1226                }
1227                Err(e) => return Err(e.into()),
1228            }
1229        }
1230
1231        // EXACT version reclamation (D8) — this is the reaper, not
1232        // lifecycle. After the CAS, every version of a touched key that
1233        // neither the installed manifest nor the (now empty) pending set
1234        // names is deleted, version-scoped and free. Steady state ⇒ one
1235        // version per key.
1236        //
1237        // Lifecycle cannot do this job on `files/`: gated staging makes
1238        // the CITED version noncurrent the moment a newer generation is
1239        // staged, so a `NoncurrentVersionExpiration` rule runs a clock
1240        // against live cited data and never reaches the newest uncited
1241        // bytes, which are current. That inversion is why the standing
1242        // retention is a long BACKSTOP and a shorter fleet-wide rule is
1243        // a refusal condition.
1244        self.reclaim_superseded(&upserts, &installed, &stage, &mut report).await?;
1245
1246        // Baseline rewrite.
1247        for (path, e) in &installed.entries {
1248            // A repaired path keeps its integrated stat; only the
1249            // version id it is now cited by is new.
1250            if report.repaired.contains(path) {
1251                if let Some(be) = baseline.entries.get_mut(path) {
1252                    be.version_id = e.version_id.clone();
1253                }
1254            }
1255            if let Some(pe) = stage.entries.get(path) {
1256                baseline.entries.insert(
1257                    path.clone(),
1258                    BaselineEntry {
1259                        etag: e.etag.clone(),
1260                        generation: e.generation,
1261                        size: pe.size,
1262                        mtime_unix: pe.mtime_unix,
1263                        version_id: e.version_id.clone(),
1264                        crc64_b64: Some(e.crc64_b64.clone()),
1265                    },
1266                );
1267            }
1268        }
1269        for path in &report.deleted {
1270            baseline.entries.remove(path);
1271        }
1272        baseline.inst_base =
1273            installed.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
1274        baseline.seq = installed.seq;
1275        baseline.manifest_etag = Some(installed_etag);
1276        self.state.save_baseline(&baseline)?;
1277        self.state.clear_intent_keys()?;
1278
1279        // What this citation installed leaves the cell now — after the
1280        // CAS, never before it: the consumed entries the manifest now
1281        // cites (or that the agent's own version has since superseded),
1282        // and the DECLARED removals whose deletion it installed.
1283        let cell = inbox::load(self.store.as_ref(), &self.cfg).await?.doc;
1284        let cited_entries: Vec<inbox::InboxEntry> = cell
1285            .entries
1286            .iter()
1287            .filter(|e| {
1288                baseline.entries.get(&e.path).map(|b| b.etag == e.etag || b.size == u64::MAX).unwrap_or(false)
1289            })
1290            .cloned()
1291            .collect();
1292        if !cited_entries.is_empty() {
1293            inbox::drop_entries(self.store.as_ref(), &self.cfg, epoch, &cited_entries).await?;
1294        }
1295        let cited_out: Vec<inbox::Removal> = cell
1296            .removals
1297            .into_iter()
1298            .filter(|r| r.refused.is_none() && deletes.contains(&r.path))
1299            .collect();
1300        inbox::settle_removals(self.store.as_ref(), &self.cfg, epoch, &cited_out, &[]).await?;
1301
1302        stage.entries.clear();
1303        stage.withheld_deletes.clear();
1304        stage.pending_reclaims.clear();
1305        stage.last_citation_unix = now_unix();
1306        stage.stable_since_unix = 0;
1307        self.save_stage(&stage)?;
1308        // The CLEARING write matters as much as the surfacing one: a
1309        // stale summary claiming candidates pages an operator about
1310        // work that was cited minutes ago (D9).
1311        if let Err(e) = self.surface_orphans(&stage).await {
1312            eprintln!("flint-sync: orphan summary not cleared (retrying next tick): {e}");
1313        }
1314
1315        let queue: Vec<inbox::InboxEntry> = foreign
1316            .into_iter()
1317            .map(|(path, e)| inbox::InboxEntry {
1318                path,
1319                etag: e.etag,
1320                author: "merge-preserved".into(),
1321                added_unix: now_unix(),
1322                crc64_b64: Some(e.crc64_b64),
1323            })
1324            .collect();
1325        inbox::clear_window(self.store.as_ref(), &self.cfg, epoch, &queue).await?;
1326        Ok(report)
1327    }
1328
1329    /// The gated floor tick: lane, then a citation if one is due.
1330    ///
1331    /// Both halves are reported because the floor arm has to describe
1332    /// them separately — a tick that staged 40 files and cited nothing
1333    /// is the mode working, and a tick that staged nothing and cited
1334    /// nothing is idle. Collapsing them into one "no change" would make
1335    /// gated mode indistinguishable from a wedged one in the log.
1336    pub async fn gated_tick(
1337        &mut self,
1338        sentinel_pending: bool,
1339    ) -> LeanResult<(LaneReport, CitationReport)> {
1340        let lane = self.upload_lane().await?;
1341        let cite = match self.citation_due(sentinel_pending)? {
1342            Some(source) => self.citation_pass(source).await?,
1343            None => CitationReport { no_change: true, ..Default::default() },
1344        };
1345        Ok((lane, cite))
1346    }
1347
1348    /// The startup gate for gated mode (D8, D11): probe the version
1349    /// surface BEFORE a single byte is staged, and refuse rather than
1350    /// degrade. Inert in `cadence`/`hybrid`, which need no version
1351    /// surface at all — a gate that took every default workspace down
1352    /// with it would be worse than the hazard.
1353    pub async fn gated_startup_check(&mut self) -> LeanResult<()> {
1354        if !self.is_gated() {
1355            return Ok(());
1356        }
1357        self.versioning_conformance().await
1358    }
1359
1360    /// `flint-sync recover-staged` (D9): re-cite durable-but-uncited
1361    /// work as ONE flagged boundary — a manifest CAS with no data
1362    /// movement, because the versions already exist.
1363    ///
1364    /// The routine path into this verb is a pure-spot pod replacement:
1365    /// the emptyDir that held `pending.json` is gone, so the pending
1366    /// record can name nothing and **the bucket is the only source of
1367    /// truth**. Two shapes are recovered, and they overlap:
1368    ///
1369    /// - *uncited work* — the key's current version is not the one the
1370    ///   manifest cites (or the key is not cited at all: a brand-new
1371    ///   file staged and never installed);
1372    /// - *a dangling citation* — the cited version no longer exists,
1373    ///   because gated staging made it NONCURRENT and the retention
1374    ///   backstop ran its clock against live cited data (D8's
1375    ///   inversion). Checkout refuses on this rather than serving a
1376    ///   hole, so recovery is what makes the workspace usable again.
1377    ///
1378    /// Recovery rolls **forward**, onto the newer bytes, and says so:
1379    /// every re-citation writes a conflict record. It is deliberately
1380    /// NOT a three-way merge — a replacement pod has no merge base, and
1381    /// merging against an empty one would classify the entire existing
1382    /// manifest as foreign and queue the whole tree into the HITL
1383    /// inbox.
1384    pub async fn recover_staged(&mut self) -> LeanResult<RecoverReport> {
1385        let epoch = self
1386            .lease
1387            .as_ref()
1388            .map(|l| l.epoch)
1389            .ok_or_else(|| LeanError::State("recover-staged without a held lease".into()))?;
1390        let mut report = RecoverReport::default();
1391        let files_prefix = format!("{}/files/", self.cfg.prefix);
1392
1393        // D9's durable summary is the MECHANISM; the prefix-wide version
1394        // LIST is the fallback (review: U1). `flint-store`'s own trait
1395        // doc has said so since the version surface landed — "the
1396        // claim-time/DR fallback when `orphans.json` is missing or
1397        // stale — the expensive path, which is why the durable summary
1398        // is written eagerly" — but nothing in the syncer read the
1399        // summary, so recovery always took the expensive path and the
1400        // eager write bought nothing here.
1401        //
1402        // The summary narrows WHICH keys we list, never what we
1403        // conclude about them: every candidate is still resolved
1404        // against the bucket below. A stale or partial summary
1405        // therefore costs coverage, not correctness — so a missing one,
1406        // or one written under a different boundary mode, falls back to
1407        // the full sweep rather than trusting a narrower answer.
1408        let summary = match self.store.get_whole(&self.orphans_key(), None).await {
1409            Ok((_, bytes)) => serde_json::from_slice::<OrphanDoc>(&bytes).ok(),
1410            Err(_) => None,
1411        };
1412        // The manifest is read BEFORE the narrowing, because the
1413        // narrowed key set is the union of the summary's candidates and
1414        // everything the manifest already CITES.
1415        //
1416        // Getting that wrong is a data-loss-shaped bug, and leg B11b
1417        // caught it: `orphans.json` names only the STAGED candidates, so
1418        // narrowing to those alone left every cited-but-not-staged path
1419        // with no listed version at all — and recovery classifies a path
1420        // with no surviving version as UNRECOVERABLE. A workspace with
1421        // one quiet cited file would have been told its data was gone.
1422        let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
1423        let cited_now = loaded.as_ref().map(|l| l.manifest.clone()).unwrap_or_default();
1424
1425        let narrowed: Option<BTreeSet<String>> = summary.as_ref().and_then(|d| {
1426            if d.boundary_mode != self.cfg.boundary_mode.as_str() || d.candidates.is_empty() {
1427                return None;
1428            }
1429            let mut keys: BTreeSet<String> =
1430                d.candidates.iter().map(|c| self.cfg.file_key(&c.path)).collect();
1431            keys.extend(cited_now.entries.keys().map(|p| self.cfg.file_key(p)));
1432            Some(keys)
1433        });
1434        report.from_summary = narrowed.is_some();
1435
1436        let listed: Vec<ListedVersion> = match &narrowed {
1437            Some(keys) => {
1438                let mut out = vec![];
1439                for key in keys {
1440                    out.extend(self.store.list_versions(key).await.unwrap_or_default());
1441                }
1442                out
1443            }
1444            None => self.store.list_versions(&files_prefix).await?,
1445        };
1446        let mut current: BTreeMap<String, ListedVersion> = BTreeMap::new();
1447        let mut known: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1448        for v in listed {
1449            known.entry(v.key.clone()).or_default().insert(v.version_id.clone());
1450            if v.is_current {
1451                current.insert(v.key.clone(), v);
1452            }
1453        }
1454
1455        let mut paths: BTreeSet<String> = cited_now.entries.keys().cloned().collect();
1456        for key in current.keys() {
1457            if let Some(p) = key.strip_prefix(&files_prefix) {
1458                // D0.2 again: a legacy `files/.flint/` citation must not
1459                // be recovered INTO the control namespace.
1460                if !p.is_empty() && !scan::is_control_path(p) {
1461                    paths.insert(p.to_string());
1462                }
1463            }
1464        }
1465
1466        let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
1467        for path in paths {
1468            let key = self.cfg.file_key(&path);
1469            let cited = cited_now.entries.get(&path).cloned();
1470            let live = current.get(&key).filter(|v| !v.is_delete_marker).cloned();
1471
1472            // A citation dangles only if it names a version BY ID that
1473            // the bucket no longer holds. A legacy entry citing no
1474            // version resolves by etag and cannot dangle this way.
1475            let dangles = match &cited {
1476                Some(e) => match &e.version_id {
1477                    Some(v) => !known.get(&key).map(|s| s.contains(v)).unwrap_or(false),
1478                    None => false,
1479                },
1480                None => false,
1481            };
1482            if dangles {
1483                report.dangling.push(path.clone());
1484            }
1485            let Some(live) = live else {
1486                if dangles {
1487                    report.unrecoverable.push(path.clone());
1488                }
1489                continue;
1490            };
1491            let already = match &cited {
1492                None => false,
1493                Some(e) => match &e.version_id {
1494                    Some(v) => v == &live.version_id,
1495                    None => e.etag == live.etag,
1496                },
1497            };
1498            if already {
1499                continue;
1500            }
1501
1502            // One HEAD per re-cited path to recover the stamps the
1503            // manifest entry needs. A HEAD is not data movement.
1504            let meta = self.store.head_version(&key, &live.version_id).await?;
1505            // The CRC the re-citation carries: the backend's attestation
1506            // when it offers one. A replacement pod has no baseline to
1507            // ask, so on a backend that attests nothing (Ozone; a HITL
1508            // upload sent without one) the bytes are the only source —
1509            // the one recovery step that moves data, and only there.
1510            let crc = match meta.crc64_b64.clone() {
1511                Some(c) => c,
1512                None => {
1513                    let (_, body) = self.store.get_version(&key, &live.version_id).await?;
1514                    crc64_to_b64(crc64_nvme(&body))
1515                }
1516            };
1517            let stamps = GenerationStamps::from_meta(&meta.meta);
1518            let posix = stamps.as_ref().and_then(|s| s.posix);
1519            upserts.insert(
1520                path.clone(),
1521                LeanEntry {
1522                    key: key.clone(),
1523                    etag: meta.etag.clone(),
1524                    crc64_b64: crc,
1525                    size: meta.size,
1526                    mode: posix
1527                        .map(|p| p.mode)
1528                        .or_else(|| cited.as_ref().map(|e| e.mode))
1529                        .unwrap_or(0o100_644),
1530                    mtime_unix: posix
1531                        .map(|p| p.mtime_unix)
1532                        .or_else(|| meta.last_modified_unix.map(|u| u as i64))
1533                        .unwrap_or(0),
1534                    generation: stamps
1535                        .as_ref()
1536                        .map(|s| s.generation)
1537                        .or_else(|| cited.as_ref().map(|e| e.generation + 1))
1538                        .unwrap_or(1),
1539                    epoch: stamps.as_ref().map(|s| s.epoch).unwrap_or(epoch),
1540                    version_id: Some(live.version_id.clone()),
1541                },
1542            );
1543            self.state.append_conflict(&ConflictRecord {
1544                path: path.clone(),
1545                foreign_etag: cited.as_ref().map(|e| e.etag.clone()).unwrap_or_default(),
1546                preserved_key: None,
1547                kind: if dangles {
1548                    "recovered-staged (dangling citation rolled forward)".into()
1549                } else {
1550                    "recovered-staged".to_string()
1551                },
1552                at_unix: now_unix(),
1553            })?;
1554            report.recited.push(path.clone());
1555        }
1556
1557        if upserts.is_empty() {
1558            report.no_change = true;
1559            report.seq = loaded.map(|l| l.manifest.seq);
1560            return Ok(report);
1561        }
1562
1563        // The upserts are ABSOLUTE — derived from bucket versions, not
1564        // from a diff — so a lost CAS re-applies them onto whatever is
1565        // current without recomputing anything.
1566        let flush_uuid = uuid::Uuid::new_v4().to_string();
1567        let mut fresh = loaded;
1568        for attempt in 0..4u32 {
1569            let (mut m, expected) = match &fresh {
1570                Some(l) => (l.manifest.clone(), Some(l.handle())),
1571                None => (manifest::LeanManifest::default(), None),
1572            };
1573            for (p, e) in &upserts {
1574                m.entries.insert(p.clone(), e.clone());
1575            }
1576            m.seq += 1;
1577            m.pinned_reads = self.is_gated();
1578            match manifest::cas_write_stamped(
1579                self.store.as_ref(),
1580                &self.cfg,
1581                &m,
1582                expected.as_ref(),
1583                epoch,
1584                &flush_uuid,
1585                Some(CitationSource::Recovered.as_str()),
1586            )
1587            .await
1588            {
1589                Ok(_) => {
1590                    report.seq = Some(m.seq);
1591                    self.note_boundary(CitationSource::Recovered.as_str(), m.seq)?;
1592                    return Ok(report);
1593                }
1594                Err(LeanError::Store(StoreError::PreconditionFailed(_)))
1595                | Err(LeanError::Store(StoreError::Conflict(_))) if attempt < 3 => {
1596                    self.verify_not_deposed_pub().await?;
1597                    fresh = manifest::load(self.store.as_ref(), &self.cfg).await?;
1598                }
1599                Err(e) => return Err(e),
1600            }
1601        }
1602        Err(LeanError::State(
1603            "recover-staged lost 4 CAS races — a live writer is still publishing".into(),
1604        ))
1605    }
1606
1607    /// Is this workspace gated?
1608    pub fn is_gated(&self) -> bool {
1609        self.cfg.boundary_mode == BoundaryMode::Gated
1610    }
1611
1612    /// The versioning conformance probe (D8) — the SHARED
1613    /// implementation in `flint_store::probe`, because the operator
1614    /// runs the same probe on its reconcile cadence and the two
1615    /// verdicts must never disagree. A workspace the operator calls
1616    /// conformant and the syncer refuses to start is worse than
1617    /// either answer alone.
1618    ///
1619    /// A project-scoped proxy that strips `x-amz-version-id` degrades
1620    /// gated mode SILENTLY — pending entries would carry `None`, and
1621    /// citation would fall back to etag semantics on a key whose
1622    /// current version is uncited, which is precisely the torn view the
1623    /// mode exists to prevent. So the probe REFUSES rather than
1624    /// degrading, and is re-run at startup and on the operator cadence:
1625    /// proxies upgrade, and a bucket's posture changes under you.
1626    pub async fn versioning_conformance(&self) -> LeanResult<()> {
1627        let key = format!("{}/{}/probe/versioning", self.cfg.prefix, super::LEAN_DIR);
1628        flint_store::probe::probe_version_surface(self.store.as_ref(), &key)
1629            .await
1630            .map_err(|m| LeanError::State(format!("versioning conformance probe FAILED: {m}")))
1631    }
1632}
1633
1634/// A stable fingerprint of a scan: quiescence is scan-to-scan
1635/// STABILITY, so what matters is that this value stops changing.
1636fn scan_fingerprint(scanned: &BTreeMap<String, scan::ScanEntry>) -> String {
1637    let mut h = flint_store::Crc64Nvme::new();
1638    for (p, e) in scanned {
1639        h.update(p.as_bytes());
1640        h.update(&e.size.to_be_bytes());
1641        h.update(&e.mtime_unix.to_be_bytes());
1642    }
1643    format!("{:016x}", h.finalize())
1644}