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