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, 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/// Deliberately BELOW the run loop's `renew_every` (min(floor, 30) s) so
103/// a long barrier keeps the same heartbeat cadence the loop would have,
104/// and comfortably below the 6-poll (~60 s) takeover window.
105const RENEW_WITHIN_SECS: u64 = 20;
106
107impl Syncer {
108 fn lease_epoch(&self) -> LeanResult<u64> {
109 self.lease
110 .as_ref()
111 .map(|l| l.epoch)
112 .ok_or_else(|| LeanError::State("barrier without a held lease".into()))
113 }
114
115 /// The in-loop honor paths' fence check (boundary-verbs plan D2):
116 /// `Syncer::sync` carries no lease/epoch check of its own, so a
117 /// straggler consuming a sync sentinel between deposal and its next
118 /// cooperative fence would apply the successor's manifest onto its
119 /// zombie tree and ack SUCCESS.
120 pub async fn verify_not_deposed_pub(&self) -> LeanResult<()> {
121 self.verify_not_deposed().await
122 }
123
124 /// Verify the cell still names us at OUR epoch; anything else is a
125 /// fence. Read-verify before the manifest CAS (the per-request
126 /// validation the gateway will also enforce).
127 async fn verify_not_deposed(&self) -> LeanResult<()> {
128 let lease = self
129 .lease
130 .as_ref()
131 .ok_or_else(|| LeanError::State("no lease".into()))?;
132 match self.store.epoch_read(&self.cfg.epoch_key()).await? {
133 Some(state) if state.epoch == lease.epoch && state.holder_id == lease.holder_id => {
134 Ok(())
135 }
136 Some(state) => Err(LeanError::Fenced(format!(
137 "cell at epoch {} holder {} (we are epoch {})",
138 state.epoch, state.holder_id, lease.epoch
139 ))),
140 None => Err(LeanError::Fenced("epoch cell vanished".into())),
141 }
142 }
143
144 /// Step 1: consume the inbox. A barrier NEVER runs against an
145 /// unconsumed inbox — this is what makes HITL uploads structurally
146 /// un-amputatable. Returns the entries integrated (dropped from the
147 /// cell at the window-open CAS).
148 pub async fn consume_inbox(&mut self) -> LeanResult<Vec<InboxEntry>> {
149 let loaded = inbox::load(self.store.as_ref(), &self.cfg).await?;
150 self.consume_inbox_doc(&loaded.doc).await
151 }
152
153 /// `consume_inbox` over a cell the caller has already read — the
154 /// barrier reads it once and hands the same document to
155 /// `apply_removals`, so the removal pass adds no request.
156 pub async fn consume_inbox_doc(&mut self, doc: &InboxDoc) -> LeanResult<Vec<InboxEntry>> {
157 // §2.5's layered doors ride the inbox GET this function already
158 // pays for: promptness is one tick, and the added request count
159 // is ZERO. A failure here must never fail the consume — the
160 // request is idempotent state and the next tick re-reads it.
161 if let Err(e) = self.note_verb_requests(doc) {
162 eprintln!("flint-sync: verb request not consumed (retrying next tick): {e}");
163 }
164 let mut consumed = vec![];
165 let mut baseline = self.state.load_baseline()?;
166 for entry in &doc.entries {
167 let key = self.cfg.file_key(&entry.path);
168 // Containment BEFORE anything else: a path we could never
169 // safely materialize must be surfaced and dropped, not
170 // routed through the locally-dirty branch — which would
171 // "preserve" it with a GET+PUT and leave a baseline entry
172 // for a path the scanner can never see (the planted-symlink
173 // shape: `inputs -> /root/.aws` reads as locally-present,
174 // therefore dirty, therefore a conflict-preserve of someone
175 // else's file).
176 if let Err(e) = check_contained(&self.cfg.root, &entry.path) {
177 self.state.append_conflict(&ConflictRecord {
178 path: entry.path.clone(),
179 foreign_etag: entry.etag.clone(),
180 preserved_key: None,
181 kind: format!("consume-refused-containment: {e}"),
182 at_unix: now_unix(),
183 })?;
184 consumed.push(entry.clone());
185 continue;
186 }
187 // Already integrated (a crashed earlier consume): idempotent.
188 if baseline.entries.get(&entry.path).map(|b| b.etag == entry.etag).unwrap_or(false) {
189 consumed.push(entry.clone());
190 continue;
191 }
192 let head = match self.store.head(&key).await {
193 Ok(m) => m,
194 Err(StoreError::NotFound(_)) => {
195 // The object vanished under a tracked entry: surface
196 // it — the bytes are NOT recoverable from here.
197 self.state.append_conflict(&ConflictRecord {
198 path: entry.path.clone(),
199 foreign_etag: entry.etag.clone(),
200 preserved_key: None,
201 kind: "consume-object-missing".into(),
202 at_unix: now_unix(),
203 })?;
204 consumed.push(entry.clone());
205 continue;
206 }
207 Err(e) => return Err(e.into()),
208 };
209 if head.etag != entry.etag {
210 // Superseded by a newer write (its own inbox entry
211 // follows, or it is the syncer's): drop.
212 consumed.push(entry.clone());
213 continue;
214 }
215 let local_path = self.cfg.root.join(&entry.path);
216 let dirty = local_dirty(&local_path, baseline.entries.get(&entry.path));
217 if dirty {
218 // Locally-dirty wins; preserve the FOREIGN bytes first
219 // (a conflict record must keep both versions
220 // recoverable), then advance the recognized ETag so our
221 // eventual publish supersedes it KNOWINGLY.
222 let preserved = self.preserve_conflict_copy(&entry.path, &head.etag).await?;
223 self.state.append_conflict(&ConflictRecord {
224 path: entry.path.clone(),
225 foreign_etag: entry.etag.clone(),
226 preserved_key: Some(preserved),
227 kind: "consume-dirty".into(),
228 at_unix: now_unix(),
229 })?;
230 let stamps = GenerationStamps::from_meta(&head.meta);
231 baseline.entries.insert(
232 entry.path.clone(),
233 BaselineEntry {
234 etag: head.etag.clone(),
235 generation: stamps.map(|s| s.generation).unwrap_or(0),
236 // Deliberately NOT the local file's stat: the
237 // path must still scan as dirty so the local
238 // version publishes.
239 size: u64::MAX,
240 mtime_unix: 0,
241 version_id: None,
242 // The sentinel's bytes are the LOCAL edit; the
243 // publish that supersedes hashes them itself.
244 crc64_b64: None,
245 },
246 );
247 } else {
248 // Clean: adopt the foreign content into the tree.
249 let (meta, body) =
250 self.store.get_whole(&key, Some(&entry.etag)).await.map_err(|e| match e {
251 StoreError::PreconditionFailed(m) => {
252 StoreError::Other(format!("consume raced a newer write: {m}"))
253 }
254 other => other,
255 })?;
256 let mode = PosixStamps::from_meta(&meta.meta).map(|p| p.mode);
257 // VERIFIED before it is written, as checkout's fresh
258 // fetch is: against the writer's CRC when the inbox
259 // entry carries one (the gateway hashes what it sent),
260 // else the backend's attestation when it offers one.
261 // Ozone offers none, so the inbox's is what a HITL
262 // upload gets checked against there. What the baseline
263 // records — and the next citation repair cites — is
264 // OURS, over the bytes actually written.
265 let got = crc64_to_b64(crc64_nvme(&body));
266 let want = entry.crc64_b64.clone().or_else(|| meta.crc64_b64.clone());
267 if let Some(want) = want {
268 if want != got {
269 // NOT consumed: the entry stays in the cell and
270 // the record repeats — a permanent, visible
271 // contradiction is the right failure for
272 // bytes nobody vouches for.
273 self.state.append_conflict(&ConflictRecord {
274 path: entry.path.clone(),
275 foreign_etag: entry.etag.clone(),
276 preserved_key: None,
277 kind: format!(
278 "consume-refused-checksum: etag {} is attested with CRC-64 \
279 {want} but the bytes fetched under it hash to {got}",
280 entry.etag
281 ),
282 at_unix: now_unix(),
283 })?;
284 continue;
285 }
286 }
287 // CONTAINMENT and I/O are split, because the answers are
288 // opposite. Containment was already decided above by
289 // `check_contained`, so a refusal reaching here is a
290 // path that only became unsafe once parents were
291 // created — still a permanent property of the path, so
292 // it is surfaced and DROPPED, exactly as above.
293 //
294 // Everything else — ENOSPC, EACCES, EIO, a read-only
295 // filesystem — is TRANSIENT, and dropping the entry for
296 // one is silent permanent loss: the foreign bytes stay
297 // in the bucket, the workspace never adopts them, and
298 // nothing ever re-offers the entry. It used to be
299 // recorded as `consume-refused-containment`, which sent
300 // whoever read it looking for a hostile path that was
301 // never there. A full disk is not a planted symlink.
302 let target = match contained_path(&self.cfg.root, &entry.path) {
303 Ok(t) => t,
304 Err(e) => {
305 self.state.append_conflict(&ConflictRecord {
306 path: entry.path.clone(),
307 foreign_etag: entry.etag.clone(),
308 preserved_key: None,
309 kind: format!("consume-refused-containment: {e}"),
310 at_unix: now_unix(),
311 })?;
312 consumed.push(entry.clone());
313 continue;
314 }
315 };
316 if let Err(e) = write_file_atomic(&target, &body, mode) {
317 // NOT consumed: the entry stays in the cell so the
318 // next barrier retries it. A visible conflict record
319 // that repeats is a far better failure than one
320 // silent drop.
321 self.state.append_conflict(&ConflictRecord {
322 path: entry.path.clone(),
323 foreign_etag: entry.etag.clone(),
324 preserved_key: None,
325 kind: format!("consume-write-failed (will retry): {e}"),
326 at_unix: now_unix(),
327 })?;
328 continue;
329 }
330 let st = std::fs::metadata(&local_path)?;
331 let stamps = GenerationStamps::from_meta(&meta.meta);
332 baseline.entries.insert(
333 entry.path.clone(),
334 BaselineEntry {
335 etag: meta.etag.clone(),
336 generation: stamps.map(|s| s.generation).unwrap_or(0),
337 size: st.len(),
338 mtime_unix: mtime_of(&st),
339 version_id: None,
340 crc64_b64: Some(got),
341 },
342 );
343 baseline.prev_scan.insert(entry.path.clone());
344 }
345 consumed.push(entry.clone());
346 }
347 // The files this baseline vouches for reached the disk before
348 // the baseline does (audit 2026-09-03, finding 9).
349 self.state.sync_tree()?;
350 self.state.save_baseline(&baseline)?;
351 Ok(consumed)
352 }
353
354 /// Step 1b: perform the DECLARED removals (delete/rename design
355 /// §4-§6). Runs after `consume_inbox_doc`, so a rename's destination
356 /// is in the tree before its source leaves it — §5: create first,
357 /// removal second; a partial application leaves an EXTRA file,
358 /// never a missing one.
359 ///
360 /// Per removal, in order:
361 /// - the path must be containable, as a consume's must;
362 /// - a rename WAITS until its destination is integrated (in the
363 /// baseline): a consume that deferred the destination defers the
364 /// removal with it;
365 /// - a locally-DIRTY path — unpublished edits, or a file the agent
366 /// created there — is REFUSED, nothing applied, the agent's work
367 /// untouched. The same rule a consume applies to a HITL write
368 /// over dirty bytes, and the reason a declared removal cannot be
369 /// "just a delete". Refused for good: the cell is told, and a
370 /// retry that waited for the agent to publish would delete the
371 /// very edit the refusal protected;
372 /// - a clean file is unlinked, and the unlink is CONFIRMED by lstat
373 /// before the path is declared — an unlink that did not take
374 /// publishes no deletion (`confirm_absences`' rule, kept);
375 /// - a path already absent locally and known to the baseline or the
376 /// merge base is declared as it stands: the agent got there
377 /// first, or this tree never held it (a scoped workspace).
378 ///
379 /// A declared path skips the two-scan guard (§4): that guard
380 /// protects against absence INFERRED by a walk, and a declaration
381 /// is not an inference. That is what lets a rename's two halves
382 /// ride one manifest generation.
383 pub async fn apply_removals(&mut self, doc: &InboxDoc) -> LeanResult<RemovalPass> {
384 let mut pass = RemovalPass::default();
385 let baseline = self.state.load_baseline()?;
386 // A crashed earlier barrier that had unlinked and journalled but
387 // not installed: its declarations are still deletions with a
388 // recorded basis — unless the agent has since put a file back.
389 for p in self.state.load_intent()?.declared_deletes {
390 let gone = matches!(
391 std::fs::symlink_metadata(self.cfg.root.join(&p)),
392 Err(ref e) if e.kind() == std::io::ErrorKind::NotFound
393 );
394 if gone && (baseline.entries.contains_key(&p) || baseline.inst_base.contains_key(&p)) {
395 pass.declared.insert(p);
396 }
397 }
398 for r in &doc.removals {
399 if r.refused.is_some() {
400 continue;
401 }
402 let refusal = |kind: &str, message: String| Removal {
403 refused: Some(Refusal { kind: kind.into(), message, at_unix: now_unix() }),
404 ..r.clone()
405 };
406 let mut refuse = |kind: &str, message: String| -> LeanResult<()> {
407 self.state.append_conflict(&ConflictRecord {
408 path: r.path.clone(),
409 foreign_etag: String::new(),
410 preserved_key: None,
411 kind: format!("{kind}: {message}"),
412 at_unix: now_unix(),
413 })?;
414 pass.refused.push(refusal(kind, message));
415 Ok(())
416 };
417 if let Err(e) = check_contained(&self.cfg.root, &r.path) {
418 refuse("removal-refused-containment", e.to_string())?;
419 continue;
420 }
421 if let Some(dst) = &r.moved_to {
422 match baseline.entries.get(dst) {
423 // Not integrated yet (a consume deferred it): wait.
424 None => {
425 pass.deferred += 1;
426 continue;
427 }
428 // Integrated as a CONFLICT: the agent had an
429 // unpublished file at the destination, its version
430 // won, and the moved bytes are preserved under
431 // `conflicts/`. Deleting the source now would leave
432 // the user's file nowhere in the tree — so the move
433 // is refused and the source stays. The sentinel is
434 // the consume-dirty baseline entry's `u64::MAX`.
435 Some(b) if b.size == u64::MAX => {
436 refuse(
437 "removal-refused-destination-conflict",
438 format!(
439 "{} 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",
440 r.path
441 ),
442 )?;
443 continue;
444 }
445 Some(_) => {}
446 }
447 }
448 let local = self.cfg.root.join(&r.path);
449 let be = baseline.entries.get(&r.path);
450 match std::fs::symlink_metadata(&local) {
451 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
452 if be.is_some() || baseline.inst_base.contains_key(&r.path) {
453 pass.declared.insert(r.path.clone());
454 }
455 // Nothing here and nothing cited: a removal of a path
456 // that was never there, or that a sibling already
457 // removed. Applied as a no-op.
458 pass.applied.push(r.clone());
459 }
460 Err(e) => {
461 // Unreadable is not absent (`confirm_absences`' rule):
462 // fail closed, and say so where the pod-side reader
463 // looks. Transient, so deferred rather than refused.
464 self.state.append_conflict(&ConflictRecord {
465 path: r.path.clone(),
466 foreign_etag: String::new(),
467 preserved_key: None,
468 kind: format!("removal-deferred: cannot stat the path: {e}"),
469 at_unix: now_unix(),
470 })?;
471 pass.deferred += 1;
472 }
473 Ok(m) if !m.is_file() => {
474 refuse(
475 "removal-refused-not-a-file",
476 format!("{} is not a regular file in the workspace", r.path),
477 )?;
478 }
479 Ok(_) => {
480 if local_dirty(&local, be) {
481 refuse(
482 "removal-refused-dirty",
483 format!(
484 "{} 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",
485 r.path, r.author
486 ),
487 )?;
488 continue;
489 }
490 if let Err(e) = std::fs::remove_file(&local) {
491 self.state.append_conflict(&ConflictRecord {
492 path: r.path.clone(),
493 foreign_etag: String::new(),
494 preserved_key: None,
495 kind: format!("removal-unlink-failed (will retry): {e}"),
496 at_unix: now_unix(),
497 })?;
498 pass.deferred += 1;
499 continue;
500 }
501 // CONFIRM: only NotFound is absence.
502 match std::fs::symlink_metadata(&local) {
503 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
504 pass.declared.insert(r.path.clone());
505 pass.applied.push(r.clone());
506 }
507 _ => {
508 self.state.append_conflict(&ConflictRecord {
509 path: r.path.clone(),
510 foreign_etag: String::new(),
511 preserved_key: None,
512 kind: "removal-unlink-unconfirmed (will retry)".into(),
513 at_unix: now_unix(),
514 })?;
515 pass.deferred += 1;
516 }
517 }
518 }
519 }
520 }
521 Ok(pass)
522 }
523
524 /// Act on the inbox document's two request fields (§2.5, D14).
525 ///
526 /// The asymmetry is deliberate and is about blast radius, not
527 /// principle: a boundary request is PERFORMED (it publishes what is
528 /// already on disk and mutates nothing local), a sync request is
529 /// CARRIED into the ticker as advisory news and the agent decides.
530 ///
531 /// Both are watermarked by `requested_unix` rather than consumed by
532 /// a clearing CAS. The field is idempotent state, so a repeat is a
533 /// no-op and a lost clear cannot double-publish; and not writing
534 /// means the doors cost no request at all, which is what §2.5
535 /// promises.
536 fn note_verb_requests(&mut self, doc: &inbox::InboxDoc) -> LeanResult<()> {
537 let mut mark = self.load_verb_watermark()?;
538 if let Some(r) = &doc.sync_request {
539 if r.requested_unix > mark.sync_unix {
540 self.carry_sync_request(r.requested_unix, &r.requestor)?;
541 mark.sync_unix = r.requested_unix;
542 }
543 }
544 if let Some(r) = &doc.boundary_request {
545 if r.requested_unix > mark.boundary_unix {
546 self.request_boundary(
547 &format!("gw:{}:{}", r.requested_unix, r.requestor),
548 Some(format!("boundary requested by {}", r.requestor)),
549 )?;
550 mark.boundary_unix = r.requested_unix;
551 }
552 }
553 self.save_verb_watermark(&mark)
554 }
555
556 fn verb_watermark_path(&self) -> std::path::PathBuf {
557 self.cfg.state_dir().join("verb-requests.json")
558 }
559
560 pub(crate) fn load_verb_watermark(&self) -> LeanResult<VerbWatermark> {
561 let p = self.verb_watermark_path();
562 if !p.exists() {
563 return Ok(VerbWatermark::default());
564 }
565 Ok(std::fs::read(&p)
566 .ok()
567 .and_then(|b| serde_json::from_slice(&b).ok())
568 .unwrap_or_default())
569 }
570
571 fn save_verb_watermark(&self, m: &VerbWatermark) -> LeanResult<()> {
572 let bytes = serde_json::to_vec_pretty(m)
573 .map_err(|e| LeanError::State(format!("verb watermark: {e}")))?;
574 super::control::write_atomic(&self.verb_watermark_path(), &bytes)
575 }
576
577 async fn preserve_conflict_copy(&self, path: &str, etag: &str) -> LeanResult<String> {
578 let key = self.cfg.file_key(path);
579 let dst = self.cfg.conflict_key(&uuid::Uuid::new_v4().to_string(), path);
580 // v1: client-side copy (GET + guarded PUT). The server-side
581 // CopyObject lever is the designed v2 optimization for large
582 // files.
583 let (meta, body) = self.store.get_whole(&key, Some(etag)).await?;
584 let crc = crc64_nvme(&body);
585 let stamps = GenerationStamps {
586 generation: 0,
587 epoch: self.lease.as_ref().map(|l| l.epoch).unwrap_or(0),
588 flush_uuid: "conflict-preserve".into(),
589 boundary_source: None,
590 posix: PosixStamps::from_meta(&meta.meta),
591 };
592 self.store
593 .put_whole(&dst, body, &PutCondition::IfNoneMatchAny, &stamps, crc)
594 .await?;
595 Ok(dst)
596 }
597
598 /// Take the SECOND absence observation now, by direct `lstat`.
599 ///
600 /// `scan::classify` withholds a path's delete until absence has
601 /// survived two consecutive scans — the rename-vs-walk race guard
602 /// (`lib.rs:37`). The hazard that rule names is the WALK missing a
603 /// file renamed under it, not deletion itself. On the cadence path
604 /// the second walk arrives at the next floor tick and nobody has
605 /// been promised otherwise. On a DECLARED boundary it cannot wait:
606 /// the ack would claim a coherent point (D1 — "everything
607 /// ordered-before T") while the manifest still cites a file the
608 /// agent removed before it touched the sentinel, and it would say
609 /// so with `report.deleted: 0` and `status: "ok"`.
610 ///
611 /// A direct `lstat` is exactly what the rename-vs-walk guard asks
612 /// for and is immune to the walk race by construction, so the
613 /// second observation costs one syscall per transiently-absent
614 /// path — never a second full pass. The cadence path is unchanged
615 /// and still waits for the second walk.
616 pub(crate) fn confirm_absences(&self, classified: &mut scan::Classified) -> LeanResult<usize> {
617 if classified.first_absence.is_empty() {
618 return Ok(0);
619 }
620 let mut confirmed = 0;
621 for path in std::mem::take(&mut classified.first_absence) {
622 // ONLY NotFound is absence. Every other errno — EACCES on a
623 // parent, EIO, EMFILE, ELOOP — used to read as "the agent
624 // deleted it", and this is the oracle that PUBLISHES the
625 // delete and then DELETES THE OBJECT. An unreadable file is
626 // the one case where guessing is unrecoverable, so it fails
627 // CLOSED, exactly as the walk does at `scan.rs`'s
628 // `symlink_metadata(&path)?`. Two call sites, one syscall:
629 // they must not have opposite error policies.
630 let gone = match std::fs::symlink_metadata(self.cfg.root.join(&path)) {
631 Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
632 Err(e) => {
633 classified.first_absence.insert(path.clone());
634 return Err(LeanError::State(format!(
635 "cannot confirm the absence of {path:?}: {e} — refusing to publish \
636 a deletion on an unreadable path"
637 )));
638 }
639 Ok(_) => false,
640 };
641 if gone {
642 classified.deletes.insert(path);
643 confirmed += 1;
644 } else {
645 // Back on disk: the rename-vs-walk race, caught exactly
646 // as the rule intends. Still only a first absence.
647 classified.first_absence.insert(path);
648 }
649 }
650 Ok(confirmed)
651 }
652
653 /// Renew the lease if it has gone stale MID-BARRIER, and hand back
654 /// the cell as it was read so the CALLER can fence on it.
655 ///
656 /// Costs nothing on a short barrier: the renewal is skipped unless
657 /// it was already due, so the request count is unchanged — this
658 /// moves WHEN the heartbeat can happen, not how often.
659 ///
660 /// What this does NOT do, stated because the previous comment
661 /// claimed it did: it never raises `Fenced` for a deposed holder.
662 /// `lease::renew` can only 412 when the cell is still at OUR epoch
663 /// with a token we do not hold; a cell at a FOREIGN epoch is
664 /// skipped here (renewing it would be nonsense), so a deposed
665 /// straggler passed straight through. The 2026-09-03 audit found
666 /// the cadence barrier relying on exactly that phantom fence
667 /// between upload chunks and completing every remaining data PUT
668 /// after a takeover. The cell is returned so the caller can compare
669 /// it against its lease — the gated lanes already pay a separate
670 /// `verify_not_deposed_pub` for the same purpose; the cadence
671 /// barrier uses the read it already paid for. `None` when there is
672 /// no lease or the read failed: neither is a fence, and the
673 /// ordinary renewal arm will try again.
674 pub(crate) async fn renew_if_due(&mut self) -> LeanResult<Option<EpochState>> {
675 let Some(lease) = self.lease.clone() else { return Ok(None) };
676 let state = match self.store.epoch_read(&self.cfg.epoch_key()).await {
677 Ok(Some(s)) => s,
678 _ => return Ok(None),
679 };
680 let now = super::now_unix();
681 let fresh = state
682 .last_renew_unix
683 .map(|t| now.saturating_sub(t) < RENEW_WITHIN_SECS)
684 .unwrap_or(false);
685 if fresh || state.epoch != lease.epoch {
686 // Not due, or not ours to renew. The caller decides what a
687 // foreign cell means; this function only knows it cannot
688 // renew one.
689 return Ok(Some(state));
690 }
691 super::lease::renew(self).await?;
692 Ok(Some(state))
693 }
694
695 /// The between-chunk fence of the cadence barrier: a cell that no
696 /// longer names this holder at this epoch stops the upload set
697 /// HERE, before the next chunk's PUTs. Every one of those PUTs
698 /// carries If-Match on a baseline etag that still matches — the
699 /// successor has published nothing yet — so without this a deposed
700 /// straggler overwrites the cited generation of every key it still
701 /// had to upload, and every reader's S3-wins arm then adopts the
702 /// uncited bytes silently (audit 2026-09-03, finding 1).
703 fn fence_on_cell(&self, cell: &EpochState) -> LeanResult<()> {
704 let Some(lease) = self.lease.as_ref() else {
705 return Err(LeanError::Fenced("lease dropped mid-barrier".into()));
706 };
707 if cell.epoch != lease.epoch || cell.holder_id != lease.holder_id {
708 return Err(LeanError::Fenced(format!(
709 "deposed between upload chunks: cell at epoch {} holder {} (we are epoch {})",
710 cell.epoch, cell.holder_id, lease.epoch
711 )));
712 }
713 Ok(())
714 }
715
716 /// Steps 2–7. `barrier` = one full publish cycle (the cadence arm).
717 pub async fn run_barrier(&mut self) -> LeanResult<BarrierReport> {
718 self.barrier_inner(false, "manual").await
719 }
720
721 /// The floor tick's barrier. Stamped `cadence` so the bucket can
722 /// answer "which clock installed this boundary?" without the ack —
723 /// the ack is a LOCAL file, and the fleet-visible question is asked
724 /// by operators and by the gateway's `/status`, neither of which
725 /// can see it.
726 pub async fn cadence_barrier(&mut self) -> LeanResult<BarrierReport> {
727 self.barrier_inner(false, "cadence").await
728 }
729
730 /// A barrier whose result somebody is going to ACK: a sentinel
731 /// honor (D1) or the preStop drain (D10). Identical to
732 /// `run_barrier` except that it confirms first-absence paths rather
733 /// than acking a boundary that withholds them.
734 pub async fn declared_barrier(&mut self) -> LeanResult<BarrierReport> {
735 self.barrier_inner(true, "sentinel").await
736 }
737
738 /// `declared_barrier` under an explicit provenance stamp — the
739 /// drain and the budget-deferred honor are the same barrier
740 /// reporting a different clock.
741 pub async fn declared_barrier_as(&mut self, source: &str) -> LeanResult<BarrierReport> {
742 self.barrier_inner(true, source).await
743 }
744
745 async fn barrier_inner(&mut self, declared: bool, source: &str) -> LeanResult<BarrierReport> {
746 let epoch = self.lease_epoch()?;
747 let mut report = BarrierReport::default();
748
749 // STEP 0: finish any rescope a crash left half-applied, before
750 // the scan reads the tree (scoped-read design §4.3). This is the
751 // gate that makes the narrow verb safe at all: a half-applied
752 // narrow is a tree whose files and whose citations disagree, and
753 // `classify` reads that disagreement as an upload or as a
754 // DELETE depending on which half landed. Replay is idempotent,
755 // so the cost on the normal path is one `stat` of a file that
756 // is not there.
757 if let Some(r) = self.replay_scope_intent().await? {
758 report.rescope_replayed = true;
759 eprintln!(
760 "flint-sync: finished an interrupted rescope before the barrier \
761 (uncited {}, unlinked {}, materialised {})",
762 r.uncited, r.unlinked, r.materialized
763 );
764 }
765
766 // Cooperative deposal check BEFORE any write (a thawed
767 // straggler fences here instead of landing data PUTs). This
768 // narrows the window; the per-request epoch validation that
769 // CLOSES it is the gateway's (P5) — the model's LeanNoEpochCheck
770 // mutation is the proof rotation alone does not cover the data
771 // path.
772 self.verify_not_deposed().await?;
773
774 // Step 1: the cell, read ONCE — HITL entries into the tree, then
775 // the DECLARED removals (delete/rename design §4-§6).
776 let inbox_doc = inbox::load(self.store.as_ref(), &self.cfg).await?.doc;
777 let consumed = self.consume_inbox_doc(&inbox_doc).await?;
778 report.consumed = consumed.len();
779 let removals = self.apply_removals(&inbox_doc).await?;
780 report.removed = removals.declared.iter().cloned().collect();
781 report.removals_refused = removals.refused.len();
782 // A refusal is settled NOW, whatever else this barrier does: the
783 // cell is where the human who asked reads the answer, and a
784 // refused removal is never retried, so saying so before the
785 // no-diff return below loses nothing.
786 inbox::settle_removals(self.store.as_ref(), &self.cfg, epoch, &[], &removals.refused)
787 .await?;
788
789 // Step 2: scan-diff against the persisted baseline.
790 let mut baseline = self.state.load_baseline()?;
791 let scanned = scan::scan(&self.cfg.root)?;
792 let mut classified = scan::classify(&scanned, &baseline);
793 if declared {
794 report.absences_confirmed = self.confirm_absences(&mut classified)?;
795 }
796 // A declared removal is a deletion with its basis RECORDED, so
797 // it skips the two-scan guard the walk needs and goes straight
798 // into the delete set — which is what lets a rename's two halves
799 // ride ONE manifest generation (§4).
800 for p in &removals.declared {
801 classified.first_absence.remove(p);
802 if classified.uploads.contains(p) {
803 // The agent re-created the path between the unlink and
804 // the scan. The declaration named the OLD file, which is
805 // gone from the tree (and, for a rename, moved); what is
806 // here now is a new file the agent wrote, and it
807 // publishes as one — its PUT supersedes the old object
808 // under the same key. Deleting it would un-cite the
809 // agent's work for a barrier and GC-skip its object; the
810 // model's install gives the upload the same precedence.
811 continue;
812 }
813 classified.deletes.insert(p.clone());
814 }
815 report.first_absence = classified.first_absence.iter().cloned().collect();
816
817 // Skip-on-no-diff: nothing local, nothing consumed, no pending
818 // citation repair, and the bucket manifest where we left it.
819 let repairs_pending = baseline
820 .entries
821 .iter()
822 .any(|(p, be)| be.size != u64::MAX && baseline.inst_base.get(p) != Some(&be.etag));
823 if classified.uploads.is_empty()
824 && classified.deletes.is_empty()
825 && consumed.is_empty()
826 && classified.first_absence.is_empty()
827 && removals.applied.is_empty()
828 && !repairs_pending
829 {
830 // Read the POINTER, never the entries: the 0b rig measured
831 // the idle tick at 1M entries as 27 s / 1.3 GiB — dominated
832 // by fetching and parsing a 264 MiB document just to read
833 // `seq`. The pointer is a few hundred bytes and carries the
834 // seq in its body, so a GET of it costs one round trip and
835 // answers strictly more than a HEAD of the old object did.
836 //
837 // Legacy workspaces (no pointer yet) keep the HEAD they had.
838 let unchanged = match manifest::load_pointer(self.store.as_ref(), &self.cfg).await? {
839 Some(manifest::LoadedPointer { pointer: p, etag, .. }) => {
840 // The news ticker rides this request for free — D5's
841 // "zero added bucket requests" is still literal.
842 report.observed_seq = Some(p.seq);
843 report.observed_etag = Some(etag.clone());
844 baseline.manifest_etag.as_deref() == Some(etag.as_str())
845 }
846 None => match self.store.head(&self.cfg.manifest_key()).await {
847 Ok(meta) => {
848 report.observed_seq =
849 GenerationStamps::from_meta(&meta.meta).map(|s| s.generation);
850 report.observed_etag = Some(meta.etag.clone());
851 baseline.manifest_etag.as_deref() == Some(meta.etag.as_str())
852 }
853 Err(StoreError::NotFound(_)) => baseline.manifest_etag.is_none(),
854 Err(e) => return Err(e.into()),
855 },
856 };
857 if unchanged {
858 report.no_change = true;
859 report.seq = Some(baseline.seq);
860 // prev_scan still advances (the two-scan rule's clock) —
861 // but the baseline document (hundreds of MB at 1M
862 // entries) is rewritten only when the scan SET moved.
863 // Compare without building the set: both sides iterate
864 // sorted, and the allocation this used to make was the
865 // size of the very document it was trying not to write.
866 if scanned.len() != baseline.prev_scan.len()
867 || !scanned.keys().eq(baseline.prev_scan.iter())
868 {
869 baseline.prev_scan = scanned.keys().cloned().collect();
870 self.state.save_baseline(&baseline)?;
871 }
872 return Ok(report);
873 }
874 }
875
876 // Step 3: intent journal, then the window (the commitment
877 // point: the same CAS drops the consumed entries).
878 let flush_uuid = uuid::Uuid::new_v4().to_string();
879 let mut intent = self.state.load_intent()?;
880 let prior_uuids = {
881 let mut v = intent.recent_uuids.clone();
882 if !intent.flush_uuid.is_empty() {
883 v.push(intent.flush_uuid.clone());
884 }
885 v
886 };
887 // Carried forward, not dropped: it stays true until we install
888 // again, and the merge below is what reads it.
889 let prev_installed = intent.installed_etag.clone();
890 intent = IntentJournal {
891 flush_uuid: flush_uuid.clone(),
892 keys: classified.uploads.iter().map(|p| self.cfg.file_key(p)).collect(),
893 recent_uuids: prior_uuids.clone(),
894 installed_etag: prev_installed.clone(),
895 declared_deletes: removals.declared.iter().cloned().collect(),
896 };
897 self.state.save_intent(&intent)?;
898 let deadline = now_unix() + self.cfg.window_slack_secs;
899 inbox::open_window(self.store.as_ref(), &self.cfg, epoch, deadline).await?;
900 // The consumed entries are NOT dropped here. They used to be —
901 // "durably in the baseline", which is true of a container
902 // restart and false of a pod REPLACEMENT: the emptyDir goes
903 // with the pod, and a spot reclamation between this CAS and
904 // the manifest CAS (the whole upload phase) then left a HITL
905 // write that was acked, consumed and never cited with nothing
906 // in the bucket tracking it — the object an orphan, the
907 // successor's checkout blind to it. They leave the cell with
908 // the window clear, after the manifest cites them; a successor
909 // that finds them re-consumes, idempotently.
910
911 // Step 4: guarded uploads, fanned out under a bounded window
912 // (each key's guard chain is independent; the 412 policy and
913 // conflict records are applied to the collected results below,
914 // in deterministic path order).
915 let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
916 let mut parked: BTreeSet<String> = BTreeSet::new();
917 let mut new_baseline_entries: BTreeMap<String, BaselineEntry> = BTreeMap::new();
918 let outcomes: Vec<(String, LeanResult<UploadOutcome>)> = {
919 use futures::stream::{self, StreamExt};
920 // CHUNKED, and the reason is the lease rather than memory.
921 //
922 // The run loop is one `select!`, so while this call runs the
923 // renewal arm CANNOT fire — branches are mutually exclusive.
924 // An unchunked upload phase therefore starves the heartbeat
925 // for its whole duration, which does two bad things: a
926 // deposed straggler cannot learn it was deposed (the CAS
927 // that would tell it never runs), and a HEALTHY syncer can
928 // outrun the 60 s takeover window and have a standby take
929 // the lease off a live writer. The 0b numbers put a 1M-file
930 // checkout at 7 m 05 s, so this is reachable, not exotic.
931 //
932 // Between chunks nothing borrows `self`, so the renewal can
933 // run on its ordinary ≤30 s cadence — it costs NO extra
934 // requests, because that renewal was already due. The chunk
935 // is a multiple of fan-out so a wave still saturates it.
936 //
937 // Residual, stated rather than hidden: starvation is now
938 // bounded by ONE CHUNK's duration instead of the whole
939 // barrier. A chunk of very large files can still exceed the
940 // window; closing that needs the renewal on its own task,
941 // which needs the lease behind a shared cell.
942 let mut outcomes: Vec<(String, LeanResult<UploadOutcome>)> = Vec::new();
943 // NOT cfg.fanout. That knob was raised to 128 for the READ
944 // path, where a byte semaphore (fetch_inflight_max_bytes)
945 // bounds what concurrency can hold in memory. THIS path has
946 // no such gate — `buffer_unordered` is the only bound — so
947 // riding the same number would have quadrupled both the
948 // bodies held in memory and `chunk`, widening the
949 // lease-fence window from 512 files to 2048 and making the
950 // hazard named in the comment above four times likelier.
951 // The read-side measurement (2.5x on 20k small files) says
952 // nothing about either, so uploads keep the value they were
953 // measured at until someone measures them.
954 let fanout = self.cfg.upload_fanout.max(1);
955 let chunk = fanout.saturating_mul(UPLOAD_CHUNK_WAVES).max(1);
956 let all: Vec<&String> = classified.uploads.iter().collect();
957 for group in all.chunks(chunk) {
958 {
959 let this: &Syncer = &*self;
960 let mut part: Vec<(String, LeanResult<UploadOutcome>)> =
961 stream::iter(group.iter().map(|path| {
962 let path = *path;
963 let scanned_entry = &scanned[path];
964 let base = baseline.entries.get(path);
965 let flush_uuid = &flush_uuid;
966 let prior_uuids = &prior_uuids;
967 async move {
968 let r = this
969 .upload_one(
970 path, scanned_entry, base, epoch, flush_uuid,
971 prior_uuids,
972 )
973 .await;
974 (path.clone(), r)
975 }
976 }))
977 .buffer_unordered(fanout)
978 .collect()
979 .await;
980 outcomes.append(&mut part);
981 }
982 // Now that the borrow is released: keep the lease alive,
983 // and FENCE on the cell that read returned. A deposed
984 // straggler stops here, before the next chunk's PUTs —
985 // the renewal alone never fenced a foreign cell (see
986 // `renew_if_due`), which is how a straggler used to run
987 // its upload set to completion after a takeover.
988 if let Some(cell) = self.renew_if_due().await? {
989 self.fence_on_cell(&cell)?;
990 }
991 }
992 outcomes
993 };
994 let mut outcomes = outcomes;
995 outcomes.sort_by(|a, b| a.0.cmp(&b.0));
996 for (path, outcome) in outcomes {
997 let path = &path;
998 match outcome? {
999 UploadOutcome::Published { entry, baseline_entry } => {
1000 report.published_bytes += entry.size;
1001 upserts.insert(path.clone(), entry);
1002 new_baseline_entries.insert(path.clone(), baseline_entry);
1003 report.uploaded.push(path.clone());
1004 }
1005 UploadOutcome::Parked { foreign_etag } => {
1006 parked.insert(path.clone());
1007 self.state.append_conflict(&ConflictRecord {
1008 path: path.clone(),
1009 foreign_etag,
1010 preserved_key: None, // parking preserves in place
1011 kind: "upload-412-parked".into(),
1012 at_unix: now_unix(),
1013 })?;
1014 report.parked.push(path.clone());
1015 }
1016 UploadOutcome::Deferred => {
1017 report.deferred.push(path.clone());
1018 }
1019 }
1020 }
1021
1022 // Citation repairs: paths whose integrated object (the
1023 // baseline) differs from the manifest's citation — consumed
1024 // HITL adoptions and checkout's S3-wins arm. No bytes move; the
1025 // manifest re-cites what this syncer already integrated.
1026 // Without this, an adopted upload is clean-vs-baseline, never
1027 // enters the upload set, and the manifest silently drops it —
1028 // the battery's amputation leg caught exactly that.
1029 let repair_candidates: Vec<String> = baseline
1030 .entries
1031 .iter()
1032 .filter(|(path, be)| {
1033 !classified.uploads.contains(*path)
1034 && !classified.deletes.contains(*path)
1035 && be.size != u64::MAX // consume-dirty sentinel: publishes via upload
1036 && baseline.inst_base.get(*path) != Some(&be.etag)
1037 })
1038 .map(|(path, _)| path.clone())
1039 .collect();
1040 for path in repair_candidates {
1041 let key = self.cfg.file_key(&path);
1042 let be = baseline.entries[&path].clone();
1043 match self.store.head(&key).await {
1044 Ok(meta) if meta.etag == be.etag => {
1045 let crc = repair_crc(&path, &be, &meta)?;
1046 let stamps = GenerationStamps::from_meta(&meta.meta);
1047 let scan_entry = scanned.get(&path);
1048 upserts.insert(
1049 path.clone(),
1050 LeanEntry {
1051 key,
1052 etag: meta.etag.clone(),
1053 crc64_b64: crc,
1054 size: meta.size,
1055 mode: stamps
1056 .as_ref()
1057 .and_then(|s| s.posix)
1058 .map(|p| p.mode)
1059 .or(scan_entry.map(|s| s.mode))
1060 .unwrap_or(0o644),
1061 mtime_unix: scan_entry.map(|s| s.mtime_unix).unwrap_or(0),
1062 generation: stamps.map(|s| s.generation).unwrap_or(be.generation),
1063 epoch,
1064 version_id: meta.version_id.clone(),
1065 },
1066 );
1067 }
1068 // Moved again or gone: the next consume reconciles it.
1069 _ => {}
1070 }
1071 }
1072
1073 // Step 5: the manifest CAS (three-way merge; bounded retries).
1074 self.verify_not_deposed().await?;
1075 let mut foreign_entries: Vec<(String, LeanEntry)> = vec![];
1076 let mut attempt = 0;
1077 let (installed, installed_etag) = loop {
1078 attempt += 1;
1079 if attempt > 4 {
1080 return Err(LeanError::State(
1081 "manifest CAS lost 4 merge races — refusing this barrier".into(),
1082 ));
1083 }
1084 let current = manifest::load(self.store.as_ref(), &self.cfg).await?;
1085 let (theirs, expected) = match ¤t {
1086 // The handle carries the LAYOUT as well as the tag: a
1087 // workspace still on the legacy single object CASes a
1088 // pointer that must not exist yet, not one it read.
1089 Some(l) => (l.manifest.clone(), Some(l.handle())),
1090 None => (Default::default(), None),
1091 };
1092 // If the bucket is still at the document THIS workspace
1093 // installed, that document IS the merge base — whatever the
1094 // persisted one says. Step 7 rewrites the merge base after
1095 // the CAS, so a restart in between leaves it behind a
1096 // document we wrote, and every entry in it would read as
1097 // somebody else's change. See `IntentJournal::installed_etag`.
1098 let own_base;
1099 let base: &BTreeMap<String, String> =
1100 if prev_installed.is_some()
1101 && prev_installed.as_deref() == expected.as_ref().map(|h| h.etag.as_str())
1102 {
1103 own_base = theirs
1104 .entries
1105 .iter()
1106 .map(|(p, e)| (p.clone(), e.etag.clone()))
1107 .collect();
1108 &own_base
1109 } else {
1110 &baseline.inst_base
1111 };
1112 let (mut merged, foreign) =
1113 manifest::merge(base, &theirs, &upserts, &classified.deletes, &parked);
1114 // `merge` clears it; the installing pass owns it. A mirror
1115 // is a property of how this workspace is DEPLOYED, so it
1116 // comes from config on every publish rather than being
1117 // inherited from whatever wrote last.
1118 merged.sole_writer = self.cfg.sole_writer;
1119 match manifest::cas_write_stamped(
1120 self.store.as_ref(),
1121 &self.cfg,
1122 &merged,
1123 expected.as_ref(),
1124 epoch,
1125 &flush_uuid,
1126 Some(source),
1127 )
1128 .await
1129 {
1130 Ok(meta) => {
1131 // Before the deletes and before step 7: this is the
1132 // only record that survives a crash in that window.
1133 intent.installed_etag = Some(meta.etag.clone());
1134 self.state.save_intent(&intent)?;
1135 foreign_entries = foreign;
1136 break (merged, meta.etag);
1137 }
1138 Err(LeanError::Store(StoreError::PreconditionFailed(_)))
1139 | Err(LeanError::Store(StoreError::Conflict(_))) => {
1140 // Re-verify the cell before retrying: a rotation is
1141 // exactly this 412, and re-merging past it would be
1142 // the straggler install.
1143 self.verify_not_deposed().await?;
1144 continue;
1145 }
1146 Err(e) => return Err(e),
1147 }
1148 };
1149 report.seq = Some(installed.seq);
1150 // A fused install IS a coherent point, and cadence/hybrid have
1151 // exactly one source. The gauges must not report "no boundary
1152 // ever" on a workspace that publishes every minute.
1153 self.note_boundary(super::gated::CitationSource::Cadence.as_str(), installed.seq)?;
1154 report.manifest_etag = Some(installed_etag.clone());
1155 report.observed_seq = Some(installed.seq);
1156 report.observed_etag = Some(installed_etag.clone());
1157 report.foreign_queued = foreign_entries.len();
1158
1159 // Step 6: deletes LAST — GC of keys the NEW manifest no longer
1160 // references, HEAD-guarded on the recognized ETag.
1161 for path in &classified.deletes {
1162 if installed.entries.contains_key(path) {
1163 continue; // delete/modify resolved foreign-wins: not garbage
1164 }
1165 let key = self.cfg.file_key(path);
1166 let recognized = baseline.entries.get(path).map(|b| b.etag.clone()).or_else(|| {
1167 // A DECLARED removal of a path this tree never held (a
1168 // scoped workspace's out-of-scope citation): what the
1169 // declaration named is the object the manifest cited
1170 // when this barrier began, and that is the merge base.
1171 removals
1172 .declared
1173 .contains(path)
1174 .then(|| baseline.inst_base.get(path).cloned())
1175 .flatten()
1176 });
1177 match self.store.head(&key).await {
1178 Err(StoreError::NotFound(_)) => {
1179 report.deleted.push(path.clone());
1180 }
1181 Ok(meta) if Some(&meta.etag) == recognized.as_ref() => {
1182 self.store.delete(&key).await?;
1183 report.deleted.push(path.clone());
1184 }
1185 Ok(meta) => {
1186 // An ETag this syncer does not recognize is NEVER
1187 // deleted (a HITL re-create landed after our CAS).
1188 self.state.append_conflict(&ConflictRecord {
1189 path: path.clone(),
1190 foreign_etag: meta.etag,
1191 preserved_key: None,
1192 kind: "gc-skip".into(),
1193 at_unix: now_unix(),
1194 })?;
1195 }
1196 Err(e) => return Err(e.into()),
1197 }
1198 }
1199
1200 // Step 7: baseline rewrite, intent clear, window clear (+ queue
1201 // the merge-preserved foreign entries for the next consume).
1202 for (path, be) in new_baseline_entries {
1203 baseline.entries.insert(path, be);
1204 }
1205 for path in &report.deleted {
1206 baseline.entries.remove(path);
1207 }
1208 baseline.inst_base =
1209 installed.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
1210 baseline.seq = installed.seq;
1211 baseline.manifest_etag = Some(installed_etag);
1212 baseline.prev_scan = scanned.keys().cloned().collect();
1213 self.state.save_baseline(&baseline)?;
1214 self.state.clear_intent_keys()?;
1215 let queue: Vec<InboxEntry> = foreign_entries
1216 .into_iter()
1217 .map(|(path, e)| InboxEntry {
1218 path,
1219 etag: e.etag,
1220 author: "merge-preserved".into(),
1221 added_unix: now_unix(),
1222 crc64_b64: Some(e.crc64_b64),
1223 })
1224 .collect();
1225 inbox::clear_window_settling(
1226 self.store.as_ref(),
1227 &self.cfg,
1228 epoch,
1229 &queue,
1230 &consumed,
1231 &removals.applied,
1232 )
1233 .await?;
1234 // Reap superseded generations. Immutable metadata that is never
1235 // collected is a leak that grows by a whole manifest per
1236 // publish, and this also collects the orphan a crash between the
1237 // entries PUT and the pointer CAS leaves behind. Best effort by
1238 // design: a publish that succeeded is not un-done by a failure
1239 // to tidy up after it.
1240 match manifest::sweep_generations(self.store.as_ref(), &self.cfg).await {
1241 Ok(0) => {}
1242 Ok(n) => eprintln!("flint-sync: reaped {n} superseded manifest generation(s)"),
1243 Err(e) => eprintln!("flint-sync: generation sweep: {e}"),
1244 }
1245 // And the CHUNK reaper, same reason, same best-effort terms.
1246 // `sweep_chunks` returns 0 without a request on a workspace that
1247 // is not chunked, so this costs nothing until the layout moves.
1248 //
1249 // It was written to four model-established rules, guarded by six
1250 // mutation configs, and called from nothing but its own tests —
1251 // so with chunking on, every publish left its superseded chunks
1252 // in the bucket forever. The rules ran in the model and in the
1253 // suite and never once in production. `the_barrier_reaps...`
1254 // below is the test that asks whether this line exists at all.
1255 match manifest::sweep_chunks(self.store.as_ref(), &self.cfg).await {
1256 Ok(0) => {}
1257 Ok(n) => eprintln!("flint-sync: reaped {n} unreferenced manifest chunk(s)"),
1258 Err(e) => eprintln!("flint-sync: chunk sweep: {e}"),
1259 }
1260 Ok(report)
1261 }
1262
1263 /// The > whole_put_max path: contiguous `PartSource::Local` chunks
1264 /// through `compose_generation` (streaming multipart; the store
1265 /// aborts its partial assembly on every failure path). The CRC is
1266 /// computed by a streaming pass first; a writer racing the compose
1267 /// fails server-side validation and the path DEFERS to the next
1268 /// barrier — publish-possibly-torn is put_whole's documented
1269 /// dilemma, not this path's.
1270 #[allow(clippy::too_many_arguments)]
1271 async fn upload_compose(
1272 &self,
1273 path: &str,
1274 key: &str,
1275 local_path: &Path,
1276 size: u64,
1277 scanned: &scan::ScanEntry,
1278 condition: PutCondition,
1279 stamps: GenerationStamps,
1280 generation: u64,
1281 epoch: u64,
1282 ) -> LeanResult<UploadOutcome> {
1283 // NO PRE-PASS. The store accumulates the full-object CRC from
1284 // the parts as it reads them for upload, so this file is read
1285 // ONCE. It used to be read TWICE — a streaming hash of the whole
1286 // file, and then again part by part — which on the 2026-09-10
1287 // door drill was roughly 11 s of the `mixed` workload's 24.3 s,
1288 // all of it at the disk's read ceiling and none of it on the
1289 // wire.
1290 //
1291 // That pre-pass was not laziness: asking the store to accumulate
1292 // used to pin its part uploads to a sequential loop, because a
1293 // CRC-64 taken out of order is a different number. `crc64_combine`
1294 // removed the choice — parts are hashed independently and folded
1295 // in part order — so the single read and the parallel upload are
1296 // no longer alternatives.
1297
1298 // Part grid: within [min_part_size, ...], at most max_parts,
1299 // contiguous from 0.
1300 let min_part = self.store.min_part_size().max(1);
1301 let max_parts = self.store.max_parts().max(1) as u64;
1302 let mut chunk = self.cfg.whole_put_max.max(min_part);
1303 let need = size.div_ceil(chunk);
1304 if need > max_parts {
1305 chunk = size.div_ceil(max_parts).div_ceil(min_part) * min_part;
1306 }
1307 let mut parts = vec![];
1308 let mut off = 0u64;
1309 while off < size {
1310 let len = chunk.min(size - off);
1311 parts.push(flint_store::PartSource::Local { offset: off, len });
1312 off += len;
1313 }
1314
1315 let spec = flint_store::ComposeSpec {
1316 progress: None,
1317 key,
1318 local_path,
1319 parts,
1320 base_key: None,
1321 base_etag: None,
1322 condition,
1323 stamps: stamps.clone(),
1324 crc64: None,
1325 };
1326 match self.store.compose_generation(&spec).await {
1327 Ok(meta) => {
1328 // The store computed it; refuse to cite bytes nothing
1329 // vouched for rather than invent a number for the
1330 // manifest. A backend that returns no checksum has not
1331 // validated the publish server-side either.
1332 let crc = meta
1333 .crc64_b64
1334 .as_deref()
1335 .and_then(flint_store::crc64_from_b64)
1336 .ok_or_else(|| {
1337 LeanError::State(format!(
1338 "compose of {key} returned no full-object checksum — refusing to \
1339 cite bytes nothing vouched for"
1340 ))
1341 })?;
1342 Ok(UploadOutcome::published(
1343 path, key.to_string(), meta.etag, crc, scanned, generation, epoch,
1344 meta.version_id,
1345 ))
1346 }
1347 Err(StoreError::ChecksumMismatch(_)) | Err(StoreError::NoSuchUpload(_)) => {
1348 // Drift mid-compose, or the operator sweep aborted us:
1349 // nothing published; the next barrier re-queues.
1350 Ok(UploadOutcome::Deferred)
1351 }
1352 Err(StoreError::PreconditionFailed(_)) => {
1353 // The AdoptOwn recognizer compares OUR bytes' checksum
1354 // against what landed, and without the pre-pass we do
1355 // not have one — so the file is read HERE, on the rare
1356 // recovery path, instead of on every publish.
1357 let crc = file_crc(local_path)?;
1358 let head = self.store.head(key).await?;
1359 let head_stamps = GenerationStamps::from_meta(&head.meta);
1360 let own = head_stamps
1361 .as_ref()
1362 .map(|s| s.flush_uuid == stamps.flush_uuid)
1363 .unwrap_or(false);
1364 if own || head.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) {
1365 // Our own torn earlier Complete with these bytes:
1366 // cite it.
1367 let g = head_stamps.map(|s| s.generation).unwrap_or(generation);
1368 return Ok(UploadOutcome::published(
1369 path, key.to_string(), head.etag, crc, scanned, g, epoch,
1370 head.version_id,
1371 ));
1372 }
1373 Ok(UploadOutcome::Parked { foreign_etag: head.etag })
1374 }
1375 Err(e) => Err(e.into()),
1376 }
1377 }
1378
1379 /// The gated staging lane's entry point into the shipped guard
1380 /// chain — same 412 policy, same AdoptOwn recognizer, same park;
1381 /// only the caller's use of the response differs. `None` = parked
1382 /// on a foreign etag.
1383 #[allow(clippy::too_many_arguments)]
1384 pub(super) async fn upload_one_pub(
1385 &self,
1386 path: &str,
1387 scanned: &scan::ScanEntry,
1388 base: Option<&BaselineEntry>,
1389 epoch: u64,
1390 flush_uuid: &str,
1391 prior_uuids: &[String],
1392 ) -> LeanResult<Option<(LeanEntry, BaselineEntry)>> {
1393 match self.upload_one(path, scanned, base, epoch, flush_uuid, prior_uuids).await? {
1394 UploadOutcome::Published { entry, baseline_entry } => {
1395 Ok(Some((entry, baseline_entry)))
1396 }
1397 UploadOutcome::Parked { .. } | UploadOutcome::Deferred => Ok(None),
1398 }
1399 }
1400
1401 async fn upload_one(
1402 &self,
1403 path: &str,
1404 scanned: &scan::ScanEntry,
1405 base: Option<&BaselineEntry>,
1406 epoch: u64,
1407 flush_uuid: &str,
1408 prior_uuids: &[String],
1409 ) -> LeanResult<UploadOutcome> {
1410 let key = self.cfg.file_key(path);
1411 let local_path = self.cfg.root.join(path);
1412 let generation = base.map(|b| b.generation + 1).unwrap_or(1);
1413 let posix = std::fs::metadata(&local_path).ok().map(|m| PosixStamps::from_metadata(&m));
1414 let stamps = GenerationStamps {
1415 generation,
1416 epoch,
1417 flush_uuid: flush_uuid.to_string(),
1418 boundary_source: None,
1419 posix,
1420 };
1421 let condition = match base {
1422 Some(b) => PutCondition::IfMatch(b.etag.clone()),
1423 None => PutCondition::IfNoneMatchAny,
1424 };
1425 let size = std::fs::metadata(&local_path)
1426 .map_err(|e| LeanError::State(format!("stat {}: {e}", local_path.display())))?
1427 .len();
1428 if size > self.cfg.whole_put_max {
1429 // Streaming multipart compose: put_whole is never fed past
1430 // whole_put_max (unbounded memory + S3's 5 GiB wall).
1431 return self
1432 .upload_compose(path, &key, &local_path, size, scanned, condition, stamps, generation, epoch)
1433 .await;
1434 }
1435 // `Bytes::from(Vec<u8>)` TAKES OWNERSHIP without copying, so the
1436 // old `Bytes::from(body.clone())` was a full memcpy of every
1437 // published file body — bought solely to leave `body` intact for
1438 // the 412 retry below, which is reached almost never. Build the
1439 // `Bytes` once; the retry gets a refcount clone.
1440 let body = Bytes::from(std::fs::read(&local_path)?);
1441 let crc = crc64_nvme(&body);
1442 match self
1443 .store
1444 .put_whole(&key, body.clone(), &condition, &stamps, crc)
1445 .await
1446 {
1447 Ok(meta) => Ok(UploadOutcome::published(
1448 path, key, meta.etag, crc, scanned, generation, epoch, meta.version_id,
1449 )),
1450 Err(StoreError::PreconditionFailed(_)) => {
1451 // The 412 policy: my own crashed/torn PUT ⇒ adopt;
1452 // foreign ⇒ park. NEVER the inherited LOCAL-WINS
1453 // overwrite.
1454 let head = self.store.head(&key).await?;
1455 let head_stamps = GenerationStamps::from_meta(&head.meta);
1456 let own = head_stamps
1457 .as_ref()
1458 .map(|s| s.flush_uuid == flush_uuid || prior_uuids.contains(&s.flush_uuid))
1459 .unwrap_or(false);
1460 if !own {
1461 return Ok(UploadOutcome::Parked { foreign_etag: head.etag });
1462 }
1463 if head.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) {
1464 // Bytes already there (torn response): cite it.
1465 let g = head_stamps.map(|s| s.generation).unwrap_or(generation);
1466 return Ok(UploadOutcome::published(
1467 path, key, head.etag, crc, scanned, g, epoch, head.version_id,
1468 ));
1469 }
1470 // Our earlier PUT, older content: supersede it knowingly.
1471 let meta = self
1472 .store
1473 .put_whole(&key, body, &PutCondition::IfMatch(head.etag), &stamps, crc)
1474 .await?;
1475 Ok(UploadOutcome::published(
1476 path, key, meta.etag, crc, scanned, generation, epoch, meta.version_id,
1477 ))
1478 }
1479 Err(e) => Err(e.into()),
1480 }
1481 }
1482}
1483
1484enum UploadOutcome {
1485 Published { entry: LeanEntry, baseline_entry: BaselineEntry },
1486 Parked { foreign_etag: String },
1487 /// The source drifted mid-transfer (checksum refused server-side)
1488 /// or the assembly was swept: publish nothing, advance nothing —
1489 /// the next scan re-queues the path.
1490 Deferred,
1491}
1492
1493impl UploadOutcome {
1494 #[allow(clippy::too_many_arguments)]
1495 fn published(
1496 path: &str,
1497 key: String,
1498 etag: String,
1499 crc: u64,
1500 scanned: &scan::ScanEntry,
1501 generation: u64,
1502 epoch: u64,
1503 version_id: Option<String>,
1504 ) -> UploadOutcome {
1505 let _ = path;
1506 UploadOutcome::Published {
1507 entry: LeanEntry {
1508 key,
1509 etag: etag.clone(),
1510 crc64_b64: crc64_to_b64(crc),
1511 size: scanned.size,
1512 mode: scanned.mode,
1513 mtime_unix: scanned.mtime_unix,
1514 generation,
1515 epoch,
1516 version_id: version_id.clone(),
1517 },
1518 baseline_entry: BaselineEntry {
1519 etag,
1520 generation,
1521 version_id,
1522 // The PRE-read stat: if the agent wrote during our read,
1523 // the next scan sees the drift and re-queues (the
1524 // re-stat/re-queue valve).
1525 size: scanned.size,
1526 mtime_unix: scanned.mtime_unix,
1527 crc64_b64: Some(crc64_to_b64(crc)),
1528 },
1529 }
1530 }
1531}
1532
1533fn local_dirty(local: &Path, base: Option<&BaselineEntry>) -> bool {
1534 match (std::fs::metadata(local), base) {
1535 (Err(_), None) => false, // both absent: clean
1536 (Err(_), Some(_)) => true, // locally deleted vs baseline
1537 (Ok(_), None) => true, // local exists, never published
1538 (Ok(m), Some(b)) => m.len() != b.size || mtime_of(&m) != b.mtime_unix,
1539 }
1540}
1541
1542/// Full-object CRC-64/NVME of a local file, streamed.
1543///
1544/// Reached only by the 412 recovery path: the ordinary publish gets its
1545/// checksum from the store, which computes one while uploading.
1546/// The CRC a citation repair cites: the bytes this syncer integrated,
1547/// as the baseline recorded them when it wrote or uploaded them. The
1548/// HEAD's own checksum is a cross-check when the backend offers one,
1549/// never the source — Ozone offers none, a HITL uploader may have sent
1550/// none, and the manifest must carry a CRC either way, because every
1551/// reader now verifies a fresh fetch against it.
1552///
1553/// A baseline entry with no CRC is refused loudly rather than cited
1554/// bare: the only entry built without one is the consume-dirty sentinel,
1555/// which the candidate filters exclude, so reaching this is a defect.
1556/// A HEAD that attests a DIFFERENT value is refused the same way — the
1557/// workspace holds bytes the object does not, and citing either would
1558/// make one reader or another refuse the path forever.
1559pub(crate) fn repair_crc(
1560 path: &str,
1561 be: &BaselineEntry,
1562 head: &flint_store::ObjectMeta,
1563) -> LeanResult<String> {
1564 let Some(ours) = be.crc64_b64.clone() else {
1565 return Err(LeanError::State(format!(
1566 "citation repair of {path}: the baseline records no CRC for the bytes it \
1567 integrated at etag {} — refusing to cite bytes nothing hashed",
1568 be.etag
1569 )));
1570 };
1571 if let Some(theirs) = head.crc64_b64.as_deref() {
1572 if theirs != ours {
1573 return Err(LeanError::State(format!(
1574 "citation repair of {path}: the store attests CRC-64 {theirs} for etag {} but \
1575 the bytes this workspace integrated under that etag hash to {ours} — the \
1576 workspace holds bytes the object does not; refusing to cite either",
1577 be.etag
1578 )));
1579 }
1580 }
1581 Ok(ours)
1582}
1583
1584fn file_crc(local_path: &Path) -> LeanResult<u64> {
1585 use std::io::Read;
1586 let mut crc = flint_store::Crc64Nvme::new();
1587 let mut f = std::fs::File::open(local_path)?;
1588 let mut buf = vec![0u8; 4 << 20];
1589 loop {
1590 let n = f.read(&mut buf)?;
1591 if n == 0 {
1592 break;
1593 }
1594 crc.update(&buf[..n]);
1595 }
1596 Ok(crc.finalize())
1597}
1598
1599pub(super) fn mtime_of(m: &std::fs::Metadata) -> i64 {
1600 m.modified()
1601 .ok()
1602 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1603 .map(|d| d.as_secs() as i64)
1604 .unwrap_or(0)
1605}
1606
1607/// Containment-safe workspace write (boundary-verbs plan §2.2 security
1608/// gate). `rel` is a workspace-relative path; the target may not escape
1609/// the root, traverse a symlink component, or land in the reserved
1610/// control namespace.
1611///
1612/// The hazard this closes is pre-existing and reachable from three
1613/// callers (checkout, inbox consume, sync): `write_file_atomic` did
1614/// `create_dir_all(parent)` + write with no `O_NOFOLLOW` and no
1615/// root-containment check, while the scanner SKIPS symlinks — so a
1616/// planted symlink is invisible to the syncer. An unprivileged app
1617/// that plants `inputs -> /root/.aws`, lands an object at
1618/// `inputs/<path>` and drops a scoped sync turns the credential-holding
1619/// syncer into an on-demand arbitrary-file-write primitive outside the
1620/// workspace.
1621pub(super) fn write_file_atomic_in(
1622 root: &Path,
1623 rel: &str,
1624 bytes: &[u8],
1625 mode: Option<u32>,
1626) -> LeanResult<()> {
1627 let target = contained_path(root, rel)?;
1628 write_file_atomic(&target, bytes, mode).map(|_| ())
1629}
1630
1631/// Validate `rel` under `root` WITHOUT creating anything — for callers
1632/// that must decide whether a path is writable before doing any work
1633/// (inbox consume refuses a refused path outright rather than routing it
1634/// through conflict-preserve, which would cost a GET+PUT and leave the
1635/// path in the baseline).
1636pub(super) fn check_contained(root: &Path, rel: &str) -> LeanResult<()> {
1637 resolve_contained(root, rel, false).map(|_| ())
1638}
1639
1640/// Resolve `rel` under `root`, refusing escapes, creating missing parent
1641/// directories. Walks the parent chain component by component: a
1642/// component that EXISTS as a symlink is a refusal (never followed).
1643pub(super) fn contained_path(root: &Path, rel: &str) -> LeanResult<std::path::PathBuf> {
1644 resolve_contained(root, rel, true).map(|(p, _)| p)
1645}
1646
1647/// As `contained_path`, and also what the walk already learned about
1648/// the FINAL component: its `lstat` if it exists, `None` if it does
1649/// not. The walk stats every component to refuse a symlink; a caller
1650/// that then asked `exists()` and `metadata()` paid two more stats for
1651/// an answer it was already holding (checkout's resume check did, on
1652/// every one of 20,000 files).
1653pub(super) fn contained_path_stat(
1654 root: &Path,
1655 rel: &str,
1656) -> LeanResult<(std::path::PathBuf, Option<std::fs::Metadata>)> {
1657 resolve_contained(root, rel, true)
1658}
1659
1660fn resolve_contained(
1661 root: &Path,
1662 rel: &str,
1663 create_dirs: bool,
1664) -> LeanResult<(std::path::PathBuf, Option<std::fs::Metadata>)> {
1665 use std::path::Component;
1666 let refuse = |why: &str| {
1667 Err(LeanError::State(format!(
1668 "refusing write to {rel:?}: {why} (containment)"
1669 )))
1670 };
1671 if rel.is_empty() {
1672 return refuse("empty path");
1673 }
1674 if super::scan::is_control_path(rel) {
1675 // The reserved namespace is the syncer's own; a citation or
1676 // inbox entry naming it is surfaced, never materialized (D0.3).
1677 return refuse("reserved control namespace");
1678 }
1679 let relp = Path::new(rel);
1680 let mut cur = root.to_path_buf();
1681 let mut comps: Vec<&std::ffi::OsStr> = vec![];
1682 for c in relp.components() {
1683 match c {
1684 Component::Normal(n) => comps.push(n),
1685 Component::CurDir => {}
1686 Component::ParentDir => return refuse("`..` component"),
1687 Component::RootDir | Component::Prefix(_) => return refuse("absolute path"),
1688 }
1689 }
1690 if comps.is_empty() {
1691 return refuse("no path components");
1692 }
1693 let last = comps.len() - 1;
1694 let mut final_meta = None;
1695 for (i, c) in comps.iter().enumerate() {
1696 cur.push(c);
1697 match std::fs::symlink_metadata(&cur) {
1698 Ok(m) if m.file_type().is_symlink() => {
1699 return refuse("path traverses a symlink");
1700 }
1701 Ok(m) if i < last && !m.is_dir() => {
1702 return refuse("parent component is not a directory");
1703 }
1704 Ok(m) => {
1705 if i == last {
1706 final_meta = Some(m);
1707 }
1708 }
1709 Err(_) if i < last => {
1710 if create_dirs {
1711 if let Err(e) = std::fs::create_dir(&cur) {
1712 // A SIBLING may have created it between the stat
1713 // above and this mkdir: fetches run on their own
1714 // tasks, so the first files of one directory
1715 // race here, and the loser's EEXIST is not a
1716 // containment refusal. It is fine only if what
1717 // exists now is a plain directory — a symlink
1718 // that appeared in the window is refused exactly
1719 // as one the stat found.
1720 match std::fs::symlink_metadata(&cur) {
1721 Ok(m) if m.file_type().is_symlink() => {
1722 return refuse("path traverses a symlink");
1723 }
1724 Ok(m) if m.is_dir() => {}
1725 _ => {
1726 return Err(LeanError::State(format!(
1727 "mkdir {}: {e}",
1728 cur.display()
1729 )))
1730 }
1731 }
1732 }
1733 }
1734 }
1735 Err(_) => {}
1736 }
1737 }
1738 Ok((cur, final_meta))
1739}
1740
1741/// Write `bytes` to `path` through an exclusive temp sibling and a
1742/// rename. The parent already exists — containment created it — and
1743/// one that vanished since is recreated on the retry path in `safefs`,
1744/// so the common path pays no `mkdir` and no `stat` for it. Returns
1745/// the written file's metadata.
1746pub(super) fn write_file_atomic(
1747 path: &Path,
1748 bytes: &[u8],
1749 mode: Option<u32>,
1750) -> LeanResult<std::fs::Metadata> {
1751 super::safefs::check_parent(path)?;
1752 // NOT with_extension(): that REPLACES the final extension, so
1753 // "a.txt" and "a.md" would collide on one tmp name.
1754 let tmp = path.with_file_name(format!(
1755 "{}.flint-sync-tmp",
1756 path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()
1757 ));
1758 // The temp sibling is computed AFTER containment ran, so the walk
1759 // never saw it: it needs its own refusal, not a plain write.
1760 // FAST (no per-file fsync): materialisations are made durable by
1761 // `sync_tree` before the marker or baseline that vouches for them.
1762 super::safefs::write_via_tmp_fast(path, &tmp, bytes, mode)
1763}