Skip to main content

flint_lean/
barrier.rs

1//! The publish barrier (plan §2.1, seven steps) — the machine
2//! `lean/formal/LeanSubtree.tla` checks. Order is load-bearing:
3//! consume → scan → intent/window → uploads → manifest CAS (merge) →
4//! GC deletes LAST → baseline rewrite.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::Path;
8
9use bytes::Bytes;
10
11use flint_store::{
12    EpochState,
13    crc64_nvme, crc64_to_b64, GenerationStamps, PosixStamps, PutCondition, StoreError,
14};
15
16use super::inbox::{self, InboxEntry};
17use super::manifest::{self, LeanEntry};
18use super::scan;
19use super::state::{BaselineEntry, ConflictRecord, IntentJournal};
20use super::{now_unix, LeanError, LeanResult, Syncer};
21
22/// The last gateway request this workspace acted on, per verb. A
23/// watermark rather than a consumed queue — see `note_verb_requests`.
24#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
25pub struct VerbWatermark {
26    #[serde(default)]
27    pub boundary_unix: u64,
28    #[serde(default)]
29    pub sync_unix: u64,
30}
31
32#[derive(Debug, Default)]
33pub struct BarrierReport {
34    pub seq: Option<u64>,
35    pub uploaded: Vec<String>,
36    pub deleted: Vec<String>,
37    pub parked: Vec<String>,
38    pub consumed: usize,
39    pub foreign_queued: usize,
40    /// Paths whose transfer drifted or was swept mid-compose: nothing
41    /// published, nothing advanced; the next scan re-queues them.
42    pub deferred: Vec<String>,
43    pub no_change: bool,
44    /// Bytes this barrier actually published — the input to the
45    /// sentinel work meter (boundary-verbs plan D3.1). Metering work
46    /// rather than calls is what keeps a sentinel storm from
47    /// un-coalescing a hot large file's republish: a counted budget
48    /// charges a 2 GiB checkpoint the same one unit as a 4 KiB file.
49    pub published_bytes: u64,
50    /// The manifest ETag this barrier installed (the ack's CAS token).
51    pub manifest_etag: Option<String>,
52    /// What the manifest HEAD/install told us the bucket is at, for the
53    /// news ticker (D5) — free, off requests the barrier already made.
54    pub observed_seq: Option<u64>,
55    pub observed_etag: Option<String>,
56    /// Paths this barrier saw absent for the FIRST time: their deletes
57    /// are withheld to the next scan (the rename-vs-walk guard). On a
58    /// declared boundary a non-empty set means the confirmation lstat
59    /// found them back on disk — the race the guard exists for.
60    pub first_absence: Vec<String>,
61    /// First-absence paths a DECLARED boundary confirmed gone by direct
62    /// lstat and published as ordinary deletes (`confirm_absences`).
63    pub absences_confirmed: usize,
64    /// This barrier finished a rescope a crash had left half-applied
65    /// before it did anything else. Reported so a run that looks like
66    /// an ordinary barrier can be told from one that had to converge a
67    /// workspace first.
68    pub rescope_replayed: bool,
69}
70
71/// Upload waves per chunk. The chunk is `fanout * this`, so a wave
72/// still saturates fan-out and the sync point between chunks is rare.
73const UPLOAD_CHUNK_WAVES: usize = 16;
74
75/// How stale the lease may get inside a barrier before it is renewed.
76/// Deliberately BELOW the run loop's `renew_every` (min(floor, 30) s) so
77/// a long barrier keeps the same heartbeat cadence the loop would have,
78/// and comfortably below the 6-poll (~60 s) takeover window.
79const RENEW_WITHIN_SECS: u64 = 20;
80
81impl Syncer {
82    fn lease_epoch(&self) -> LeanResult<u64> {
83        self.lease
84            .as_ref()
85            .map(|l| l.epoch)
86            .ok_or_else(|| LeanError::State("barrier without a held lease".into()))
87    }
88
89    /// The in-loop honor paths' fence check (boundary-verbs plan D2):
90    /// `Syncer::sync` carries no lease/epoch check of its own, so a
91    /// straggler consuming a sync sentinel between deposal and its next
92    /// cooperative fence would apply the successor's manifest onto its
93    /// zombie tree and ack SUCCESS.
94    pub async fn verify_not_deposed_pub(&self) -> LeanResult<()> {
95        self.verify_not_deposed().await
96    }
97
98    /// Verify the cell still names us at OUR epoch; anything else is a
99    /// fence. Read-verify before the manifest CAS (the per-request
100    /// validation the gateway will also enforce).
101    async fn verify_not_deposed(&self) -> LeanResult<()> {
102        let lease = self
103            .lease
104            .as_ref()
105            .ok_or_else(|| LeanError::State("no lease".into()))?;
106        match self.store.epoch_read(&self.cfg.epoch_key()).await? {
107            Some(state) if state.epoch == lease.epoch && state.holder_id == lease.holder_id => {
108                Ok(())
109            }
110            Some(state) => Err(LeanError::Fenced(format!(
111                "cell at epoch {} holder {} (we are epoch {})",
112                state.epoch, state.holder_id, lease.epoch
113            ))),
114            None => Err(LeanError::Fenced("epoch cell vanished".into())),
115        }
116    }
117
118    /// Step 1: consume the inbox. A barrier NEVER runs against an
119    /// unconsumed inbox — this is what makes HITL uploads structurally
120    /// un-amputatable. Returns the entries integrated (dropped from the
121    /// cell at the window-open CAS).
122    pub async fn consume_inbox(&mut self) -> LeanResult<Vec<InboxEntry>> {
123        let loaded = inbox::load(self.store.as_ref(), &self.cfg).await?;
124        // §2.5's layered doors ride the inbox GET this function already
125        // pays for: promptness is one tick, and the added request count
126        // is ZERO. A failure here must never fail the consume — the
127        // request is idempotent state and the next tick re-reads it.
128        if let Err(e) = self.note_verb_requests(&loaded.doc) {
129            eprintln!("flint-sync: verb request not consumed (retrying next tick): {e}");
130        }
131        let mut consumed = vec![];
132        let mut baseline = self.state.load_baseline()?;
133        for entry in &loaded.doc.entries {
134            let key = self.cfg.file_key(&entry.path);
135            // Containment BEFORE anything else: a path we could never
136            // safely materialize must be surfaced and dropped, not
137            // routed through the locally-dirty branch — which would
138            // "preserve" it with a GET+PUT and leave a baseline entry
139            // for a path the scanner can never see (the planted-symlink
140            // shape: `inputs -> /root/.aws` reads as locally-present,
141            // therefore dirty, therefore a conflict-preserve of someone
142            // else's file).
143            if let Err(e) = check_contained(&self.cfg.root, &entry.path) {
144                self.state.append_conflict(&ConflictRecord {
145                    path: entry.path.clone(),
146                    foreign_etag: entry.etag.clone(),
147                    preserved_key: None,
148                    kind: format!("consume-refused-containment: {e}"),
149                    at_unix: now_unix(),
150                })?;
151                consumed.push(entry.clone());
152                continue;
153            }
154            // Already integrated (a crashed earlier consume): idempotent.
155            if baseline.entries.get(&entry.path).map(|b| b.etag == entry.etag).unwrap_or(false) {
156                consumed.push(entry.clone());
157                continue;
158            }
159            let head = match self.store.head(&key).await {
160                Ok(m) => m,
161                Err(StoreError::NotFound(_)) => {
162                    // The object vanished under a tracked entry: surface
163                    // it — the bytes are NOT recoverable from here.
164                    self.state.append_conflict(&ConflictRecord {
165                        path: entry.path.clone(),
166                        foreign_etag: entry.etag.clone(),
167                        preserved_key: None,
168                        kind: "consume-object-missing".into(),
169                        at_unix: now_unix(),
170                    })?;
171                    consumed.push(entry.clone());
172                    continue;
173                }
174                Err(e) => return Err(e.into()),
175            };
176            if head.etag != entry.etag {
177                // Superseded by a newer write (its own inbox entry
178                // follows, or it is the syncer's): drop.
179                consumed.push(entry.clone());
180                continue;
181            }
182            let local_path = self.cfg.root.join(&entry.path);
183            let dirty = local_dirty(&local_path, baseline.entries.get(&entry.path));
184            if dirty {
185                // Locally-dirty wins; preserve the FOREIGN bytes first
186                // (a conflict record must keep both versions
187                // recoverable), then advance the recognized ETag so our
188                // eventual publish supersedes it KNOWINGLY.
189                let preserved = self.preserve_conflict_copy(&entry.path, &head.etag).await?;
190                self.state.append_conflict(&ConflictRecord {
191                    path: entry.path.clone(),
192                    foreign_etag: entry.etag.clone(),
193                    preserved_key: Some(preserved),
194                    kind: "consume-dirty".into(),
195                    at_unix: now_unix(),
196                })?;
197                let stamps = GenerationStamps::from_meta(&head.meta);
198                baseline.entries.insert(
199                    entry.path.clone(),
200                    BaselineEntry {
201                        etag: head.etag.clone(),
202                        generation: stamps.map(|s| s.generation).unwrap_or(0),
203                        // Deliberately NOT the local file's stat: the
204                        // path must still scan as dirty so the local
205                        // version publishes.
206                        size: u64::MAX,
207                        mtime_unix: 0,
208                        version_id: None,
209                        // The sentinel's bytes are the LOCAL edit; the
210                        // publish that supersedes hashes them itself.
211                        crc64_b64: None,
212                    },
213                );
214            } else {
215                // Clean: adopt the foreign content into the tree.
216                let (meta, body) =
217                    self.store.get_whole(&key, Some(&entry.etag)).await.map_err(|e| match e {
218                        StoreError::PreconditionFailed(m) => {
219                            StoreError::Other(format!("consume raced a newer write: {m}"))
220                        }
221                        other => other,
222                    })?;
223                let mode = PosixStamps::from_meta(&meta.meta).map(|p| p.mode);
224                // VERIFIED before it is written, as checkout's fresh
225                // fetch is: against the writer's CRC when the inbox
226                // entry carries one (the gateway hashes what it sent),
227                // else the backend's attestation when it offers one.
228                // Ozone offers none, so the inbox's is what a HITL
229                // upload gets checked against there. What the baseline
230                // records — and the next citation repair cites — is
231                // OURS, over the bytes actually written.
232                let got = crc64_to_b64(crc64_nvme(&body));
233                let want = entry.crc64_b64.clone().or_else(|| meta.crc64_b64.clone());
234                if let Some(want) = want {
235                    if want != got {
236                        // NOT consumed: the entry stays in the cell and
237                        // the record repeats — a permanent, visible
238                        // contradiction is the right failure for
239                        // bytes nobody vouches for.
240                        self.state.append_conflict(&ConflictRecord {
241                            path: entry.path.clone(),
242                            foreign_etag: entry.etag.clone(),
243                            preserved_key: None,
244                            kind: format!(
245                                "consume-refused-checksum: etag {} is attested with CRC-64 \
246                                 {want} but the bytes fetched under it hash to {got}",
247                                entry.etag
248                            ),
249                            at_unix: now_unix(),
250                        })?;
251                        continue;
252                    }
253                }
254                // CONTAINMENT and I/O are split, because the answers are
255                // opposite. Containment was already decided above by
256                // `check_contained`, so a refusal reaching here is a
257                // path that only became unsafe once parents were
258                // created — still a permanent property of the path, so
259                // it is surfaced and DROPPED, exactly as above.
260                //
261                // Everything else — ENOSPC, EACCES, EIO, a read-only
262                // filesystem — is TRANSIENT, and dropping the entry for
263                // one is silent permanent loss: the foreign bytes stay
264                // in the bucket, the workspace never adopts them, and
265                // nothing ever re-offers the entry. It used to be
266                // recorded as `consume-refused-containment`, which sent
267                // whoever read it looking for a hostile path that was
268                // never there. A full disk is not a planted symlink.
269                let target = match contained_path(&self.cfg.root, &entry.path) {
270                    Ok(t) => t,
271                    Err(e) => {
272                        self.state.append_conflict(&ConflictRecord {
273                            path: entry.path.clone(),
274                            foreign_etag: entry.etag.clone(),
275                            preserved_key: None,
276                            kind: format!("consume-refused-containment: {e}"),
277                            at_unix: now_unix(),
278                        })?;
279                        consumed.push(entry.clone());
280                        continue;
281                    }
282                };
283                if let Err(e) = write_file_atomic(&target, &body, mode) {
284                    // NOT consumed: the entry stays in the cell so the
285                    // next barrier retries it. A visible conflict record
286                    // that repeats is a far better failure than one
287                    // silent drop.
288                    self.state.append_conflict(&ConflictRecord {
289                        path: entry.path.clone(),
290                        foreign_etag: entry.etag.clone(),
291                        preserved_key: None,
292                        kind: format!("consume-write-failed (will retry): {e}"),
293                        at_unix: now_unix(),
294                    })?;
295                    continue;
296                }
297                let st = std::fs::metadata(&local_path)?;
298                let stamps = GenerationStamps::from_meta(&meta.meta);
299                baseline.entries.insert(
300                    entry.path.clone(),
301                    BaselineEntry {
302                        etag: meta.etag.clone(),
303                        generation: stamps.map(|s| s.generation).unwrap_or(0),
304                        size: st.len(),
305                        mtime_unix: mtime_of(&st),
306                        version_id: None,
307                        crc64_b64: Some(got),
308                    },
309                );
310                baseline.prev_scan.insert(entry.path.clone());
311            }
312            consumed.push(entry.clone());
313        }
314        // The files this baseline vouches for reached the disk before
315        // the baseline does (audit 2026-09-03, finding 9).
316        self.state.sync_tree()?;
317        self.state.save_baseline(&baseline)?;
318        Ok(consumed)
319    }
320
321    /// Act on the inbox document's two request fields (§2.5, D14).
322    ///
323    /// The asymmetry is deliberate and is about blast radius, not
324    /// principle: a boundary request is PERFORMED (it publishes what is
325    /// already on disk and mutates nothing local), a sync request is
326    /// CARRIED into the ticker as advisory news and the agent decides.
327    ///
328    /// Both are watermarked by `requested_unix` rather than consumed by
329    /// a clearing CAS. The field is idempotent state, so a repeat is a
330    /// no-op and a lost clear cannot double-publish; and not writing
331    /// means the doors cost no request at all, which is what §2.5
332    /// promises.
333    fn note_verb_requests(&mut self, doc: &inbox::InboxDoc) -> LeanResult<()> {
334        let mut mark = self.load_verb_watermark()?;
335        if let Some(r) = &doc.sync_request {
336            if r.requested_unix > mark.sync_unix {
337                self.carry_sync_request(r.requested_unix, &r.requestor)?;
338                mark.sync_unix = r.requested_unix;
339            }
340        }
341        if let Some(r) = &doc.boundary_request {
342            if r.requested_unix > mark.boundary_unix {
343                self.request_boundary(
344                    &format!("gw:{}:{}", r.requested_unix, r.requestor),
345                    Some(format!("boundary requested by {}", r.requestor)),
346                )?;
347                mark.boundary_unix = r.requested_unix;
348            }
349        }
350        self.save_verb_watermark(&mark)
351    }
352
353    fn verb_watermark_path(&self) -> std::path::PathBuf {
354        self.cfg.state_dir().join("verb-requests.json")
355    }
356
357    pub(crate) fn load_verb_watermark(&self) -> LeanResult<VerbWatermark> {
358        let p = self.verb_watermark_path();
359        if !p.exists() {
360            return Ok(VerbWatermark::default());
361        }
362        Ok(std::fs::read(&p)
363            .ok()
364            .and_then(|b| serde_json::from_slice(&b).ok())
365            .unwrap_or_default())
366    }
367
368    fn save_verb_watermark(&self, m: &VerbWatermark) -> LeanResult<()> {
369        let bytes = serde_json::to_vec_pretty(m)
370            .map_err(|e| LeanError::State(format!("verb watermark: {e}")))?;
371        super::control::write_atomic(&self.verb_watermark_path(), &bytes)
372    }
373
374    async fn preserve_conflict_copy(&self, path: &str, etag: &str) -> LeanResult<String> {
375        let key = self.cfg.file_key(path);
376        let dst = self.cfg.conflict_key(&uuid::Uuid::new_v4().to_string(), path);
377        // v1: client-side copy (GET + guarded PUT). The server-side
378        // CopyObject lever is the designed v2 optimization for large
379        // files.
380        let (meta, body) = self.store.get_whole(&key, Some(etag)).await?;
381        let crc = crc64_nvme(&body);
382        let stamps = GenerationStamps {
383            generation: 0,
384            epoch: self.lease.as_ref().map(|l| l.epoch).unwrap_or(0),
385            flush_uuid: "conflict-preserve".into(),
386            boundary_source: None,
387            posix: PosixStamps::from_meta(&meta.meta),
388        };
389        self.store
390            .put_whole(&dst, body, &PutCondition::IfNoneMatchAny, &stamps, crc)
391            .await?;
392        Ok(dst)
393    }
394
395    /// Take the SECOND absence observation now, by direct `lstat`.
396    ///
397    /// `scan::classify` withholds a path's delete until absence has
398    /// survived two consecutive scans — the rename-vs-walk race guard
399    /// (`lib.rs:37`). The hazard that rule names is the WALK missing a
400    /// file renamed under it, not deletion itself. On the cadence path
401    /// the second walk arrives at the next floor tick and nobody has
402    /// been promised otherwise. On a DECLARED boundary it cannot wait:
403    /// the ack would claim a coherent point (D1 — "everything
404    /// ordered-before T") while the manifest still cites a file the
405    /// agent removed before it touched the sentinel, and it would say
406    /// so with `report.deleted: 0` and `status: "ok"`.
407    ///
408    /// A direct `lstat` is exactly what the rename-vs-walk guard asks
409    /// for and is immune to the walk race by construction, so the
410    /// second observation costs one syscall per transiently-absent
411    /// path — never a second full pass. The cadence path is unchanged
412    /// and still waits for the second walk.
413    pub(crate) fn confirm_absences(&self, classified: &mut scan::Classified) -> LeanResult<usize> {
414        if classified.first_absence.is_empty() {
415            return Ok(0);
416        }
417        let mut confirmed = 0;
418        for path in std::mem::take(&mut classified.first_absence) {
419            // ONLY NotFound is absence. Every other errno — EACCES on a
420            // parent, EIO, EMFILE, ELOOP — used to read as "the agent
421            // deleted it", and this is the oracle that PUBLISHES the
422            // delete and then DELETES THE OBJECT. An unreadable file is
423            // the one case where guessing is unrecoverable, so it fails
424            // CLOSED, exactly as the walk does at `scan.rs`'s
425            // `symlink_metadata(&path)?`. Two call sites, one syscall:
426            // they must not have opposite error policies.
427            let gone = match std::fs::symlink_metadata(self.cfg.root.join(&path)) {
428                Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
429                Err(e) => {
430                    classified.first_absence.insert(path.clone());
431                    return Err(LeanError::State(format!(
432                        "cannot confirm the absence of {path:?}: {e} — refusing to publish \
433                         a deletion on an unreadable path"
434                    )));
435                }
436                Ok(_) => false,
437            };
438            if gone {
439                classified.deletes.insert(path);
440                confirmed += 1;
441            } else {
442                // Back on disk: the rename-vs-walk race, caught exactly
443                // as the rule intends. Still only a first absence.
444                classified.first_absence.insert(path);
445            }
446        }
447        Ok(confirmed)
448    }
449
450    /// Renew the lease if it has gone stale MID-BARRIER, and hand back
451    /// the cell as it was read so the CALLER can fence on it.
452    ///
453    /// Costs nothing on a short barrier: the renewal is skipped unless
454    /// it was already due, so the request count is unchanged — this
455    /// moves WHEN the heartbeat can happen, not how often.
456    ///
457    /// What this does NOT do, stated because the previous comment
458    /// claimed it did: it never raises `Fenced` for a deposed holder.
459    /// `lease::renew` can only 412 when the cell is still at OUR epoch
460    /// with a token we do not hold; a cell at a FOREIGN epoch is
461    /// skipped here (renewing it would be nonsense), so a deposed
462    /// straggler passed straight through. The 2026-09-03 audit found
463    /// the cadence barrier relying on exactly that phantom fence
464    /// between upload chunks and completing every remaining data PUT
465    /// after a takeover. The cell is returned so the caller can compare
466    /// it against its lease — the gated lanes already pay a separate
467    /// `verify_not_deposed_pub` for the same purpose; the cadence
468    /// barrier uses the read it already paid for. `None` when there is
469    /// no lease or the read failed: neither is a fence, and the
470    /// ordinary renewal arm will try again.
471    pub(crate) async fn renew_if_due(&mut self) -> LeanResult<Option<EpochState>> {
472        let Some(lease) = self.lease.clone() else { return Ok(None) };
473        let state = match self.store.epoch_read(&self.cfg.epoch_key()).await {
474            Ok(Some(s)) => s,
475            _ => return Ok(None),
476        };
477        let now = super::now_unix();
478        let fresh = state
479            .last_renew_unix
480            .map(|t| now.saturating_sub(t) < RENEW_WITHIN_SECS)
481            .unwrap_or(false);
482        if fresh || state.epoch != lease.epoch {
483            // Not due, or not ours to renew. The caller decides what a
484            // foreign cell means; this function only knows it cannot
485            // renew one.
486            return Ok(Some(state));
487        }
488        super::lease::renew(self).await?;
489        Ok(Some(state))
490    }
491
492    /// The between-chunk fence of the cadence barrier: a cell that no
493    /// longer names this holder at this epoch stops the upload set
494    /// HERE, before the next chunk's PUTs. Every one of those PUTs
495    /// carries If-Match on a baseline etag that still matches — the
496    /// successor has published nothing yet — so without this a deposed
497    /// straggler overwrites the cited generation of every key it still
498    /// had to upload, and every reader's S3-wins arm then adopts the
499    /// uncited bytes silently (audit 2026-09-03, finding 1).
500    fn fence_on_cell(&self, cell: &EpochState) -> LeanResult<()> {
501        let Some(lease) = self.lease.as_ref() else {
502            return Err(LeanError::Fenced("lease dropped mid-barrier".into()));
503        };
504        if cell.epoch != lease.epoch || cell.holder_id != lease.holder_id {
505            return Err(LeanError::Fenced(format!(
506                "deposed between upload chunks: cell at epoch {} holder {} (we are epoch {})",
507                cell.epoch, cell.holder_id, lease.epoch
508            )));
509        }
510        Ok(())
511    }
512
513    /// Steps 2–7. `barrier` = one full publish cycle (the cadence arm).
514    pub async fn run_barrier(&mut self) -> LeanResult<BarrierReport> {
515        self.barrier_inner(false, "manual").await
516    }
517
518    /// The floor tick's barrier. Stamped `cadence` so the bucket can
519    /// answer "which clock installed this boundary?" without the ack —
520    /// the ack is a LOCAL file, and the fleet-visible question is asked
521    /// by operators and by the gateway's `/status`, neither of which
522    /// can see it.
523    pub async fn cadence_barrier(&mut self) -> LeanResult<BarrierReport> {
524        self.barrier_inner(false, "cadence").await
525    }
526
527    /// A barrier whose result somebody is going to ACK: a sentinel
528    /// honor (D1) or the preStop drain (D10). Identical to
529    /// `run_barrier` except that it confirms first-absence paths rather
530    /// than acking a boundary that withholds them.
531    pub async fn declared_barrier(&mut self) -> LeanResult<BarrierReport> {
532        self.barrier_inner(true, "sentinel").await
533    }
534
535    /// `declared_barrier` under an explicit provenance stamp — the
536    /// drain and the budget-deferred honor are the same barrier
537    /// reporting a different clock.
538    pub async fn declared_barrier_as(&mut self, source: &str) -> LeanResult<BarrierReport> {
539        self.barrier_inner(true, source).await
540    }
541
542    async fn barrier_inner(&mut self, declared: bool, source: &str) -> LeanResult<BarrierReport> {
543        let epoch = self.lease_epoch()?;
544        let mut report = BarrierReport::default();
545
546        // STEP 0: finish any rescope a crash left half-applied, before
547        // the scan reads the tree (scoped-read design §4.3). This is the
548        // gate that makes the narrow verb safe at all: a half-applied
549        // narrow is a tree whose files and whose citations disagree, and
550        // `classify` reads that disagreement as an upload or as a
551        // DELETE depending on which half landed. Replay is idempotent,
552        // so the cost on the normal path is one `stat` of a file that
553        // is not there.
554        if let Some(r) = self.replay_scope_intent().await? {
555            report.rescope_replayed = true;
556            eprintln!(
557                "flint-sync: finished an interrupted rescope before the barrier \
558                 (uncited {}, unlinked {}, materialised {})",
559                r.uncited, r.unlinked, r.materialized
560            );
561        }
562
563        // Cooperative deposal check BEFORE any write (a thawed
564        // straggler fences here instead of landing data PUTs). This
565        // narrows the window; the per-request epoch validation that
566        // CLOSES it is the gateway's (P5) — the model's LeanNoEpochCheck
567        // mutation is the proof rotation alone does not cover the data
568        // path.
569        self.verify_not_deposed().await?;
570
571        // Step 1.
572        let consumed = self.consume_inbox().await?;
573        report.consumed = consumed.len();
574
575        // Step 2: scan-diff against the persisted baseline.
576        let mut baseline = self.state.load_baseline()?;
577        let scanned = scan::scan(&self.cfg.root)?;
578        let mut classified = scan::classify(&scanned, &baseline);
579        if declared {
580            report.absences_confirmed = self.confirm_absences(&mut classified)?;
581        }
582        report.first_absence = classified.first_absence.iter().cloned().collect();
583
584        // Skip-on-no-diff: nothing local, nothing consumed, no pending
585        // citation repair, and the bucket manifest where we left it.
586        let repairs_pending = baseline
587            .entries
588            .iter()
589            .any(|(p, be)| be.size != u64::MAX && baseline.inst_base.get(p) != Some(&be.etag));
590        if classified.uploads.is_empty()
591            && classified.deletes.is_empty()
592            && consumed.is_empty()
593            && classified.first_absence.is_empty()
594            && !repairs_pending
595        {
596            // Read the POINTER, never the entries: the 0b rig measured
597            // the idle tick at 1M entries as 27 s / 1.3 GiB — dominated
598            // by fetching and parsing a 264 MiB document just to read
599            // `seq`. The pointer is a few hundred bytes and carries the
600            // seq in its body, so a GET of it costs one round trip and
601            // answers strictly more than a HEAD of the old object did.
602            //
603            // Legacy workspaces (no pointer yet) keep the HEAD they had.
604            let unchanged = match manifest::load_pointer(self.store.as_ref(), &self.cfg).await? {
605                Some(manifest::LoadedPointer { pointer: p, etag, .. }) => {
606                    // The news ticker rides this request for free — D5's
607                    // "zero added bucket requests" is still literal.
608                    report.observed_seq = Some(p.seq);
609                    report.observed_etag = Some(etag.clone());
610                    baseline.manifest_etag.as_deref() == Some(etag.as_str())
611                }
612                None => match self.store.head(&self.cfg.manifest_key()).await {
613                    Ok(meta) => {
614                        report.observed_seq =
615                            GenerationStamps::from_meta(&meta.meta).map(|s| s.generation);
616                        report.observed_etag = Some(meta.etag.clone());
617                        baseline.manifest_etag.as_deref() == Some(meta.etag.as_str())
618                    }
619                    Err(StoreError::NotFound(_)) => baseline.manifest_etag.is_none(),
620                    Err(e) => return Err(e.into()),
621                },
622            };
623            if unchanged {
624                report.no_change = true;
625                report.seq = Some(baseline.seq);
626                // prev_scan still advances (the two-scan rule's clock) —
627                // but the baseline document (hundreds of MB at 1M
628                // entries) is rewritten only when the scan SET moved.
629                // Compare without building the set: both sides iterate
630                // sorted, and the allocation this used to make was the
631                // size of the very document it was trying not to write.
632                if scanned.len() != baseline.prev_scan.len()
633                    || !scanned.keys().eq(baseline.prev_scan.iter())
634                {
635                    baseline.prev_scan = scanned.keys().cloned().collect();
636                    self.state.save_baseline(&baseline)?;
637                }
638                return Ok(report);
639            }
640        }
641
642        // Step 3: intent journal, then the window (the commitment
643        // point: the same CAS drops the consumed entries).
644        let flush_uuid = uuid::Uuid::new_v4().to_string();
645        let mut intent = self.state.load_intent()?;
646        let prior_uuids = {
647            let mut v = intent.recent_uuids.clone();
648            if !intent.flush_uuid.is_empty() {
649                v.push(intent.flush_uuid.clone());
650            }
651            v
652        };
653        // Carried forward, not dropped: it stays true until we install
654        // again, and the merge below is what reads it.
655        let prev_installed = intent.installed_etag.clone();
656        intent = IntentJournal {
657            flush_uuid: flush_uuid.clone(),
658            keys: classified.uploads.iter().map(|p| self.cfg.file_key(p)).collect(),
659            recent_uuids: prior_uuids.clone(),
660            installed_etag: prev_installed.clone(),
661        };
662        self.state.save_intent(&intent)?;
663        let deadline = now_unix() + self.cfg.window_slack_secs;
664        inbox::open_window(self.store.as_ref(), &self.cfg, epoch, deadline).await?;
665        if !consumed.is_empty() {
666            // Drop consumed entries now that they are durably in the
667            // baseline (idempotent if this crashes: re-consume skips).
668            inbox::drop_entries(self.store.as_ref(), &self.cfg, epoch, &consumed).await?;
669        }
670
671        // Step 4: guarded uploads, fanned out under a bounded window
672        // (each key's guard chain is independent; the 412 policy and
673        // conflict records are applied to the collected results below,
674        // in deterministic path order).
675        let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
676        let mut parked: BTreeSet<String> = BTreeSet::new();
677        let mut new_baseline_entries: BTreeMap<String, BaselineEntry> = BTreeMap::new();
678        let outcomes: Vec<(String, LeanResult<UploadOutcome>)> = {
679            use futures::stream::{self, StreamExt};
680            // CHUNKED, and the reason is the lease rather than memory.
681            //
682            // The run loop is one `select!`, so while this call runs the
683            // renewal arm CANNOT fire — branches are mutually exclusive.
684            // An unchunked upload phase therefore starves the heartbeat
685            // for its whole duration, which does two bad things: a
686            // deposed straggler cannot learn it was deposed (the CAS
687            // that would tell it never runs), and a HEALTHY syncer can
688            // outrun the 60 s takeover window and have a standby take
689            // the lease off a live writer. The 0b numbers put a 1M-file
690            // checkout at 7 m 05 s, so this is reachable, not exotic.
691            //
692            // Between chunks nothing borrows `self`, so the renewal can
693            // run on its ordinary ≤30 s cadence — it costs NO extra
694            // requests, because that renewal was already due. The chunk
695            // is a multiple of fan-out so a wave still saturates it.
696            //
697            // Residual, stated rather than hidden: starvation is now
698            // bounded by ONE CHUNK's duration instead of the whole
699            // barrier. A chunk of very large files can still exceed the
700            // window; closing that needs the renewal on its own task,
701            // which needs the lease behind a shared cell.
702            let mut outcomes: Vec<(String, LeanResult<UploadOutcome>)> = Vec::new();
703            // NOT cfg.fanout. That knob was raised to 128 for the READ
704            // path, where a byte semaphore (fetch_inflight_max_bytes)
705            // bounds what concurrency can hold in memory. THIS path has
706            // no such gate — `buffer_unordered` is the only bound — so
707            // riding the same number would have quadrupled both the
708            // bodies held in memory and `chunk`, widening the
709            // lease-fence window from 512 files to 2048 and making the
710            // hazard named in the comment above four times likelier.
711            // The read-side measurement (2.5x on 20k small files) says
712            // nothing about either, so uploads keep the value they were
713            // measured at until someone measures them.
714            let fanout = self.cfg.upload_fanout.max(1);
715            let chunk = fanout.saturating_mul(UPLOAD_CHUNK_WAVES).max(1);
716            let all: Vec<&String> = classified.uploads.iter().collect();
717            for group in all.chunks(chunk) {
718                {
719                    let this: &Syncer = &*self;
720                    let mut part: Vec<(String, LeanResult<UploadOutcome>)> =
721                        stream::iter(group.iter().map(|path| {
722                            let path = *path;
723                            let scanned_entry = &scanned[path];
724                            let base = baseline.entries.get(path);
725                            let flush_uuid = &flush_uuid;
726                            let prior_uuids = &prior_uuids;
727                            async move {
728                                let r = this
729                                    .upload_one(
730                                        path, scanned_entry, base, epoch, flush_uuid,
731                                        prior_uuids,
732                                    )
733                                    .await;
734                                (path.clone(), r)
735                            }
736                        }))
737                        .buffer_unordered(fanout)
738                        .collect()
739                        .await;
740                    outcomes.append(&mut part);
741                }
742                // Now that the borrow is released: keep the lease alive,
743                // and FENCE on the cell that read returned. A deposed
744                // straggler stops here, before the next chunk's PUTs —
745                // the renewal alone never fenced a foreign cell (see
746                // `renew_if_due`), which is how a straggler used to run
747                // its upload set to completion after a takeover.
748                if let Some(cell) = self.renew_if_due().await? {
749                    self.fence_on_cell(&cell)?;
750                }
751            }
752            outcomes
753        };
754        let mut outcomes = outcomes;
755        outcomes.sort_by(|a, b| a.0.cmp(&b.0));
756        for (path, outcome) in outcomes {
757            let path = &path;
758            match outcome? {
759                UploadOutcome::Published { entry, baseline_entry } => {
760                    report.published_bytes += entry.size;
761                    upserts.insert(path.clone(), entry);
762                    new_baseline_entries.insert(path.clone(), baseline_entry);
763                    report.uploaded.push(path.clone());
764                }
765                UploadOutcome::Parked { foreign_etag } => {
766                    parked.insert(path.clone());
767                    self.state.append_conflict(&ConflictRecord {
768                        path: path.clone(),
769                        foreign_etag,
770                        preserved_key: None, // parking preserves in place
771                        kind: "upload-412-parked".into(),
772                        at_unix: now_unix(),
773                    })?;
774                    report.parked.push(path.clone());
775                }
776                UploadOutcome::Deferred => {
777                    report.deferred.push(path.clone());
778                }
779            }
780        }
781
782        // Citation repairs: paths whose integrated object (the
783        // baseline) differs from the manifest's citation — consumed
784        // HITL adoptions and checkout's S3-wins arm. No bytes move; the
785        // manifest re-cites what this syncer already integrated.
786        // Without this, an adopted upload is clean-vs-baseline, never
787        // enters the upload set, and the manifest silently drops it —
788        // the battery's amputation leg caught exactly that.
789        let repair_candidates: Vec<String> = baseline
790            .entries
791            .iter()
792            .filter(|(path, be)| {
793                !classified.uploads.contains(*path)
794                    && !classified.deletes.contains(*path)
795                    && be.size != u64::MAX // consume-dirty sentinel: publishes via upload
796                    && baseline.inst_base.get(*path) != Some(&be.etag)
797            })
798            .map(|(path, _)| path.clone())
799            .collect();
800        for path in repair_candidates {
801            let key = self.cfg.file_key(&path);
802            let be = baseline.entries[&path].clone();
803            match self.store.head(&key).await {
804                Ok(meta) if meta.etag == be.etag => {
805                    let crc = repair_crc(&path, &be, &meta)?;
806                    let stamps = GenerationStamps::from_meta(&meta.meta);
807                    let scan_entry = scanned.get(&path);
808                    upserts.insert(
809                        path.clone(),
810                        LeanEntry {
811                            key,
812                            etag: meta.etag.clone(),
813                            crc64_b64: crc,
814                            size: meta.size,
815                            mode: stamps
816                                .as_ref()
817                                .and_then(|s| s.posix)
818                                .map(|p| p.mode)
819                                .or(scan_entry.map(|s| s.mode))
820                                .unwrap_or(0o644),
821                            mtime_unix: scan_entry.map(|s| s.mtime_unix).unwrap_or(0),
822                            generation: stamps.map(|s| s.generation).unwrap_or(be.generation),
823                            epoch,
824                            version_id: meta.version_id.clone(),
825                        },
826                    );
827                }
828                // Moved again or gone: the next consume reconciles it.
829                _ => {}
830            }
831        }
832
833        // Step 5: the manifest CAS (three-way merge; bounded retries).
834        self.verify_not_deposed().await?;
835        let mut foreign_entries: Vec<(String, LeanEntry)> = vec![];
836        let mut attempt = 0;
837        let (installed, installed_etag) = loop {
838            attempt += 1;
839            if attempt > 4 {
840                return Err(LeanError::State(
841                    "manifest CAS lost 4 merge races — refusing this barrier".into(),
842                ));
843            }
844            let current = manifest::load(self.store.as_ref(), &self.cfg).await?;
845            let (theirs, expected) = match &current {
846                // The handle carries the LAYOUT as well as the tag: a
847                // workspace still on the legacy single object CASes a
848                // pointer that must not exist yet, not one it read.
849                Some(l) => (l.manifest.clone(), Some(l.handle())),
850                None => (Default::default(), None),
851            };
852            // If the bucket is still at the document THIS workspace
853            // installed, that document IS the merge base — whatever the
854            // persisted one says. Step 7 rewrites the merge base after
855            // the CAS, so a restart in between leaves it behind a
856            // document we wrote, and every entry in it would read as
857            // somebody else's change. See `IntentJournal::installed_etag`.
858            let own_base;
859            let base: &BTreeMap<String, String> =
860                if prev_installed.is_some()
861                    && prev_installed.as_deref() == expected.as_ref().map(|h| h.etag.as_str())
862                {
863                    own_base = theirs
864                        .entries
865                        .iter()
866                        .map(|(p, e)| (p.clone(), e.etag.clone()))
867                        .collect();
868                    &own_base
869                } else {
870                    &baseline.inst_base
871                };
872            let (mut merged, foreign) =
873                manifest::merge(base, &theirs, &upserts, &classified.deletes, &parked);
874            // `merge` clears it; the installing pass owns it. A mirror
875            // is a property of how this workspace is DEPLOYED, so it
876            // comes from config on every publish rather than being
877            // inherited from whatever wrote last.
878            merged.sole_writer = self.cfg.sole_writer;
879            match manifest::cas_write_stamped(
880                self.store.as_ref(),
881                &self.cfg,
882                &merged,
883                expected.as_ref(),
884                epoch,
885                &flush_uuid,
886                Some(source),
887            )
888            .await
889            {
890                Ok(meta) => {
891                    // Before the deletes and before step 7: this is the
892                    // only record that survives a crash in that window.
893                    intent.installed_etag = Some(meta.etag.clone());
894                    self.state.save_intent(&intent)?;
895                    foreign_entries = foreign;
896                    break (merged, meta.etag);
897                }
898                Err(LeanError::Store(StoreError::PreconditionFailed(_)))
899                | Err(LeanError::Store(StoreError::Conflict(_))) => {
900                    // Re-verify the cell before retrying: a rotation is
901                    // exactly this 412, and re-merging past it would be
902                    // the straggler install.
903                    self.verify_not_deposed().await?;
904                    continue;
905                }
906                Err(e) => return Err(e),
907            }
908        };
909        report.seq = Some(installed.seq);
910        // A fused install IS a coherent point, and cadence/hybrid have
911        // exactly one source. The gauges must not report "no boundary
912        // ever" on a workspace that publishes every minute.
913        self.note_boundary(super::gated::CitationSource::Cadence.as_str(), installed.seq)?;
914        report.manifest_etag = Some(installed_etag.clone());
915        report.observed_seq = Some(installed.seq);
916        report.observed_etag = Some(installed_etag.clone());
917        report.foreign_queued = foreign_entries.len();
918
919        // Step 6: deletes LAST — GC of keys the NEW manifest no longer
920        // references, HEAD-guarded on the recognized ETag.
921        for path in &classified.deletes {
922            if installed.entries.contains_key(path) {
923                continue; // delete/modify resolved foreign-wins: not garbage
924            }
925            let key = self.cfg.file_key(path);
926            let recognized = baseline.entries.get(path).map(|b| b.etag.clone());
927            match self.store.head(&key).await {
928                Err(StoreError::NotFound(_)) => {
929                    report.deleted.push(path.clone());
930                }
931                Ok(meta) if Some(&meta.etag) == recognized.as_ref() => {
932                    self.store.delete(&key).await?;
933                    report.deleted.push(path.clone());
934                }
935                Ok(meta) => {
936                    // An ETag this syncer does not recognize is NEVER
937                    // deleted (a HITL re-create landed after our CAS).
938                    self.state.append_conflict(&ConflictRecord {
939                        path: path.clone(),
940                        foreign_etag: meta.etag,
941                        preserved_key: None,
942                        kind: "gc-skip".into(),
943                        at_unix: now_unix(),
944                    })?;
945                }
946                Err(e) => return Err(e.into()),
947            }
948        }
949
950        // Step 7: baseline rewrite, intent clear, window clear (+ queue
951        // the merge-preserved foreign entries for the next consume).
952        for (path, be) in new_baseline_entries {
953            baseline.entries.insert(path, be);
954        }
955        for path in &report.deleted {
956            baseline.entries.remove(path);
957        }
958        baseline.inst_base =
959            installed.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
960        baseline.seq = installed.seq;
961        baseline.manifest_etag = Some(installed_etag);
962        baseline.prev_scan = scanned.keys().cloned().collect();
963        self.state.save_baseline(&baseline)?;
964        self.state.clear_intent_keys()?;
965        let queue: Vec<InboxEntry> = foreign_entries
966            .into_iter()
967            .map(|(path, e)| InboxEntry {
968                path,
969                etag: e.etag,
970                author: "merge-preserved".into(),
971                added_unix: now_unix(),
972                crc64_b64: Some(e.crc64_b64),
973            })
974            .collect();
975        inbox::clear_window(self.store.as_ref(), &self.cfg, epoch, &queue).await?;
976        // Reap superseded generations. Immutable metadata that is never
977        // collected is a leak that grows by a whole manifest per
978        // publish, and this also collects the orphan a crash between the
979        // entries PUT and the pointer CAS leaves behind. Best effort by
980        // design: a publish that succeeded is not un-done by a failure
981        // to tidy up after it.
982        match manifest::sweep_generations(self.store.as_ref(), &self.cfg).await {
983            Ok(0) => {}
984            Ok(n) => eprintln!("flint-sync: reaped {n} superseded manifest generation(s)"),
985            Err(e) => eprintln!("flint-sync: generation sweep: {e}"),
986        }
987        // And the CHUNK reaper, same reason, same best-effort terms.
988        // `sweep_chunks` returns 0 without a request on a workspace that
989        // is not chunked, so this costs nothing until the layout moves.
990        //
991        // It was written to four model-established rules, guarded by six
992        // mutation configs, and called from nothing but its own tests —
993        // so with chunking on, every publish left its superseded chunks
994        // in the bucket forever. The rules ran in the model and in the
995        // suite and never once in production. `the_barrier_reaps...`
996        // below is the test that asks whether this line exists at all.
997        match manifest::sweep_chunks(self.store.as_ref(), &self.cfg).await {
998            Ok(0) => {}
999            Ok(n) => eprintln!("flint-sync: reaped {n} unreferenced manifest chunk(s)"),
1000            Err(e) => eprintln!("flint-sync: chunk sweep: {e}"),
1001        }
1002        Ok(report)
1003    }
1004
1005    /// The > whole_put_max path: contiguous `PartSource::Local` chunks
1006    /// through `compose_generation` (streaming multipart; the store
1007    /// aborts its partial assembly on every failure path). The CRC is
1008    /// computed by a streaming pass first; a writer racing the compose
1009    /// fails server-side validation and the path DEFERS to the next
1010    /// barrier — publish-possibly-torn is put_whole's documented
1011    /// dilemma, not this path's.
1012    #[allow(clippy::too_many_arguments)]
1013    async fn upload_compose(
1014        &self,
1015        path: &str,
1016        key: &str,
1017        local_path: &Path,
1018        size: u64,
1019        scanned: &scan::ScanEntry,
1020        condition: PutCondition,
1021        stamps: GenerationStamps,
1022        generation: u64,
1023        epoch: u64,
1024    ) -> LeanResult<UploadOutcome> {
1025        // NO PRE-PASS. The store accumulates the full-object CRC from
1026        // the parts as it reads them for upload, so this file is read
1027        // ONCE. It used to be read TWICE — a streaming hash of the whole
1028        // file, and then again part by part — which on the 2026-09-10
1029        // door drill was roughly 11 s of the `mixed` workload's 24.3 s,
1030        // all of it at the disk's read ceiling and none of it on the
1031        // wire.
1032        //
1033        // That pre-pass was not laziness: asking the store to accumulate
1034        // used to pin its part uploads to a sequential loop, because a
1035        // CRC-64 taken out of order is a different number. `crc64_combine`
1036        // removed the choice — parts are hashed independently and folded
1037        // in part order — so the single read and the parallel upload are
1038        // no longer alternatives.
1039
1040        // Part grid: within [min_part_size, ...], at most max_parts,
1041        // contiguous from 0.
1042        let min_part = self.store.min_part_size().max(1);
1043        let max_parts = self.store.max_parts().max(1) as u64;
1044        let mut chunk = self.cfg.whole_put_max.max(min_part);
1045        let need = size.div_ceil(chunk);
1046        if need > max_parts {
1047            chunk = size.div_ceil(max_parts).div_ceil(min_part) * min_part;
1048        }
1049        let mut parts = vec![];
1050        let mut off = 0u64;
1051        while off < size {
1052            let len = chunk.min(size - off);
1053            parts.push(flint_store::PartSource::Local { offset: off, len });
1054            off += len;
1055        }
1056
1057        let spec = flint_store::ComposeSpec {
1058            progress: None,
1059            key,
1060            local_path,
1061            parts,
1062            base_key: None,
1063            base_etag: None,
1064            condition,
1065            stamps: stamps.clone(),
1066            crc64: None,
1067        };
1068        match self.store.compose_generation(&spec).await {
1069            Ok(meta) => {
1070                // The store computed it; refuse to cite bytes nothing
1071                // vouched for rather than invent a number for the
1072                // manifest. A backend that returns no checksum has not
1073                // validated the publish server-side either.
1074                let crc = meta
1075                    .crc64_b64
1076                    .as_deref()
1077                    .and_then(flint_store::crc64_from_b64)
1078                    .ok_or_else(|| {
1079                        LeanError::State(format!(
1080                            "compose of {key} returned no full-object checksum — refusing to \
1081                             cite bytes nothing vouched for"
1082                        ))
1083                    })?;
1084                Ok(UploadOutcome::published(
1085                    path, key.to_string(), meta.etag, crc, scanned, generation, epoch,
1086                    meta.version_id,
1087                ))
1088            }
1089            Err(StoreError::ChecksumMismatch(_)) | Err(StoreError::NoSuchUpload(_)) => {
1090                // Drift mid-compose, or the operator sweep aborted us:
1091                // nothing published; the next barrier re-queues.
1092                Ok(UploadOutcome::Deferred)
1093            }
1094            Err(StoreError::PreconditionFailed(_)) => {
1095                // The AdoptOwn recognizer compares OUR bytes' checksum
1096                // against what landed, and without the pre-pass we do
1097                // not have one — so the file is read HERE, on the rare
1098                // recovery path, instead of on every publish.
1099                let crc = file_crc(local_path)?;
1100                let head = self.store.head(key).await?;
1101                let head_stamps = GenerationStamps::from_meta(&head.meta);
1102                let own = head_stamps
1103                    .as_ref()
1104                    .map(|s| s.flush_uuid == stamps.flush_uuid)
1105                    .unwrap_or(false);
1106                if own || head.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) {
1107                    // Our own torn earlier Complete with these bytes:
1108                    // cite it.
1109                    let g = head_stamps.map(|s| s.generation).unwrap_or(generation);
1110                    return Ok(UploadOutcome::published(
1111                        path, key.to_string(), head.etag, crc, scanned, g, epoch,
1112                        head.version_id,
1113                    ));
1114                }
1115                Ok(UploadOutcome::Parked { foreign_etag: head.etag })
1116            }
1117            Err(e) => Err(e.into()),
1118        }
1119    }
1120
1121    /// The gated staging lane's entry point into the shipped guard
1122    /// chain — same 412 policy, same AdoptOwn recognizer, same park;
1123    /// only the caller's use of the response differs. `None` = parked
1124    /// on a foreign etag.
1125    #[allow(clippy::too_many_arguments)]
1126    pub(super) async fn upload_one_pub(
1127        &self,
1128        path: &str,
1129        scanned: &scan::ScanEntry,
1130        base: Option<&BaselineEntry>,
1131        epoch: u64,
1132        flush_uuid: &str,
1133        prior_uuids: &[String],
1134    ) -> LeanResult<Option<(LeanEntry, BaselineEntry)>> {
1135        match self.upload_one(path, scanned, base, epoch, flush_uuid, prior_uuids).await? {
1136            UploadOutcome::Published { entry, baseline_entry } => {
1137                Ok(Some((entry, baseline_entry)))
1138            }
1139            UploadOutcome::Parked { .. } | UploadOutcome::Deferred => Ok(None),
1140        }
1141    }
1142
1143    async fn upload_one(
1144        &self,
1145        path: &str,
1146        scanned: &scan::ScanEntry,
1147        base: Option<&BaselineEntry>,
1148        epoch: u64,
1149        flush_uuid: &str,
1150        prior_uuids: &[String],
1151    ) -> LeanResult<UploadOutcome> {
1152        let key = self.cfg.file_key(path);
1153        let local_path = self.cfg.root.join(path);
1154        let generation = base.map(|b| b.generation + 1).unwrap_or(1);
1155        let posix = std::fs::metadata(&local_path).ok().map(|m| PosixStamps::from_metadata(&m));
1156        let stamps = GenerationStamps {
1157            generation,
1158            epoch,
1159            flush_uuid: flush_uuid.to_string(),
1160            boundary_source: None,
1161            posix,
1162        };
1163        let condition = match base {
1164            Some(b) => PutCondition::IfMatch(b.etag.clone()),
1165            None => PutCondition::IfNoneMatchAny,
1166        };
1167        let size = std::fs::metadata(&local_path)
1168            .map_err(|e| LeanError::State(format!("stat {}: {e}", local_path.display())))?
1169            .len();
1170        if size > self.cfg.whole_put_max {
1171            // Streaming multipart compose: put_whole is never fed past
1172            // whole_put_max (unbounded memory + S3's 5 GiB wall).
1173            return self
1174                .upload_compose(path, &key, &local_path, size, scanned, condition, stamps, generation, epoch)
1175                .await;
1176        }
1177        // `Bytes::from(Vec<u8>)` TAKES OWNERSHIP without copying, so the
1178        // old `Bytes::from(body.clone())` was a full memcpy of every
1179        // published file body — bought solely to leave `body` intact for
1180        // the 412 retry below, which is reached almost never. Build the
1181        // `Bytes` once; the retry gets a refcount clone.
1182        let body = Bytes::from(std::fs::read(&local_path)?);
1183        let crc = crc64_nvme(&body);
1184        match self
1185            .store
1186            .put_whole(&key, body.clone(), &condition, &stamps, crc)
1187            .await
1188        {
1189            Ok(meta) => Ok(UploadOutcome::published(
1190                path, key, meta.etag, crc, scanned, generation, epoch, meta.version_id,
1191            )),
1192            Err(StoreError::PreconditionFailed(_)) => {
1193                // The 412 policy: my own crashed/torn PUT ⇒ adopt;
1194                // foreign ⇒ park. NEVER the inherited LOCAL-WINS
1195                // overwrite.
1196                let head = self.store.head(&key).await?;
1197                let head_stamps = GenerationStamps::from_meta(&head.meta);
1198                let own = head_stamps
1199                    .as_ref()
1200                    .map(|s| s.flush_uuid == flush_uuid || prior_uuids.contains(&s.flush_uuid))
1201                    .unwrap_or(false);
1202                if !own {
1203                    return Ok(UploadOutcome::Parked { foreign_etag: head.etag });
1204                }
1205                if head.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) {
1206                    // Bytes already there (torn response): cite it.
1207                    let g = head_stamps.map(|s| s.generation).unwrap_or(generation);
1208                    return Ok(UploadOutcome::published(
1209                        path, key, head.etag, crc, scanned, g, epoch, head.version_id,
1210                    ));
1211                }
1212                // Our earlier PUT, older content: supersede it knowingly.
1213                let meta = self
1214                    .store
1215                    .put_whole(&key, body, &PutCondition::IfMatch(head.etag), &stamps, crc)
1216                    .await?;
1217                Ok(UploadOutcome::published(
1218                    path, key, meta.etag, crc, scanned, generation, epoch, meta.version_id,
1219                ))
1220            }
1221            Err(e) => Err(e.into()),
1222        }
1223    }
1224}
1225
1226enum UploadOutcome {
1227    Published { entry: LeanEntry, baseline_entry: BaselineEntry },
1228    Parked { foreign_etag: String },
1229    /// The source drifted mid-transfer (checksum refused server-side)
1230    /// or the assembly was swept: publish nothing, advance nothing —
1231    /// the next scan re-queues the path.
1232    Deferred,
1233}
1234
1235impl UploadOutcome {
1236    #[allow(clippy::too_many_arguments)]
1237    fn published(
1238        path: &str,
1239        key: String,
1240        etag: String,
1241        crc: u64,
1242        scanned: &scan::ScanEntry,
1243        generation: u64,
1244        epoch: u64,
1245        version_id: Option<String>,
1246    ) -> UploadOutcome {
1247        let _ = path;
1248        UploadOutcome::Published {
1249            entry: LeanEntry {
1250                key,
1251                etag: etag.clone(),
1252                crc64_b64: crc64_to_b64(crc),
1253                size: scanned.size,
1254                mode: scanned.mode,
1255                mtime_unix: scanned.mtime_unix,
1256                generation,
1257                epoch,
1258                version_id: version_id.clone(),
1259            },
1260            baseline_entry: BaselineEntry {
1261                etag,
1262                generation,
1263                version_id,
1264                // The PRE-read stat: if the agent wrote during our read,
1265                // the next scan sees the drift and re-queues (the
1266                // re-stat/re-queue valve).
1267                size: scanned.size,
1268                mtime_unix: scanned.mtime_unix,
1269                crc64_b64: Some(crc64_to_b64(crc)),
1270            },
1271        }
1272    }
1273}
1274
1275fn local_dirty(local: &Path, base: Option<&BaselineEntry>) -> bool {
1276    match (std::fs::metadata(local), base) {
1277        (Err(_), None) => false,                   // both absent: clean
1278        (Err(_), Some(_)) => true,                 // locally deleted vs baseline
1279        (Ok(_), None) => true,                     // local exists, never published
1280        (Ok(m), Some(b)) => m.len() != b.size || mtime_of(&m) != b.mtime_unix,
1281    }
1282}
1283
1284/// Full-object CRC-64/NVME of a local file, streamed.
1285///
1286/// Reached only by the 412 recovery path: the ordinary publish gets its
1287/// checksum from the store, which computes one while uploading.
1288/// The CRC a citation repair cites: the bytes this syncer integrated,
1289/// as the baseline recorded them when it wrote or uploaded them. The
1290/// HEAD's own checksum is a cross-check when the backend offers one,
1291/// never the source — Ozone offers none, a HITL uploader may have sent
1292/// none, and the manifest must carry a CRC either way, because every
1293/// reader now verifies a fresh fetch against it.
1294///
1295/// A baseline entry with no CRC is refused loudly rather than cited
1296/// bare: the only entry built without one is the consume-dirty sentinel,
1297/// which the candidate filters exclude, so reaching this is a defect.
1298/// A HEAD that attests a DIFFERENT value is refused the same way — the
1299/// workspace holds bytes the object does not, and citing either would
1300/// make one reader or another refuse the path forever.
1301pub(crate) fn repair_crc(
1302    path: &str,
1303    be: &BaselineEntry,
1304    head: &flint_store::ObjectMeta,
1305) -> LeanResult<String> {
1306    let Some(ours) = be.crc64_b64.clone() else {
1307        return Err(LeanError::State(format!(
1308            "citation repair of {path}: the baseline records no CRC for the bytes it \
1309             integrated at etag {} — refusing to cite bytes nothing hashed",
1310            be.etag
1311        )));
1312    };
1313    if let Some(theirs) = head.crc64_b64.as_deref() {
1314        if theirs != ours {
1315            return Err(LeanError::State(format!(
1316                "citation repair of {path}: the store attests CRC-64 {theirs} for etag {} but \
1317                 the bytes this workspace integrated under that etag hash to {ours} — the \
1318                 workspace holds bytes the object does not; refusing to cite either",
1319                be.etag
1320            )));
1321        }
1322    }
1323    Ok(ours)
1324}
1325
1326fn file_crc(local_path: &Path) -> LeanResult<u64> {
1327    use std::io::Read;
1328    let mut crc = flint_store::Crc64Nvme::new();
1329    let mut f = std::fs::File::open(local_path)?;
1330    let mut buf = vec![0u8; 4 << 20];
1331    loop {
1332        let n = f.read(&mut buf)?;
1333        if n == 0 {
1334            break;
1335        }
1336        crc.update(&buf[..n]);
1337    }
1338    Ok(crc.finalize())
1339}
1340
1341pub(super) fn mtime_of(m: &std::fs::Metadata) -> i64 {
1342    m.modified()
1343        .ok()
1344        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1345        .map(|d| d.as_secs() as i64)
1346        .unwrap_or(0)
1347}
1348
1349/// Containment-safe workspace write (boundary-verbs plan §2.2 security
1350/// gate). `rel` is a workspace-relative path; the target may not escape
1351/// the root, traverse a symlink component, or land in the reserved
1352/// control namespace.
1353///
1354/// The hazard this closes is pre-existing and reachable from three
1355/// callers (checkout, inbox consume, sync): `write_file_atomic` did
1356/// `create_dir_all(parent)` + write with no `O_NOFOLLOW` and no
1357/// root-containment check, while the scanner SKIPS symlinks — so a
1358/// planted symlink is invisible to the syncer. An unprivileged app
1359/// that plants `inputs -> /root/.aws`, lands an object at
1360/// `inputs/<path>` and drops a scoped sync turns the credential-holding
1361/// syncer into an on-demand arbitrary-file-write primitive outside the
1362/// workspace.
1363pub(super) fn write_file_atomic_in(
1364    root: &Path,
1365    rel: &str,
1366    bytes: &[u8],
1367    mode: Option<u32>,
1368) -> LeanResult<()> {
1369    let target = contained_path(root, rel)?;
1370    write_file_atomic(&target, bytes, mode).map(|_| ())
1371}
1372
1373/// Validate `rel` under `root` WITHOUT creating anything — for callers
1374/// that must decide whether a path is writable before doing any work
1375/// (inbox consume refuses a refused path outright rather than routing it
1376/// through conflict-preserve, which would cost a GET+PUT and leave the
1377/// path in the baseline).
1378pub(super) fn check_contained(root: &Path, rel: &str) -> LeanResult<()> {
1379    resolve_contained(root, rel, false).map(|_| ())
1380}
1381
1382/// Resolve `rel` under `root`, refusing escapes, creating missing parent
1383/// directories. Walks the parent chain component by component: a
1384/// component that EXISTS as a symlink is a refusal (never followed).
1385pub(super) fn contained_path(root: &Path, rel: &str) -> LeanResult<std::path::PathBuf> {
1386    resolve_contained(root, rel, true).map(|(p, _)| p)
1387}
1388
1389/// As `contained_path`, and also what the walk already learned about
1390/// the FINAL component: its `lstat` if it exists, `None` if it does
1391/// not. The walk stats every component to refuse a symlink; a caller
1392/// that then asked `exists()` and `metadata()` paid two more stats for
1393/// an answer it was already holding (checkout's resume check did, on
1394/// every one of 20,000 files).
1395pub(super) fn contained_path_stat(
1396    root: &Path,
1397    rel: &str,
1398) -> LeanResult<(std::path::PathBuf, Option<std::fs::Metadata>)> {
1399    resolve_contained(root, rel, true)
1400}
1401
1402fn resolve_contained(
1403    root: &Path,
1404    rel: &str,
1405    create_dirs: bool,
1406) -> LeanResult<(std::path::PathBuf, Option<std::fs::Metadata>)> {
1407    use std::path::Component;
1408    let refuse = |why: &str| {
1409        Err(LeanError::State(format!(
1410            "refusing write to {rel:?}: {why} (containment)"
1411        )))
1412    };
1413    if rel.is_empty() {
1414        return refuse("empty path");
1415    }
1416    if super::scan::is_control_path(rel) {
1417        // The reserved namespace is the syncer's own; a citation or
1418        // inbox entry naming it is surfaced, never materialized (D0.3).
1419        return refuse("reserved control namespace");
1420    }
1421    let relp = Path::new(rel);
1422    let mut cur = root.to_path_buf();
1423    let mut comps: Vec<&std::ffi::OsStr> = vec![];
1424    for c in relp.components() {
1425        match c {
1426            Component::Normal(n) => comps.push(n),
1427            Component::CurDir => {}
1428            Component::ParentDir => return refuse("`..` component"),
1429            Component::RootDir | Component::Prefix(_) => return refuse("absolute path"),
1430        }
1431    }
1432    if comps.is_empty() {
1433        return refuse("no path components");
1434    }
1435    let last = comps.len() - 1;
1436    let mut final_meta = None;
1437    for (i, c) in comps.iter().enumerate() {
1438        cur.push(c);
1439        match std::fs::symlink_metadata(&cur) {
1440            Ok(m) if m.file_type().is_symlink() => {
1441                return refuse("path traverses a symlink");
1442            }
1443            Ok(m) if i < last && !m.is_dir() => {
1444                return refuse("parent component is not a directory");
1445            }
1446            Ok(m) => {
1447                if i == last {
1448                    final_meta = Some(m);
1449                }
1450            }
1451            Err(_) if i < last => {
1452                if create_dirs {
1453                    if let Err(e) = std::fs::create_dir(&cur) {
1454                        // A SIBLING may have created it between the stat
1455                        // above and this mkdir: fetches run on their own
1456                        // tasks, so the first files of one directory
1457                        // race here, and the loser's EEXIST is not a
1458                        // containment refusal. It is fine only if what
1459                        // exists now is a plain directory — a symlink
1460                        // that appeared in the window is refused exactly
1461                        // as one the stat found.
1462                        match std::fs::symlink_metadata(&cur) {
1463                            Ok(m) if m.file_type().is_symlink() => {
1464                                return refuse("path traverses a symlink");
1465                            }
1466                            Ok(m) if m.is_dir() => {}
1467                            _ => {
1468                                return Err(LeanError::State(format!(
1469                                    "mkdir {}: {e}",
1470                                    cur.display()
1471                                )))
1472                            }
1473                        }
1474                    }
1475                }
1476            }
1477            Err(_) => {}
1478        }
1479    }
1480    Ok((cur, final_meta))
1481}
1482
1483/// Write `bytes` to `path` through an exclusive temp sibling and a
1484/// rename. The parent already exists — containment created it — and
1485/// one that vanished since is recreated on the retry path in `safefs`,
1486/// so the common path pays no `mkdir` and no `stat` for it. Returns
1487/// the written file's metadata.
1488pub(super) fn write_file_atomic(
1489    path: &Path,
1490    bytes: &[u8],
1491    mode: Option<u32>,
1492) -> LeanResult<std::fs::Metadata> {
1493    super::safefs::check_parent(path)?;
1494    // NOT with_extension(): that REPLACES the final extension, so
1495    // "a.txt" and "a.md" would collide on one tmp name.
1496    let tmp = path.with_file_name(format!(
1497        "{}.flint-sync-tmp",
1498        path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()
1499    ));
1500    // The temp sibling is computed AFTER containment ran, so the walk
1501    // never saw it: it needs its own refusal, not a plain write.
1502    // FAST (no per-file fsync): materialisations are made durable by
1503    // `sync_tree` before the marker or baseline that vouches for them.
1504    super::safefs::write_via_tmp_fast(path, &tmp, bytes, mode)
1505}