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