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::{crc64_nvme, crc64_to_b64, GenerationStamps, PosixStamps, PutCondition, StoreError};
12
13use super::inbox;
14use super::manifest::{self, LeanEntry};
15use super::scan;
16use super::state::{BaselineEntry, Carrier, ConflictRecord, IntentJournal};
17use super::{now_unix, LeanError, LeanResult, Syncer};
18
19/// The last gateway request this workspace acted on, per verb. A
20/// watermark rather than a consumed queue — see `note_verb_requests`.
21#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
22pub struct VerbWatermark {
23    #[serde(default)]
24    pub boundary_unix: u64,
25    #[serde(default)]
26    pub sync_unix: u64,
27}
28
29#[derive(Debug, Default)]
30pub struct BarrierReport {
31    pub seq: Option<u64>,
32    pub uploaded: Vec<String>,
33    pub deleted: Vec<String>,
34    /// Paths a DECLARED removal (delete/rename design) cited out this
35    /// barrier — a subset of `deleted` once the GC has run.
36    pub removed: Vec<String>,
37    /// Declared removals this barrier REFUSED (locally dirty, not a
38    /// file, uncontainable); the cell carries the reason.
39    pub removals_refused: usize,
40    pub parked: Vec<String>,
41    pub consumed: usize,
42    pub foreign_queued: usize,
43    /// Paths whose transfer drifted or was swept mid-compose: nothing
44    /// published, nothing advanced; the next scan re-queues them.
45    pub deferred: Vec<String>,
46    /// Paths whose object the collector LEFT BEHIND because this store
47    /// does not enforce `If-Match` on DELETE (`conformance.rs`). The
48    /// delete still reached the boundary; only the object survives.
49    ///
50    /// NOT added to `deleted`, and so NOT dropped from the merge base:
51    /// the rule step 7 follows is "drop the entry once the key no longer
52    /// holds our bytes", which is why the absent and replaced-absent
53    /// arms drop it and the skipped-etag arm does not. A leaked key
54    /// still holds them. The model names that rule
55    /// `BaselineKeepsUncollected` and it is what ships, so a leak
56    /// follows the arm that already exists rather than inventing a
57    /// third; the cost is that the collector re-offers the path (one
58    /// HEAD) at every later barrier, exactly as a skipped one is.
59    pub leaked: Vec<String>,
60    /// Paths this barrier published OVER a version it never integrated;
61    /// each has a conflict record naming the preserved copy (R7).
62    pub surfaced: Vec<String>,
63    /// Retired handles the batch delete took.
64    pub collected: usize,
65    /// Orphan handles the sweep took, when it was due.
66    pub swept: usize,
67    pub no_change: bool,
68    /// Bytes this barrier actually published — the input to the
69    /// sentinel work meter (boundary-verbs plan D3.1). Metering work
70    /// rather than calls is what keeps a sentinel storm from
71    /// un-coalescing a hot large file's republish: a counted budget
72    /// charges a 2 GiB checkpoint the same one unit as a 4 KiB file.
73    pub published_bytes: u64,
74    /// The manifest ETag this barrier installed (the ack's CAS token).
75    pub manifest_etag: Option<String>,
76    /// What the manifest HEAD/install told us the bucket is at, for the
77    /// news ticker (D5) — free, off requests the barrier already made.
78    pub observed_seq: Option<u64>,
79    pub observed_etag: Option<String>,
80    /// Paths this barrier saw absent for the FIRST time: their deletes
81    /// are withheld to the next scan (the rename-vs-walk guard). On a
82    /// declared boundary a non-empty set means the confirmation lstat
83    /// found them back on disk — the race the guard exists for.
84    pub first_absence: Vec<String>,
85    /// First-absence paths a DECLARED boundary confirmed gone by direct
86    /// lstat and published as ordinary deletes (`confirm_absences`).
87    pub absences_confirmed: usize,
88    /// This barrier finished a rescope a crash had left half-applied
89    /// before it did anything else. Reported so a run that looks like
90    /// an ordinary barrier can be told from one that had to converge a
91    /// workspace first.
92    pub rescope_replayed: bool,
93}
94
95/// Upload waves per chunk. The chunk is `fanout * this`, so a wave
96/// still saturates fan-out and the sync point between chunks is rare.
97const UPLOAD_CHUNK_WAVES: usize = 16;
98
99
100
101/// What a consume's read of the document established, for the barrier's
102/// no-change fast path (which must not pay for the same read twice).
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum Seen {
105    /// The pointer at this (seq, etag).
106    Pointer(u64, String),
107    /// Neither the pointer nor the legacy object exists: nothing has ever
108    /// been published here. Both reads happened in this pass.
109    Nothing,
110    /// A legacy single-object document (no pointer); the fast path asks.
111    Legacy,
112}
113
114impl Syncer {
115    /// Verify the cell still names us at OUR epoch; anything else is a
116    /// fence. Read-verify before the manifest CAS (the per-request
117    /// validation the gateway will also enforce).
118    async fn verify_not_deposed(&self) -> LeanResult<()> {
119        let lease = self
120            .lease
121            .as_ref()
122            .ok_or_else(|| LeanError::State("no lease".into()))?;
123        match self.store.epoch_read(&self.cfg.epoch_key()).await? {
124            Some(state) if state.epoch == lease.epoch && state.holder_id == lease.holder_id => {
125                Ok(())
126            }
127            Some(state) => Err(LeanError::Fenced(format!(
128                "cell at epoch {} holder {} (we are epoch {})",
129                state.epoch, state.holder_id, lease.epoch
130            ))),
131            None => Err(LeanError::Fenced("epoch cell vanished".into())),
132        }
133    }
134
135    /// Step 1 (P1-lite, 2026-09-25): what the tree is OWED, derived from
136    /// the document and taken in one pass. A path is owed where the
137    /// document differs from the baseline — which is also the merge base —
138    /// and the tree is clean there; only a path the tree HOLDS or its
139    /// scope covers (what the scope declined stays declined). Nothing is
140    /// stored between deriving it and taking it, so nothing can be
141    /// overtaken: the queue this replaces needed a prune for that (L-123).
142    /// A DIRTY path is the agent's newer work and is not owed: it
143    /// publishes, and the merge records what it publishes over (R7, the
144    /// delete override). Unless its bytes already ARE the document's —
145    /// a restart between this writer's CAS and step 7 leaves exactly
146    /// that — and then the baseline simply follows (content convergence).
147    /// Returns how many paths it took, and what its read of the document
148    /// established ([`Seen`]) — the barrier's fast path needs exactly that,
149    /// and must not pay a second GET for it (an idle tick is the cell and
150    /// the pointer).
151    pub async fn consume_owed(&mut self) -> LeanResult<(usize, Seen)> {
152        let mut baseline = self.state.load_baseline()?;
153        // THE CHEAP PATH (scan trigger). Only three things make a path
154        // newly owed: the document moved (the pointer is not the one last
155        // derived against — a peer, a UI save, or this tree's own commit
156        // over a document that had moved), the baseline moved (only this
157        // tree's own commit, which moves the pointer too), or the agent
158        // backed out of a path the last derive skipped as its work. So: one
159        // small GET, a stat per skipped path, never the entries.
160        //
161        // The pointer read here is handed to the load below rather than
162        // read again: a moved pointer needs the document behind THAT read,
163        // and an absent one sends the load straight to the legacy key.
164        let mut read: Option<Option<manifest::LoadedPointer>> = None;
165        if let Some(d) = baseline.derived_etag.clone() {
166            let lp = manifest::load_pointer(self.store.as_ref(), &self.cfg).await?;
167            if let Some(p) = &lp {
168                let still_theirs = baseline.skipped.iter().all(|path| {
169                    check_contained(&self.cfg.root, path).is_ok()
170                        && local_dirty(&self.cfg.root.join(path), baseline.entries.get(path))
171                });
172                if d == p.etag && still_theirs {
173                    // The document this consume derived against: a replay of
174                    // several writers' traces (formal/trace/storm2conf.py)
175                    // places the consume after the install that wrote it.
176                    self.trace("derive", serde_json::json!({"seq": p.pointer.seq, "cheap": true}));
177                    return Ok((0, Seen::Pointer(p.pointer.seq, p.etag.clone())));
178                }
179            }
180            read = Some(lp);
181        }
182        let Some(current) = manifest::load_from(self.store.as_ref(), &self.cfg, read).await? else {
183            // Neither the pointer nor the legacy object exists: nothing
184            // has ever been published here, and this pass read both.
185            return Ok((0, Seen::Nothing));
186        };
187        let doc = &current.manifest;
188        self.trace("derive", serde_json::json!({"seq": doc.seq, "cheap": false}));
189        // H10: whatever this barrier does next — the fast path included —
190        // the ack names this document or a later one.
191        self.note_carrier_uncited(doc)?;
192        let scope = self.state.load_scope()?.map(|v| super::sync::Scope::new(&v));
193        let same = |a: &str, b: &str| a.trim_matches('"') == b.trim_matches('"');
194        let mut taken = 0usize;
195        // Something owed could not be taken now (a fetch that lost to a
196        // newer document, an unreadable path, a failed write): the next
197        // consume derives again even if the pointer does not move.
198        let mut left = false;
199        // What is not owed because the agent is working on it: owed the
200        // moment it backs out, which the cheap path checks.
201        let mut skipped: BTreeSet<String> = BTreeSet::new();
202
203        // Deletions first: a document that turned the file `build` into
204        // the directory `build/log.txt` owes both, and the file must go
205        // before the directory can be made.
206        let gone: Vec<String> =
207            baseline.entries.keys().filter(|p| !doc.entries.contains_key(*p)).cloned().collect();
208        for path in gone {
209            if check_contained(&self.cfg.root, &path).is_err() {
210                // Never materializable here, so nothing to remove.
211                baseline.entries.remove(&path);
212                baseline.prev_scan.remove(&path);
213                continue;
214            }
215            let local = self.cfg.root.join(&path);
216            let be = baseline.entries.get(&path).cloned();
217            let record = |kind: String| ConflictRecord {
218                path: path.clone(),
219                foreign_etag: String::new(),
220                preserved_key: None,
221                kind,
222                at_unix: now_unix(),
223            };
224            match std::fs::symlink_metadata(&local) {
225                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
226                    self.trace("tombstone", serde_json::json!({"path": path, "action": "absent"}));
227                }
228                Err(e) => {
229                    // Unreadable is not absent.
230                    self.trace("tombstone", serde_json::json!({"path": path, "action": "deferred"}));
231                    self.state.append_conflict(&record(format!(
232                        "consume-foreign-delete-deferred: cannot stat the path: {e}"
233                    )))?;
234                    left = true;
235                    continue;
236                }
237                Ok(m) if !m.is_file() || local_dirty(&local, be.as_ref()) => {
238                    // The agent's newer work: not owed. It publishes over
239                    // the delete, with a record (`commit-recreated-deleted`).
240                    // Owed again if the agent backs out.
241                    self.trace("tombstone", serde_json::json!({"path": path, "action": "kept-dirty"}));
242                    skipped.insert(path.clone());
243                    continue;
244                }
245                Ok(_) => {
246                    if let Err(e) = std::fs::remove_file(&local) {
247                        self.state.append_conflict(&record(format!(
248                            "consume-foreign-delete-failed (will retry): {e}"
249                        )))?;
250                        left = true;
251                        continue;
252                    }
253                    // CONFIRM: only NotFound is absence.
254                    if !matches!(
255                        std::fs::symlink_metadata(&local),
256                        Err(ref e) if e.kind() == std::io::ErrorKind::NotFound
257                    ) {
258                        self.state.append_conflict(&record(
259                            "consume-foreign-delete-unconfirmed (will retry)".into(),
260                        ))?;
261                        left = true;
262                        continue;
263                    }
264                    self.trace("tombstone", serde_json::json!({"path": path, "action": "removed"}));
265                    taken += 1;
266                }
267            }
268            baseline.entries.remove(&path);
269            baseline.prev_scan.remove(&path);
270        }
271
272        for (path, e) in &doc.entries {
273            let be = baseline.entries.get(path).cloned();
274            if be.as_ref().is_some_and(|b| same(&b.etag, &e.etag)) {
275                continue;
276            }
277            let held = be.is_some() || scope.as_ref().is_none_or(|s| s.covers(path));
278            if !held {
279                continue;
280            }
281            if let Err(err) = check_contained(&self.cfg.root, path) {
282                // A path this tree can never safely materialise: surfaced,
283                // and not left owed — nothing here will ever take it.
284                self.state.append_conflict(&ConflictRecord {
285                    path: path.clone(),
286                    foreign_etag: e.etag.clone(),
287                    preserved_key: None,
288                    kind: format!("consume-refused-containment: {err}"),
289                    at_unix: now_unix(),
290                })?;
291                self.trace("consume", serde_json::json!({"path": path, "etag": e.etag, "from": "manifest", "action": "refused"}));
292                continue;
293            }
294            let local = self.cfg.root.join(path);
295            if local_dirty(&local, be.as_ref()) {
296                // CONTENT CONVERGENCE: the tree's bytes are the document's.
297                let converged = std::fs::symlink_metadata(&local).is_ok_and(|m| m.is_file() && m.len() == e.size)
298                    && file_crc(&self.cfg.root, path).is_ok_and(|c| same(&crc64_to_b64(c), &e.crc64_b64));
299                if converged {
300                    let st = std::fs::metadata(&local)?;
301                    baseline.entries.insert(
302                        path.clone(),
303                        BaselineEntry {
304                            etag: e.etag.clone(),
305                            key: Some(e.key.clone()),
306                            generation: e.generation,
307                            size: st.len(),
308                            mtime_unix: mtime_of(&st),
309                            mtime_nanos: Some(mtime_nanos_of(&st)),
310                            crc64_b64: Some(e.crc64_b64.clone()),
311                        },
312                    );
313                    baseline.prev_scan.insert(path.clone());
314                    self.trace("consume", serde_json::json!({"path": path, "etag": e.etag, "from": "manifest", "action": "converged"}));
315                    taken += 1;
316                } else {
317                    // The agent's work, not owed — until it backs out.
318                    skipped.insert(path.clone());
319                }
320                continue;
321            }
322            let (meta, body) = match self.store.get_whole(&e.key, Some(&e.etag)).await {
323                Ok(got) => got,
324                // A newer document replaced it (and its collector took the
325                // handle): owed again against that one.
326                Err(StoreError::NotFound(_)) | Err(StoreError::PreconditionFailed(_)) => {
327                    self.trace("consume", serde_json::json!({"path": path, "etag": e.etag, "from": "manifest", "action": "missing"}));
328                    left = true;
329                    continue;
330                }
331                Err(err) => return Err(err.into()),
332            };
333            // VERIFIED before it is written, as checkout's fetch is: against
334            // the CRC the document cites, else the backend's attestation.
335            let got = crc64_to_b64(crc64_nvme(&body));
336            let want = Some(e.crc64_b64.clone()).filter(|c| !c.is_empty()).or_else(|| meta.crc64_b64.clone());
337            if let Some(want) = want {
338                if want != got {
339                    self.state.append_conflict(&ConflictRecord {
340                        path: path.clone(),
341                        foreign_etag: e.etag.clone(),
342                        preserved_key: None,
343                        kind: format!(
344                            "consume-refused-checksum: etag {} is cited with CRC-64 {want} but the \
345                             bytes fetched under it hash to {got}",
346                            e.etag
347                        ),
348                        at_unix: now_unix(),
349                    })?;
350                    self.trace("consume", serde_json::json!({"path": path, "etag": e.etag, "from": "manifest", "action": "refused-checksum"}));
351                    left = true;
352                    continue;
353                }
354            }
355            let mode = PosixStamps::from_meta(&meta.meta).map(|p| p.mode);
356            consume_window("before-write", path);
357            // H7: the licence to overwrite is re-checked with the temp
358            // written, immediately before the rename; a write since is the
359            // agent's, and it stays (and publishes).
360            let still_clean = || !local_dirty(&local, be.as_ref());
361            let st = match contained_path(&self.cfg.root, path)
362                .and_then(|target| write_file_atomic_if(&target, &body, mode, &still_clean))
363            {
364                Ok(Some(st)) => st,
365                // The agent wrote first: as a dirty path above.
366                Ok(None) => {
367                    skipped.insert(path.clone());
368                    continue;
369                }
370                Err(err) => {
371                    // TRANSIENT (a full disk is not a planted symlink): the
372                    // path stays owed and the record repeats.
373                    self.state.append_conflict(&ConflictRecord {
374                        path: path.clone(),
375                        foreign_etag: e.etag.clone(),
376                        preserved_key: None,
377                        kind: format!("consume-write-failed (will retry): {err}"),
378                        at_unix: now_unix(),
379                    })?;
380                    self.trace("consume", serde_json::json!({"path": path, "etag": e.etag, "from": "manifest", "action": "deferred"}));
381                    left = true;
382                    continue;
383                }
384            };
385            consume_window("after-rename", path);
386            // The baseline records the inode this consume WROTE (H7).
387            let stamps = GenerationStamps::from_meta(&meta.meta);
388            baseline.entries.insert(
389                path.clone(),
390                BaselineEntry {
391                    etag: meta.etag.clone(),
392                    key: Some(e.key.clone()),
393                    generation: stamps.map(|s| s.generation).unwrap_or(e.generation),
394                    size: st.len(),
395                    mtime_unix: mtime_of(&st),
396                    mtime_nanos: Some(mtime_nanos_of(&st)),
397                    crc64_b64: Some(got),
398                },
399            );
400            baseline.prev_scan.insert(path.clone());
401            self.trace("consume", serde_json::json!({"path": path, "etag": e.etag, "from": "manifest", "action": "adopted"}));
402            taken += 1;
403        }
404        baseline.derived_etag = (!left).then(|| current.etag.clone());
405        baseline.skipped = skipped;
406        if !left {
407            // Everything owed is taken: the tree is integrated with this
408            // document (its dirty paths are the agent's, about to publish).
409            baseline.seq = doc.seq;
410            baseline.manifest_etag = Some(current.etag.clone());
411        }
412        // The files this baseline vouches for reached the disk before the
413        // baseline does (audit 2026-09-03, finding 9).
414        self.state.sync_tree()?;
415        self.state.save_baseline(&baseline)?;
416        // The pointer as this load saw it (a legacy single object has none,
417        // and the fast path asks the bucket itself).
418        let seen = match &current.pointer {
419            Some(_) => Seen::Pointer(doc.seq, current.etag.clone()),
420            None => Seen::Legacy,
421        };
422        Ok((taken, seen))
423    }
424
425    /// The cell's verb requests alone (§2.5), as a barrier's step 1 reads
426    /// them: no consume, no fetch.
427    pub async fn read_cell_requests(&mut self) -> LeanResult<()> {
428        let doc = inbox::load(self.store.as_ref(), &self.cfg).await?.doc;
429        self.note_verb_requests(&doc)
430    }
431
432    /// Act on the inbox document's two request fields (§2.5, D14).
433    ///
434    /// The asymmetry is deliberate and is about blast radius, not
435    /// principle: a boundary request is PERFORMED (it publishes what is
436    /// already on disk and mutates nothing local), a sync request is
437    /// CARRIED into the ticker as advisory news and the agent decides.
438    ///
439    /// Both are watermarked by `requested_unix` rather than consumed by
440    /// a clearing CAS. The field is idempotent state, so a repeat is a
441    /// no-op and a lost clear cannot double-publish; and not writing
442    /// means the doors cost no request at all, which is what §2.5
443    /// promises.
444    fn note_verb_requests(&mut self, doc: &inbox::InboxDoc) -> LeanResult<()> {
445        let mut mark = self.load_verb_watermark()?;
446        if let Some(r) = &doc.sync_request {
447            if r.requested_unix > mark.sync_unix {
448                self.carry_sync_request(r.requested_unix, &r.requestor)?;
449                mark.sync_unix = r.requested_unix;
450            }
451        }
452        if let Some(r) = &doc.boundary_request {
453            if r.requested_unix > mark.boundary_unix {
454                self.request_boundary(
455                    &format!("gw:{}:{}", r.requested_unix, r.requestor),
456                    Some(format!("boundary requested by {}", r.requestor)),
457                )?;
458                mark.boundary_unix = r.requested_unix;
459            }
460        }
461        self.save_verb_watermark(&mark)
462    }
463
464    fn verb_watermark_path(&self) -> std::path::PathBuf {
465        self.cfg.state_dir().join("verb-requests.json")
466    }
467
468    pub(crate) fn load_verb_watermark(&self) -> LeanResult<VerbWatermark> {
469        let p = self.verb_watermark_path();
470        if !p.exists() {
471            return Ok(VerbWatermark::default());
472        }
473        Ok(std::fs::read(&p)
474            .ok()
475            .and_then(|b| serde_json::from_slice(&b).ok())
476            .unwrap_or_default())
477    }
478
479    fn save_verb_watermark(&self, m: &VerbWatermark) -> LeanResult<()> {
480        let bytes = serde_json::to_vec_pretty(m)
481            .map_err(|e| LeanError::State(format!("verb watermark: {e}")))?;
482        super::control::write_atomic(&self.verb_watermark_path(), &bytes)
483    }
484
485    /// The consume's dirty case: the agent's local version wins; the
486    /// FOREIGN bytes are preserved first (a conflict record keeps both
487    /// recoverable), then the recognized ETag advances so the eventual
488    /// publish supersedes them KNOWINGLY.
489    /// Preserve the bytes at `key` (a handle, or an ingress object) under
490    /// `conflicts/<uuid>/<path>`, by a server-side copy guarded on `etag`:
491    /// no bytes cross this process, and a 10 GB checkpoint is preserved
492    /// in one request. The copy is what a conflict record names, and what
493    /// outlives the handle once the collector or the orphan sweep takes it.
494    async fn preserve_conflict_copy(&self, path: &str, key: &str, etag: &str) -> LeanResult<String> {
495        let dst = self.cfg.conflict_key(&uuid::Uuid::new_v4().to_string(), path);
496        let stamps = GenerationStamps {
497            generation: 0,
498            epoch: self
499                .lease
500                .as_ref()
501                .map(|l| l.epoch)
502                .or_else(|| self.state.load_incarnation().ok().flatten().map(|i| i.epoch))
503                .unwrap_or(0),
504            flush_uuid: "conflict-preserve".into(),
505            boundary_source: None,
506            posix: None,
507        };
508        self.store
509            .copy_object(key, Some(etag), &dst, &PutCondition::IfNoneMatchAny, &stamps)
510            .await?;
511        Ok(dst)
512    }
513
514    /// Take the SECOND absence observation now, by direct `lstat`.
515    ///
516    /// `scan::classify` withholds a path's delete until absence has
517    /// survived two consecutive scans — the rename-vs-walk race guard
518    /// (`lib.rs:37`). The hazard that rule names is the WALK missing a
519    /// file renamed under it, not deletion itself. On the cadence path
520    /// the second walk arrives at the next floor tick and nobody has
521    /// been promised otherwise. On a DECLARED boundary it cannot wait:
522    /// the ack would claim a coherent point (D1 — "everything
523    /// ordered-before T") while the manifest still cites a file the
524    /// agent removed before it touched the sentinel, and it would say
525    /// so with `report.deleted: 0` and `status: "ok"`.
526    ///
527    /// A direct `lstat` is exactly what the rename-vs-walk guard asks
528    /// for and is immune to the walk race by construction, so the
529    /// second observation costs one syscall per transiently-absent
530    /// path — never a second full pass. The cadence path is unchanged
531    /// and still waits for the second walk.
532    pub(crate) fn confirm_absences(&self, classified: &mut scan::Classified) -> LeanResult<usize> {
533        if classified.first_absence.is_empty() {
534            return Ok(0);
535        }
536        let mut confirmed = 0;
537        for path in std::mem::take(&mut classified.first_absence) {
538            // ONLY NotFound is absence. Every other errno — EACCES on a
539            // parent, EIO, EMFILE, ELOOP — used to read as "the agent
540            // deleted it", and this is the oracle that PUBLISHES the
541            // delete and then DELETES THE OBJECT. An unreadable file is
542            // the one case where guessing is unrecoverable, so it fails
543            // CLOSED, exactly as the walk does at `scan.rs`'s
544            // `symlink_metadata(&path)?`. Two call sites, one syscall:
545            // they must not have opposite error policies.
546            let gone = match std::fs::symlink_metadata(self.cfg.root.join(&path)) {
547                Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
548                Err(e) => {
549                    classified.first_absence.insert(path.clone());
550                    return Err(LeanError::State(format!(
551                        "cannot confirm the absence of {path:?}: {e} — refusing to publish \
552                         a deletion on an unreadable path"
553                    )));
554                }
555                Ok(_) => false,
556            };
557            if gone {
558                classified.deletes.insert(path);
559                confirmed += 1;
560            } else {
561                // Back on disk: the rename-vs-walk race, caught exactly
562                // as the rule intends. Still only a first absence.
563                classified.first_absence.insert(path);
564            }
565        }
566        Ok(confirmed)
567    }
568
569    /// Steps 2–7. `barrier` = one full publish cycle (the cadence arm).
570    pub async fn run_barrier(&mut self) -> LeanResult<BarrierReport> {
571        self.barrier_inner(false, "manual").await
572    }
573
574    /// The floor tick's barrier. Stamped `cadence` so the bucket can
575    /// answer "which clock installed this boundary?" without the ack —
576    /// the ack is a LOCAL file, and the fleet-visible question is asked
577    /// by operators and by the gateway's `/status`, neither of which
578    /// can see it.
579    pub async fn cadence_barrier(&mut self) -> LeanResult<BarrierReport> {
580        self.barrier_inner(false, "cadence").await
581    }
582
583    /// A barrier whose result somebody is going to ACK: a sentinel
584    /// honor (D1) or the preStop drain (D10). Identical to
585    /// `run_barrier` except that it confirms first-absence paths rather
586    /// than acking a boundary that withholds them.
587    pub async fn declared_barrier(&mut self) -> LeanResult<BarrierReport> {
588        self.barrier_inner(true, "sentinel").await
589    }
590
591    /// `declared_barrier` under an explicit provenance stamp — the
592    /// drain and the budget-deferred honor are the same barrier
593    /// reporting a different clock.
594    pub async fn declared_barrier_as(&mut self, source: &str) -> LeanResult<BarrierReport> {
595        self.barrier_inner(true, source).await
596    }
597
598    async fn barrier_inner(&mut self, declared: bool, source: &str) -> LeanResult<BarrierReport> {
599        // Every publishing path ends here — cadence, sentinel, drain, the
600        // one-shot verb — so a reader is refused here before the first
601        // request, whichever of them a caller reached. The callers that
602        // owe an answer (the publish sentinel, the drain) give it before
603        // this; this is the backstop for the ones that do not.
604        self.refuse_if_read("barrier")?;
605        // Review 2026-09-18, H10: a barrier that begins while a publish
606        // declaration stands CARRIES it, whoever called — the floor's
607        // cadence barrier after a failed honor as much as the honor itself
608        // (the model's `DSet` and `InstallSource` read the live pending,
609        // not the caller). So it confirms first absences as a declared
610        // barrier does, stamps the deferred clock, and journals itself
611        // with its CAS as the install the ack names (`IntentJournal::
612        // carrier`). A record written before the id field has none and
613        // carries nothing.
614        // Step 1's first half: the cell's verb requests (§2.5's doors ride
615        // this GET), read BEFORE the carry is decided. Read after it, a
616        // gateway `boundary_request` folded into the pending here was not
617        // carried by the barrier that read it: its install journalled no
618        // carrier, and a peer's delete of a path it published, before the
619        // honour, was answered `ok` (L-135, 2026-10-14). A failure here
620        // must never fail the barrier: the request is idempotent state and
621        // the next tick re-reads it.
622        let inbox_doc = inbox::load(self.store.as_ref(), &self.cfg).await?.doc;
623        if let Err(e) = self.note_verb_requests(&inbox_doc) {
624            eprintln!("flint-sync: verb request not consumed (retrying next tick): {e}");
625        }
626        let carries: Option<String> = self
627            .load_pending(super::sentinel::Verb::Publish)
628            .ok()
629            .flatten()
630            .map(|p| p.id)
631            .filter(|id| !id.is_empty());
632        let declared = declared || carries.is_some();
633        let source = if carries.is_some() && source == "cadence" { "sentinel-deferred" } else { source };
634        // No lease is held until the commit section (design 2026-09-13
635        // §4): the consume, the scan and the uploads below run with the
636        // cell at rest, guarded by each object's own etag. What the
637        // uploads are STAMPED with is the last epoch this incarnation
638        // held — informational, like every epoch stamp on an object; the
639        // manifest entries carry the commit section's real epoch.
640        let epoch_hint = self.state.load_incarnation()?.map(|i| i.epoch).unwrap_or(0);
641        let mut report = BarrierReport::default();
642        let barrier_started = std::time::Instant::now();
643        self.trace("barrier_start", serde_json::json!({"source": source, "declared": declared}));
644
645        // STEP 0: finish any rescope a crash left half-applied, before
646        // the scan reads the tree (scoped-read design §4.3). This is the
647        // gate that makes the narrow verb safe at all: a half-applied
648        // narrow is a tree whose files and whose citations disagree, and
649        // `classify` reads that disagreement as an upload or as a
650        // DELETE depending on which half landed. Replay is idempotent,
651        // so the cost on the normal path is one `stat` of a file that
652        // is not there.
653        if let Some(r) = self.replay_scope_intent().await? {
654            report.rescope_replayed = true;
655            eprintln!(
656                "flint-sync: finished an interrupted rescope before the barrier \
657                 (uncited {}, unlinked {}, materialised {})",
658                r.uncited, r.unlinked, r.materialized
659            );
660        }
661
662        // Step 1's second half: what the tree is owed. A UI save, delete or
663        // rename COMMITS (P2), so it reaches the tree as a peer's change
664        // does: derived, not queued.
665        let (consumed, seen_pointer) = self.consume_owed().await?;
666        report.consumed = consumed;
667        // The agent runs alongside: what it does to the tree between the
668        // removal pass above and the scan below is the model's interleaving
669        // of a local edit between Consume and Scan (the rename world's
670        // tenth box run reached its counterexample through it). A test
671        // window, like the consume's (H7); nothing in it calls the store.
672        consume_window("before-scan", "");
673        // Step 2: scan-diff against the persisted baseline.
674        let mut baseline = self.state.load_baseline()?;
675        let scanned = scan::scan(&self.cfg.root)?;
676        let mut classified = scan::classify(&scanned, &baseline);
677        if declared {
678            report.absences_confirmed = self.confirm_absences(&mut classified)?;
679        }
680        report.first_absence = classified.first_absence.iter().cloned().collect();
681
682        // Skip-on-no-diff: nothing local, and the bucket manifest where the
683        // consume left it.
684        if classified.uploads.is_empty()
685            && classified.deletes.is_empty()
686            && classified.first_absence.is_empty()
687        {
688            // Read the POINTER, never the entries: the 0b rig measured
689            // the idle tick at 1M entries as 27 s / 1.3 GiB — dominated
690            // by fetching and parsing a 264 MiB document just to read
691            // `seq`. The pointer is a few hundred bytes and carries the
692            // seq in its body, so a GET of it costs one round trip and
693            // answers strictly more than a HEAD of the old object did.
694            //
695            // Legacy workspaces (no pointer yet) keep the HEAD they had.
696            let pointer = match seen_pointer {
697                // The consume just read it: no second GET.
698                Seen::Pointer(seq, etag) => Some(Some((seq, etag))),
699                // The consume read the pointer AND the legacy key and found
700                // neither: decided below with no request at all.
701                Seen::Nothing => None,
702                Seen::Legacy => Some(
703                    manifest::load_pointer(self.store.as_ref(), &self.cfg).await?.map(|l| (l.pointer.seq, l.etag)),
704                ),
705            };
706            let unchanged = match pointer {
707                // Nothing published: the answer the 404 arm reaches, read
708                // once in this pass instead of three times (B8 trace,
709                // 2026-10-03: three pointer GETs and a legacy HEAD a tick).
710                None => baseline.manifest_etag.is_none(),
711                Some(Some((seq, etag))) => {
712                    // The news ticker rides this request for free — D5's
713                    // "zero added bucket requests" is still literal.
714                    report.observed_seq = Some(seq);
715                    report.observed_etag = Some(etag.clone());
716                    baseline.manifest_etag.as_deref() == Some(etag.as_str())
717                }
718                Some(None) => match self.store.head(&self.cfg.manifest_key()).await {
719                    Ok(meta) => {
720                        report.observed_seq =
721                            GenerationStamps::from_meta(&meta.meta).map(|s| s.generation);
722                        report.observed_etag = Some(meta.etag.clone());
723                        baseline.manifest_etag.as_deref() == Some(meta.etag.as_str())
724                    }
725                    Err(StoreError::NotFound(_)) => baseline.manifest_etag.is_none(),
726                    Err(e) => return Err(e.into()),
727                },
728            };
729            if unchanged {
730                report.no_change = true;
731                report.seq = Some(baseline.seq);
732                // prev_scan still advances (the two-scan rule's clock) —
733                // but the baseline document (hundreds of MB at 1M
734                // entries) is rewritten only when the scan SET moved.
735                // Compare without building the set: both sides iterate
736                // sorted, and the allocation this used to make was the
737                // size of the very document it was trying not to write.
738                if scanned.len() != baseline.prev_scan.len()
739                    || !scanned.keys().eq(baseline.prev_scan.iter())
740                {
741                    baseline.prev_scan = scanned.keys().cloned().collect();
742                    self.state.save_baseline(&baseline)?;
743                }
744                self.trace_barrier_end(&report, barrier_started);
745                return Ok(report);
746            }
747        }
748
749        // Step 3: intent journal, then the window (the commitment
750        // point: the same CAS drops the consumed entries).
751        let flush_uuid = uuid::Uuid::new_v4().to_string();
752        self.trace("scan", serde_json::json!({"flush": flush_uuid, "uploads": classified.uploads.len(),
753            "deletes": classified.deletes.len(), "first_absence": classified.first_absence.len()}));
754        let mut intent = self.state.load_intent()?;
755        intent = IntentJournal { flush_uuid: flush_uuid.clone(), carrier: intent.carrier.clone() };
756        self.state.save_intent(&intent)?;
757        // No window (P2): a UI save commits without the lease, and the
758        // uploads below race it the way two writers race each other —
759        // the commit's CAS decides, and the loser is preserved and
760        // recorded.
761
762        // Step 4: guarded uploads, fanned out under a bounded window
763        // (each key's guard chain is independent; the 412 policy and
764        // conflict records are applied to the collected results below,
765        // in deterministic path order).
766        let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
767        let mut parked: BTreeSet<String> = BTreeSet::new();
768        // Citations whose etag was observed with no lease held (adopted
769        // uploads, citation repairs): re-read inside the commit section.
770        let mut observed: BTreeSet<String> = BTreeSet::new();
771        let mut new_baseline_entries: BTreeMap<String, BaselineEntry> = BTreeMap::new();
772        let outcomes: Vec<(String, LeanResult<UploadOutcome>)> = {
773            use futures::stream::{self, StreamExt};
774            // CHUNKED into waves, so a wave's outcomes are collected
775            // before the next is fanned out. No lease is held here
776            // (design 2026-09-13 §4), so nothing renews or fences
777            // between chunks any more — the between-chunk fence that
778            // stopped a deposed straggler's PUTs went with the straggler:
779            // a PUT outside the lease is one legitimate writer's PUT,
780            // guarded by If-Match, and a collision is preserved and
781            // superseded, never silently overwritten.
782            let mut outcomes: Vec<(String, LeanResult<UploadOutcome>)> = Vec::new();
783            // NOT cfg.fanout. That knob was raised to 128 for the READ
784            // path, and the read-side measurement (2.5x on 20k small
785            // files) says nothing about this one, so uploads keep the
786            // value they were measured at until someone measures them.
787            // Memory is no longer the reason they are separate: this
788            // path is byte-bounded too now, by the store's gate
789            // (`with_upload_inflight_max_bytes`), which charges every
790            // whole body below and every multipart part inside the
791            // store from before its read until its PUT returns.
792            let fanout = self.cfg.upload_fanout.max(1);
793            let chunk = fanout.saturating_mul(UPLOAD_CHUNK_WAVES).max(1);
794            let all: Vec<&String> = classified.uploads.iter().collect();
795            for group in all.chunks(chunk) {
796                {
797                    let this: &Syncer = &*self;
798                    let mut part: Vec<(String, LeanResult<UploadOutcome>)> =
799                        stream::iter(group.iter().map(|path| {
800                            let path = *path;
801                            let scanned_entry = &scanned[path];
802                            let base = baseline.entries.get(path);
803                            let flush_uuid = &flush_uuid;
804                            async move {
805                                let r = this
806                                    .upload_one(path, scanned_entry, base, epoch_hint, flush_uuid)
807                                    .await;
808                                (path.clone(), r)
809                            }
810                        }))
811                        .buffer_unordered(fanout)
812                        .collect()
813                        .await;
814                    outcomes.append(&mut part);
815                }
816            }
817            outcomes
818        };
819        let mut outcomes = outcomes;
820        outcomes.sort_by(|a, b| a.0.cmp(&b.0));
821        for (path, outcome) in outcomes {
822            let path = &path;
823            match outcome? {
824                UploadOutcome::Published { entry, baseline_entry, adopted } => {
825                    self.trace("upload", serde_json::json!({"flush": flush_uuid, "path": path, "etag": entry.etag,
826                        "key": entry.key, "outcome": if adopted { "adopted" } else { "put" }}));
827                    if adopted {
828                        observed.insert(path.clone());
829                    }
830                    report.published_bytes += entry.size;
831                    upserts.insert(path.clone(), entry);
832                    new_baseline_entries.insert(path.clone(), baseline_entry);
833                    report.uploaded.push(path.clone());
834                }
835                UploadOutcome::Deferred => {
836                    self.trace("upload", serde_json::json!({"flush": flush_uuid, "path": path, "outcome": "deferred"}));
837                    report.deferred.push(path.clone());
838                }
839            }
840        }
841
842        // A PULL-ONLY boundary: nothing uploaded or deleted, so the merge
843        // can only add nothing — and then the commit section writes nothing
844        // to the bucket (no CAS, no GC) and what remains is local: note what
845        // theirs owes this tree and take its seq. That needs no
846        // fence and no window; claiming for it queued every such writer
847        // behind the publishing ones (65 of 191 claims in the writers drill).
848        // A merge that does add something (a mirror flag to restamp, say)
849        // falls through to the commit section.
850        if classified.uploads.is_empty()
851            && classified.deletes.is_empty()
852            && upserts.is_empty()
853            && observed.is_empty()
854        {
855            let current = manifest::load(self.store.as_ref(), &self.cfg).await?;
856            let m = self.merge_onto(
857                current.as_ref(),
858                &merge_base(&baseline),
859                &upserts,
860                &classified.deletes,
861                &parked,
862                &flush_uuid,
863            );
864            if let Some(handle) = m.expected.as_ref().filter(|_| m.adds_nothing()) {
865                // What theirs changed at a path this tree holds is owed: the
866                // next consume derives it, because theirs is not the document
867                // it last derived against (or is, and there is nothing).
868                self.note_carrier_uncited(&m.theirs)?;
869                report.seq = Some(m.theirs.seq);
870                report.no_change = true;
871                report.manifest_etag = Some(handle.etag.clone());
872                report.observed_seq = Some(m.theirs.seq);
873                report.observed_etag = Some(handle.etag.clone());
874                report.foreign_queued = m.foreign.len();
875                baseline.seq = m.theirs.seq;
876                baseline.manifest_etag = Some(handle.etag.clone());
877                baseline.prev_scan = scanned.keys().cloned().collect();
878                self.state.save_baseline(&baseline)?;
879                self.state.clear_intent_keys()?;
880                self.trace_barrier_end(&report, barrier_started);
881                return Ok(report);
882            }
883        }
884
885        // THE COMMIT SECTION (design 2026-09-13 §4). Everything above ran
886        // with no lease: the uploads are durable and etag-guarded, and
887        // nothing is cited yet. Claim the cell now — for the merge, the
888        // CAS, the deletes and the baseline — and hand it to the next
889        // waiter when done. This is the only stretch two writers of one
890        // workspace serialise on, and it is small: one pointer CAS and
891        // the guarded deletes, milliseconds to seconds, never the upload
892        // of a checkpoint.
893        let held = super::lease::claim(self).await?;
894        let epoch = held.epoch;
895        eprintln!("flint-sync: publish fence held (epoch {epoch})");
896        if self.cfg.drill_hold_commit_secs > 0 {
897            eprintln!(
898                "flint-sync: DRILL: holding the fence for {}s inside the commit section",
899                self.cfg.drill_hold_commit_secs
900            );
901            tokio::time::sleep(std::time::Duration::from_secs(self.cfg.drill_hold_commit_secs)).await;
902        }
903        let commit: LeanResult<()> = async {
904            // The entries carry the epoch the commit HOLDS, not the hint
905            // the uploads were stamped with.
906            for e in upserts.values_mut() {
907                e.epoch = epoch;
908            }
909            // Step 5: the manifest CAS (three-way merge; bounded retries).
910            // The cell is read after each manifest load below, not here: a
911            // read here preceded the HEAD fan-out and the window, and a
912            // holder deposed after it CASed onto its successor's document
913            // (review 2026-09-18, H2).
914            // EVERY citation this barrier adds was read or written with no
915            // lease held. Another writer's commit may since have uncited
916            // the path and collected the object: its GC deletes by the etag
917            // it integrated. That etag names an ADOPTED object (the model's
918            // LeanBarrierLeaseAdoptBlind) — and, since an S3 whole-PUT etag
919            // is the MD5 of the bytes, it also names this barrier's own PUT
920            // of IDENTICAL bytes (finding 13, the writers drill's A3: a
921            // same-content rewrite re-uploaded, the peer's GC deleted it,
922            // the commit cited a hole). Collection runs only inside a
923            // commit section, so a re-read HERE, holding the fence, cannot
924            // be overtaken before this CAS — held, that is, until the read
925            // of the cell after the load: a deposal before it is seen
926            // there, and after it the successor's rotation moves the
927            // pointer the CAS expects (H2). Whatever is gone or replaced is
928            // withheld: parked, recorded, and left dirty for the next
929            // barrier to publish with a PUT of its own.
930            let heads: Vec<(String, LeanResult<bool>)> = {
931                use futures::stream::{self, StreamExt};
932                let this: &Syncer = &*self;
933                stream::iter(upserts.iter().map(|(path, cited)| async move {
934                    let still = match this.store.head(&cited.key).await {
935                        Ok(meta) => Ok(meta.etag == cited.etag),
936                        Err(StoreError::NotFound(_)) => Ok(false),
937                        Err(e) => Err(e.into()),
938                    };
939                    (path.clone(), still)
940                }))
941                .buffer_unordered(self.cfg.upload_fanout.max(1))
942                .collect()
943                .await
944            };
945            let mut heads = heads;
946            heads.sort_by(|a, b| a.0.cmp(&b.0));
947            for (path, still) in heads {
948                let still_there = still?;
949                let was_observed = observed.contains(&path);
950                let cited = &upserts[&path];
951                self.trace("observed", serde_json::json!({"flush": flush_uuid, "path": path, "etag": cited.etag, "still": still_there, "own_put": !was_observed}));
952                if still_there {
953                    continue;
954                }
955                let gone = upserts.remove(&path).expect("looked up above");
956                new_baseline_entries.remove(&path);
957                report.uploaded.retain(|p| p != &path);
958                report.published_bytes = report.published_bytes.saturating_sub(gone.size);
959                // ADOPTED bytes that are the baseline's own (a file touched
960                // without a change, re-uploaded by a restarted barrier under
961                // the same flush) that went before the commit: nothing new
962                // is lost, so the path is not parked, and the next consume
963                // takes the manifest's version. (Once this was the citation
964                // repair's arm; storm drill S0, 2026-09-15, showed parking
965                // it kept a writer on withheld bytes for good.)
966                let integrated = was_observed
967                    && baseline.entries.get(&path).is_some_and(|be| be.etag == gone.etag);
968                if integrated {
969                    self.state.append_conflict(&ConflictRecord {
970                        path: path.clone(),
971                        foreign_etag: gone.etag,
972                        preserved_key: None,
973                        kind: "repair-withheld: the object this barrier re-cited was replaced or \
974                               collected before its commit; nothing cited, and the next consume \
975                               installs the manifest's version"
976                            .into(),
977                        at_unix: now_unix(),
978                    })?;
979                    continue;
980                }
981                parked.insert(path.clone());
982                let kind = if was_observed {
983                    "adopt-withheld: the object this barrier found already at its key was \
984                     replaced or collected before its commit; nothing cited, the path stays \
985                     dirty and publishes next barrier"
986                } else {
987                    "upload-withheld: the object this barrier PUT was replaced or collected \
988                     before its commit (a peer's GC recognizes the etag of identical bytes); \
989                     nothing cited, the path stays dirty and publishes next barrier"
990                };
991                self.state.append_conflict(&ConflictRecord {
992                    path: path.clone(),
993                    foreign_etag: gone.etag,
994                    preserved_key: None,
995                    kind: kind.into(),
996                    at_unix: now_unix(),
997                })?;
998                report.parked.push(path.clone());
999            }
1000            let mut foreign_entries: Vec<(String, LeanEntry)> = vec![];
1001            // Set when the merge added nothing to the document: nothing is
1002            // installed, and theirs becomes the merge base as it stands.
1003            let mut installed_nothing = false;
1004            let mut attempt = 0;
1005            // The last value carried out: the adoptions this install
1006            // declined for a move, whose entries stay in the cell.
1007            let (theirs_at_cas, installed, installed_etag, overridden, recreated, over_theirs, replaced_etag) = loop {
1008                attempt += 1;
1009                if attempt > CAS_ATTEMPTS {
1010                    return Err(LeanError::State(format!(
1011                        "manifest CAS lost {CAS_ATTEMPTS} merge races — refusing this barrier"
1012                    )));
1013                }
1014                let current = manifest::load(self.store.as_ref(), &self.cfg).await?;
1015                // Review 2026-09-18, H2: THE fence — the cell read AFTER the
1016                // load this CAS merges onto. The read at the top of step 5
1017                // comes before the HEAD fan-out and the window, and a holder
1018                // deposed in between loaded its successor's document,
1019                // rotation included, and CASed onto it: the rotation fences
1020                // only a load that PRECEDES it. Read here, the order closes
1021                // both ways: deposed before this load, and this read sees
1022                // it; deposed after, and the successor's rotation has moved
1023                // the pointer this CAS expects.
1024                //
1025                // Review 2026-09-18, M3: renew first if the cell has stood
1026                // still for half the takeover threshold. Nothing else moves
1027                // the token between the claim and the end of the section, so
1028                // a live holder whose verify, merge or CAS retries ran past
1029                // it was deposed by a waiter, and two such writers could
1030                // depose each other every floor.
1031                super::lease::renew_if_due(self).await?;
1032                self.verify_not_deposed().await?;
1033                // The baseline IS the merge base (P1-lite).
1034                let base = merge_base(&baseline);
1035                let MergeOnto { theirs, expected, merged, foreign, overridden, recreated, over_theirs } = self.merge_onto(
1036                    current.as_ref(),
1037                    &base,
1038                    &upserts,
1039                    &classified.deletes,
1040                    &parked,
1041                    &flush_uuid,
1042                );
1043                // A DOCUMENT MUST BE MATERIALISABLE. No filesystem can hold
1044                // a file `a` and a file `a/x`, so no document may cite
1045                // both — a fresh checkout would have to create a directory
1046                // where its own manifest says a file goes. The agent makes
1047                // exactly that shape by replacing a published file with a
1048                // directory (or the reverse): the scan sees the old path
1049                // absent and the new one new, the two-scan guard withholds a
1050                // FIRST absence, and the upload lands beside the citation it
1051                // was meant to replace (review 2026-09-18, M5's agent-side
1052                // twin). Judged here because this is where the document's
1053                // final shape is known — theirs, my upserts and the deletes
1054                // that actually applied. Mine is the side that yields: the
1055                // path stays dirty and publishes at the next barrier, by
1056                // which time the withheld absence is confirmed and its
1057                // citation gone. This re-merges without spending a CAS
1058                // attempt; the clash is decided, not raced.
1059                let clash = path_clashes(&merged.entries, &upserts);
1060                if !clash.is_empty() {
1061                    for path in clash {
1062                        let gone = upserts.remove(&path).expect("named from upserts above");
1063                        new_baseline_entries.remove(&path);
1064                        report.uploaded.retain(|p| p != &path);
1065                        report.published_bytes =
1066                            report.published_bytes.saturating_sub(gone.size);
1067                        parked.insert(path.clone());
1068                        report.parked.push(path.clone());
1069                        self.state.append_conflict(&ConflictRecord {
1070                            path: path.clone(),
1071                            foreign_etag: gone.etag,
1072                            preserved_key: None,
1073                            kind: "upload-withheld-path-clash: the document still cites a path \
1074                                   this one would sit inside (or around) — a published file \
1075                                   replaced by a directory, or the reverse. No workspace can \
1076                                   hold both, so nothing is cited here until that citation \
1077                                   goes; the path stays dirty and publishes next barrier"
1078                                .into(),
1079                            at_unix: now_unix(),
1080                        })?;
1081                        self.trace(
1082                            "path_clash",
1083                            serde_json::json!({"flush": flush_uuid, "path": path}),
1084                        );
1085                    }
1086                    attempt -= 1;
1087                    continue;
1088                }
1089                // Nothing of ours changes the document — a barrier that only
1090                // found the manifest moved by another writer. Installing it
1091                // anyway was an empty generation, and the peer's next tick
1092                // then found the manifest moved and did the same: two idle
1093                // writers traded generations (and cell claims) for as long
1094                // as both ran. Theirs becomes the merge base as it stands.
1095                if let Some(handle) = expected.as_ref() {
1096                    if merged.entries == theirs.entries && merged.sole_writer == theirs.sole_writer {
1097                        foreign_entries = foreign;
1098                        installed_nothing = true;
1099                        break (theirs.clone(), theirs, handle.etag.clone(), vec![], vec![], vec![], Some(handle.etag.clone()));
1100                    }
1101                }
1102                match manifest::cas_write_retiring(
1103                    self.store.as_ref(),
1104                    &self.cfg,
1105                    &merged,
1106                    Some(&theirs),
1107                    expected.as_ref(),
1108                    epoch,
1109                    &flush_uuid,
1110                    Some(source),
1111                )
1112                .await
1113                {
1114                    Ok(meta) => {
1115                        // Before the deletes and before step 7: this is the
1116                        // only record that survives a crash in that window.
1117                        self.trace("cas", serde_json::json!({"flush": flush_uuid, "seq": merged.seq,
1118                            "expected": expected.as_ref().map(|h| h.etag.clone()), "etag": meta.etag, "result": "ok"}));
1119                        // H10: what this install carried of the standing
1120                        // declaration survives a restart, for the ack.
1121                        if let Some(id) = carries.as_ref() {
1122                            let mut c = intent
1123                                .carrier
1124                                .take()
1125                                .filter(|c| &c.pending_id == id)
1126                                .unwrap_or_else(|| Carrier { pending_id: id.clone(), ..Default::default() });
1127                            c.paths.extend(classified.uploads.iter().chain(&classified.deletes).cloned());
1128                            // L-136: an earlier carry's delete that this one
1129                            // uploads is an upload again, so a peer's delete
1130                            // of it is named (`note_carrier_uncited`).
1131                            // L-137: the bytes it published, so a peer's
1132                            // replacement of them is named too.
1133                            for p in &classified.uploads {
1134                                c.deletes.remove(p);
1135                                match upserts.get(p) {
1136                                    Some(e) => c.published.insert(p.clone(), e.crc64_b64.clone()),
1137                                    None => c.published.remove(p),
1138                                };
1139                            }
1140                            for p in &classified.deletes {
1141                                c.published.remove(p);
1142                            }
1143                            c.deletes.extend(classified.deletes.iter().cloned());
1144                            // `honor_publish`'s dropped set, decided here:
1145                            // parks and deferrals are settled before the
1146                            // CAS, and an outranked delete is one the merged
1147                            // document still cites. Since M3 no delete is
1148                            // outranked (only uploads park, and the merge
1149                            // removes every unparked delete: `merge_applies_
1150                            // a_local_delete_and_names_the_version_it_
1151                            // deleted_over`), so the second clause is a
1152                            // backstop no barrier reaches.
1153                            c.dropped.extend(report.parked.iter().chain(&report.deferred).cloned());
1154                            c.dropped.extend(
1155                                classified.deletes.iter().filter(|p| merged.entries.contains_key(p.as_str())).cloned(),
1156                            );
1157                            intent.carrier = Some(c);
1158                        }
1159                        self.state.save_intent(&intent)?;
1160                        foreign_entries = foreign;
1161                        let replaced = expected.as_ref().map(|h| h.etag.clone());
1162                        break (theirs, merged, meta.etag, overridden, recreated, over_theirs, replaced);
1163                    }
1164                    Err(LeanError::Store(StoreError::PreconditionFailed(_)))
1165                    | Err(LeanError::Store(StoreError::Conflict(_))) => {
1166                        self.trace("cas", serde_json::json!({"flush": flush_uuid, "seq": merged.seq,
1167                            "expected": expected.as_ref().map(|h| h.etag.clone()), "result": "lost"}));
1168                        // A rotation is exactly this 412, and re-merging past
1169                        // it would be the straggler install: the next
1170                        // attempt's read after its load is the fence.
1171                        // Back off first: under P2 a UI save never waits on
1172                        // the lease, so the race is usually lost to saves,
1173                        // and the gap between two of them is the way in (G2).
1174                        tokio::time::sleep(cas_backoff(attempt)).await;
1175                        continue;
1176                    }
1177                    Err(e) => return Err(e),
1178                }
1179            };
1180            report.seq = Some(installed.seq);
1181            report.no_change = installed_nothing;
1182            // A fused install IS a coherent point, and cadence/hybrid have
1183            // exactly one source. The gauges must not report "no boundary
1184            // ever" on a workspace that publishes every minute. A barrier
1185            // that installed nothing marks no boundary of its own.
1186            if !installed_nothing {
1187                self.note_boundary("cadence", installed.seq)?;
1188            }
1189            report.manifest_etag = Some(installed_etag.clone());
1190            report.observed_seq = Some(installed.seq);
1191            report.observed_etag = Some(installed_etag.clone());
1192            report.foreign_queued = foreign_entries.len();
1193
1194            // Step 6: RETIREMENT and collection (design 2026-09-19, R3 and
1195            // R7). What the document this CAS merged onto cited and the
1196            // installed document does not — a modified path's old handle,
1197            // a deleted path's — is retired, minus every handle the
1198            // installed document still cites at another path (a rename
1199            // cites the source's handle at the destination) and every
1200            // handle an inbox entry names (a rename whose destination is
1201            // still only in the cell). A retired handle can never be cited
1202            // again: nothing discovers handles from paths, and the merge
1203            // carries forward only what the base cites. So the collector
1204            // deletes it UNCONDITIONALLY, in one batch — no HEAD, no
1205            // If-Match, no fence, no renew (a straggler deposed after its
1206            // CAS has an exact retired set too; the model's
1207            // `LeanImmutableStragglerGC`). The HEAD-then-If-Match dance,
1208            // the collector that gave way on a store without a conditional
1209            // DELETE (review H1's leak) and the C2 renew-before-delete
1210            // clock all went with the slot.
1211            //
1212            // And what this barrier published OVER without ever integrating
1213            // it — a peer's publish, a UI write consumed elsewhere — is
1214            // preserved by a server-side copy and a record BEFORE the batch
1215            // takes the handle. The slot's 412 caught that at the PUT and
1216            // `Upload412Preserves` did the same; a fresh handle has no slot
1217            // to fail on, so the CAS, the one place the citation is read,
1218            // takes the decision (R7; the model's first HitlOverAny run
1219            // under handles cited an acked write over blind without it).
1220            let still_cited: BTreeSet<&str> = installed.entries.values().map(|e| e.key.as_str()).collect();
1221            let mut retired: Vec<String> = vec![];
1222            for (path, was) in &theirs_at_cas.entries {
1223                let dropped = installed.entries.get(path).map(|now| now.key != was.key).unwrap_or(true);
1224                if dropped && !still_cited.contains(was.key.as_str()) {
1225                    retired.push(was.key.clone());
1226                }
1227            }
1228            for (path, was) in &overridden {
1229                let integrated = baseline
1230                    .entries
1231                    .get(path)
1232                    .is_some_and(|be| be.key.as_deref() == Some(was.key.as_str()));
1233                if integrated || parked.contains(path) {
1234                    continue;
1235                }
1236                let preserved = self.preserve_conflict_copy(path, &was.key, &was.etag).await?;
1237                self.state.append_conflict(&ConflictRecord {
1238                    path: path.clone(),
1239                    foreign_etag: was.etag.clone(),
1240                    preserved_key: Some(preserved),
1241                    kind: "commit-surfaced-foreign: this barrier published over a version it never \
1242                           integrated (another writer's publish, or a UI write consumed elsewhere); \
1243                           that version is preserved under this record and superseded knowingly"
1244                        .into(),
1245                    at_unix: now_unix(),
1246                })?;
1247                report.surfaced.push(path.clone());
1248                self.trace("surface", serde_json::json!({"flush": flush_uuid, "path": path, "etag": was.etag, "key": was.key}));
1249            }
1250            for (path, deleted) in &recreated {
1251                self.state.append_conflict(&ConflictRecord {
1252                    path: path.clone(),
1253                    foreign_etag: deleted.clone(),
1254                    preserved_key: None,
1255                    kind: "commit-recreated-deleted: this barrier published over a DELETE it never \
1256                           integrated (another writer's, or the UI's); mine wins, the path is cited \
1257                           again with this tree's bytes, and the deleted version is named here"
1258                        .into(),
1259                    at_unix: now_unix(),
1260                })?;
1261                report.surfaced.push(path.clone());
1262                self.trace("surface", serde_json::json!({"flush": flush_uuid, "path": path, "etag": deleted, "deleted": true}));
1263            }
1264            // M3: what this barrier's DELETE removed that it never
1265            // integrated — theirs, changed since the baseline — is preserved
1266            // before the batch below takes the handle, and named.
1267            for (path, was) in &over_theirs {
1268                let preserved = self.preserve_conflict_copy(path, &was.key, &was.etag).await?;
1269                self.state.append_conflict(&ConflictRecord {
1270                    path: path.clone(),
1271                    foreign_etag: was.etag.clone(),
1272                    preserved_key: Some(preserved),
1273                    kind: "commit-deleted-over-theirs: this barrier deleted a path whose version it \
1274                           never integrated (another writer's publish, or a UI save); the delete \
1275                           stands, and that version is preserved under this record"
1276                        .into(),
1277                    at_unix: now_unix(),
1278                })?;
1279                report.surfaced.push(path.clone());
1280                self.trace("surface", serde_json::json!({"flush": flush_uuid, "path": path, "etag": was.etag, "key": was.key, "over_delete": true}));
1281            }
1282            for path in &classified.deletes {
1283                if installed.entries.contains_key(path) {
1284                    // Never expected: since M3 a delete applies over theirs,
1285                    // and only uploads are parked, so every delete leaves the
1286                    // document. Were one still cited it is not in this
1287                    // boundary — PARKED, which the ack names as not carried —
1288                    // and the next scan classifies it again. Traced like
1289                    // every outcome: a replay owes a GC step for every path
1290                    // in the delete set (W4 phase 2, R3-S2).
1291                    self.trace("gc", serde_json::json!({"flush": flush_uuid, "path": path, "result": "still-cited"}));
1292                    report.parked.push(path.clone());
1293                } else {
1294                    let key = theirs_at_cas.entries.get(path).map(|e| e.key.clone());
1295                    self.trace("gc", serde_json::json!({"flush": flush_uuid, "path": path, "key": key, "result": "retired"}));
1296                    report.deleted.push(path.clone());
1297                }
1298            }
1299            if self.cfg.drill_hold_gc_secs > 0 && !retired.is_empty() {
1300                eprintln!(
1301                    "flint-sync: DRILL: holding the GC for {}s before the batch delete",
1302                    self.cfg.drill_hold_gc_secs
1303                );
1304                self.trace("drill_hold", serde_json::json!({"flush": flush_uuid, "where": "gc",
1305                    "secs": self.cfg.drill_hold_gc_secs}));
1306                tokio::time::sleep(std::time::Duration::from_secs(self.cfg.drill_hold_gc_secs)).await;
1307            }
1308            // RETIRE-AGE G (M1): what this commit retired was LOGGED with its
1309            // CAS (`manifest::cas_write_retiring`), as the gateway's commits
1310            // log theirs. It is collected once the retirement is
1311            // `retire_grace_secs` old — at once when that is 0 — by whichever
1312            // writer's commit section comes next; until then the sweeps spare
1313            // it, so a reader that loaded the document before this CAS can
1314            // still fetch it. No ledger, no sweep: a leak, never a deletion
1315            // a lagging reader meets.
1316            let ledger = match self.retire_ledger(now_unix()).await {
1317                Ok(l) => Some(l),
1318                Err(e) => {
1319                    eprintln!("flint-sync: could not read the retire logs ({e}); no sweep this time");
1320                    None
1321                }
1322            };
1323            if let Some(l) = &ledger {
1324                match self.reap_retired(l, &installed, &flush_uuid).await {
1325                    Ok(n) => report.collected = n,
1326                    Err(e) => eprintln!("flint-sync: retired handles not collected (next time): {e}"),
1327                }
1328            }
1329            if !retired.is_empty() {
1330                self.trace("gc", serde_json::json!({"flush": flush_uuid, "retired": retired.len(),
1331                    "collected": report.collected}));
1332            }
1333            // The orphan sweep (R4): a handle nothing cites, no young retire
1334            // log names, older than its grace — left by a writer that never
1335            // committed. Inside the commit section, under the cell, so the
1336            // commit's re-read of its own uploads above and this CAS are
1337            // race-free against it; never this barrier's own handles.
1338            if let Some(l) = ledger.as_ref().filter(|_| self.lease.is_some()) {
1339                match self.sweep_orphans_if_due(&installed, &flush_uuid, now_unix(), &l.spared).await {
1340                    Ok(n) => report.swept = n,
1341                    Err(e) => eprintln!("flint-sync: orphan sweep (retrying next time it is due): {e}"),
1342                }
1343            }
1344
1345            // Step 7: the baseline follows what this barrier published. What
1346            // theirs changed is owed, and the next consume derives it: the
1347            // pointer now names a document it never derived against. UNLESS
1348            // this CAS replaced exactly the document the consume derived
1349            // against — then the installed one is that plus this tree's own
1350            // changes, nothing new is owed, and the next tick stays cheap
1351            // (a busy agent's publishes would otherwise each cost a full
1352            // derive). The paths published or deleted here are no longer the
1353            // agent's pending work.
1354            let same = |a: &str, b: &str| a.trim_matches('"') == b.trim_matches('"');
1355            // No document replaced ("") is the empty workspace's derive.
1356            let over_derived = matches!(&baseline.derived_etag,
1357                Some(d) if same(replaced_etag.as_deref().unwrap_or(""), d));
1358            if over_derived {
1359                baseline.derived_etag = Some(installed_etag.clone());
1360                for path in new_baseline_entries.keys().chain(&report.deleted) {
1361                    baseline.skipped.remove(path);
1362                }
1363            }
1364            for (path, be) in new_baseline_entries {
1365                baseline.entries.insert(path, be);
1366            }
1367            for path in &report.deleted {
1368                baseline.entries.remove(path);
1369            }
1370            self.note_carrier_uncited(&installed)?;
1371            baseline.seq = installed.seq;
1372            baseline.manifest_etag = Some(installed_etag);
1373            baseline.prev_scan = scanned.keys().cloned().collect();
1374            self.state.save_baseline(&baseline)?;
1375            self.state.clear_intent_keys()?;
1376            // The commit section's end (the model's `Finish`). No window is
1377            // written: under P2 a UI save never waits on one (G1).
1378            self.trace("window_clear", serde_json::json!({"flush": flush_uuid}));
1379            // Reap superseded generations. Immutable metadata that is never
1380            // collected is a leak that grows by a whole manifest per
1381            // publish, and this also collects the orphan a crash between the
1382            // entries PUT and the pointer CAS leaves behind. Best effort by
1383            // design: a publish that succeeded is not un-done by a failure
1384            // to tidy up after it.
1385            // A deposal after the CAS stops every deletion after it (M3):
1386            // what is left is a leak the next holder's sweeps take, never a
1387            // deletion under a successor's cell. The reap and the orphan
1388            // sweep renew between their batches (`delete_many_renewing`);
1389            // these two renew once before they start.
1390            let may_delete = self.lease.is_some() && super::lease::renew_if_due(self).await.is_ok();
1391            let swept_generations = match may_delete {
1392                false => Ok(0),
1393                true => manifest::sweep_generations(self.store.as_ref(), &self.cfg).await,
1394            };
1395            let swept_generations = match swept_generations {
1396                Ok(0) => 0,
1397                Ok(n) => {
1398                    eprintln!("flint-sync: reaped {n} superseded manifest generation(s)");
1399                    n
1400                }
1401                Err(e) => {
1402                    eprintln!("flint-sync: generation sweep: {e}");
1403                    0
1404                }
1405            };
1406            self.trace("sweep", serde_json::json!({"flush": flush_uuid, "what": "generations", "removed": swept_generations}));
1407            // And the CHUNK reaper, same reason, same best-effort terms.
1408            // `sweep_chunks` returns 0 without a request on a workspace that
1409            // is not chunked, so this costs nothing until the layout moves.
1410            //
1411            // It was written to four model-established rules, guarded by six
1412            // mutation configs, and called from nothing but its own tests —
1413            // so with chunking on, every publish left its superseded chunks
1414            // in the bucket forever. The rules ran in the model and in the
1415            // suite and never once in production. `the_barrier_reaps...`
1416            // below is the test that asks whether this line exists at all.
1417            let may_delete = may_delete && self.lease.is_some() && super::lease::renew_if_due(self).await.is_ok();
1418            let swept_chunks = match ledger.as_ref() {
1419                _ if !may_delete => Ok(0),
1420                None => Ok(0),
1421                Some(l) => manifest::sweep_chunks_sparing(self.store.as_ref(), &self.cfg, &l.spared).await,
1422            };
1423            let swept_chunks = match swept_chunks {
1424                Ok(0) => 0,
1425                Ok(n) => {
1426                    eprintln!("flint-sync: reaped {n} unreferenced manifest chunk(s)");
1427                    n
1428                }
1429                Err(e) => {
1430                    eprintln!("flint-sync: chunk sweep: {e}");
1431                    0
1432                }
1433            };
1434            self.trace("sweep", serde_json::json!({"flush": flush_uuid, "what": "chunks", "removed": swept_chunks}));
1435            Ok(())
1436        }
1437        .await;
1438        // Hand the cell on whatever happened — unless it is no longer
1439        // ours to hand on: a fence means a successor holds it, and the
1440        // barrier is simply abandoned (its manifest never installed; its
1441        // uploads stand, and the next barrier adopts them by flush_uuid).
1442        match &commit {
1443            Err(LeanError::Fenced(m)) => {
1444                self.trace("fence", serde_json::json!({"where": "commit", "detail": m}));
1445                self.lease = None
1446            }
1447            _ => {
1448                if let Err(e) = super::lease::release(self).await {
1449                    eprintln!(
1450                        "flint-sync: the publish fence could not be released ({e}); a waiter \
1451                         deposes it after the quiet threshold"
1452                    );
1453                }
1454            }
1455        }
1456        commit?;
1457        self.trace_barrier_end(&report, barrier_started);
1458        Ok(report)
1459    }
1460
1461    fn trace_barrier_end(&self, report: &BarrierReport, started: std::time::Instant) {
1462        if self.cfg.event_trace.is_none() {
1463            return;
1464        }
1465        self.trace(
1466            "barrier_end",
1467            serde_json::json!({
1468                "seq": report.seq, "uploaded": report.uploaded.len(), "deleted": report.deleted.len(),
1469                "parked": report.parked.len(),
1470                "leaked": report.leaked.len(),
1471                "consumed": report.consumed, "no_change": report.no_change,
1472                "ms": started.elapsed().as_millis() as u64, "requests": self.trace_requests(),
1473            }),
1474        );
1475    }
1476
1477    /// The > whole_put_max path: contiguous `PartSource::Local` chunks
1478    /// through `compose_generation` (streaming multipart; the store
1479    /// aborts its partial assembly on every failure path). The CRC is
1480    /// computed by a streaming pass first; a writer racing the compose
1481    /// fails server-side validation and the path DEFERS to the next
1482    /// barrier — publish-possibly-torn is put_whole's documented
1483    /// dilemma, not this path's.
1484    #[allow(clippy::too_many_arguments)]
1485    #[allow(clippy::too_many_arguments)]
1486    async fn upload_compose(
1487        &self,
1488        path: &str,
1489        key: &str,
1490        // The ONE descriptor `upload_one` opened by a walk that follows
1491        // no link at any depth. The store reads its parts from this and
1492        // never reopens the path (review 2026-09-18, H5's residual).
1493        file: std::sync::Arc<std::fs::File>,
1494        size: u64,
1495        scanned: &scan::ScanEntry,
1496        stamps: GenerationStamps,
1497        generation: u64,
1498        epoch: u64,
1499    ) -> LeanResult<UploadOutcome> {
1500        // NO PRE-PASS. The store accumulates the full-object CRC from
1501        // the parts as it reads them for upload, so this file is read
1502        // ONCE (the 2026-09-10 door drill measured the second read at
1503        // roughly 11 s of the `mixed` workload's 24.3 s).
1504        // Part grid: within [min_part_size, ...], at most max_parts,
1505        // contiguous from 0.
1506        let min_part = self.store.min_part_size().max(1);
1507        let max_parts = self.store.max_parts().max(1) as u64;
1508        let mut chunk = self.cfg.whole_put_max.max(min_part);
1509        let need = size.div_ceil(chunk);
1510        if need > max_parts {
1511            chunk = size.div_ceil(max_parts).div_ceil(min_part) * min_part;
1512        }
1513        let mut parts = vec![];
1514        let mut off = 0u64;
1515        while off < size {
1516            let len = chunk.min(size - off);
1517            parts.push(flint_store::PartSource::Local { offset: off, len });
1518            off += len;
1519        }
1520
1521
1522        // A fresh handle: the Complete lands on a key nobody else writes
1523        // (design 2026-09-19, R1). The one thing that can already sit
1524        // there is this barrier's OWN Complete whose response was lost —
1525        // a torn response — and the recogniser is the bytes' checksum:
1526        // the same, cite them (re-read under the fence first, like every
1527        // citation); different, nothing minted this key but us, so the
1528        // barrier refuses rather than guess. The 412 arms this replaced
1529        // — adopt-own through the crash journal, preserve-a-foreign-
1530        // version, park — arbitrated a slot that no longer exists.
1531        let spec = flint_store::ComposeSpec {
1532            progress: None,
1533            key,
1534            local: Some(file),
1535            parts: parts.clone(),
1536            base_key: None,
1537            base_etag: None,
1538            condition: PutCondition::IfNoneMatchAny,
1539            stamps: stamps.clone(),
1540            crc64: None,
1541        };
1542        match self.store.compose_generation(&spec).await {
1543            Ok(meta) => {
1544                // The store computed it; refuse to cite bytes nothing
1545                // vouched for rather than invent a number for the
1546                // manifest. A backend that returns no checksum has not
1547                // validated the publish server-side either.
1548                let crc = meta
1549                    .crc64_b64
1550                    .as_deref()
1551                    .and_then(flint_store::crc64_from_b64)
1552                    .ok_or_else(|| {
1553                        LeanError::State(format!(
1554                            "compose of {key} returned no full-object checksum — refusing to \
1555                             cite bytes nothing vouched for"
1556                        ))
1557                    })?;
1558                Ok(UploadOutcome::published(
1559                    path, key.to_string(), meta.etag, crc, size, scanned, generation, epoch,
1560                ))
1561            }
1562            Err(StoreError::ChecksumMismatch(_)) | Err(StoreError::NoSuchUpload(_)) => {
1563                // Drift mid-compose, or the operator sweep aborted us:
1564                // nothing published; the next barrier re-queues.
1565                Ok(UploadOutcome::Deferred)
1566            }
1567            Err(StoreError::PreconditionFailed(_)) => {
1568                // The torn-response recogniser compares OUR bytes' checksum
1569                // against what landed, and without the pre-pass we do not
1570                // have one — so the file is read HERE, on the rare recovery
1571                // path, instead of on every publish.
1572                let crc = file_crc(&self.cfg.root, path)?;
1573                let head = self.store.head(key).await?;
1574                if head.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) {
1575                    let g = GenerationStamps::from_meta(&head.meta).map(|s| s.generation).unwrap_or(generation);
1576                    return Ok(UploadOutcome::adopted(
1577                        path, key.to_string(), head.etag, crc, head.size, scanned, g, epoch,
1578                    ));
1579                }
1580                Err(LeanError::State(format!(
1581                    "handle {key} already holds different bytes (etag {}): a handle is minted once \
1582                     per flush, so something other than this barrier wrote it; refusing",
1583                    head.etag
1584                )))
1585            }
1586            Err(e) => Err(e.into()),
1587        }
1588    }
1589
1590
1591    async fn upload_one(
1592        &self,
1593        path: &str,
1594        scanned: &scan::ScanEntry,
1595        base: Option<&BaselineEntry>,
1596        epoch: u64,
1597        flush_uuid: &str,
1598    ) -> LeanResult<UploadOutcome> {
1599        let key = self.cfg.handle_key(path, flush_uuid);
1600        let local_path = self.cfg.root.join(path);
1601        let generation = base.map(|b| b.generation + 1).unwrap_or(1);
1602        // Review 2026-09-12, atomicity-6: the scan skipped symlinks; this
1603        // read followed them. A regular file swapped for a symlink between
1604        // the two published the link's TARGET — a file outside the
1605        // workspace, the syncer's own /proc/self/environ included. Every
1606        // stat here is an lstat and the read opens O_NOFOLLOW; a path that
1607        // is no longer a regular file is refused, recorded, and deferred
1608        // (the next scan skips it, and the two-scan rule retires it).
1609        //
1610        // Review 2026-09-18, H5: and not only the last component. A
1611        // DIRECTORY on the path swapped for a link (`mv d d.bak; ln -s
1612        // /proc/self d`) made `d/environ` a regular file outside the
1613        // workspace; the lstat and the O_NOFOLLOW open guarded the last
1614        // component only. The file is opened by a walk that follows no
1615        // link at any depth, and everything below — the stamps, the
1616        // size, the bytes — comes from that one descriptor.
1617        let file = match super::safefs::open_beneath_nofollow(&self.cfg.root, path) {
1618            Ok(f) => f,
1619            Err(e)
1620                if e.kind() == std::io::ErrorKind::InvalidInput
1621                    || matches!(e.raw_os_error(), Some(libc::ELOOP) | Some(libc::ENOTDIR)) =>
1622            {
1623                self.state.append_conflict(&ConflictRecord {
1624                    path: path.to_string(),
1625                    foreign_etag: String::new(),
1626                    preserved_key: None,
1627                    kind: format!(
1628                        "upload-refused-not-regular: no longer a regular file inside the workspace after \
1629                         the scan (a symlink or special file replaced it or a directory above it: {e}); \
1630                         nothing published"
1631                    ),
1632                    at_unix: now_unix(),
1633                })?;
1634                return Ok(UploadOutcome::Deferred);
1635            }
1636            Err(e) => return Err(LeanError::State(format!("stat {}: {e}", local_path.display()))),
1637        };
1638        let lmeta = file
1639            .metadata()
1640            .map_err(|e| LeanError::State(format!("stat {}: {e}", local_path.display())))?;
1641        let posix = Some(PosixStamps::from_metadata(&lmeta));
1642        let stamps = GenerationStamps {
1643            generation,
1644            epoch,
1645            flush_uuid: flush_uuid.to_string(),
1646            boundary_source: None,
1647            posix,
1648        };
1649        let size = lmeta.len();
1650        if size > self.cfg.whole_put_max {
1651            // Streaming multipart compose: put_whole is never fed past
1652            // whole_put_max (unbounded memory + S3's 5 GiB wall).
1653            return self
1654                .upload_compose(path, &key, std::sync::Arc::new(file), size, scanned, stamps, generation, epoch)
1655                .await;
1656        }
1657        // The upload byte gate — the store's own, shared with the compose
1658        // arm above, which charges its parts inside the store. This body
1659        // is charged from before it is read (the read is the allocation)
1660        // until its PUT, retried or not, has returned: the permit lives
1661        // to the end of this function. `None` = a store with no gate.
1662        let _upload_permit = match self.store.upload_gate() {
1663            Some(g) => Some(g.acquire(size).await),
1664            None => None,
1665        };
1666        // `Bytes::from(Vec<u8>)` TAKES OWNERSHIP without copying, so the
1667        // old `Bytes::from(body.clone())` was a full memcpy of every
1668        // published file body — bought solely to leave `body` intact for
1669        // the 412 retry below, which is reached almost never. Build the
1670        // `Bytes` once; the retry gets a refcount clone.
1671        let body = Bytes::from({
1672            use std::io::Read;
1673            let mut v = Vec::with_capacity(size as usize);
1674            (&file).read_to_end(&mut v)?;
1675            v
1676        });
1677        let uploaded_len = body.len() as u64;
1678        let crc = crc64_nvme(&body);
1679        // A fresh handle (design 2026-09-19, R1): no If-Match, because
1680        // nothing else ever writes this key. The one thing that can sit
1681        // there is this barrier's OWN PUT whose response was lost, and
1682        // the bytes' checksum recognises it: the same, cite it (re-read
1683        // under the fence first, like every citation); different, nothing
1684        // minted this key but us, so refuse rather than guess. The 412
1685        // policy this replaced — adopt-own, preserve-then-supersede a
1686        // foreign version, park, the 404-on-If-Match create arm —
1687        // arbitrated a slot that no longer exists; a foreign version is
1688        // now met at the CAS, the one place a citation is read (R7).
1689        match self
1690            .store
1691            .put_whole(&key, body, &PutCondition::IfNoneMatchAny, &stamps, crc)
1692            .await
1693        {
1694            Ok(meta) => Ok(UploadOutcome::published(
1695                path, key, meta.etag, crc, uploaded_len, scanned, generation, epoch,
1696            )),
1697            Err(StoreError::PreconditionFailed(_)) => {
1698                let head = self.store.head(&key).await?;
1699                if head.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) {
1700                    let g = GenerationStamps::from_meta(&head.meta).map(|s| s.generation).unwrap_or(generation);
1701                    return Ok(UploadOutcome::adopted(
1702                        path, key, head.etag, crc, head.size, scanned, g, epoch,
1703                    ));
1704                }
1705                Err(LeanError::State(format!(
1706                    "handle {key} already holds different bytes (etag {}): a handle is minted once \
1707                     per flush, so something other than this barrier wrote it; refusing",
1708                    head.etag
1709                )))
1710            }
1711            Err(e) => Err(e.into()),
1712        }
1713    }
1714}
1715
1716enum UploadOutcome {
1717    /// `adopted`: the etag was OBSERVED at the handle (bytes already
1718    /// there, no PUT of ours produced them — a torn response), so the
1719    /// commit section re-reads it before citing, as it does every
1720    /// citation.
1721    Published { entry: LeanEntry, baseline_entry: BaselineEntry, adopted: bool },
1722    /// The source drifted mid-transfer (checksum refused server-side)
1723    /// or the assembly was swept: publish nothing, advance nothing —
1724    /// the next scan re-queues the path.
1725    Deferred,
1726}
1727
1728impl UploadOutcome {
1729    #[allow(clippy::too_many_arguments)]
1730    fn published(
1731        path: &str,
1732        key: String,
1733        etag: String,
1734        crc: u64,
1735        uploaded_len: u64,
1736        scanned: &scan::ScanEntry,
1737        generation: u64,
1738        epoch: u64,
1739    ) -> UploadOutcome {
1740        let _ = path;
1741        UploadOutcome::Published {
1742            adopted: false,
1743            entry: LeanEntry {
1744                key: key.clone(),
1745                etag: etag.clone(),
1746                crc64_b64: crc64_to_b64(crc),
1747                // The length the object HAS (review 2026-09-12,
1748                // atomicity-1): the scanned size was cited while the
1749                // body was read fresh, so a file that grew between the
1750                // two was cited short and every fresh checkout of it
1751                // failed its CRC fold — the successor never started.
1752                size: uploaded_len,
1753                mode: scanned.mode,
1754                mtime_unix: scanned.mtime_unix,
1755                generation,
1756                epoch,
1757            },
1758            baseline_entry: BaselineEntry {
1759                etag,
1760                key: Some(key),
1761                generation,
1762                // The PRE-read stat: if the agent wrote during our read,
1763                // the next scan sees the drift and re-queues (the
1764                // re-stat/re-queue valve).
1765                size: scanned.size,
1766                mtime_unix: scanned.mtime_unix,
1767                mtime_nanos: Some(scanned.mtime_nanos),
1768                crc64_b64: Some(crc64_to_b64(crc)),
1769            },
1770        }
1771    }
1772}
1773
1774impl UploadOutcome {
1775    /// `published`, for bytes this barrier found already at the key and
1776    /// cites without a PUT of its own.
1777    #[allow(clippy::too_many_arguments)]
1778    fn adopted(
1779        path: &str,
1780        key: String,
1781        etag: String,
1782        crc: u64,
1783        uploaded_len: u64,
1784        scanned: &scan::ScanEntry,
1785        generation: u64,
1786        epoch: u64,
1787    ) -> UploadOutcome {
1788        match UploadOutcome::published(path, key, etag, crc, uploaded_len, scanned, generation, epoch) {
1789            UploadOutcome::Published { entry, baseline_entry, .. } => {
1790                UploadOutcome::Published { entry, baseline_entry, adopted: true }
1791            }
1792            other => other,
1793        }
1794    }
1795}
1796
1797/// The paths THIS barrier must withhold because the document would cite a
1798/// file and something under it (a published file replaced by a directory,
1799/// or the reverse). Whichever side is this barrier's upsert yields; if both
1800/// are, so does the parent.
1801///
1802/// Every key under `p/` sits in one contiguous run of the sorted map, so a
1803/// range lookup finds them: O(N log N). The first version scanned every
1804/// key for every key, and a 200k-path commit spent 80 s here (bench
1805/// 2026-09-25). The run does NOT start right after `p`: `p-b` and `p.txt`
1806/// sort between `p` and `p/`.
1807pub(super) fn path_clashes<V, U>(
1808    cited: &BTreeMap<String, V>,
1809    upserts: &BTreeMap<String, U>,
1810) -> Vec<String> {
1811    let mut mine: Vec<String> = vec![];
1812    for p in cited.keys() {
1813        let pfx = format!("{p}/");
1814        let mut under = cited.range::<str, _>((std::ops::Bound::Included(pfx.as_str()), std::ops::Bound::Unbounded))
1815            .map(|(k, _)| k)
1816            .take_while(|q| q.starts_with(&pfx))
1817            .peekable();
1818        if under.peek().is_none() {
1819            continue;
1820        }
1821        if upserts.contains_key(p) {
1822            mine.push(p.clone());
1823        } else if let Some(c) = under.find(|q| upserts.contains_key(*q)) {
1824            mine.push(c.clone());
1825        }
1826    }
1827    mine.sort();
1828    mine.dedup();
1829    mine
1830}
1831
1832pub(super) fn local_dirty(local: &Path, base: Option<&BaselineEntry>) -> bool {
1833    match (std::fs::metadata(local), base) {
1834        (Err(_), None) => false,                   // both absent: clean
1835        (Err(_), Some(_)) => true,                 // locally deleted vs baseline
1836        (Ok(_), None) => true,                     // local exists, never published
1837        (Ok(m), Some(b)) => {
1838            scan::stat_changed(b.size, b.mtime_unix, b.mtime_nanos, m.len(), mtime_of(&m), mtime_nanos_of(&m))
1839        }
1840    }
1841}
1842
1843fn file_crc(root: &Path, rel: &str) -> LeanResult<u64> {
1844    use std::io::Read;
1845    let mut crc = flint_store::Crc64Nvme::new();
1846    // No link anywhere on the path (H5): this hashes what a 412 recovery
1847    // is about to call "already there".
1848    let mut f = super::safefs::open_beneath_nofollow(root, rel)?;
1849    let mut buf = vec![0u8; 4 << 20];
1850    loop {
1851        let n = f.read(&mut buf)?;
1852        if n == 0 {
1853            break;
1854        }
1855        crc.update(&buf[..n]);
1856    }
1857    Ok(crc.finalize())
1858}
1859
1860pub(super) fn mtime_nanos_of(m: &std::fs::Metadata) -> u32 {
1861    m.modified()
1862        .ok()
1863        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1864        .map(|d| d.subsec_nanos())
1865        .unwrap_or(0)
1866}
1867
1868pub(super) fn mtime_of(m: &std::fs::Metadata) -> i64 {
1869    m.modified()
1870        .ok()
1871        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1872        .map(|d| d.as_secs() as i64)
1873        .unwrap_or(0)
1874}
1875
1876/// Validate `rel` under `root` WITHOUT creating anything — for callers
1877/// that must decide whether a path is writable before doing any work
1878/// (inbox consume refuses a refused path outright rather than routing it
1879/// through conflict-preserve, which would cost a GET+PUT and leave the
1880/// path in the baseline).
1881pub(super) fn check_contained(root: &Path, rel: &str) -> LeanResult<()> {
1882    resolve_contained(root, rel, false).map(|_| ())
1883}
1884
1885/// Resolve `rel` under `root`, refusing escapes, creating missing parent
1886/// directories. Walks the parent chain component by component: a
1887/// component that EXISTS as a symlink is a refusal (never followed).
1888pub(super) fn contained_path(root: &Path, rel: &str) -> LeanResult<std::path::PathBuf> {
1889    resolve_contained(root, rel, true).map(|(p, _)| p)
1890}
1891
1892/// As `contained_path`, and also what the walk already learned about
1893/// the FINAL component: its `lstat` if it exists, `None` if it does
1894/// not. The walk stats every component to refuse a symlink; a caller
1895/// that then asked `exists()` and `metadata()` paid two more stats for
1896/// an answer it was already holding (checkout's resume check did, on
1897/// every one of 20,000 files).
1898pub(super) fn contained_path_stat(
1899    root: &Path,
1900    rel: &str,
1901) -> LeanResult<(std::path::PathBuf, Option<std::fs::Metadata>)> {
1902    resolve_contained(root, rel, true)
1903}
1904
1905fn resolve_contained(
1906    root: &Path,
1907    rel: &str,
1908    create_dirs: bool,
1909) -> LeanResult<(std::path::PathBuf, Option<std::fs::Metadata>)> {
1910    use std::path::Component;
1911    let refuse = |why: &str| {
1912        Err(LeanError::State(format!(
1913            "refusing write to {rel:?}: {why} (containment)"
1914        )))
1915    };
1916    if rel.is_empty() {
1917        return refuse("empty path");
1918    }
1919    if super::scan::is_control_path(rel) {
1920        // The reserved namespace is the syncer's own; a citation or
1921        // inbox entry naming it is surfaced, never materialized (D0.3).
1922        return refuse("reserved control namespace");
1923    }
1924    if rel.split('/').any(|seg| seg.ends_with(super::scan::TMP_SUFFIX)) {
1925        // Review 2026-09-18, C1: the walk skips this suffix at every
1926        // depth (the consume's own temp sibling, atomicity-7), so a file
1927        // materialised under it is absent from every scan — cited once
1928        // by the repair as a first absence, then classified a delete and
1929        // its object collected. Surfaced and never materialised, like the
1930        // control namespace; the gateway refuses the name at the door.
1931        return refuse("a name reserved for the syncer's temporary files");
1932    }
1933    let relp = Path::new(rel);
1934    let mut cur = root.to_path_buf();
1935    let mut comps: Vec<&std::ffi::OsStr> = vec![];
1936    for c in relp.components() {
1937        match c {
1938            Component::Normal(n) => comps.push(n),
1939            Component::CurDir => {}
1940            Component::ParentDir => return refuse("`..` component"),
1941            Component::RootDir | Component::Prefix(_) => return refuse("absolute path"),
1942        }
1943    }
1944    if comps.is_empty() {
1945        return refuse("no path components");
1946    }
1947    let last = comps.len() - 1;
1948    let mut final_meta = None;
1949    for (i, c) in comps.iter().enumerate() {
1950        cur.push(c);
1951        match std::fs::symlink_metadata(&cur) {
1952            Ok(m) if m.file_type().is_symlink() => {
1953                return refuse("path traverses a symlink");
1954            }
1955            Ok(m) if i < last && !m.is_dir() => {
1956                return refuse("parent component is not a directory");
1957            }
1958            Ok(m) => {
1959                if i == last {
1960                    final_meta = Some(m);
1961                }
1962            }
1963            Err(_) if i < last => {
1964                if create_dirs {
1965                    if let Err(e) = std::fs::create_dir(&cur) {
1966                        // A SIBLING may have created it between the stat
1967                        // above and this mkdir: fetches run on their own
1968                        // tasks, so the first files of one directory
1969                        // race here, and the loser's EEXIST is not a
1970                        // containment refusal. It is fine only if what
1971                        // exists now is a plain directory — a symlink
1972                        // that appeared in the window is refused exactly
1973                        // as one the stat found.
1974                        match std::fs::symlink_metadata(&cur) {
1975                            Ok(m) if m.file_type().is_symlink() => {
1976                                return refuse("path traverses a symlink");
1977                            }
1978                            Ok(m) if m.is_dir() => {}
1979                            _ => {
1980                                return Err(LeanError::State(format!(
1981                                    "mkdir {}: {e}",
1982                                    cur.display()
1983                                )))
1984                            }
1985                        }
1986                    }
1987                }
1988            }
1989            Err(_) => {}
1990        }
1991    }
1992    Ok((cur, final_meta))
1993}
1994
1995/// Write `bytes` to `path` through an exclusive temp sibling and a
1996/// rename. The parent already exists — containment created it — and
1997/// one that vanished since is recreated on the retry path in `safefs`,
1998/// so the common path pays no `mkdir` and no `stat` for it. Returns
1999/// the written file's metadata.
2000pub(super) fn write_file_atomic(
2001    path: &Path,
2002    bytes: &[u8],
2003    mode: Option<u32>,
2004) -> LeanResult<std::fs::Metadata> {
2005    super::safefs::check_parent(path)?;
2006    // NOT with_extension(): that REPLACES the final extension, so
2007    // "a.txt" and "a.md" would collide on one tmp name.
2008    let tmp = path.with_file_name(format!(
2009        "{}.flint-sync-tmp",
2010        path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()
2011    ));
2012    // The temp sibling is computed AFTER containment ran, so the walk
2013    // never saw it: it needs its own refusal, not a plain write.
2014    // FAST (no per-file fsync): materialisations are made durable by
2015    // `sync_tree` before the marker or baseline that vouches for them.
2016    super::safefs::write_via_tmp_fast(path, &tmp, bytes, mode)
2017}
2018
2019/// `write_file_atomic`, renamed over `path` only if `proceed()` holds once
2020/// the temp is written (`safefs::write_via_tmp_fast_if`).
2021pub(super) fn write_file_atomic_if(
2022    path: &Path,
2023    bytes: &[u8],
2024    mode: Option<u32>,
2025    proceed: &dyn Fn() -> bool,
2026) -> LeanResult<Option<std::fs::Metadata>> {
2027    super::safefs::check_parent(path)?;
2028    let tmp = path.with_file_name(format!(
2029        "{}.flint-sync-tmp",
2030        path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()
2031    ));
2032    super::safefs::write_via_tmp_fast_if(path, &tmp, bytes, mode, proceed)
2033}
2034
2035// Test-only: run a closure inside a write window for one path —
2036// `"before-write"` (the licence to overwrite checked, the temp not yet
2037// written), `"after-rename"` (before the baseline records the write), or
2038// `"before-delete"` (`sync`'s remote-delete arm). The consume and `sync`
2039// both open them. Nothing in either window calls the store, so the hooked double
2040// cannot reach them (review 2026-09-18, H7).
2041#[cfg(test)]
2042thread_local! {
2043    pub(super) static CONSUME_WINDOW_HOOK: std::cell::RefCell<Option<(String, &'static str, Box<dyn Fn()>)>> =
2044        std::cell::RefCell::new(None);
2045}
2046
2047#[cfg(test)]
2048pub(super) fn consume_window(stage: &'static str, path: &str) {
2049    let hook = CONSUME_WINDOW_HOOK.with(|h| {
2050        let mut h = h.borrow_mut();
2051        if matches!(&*h, Some((p, s, _)) if p == path && *s == stage) {
2052            h.take().map(|(_, _, f)| f)
2053        } else {
2054            None
2055        }
2056    });
2057    if let Some(f) = hook {
2058        f();
2059    }
2060}
2061
2062#[cfg(not(test))]
2063#[inline(always)]
2064pub(super) fn consume_window(_: &'static str, _: &str) {}
2065
2066/// How many merge races a commit may lose before its barrier gives up (the
2067/// next tick tries again). Under P2 the UI saves without the lease (G1), so
2068/// the races are lost to saves, and backing off finds the gaps between them.
2069const CAS_ATTEMPTS: u32 = 12;
2070
2071/// The wait after the `attempt`-th lost race: 10 ms, doubling, capped at
2072/// 640 ms, plus up to half again of jitter from the clock's nanoseconds, so
2073/// a writer and a stream of saves do not fall into lockstep.
2074fn cas_backoff(attempt: u32) -> std::time::Duration {
2075    let base = 10u64 << attempt.saturating_sub(1).min(6);
2076    let nanos = std::time::SystemTime::now()
2077        .duration_since(std::time::UNIX_EPOCH)
2078        .map(|d| d.subsec_nanos() as u64)
2079        .unwrap_or(0);
2080    std::time::Duration::from_millis(base + nanos % (base / 2 + 1))
2081}
2082
2083
2084/// The merge base, P1-lite: the baseline's own citations. A tree holds no
2085/// version the document never cited (P2), so what it integrated IS what it
2086/// merges from; one base serves the merge and every reader of its outcome
2087/// (M5, L-124).
2088fn merge_base(baseline: &super::state::Baseline) -> BTreeMap<String, String> {
2089    baseline.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect()
2090}
2091
2092/// One three-way merge of a barrier's changes onto the bucket's manifest.
2093struct MergeOnto {
2094    theirs: manifest::LeanManifest,
2095    /// The CAS handle over `theirs`; `None` when the bucket has no manifest.
2096    expected: Option<manifest::ManifestHandle>,
2097    merged: manifest::LeanManifest,
2098    /// Other writers' changes since the merge base that the tree lacks.
2099    foreign: Vec<(String, LeanEntry)>,
2100    /// Theirs' version at a path this barrier uploaded, changed since the
2101    /// merge base: what mine publishes OVER. The caller surfaces those it
2102    /// never integrated (design 2026-09-19, R7).
2103    overridden: Vec<(String, LeanEntry)>,
2104    /// Theirs' DELETES this barrier's uploads publish over (`merge`).
2105    recreated: Vec<(String, String)>,
2106    /// Theirs' version at a path this barrier DELETES, changed since the
2107    /// merge base (M3): the caller preserves and records each.
2108    over_theirs: Vec<(String, LeanEntry)>,
2109}
2110
2111impl MergeOnto {
2112    /// The merge adds nothing to the document: installing it would be an
2113    /// empty generation.
2114    fn adds_nothing(&self) -> bool {
2115        self.merged.entries == self.theirs.entries && self.merged.sole_writer == self.theirs.sole_writer
2116    }
2117}
2118
2119impl Syncer {
2120    /// H10: the carried uploads the document this barrier ended at does not
2121    /// cite (a peer deleted them since). The ack names that document, so it
2122    /// reads this; no request is made for it.
2123    fn note_carrier_uncited(&self, doc: &manifest::LeanManifest) -> LeanResult<()> {
2124        let mut intent = self.state.load_intent()?;
2125        let Some(c) = intent.carrier.as_mut() else { return Ok(()) };
2126        let uncited: BTreeSet<String> = c
2127            .paths
2128            .iter()
2129            .filter(|p| {
2130                !c.deletes.contains(*p)
2131                    && match doc.entries.get(p.as_str()) {
2132                        None => true,
2133                        // L-137: other bytes at the path are not the upload
2134                        // this writer carried.
2135                        Some(e) => c.published.get(p.as_str()).is_some_and(|crc| *crc != e.crc64_b64),
2136                    }
2137            })
2138            .cloned()
2139            .collect();
2140        if uncited != c.uncited {
2141            c.uncited = uncited;
2142            self.state.save_intent(&intent)?;
2143        }
2144        Ok(())
2145    }
2146
2147    fn merge_onto(
2148        &self,
2149        current: Option<&manifest::LoadedManifest>,
2150        // `merge_base`'s answer, which the caller also gives every other
2151        // reader of this merge's outcome.
2152        base: &BTreeMap<String, String>,
2153        upserts: &BTreeMap<String, LeanEntry>,
2154        deletes: &BTreeSet<String>,
2155        parked: &BTreeSet<String>,
2156        flush_uuid: &str,
2157    ) -> MergeOnto {
2158        let (theirs, expected) = match current {
2159            // The handle carries the LAYOUT as well as the tag: a
2160            // workspace still on the legacy single object CASes a
2161            // pointer that must not exist yet, not one it read.
2162            Some(l) => (l.manifest.clone(), Some(l.handle())),
2163            None => (Default::default(), None),
2164        };
2165        let manifest::Merged { doc: mut merged, foreign, overridden, deletes: outcomes, recreated, over_theirs } =
2166            manifest::merge(base, &theirs, upserts, deletes, parked);
2167        // The deletes theirs outranked, named: the trace's record of the
2168        // merge's per-path decision (`TraceCore.tla` checks it against
2169        // `Install`'s `Foreign(s, p)`).
2170        // Since M3 no delete is outranked; the trace keeps the field (empty)
2171        // and names the deletes that went over theirs.
2172        let outranked: Vec<&String> = vec![];
2173        let deleted_over: Vec<&String> =
2174            outcomes.iter().filter(|(_, o)| **o == manifest::DeleteOutcome::OverTheirs).map(|(p, _)| p).collect();
2175        // Deletions another writer made since this workspace's
2176        // merge base: in the base, gone from theirs, and not this
2177        // barrier's own upsert, delete or park. `merge` has no use
2178        // for them — theirs already lacks the path — but the TREE
2179        // does: they reach it through the local queue, as the
2180        // foreign upserts do.
2181        let gone: Vec<(String, String)> = base
2182            .iter()
2183            .filter(|(p, _)| {
2184                !theirs.entries.contains_key(*p)
2185                    && !upserts.contains_key(*p)
2186                    && !deletes.contains(*p)
2187                    && !parked.contains(*p)
2188            })
2189            .map(|(p, retired)| (p.clone(), retired.clone()))
2190            .collect();
2191        // `merge` clears it; the installing pass owns it. A mirror
2192        // is a property of how this workspace is DEPLOYED, so it
2193        // comes from config on every publish rather than being
2194        // inherited from whatever wrote last.
2195        merged.sole_writer = self.cfg.sole_writer;
2196        self.trace("merge", serde_json::json!({"flush": flush_uuid, "theirs_seq": theirs.seq, "upserts": upserts.len(),
2197            "deletes": deletes.len(), "foreign": foreign.len(), "gone": gone.len(), "overridden": overridden.len(),
2198            "outranked": outranked, "over_theirs": deleted_over, "adds_nothing": merged.entries == theirs.entries}));
2199        MergeOnto { theirs, expected, merged, foreign, overridden, recreated, over_theirs }
2200    }
2201}