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