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