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, ForeignChange, 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/// comfortably below the 6-poll (~60 s) takeover window, so a holder
103/// busy in a long commit section never reads as quiet to a waiter.
104const RENEW_WITHIN_SECS: u64 = 20;
105
106impl Syncer {
107    /// Verify the cell still names us at OUR epoch; anything else is a
108    /// fence. Read-verify before the manifest CAS (the per-request
109    /// validation the gateway will also enforce).
110    async fn verify_not_deposed(&self) -> LeanResult<()> {
111        let lease = self
112            .lease
113            .as_ref()
114            .ok_or_else(|| LeanError::State("no lease".into()))?;
115        match self.store.epoch_read(&self.cfg.epoch_key()).await? {
116            Some(state) if state.epoch == lease.epoch && state.holder_id == lease.holder_id => {
117                Ok(())
118            }
119            Some(state) => Err(LeanError::Fenced(format!(
120                "cell at epoch {} holder {} (we are epoch {})",
121                state.epoch, state.holder_id, lease.epoch
122            ))),
123            None => Err(LeanError::Fenced("epoch cell vanished".into())),
124        }
125    }
126
127    /// Step 1: consume the inbox. A barrier NEVER runs against an
128    /// unconsumed inbox — this is what makes HITL uploads structurally
129    /// un-amputatable. Returns the entries integrated (dropped from the
130    /// cell at the window-open CAS).
131    pub async fn consume_inbox(&mut self) -> LeanResult<Vec<InboxEntry>> {
132        let loaded = inbox::load(self.store.as_ref(), &self.cfg).await?;
133        self.consume_inbox_doc(&loaded.doc).await
134    }
135
136    /// `consume_inbox` over a cell the caller has already read — the
137    /// barrier reads it once and hands the same document to
138    /// `apply_removals`, so the removal pass adds no request.
139    pub async fn consume_inbox_doc(&mut self, doc: &InboxDoc) -> LeanResult<Vec<InboxEntry>> {
140        Ok(self.consume_counted(doc).await?.0)
141    }
142
143    /// `consume_inbox_doc`, plus how many changes from this writer's LOCAL
144    /// queue it settled. Those leave no entry in the cell to drop, so they
145    /// are not in the returned list — but they are consumptions, and the
146    /// report (and the ack) count them.
147    async fn consume_counted(&mut self, doc: &InboxDoc) -> LeanResult<(Vec<InboxEntry>, usize)> {
148        // §2.5's layered doors ride the inbox GET this function already
149        // pays for: promptness is one tick, and the added request count
150        // is ZERO. A failure here must never fail the consume — the
151        // request is idempotent state and the next tick re-reads it.
152        if let Err(e) = self.note_verb_requests(doc) {
153            eprintln!("flint-sync: verb request not consumed (retrying next tick): {e}");
154        }
155        // Indices into `entries`: the writer-local queue's upserts first,
156        // then the shared inbox's. Both run the same rules below.
157        let mut consumed: Vec<usize> = vec![];
158        let mut baseline = self.state.load_baseline()?;
159        // An install whose step 7 a restart cut off left the other writers'
160        // changes it carried in the intent journal (`installed_foreign`).
161        let requeued = self.state.requeue_installed_foreign()?;
162        if requeued > 0 {
163            self.trace("queue", serde_json::json!({"requeued_from_intent": requeued}));
164        }
165        // The writer-local queue: changes other writers made that this
166        // workspace's own merges carried into the manifest but not yet
167        // into the tree (`state::ForeignChange` says why it is not the
168        // shared inbox any more).
169        let queue = self.state.load_foreign_queue()?;
170        let queued: Vec<InboxEntry> = queue
171            .iter()
172            .filter_map(|c| {
173                c.etag.as_ref().map(|etag| InboxEntry {
174                    path: c.path.clone(),
175                    etag: etag.clone(),
176                    author: "merge-preserved".into(),
177                    added_unix: 0,
178                    crc64_b64: c.crc64_b64.clone(),
179                })
180            })
181            .collect();
182        let n_queued = queued.len();
183        let entries: Vec<InboxEntry> = queued.into_iter().chain(doc.entries.iter().cloned()).collect();
184        for (idx, entry) in entries.iter().enumerate() {
185            let key = self.cfg.file_key(&entry.path);
186            // Containment BEFORE anything else: a path we could never
187            // safely materialize must be surfaced and dropped, not
188            // routed through the locally-dirty branch — which would
189            // "preserve" it with a GET+PUT and leave a baseline entry
190            // for a path the scanner can never see (the planted-symlink
191            // shape: `inputs -> /root/.aws` reads as locally-present,
192            // therefore dirty, therefore a conflict-preserve of someone
193            // else's file).
194            if let Err(e) = check_contained(&self.cfg.root, &entry.path) {
195                self.state.append_conflict(&ConflictRecord {
196                    path: entry.path.clone(),
197                    foreign_etag: entry.etag.clone(),
198                    preserved_key: None,
199                    kind: format!("consume-refused-containment: {e}"),
200                    at_unix: now_unix(),
201                })?;
202                self.trace("consume", serde_json::json!({"path": entry.path, "etag": entry.etag, "from": if idx < n_queued { "queue" } else { "inbox" }, "action": "refused"}));
203                consumed.push(idx);
204                continue;
205            }
206            // Already integrated (a crashed earlier consume): idempotent.
207            if baseline.entries.get(&entry.path).map(|b| b.etag == entry.etag).unwrap_or(false) {
208                self.trace("consume", serde_json::json!({"path": entry.path, "etag": entry.etag, "from": if idx < n_queued { "queue" } else { "inbox" }, "action": "already"}));
209                consumed.push(idx);
210                continue;
211            }
212            let head = match self.store.head(&key).await {
213                Ok(m) => m,
214                Err(StoreError::NotFound(_)) => {
215                    // The object vanished under a tracked entry: surface
216                    // it — the bytes are NOT recoverable from here.
217                    self.state.append_conflict(&ConflictRecord {
218                        path: entry.path.clone(),
219                        foreign_etag: entry.etag.clone(),
220                        preserved_key: None,
221                        kind: "consume-object-missing".into(),
222                        at_unix: now_unix(),
223                    })?;
224                    self.trace("consume", serde_json::json!({"path": entry.path, "etag": entry.etag, "from": if idx < n_queued { "queue" } else { "inbox" }, "action": "missing"}));
225                    consumed.push(idx);
226                    continue;
227                }
228                Err(e) => return Err(e.into()),
229            };
230            if head.etag != entry.etag {
231                // Superseded by a newer write (its own inbox entry
232                // follows, or it is the syncer's): drop.
233                self.trace("consume", serde_json::json!({"path": entry.path, "etag": entry.etag, "from": if idx < n_queued { "queue" } else { "inbox" }, "action": "superseded"}));
234                consumed.push(idx);
235                continue;
236            }
237            let local_path = self.cfg.root.join(&entry.path);
238            let mut dirty = local_dirty(&local_path, baseline.entries.get(&entry.path));
239            // Review 2026-09-12, atomicity-3 / inbox-2: the fetch is a
240            // network round trip and the agent may write the path inside
241            // it. The stat that licenses the adopt is repeated AFTER the
242            // fetch, before anything touches the file: a write in that
243            // window makes this the dirty case — the foreign bytes are
244            // preserved and a record names them, the agent's stay.
245            let mut fetched = None;
246            if !dirty {
247                let got = self.store.get_whole(&key, Some(&entry.etag)).await.map_err(|e| match e {
248                    StoreError::PreconditionFailed(m) => {
249                        StoreError::Other(format!("consume raced a newer write: {m}"))
250                    }
251                    other => other,
252                })?;
253                if local_dirty(&local_path, baseline.entries.get(&entry.path)) {
254                    dirty = true;
255                } else {
256                    fetched = Some(got);
257                }
258            }
259            if dirty {
260                // Locally-dirty wins; preserve the FOREIGN bytes first
261                // (a conflict record must keep both versions
262                // recoverable), then advance the recognized ETag so our
263                // eventual publish supersedes it KNOWINGLY.
264                let preserved = self.preserve_conflict_copy(&entry.path, &head.etag).await?;
265                self.state.append_conflict(&ConflictRecord {
266                    path: entry.path.clone(),
267                    foreign_etag: entry.etag.clone(),
268                    preserved_key: Some(preserved),
269                    kind: "consume-dirty".into(),
270                    at_unix: now_unix(),
271                })?;
272                let stamps = GenerationStamps::from_meta(&head.meta);
273                baseline.entries.insert(
274                    entry.path.clone(),
275                    BaselineEntry {
276                        etag: head.etag.clone(),
277                        generation: stamps.map(|s| s.generation).unwrap_or(0),
278                        // Deliberately NOT the local file's stat: the
279                        // path must still scan as dirty so the local
280                        // version publishes.
281                        size: u64::MAX,
282                        mtime_unix: 0,
283                        mtime_nanos: None,
284                        // The sentinel's bytes are the LOCAL edit; the
285                        // publish that supersedes hashes them itself.
286                        crc64_b64: None,
287                    },
288                );
289                self.trace("consume", serde_json::json!({"path": entry.path, "etag": entry.etag, "from": if idx < n_queued { "queue" } else { "inbox" }, "action": "dirty-preserved"}));
290            } else {
291                // Clean, re-checked after the fetch: adopt the foreign
292                // content into the tree.
293                let (meta, body) = fetched.expect("fetched while clean");
294                let mode = PosixStamps::from_meta(&meta.meta).map(|p| p.mode);
295                // VERIFIED before it is written, as checkout's fresh
296                // fetch is: against the writer's CRC when the inbox
297                // entry carries one (the gateway hashes what it sent),
298                // else the backend's attestation when it offers one.
299                // Ozone offers none, so the inbox's is what a HITL
300                // upload gets checked against there. What the baseline
301                // records — and the next citation repair cites — is
302                // OURS, over the bytes actually written.
303                let got = crc64_to_b64(crc64_nvme(&body));
304                let want = entry.crc64_b64.clone().or_else(|| meta.crc64_b64.clone());
305                if let Some(want) = want {
306                    if want != got {
307                        // NOT consumed: the entry stays in the cell and
308                        // the record repeats — a permanent, visible
309                        // contradiction is the right failure for
310                        // bytes nobody vouches for.
311                        self.state.append_conflict(&ConflictRecord {
312                            path: entry.path.clone(),
313                            foreign_etag: entry.etag.clone(),
314                            preserved_key: None,
315                            kind: format!(
316                                "consume-refused-checksum: etag {} is attested with CRC-64 \
317                                 {want} but the bytes fetched under it hash to {got}",
318                                entry.etag
319                            ),
320                            at_unix: now_unix(),
321                        })?;
322                        self.trace("consume", serde_json::json!({"path": entry.path, "etag": entry.etag, "from": if idx < n_queued { "queue" } else { "inbox" }, "action": "refused-checksum"}));
323                        continue;
324                    }
325                }
326                // CONTAINMENT and I/O are split, because the answers are
327                // opposite. Containment was already decided above by
328                // `check_contained`, so a refusal reaching here is a
329                // path that only became unsafe once parents were
330                // created — still a permanent property of the path, so
331                // it is surfaced and DROPPED, exactly as above.
332                //
333                // Everything else — ENOSPC, EACCES, EIO, a read-only
334                // filesystem — is TRANSIENT, and dropping the entry for
335                // one is silent permanent loss: the foreign bytes stay
336                // in the bucket, the workspace never adopts them, and
337                // nothing ever re-offers the entry. It used to be
338                // recorded as `consume-refused-containment`, which sent
339                // whoever read it looking for a hostile path that was
340                // never there. A full disk is not a planted symlink.
341                let target = match contained_path(&self.cfg.root, &entry.path) {
342                    Ok(t) => t,
343                    Err(e) => {
344                        self.state.append_conflict(&ConflictRecord {
345                            path: entry.path.clone(),
346                            foreign_etag: entry.etag.clone(),
347                            preserved_key: None,
348                            kind: format!("consume-refused-containment: {e}"),
349                            at_unix: now_unix(),
350                        })?;
351                        self.trace("consume", serde_json::json!({"path": entry.path, "etag": entry.etag, "from": if idx < n_queued { "queue" } else { "inbox" }, "action": "refused"}));
352                        consumed.push(idx);
353                        continue;
354                    }
355                };
356                if let Err(e) = write_file_atomic(&target, &body, mode) {
357                    // NOT consumed: the entry stays in the cell so the
358                    // next barrier retries it. A visible conflict record
359                    // that repeats is a far better failure than one
360                    // silent drop.
361                    self.state.append_conflict(&ConflictRecord {
362                        path: entry.path.clone(),
363                        foreign_etag: entry.etag.clone(),
364                        preserved_key: None,
365                        kind: format!("consume-write-failed (will retry): {e}"),
366                        at_unix: now_unix(),
367                    })?;
368                    self.trace("consume", serde_json::json!({"path": entry.path, "etag": entry.etag, "from": if idx < n_queued { "queue" } else { "inbox" }, "action": "deferred"}));
369                    continue;
370                }
371                let st = std::fs::metadata(&local_path)?;
372                let stamps = GenerationStamps::from_meta(&meta.meta);
373                baseline.entries.insert(
374                    entry.path.clone(),
375                    BaselineEntry {
376                        etag: meta.etag.clone(),
377                        generation: stamps.map(|s| s.generation).unwrap_or(0),
378                        size: st.len(),
379                        mtime_unix: mtime_of(&st),
380                        mtime_nanos: Some(mtime_nanos_of(&st)),
381                        crc64_b64: Some(got),
382                    },
383                );
384                baseline.prev_scan.insert(entry.path.clone());
385                self.trace("consume", serde_json::json!({"path": entry.path, "etag": entry.etag, "from": if idx < n_queued { "queue" } else { "inbox" }, "action": "adopted"}));
386            }
387            consumed.push(idx);
388        }
389        let mut settled: BTreeSet<String> =
390            consumed.iter().filter(|i| **i < n_queued).map(|i| entries[*i].path.clone()).collect();
391
392        // A queued DELETION: another writer removed the path and this
393        // workspace's merge cited that. A clean local copy goes; a dirty
394        // one is the agent's newer work and stays — it publishes, and a
395        // modify beats a delete at the merge. Never a declared removal of
396        // our own: the deletion is already in the manifest.
397        for change in queue.iter().filter(|c| c.etag.is_none()) {
398            if check_contained(&self.cfg.root, &change.path).is_err() {
399                // Never materializable here, so nothing to remove.
400                settled.insert(change.path.clone());
401                continue;
402            }
403            let local = self.cfg.root.join(&change.path);
404            let be = baseline.entries.get(&change.path).cloned();
405            let record = |kind: String| ConflictRecord {
406                path: change.path.clone(),
407                foreign_etag: String::new(),
408                preserved_key: None,
409                kind,
410                at_unix: now_unix(),
411            };
412            match std::fs::symlink_metadata(&local) {
413                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
414                    self.trace("tombstone", serde_json::json!({"path": change.path, "action": "absent"}));
415                }
416                Err(e) => {
417                    // Unreadable is not absent: keep it queued.
418                    self.trace("tombstone", serde_json::json!({"path": change.path, "action": "deferred"}));
419                    self.state.append_conflict(&record(format!(
420                        "consume-foreign-delete-deferred: cannot stat the path: {e}"
421                    )))?;
422                    continue;
423                }
424                Ok(m) if !m.is_file() || be.is_none() || local_dirty(&local, be.as_ref()) => {
425                    self.state.append_conflict(&record(
426                        "consume-foreign-delete-vs-dirty: another writer deleted this path; the \
427                         local version has unpublished changes, so it stays and publishes"
428                            .into(),
429                    ))?;
430                    self.trace("tombstone", serde_json::json!({"path": change.path, "action": "kept-dirty"}));
431                    settled.insert(change.path.clone());
432                    continue;
433                }
434                Ok(_) => {
435                    if let Err(e) = std::fs::remove_file(&local) {
436                        self.state.append_conflict(&record(format!(
437                            "consume-foreign-delete-failed (will retry): {e}"
438                        )))?;
439                        continue;
440                    }
441                    // CONFIRM: only NotFound is absence.
442                    if !matches!(
443                        std::fs::symlink_metadata(&local),
444                        Err(ref e) if e.kind() == std::io::ErrorKind::NotFound
445                    ) {
446                        self.state.append_conflict(&record(
447                            "consume-foreign-delete-unconfirmed (will retry)".into(),
448                        ))?;
449                        continue;
450                    }
451                    self.trace("tombstone", serde_json::json!({"path": change.path, "action": "removed"}));
452                }
453            }
454            baseline.entries.remove(&change.path);
455            baseline.prev_scan.remove(&change.path);
456            settled.insert(change.path.clone());
457        }
458
459        // The files this baseline vouches for reached the disk before
460        // the baseline does (audit 2026-09-03, finding 9).
461        self.state.sync_tree()?;
462        self.state.save_baseline(&baseline)?;
463        // After the baseline: a crash between the two re-applies a
464        // settled change, and both arms are idempotent (an upsert the
465        // baseline holds is "already integrated"; a deletion with nothing
466        // on disk only drops a baseline entry that is already gone).
467        if !settled.is_empty() {
468            let remaining: Vec<_> =
469                queue.into_iter().filter(|c| !settled.contains(&c.path)).collect();
470            self.state.save_foreign_queue(&remaining)?;
471        }
472        // Only the SHARED inbox's entries leave the cell at the window
473        // clear; the local queue was settled above.
474        let n_settled = settled.len();
475        let shared = consumed.into_iter().filter(|i| *i >= n_queued).map(|i| entries[i].clone()).collect();
476        Ok((shared, n_settled))
477    }
478
479    /// Step 1b: perform the DECLARED removals (delete/rename design
480    /// §4-§6). Runs after `consume_inbox_doc`, so a rename's destination
481    /// is in the tree before its source leaves it — §5: create first,
482    /// removal second; a partial application leaves an EXTRA file,
483    /// never a missing one.
484    ///
485    /// Per removal, in order:
486    /// - the path must be containable, as a consume's must;
487    /// - a rename WAITS until its destination is integrated (in the
488    ///   baseline): a consume that deferred the destination defers the
489    ///   removal with it;
490    /// - a locally-DIRTY path — unpublished edits, or a file the agent
491    ///   created there — is REFUSED, nothing applied, the agent's work
492    ///   untouched. The same rule a consume applies to a HITL write
493    ///   over dirty bytes, and the reason a declared removal cannot be
494    ///   "just a delete". Refused for good: the cell is told, and a
495    ///   retry that waited for the agent to publish would delete the
496    ///   very edit the refusal protected;
497    /// - a clean file is unlinked, and the unlink is CONFIRMED by lstat
498    ///   before the path is declared — an unlink that did not take
499    ///   publishes no deletion (`confirm_absences`' rule, kept);
500    /// - a path already absent locally and known to the baseline or the
501    ///   merge base is declared as it stands: the agent got there
502    ///   first, or this tree never held it (a scoped workspace).
503    ///
504    /// A declared path skips the two-scan guard (§4): that guard
505    /// protects against absence INFERRED by a walk, and a declaration
506    /// is not an inference. That is what lets a rename's two halves
507    /// ride one manifest generation.
508    pub async fn apply_removals(&mut self, doc: &InboxDoc) -> LeanResult<RemovalPass> {
509        let mut pass = RemovalPass::default();
510        let baseline = self.state.load_baseline()?;
511        // A crashed earlier barrier that had unlinked and journalled but
512        // not installed: its declarations are still deletions with a
513        // recorded basis — unless the agent has since put a file back.
514        for p in self.state.load_intent()?.declared_deletes {
515            let gone = matches!(
516                std::fs::symlink_metadata(self.cfg.root.join(&p)),
517                Err(ref e) if e.kind() == std::io::ErrorKind::NotFound
518            );
519            if gone && (baseline.entries.contains_key(&p) || baseline.inst_base.contains_key(&p)) {
520                pass.declared.insert(p);
521            }
522        }
523        for r in &doc.removals {
524            if r.refused.is_some() {
525                continue;
526            }
527            let refusal = |kind: &str, message: String| Removal {
528                refused: Some(Refusal { kind: kind.into(), message, at_unix: now_unix() }),
529                ..r.clone()
530            };
531            let mut refuse = |kind: &str, message: String| -> LeanResult<()> {
532                self.state.append_conflict(&ConflictRecord {
533                    path: r.path.clone(),
534                    foreign_etag: String::new(),
535                    preserved_key: None,
536                    kind: format!("{kind}: {message}"),
537                    at_unix: now_unix(),
538                })?;
539                pass.refused.push(refusal(kind, message));
540                Ok(())
541            };
542            if let Err(e) = check_contained(&self.cfg.root, &r.path) {
543                refuse("removal-refused-containment", e.to_string())?;
544                continue;
545            }
546            if let Some(dst) = &r.moved_to {
547                match baseline.entries.get(dst) {
548                    // Not integrated yet (a consume deferred it): wait.
549                    None => {
550                        pass.deferred += 1;
551                        continue;
552                    }
553                    // Integrated as a CONFLICT: the agent had an
554                    // unpublished file at the destination, its version
555                    // won, and the moved bytes are preserved under
556                    // `conflicts/`. Deleting the source now would leave
557                    // the user's file nowhere in the tree — so the move
558                    // is refused and the source stays. The sentinel is
559                    // the consume-dirty baseline entry's `u64::MAX`.
560                    Some(b) if b.size == u64::MAX => {
561                        refuse(
562                            "removal-refused-destination-conflict",
563                            format!(
564                                "{} 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",
565                                r.path
566                            ),
567                        )?;
568                        continue;
569                    }
570                    Some(_) => {}
571                }
572            }
573            let local = self.cfg.root.join(&r.path);
574            let be = baseline.entries.get(&r.path);
575            match std::fs::symlink_metadata(&local) {
576                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
577                    if be.is_some() || baseline.inst_base.contains_key(&r.path) {
578                        pass.declared.insert(r.path.clone());
579                    }
580                    // Nothing here and nothing cited: a removal of a path
581                    // that was never there, or that a sibling already
582                    // removed. Applied as a no-op.
583                    pass.applied.push(r.clone());
584                }
585                Err(e) => {
586                    // Unreadable is not absent (`confirm_absences`' rule):
587                    // fail closed, and say so where the pod-side reader
588                    // looks. Transient, so deferred rather than refused.
589                    self.state.append_conflict(&ConflictRecord {
590                        path: r.path.clone(),
591                        foreign_etag: String::new(),
592                        preserved_key: None,
593                        kind: format!("removal-deferred: cannot stat the path: {e}"),
594                        at_unix: now_unix(),
595                    })?;
596                    pass.deferred += 1;
597                }
598                Ok(m) if !m.is_file() => {
599                    refuse(
600                        "removal-refused-not-a-file",
601                        format!("{} is not a regular file in the workspace", r.path),
602                    )?;
603                }
604                Ok(_) => {
605                    if local_dirty(&local, be) {
606                        refuse(
607                            "removal-refused-dirty",
608                            format!(
609                                "{} 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",
610                                r.path, r.author
611                            ),
612                        )?;
613                        continue;
614                    }
615                    if let Err(e) = std::fs::remove_file(&local) {
616                        self.state.append_conflict(&ConflictRecord {
617                            path: r.path.clone(),
618                            foreign_etag: String::new(),
619                            preserved_key: None,
620                            kind: format!("removal-unlink-failed (will retry): {e}"),
621                            at_unix: now_unix(),
622                        })?;
623                        pass.deferred += 1;
624                        continue;
625                    }
626                    // CONFIRM: only NotFound is absence.
627                    match std::fs::symlink_metadata(&local) {
628                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
629                            pass.declared.insert(r.path.clone());
630                            pass.applied.push(r.clone());
631                        }
632                        _ => {
633                            self.state.append_conflict(&ConflictRecord {
634                                path: r.path.clone(),
635                                foreign_etag: String::new(),
636                                preserved_key: None,
637                                kind: "removal-unlink-unconfirmed (will retry)".into(),
638                                at_unix: now_unix(),
639                            })?;
640                            pass.deferred += 1;
641                        }
642                    }
643                }
644            }
645        }
646        Ok(pass)
647    }
648
649    /// Act on the inbox document's two request fields (§2.5, D14).
650    ///
651    /// The asymmetry is deliberate and is about blast radius, not
652    /// principle: a boundary request is PERFORMED (it publishes what is
653    /// already on disk and mutates nothing local), a sync request is
654    /// CARRIED into the ticker as advisory news and the agent decides.
655    ///
656    /// Both are watermarked by `requested_unix` rather than consumed by
657    /// a clearing CAS. The field is idempotent state, so a repeat is a
658    /// no-op and a lost clear cannot double-publish; and not writing
659    /// means the doors cost no request at all, which is what §2.5
660    /// promises.
661    fn note_verb_requests(&mut self, doc: &inbox::InboxDoc) -> LeanResult<()> {
662        let mut mark = self.load_verb_watermark()?;
663        if let Some(r) = &doc.sync_request {
664            if r.requested_unix > mark.sync_unix {
665                self.carry_sync_request(r.requested_unix, &r.requestor)?;
666                mark.sync_unix = r.requested_unix;
667            }
668        }
669        if let Some(r) = &doc.boundary_request {
670            if r.requested_unix > mark.boundary_unix {
671                self.request_boundary(
672                    &format!("gw:{}:{}", r.requested_unix, r.requestor),
673                    Some(format!("boundary requested by {}", r.requestor)),
674                )?;
675                mark.boundary_unix = r.requested_unix;
676            }
677        }
678        self.save_verb_watermark(&mark)
679    }
680
681    fn verb_watermark_path(&self) -> std::path::PathBuf {
682        self.cfg.state_dir().join("verb-requests.json")
683    }
684
685    pub(crate) fn load_verb_watermark(&self) -> LeanResult<VerbWatermark> {
686        let p = self.verb_watermark_path();
687        if !p.exists() {
688            return Ok(VerbWatermark::default());
689        }
690        Ok(std::fs::read(&p)
691            .ok()
692            .and_then(|b| serde_json::from_slice(&b).ok())
693            .unwrap_or_default())
694    }
695
696    fn save_verb_watermark(&self, m: &VerbWatermark) -> LeanResult<()> {
697        let bytes = serde_json::to_vec_pretty(m)
698            .map_err(|e| LeanError::State(format!("verb watermark: {e}")))?;
699        super::control::write_atomic(&self.verb_watermark_path(), &bytes)
700    }
701
702    async fn preserve_conflict_copy(&self, path: &str, etag: &str) -> LeanResult<String> {
703        let key = self.cfg.file_key(path);
704        let dst = self.cfg.conflict_key(&uuid::Uuid::new_v4().to_string(), path);
705        // v1: client-side copy (GET + guarded PUT). The server-side
706        // CopyObject lever is the designed v2 optimization for large
707        // files.
708        let (meta, body) = self.store.get_whole(&key, Some(etag)).await?;
709        let crc = crc64_nvme(&body);
710        let stamps = GenerationStamps {
711            generation: 0,
712            epoch: self
713                .lease
714                .as_ref()
715                .map(|l| l.epoch)
716                .or_else(|| self.state.load_incarnation().ok().flatten().map(|i| i.epoch))
717                .unwrap_or(0),
718            flush_uuid: "conflict-preserve".into(),
719            boundary_source: None,
720            posix: PosixStamps::from_meta(&meta.meta),
721        };
722        self.store
723            .put_whole(&dst, body, &PutCondition::IfNoneMatchAny, &stamps, crc)
724            .await?;
725        Ok(dst)
726    }
727
728    /// Take the SECOND absence observation now, by direct `lstat`.
729    ///
730    /// `scan::classify` withholds a path's delete until absence has
731    /// survived two consecutive scans — the rename-vs-walk race guard
732    /// (`lib.rs:37`). The hazard that rule names is the WALK missing a
733    /// file renamed under it, not deletion itself. On the cadence path
734    /// the second walk arrives at the next floor tick and nobody has
735    /// been promised otherwise. On a DECLARED boundary it cannot wait:
736    /// the ack would claim a coherent point (D1 — "everything
737    /// ordered-before T") while the manifest still cites a file the
738    /// agent removed before it touched the sentinel, and it would say
739    /// so with `report.deleted: 0` and `status: "ok"`.
740    ///
741    /// A direct `lstat` is exactly what the rename-vs-walk guard asks
742    /// for and is immune to the walk race by construction, so the
743    /// second observation costs one syscall per transiently-absent
744    /// path — never a second full pass. The cadence path is unchanged
745    /// and still waits for the second walk.
746    pub(crate) fn confirm_absences(&self, classified: &mut scan::Classified) -> LeanResult<usize> {
747        if classified.first_absence.is_empty() {
748            return Ok(0);
749        }
750        let mut confirmed = 0;
751        for path in std::mem::take(&mut classified.first_absence) {
752            // ONLY NotFound is absence. Every other errno — EACCES on a
753            // parent, EIO, EMFILE, ELOOP — used to read as "the agent
754            // deleted it", and this is the oracle that PUBLISHES the
755            // delete and then DELETES THE OBJECT. An unreadable file is
756            // the one case where guessing is unrecoverable, so it fails
757            // CLOSED, exactly as the walk does at `scan.rs`'s
758            // `symlink_metadata(&path)?`. Two call sites, one syscall:
759            // they must not have opposite error policies.
760            let gone = match std::fs::symlink_metadata(self.cfg.root.join(&path)) {
761                Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
762                Err(e) => {
763                    classified.first_absence.insert(path.clone());
764                    return Err(LeanError::State(format!(
765                        "cannot confirm the absence of {path:?}: {e} — refusing to publish \
766                         a deletion on an unreadable path"
767                    )));
768                }
769                Ok(_) => false,
770            };
771            if gone {
772                classified.deletes.insert(path);
773                confirmed += 1;
774            } else {
775                // Back on disk: the rename-vs-walk race, caught exactly
776                // as the rule intends. Still only a first absence.
777                classified.first_absence.insert(path);
778            }
779        }
780        Ok(confirmed)
781    }
782
783    /// Renew the lease if it has gone stale MID-BARRIER, and hand back
784    /// the cell as it was read so the CALLER can fence on it.
785    ///
786    /// Costs nothing on a short barrier: the renewal is skipped unless
787    /// it was already due, so the request count is unchanged — this
788    /// moves WHEN the renewal can happen, not how often.
789    ///
790    /// What this does NOT do, stated because the previous comment
791    /// claimed it did: it never raises `Fenced` for a deposed holder.
792    /// `lease::renew` can only 412 when the cell is still at OUR epoch
793    /// with a token we do not hold; a cell at a FOREIGN epoch is
794    /// skipped here (renewing it would be nonsense), so a deposed
795    /// straggler passed straight through. The 2026-09-03 audit found
796    /// the cadence barrier relying on exactly that phantom fence
797    /// between upload chunks and completing every remaining data PUT
798    /// after a takeover. The cell is returned so the caller can compare
799    /// it against its lease — the cadence barrier uses the read it
800    /// already paid for rather than a separate fence. `None` when there is
801    /// no lease or the read failed: neither is a fence, and the
802    /// ordinary renewal arm will try again.
803    pub(crate) async fn renew_if_due(&mut self) -> LeanResult<Option<EpochState>> {
804        let Some(lease) = self.lease.clone() else { return Ok(None) };
805        let state = match self.store.epoch_read(&self.cfg.epoch_key()).await {
806            Ok(Some(s)) => s,
807            _ => return Ok(None),
808        };
809        let now = super::now_unix();
810        let fresh = state
811            .last_renew_unix
812            .map(|t| now.saturating_sub(t) < RENEW_WITHIN_SECS)
813            .unwrap_or(false);
814        if fresh || state.epoch != lease.epoch {
815            // Not due, or not ours to renew. The caller decides what a
816            // foreign cell means; this function only knows it cannot
817            // renew one.
818            return Ok(Some(state));
819        }
820        super::lease::renew(self).await?;
821        Ok(Some(state))
822    }
823
824    /// The between-chunk fence of the cadence barrier: a cell that no
825    /// longer names this holder at this epoch stops the upload set
826    /// HERE, before the next chunk's PUTs. Every one of those PUTs
827    /// carries If-Match on a baseline etag that still matches — the
828    /// successor has published nothing yet — so without this a deposed
829    /// straggler overwrites the cited generation of every key it still
830    /// had to upload, and every reader's S3-wins arm then adopts the
831    /// uncited bytes silently (audit 2026-09-03, finding 1).
832    fn fence_on_cell(&self, cell: &EpochState) -> LeanResult<()> {
833        let Some(lease) = self.lease.as_ref() else {
834            return Err(LeanError::Fenced("lease dropped mid-barrier".into()));
835        };
836        if cell.epoch != lease.epoch || cell.holder_id != lease.holder_id {
837            return Err(LeanError::Fenced(format!(
838                "deposed between upload chunks: cell at epoch {} holder {} (we are epoch {})",
839                cell.epoch, cell.holder_id, lease.epoch
840            )));
841        }
842        Ok(())
843    }
844
845    /// Steps 2–7. `barrier` = one full publish cycle (the cadence arm).
846    pub async fn run_barrier(&mut self) -> LeanResult<BarrierReport> {
847        self.barrier_inner(false, "manual").await
848    }
849
850    /// The floor tick's barrier. Stamped `cadence` so the bucket can
851    /// answer "which clock installed this boundary?" without the ack —
852    /// the ack is a LOCAL file, and the fleet-visible question is asked
853    /// by operators and by the gateway's `/status`, neither of which
854    /// can see it.
855    pub async fn cadence_barrier(&mut self) -> LeanResult<BarrierReport> {
856        self.barrier_inner(false, "cadence").await
857    }
858
859    /// A barrier whose result somebody is going to ACK: a sentinel
860    /// honor (D1) or the preStop drain (D10). Identical to
861    /// `run_barrier` except that it confirms first-absence paths rather
862    /// than acking a boundary that withholds them.
863    pub async fn declared_barrier(&mut self) -> LeanResult<BarrierReport> {
864        self.barrier_inner(true, "sentinel").await
865    }
866
867    /// `declared_barrier` under an explicit provenance stamp — the
868    /// drain and the budget-deferred honor are the same barrier
869    /// reporting a different clock.
870    pub async fn declared_barrier_as(&mut self, source: &str) -> LeanResult<BarrierReport> {
871        self.barrier_inner(true, source).await
872    }
873
874    async fn barrier_inner(&mut self, declared: bool, source: &str) -> LeanResult<BarrierReport> {
875        // No lease is held until the commit section (design 2026-09-13
876        // §4): the consume, the scan and the uploads below run with the
877        // cell at rest, guarded by each object's own etag. What the
878        // uploads are STAMPED with is the last epoch this incarnation
879        // held — informational, like every epoch stamp on an object; the
880        // manifest entries carry the commit section's real epoch.
881        let epoch_hint = self.state.load_incarnation()?.map(|i| i.epoch).unwrap_or(0);
882        let mut report = BarrierReport::default();
883        let barrier_started = std::time::Instant::now();
884        self.trace("barrier_start", serde_json::json!({"source": source, "declared": declared}));
885
886        // STEP 0: finish any rescope a crash left half-applied, before
887        // the scan reads the tree (scoped-read design §4.3). This is the
888        // gate that makes the narrow verb safe at all: a half-applied
889        // narrow is a tree whose files and whose citations disagree, and
890        // `classify` reads that disagreement as an upload or as a
891        // DELETE depending on which half landed. Replay is idempotent,
892        // so the cost on the normal path is one `stat` of a file that
893        // is not there.
894        if let Some(r) = self.replay_scope_intent().await? {
895            report.rescope_replayed = true;
896            eprintln!(
897                "flint-sync: finished an interrupted rescope before the barrier \
898                 (uncited {}, unlinked {}, materialised {})",
899                r.uncited, r.unlinked, r.materialized
900            );
901        }
902
903        // Step 1: the cell, read ONCE — HITL entries into the tree, then
904        // the DECLARED removals (delete/rename design §4-§6).
905        let inbox_doc = inbox::load(self.store.as_ref(), &self.cfg).await?.doc;
906        let (consumed, settled_locally) = self.consume_counted(&inbox_doc).await?;
907        report.consumed = consumed.len() + settled_locally;
908        let removals = self.apply_removals(&inbox_doc).await?;
909        report.removed = removals.declared.iter().cloned().collect();
910        report.removals_refused = removals.refused.len();
911        // A refusal is settled NOW, whatever else this barrier does: the
912        // cell is where the human who asked reads the answer, and a
913        // refused removal is never retried, so saying so before the
914        // no-diff return below loses nothing.
915        inbox::settle_removals(self.store.as_ref(), &self.cfg, epoch_hint, &[], &removals.refused)
916            .await?;
917
918        // Step 2: scan-diff against the persisted baseline.
919        let mut baseline = self.state.load_baseline()?;
920        let scanned = scan::scan(&self.cfg.root)?;
921        let mut classified = scan::classify(&scanned, &baseline);
922        if declared {
923            report.absences_confirmed = self.confirm_absences(&mut classified)?;
924        }
925        // A declared removal is a deletion with its basis RECORDED, so
926        // it skips the two-scan guard the walk needs and goes straight
927        // into the delete set — which is what lets a rename's two halves
928        // ride ONE manifest generation (§4).
929        for p in &removals.declared {
930            classified.first_absence.remove(p);
931            if classified.uploads.contains(p) {
932                // The agent re-created the path between the unlink and
933                // the scan. The declaration named the OLD file, which is
934                // gone from the tree (and, for a rename, moved); what is
935                // here now is a new file the agent wrote, and it
936                // publishes as one — its PUT supersedes the old object
937                // under the same key. Deleting it would un-cite the
938                // agent's work for a barrier and GC-skip its object; the
939                // model's install gives the upload the same precedence.
940                continue;
941            }
942            classified.deletes.insert(p.clone());
943        }
944        report.first_absence = classified.first_absence.iter().cloned().collect();
945
946        // Skip-on-no-diff: nothing local, nothing consumed, no pending
947        // citation repair, and the bucket manifest where we left it.
948        let repairs_pending = baseline
949            .entries
950            .iter()
951            .any(|(p, be)| be.size != u64::MAX && baseline.inst_base.get(p) != Some(&be.etag));
952        if classified.uploads.is_empty()
953            && classified.deletes.is_empty()
954            && consumed.is_empty()
955            && classified.first_absence.is_empty()
956            && removals.applied.is_empty()
957            && !repairs_pending
958        {
959            // Read the POINTER, never the entries: the 0b rig measured
960            // the idle tick at 1M entries as 27 s / 1.3 GiB — dominated
961            // by fetching and parsing a 264 MiB document just to read
962            // `seq`. The pointer is a few hundred bytes and carries the
963            // seq in its body, so a GET of it costs one round trip and
964            // answers strictly more than a HEAD of the old object did.
965            //
966            // Legacy workspaces (no pointer yet) keep the HEAD they had.
967            let unchanged = match manifest::load_pointer(self.store.as_ref(), &self.cfg).await? {
968                Some(manifest::LoadedPointer { pointer: p, etag, .. }) => {
969                    // The news ticker rides this request for free — D5's
970                    // "zero added bucket requests" is still literal.
971                    report.observed_seq = Some(p.seq);
972                    report.observed_etag = Some(etag.clone());
973                    baseline.manifest_etag.as_deref() == Some(etag.as_str())
974                }
975                None => match self.store.head(&self.cfg.manifest_key()).await {
976                    Ok(meta) => {
977                        report.observed_seq =
978                            GenerationStamps::from_meta(&meta.meta).map(|s| s.generation);
979                        report.observed_etag = Some(meta.etag.clone());
980                        baseline.manifest_etag.as_deref() == Some(meta.etag.as_str())
981                    }
982                    Err(StoreError::NotFound(_)) => baseline.manifest_etag.is_none(),
983                    Err(e) => return Err(e.into()),
984                },
985            };
986            if unchanged {
987                report.no_change = true;
988                report.seq = Some(baseline.seq);
989                // prev_scan still advances (the two-scan rule's clock) —
990                // but the baseline document (hundreds of MB at 1M
991                // entries) is rewritten only when the scan SET moved.
992                // Compare without building the set: both sides iterate
993                // sorted, and the allocation this used to make was the
994                // size of the very document it was trying not to write.
995                if scanned.len() != baseline.prev_scan.len()
996                    || !scanned.keys().eq(baseline.prev_scan.iter())
997                {
998                    baseline.prev_scan = scanned.keys().cloned().collect();
999                    self.state.save_baseline(&baseline)?;
1000                }
1001                self.trace_barrier_end(&report, barrier_started);
1002                return Ok(report);
1003            }
1004        }
1005
1006        // Step 3: intent journal, then the window (the commitment
1007        // point: the same CAS drops the consumed entries).
1008        let flush_uuid = uuid::Uuid::new_v4().to_string();
1009        self.trace("scan", serde_json::json!({"flush": flush_uuid, "uploads": classified.uploads.len(),
1010            "deletes": classified.deletes.len(), "first_absence": classified.first_absence.len()}));
1011        let mut intent = self.state.load_intent()?;
1012        let prior_uuids = {
1013            let mut v = intent.recent_uuids.clone();
1014            if !intent.flush_uuid.is_empty() {
1015                v.push(intent.flush_uuid.clone());
1016            }
1017            v
1018        };
1019        // Carried forward, not dropped: it stays true until we install
1020        // again, and the merge below is what reads it.
1021        let prev_installed = intent.installed_etag.clone();
1022        intent = IntentJournal {
1023            flush_uuid: flush_uuid.clone(),
1024            keys: classified.uploads.iter().map(|p| self.cfg.file_key(p)).collect(),
1025            recent_uuids: prior_uuids.clone(),
1026            installed_etag: prev_installed.clone(),
1027            installed_foreign: intent.installed_foreign.clone(),
1028            declared_deletes: removals.declared.iter().cloned().collect(),
1029        };
1030        self.state.save_intent(&intent)?;
1031        // The window is NOT opened here any more. It is the bucket's
1032        // "a barrier is committing" sign for HITL writers, and it opens
1033        // in the commit section, under the lease, at the epoch the
1034        // commit really holds. The uploads below race a HITL write the
1035        // way two writers race each other: If-Match decides, the loser
1036        // is preserved and recorded (`LeanNoWindowHolds` proves safety
1037        // never depended on the window).
1038        //
1039        // The consumed entries are not dropped here either — "durably
1040        // in the baseline" is true of a container restart and false of
1041        // a pod REPLACEMENT: the emptyDir goes with the pod. They leave
1042        // the cell with the window clear, after the manifest cites
1043        // them; a successor that finds them re-consumes, idempotently.
1044
1045        // Step 4: guarded uploads, fanned out under a bounded window
1046        // (each key's guard chain is independent; the 412 policy and
1047        // conflict records are applied to the collected results below,
1048        // in deterministic path order).
1049        let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
1050        let mut parked: BTreeSet<String> = BTreeSet::new();
1051        // Citations whose etag was observed with no lease held (adopted
1052        // uploads, citation repairs): re-read inside the commit section.
1053        let mut observed: BTreeSet<String> = BTreeSet::new();
1054        let mut new_baseline_entries: BTreeMap<String, BaselineEntry> = BTreeMap::new();
1055        let outcomes: Vec<(String, LeanResult<UploadOutcome>)> = {
1056            use futures::stream::{self, StreamExt};
1057            // CHUNKED into waves, so a wave's outcomes are collected
1058            // before the next is fanned out. No lease is held here
1059            // (design 2026-09-13 §4), so nothing renews or fences
1060            // between chunks any more — the between-chunk fence that
1061            // stopped a deposed straggler's PUTs went with the straggler:
1062            // a PUT outside the lease is one legitimate writer's PUT,
1063            // guarded by If-Match, and a collision is preserved and
1064            // superseded, never silently overwritten.
1065            let mut outcomes: Vec<(String, LeanResult<UploadOutcome>)> = Vec::new();
1066            // NOT cfg.fanout. That knob was raised to 128 for the READ
1067            // path, and the read-side measurement (2.5x on 20k small
1068            // files) says nothing about this one, so uploads keep the
1069            // value they were measured at until someone measures them.
1070            // Memory is no longer the reason they are separate: this
1071            // path is byte-bounded too now, by the store's gate
1072            // (`with_upload_inflight_max_bytes`), which charges every
1073            // whole body below and every multipart part inside the
1074            // store from before its read until its PUT returns.
1075            let fanout = self.cfg.upload_fanout.max(1);
1076            let chunk = fanout.saturating_mul(UPLOAD_CHUNK_WAVES).max(1);
1077            let all: Vec<&String> = classified.uploads.iter().collect();
1078            for group in all.chunks(chunk) {
1079                {
1080                    let this: &Syncer = &*self;
1081                    let mut part: Vec<(String, LeanResult<UploadOutcome>)> =
1082                        stream::iter(group.iter().map(|path| {
1083                            let path = *path;
1084                            let scanned_entry = &scanned[path];
1085                            let base = baseline.entries.get(path);
1086                            let flush_uuid = &flush_uuid;
1087                            let prior_uuids = &prior_uuids;
1088                            async move {
1089                                let r = this
1090                                    .upload_one(
1091                                        path, scanned_entry, base, epoch_hint, flush_uuid,
1092                                        prior_uuids,
1093                                    )
1094                                    .await;
1095                                (path.clone(), r)
1096                            }
1097                        }))
1098                        .buffer_unordered(fanout)
1099                        .collect()
1100                        .await;
1101                    outcomes.append(&mut part);
1102                }
1103            }
1104            outcomes
1105        };
1106        let mut outcomes = outcomes;
1107        outcomes.sort_by(|a, b| a.0.cmp(&b.0));
1108        for (path, outcome) in outcomes {
1109            let path = &path;
1110            match outcome? {
1111                UploadOutcome::Published { entry, baseline_entry, adopted } => {
1112                    self.trace("upload", serde_json::json!({"flush": flush_uuid, "path": path, "etag": entry.etag,
1113                        "outcome": if adopted { "adopted" } else { "put" }}));
1114                    if adopted {
1115                        observed.insert(path.clone());
1116                    }
1117                    report.published_bytes += entry.size;
1118                    upserts.insert(path.clone(), entry);
1119                    new_baseline_entries.insert(path.clone(), baseline_entry);
1120                    report.uploaded.push(path.clone());
1121                }
1122                UploadOutcome::Parked { foreign_etag } => {
1123                    self.trace("upload", serde_json::json!({"flush": flush_uuid, "path": path, "etag": foreign_etag, "outcome": "parked"}));
1124                    parked.insert(path.clone());
1125                    self.state.append_conflict(&ConflictRecord {
1126                        path: path.clone(),
1127                        foreign_etag,
1128                        preserved_key: None, // parking preserves in place
1129                        kind: "upload-412-parked".into(),
1130                        at_unix: now_unix(),
1131                    })?;
1132                    report.parked.push(path.clone());
1133                }
1134                UploadOutcome::Deferred => {
1135                    self.trace("upload", serde_json::json!({"flush": flush_uuid, "path": path, "outcome": "deferred"}));
1136                    report.deferred.push(path.clone());
1137                }
1138            }
1139        }
1140
1141        // Citation repairs: paths whose integrated object (the
1142        // baseline) differs from the manifest's citation — consumed
1143        // HITL adoptions and checkout's S3-wins arm. No bytes move; the
1144        // manifest re-cites what this syncer already integrated.
1145        // Without this, an adopted upload is clean-vs-baseline, never
1146        // enters the upload set, and the manifest silently drops it —
1147        // the battery's amputation leg caught exactly that.
1148        let repair_candidates: Vec<String> = baseline
1149            .entries
1150            .iter()
1151            .filter(|(path, be)| {
1152                !classified.uploads.contains(*path)
1153                    && !classified.deletes.contains(*path)
1154                    && be.size != u64::MAX // consume-dirty sentinel: publishes via upload
1155                    && baseline.inst_base.get(*path) != Some(&be.etag)
1156            })
1157            .map(|(path, _)| path.clone())
1158            .collect();
1159        for path in repair_candidates {
1160            let key = self.cfg.file_key(&path);
1161            let be = baseline.entries[&path].clone();
1162            match self.store.head(&key).await {
1163                Ok(meta) if meta.etag == be.etag => {
1164                    let crc = repair_crc(&path, &be, &meta)?;
1165                    let stamps = GenerationStamps::from_meta(&meta.meta);
1166                    let scan_entry = scanned.get(&path);
1167                    upserts.insert(
1168                        path.clone(),
1169                        LeanEntry {
1170                            key,
1171                            etag: meta.etag.clone(),
1172                            crc64_b64: crc,
1173                            size: meta.size,
1174                            mode: stamps
1175                                .as_ref()
1176                                .and_then(|s| s.posix)
1177                                .map(|p| p.mode)
1178                                .or(scan_entry.map(|s| s.mode))
1179                                .unwrap_or(0o644),
1180                            mtime_unix: scan_entry.map(|s| s.mtime_unix).unwrap_or(0),
1181                            generation: stamps.map(|s| s.generation).unwrap_or(be.generation),
1182                            epoch: epoch_hint,
1183                        },
1184                    );
1185                    observed.insert(path.clone());
1186                }
1187                // Moved again or gone: the next consume reconciles it.
1188                _ => {}
1189            }
1190        }
1191
1192        // THE COMMIT SECTION (design 2026-09-13 §4). Everything above ran
1193        // with no lease: the uploads are durable and etag-guarded, and
1194        // nothing is cited yet. Claim the cell now — for the merge, the
1195        // CAS, the deletes and the baseline — and hand it to the next
1196        // waiter when done. This is the only stretch two writers of one
1197        // workspace serialise on, and it is small: one pointer CAS and
1198        // the guarded deletes, milliseconds to seconds, never the upload
1199        // of a checkpoint.
1200        let held = super::lease::claim(self).await?;
1201        let epoch = held.epoch;
1202        eprintln!("flint-sync: publish fence held (epoch {epoch})");
1203        if self.cfg.drill_hold_commit_secs > 0 {
1204            eprintln!(
1205                "flint-sync: DRILL: holding the fence for {}s inside the commit section",
1206                self.cfg.drill_hold_commit_secs
1207            );
1208            tokio::time::sleep(std::time::Duration::from_secs(self.cfg.drill_hold_commit_secs)).await;
1209        }
1210        let commit: LeanResult<()> = async {
1211            // The entries carry the epoch the commit HOLDS, not the hint
1212            // the uploads were stamped with.
1213            for e in upserts.values_mut() {
1214                e.epoch = epoch;
1215            }
1216            // Step 5: the manifest CAS (three-way merge; bounded retries).
1217            self.verify_not_deposed().await?;
1218            // EVERY citation this barrier adds was read or written with no
1219            // lease held. Another writer's commit may since have uncited
1220            // the path and collected the object: its GC deletes by the etag
1221            // it integrated. That etag names an ADOPTED object (the model's
1222            // LeanBarrierLeaseAdoptBlind) — and, since an S3 whole-PUT etag
1223            // is the MD5 of the bytes, it also names this barrier's own PUT
1224            // of IDENTICAL bytes (finding 13, the writers drill's A3: a
1225            // same-content rewrite re-uploaded, the peer's GC deleted it,
1226            // the commit cited a hole). Collection runs only inside a
1227            // commit section, so a re-read HERE, holding the fence, cannot
1228            // be overtaken before this CAS. Whatever is gone or replaced is
1229            // withheld: parked, recorded, and left dirty for the next
1230            // barrier to publish with a PUT of its own.
1231            let heads: Vec<(String, LeanResult<bool>)> = {
1232                use futures::stream::{self, StreamExt};
1233                let this: &Syncer = &*self;
1234                stream::iter(upserts.iter().map(|(path, cited)| async move {
1235                    let still = match this.store.head(&cited.key).await {
1236                        Ok(meta) => Ok(meta.etag == cited.etag),
1237                        Err(StoreError::NotFound(_)) => Ok(false),
1238                        Err(e) => Err(e.into()),
1239                    };
1240                    (path.clone(), still)
1241                }))
1242                .buffer_unordered(self.cfg.upload_fanout.max(1))
1243                .collect()
1244                .await
1245            };
1246            let mut heads = heads;
1247            heads.sort_by(|a, b| a.0.cmp(&b.0));
1248            for (path, still) in heads {
1249                let still_there = still?;
1250                let was_observed = observed.contains(&path);
1251                let cited = &upserts[&path];
1252                self.trace("observed", serde_json::json!({"flush": flush_uuid, "path": path, "etag": cited.etag, "still": still_there, "own_put": !was_observed}));
1253                if still_there {
1254                    continue;
1255                }
1256                let gone = upserts.remove(&path).expect("looked up above");
1257                new_baseline_entries.remove(&path);
1258                report.uploaded.retain(|p| p != &path);
1259                report.published_bytes = report.published_bytes.saturating_sub(gone.size);
1260                parked.insert(path.clone());
1261                let kind = if was_observed {
1262                    "adopt-withheld: the object this barrier found already at its key was \
1263                     replaced or collected before its commit; nothing cited, the path stays \
1264                     dirty and publishes next barrier"
1265                } else {
1266                    "upload-withheld: the object this barrier PUT was replaced or collected \
1267                     before its commit (a peer's GC recognizes the etag of identical bytes); \
1268                     nothing cited, the path stays dirty and publishes next barrier"
1269                };
1270                self.state.append_conflict(&ConflictRecord {
1271                    path: path.clone(),
1272                    foreign_etag: gone.etag,
1273                    preserved_key: None,
1274                    kind: kind.into(),
1275                    at_unix: now_unix(),
1276                })?;
1277                report.parked.push(path.clone());
1278            }
1279            // The window: the bucket-visible "a barrier is committing" sign
1280            // HITL writers wait on, at the epoch this commit holds.
1281            let deadline = now_unix() + self.cfg.window_slack_secs;
1282            inbox::open_window(self.store.as_ref(), &self.cfg, epoch, deadline).await?;
1283            let mut foreign_entries: Vec<(String, LeanEntry)> = vec![];
1284            let mut foreign_gone: Vec<String> = vec![];
1285            // Set when the merge added nothing to the document: nothing is
1286            // installed, and theirs becomes the merge base as it stands.
1287            let mut installed_nothing = false;
1288            let mut attempt = 0;
1289            let (installed, installed_etag) = loop {
1290                attempt += 1;
1291                if attempt > 4 {
1292                    return Err(LeanError::State(
1293                        "manifest CAS lost 4 merge races — refusing this barrier".into(),
1294                    ));
1295                }
1296                let current = manifest::load(self.store.as_ref(), &self.cfg).await?;
1297                let (theirs, expected) = match &current {
1298                    // The handle carries the LAYOUT as well as the tag: a
1299                    // workspace still on the legacy single object CASes a
1300                    // pointer that must not exist yet, not one it read.
1301                    Some(l) => (l.manifest.clone(), Some(l.handle())),
1302                    None => (Default::default(), None),
1303                };
1304                // If the bucket is still at the document THIS workspace
1305                // installed, that document IS the merge base — whatever the
1306                // persisted one says. Step 7 rewrites the merge base after
1307                // the CAS, so a restart in between leaves it behind a
1308                // document we wrote, and every entry in it would read as
1309                // somebody else's change. See `IntentJournal::installed_etag`.
1310                let own_base;
1311                let base: &BTreeMap<String, String> =
1312                    if prev_installed.is_some()
1313                        && prev_installed.as_deref() == expected.as_ref().map(|h| h.etag.as_str())
1314                    {
1315                        own_base = theirs
1316                            .entries
1317                            .iter()
1318                            .map(|(p, e)| (p.clone(), e.etag.clone()))
1319                            .collect();
1320                        &own_base
1321                    } else {
1322                        &baseline.inst_base
1323                    };
1324                let (mut merged, foreign) =
1325                    manifest::merge(base, &theirs, &upserts, &classified.deletes, &parked);
1326                // Deletions another writer made since this workspace's
1327                // merge base: in the base, gone from theirs, and not this
1328                // barrier's own upsert, delete or park. `merge` has no use
1329                // for them — theirs already lacks the path — but the TREE
1330                // does: they reach it through the local queue, as the
1331                // foreign upserts do.
1332                let gone: Vec<String> = base
1333                    .keys()
1334                    .filter(|p| {
1335                        !theirs.entries.contains_key(*p)
1336                            && !upserts.contains_key(*p)
1337                            && !classified.deletes.contains(*p)
1338                            && !parked.contains(*p)
1339                    })
1340                    .cloned()
1341                    .collect();
1342                // `merge` clears it; the installing pass owns it. A mirror
1343                // is a property of how this workspace is DEPLOYED, so it
1344                // comes from config on every publish rather than being
1345                // inherited from whatever wrote last.
1346                merged.sole_writer = self.cfg.sole_writer;
1347                // Nothing of ours changes the document — a barrier that only
1348                // found the manifest moved by another writer. Installing it
1349                // anyway was an empty generation, and the peer's next tick
1350                // then found the manifest moved and did the same: two idle
1351                // writers traded generations (and cell claims) for as long
1352                // as both ran. Theirs becomes the merge base as it stands.
1353                self.trace("merge", serde_json::json!({"flush": flush_uuid, "theirs_seq": theirs.seq, "upserts": upserts.len(),
1354                    "deletes": classified.deletes.len(), "foreign": foreign.len(), "gone": gone.len(),
1355                    "adds_nothing": merged.entries == theirs.entries}));
1356                if let Some(handle) = expected.as_ref() {
1357                    if merged.entries == theirs.entries && merged.sole_writer == theirs.sole_writer {
1358                        foreign_entries = foreign;
1359                        foreign_gone = gone;
1360                        installed_nothing = true;
1361                        break (theirs, handle.etag.clone());
1362                    }
1363                }
1364                match manifest::cas_write_stamped(
1365                    self.store.as_ref(),
1366                    &self.cfg,
1367                    &merged,
1368                    expected.as_ref(),
1369                    epoch,
1370                    &flush_uuid,
1371                    Some(source),
1372                )
1373                .await
1374                {
1375                    Ok(meta) => {
1376                        // Before the deletes and before step 7: this is the
1377                        // only record that survives a crash in that window.
1378                        self.trace("cas", serde_json::json!({"flush": flush_uuid, "seq": merged.seq,
1379                            "expected": expected.as_ref().map(|h| h.etag.clone()), "etag": meta.etag, "result": "ok"}));
1380                        intent.installed_etag = Some(meta.etag.clone());
1381                        intent.installed_foreign = foreign_changes(&foreign, &gone);
1382                        self.state.save_intent(&intent)?;
1383                        foreign_entries = foreign;
1384                        foreign_gone = gone;
1385                        break (merged, meta.etag);
1386                    }
1387                    Err(LeanError::Store(StoreError::PreconditionFailed(_)))
1388                    | Err(LeanError::Store(StoreError::Conflict(_))) => {
1389                        self.trace("cas", serde_json::json!({"flush": flush_uuid, "seq": merged.seq,
1390                            "expected": expected.as_ref().map(|h| h.etag.clone()), "result": "lost"}));
1391                        // Re-verify the cell before retrying: a rotation is
1392                        // exactly this 412, and re-merging past it would be
1393                        // the straggler install.
1394                        self.verify_not_deposed().await?;
1395                        continue;
1396                    }
1397                    Err(e) => return Err(e),
1398                }
1399            };
1400            report.seq = Some(installed.seq);
1401            report.no_change = installed_nothing;
1402            // A fused install IS a coherent point, and cadence/hybrid have
1403            // exactly one source. The gauges must not report "no boundary
1404            // ever" on a workspace that publishes every minute. A barrier
1405            // that installed nothing marks no boundary of its own.
1406            if !installed_nothing {
1407                self.note_boundary("cadence", installed.seq)?;
1408            }
1409            report.manifest_etag = Some(installed_etag.clone());
1410            report.observed_seq = Some(installed.seq);
1411            report.observed_etag = Some(installed_etag.clone());
1412            report.foreign_queued = foreign_entries.len();
1413
1414            // Step 6: deletes LAST — GC of keys the NEW manifest no longer
1415            // references, HEAD-guarded on the recognized ETag.
1416            let mut swept = 0usize;
1417            let mut gc_held = false;
1418            for path in &classified.deletes {
1419                // A mass delete is the one long stretch of the commit
1420                // section: keep the token moving so a waiter does not count
1421                // a live holder dead, and fence on what the read returns.
1422                swept += 1;
1423                if swept % 200 == 0 {
1424                    if let Some(cell) = self.renew_if_due().await? {
1425                        self.fence_on_cell(&cell)?;
1426                    }
1427                }
1428                if installed.entries.contains_key(path) {
1429                    continue; // delete/modify resolved foreign-wins: not garbage
1430                }
1431                let key = self.cfg.file_key(path);
1432                let recognized = baseline.entries.get(path).map(|b| b.etag.clone()).or_else(|| {
1433                    // A DECLARED removal of a path this tree never held (a
1434                    // scoped workspace's out-of-scope citation): what the
1435                    // declaration named is the object the manifest cited
1436                    // when this barrier began, and that is the merge base.
1437                    removals
1438                        .declared
1439                        .contains(path)
1440                        .then(|| baseline.inst_base.get(path).cloned())
1441                        .flatten()
1442                });
1443                // The HEAD answers most paths in one request (gone, or an
1444                // etag we do not recognize). The DELETE itself carries
1445                // If-Match on the recognized etag: another writer's
1446                // upload holds no lease and can replace the object
1447                // between the two requests, and an unconditional delete
1448                // then removed bytes that writer's commit cites (the
1449                // model's LeanBarrierLeaseGCUnconditional).
1450                let unrecognized = match self.store.head(&key).await {
1451                    Err(StoreError::NotFound(_)) => {
1452                        self.trace("gc", serde_json::json!({"flush": flush_uuid, "path": path, "head": null, "result": "absent"}));
1453                        report.deleted.push(path.clone());
1454                        continue;
1455                    }
1456                    Ok(meta) if Some(&meta.etag) == recognized.as_ref() => {
1457                        if self.cfg.drill_hold_gc_secs > 0 && !gc_held {
1458                            gc_held = true;
1459                            eprintln!(
1460                                "flint-sync: DRILL: holding the GC for {}s between the HEAD of {path} and its DELETE",
1461                                self.cfg.drill_hold_gc_secs
1462                            );
1463                            self.trace("drill_hold", serde_json::json!({"flush": flush_uuid, "where": "gc", "path": path,
1464                                "secs": self.cfg.drill_hold_gc_secs}));
1465                            tokio::time::sleep(std::time::Duration::from_secs(self.cfg.drill_hold_gc_secs)).await;
1466                        }
1467                        match self.store.delete_if_match(&key, &meta.etag).await {
1468                            Ok(()) | Err(StoreError::NotFound(_)) => {
1469                                self.trace("gc", serde_json::json!({"flush": flush_uuid, "path": path, "head": meta.etag, "result": "deleted"}));
1470                                report.deleted.push(path.clone());
1471                                continue;
1472                            }
1473                            // Replaced between the HEAD and the DELETE:
1474                            // whatever is there now is not ours to
1475                            // collect. Name it, if it still exists.
1476                            Err(StoreError::PreconditionFailed(_)) => match self.store.head(&key).await {
1477                                Ok(now) => now.etag,
1478                                Err(StoreError::NotFound(_)) => {
1479                                    self.trace("gc", serde_json::json!({"flush": flush_uuid, "path": path, "head": meta.etag, "result": "replaced-absent"}));
1480                                    report.deleted.push(path.clone());
1481                                    continue;
1482                                }
1483                                Err(e) => return Err(e.into()),
1484                            },
1485                            Err(e) => return Err(e.into()),
1486                        }
1487                    }
1488                    Ok(meta) => meta.etag,
1489                    Err(e) => return Err(e.into()),
1490                };
1491                // An ETag this syncer does not recognize is NEVER deleted
1492                // (a HITL re-create landed after our CAS, or another
1493                // writer's upload).
1494                self.trace("gc", serde_json::json!({"flush": flush_uuid, "path": path, "head": unrecognized,
1495                    "recognized": recognized, "result": "skip"}));
1496                self.state.append_conflict(&ConflictRecord {
1497                    path: path.clone(),
1498                    foreign_etag: unrecognized,
1499                    preserved_key: None,
1500                    kind: "gc-skip".into(),
1501                    at_unix: now_unix(),
1502                })?;
1503            }
1504
1505            // Step 7: the foreign queue, then the baseline rewrite, intent
1506            // clear and window clear.
1507            //
1508            // Other writers' changes go to THIS writer's queue BEFORE the
1509            // merge base below moves past them: a crash between the two
1510            // re-applies them idempotently, where the other order would
1511            // leave a base that claims changes the tree never received.
1512            if !foreign_entries.is_empty() || !foreign_gone.is_empty() {
1513                self.state.queue_foreign(&foreign_changes(&foreign_entries, &foreign_gone))?;
1514                self.trace("queue", serde_json::json!({"flush": flush_uuid, "upserts": foreign_entries.len(), "tombstones": foreign_gone.len()}));
1515            }
1516            for (path, be) in new_baseline_entries {
1517                baseline.entries.insert(path, be);
1518            }
1519            for path in &report.deleted {
1520                baseline.entries.remove(path);
1521            }
1522            baseline.inst_base =
1523                installed.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
1524            baseline.seq = installed.seq;
1525            baseline.manifest_etag = Some(installed_etag);
1526            baseline.prev_scan = scanned.keys().cloned().collect();
1527            self.state.save_baseline(&baseline)?;
1528            self.state.clear_intent_keys()?;
1529            // No `merge-preserved` entries into the SHARED inbox any more:
1530            // they are this writer's, and sit in its local queue.
1531            inbox::clear_window_settling(
1532                self.store.as_ref(),
1533                &self.cfg,
1534                epoch,
1535                &[],
1536                &consumed,
1537                &removals.applied,
1538            )
1539            .await?;
1540            // Reap superseded generations. Immutable metadata that is never
1541            // collected is a leak that grows by a whole manifest per
1542            // publish, and this also collects the orphan a crash between the
1543            // entries PUT and the pointer CAS leaves behind. Best effort by
1544            // design: a publish that succeeded is not un-done by a failure
1545            // to tidy up after it.
1546            match manifest::sweep_generations(self.store.as_ref(), &self.cfg).await {
1547                Ok(0) => {}
1548                Ok(n) => eprintln!("flint-sync: reaped {n} superseded manifest generation(s)"),
1549                Err(e) => eprintln!("flint-sync: generation sweep: {e}"),
1550            }
1551            // And the CHUNK reaper, same reason, same best-effort terms.
1552            // `sweep_chunks` returns 0 without a request on a workspace that
1553            // is not chunked, so this costs nothing until the layout moves.
1554            //
1555            // It was written to four model-established rules, guarded by six
1556            // mutation configs, and called from nothing but its own tests —
1557            // so with chunking on, every publish left its superseded chunks
1558            // in the bucket forever. The rules ran in the model and in the
1559            // suite and never once in production. `the_barrier_reaps...`
1560            // below is the test that asks whether this line exists at all.
1561            match manifest::sweep_chunks(self.store.as_ref(), &self.cfg).await {
1562                Ok(0) => {}
1563                Ok(n) => eprintln!("flint-sync: reaped {n} unreferenced manifest chunk(s)"),
1564                Err(e) => eprintln!("flint-sync: chunk sweep: {e}"),
1565            }
1566            Ok(())
1567        }
1568        .await;
1569        // Hand the cell on whatever happened — unless it is no longer
1570        // ours to hand on: a fence means a successor holds it, and the
1571        // barrier is simply abandoned (its manifest never installed; its
1572        // uploads stand, and the next barrier adopts them by flush_uuid).
1573        match &commit {
1574            Err(LeanError::Fenced(m)) => {
1575                self.trace("fence", serde_json::json!({"where": "commit", "detail": m}));
1576                self.lease = None
1577            }
1578            _ => {
1579                if let Err(e) = super::lease::release(self).await {
1580                    eprintln!(
1581                        "flint-sync: the publish fence could not be released ({e}); a waiter \
1582                         deposes it after the quiet threshold"
1583                    );
1584                }
1585            }
1586        }
1587        commit?;
1588        self.trace_barrier_end(&report, barrier_started);
1589        Ok(report)
1590    }
1591
1592    fn trace_barrier_end(&self, report: &BarrierReport, started: std::time::Instant) {
1593        if self.cfg.event_trace.is_none() {
1594            return;
1595        }
1596        self.trace(
1597            "barrier_end",
1598            serde_json::json!({
1599                "seq": report.seq, "uploaded": report.uploaded.len(), "deleted": report.deleted.len(),
1600                "parked": report.parked.len(), "consumed": report.consumed, "no_change": report.no_change,
1601                "ms": started.elapsed().as_millis() as u64, "requests": self.trace_requests(),
1602            }),
1603        );
1604    }
1605
1606    /// The > whole_put_max path: contiguous `PartSource::Local` chunks
1607    /// through `compose_generation` (streaming multipart; the store
1608    /// aborts its partial assembly on every failure path). The CRC is
1609    /// computed by a streaming pass first; a writer racing the compose
1610    /// fails server-side validation and the path DEFERS to the next
1611    /// barrier — publish-possibly-torn is put_whole's documented
1612    /// dilemma, not this path's.
1613    #[allow(clippy::too_many_arguments)]
1614    #[allow(clippy::too_many_arguments)]
1615    async fn upload_compose(
1616        &self,
1617        path: &str,
1618        key: &str,
1619        local_path: &Path,
1620        size: u64,
1621        scanned: &scan::ScanEntry,
1622        condition: PutCondition,
1623        stamps: GenerationStamps,
1624        generation: u64,
1625        epoch: u64,
1626        prior_uuids: &[String],
1627    ) -> LeanResult<UploadOutcome> {
1628        // NO PRE-PASS. The store accumulates the full-object CRC from
1629        // the parts as it reads them for upload, so this file is read
1630        // ONCE (the 2026-09-10 door drill measured the second read at
1631        // roughly 11 s of the `mixed` workload's 24.3 s).
1632        // Part grid: within [min_part_size, ...], at most max_parts,
1633        // contiguous from 0.
1634        let min_part = self.store.min_part_size().max(1);
1635        let max_parts = self.store.max_parts().max(1) as u64;
1636        let mut chunk = self.cfg.whole_put_max.max(min_part);
1637        let need = size.div_ceil(chunk);
1638        if need > max_parts {
1639            chunk = size.div_ceil(max_parts).div_ceil(min_part) * min_part;
1640        }
1641        let mut parts = vec![];
1642        let mut off = 0u64;
1643        while off < size {
1644            let len = chunk.min(size - off);
1645            parts.push(flint_store::PartSource::Local { offset: off, len });
1646            off += len;
1647        }
1648
1649
1650        // At most three attempts: the caller's condition; then once more
1651        // If-Match on what a 412 showed us — our own torn Complete
1652        // (recognized through the crash journal, review 2026-09-12
1653        // atomicity-2), or a foreign version now PRESERVED (inbox-1) —
1654        // or a create if the base object vanished (inbox-5). A 412 on
1655        // the retry means the path is being written under us: park.
1656        let mut condition = condition;
1657        for attempt in 0..3u32 {
1658            let spec = flint_store::ComposeSpec {
1659                progress: None,
1660                key,
1661                local_path,
1662                parts: parts.clone(),
1663                base_key: None,
1664                base_etag: None,
1665                condition: condition.clone(),
1666                stamps: stamps.clone(),
1667                crc64: None,
1668            };
1669            match self.store.compose_generation(&spec).await {
1670                Ok(meta) => {
1671                    // The store computed it; refuse to cite bytes nothing
1672                    // vouched for rather than invent a number for the
1673                    // manifest. A backend that returns no checksum has not
1674                    // validated the publish server-side either.
1675                    let crc = meta
1676                        .crc64_b64
1677                        .as_deref()
1678                        .and_then(flint_store::crc64_from_b64)
1679                        .ok_or_else(|| {
1680                            LeanError::State(format!(
1681                                "compose of {key} returned no full-object checksum — refusing to \
1682                                 cite bytes nothing vouched for"
1683                            ))
1684                        })?;
1685                    return Ok(UploadOutcome::published(
1686                        path, key.to_string(), meta.etag, crc, size, scanned, generation, epoch,
1687                    ));
1688                }
1689                Err(StoreError::ChecksumMismatch(_)) | Err(StoreError::NoSuchUpload(_)) => {
1690                    // Drift mid-compose, or the operator sweep aborted us:
1691                    // nothing published; the next barrier re-queues.
1692                    return Ok(UploadOutcome::Deferred);
1693                }
1694                // S3's answer to If-Match on a missing key (see upload_one):
1695                // the base vanished, and a vanished base is a create.
1696                Err(StoreError::NotFound(_)) if matches!(condition, PutCondition::IfMatch(_)) => {
1697                    condition = PutCondition::IfNoneMatchAny;
1698                    continue;
1699                }
1700                Err(StoreError::PreconditionFailed(_)) => {
1701                    // The AdoptOwn recognizer compares OUR bytes' checksum
1702                    // against what landed, and without the pre-pass we do
1703                    // not have one — so the file is read HERE, on the rare
1704                    // recovery path, instead of on every publish.
1705                    let crc = file_crc(local_path)?;
1706                    let head = match self.store.head(key).await {
1707                        Ok(h) => h,
1708                        Err(StoreError::NotFound(_)) => {
1709                            condition = PutCondition::IfNoneMatchAny;
1710                            continue;
1711                        }
1712                        Err(e) => return Err(e.into()),
1713                    };
1714                    let head_stamps = GenerationStamps::from_meta(&head.meta);
1715                    if head.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) {
1716                        // These bytes are already there (a torn Complete):
1717                        // cite them — re-read under the fence first.
1718                        let g = head_stamps.map(|s| s.generation).unwrap_or(generation);
1719                        return Ok(UploadOutcome::adopted(
1720                            path, key.to_string(), head.etag, crc, head.size, scanned, g, epoch,
1721                        ));
1722                    }
1723                    if attempt >= 1 {
1724                        return Ok(UploadOutcome::Parked { foreign_etag: head.etag });
1725                    }
1726                    let own = head_stamps
1727                        .as_ref()
1728                        .map(|s| s.flush_uuid == stamps.flush_uuid || prior_uuids.contains(&s.flush_uuid))
1729                        .unwrap_or(false);
1730                    if !own && self.preserve_foreign_412(path, &head).await.is_none() {
1731                        return Ok(UploadOutcome::Parked { foreign_etag: head.etag });
1732                    }
1733                    condition = PutCondition::IfMatch(head.etag);
1734                }
1735                Err(e) => return Err(e.into()),
1736            }
1737        }
1738        Ok(UploadOutcome::Deferred)
1739    }
1740
1741
1742    async fn upload_one(
1743        &self,
1744        path: &str,
1745        scanned: &scan::ScanEntry,
1746        base: Option<&BaselineEntry>,
1747        epoch: u64,
1748        flush_uuid: &str,
1749        prior_uuids: &[String],
1750    ) -> LeanResult<UploadOutcome> {
1751        let key = self.cfg.file_key(path);
1752        let local_path = self.cfg.root.join(path);
1753        let generation = base.map(|b| b.generation + 1).unwrap_or(1);
1754        // Review 2026-09-12, atomicity-6: the scan skipped symlinks; this
1755        // read followed them. A regular file swapped for a symlink between
1756        // the two published the link's TARGET — a file outside the
1757        // workspace, the syncer's own /proc/self/environ included. Every
1758        // stat here is an lstat and the read opens O_NOFOLLOW; a path that
1759        // is no longer a regular file is refused, recorded, and deferred
1760        // (the next scan skips it, and the two-scan rule retires it).
1761        let lmeta = std::fs::symlink_metadata(&local_path)
1762            .map_err(|e| LeanError::State(format!("stat {}: {e}", local_path.display())))?;
1763        if !lmeta.is_file() {
1764            self.state.append_conflict(&ConflictRecord {
1765                path: path.to_string(),
1766                foreign_etag: String::new(),
1767                preserved_key: None,
1768                kind: "upload-refused-not-regular: no longer a regular file after the scan (a symlink \
1769                       or special file replaced it); nothing published"
1770                    .into(),
1771                at_unix: now_unix(),
1772            })?;
1773            return Ok(UploadOutcome::Deferred);
1774        }
1775        let posix = Some(PosixStamps::from_metadata(&lmeta));
1776        let stamps = GenerationStamps {
1777            generation,
1778            epoch,
1779            flush_uuid: flush_uuid.to_string(),
1780            boundary_source: None,
1781            posix,
1782        };
1783        let condition = match base {
1784            Some(b) => PutCondition::IfMatch(b.etag.clone()),
1785            None => PutCondition::IfNoneMatchAny,
1786        };
1787        let size = lmeta.len();
1788        if size > self.cfg.whole_put_max {
1789            // Streaming multipart compose: put_whole is never fed past
1790            // whole_put_max (unbounded memory + S3's 5 GiB wall).
1791            return self
1792                .upload_compose(
1793                    path, &key, &local_path, size, scanned, condition, stamps, generation, epoch,
1794                    prior_uuids,
1795                )
1796                .await;
1797        }
1798        // The upload byte gate — the store's own, shared with the compose
1799        // arm above, which charges its parts inside the store. This body
1800        // is charged from before it is read (the read is the allocation)
1801        // until its PUT, retried or not, has returned: the permit lives
1802        // to the end of this function. `None` = a store with no gate.
1803        let _upload_permit = match self.store.upload_gate() {
1804            Some(g) => Some(g.acquire(size).await),
1805            None => None,
1806        };
1807        // `Bytes::from(Vec<u8>)` TAKES OWNERSHIP without copying, so the
1808        // old `Bytes::from(body.clone())` was a full memcpy of every
1809        // published file body — bought solely to leave `body` intact for
1810        // the 412 retry below, which is reached almost never. Build the
1811        // `Bytes` once; the retry gets a refcount clone.
1812        let body = Bytes::from(read_nofollow(&local_path)?);
1813        let uploaded_len = body.len() as u64;
1814        let crc = crc64_nvme(&body);
1815        match self
1816            .store
1817            .put_whole(&key, body.clone(), &condition, &stamps, crc)
1818            .await
1819        {
1820            Ok(meta) => Ok(UploadOutcome::published(
1821                path, key, meta.etag, crc, uploaded_len, scanned, generation, epoch,
1822            )),
1823            // S3 answers If-Match on a key that no longer exists with 404
1824            // NoSuchKey, not 412 (Ozone answers 412, and so did the
1825            // double): the base is gone — a peer's GC collected it, or a
1826            // bucket-level delete. The same rule as the 412 arm's vanished
1827            // HEAD below: a vanished base is a create. Without this arm the
1828            // rule never ran on S3, and a writer whose edited path a peer
1829            // deleted failed EVERY barrier (drill host leg H2, 2026-09-13).
1830            Err(StoreError::NotFound(_)) if matches!(condition, PutCondition::IfMatch(_)) => {
1831                let meta = self
1832                    .store
1833                    .put_whole(&key, body, &PutCondition::IfNoneMatchAny, &stamps, crc)
1834                    .await?;
1835                Ok(UploadOutcome::published(
1836                    path, key, meta.etag, crc, uploaded_len, scanned, generation, epoch,
1837                ))
1838            }
1839            Err(StoreError::PreconditionFailed(_)) => {
1840                // The 412 policy: my own crashed/torn PUT ⇒ adopt; a
1841                // foreign version ⇒ the consume-dirty rule, at upload time
1842                // (preserve it, then supersede it knowingly). NEVER the
1843                // inherited LOCAL-WINS overwrite, and never a park with
1844                // no way out.
1845                let head = match self.store.head(&key).await {
1846                    Ok(h) => h,
1847                    Err(StoreError::NotFound(_)) => {
1848                        // Review 2026-09-12, inbox-5: the base object is
1849                        // gone (a bucket-level delete, or our own GC after
1850                        // a crash between the CAS and the baseline
1851                        // rewrite). Failing the barrier here failed EVERY
1852                        // barrier, forever. A vanished base is a create.
1853                        let meta = self
1854                            .store
1855                            .put_whole(&key, body, &PutCondition::IfNoneMatchAny, &stamps, crc)
1856                            .await?;
1857                        return Ok(UploadOutcome::published(
1858                            path, key, meta.etag, crc, uploaded_len, scanned, generation, epoch,
1859                        ));
1860                    }
1861                    Err(e) => return Err(e.into()),
1862                };
1863                let head_stamps = GenerationStamps::from_meta(&head.meta);
1864                let own = head_stamps
1865                    .as_ref()
1866                    .map(|s| s.flush_uuid == flush_uuid || prior_uuids.contains(&s.flush_uuid))
1867                    .unwrap_or(false);
1868                if head.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) {
1869                    // Bytes already there (a torn response, ours or a
1870                    // foreign write of the same content): cite it — re-read
1871                    // under the fence first.
1872                    let g = head_stamps.map(|s| s.generation).unwrap_or(generation);
1873                    return Ok(UploadOutcome::adopted(
1874                        path, key, head.etag, crc, head.size, scanned, g, epoch,
1875                    ));
1876                }
1877                if !own && self.preserve_foreign_412(path, &head).await.is_none() {
1878                    return Ok(UploadOutcome::Parked { foreign_etag: head.etag });
1879                }
1880                // Our earlier PUT (older content), or a foreign version
1881                // now preserved: supersede it knowingly, If-Match on what
1882                // we saw. A second 412 means the path is being written
1883                // under us right now; that one parks.
1884                let meta = match self
1885                    .store
1886                    .put_whole(&key, body, &PutCondition::IfMatch(head.etag.clone()), &stamps, crc)
1887                    .await
1888                {
1889                    Ok(m) => m,
1890                    // 412 (a writer landed between) or 404 (a GC removed
1891                    // it between the HEAD and this PUT): written under us
1892                    // right now. Park; the next barrier retries.
1893                    Err(StoreError::PreconditionFailed(_)) | Err(StoreError::NotFound(_)) => {
1894                        return Ok(UploadOutcome::Parked { foreign_etag: head.etag });
1895                    }
1896                    Err(e) => return Err(e.into()),
1897                };
1898                Ok(UploadOutcome::published(
1899                    path, key, meta.etag, crc, uploaded_len, scanned, generation, epoch,
1900                ))
1901            }
1902            Err(e) => Err(e.into()),
1903        }
1904    }
1905
1906    /// A 412 against a version this syncer did not write, met at upload
1907    /// time. The contract's rule for a foreign write to a path the agent
1908    /// modified is the consume-dirty rule — the agent's version wins and
1909    /// the foreign bytes are preserved — and a "park" was that case with
1910    /// no resolution: nothing ever un-parked, and every ack said `ok`
1911    /// (review 2026-09-12, inbox-1). Preserve, record, and let the caller
1912    /// supersede. `None` = the preserve failed; the caller parks, and the
1913    /// boundary is `partial`.
1914    async fn preserve_foreign_412(&self, path: &str, head: &flint_store::ObjectMeta) -> Option<()> {
1915        let preserved = match self.preserve_conflict_copy(path, &head.etag).await {
1916            Ok(k) => k,
1917            Err(e) => {
1918                eprintln!("flint-sync: {path}: foreign version {} could not be preserved ({e}); parking", head.etag);
1919                return None;
1920            }
1921        };
1922        self.state
1923            .append_conflict(&ConflictRecord {
1924                path: path.to_string(),
1925                foreign_etag: head.etag.clone(),
1926                preserved_key: Some(preserved),
1927                kind: "upload-412-preserved".into(),
1928                at_unix: now_unix(),
1929            })
1930            .ok()?;
1931        Some(())
1932    }
1933}
1934
1935/// Read a workspace file for upload without following a symlink at the
1936/// final component, and refuse anything that is not a regular file once
1937/// open (review 2026-09-12, atomicity-6).
1938fn read_nofollow(p: &Path) -> std::io::Result<Vec<u8>> {
1939    use std::io::Read;
1940    use std::os::unix::fs::OpenOptionsExt;
1941    let mut f = std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW).open(p)?;
1942    if !f.metadata()?.is_file() {
1943        return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a regular file"));
1944    }
1945    let mut v = Vec::new();
1946    f.read_to_end(&mut v)?;
1947    Ok(v)
1948}
1949
1950enum UploadOutcome {
1951    /// `adopted`: the etag was OBSERVED at the key (bytes already there,
1952    /// no PUT of ours produced it), so the commit section must re-read it
1953    /// before citing — see `verify_observed_citations` in the barrier.
1954    Published { entry: LeanEntry, baseline_entry: BaselineEntry, adopted: bool },
1955    Parked { foreign_etag: String },
1956    /// The source drifted mid-transfer (checksum refused server-side)
1957    /// or the assembly was swept: publish nothing, advance nothing —
1958    /// the next scan re-queues the path.
1959    Deferred,
1960}
1961
1962impl UploadOutcome {
1963    #[allow(clippy::too_many_arguments)]
1964    fn published(
1965        path: &str,
1966        key: String,
1967        etag: String,
1968        crc: u64,
1969        uploaded_len: u64,
1970        scanned: &scan::ScanEntry,
1971        generation: u64,
1972        epoch: u64,
1973    ) -> UploadOutcome {
1974        let _ = path;
1975        UploadOutcome::Published {
1976            adopted: false,
1977            entry: LeanEntry {
1978                key,
1979                etag: etag.clone(),
1980                crc64_b64: crc64_to_b64(crc),
1981                // The length the object HAS (review 2026-09-12,
1982                // atomicity-1): the scanned size was cited while the
1983                // body was read fresh, so a file that grew between the
1984                // two was cited short and every fresh checkout of it
1985                // failed its CRC fold — the successor never started.
1986                size: uploaded_len,
1987                mode: scanned.mode,
1988                mtime_unix: scanned.mtime_unix,
1989                generation,
1990                epoch,
1991            },
1992            baseline_entry: BaselineEntry {
1993                etag,
1994                generation,
1995                // The PRE-read stat: if the agent wrote during our read,
1996                // the next scan sees the drift and re-queues (the
1997                // re-stat/re-queue valve).
1998                size: scanned.size,
1999                mtime_unix: scanned.mtime_unix,
2000                mtime_nanos: Some(scanned.mtime_nanos),
2001                crc64_b64: Some(crc64_to_b64(crc)),
2002            },
2003        }
2004    }
2005}
2006
2007impl UploadOutcome {
2008    /// `published`, for bytes this barrier found already at the key and
2009    /// cites without a PUT of its own.
2010    #[allow(clippy::too_many_arguments)]
2011    fn adopted(
2012        path: &str,
2013        key: String,
2014        etag: String,
2015        crc: u64,
2016        uploaded_len: u64,
2017        scanned: &scan::ScanEntry,
2018        generation: u64,
2019        epoch: u64,
2020    ) -> UploadOutcome {
2021        match UploadOutcome::published(path, key, etag, crc, uploaded_len, scanned, generation, epoch) {
2022            UploadOutcome::Published { entry, baseline_entry, .. } => {
2023                UploadOutcome::Published { entry, baseline_entry, adopted: true }
2024            }
2025            other => other,
2026        }
2027    }
2028}
2029
2030fn local_dirty(local: &Path, base: Option<&BaselineEntry>) -> bool {
2031    match (std::fs::metadata(local), base) {
2032        (Err(_), None) => false,                   // both absent: clean
2033        (Err(_), Some(_)) => true,                 // locally deleted vs baseline
2034        (Ok(_), None) => true,                     // local exists, never published
2035        (Ok(m), Some(b)) => {
2036            scan::stat_changed(b.size, b.mtime_unix, b.mtime_nanos, m.len(), mtime_of(&m), mtime_nanos_of(&m))
2037        }
2038    }
2039}
2040
2041/// Full-object CRC-64/NVME of a local file, streamed.
2042///
2043/// Reached only by the 412 recovery path: the ordinary publish gets its
2044/// checksum from the store, which computes one while uploading.
2045/// The CRC a citation repair cites: the bytes this syncer integrated,
2046/// as the baseline recorded them when it wrote or uploaded them. The
2047/// HEAD's own checksum is a cross-check when the backend offers one,
2048/// never the source — Ozone offers none, a HITL uploader may have sent
2049/// none, and the manifest must carry a CRC either way, because every
2050/// reader now verifies a fresh fetch against it.
2051///
2052/// A baseline entry with no CRC is refused loudly rather than cited
2053/// bare: the only entry built without one is the consume-dirty sentinel,
2054/// which the candidate filters exclude, so reaching this is a defect.
2055/// A HEAD that attests a DIFFERENT value is refused the same way — the
2056/// workspace holds bytes the object does not, and citing either would
2057/// make one reader or another refuse the path forever.
2058pub(crate) fn repair_crc(
2059    path: &str,
2060    be: &BaselineEntry,
2061    head: &flint_store::ObjectMeta,
2062) -> LeanResult<String> {
2063    let Some(ours) = be.crc64_b64.clone() else {
2064        return Err(LeanError::State(format!(
2065            "citation repair of {path}: the baseline records no CRC for the bytes it \
2066             integrated at etag {} — refusing to cite bytes nothing hashed",
2067            be.etag
2068        )));
2069    };
2070    if let Some(theirs) = head.crc64_b64.as_deref() {
2071        if theirs != ours {
2072            return Err(LeanError::State(format!(
2073                "citation repair of {path}: the store attests CRC-64 {theirs} for etag {} but \
2074                 the bytes this workspace integrated under that etag hash to {ours} — the \
2075                 workspace holds bytes the object does not; refusing to cite either",
2076                be.etag
2077            )));
2078        }
2079    }
2080    Ok(ours)
2081}
2082
2083fn file_crc(local_path: &Path) -> LeanResult<u64> {
2084    use std::io::Read;
2085    let mut crc = flint_store::Crc64Nvme::new();
2086    let mut f = std::fs::File::open(local_path)?;
2087    let mut buf = vec![0u8; 4 << 20];
2088    loop {
2089        let n = f.read(&mut buf)?;
2090        if n == 0 {
2091            break;
2092        }
2093        crc.update(&buf[..n]);
2094    }
2095    Ok(crc.finalize())
2096}
2097
2098pub(super) fn mtime_nanos_of(m: &std::fs::Metadata) -> u32 {
2099    m.modified()
2100        .ok()
2101        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2102        .map(|d| d.subsec_nanos())
2103        .unwrap_or(0)
2104}
2105
2106pub(super) fn mtime_of(m: &std::fs::Metadata) -> i64 {
2107    m.modified()
2108        .ok()
2109        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2110        .map(|d| d.as_secs() as i64)
2111        .unwrap_or(0)
2112}
2113
2114/// Containment-safe workspace write (boundary-verbs plan §2.2 security
2115/// gate). `rel` is a workspace-relative path; the target may not escape
2116/// the root, traverse a symlink component, or land in the reserved
2117/// control namespace.
2118///
2119/// The hazard this closes is pre-existing and reachable from three
2120/// callers (checkout, inbox consume, sync): `write_file_atomic` did
2121/// `create_dir_all(parent)` + write with no `O_NOFOLLOW` and no
2122/// root-containment check, while the scanner SKIPS symlinks — so a
2123/// planted symlink is invisible to the syncer. An unprivileged app
2124/// that plants `inputs -> /root/.aws`, lands an object at
2125/// `inputs/<path>` and drops a scoped sync turns the credential-holding
2126/// syncer into an on-demand arbitrary-file-write primitive outside the
2127/// workspace.
2128pub(super) fn write_file_atomic_in(
2129    root: &Path,
2130    rel: &str,
2131    bytes: &[u8],
2132    mode: Option<u32>,
2133) -> LeanResult<()> {
2134    let target = contained_path(root, rel)?;
2135    write_file_atomic(&target, bytes, mode).map(|_| ())
2136}
2137
2138/// Validate `rel` under `root` WITHOUT creating anything — for callers
2139/// that must decide whether a path is writable before doing any work
2140/// (inbox consume refuses a refused path outright rather than routing it
2141/// through conflict-preserve, which would cost a GET+PUT and leave the
2142/// path in the baseline).
2143pub(super) fn check_contained(root: &Path, rel: &str) -> LeanResult<()> {
2144    resolve_contained(root, rel, false).map(|_| ())
2145}
2146
2147/// Resolve `rel` under `root`, refusing escapes, creating missing parent
2148/// directories. Walks the parent chain component by component: a
2149/// component that EXISTS as a symlink is a refusal (never followed).
2150pub(super) fn contained_path(root: &Path, rel: &str) -> LeanResult<std::path::PathBuf> {
2151    resolve_contained(root, rel, true).map(|(p, _)| p)
2152}
2153
2154/// As `contained_path`, and also what the walk already learned about
2155/// the FINAL component: its `lstat` if it exists, `None` if it does
2156/// not. The walk stats every component to refuse a symlink; a caller
2157/// that then asked `exists()` and `metadata()` paid two more stats for
2158/// an answer it was already holding (checkout's resume check did, on
2159/// every one of 20,000 files).
2160pub(super) fn contained_path_stat(
2161    root: &Path,
2162    rel: &str,
2163) -> LeanResult<(std::path::PathBuf, Option<std::fs::Metadata>)> {
2164    resolve_contained(root, rel, true)
2165}
2166
2167fn resolve_contained(
2168    root: &Path,
2169    rel: &str,
2170    create_dirs: bool,
2171) -> LeanResult<(std::path::PathBuf, Option<std::fs::Metadata>)> {
2172    use std::path::Component;
2173    let refuse = |why: &str| {
2174        Err(LeanError::State(format!(
2175            "refusing write to {rel:?}: {why} (containment)"
2176        )))
2177    };
2178    if rel.is_empty() {
2179        return refuse("empty path");
2180    }
2181    if super::scan::is_control_path(rel) {
2182        // The reserved namespace is the syncer's own; a citation or
2183        // inbox entry naming it is surfaced, never materialized (D0.3).
2184        return refuse("reserved control namespace");
2185    }
2186    let relp = Path::new(rel);
2187    let mut cur = root.to_path_buf();
2188    let mut comps: Vec<&std::ffi::OsStr> = vec![];
2189    for c in relp.components() {
2190        match c {
2191            Component::Normal(n) => comps.push(n),
2192            Component::CurDir => {}
2193            Component::ParentDir => return refuse("`..` component"),
2194            Component::RootDir | Component::Prefix(_) => return refuse("absolute path"),
2195        }
2196    }
2197    if comps.is_empty() {
2198        return refuse("no path components");
2199    }
2200    let last = comps.len() - 1;
2201    let mut final_meta = None;
2202    for (i, c) in comps.iter().enumerate() {
2203        cur.push(c);
2204        match std::fs::symlink_metadata(&cur) {
2205            Ok(m) if m.file_type().is_symlink() => {
2206                return refuse("path traverses a symlink");
2207            }
2208            Ok(m) if i < last && !m.is_dir() => {
2209                return refuse("parent component is not a directory");
2210            }
2211            Ok(m) => {
2212                if i == last {
2213                    final_meta = Some(m);
2214                }
2215            }
2216            Err(_) if i < last => {
2217                if create_dirs {
2218                    if let Err(e) = std::fs::create_dir(&cur) {
2219                        // A SIBLING may have created it between the stat
2220                        // above and this mkdir: fetches run on their own
2221                        // tasks, so the first files of one directory
2222                        // race here, and the loser's EEXIST is not a
2223                        // containment refusal. It is fine only if what
2224                        // exists now is a plain directory — a symlink
2225                        // that appeared in the window is refused exactly
2226                        // as one the stat found.
2227                        match std::fs::symlink_metadata(&cur) {
2228                            Ok(m) if m.file_type().is_symlink() => {
2229                                return refuse("path traverses a symlink");
2230                            }
2231                            Ok(m) if m.is_dir() => {}
2232                            _ => {
2233                                return Err(LeanError::State(format!(
2234                                    "mkdir {}: {e}",
2235                                    cur.display()
2236                                )))
2237                            }
2238                        }
2239                    }
2240                }
2241            }
2242            Err(_) => {}
2243        }
2244    }
2245    Ok((cur, final_meta))
2246}
2247
2248/// Write `bytes` to `path` through an exclusive temp sibling and a
2249/// rename. The parent already exists — containment created it — and
2250/// one that vanished since is recreated on the retry path in `safefs`,
2251/// so the common path pays no `mkdir` and no `stat` for it. Returns
2252/// the written file's metadata.
2253pub(super) fn write_file_atomic(
2254    path: &Path,
2255    bytes: &[u8],
2256    mode: Option<u32>,
2257) -> LeanResult<std::fs::Metadata> {
2258    super::safefs::check_parent(path)?;
2259    // NOT with_extension(): that REPLACES the final extension, so
2260    // "a.txt" and "a.md" would collide on one tmp name.
2261    let tmp = path.with_file_name(format!(
2262        "{}.flint-sync-tmp",
2263        path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()
2264    ));
2265    // The temp sibling is computed AFTER containment ran, so the walk
2266    // never saw it: it needs its own refusal, not a plain write.
2267    // FAST (no per-file fsync): materialisations are made durable by
2268    // `sync_tree` before the marker or baseline that vouches for them.
2269    super::safefs::write_via_tmp_fast(path, &tmp, bytes, mode)
2270}
2271
2272/// The queue form of a merge's foreign upserts and deletions.
2273fn foreign_changes(entries: &[(String, LeanEntry)], gone: &[String]) -> Vec<ForeignChange> {
2274    entries
2275        .iter()
2276        .map(|(path, e)| ForeignChange {
2277            path: path.clone(),
2278            etag: Some(e.etag.clone()),
2279            crc64_b64: Some(e.crc64_b64.clone()),
2280        })
2281        .chain(gone.iter().map(|path| ForeignChange { path: path.clone(), etag: None, crc64_b64: None }))
2282        .collect()
2283}