flint_lean/checkout.rs
1//! Checkout + the restart matrix (plan §2.1).
2//!
3//! | State on wake | Action |
4//! |--------------------------|-----------------------------------------|
5//! | No marker, empty tree | full checkout |
6//! | No marker, partial tree | resume (local-wins skips present paths) |
7//! | Marker present | NEVER re-materialize: reload baseline, |
8//! | | rescan rebuilds dirt, lease self- |
9//! | | recognizes via the persisted id |
10//!
11//! Re-checkout over a live tree is forbidden: local-wins protects only
12//! PRESENT paths, so it would resurrect the agent's unpublished deletes
13//! (`LeanRematerialize.cfg` rediscovers exactly that). The marker is
14//! written LAST — it is the agent-start gate.
15
16use std::collections::BTreeSet;
17
18use flint_store::StoreError;
19
20use super::barrier::{contained_path, contained_path_stat, mode_of, mtime_nanos_of, mtime_of, write_file_atomic};
21use super::manifest;
22use super::state::BaselineEntry;
23use super::{LeanError, LeanResult, Syncer};
24
25#[derive(Debug, Default)]
26pub struct CheckoutReport {
27 pub materialized: usize,
28 pub skipped_present: usize,
29 pub bytes: u64,
30 /// Restart-matrix row taken.
31 pub resumed_live_tree: bool,
32 /// Citations left in place rather than materialized (D0.3 legacy
33 /// `.flint/` paths, containment refusals). Never a silent drop:
34 /// each has a conflict record.
35 pub refused: usize,
36 /// Phase attribution for the agent-blocking path. Without these the
37 /// checkout wall clock cannot be split between the manifest GET,
38 /// the fan-out fetch window, and the local commit — which is the
39 /// one fact any read-path decision here rests on.
40 pub manifest_secs: f64,
41 pub fetch_secs: f64,
42 pub commit_secs: f64,
43 /// Passes re-run because the pointer moved past the document a
44 /// pass read and a handle it cited was collected (handles design
45 /// 2026-09-19): the checkout re-resolves instead of asking the
46 /// caller to.
47 pub reresolved: u32,
48 /// The admitted set this checkout was scoped to, or `None` for the
49 /// whole manifest.
50 pub scope: Option<Vec<String>>,
51 /// Citations the scope DECLINED. Zero for an unscoped checkout, and
52 /// the number that says a scope did anything at all: a scope that
53 /// admits everything reads exactly like no scope.
54 pub out_of_scope: usize,
55 /// Objects materialised through the RANGED path. On a cluster
56 /// nothing else can tell "ranging did not help" from "ranging did
57 /// not run" — the knob is a threshold and a threshold that never
58 /// fires reads exactly like an optimisation that does not work.
59 pub ranged: usize,
60}
61
62/// Materialise one object as PARALLEL RANGES, writing each at its
63/// offset as it lands.
64///
65/// Returns `Ok(None)` when the caller should fall through to the
66/// whole-object arm — either because ranging does not apply, or because
67/// the store answered with one of the two POLICY-BEARING errors. That
68/// second case is deliberate: `PreconditionFailed` and `NotFound` mean
69/// two different things here depending on `sole_writer`,
70/// and all three refusals are written once, in the whole-object arm
71/// below. Re-deciding them here would put the same policy in two
72/// places, which is how the two drift apart. The cost is one wasted
73/// request on a path that is already an incident.
74///
75/// Every other error propagates after the per-range retries: a transport
76/// failure is a failure, not a quiet fallback that would hide a broken
77/// network behind a slower code path.
78async fn fetch_ranged(
79 store: &std::sync::Arc<dyn flint_store::ObjectStore>,
80 key: &str,
81 etag: &str,
82 size: u64,
83 target: &std::path::Path,
84 tmp: &std::path::Path,
85 mode: Option<u32>,
86 chunk_bytes: u64,
87 parallelism: usize,
88 expect_crc: u64,
89) -> LeanResult<Option<u64>> {
90 use futures::stream::StreamExt;
91
92 /// A range that failed for a reason the caller must distinguish.
93 enum RangeFail {
94 /// 412/404 — the whole-object arm owns this policy.
95 Fallback,
96 Fatal(LeanError),
97 }
98
99 const RANGE_RETRIES: u32 = 3;
100 let chunk_bytes = chunk_bytes.max(1);
101 let mut parts: Vec<(u64, u64)> = Vec::new();
102 let mut at = 0u64;
103 while at < size {
104 let len = chunk_bytes.min(size - at);
105 parts.push((at, len));
106 at += len;
107 }
108 if parts.len() < 2 {
109 // One range is one whole GET with extra steps.
110 return Ok(None);
111 }
112
113 // SHARED so each range writes ITSELF, on the blocking pool. Before
114 // this the write ran on the task that polls the fan-out, so a pwrite
115 // of one range stalled the FETCH of every other range in flight —
116 // concurrency that could never overlap I/O with I/O. Measured on
117 // i4i.large against an in-memory S3: system time beat user time 6:1
118 // and the process never exceeded 1.31 of 2 cores, flat from 4
119 // streams to 32.
120 let sink = std::sync::Arc::new(super::safefs::RangedTmp::create(target, tmp, size, mode)?);
121 let mut ranges = futures::stream::iter(parts.into_iter().map(|(off, len)| {
122 let store = store.clone();
123 let key = key.to_string();
124 let etag = etag.to_string();
125 let sink = sink.clone();
126 async move {
127 let mut attempt: u32 = 0;
128 let b = loop {
129 // EVERY range carries the same If-Match, which is what
130 // makes the assembled file one object rather than a
131 // splice of two: a write between range 3 and range 4
132 // fails range 4 instead of silently interleaving
133 // generations.
134 match store.get_range_segments(&key, off, len, &etag).await {
135 Ok(b) => break b,
136 Err(StoreError::PreconditionFailed(_)) | Err(StoreError::NotFound(_)) => {
137 return Err(RangeFail::Fallback)
138 }
139 Err(_) if attempt < RANGE_RETRIES => {
140 attempt += 1;
141 // The retry is per RANGE: a cut connection at
142 // range N of a multi-GiB object must not throw
143 // away N ranges of progress.
144 tokio::time::sleep(std::time::Duration::from_millis(
145 300 * u64::from(attempt),
146 ))
147 .await;
148 }
149 Err(e) => {
150 return Err(RangeFail::Fatal(LeanError::State(format!(
151 "get_range {key} at {off}+{len} after {RANGE_RETRIES} retries: {e}"
152 ))))
153 }
154 }
155 };
156 // The SDK's own frames, never flattened into one buffer.
157 // Summed rather than `b.is_empty()`: an empty range can also
158 // arrive as one zero-length segment, which is the same hole.
159 let n: u64 = b.iter().map(|seg| seg.len() as u64).sum();
160 // Checked HERE rather than at the drain: a hole must be
161 // refused before it is written, not after.
162 if n == 0 {
163 return Err(RangeFail::Fatal(LeanError::State(format!(
164 "get_range {key} at {off} returned an empty range before the object's \
165 end — refusing a hole"
166 ))));
167 }
168 let crc = tokio::task::spawn_blocking(move || {
169 // Segments are contiguous and in order within the range,
170 // so each lands at the running offset. The file is
171 // positional throughout, so siblings still cannot race.
172 // The range's CRC-64 is taken here, on the same pass, so
173 // the drain can fold the ranges in offset order and
174 // compare the whole against the manifest before the
175 // rename makes the file visible.
176 let mut h = flint_store::Crc64Nvme::new();
177 let mut at = off;
178 for seg in &b {
179 sink.write_at(at, seg)?;
180 h.update(seg);
181 at += seg.len() as u64;
182 }
183 Ok::<u64, LeanError>(h.finalize())
184 })
185 .await
186 .map_err(|e| {
187 RangeFail::Fatal(LeanError::State(format!(
188 "write task for {key} at {off} did not complete: {e}"
189 )))
190 })?
191 .map_err(RangeFail::Fatal)?;
192 Ok((off, n, crc))
193 }
194 }))
195 .buffer_unordered(parallelism.max(1));
196
197 let mut bytes = 0u64;
198 let mut crcs: Vec<(u64, u64, u64)> = Vec::new();
199 while let Some(next) = ranges.next().await {
200 match next {
201 Ok((off, n, crc)) => {
202 bytes += n;
203 crcs.push((off, n, crc));
204 }
205 Err(RangeFail::Fallback) => {
206 drop(ranges);
207 let _ = std::fs::remove_file(tmp);
208 return Ok(None);
209 }
210 Err(RangeFail::Fatal(e)) => {
211 drop(ranges);
212 let _ = std::fs::remove_file(tmp);
213 return Err(e);
214 }
215 }
216 }
217 // Every range future is finished and dropped, so this is the last
218 // reference. try_unwrap rather than a clone-tolerant commit: if a
219 // writer were somehow still alive, renaming now would publish a
220 // file still being written into.
221 drop(ranges);
222 let sink = std::sync::Arc::try_unwrap(sink).map_err(|_| {
223 LeanError::State(format!(
224 "ranged materialisation of {}: a range writer outlived the fan-out",
225 target.display()
226 ))
227 })?;
228 // The whole object's CRC-64 from its ranges: `crc64_combine`
229 // composes A-then-B, so the fold runs in offset order whatever
230 // order the ranges landed in. Checked BEFORE the rename, for the
231 // same reason a hole is: a wrong file must never become visible.
232 // Unconditional: this arm fetches only under If-Match on the cited
233 // etag, so the bytes are always the manifest's to describe.
234 crcs.sort_unstable_by_key(|(off, _, _)| *off);
235 let mut folded: Option<u64> = None;
236 for (_, len, crc) in &crcs {
237 folded = Some(match folded {
238 None => *crc,
239 Some(acc) => flint_store::crc64_combine(acc, *crc, *len),
240 });
241 }
242 if folded != Some(expect_crc) {
243 drop(sink);
244 let _ = std::fs::remove_file(tmp);
245 return Err(LeanError::State(format!(
246 "manifest cites {key} at etag {etag} with CRC-64 {}, but the {} ranges fetched \
247 under that etag fold to {} — the object is corrupt or the store returned the \
248 wrong bytes; refusing to materialise {} (nothing renamed into place)",
249 flint_store::crc64_to_b64(expect_crc),
250 crcs.len(),
251 folded.map(flint_store::crc64_to_b64).unwrap_or_else(|| "nothing".into()),
252 target.display()
253 )));
254 }
255 sink.commit()?;
256 Ok(Some(bytes))
257}
258
259/// A read buffer for streaming a file of `size` bytes: the file's size,
260/// at least one byte, at most 1 MiB. A fixed 1 MiB, zeroed and faulted
261/// in afresh for every file, was most of the CPU of re-verifying a tree
262/// of small files. The size only chunks the reads: the loop reads to the
263/// end whatever the file does meanwhile.
264fn read_buf_len(size: u64) -> usize {
265 size.clamp(1, 1 << 20) as usize
266}
267
268/// CRC-64/NVME of a local file, in the same base64 form the manifest
269/// carries. Streamed: a resumed checkout may be verifying a 20 GiB
270/// workspace and must not hold it in memory.
271fn local_crc64_b64(path: &std::path::Path) -> Option<String> {
272 use std::io::Read;
273 let mut f = std::fs::File::open(path).ok()?;
274 let mut h = flint_store::Crc64Nvme::new();
275 let mut buf = vec![0u8; read_buf_len(f.metadata().ok()?.len())];
276 loop {
277 let n = f.read(&mut buf).ok()?;
278 if n == 0 {
279 break;
280 }
281 h.update(&buf[..n]);
282 }
283 Some(flint_store::crc64_to_b64(h.finalize()))
284}
285
286/// One admitted citation's outcome from the fan-out materializer.
287 struct Fetched {
288 /// Took the ranged path (report-only).
289 ranged: bool,
290 path: String,
291 be: Option<BaselineEntry>,
292 skipped: bool,
293 bytes: u64,
294 /// A citation this checkout refused to materialize (D0.3 /
295 /// containment): left cited, surfaced as a conflict.
296 refused: Option<String>,
297 }
298
299/// Names a scope for a human in an error: the whole manifest, or the
300/// entries themselves. The entries matter — "a different scope" sends
301/// the reader to go and diff two things they cannot see.
302fn describe_scope(s: Option<&[String]>) -> String {
303 match s {
304 None => "UNSCOPED (the whole manifest)".to_string(),
305 Some(e) => format!("scoped to {:?}", e),
306 }
307}
308
309impl Syncer {
310 /// Materialize an ADMITTED SET under a bounded fan-out window:
311 /// each entry's guarded fetch + atomic write is independent (the
312 /// 0b rig measured the sequential loop at ~1,000-2,000 files/s and
313 /// 3.3 s/GiB; fan-out multiplies directly against both).
314 ///
315 /// Takes the admitted set rather than the manifest, so the one
316 /// decision about WHICH citations get materialized lives at the call
317 /// site and every caller inherits the same LPT ordering, the same
318 /// in-flight byte bound, and the same D0.3 refusals. A caller
319 /// that admits a subset owes its own budget arithmetic over that
320 /// subset — this function refuses nothing on size.
321 async fn materialize<'a>(
322 &'a self,
323 mut admission: Vec<(&'a String, &'a super::manifest::LeanEntry)>,
324 sole_writer: bool,
325 ) -> Vec<LeanResult<Fetched>> {
326 // LARGEST FIRST. `m.entries` is a BTreeMap, so iterating it
327 // admits in PATH order, which appends the biggest object's
328 // transfer to the tail of the fan-out window: a multi-GiB
329 // checkpoint whose name sorts late is a whole transfer of pure
330 // makespan after every other slot has drained. Longest-
331 // processing-time-first bounds that at (4/3 - 1/3k) x optimal.
332 // `size` is already in the entry, so this costs no request and
333 // one sort; on a size-uniform tree it is exactly a no-op.
334 //
335 // Nothing downstream may depend on admission order:
336 // `buffer_unordered` already yields in COMPLETION order, and the
337 // budget refusals above ran over the whole map before this.
338 admission.sort_unstable_by(|a, b| b.1.size.cmp(&a.1.size).then_with(|| a.0.cmp(b.0)));
339 // The in-flight BYTE bound (see `fetch_inflight_max_bytes`).
340 // Permits are 1 MiB units; an entry larger than the whole budget
341 // clamps to the budget rather than deadlocking on a permit count
342 // the semaphore can never grant.
343 const FETCH_UNIT: u64 = 1 << 20;
344 let budget_units =
345 (self.cfg.fetch_inflight_max_bytes / FETCH_UNIT).clamp(1, u32::MAX as u64) as u32;
346 let gate = std::sync::Arc::new(tokio::sync::Semaphore::new(budget_units as usize));
347 use futures::stream::{self, StreamExt};
348 let this: &super::Syncer = self;
349 let range_min = this.cfg.range_get_min_bytes;
350 let range_chunk = this.cfg.range_get_chunk_bytes;
351 let range_par = this.cfg.range_get_parallelism;
352 let (tree_reserve, tree_room) = (this.cfg.tree_reserve, this.cfg.tree_room_for_tests);
353 // SHARDED DRIVERS. Every fetch future used to be polled by ONE
354 // `buffer_unordered` task on the block_on thread, so each
355 // request's SDK work (build, sign, orchestrate, parse, collect,
356 // checksum) ran on one thread whatever `fanout` said: the main
357 // thread saturated at ~8,000 files/s on a 2-vCPU VM with half a
358 // core idle. Spawning every fetch as its own task fixed that and
359 // then lost to the single driver on a 6-vCPU loopback rig (7.6k
360 // vs 11.4k) — with no RTT to hide behind, a wake per response
361 // per task costs more than the parallelism buys.
362 //
363 // So: `fetch_drivers` driver tasks, each running its own
364 // `buffer_unordered` over a round-robin slice of the (already
365 // largest-first) admission list. A driver's fetches stay on one
366 // task — one wake path, the batching the single driver had —
367 // and the per-request work spreads over that many cores.
368 let drivers = this.cfg.fetch_drivers.max(1);
369 let per_driver = (this.cfg.fanout.max(1) / drivers).max(1);
370 let mut shards: Vec<Vec<(String, super::manifest::LeanEntry)>> =
371 (0..drivers).map(|_| Vec::new()).collect();
372 for (i, (path, entry)) in admission.into_iter().enumerate() {
373 shards[i % drivers].push((path.clone(), entry.clone()));
374 }
375 let store_all = this.store.clone();
376 let root_all = this.cfg.root.clone();
377 let mut set = tokio::task::JoinSet::new();
378 for shard in shards {
379 let store_d = store_all.clone();
380 let root_d = root_all.clone();
381 let gate_d = gate.clone();
382 set.spawn(async move {
383 stream::iter(shard.into_iter().map(|(path, entry)| {
384 let store = store_d.clone();
385 let root = root_d.clone();
386 let local = root_d.join(&path);
387 let gate = gate_d.clone();
388 // THE CHARGE MUST DESCRIBE THE PATH THAT WILL RUN.
389 //
390 // `fetch_inflight_max_bytes` is a bound on BYTES HELD IN
391 // MEMORY, and the two arms hold wildly different amounts.
392 // `get_whole` returns the entire object, so charging
393 // `entry.size` is exactly right for it. The ranged arm does
394 // not: it is `buffer_unordered(range_par)` over per-chunk
395 // `get_range` calls, each written at its offset as it
396 // lands, so it holds `range_par * range_chunk` — 64 MiB at
397 // the shipped defaults — WHATEVER the object's size.
398 //
399 // Charging a ranged 1 GiB fetch 1 GiB of a 512 MiB budget
400 // therefore mis-states its footprint by ~16x, and the
401 // `clamp` turns that into the pathology: an entry at or
402 // over the budget takes the WHOLE semaphore, so object
403 // concurrency collapses to ONE and `fanout` is dead code.
404 // On a 6 x 1 GiB tree that leaves 4 streams on a 25 Gbps
405 // link — measured at 325 MiB/s, 11% of the guarantee, and
406 // identical on a node with 12x the vCPU and 32x the
407 // bandwidth. The bound was never the memory it names.
408 let ranged_eligible = range_min > 0 && entry.size >= range_min;
409 let whole_units =
410 entry.size.div_ceil(FETCH_UNIT).clamp(1, budget_units as u64) as u32;
411 let ranged_units = (range_par.max(1) as u64)
412 .saturating_mul(range_chunk.max(1))
413 .min(entry.size)
414 .div_ceil(FETCH_UNIT)
415 .clamp(1, budget_units as u64) as u32;
416 let want = if ranged_eligible { ranged_units } else { whole_units };
417 async move {
418 // Held until this entry's bytes have reached disk.
419 let mut permit = gate
420 .clone()
421 .acquire_many_owned(want)
422 .await
423 .map_err(|_| LeanError::State("fetch budget closed".into()))?;
424 // D0.3: a legacy `files/.flint/...` citation is
425 // never materialized (it would collide with the
426 // control files); it stays cited and a conflict
427 // record names it. Same arm refuses a citation
428 // whose path escapes the workspace.
429 // The walk hands back the final component's lstat as
430 // well, which is the resume check's whole question.
431 let (target, present) = match contained_path_stat(&root, &path) {
432 Ok(t) => t,
433 Err(e) => {
434 return Ok(Fetched {
435 ranged: false,
436 path: path.clone(),
437 be: None,
438 skipped: true,
439 bytes: 0,
440 refused: Some(e.to_string()),
441 })
442 }
443 };
444 if let Some(st) = present {
445 // Resume: a present path is one THIS checkout
446 // already fetched, so re-downloading it would
447 // pay bucket GETs for bytes that are on disk.
448 //
449 // But only if it is the same bytes. A checkout
450 // that died halfway leaves generation N on
451 // disk, and the manifest can MOVE before the
452 // replacement pod resumes (a HITL write, a
453 // sibling's barrier — routine on this fleet).
454 // Adopting then would stamp the baseline with
455 // the NEW entry's etag over the OLD content:
456 // the scan reads the file as clean and never
457 // uploads it, a sync reads baseline == manifest
458 // and never re-fetches it, and the workspace
459 // holds bytes nothing will ever reconcile. The
460 // divergence is silent and permanent.
461 //
462 // The check is local-only: size from the stat
463 // the walk already took, then crc of the local
464 // file. No bucket request either way — which is
465 // why it can be unconditional rather than a knob.
466 //
467 // And the mode, first: a chmod publishes a new
468 // generation of the SAME bytes, and adopting the
469 // old one would record its mode as the tree's own.
470 let same = !super::scan::mode_differs(mode_of(&st), entry.mode)
471 && st.len() == entry.size
472 && local_crc64_b64(&local)
473 .map(|got| got == entry.crc64_b64)
474 .unwrap_or(false);
475 if same {
476 return Ok(Fetched {
477 ranged: false,
478 path: path.clone(),
479 be: Some(BaselineEntry {
480 etag: entry.etag.clone(),
481 key: Some(entry.key.clone()),
482 generation: entry.generation,
483 size: st.len(),
484 mtime_unix: mtime_of(&st),
485 mtime_nanos: Some(mtime_nanos_of(&st)),
486 mode: Some(entry.mode & super::scan::PERM_BITS),
487 racy: super::barrier::racy_stat(&st),
488 crc64_b64: Some(entry.crc64_b64.clone()),
489 }),
490 skipped: true,
491 bytes: 0,
492 refused: None,
493 });
494 }
495 // Fall through and re-materialize.
496 }
497 // Before a byte is fetched: on a quota'd tree a body the
498 // tenant could not have written itself would spend the
499 // syncer's reserve (`safefs::leave_the_reserve`).
500 if tree_reserve {
501 super::safefs::leave_the_reserve(&root, &path, entry.size, tree_room)?;
502 }
503 // RANGED FIRST, when the object is big enough to
504 // pay for it.
505 let ranged_bytes = if range_min > 0 && entry.size >= range_min {
506 let tmp = target.with_file_name(format!(
507 "{}.flint-sync-tmp",
508 target.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()
509 ));
510 let want = flint_store::crc64_from_b64(&entry.crc64_b64).ok_or_else(|| {
511 LeanError::State(format!(
512 "manifest cites {} with a CRC-64 that is not one: {:?}",
513 entry.key, entry.crc64_b64
514 ))
515 })?;
516 fetch_ranged(
517 &store,
518 &entry.key,
519 &entry.etag,
520 entry.size,
521 &target,
522 &tmp,
523 Some(entry.mode),
524 range_chunk,
525 range_par,
526 want,
527 )
528 .await?
529 } else {
530 None
531 };
532 if let Some(n) = ranged_bytes {
533 let st = std::fs::metadata(&local)?;
534 return Ok(Fetched {
535 ranged: true,
536 path: path.clone(),
537 be: Some(BaselineEntry {
538 etag: entry.etag.clone(),
539 key: Some(entry.key.clone()),
540 generation: entry.generation,
541 size: st.len(),
542 mtime_unix: mtime_of(&st),
543 mtime_nanos: Some(mtime_nanos_of(&st)),
544 mode: Some(entry.mode & super::scan::PERM_BITS),
545 racy: super::barrier::racy_stat(&st),
546 // The fold above equalled it, or we would
547 // not be here.
548 crc64_b64: Some(entry.crc64_b64.clone()),
549 }),
550 skipped: false,
551 bytes: n,
552 refused: None,
553 });
554 }
555
556 // TOP UP BEFORE BUFFERING THE WHOLE OBJECT.
557 //
558 // Reaching here having been charged the ranged rate
559 // means `fetch_ranged` declined — either the object is
560 // one chunk (harmless: it holds less than the ranged
561 // charge) or it hit the 412/404 policy fallback, which
562 // hands the whole object to `get_whole` below. In that
563 // second case we are about to hold `entry.size` while
564 // charged for 64 MiB, and N concurrent fallbacks would
565 // walk RSS straight through the bound this semaphore
566 // exists to enforce.
567 //
568 // Released BEFORE re-acquiring, never upgraded in
569 // place: holding `want` while waiting for
570 // `whole_units` is a deadlock the moment two entries
571 // do it at once against a full budget.
572 if want < whole_units {
573 drop(permit);
574 permit = gate
575 .clone()
576 .acquire_many_owned(whole_units)
577 .await
578 .map_err(|_| LeanError::State("fetch budget closed".into()))?;
579 }
580 let _permit = permit;
581
582 let (meta, body) = match store.get_whole(&entry.key, Some(&entry.etag)).await {
583 Ok(ok) => ok,
584 // A cited HANDLE is immutable: nothing in the
585 // protocol writes one twice (P2: a UI save goes to
586 // a fresh handle and commits), so a handle whose
587 // etag moved was written by something outside it.
588 // Refused, never adopted — adopting bytes no
589 // manifest cites was a citation-repair source
590 // (M4), and the S3-wins arm that did it served a
591 // HITL write the inbox had not re-cited yet, which
592 // no longer exists.
593 Err(StoreError::PreconditionFailed(_)) => {
594 let who = if sole_writer {
595 " This workspace is published by a SOLE WRITER — if this is \
596 forge's legible export, look for a read-write mount over its \
597 prefix; the export republishes only what git changed and will \
598 not repair this on its own."
599 } else {
600 ""
601 };
602 return Err(LeanError::State(format!(
603 "manifest cites {} at an etag the object no longer carries: \
604 something outside the protocol overwrote an immutable handle. \
605 Refusing to adopt bytes no manifest cites.{who}",
606 entry.key
607 )));
608 }
609 Err(StoreError::NotFound(_)) => {
610 return Err(LeanError::State(format!(
611 "manifest cites {} but the object is gone — refusing a \
612 silent hole (mixed-writer bucket?)",
613 entry.key
614 )));
615 }
616 Err(e) => return Err(e.into()),
617 };
618 // OFF the fan-out task, for the same reason the ranged arm
619 // is: write_file_atomic is a blocking create+write+fsync+
620 // rename, and running it inline here froze every other
621 // in-flight fetch sharing this task. Small files ALL come
622 // through this arm — 8 KiB is far below the 16 MiB
623 // effective ranged threshold — which is why raising fanout
624 // stopped paying above 128 with half a core sitting idle:
625 // 3,200 files/s at 1.29 of 2 cores, flat from 128 to 512.
626 //
627 // And VERIFIED against the manifest before it is written.
628 // This is the one integrity check that does not depend on
629 // the backend: S3 returns a checksum header the SDK
630 // validates, Ozone returns no CRC-64 at all so the SDK
631 // validates nothing, and a body that is wrong under the
632 // cited etag — bit-rot, a broken gateway, a cache serving
633 // the wrong object — passes If-Match either way. Until
634 // 2026-09-12 the manifest's CRC was compared only on the
635 // RESUME path, so a corrupt fresh fetch was written, cited
636 // in the baseline and read by the agent as the file.
637 let expect_crc = Some(entry.crc64_b64.clone());
638 let (st, got) = {
639 let target_w = target.clone();
640 let body_w = body.clone(); // Bytes: a refcount bump, not a copy
641 let mode_w = entry.mode;
642 let key_err = entry.key.clone();
643 let key_crc = entry.key.clone();
644 let etag_err = entry.etag.clone();
645 let rel_err = path.clone();
646 tokio::task::spawn_blocking(
647 move || -> LeanResult<(std::fs::Metadata, String)> {
648 let got = flint_store::crc64_to_b64(flint_store::crc64_nvme(&body_w));
649 if let Some(want) = expect_crc {
650 if got != want {
651 let who = format!("manifest cites {rel_err} ({key_crc} at etag {etag_err}) with");
652 return Err(LeanError::State(format!(
653 "{who} CRC-64 {want}, but the bytes fetched under that \
654 etag hash to {got} — the object is corrupt or the store \
655 returned the wrong bytes; refusing to materialise it \
656 (nothing written)"
657 )));
658 }
659 }
660 let st = write_file_atomic(&target_w, &body_w, Some(mode_w))?;
661 Ok((st, got))
662 },
663 )
664 .await
665 .map_err(|e| {
666 LeanError::State(format!(
667 "write task for {key_err} did not complete: {e}"
668 ))
669 })??
670 };
671 Ok(Fetched {
672 ranged: false,
673 path: path.clone(),
674 be: Some(BaselineEntry {
675 etag: meta.etag.clone(),
676 key: Some(entry.key.clone()),
677 generation: entry.generation,
678 size: st.len(),
679 mtime_unix: mtime_of(&st),
680 mtime_nanos: Some(mtime_nanos_of(&st)),
681 mode: Some(entry.mode & super::scan::PERM_BITS),
682 racy: super::barrier::racy_stat(&st),
683 crc64_b64: Some(got),
684 }),
685 skipped: false,
686 bytes: body.len() as u64,
687 refused: None,
688 })
689 }
690 }))
691 .buffer_unordered(per_driver)
692 .collect::<Vec<LeanResult<Fetched>>>()
693 .await
694 });
695 }
696 let mut out = Vec::new();
697 while let Some(r) = set.join_next().await {
698 match r {
699 Ok(v) => out.extend(v),
700 Err(e) => out.push(Err(LeanError::State(format!(
701 "fetch driver did not complete: {e}"
702 )))),
703 }
704 }
705 out
706 }
707
708 /// Materialize the workspace from the manifest. Idempotent across
709 /// crashes (resume skips present paths); refuses over budget
710 /// BEFORE the first byte.
711 pub async fn checkout(&mut self) -> LeanResult<CheckoutReport> {
712 self.checkout_scoped(None).await
713 }
714
715 /// Materialize only the citations an explicit scope admits.
716 ///
717 /// The safety argument is `classify` (`scan.rs`), which derives
718 /// deletions by iterating `baseline.entries.keys()`: a path this
719 /// checkout never materialized is never cited, so it can never be
720 /// classified absent and can never have its object DELETEd. So the
721 /// DELETE sites need no scope filter: the admission filter here is
722 /// that whole argument. What the tree is OWED does need one, below.
723 ///
724 /// The held set is what the baseline cites plus what the scope covers.
725 /// A path outside both that changes REMOTELY is not owed: the consume
726 /// and the sync decline it (`Prop_ScopeRespected` in `LeanP1.tla`).
727 /// A file the AGENT writes outside the scope publishes like any other,
728 /// and from then on the baseline holds it. `rescope` narrows or widens
729 /// the set later.
730 /// Name the right culprit when a fetch could not be served.
731 ///
732 /// Two of `materialize`'s refusals — the SOLE WRITER 412 and the
733 /// cited-object-is-gone 404 — read the same
734 /// evidence ("this object is not what the manifest said") and
735 /// accuse a stranger of writing the bucket. There is a second
736 /// explanation, and after 2026-09-11 it is the LIKELIER one:
737 /// `checkout` no longer holds the epoch, so the workspace's own
738 /// publisher may have published a new generation between the
739 /// manifest we loaded and the object we asked for. A reader is then
740 /// citing a manifest one generation stale, and every word of
741 /// "something other than its publisher wrote that object" is false.
742 ///
743 /// One GET of the pointer tells them apart, and it is paid only on
744 /// a path that is already returning an error. A pointer that has
745 /// not moved leaves the original refusal exactly as written — the
746 /// stranger accusation is still the right one, and softening it for
747 /// every caller to buy this would be the worse trade.
748 /// Has the pointer moved past the document this checkout read? One
749 /// GET of the pointer; unreadable reads as "no", so nothing is
750 /// retried on a store that cannot answer.
751 async fn pointer_moved_past(&self, read_seq: u64) -> bool {
752 match manifest::load_pointer(self.store.as_ref(), &self.cfg).await {
753 Ok(Some(p)) => p.pointer.seq > read_seq,
754 Ok(None) | Err(_) => false,
755 }
756 }
757
758 async fn say_who_moved_it(&self, read_seq: u64, e: LeanError) -> LeanError {
759 let now = match manifest::load_pointer(self.store.as_ref(), &self.cfg).await {
760 Ok(Some(p)) => p.pointer.seq,
761 // No pointer, or we cannot read one. Either way we have
762 // learned nothing, so we must not overwrite what we know.
763 Ok(None) | Err(_) => return e,
764 };
765 if now <= read_seq {
766 return e;
767 }
768 LeanError::State(format!(
769 "this checkout read manifest seq {read_seq} and the workspace is now at \
770 seq {now}: its publisher published while the checkout was running, so \
771 the citation it was still fetching is one generation stale. Re-run \
772 checkout. (The underlying refusal, which names a stranger and in this \
773 case means nothing of the kind: {e})"
774 ))
775 }
776
777 pub async fn checkout_scoped(
778 &mut self,
779 scope: Option<Vec<String>>,
780 ) -> LeanResult<CheckoutReport> {
781 // Validated BEFORE the live-tree row, because a malformed scope
782 // is a configuration error whether or not this tree is already
783 // up, and the one thing it must never do is quietly widen.
784 // `Scope::new` silently drops entries that are too long, past
785 // MAX_SCOPE_ENTRIES, or contain `.`/`..`; three malformed paths
786 // normalize to an EMPTY scope, and an empty scope reads
787 // everywhere as "no restriction" = the whole manifest. Same rule
788 // and same shape as `sync_scoped`.
789 let scope = match scope {
790 None => None,
791 Some(raw) => {
792 let s = super::sync::Scope::new(&raw);
793 if s.is_empty() {
794 return Err(LeanError::State(format!(
795 "checkout scope named {} entr{} and NONE survived validation — \
796 refusing, because an empty scope widens to the WHOLE MANIFEST",
797 raw.len(),
798 if raw.len() == 1 { "y" } else { "ies" }
799 )));
800 }
801 Some(s)
802 }
803 };
804 let requested: Option<Vec<String>> = scope.as_ref().map(|s| s.entries().to_vec());
805
806 let mut report = CheckoutReport::default();
807 report.scope = requested.clone();
808 if self.state.marker_present() {
809 // The live-tree row: never re-materialize. But a live tree
810 // holds what it holds, and `checkout` cannot change that —
811 // so a caller whose scope disagrees with the one on disk
812 // gets an error, never a success naming a set it did not
813 // get. The dangerous direction is the quiet one: a caller
814 // that asks for the whole tree and resumes a 3-file
815 // workspace believes it is holding 2001 paths.
816 let held = self.state.load_scope()?;
817 if held != requested {
818 return Err(LeanError::State(format!(
819 "this workspace is already checked out {}, and the request asks for \
820 {} — checkout cannot change the admitted set of a live tree, and \
821 resuming it would return success for a set you did not get",
822 describe_scope(held.as_deref()),
823 describe_scope(requested.as_deref()),
824 )));
825 }
826 report.resumed_live_tree = true;
827 return Ok(report);
828 }
829
830 let mut baseline = self.state.take_baseline()?;
831 let mut present: BTreeSet<String> = BTreeSet::new();
832 let mut attempt = 0u32;
833 let (m, metag, results) = loop {
834 attempt += 1;
835 let t_start = std::time::Instant::now();
836 let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
837 report.manifest_secs = t_start.elapsed().as_secs_f64();
838 let (m, metag) = match loaded {
839 Some(l) => (l.manifest, Some(l.etag)),
840 None => (Default::default(), None),
841 };
842
843 // ADMISSION FIRST, budgets second. The order is load-bearing:
844 // a budget is a promise about what THIS checkout will write, and
845 // a 3-file scoped checkout summed over the whole 2001-file
846 // manifest is refused for bytes it was never going to fetch.
847 let admission: Vec<(&String, &super::manifest::LeanEntry)> = match &scope {
848 None => m.entries.iter().collect(),
849 Some(s) => m.entries.iter().filter(|(p, _)| s.covers(p)).collect(),
850 };
851 report.out_of_scope = m.entries.len() - admission.len();
852
853 // Budgets: refuse before materializing anything.
854 let total_bytes: u64 = admission.iter().map(|(_, e)| e.size).sum();
855 if self.cfg.max_bytes > 0 && total_bytes > self.cfg.max_bytes {
856 return Err(LeanError::Budget(format!(
857 "checkout is {} bytes; budget {}",
858 total_bytes, self.cfg.max_bytes
859 )));
860 }
861 if self.cfg.max_files > 0 && admission.len() as u64 > self.cfg.max_files {
862 return Err(LeanError::Budget(format!(
863 "checkout is {} files; budget {}",
864 admission.len(),
865 self.cfg.max_files
866 )));
867 }
868
869 let t_fetch = std::time::Instant::now();
870 // A mirror's publisher is the only party entitled to write it,
871 // so an object off its citation was moved by a stranger.
872 // Adopting it would copy bytes no manifest cites into this
873 // tree, silently — drill C4.
874 let results = self.materialize(admission, m.sole_writer).await;
875 report.fetch_secs += t_fetch.elapsed().as_secs_f64();
876 // A citation that could not be served has a second explanation
877 // under handles (design 2026-09-19): the workspace's own
878 // publisher moved the pointer past the document this pass read
879 // and its collector took the handle. Then the pass is simply
880 // re-run against the new document — bounded, and cheap: every
881 // file already on disk at the cited bytes is adopted, not
882 // fetched again — instead of telling the caller to. A pointer
883 // that has not moved leaves the refusal exactly as written.
884 let moved = results.iter().any(|r| r.is_err()) && self.pointer_moved_past(m.seq).await;
885 if moved && attempt < 3 {
886 report.reresolved += 1;
887 continue;
888 }
889 break (m, metag, results);
890 };
891 let t_commit = std::time::Instant::now();
892 for r in results {
893 let f = match r {
894 Ok(f) => f,
895 Err(e) => return Err(self.say_who_moved_it(m.seq, e).await),
896 };
897 if let Some(why) = f.refused {
898 self.state.append_conflict(&super::state::ConflictRecord {
899 path: f.path.clone(),
900 foreign_etag: String::new(),
901 preserved_key: None,
902 kind: format!("checkout-refused: {why}"),
903 at_unix: super::now_unix(),
904 })?;
905 report.refused += 1;
906 continue;
907 }
908 if f.ranged {
909 report.ranged += 1;
910 }
911 if f.skipped {
912 report.skipped_present += 1;
913 } else {
914 report.materialized += 1;
915 report.bytes += f.bytes;
916 }
917 present.insert(f.path.clone());
918 if let Some(be) = f.be {
919 baseline.entries.insert(f.path, be);
920 }
921 }
922
923 baseline.seq = m.seq;
924 baseline.manifest_etag = metag;
925 // Checked out whole or scoped, the tree owes nothing: the baseline
926 // (the merge base since P1-lite) holds what was materialised, and the
927 // consume never owes a path the scope declined. So this document is
928 // derived, and nothing is skipped.
929 baseline.derived_etag = Some(baseline.manifest_etag.clone().unwrap_or_default());
930 baseline.skipped.clear();
931 baseline.prev_scan.sync_to(&present);
932 // Every materialised file reaches stable storage BEFORE the
933 // baseline and the marker that vouch for it: after a power loss
934 // the tree is then at least as durable as its description, so
935 // the next scan cannot read a zero-length survivor as a local
936 // edit and publish it over the good version.
937 self.state.sync_tree()?;
938 // THE GATE (latency plan §17; LeanScanRacy's `Open`). Every file
939 // above was stat'ed in the tick it was written, so it is racy, and
940 // the first scan would hash the whole tree. But nothing writes the
941 // tree before the marker below exists (the CSI node plugin binds it
942 // into the pod only then), so each flag is measured now: a file
943 // written a window before the gate cannot hide a write, which can
944 // only come after it. What this checkout wrote in its last window
945 // stays racy, and the first scan hashes only that. Measured before
946 // the marker, so earlier than the gate: the safe direction. After
947 // the sync above, so its time counts toward the window for free.
948 super::barrier::consume_window("before-gate", "");
949 let mut gate = super::scan::now_nanos();
950 // When what it wrote in its last window is many files, the gate
951 // opens the rest of their window later instead: at most a window on
952 // the agent's start, against hashing every one of them in the first
953 // scan. Nothing writes before the gate, so it may open at any tick.
954 if self.cfg.racy_at_gate && self.cfg.racy_wait_min > 0 {
955 let (n, newest) = baseline
956 .entries
957 .values()
958 .filter(|e| e.racy && super::scan::racy(e.mtime_unix, e.mtime_nanos.unwrap_or(999_999_999), gate))
959 .map(|e| e.mtime_unix as i128 * 1_000_000_000 + e.mtime_nanos.unwrap_or(999_999_999) as i128)
960 .fold((0usize, i128::MIN), |(n, m), t| (n + 1, m.max(t)));
961 if n >= self.cfg.racy_wait_min {
962 // A clock that runs backwards, or an mtime in the future,
963 // leaves those entries racy: the safe direction.
964 let rest = (newest + super::scan::RACY_WINDOW_NANOS - gate).clamp(0, super::scan::RACY_WINDOW_NANOS);
965 tokio::time::sleep(std::time::Duration::from_nanos(rest as u64 + 1_000_000)).await;
966 gate = super::scan::now_nanos();
967 }
968 }
969 let settled: Vec<String> = baseline
970 .entries
971 .iter()
972 .filter(|_| self.cfg.racy_at_gate)
973 .filter(|(_, e)| e.racy && !super::scan::racy(e.mtime_unix, e.mtime_nanos.unwrap_or(999_999_999), gate))
974 .map(|(p, _)| p.clone())
975 .collect();
976 for p in settled {
977 if let Some(e) = baseline.entries.get_mut(&p) {
978 e.racy = false;
979 }
980 }
981 self.state.commit_baseline(baseline)?;
982 // Before the marker, always — including the `None` case, which
983 // REMOVES any scope a crashed predecessor left behind. The
984 // marker is the agent-start gate; a scope that landed after it
985 // would leave a window in which an agent is cleared to run
986 // against a tree whose admitted set is not yet durable, and a
987 // crash there yields a workspace holding three files that
988 // claims, by the absence of any scope, to hold all of them.
989 self.state.save_scope(requested.as_deref())?;
990 // D11: the capability marker and the gauges exist BEFORE the
991 // agent-start gate opens, so the first thing the agent does can
992 // be to read them. `run` writes capabilities around checkout
993 // too; doing it here as well covers the standalone `checkout`
994 // subcommand, which otherwise leaves an agent with no marker to
995 // read and therefore no way to know the verbs exist.
996 let posture = self.sentinel_preflight()?;
997 self.write_capabilities(&posture)?;
998 self.write_gauges(None)?;
999 // The marker is written LAST: the agent-start gate.
1000 self.state.write_marker()?;
1001 report.commit_secs = t_commit.elapsed().as_secs_f64();
1002 Ok(report)
1003 }
1004}
1005
1006// ── the narrow / widen verb (scoped-read design §4) ──────────────────
1007
1008#[derive(Debug, Default)]
1009pub struct RescopeReport {
1010 /// The scope this workspace now holds. `None` = the whole tree.
1011 pub target: Option<Vec<String>>,
1012 /// Citations removed from the held set — the NARROW half.
1013 pub uncited: usize,
1014 /// Files removed from the tree. Normally equal to `uncited`; lower
1015 /// when a path was already gone.
1016 pub unlinked: usize,
1017 /// Newly admitted citations fetched — the WIDEN half.
1018 pub materialized: usize,
1019 pub bytes: u64,
1020 /// Admitted and already held: the scope grew over paths the
1021 /// workspace had. Zero work, and the number that tells a widen that
1022 /// did nothing from a widen that had nothing to do.
1023 pub already_held: usize,
1024 /// Paths whose unpublished local changes a rescope kept, each with a
1025 /// conflict record: on a narrow, paths that should have left the held
1026 /// set (KEPT, cited — a narrow may unwatch a file, never discard an
1027 /// edit); on a widen, admitted paths where the agent had made a file
1028 /// the tree does not cite yet (kept, uncited, published by the next
1029 /// barrier).
1030 pub kept_dirty: Vec<String>,
1031 /// This call finished a rescope a crash had left half-applied.
1032 pub replayed: bool,
1033}
1034
1035impl Syncer {
1036 /// Move the workspace to `target`: stop holding what it no longer
1037 /// admits, and fetch what it newly does.
1038 ///
1039 /// ## Why this cannot be `rm` plus a scope edit
1040 ///
1041 /// `classify` reads exactly the two states a narrow passes through:
1042 /// present-in-scan-absent-from-baseline is an **upload**, and
1043 /// present-in-baseline-absent-from-scan-and-`prev_scan` is a
1044 /// **delete**. So unlink-then-uncite crashes into publishing
1045 /// deletions, and uncite-then-unlink crashes into re-uploading
1046 /// identical bytes and re-citing everything just dropped. Neither
1047 /// order is safe alone. The invariant, stated for the model:
1048 /// **a narrow is an unwatch, never an absence.**
1049 ///
1050 /// What makes it safe is the INTENT, not the order: the target is
1051 /// durable before the first mutation and cleared after the last,
1052 /// `run_barrier` replays any intent it finds before it does
1053 /// anything else, and the replay is idempotent. The order within
1054 /// still matters for the window where the intent itself is lost —
1055 /// uncite first, so the failure mode is a file that gets
1056 /// re-uploaded rather than one that gets DELETED from the bucket.
1057 ///
1058 /// ## Why the verb takes a SET
1059 ///
1060 /// `manifest::load` fetches the pointer and every chunk regardless
1061 /// of scope, so the floor is one whole-manifest load per call.
1062 /// A per-path verb would pay that floor per path.
1063 pub async fn rescope(&mut self, target: Option<Vec<String>>) -> LeanResult<RescopeReport> {
1064 if let Some(raw) = &target {
1065 // An all-rejected scope is REFUSED, never widened to the
1066 // whole tree — `Scope::new` silently drops malformed
1067 // entries, and `sync.rs` already shipped the bug where that
1068 // turned a typo into maximum privilege.
1069 if super::sync::Scope::new(raw).is_empty() {
1070 return Err(LeanError::State(format!(
1071 "every entry of the requested scope was rejected ({raw:?}); refusing to \
1072 read that as the whole tree"
1073 )));
1074 }
1075 }
1076 if !self.state.marker_present() {
1077 return Err(LeanError::State(
1078 "rescope needs a checked-out workspace — run checkout first".into(),
1079 ));
1080 }
1081
1082 // The strict check belongs at the DOOR, before anything is
1083 // written: a caller asking to drop a path it has unpublished
1084 // edits to gets told, and the old scope stands untouched. The
1085 // replay below cannot be this strict — it must converge — so it
1086 // keeps such paths instead. Two postures, deliberately.
1087 let leaving = self.paths_leaving(&target)?;
1088 let dirty = self.dirty_among(&leaving)?;
1089 if !dirty.is_empty() {
1090 return Err(LeanError::State(format!(
1091 "these paths would leave the scope but have unpublished local changes, or the \
1092 syncer may not read them: {:?} — publish or discard them, or make them readable, \
1093 first",
1094 dirty
1095 )));
1096 }
1097
1098 let intent = super::state::ScopeIntent { target, drop: leaving, held: Default::default() };
1099 self.state.save_scope_intent(&intent)?;
1100 // The model's RescopeBegin: the intent durable, nothing applied.
1101 self.trace("rescope", serde_json::json!({"stage": "begin", "target": intent.target, "drop": intent.drop}));
1102 self.apply_scope_intent(&intent).await
1103 }
1104
1105 /// Finish a rescope a crash left half-applied. `None` when there is
1106 /// none in flight. Idempotent: running it twice is running it once.
1107 pub async fn replay_scope_intent(&mut self) -> LeanResult<Option<RescopeReport>> {
1108 let Some(intent) = self.state.load_scope_intent()? else { return Ok(None) };
1109 let mut report = self.apply_scope_intent(&intent).await?;
1110 report.replayed = true;
1111 Ok(Some(report))
1112 }
1113
1114 /// Which held paths the target scope would stop admitting.
1115 fn paths_leaving(&self, target: &Option<Vec<String>>) -> LeanResult<Vec<String>> {
1116 let baseline = self.state.load_baseline()?;
1117 let scope = target.as_ref().map(|r| super::sync::Scope::new(r));
1118 Ok(baseline
1119 .entries
1120 .keys()
1121 .filter(|p| scope.as_ref().map(|s| !s.covers(p)).unwrap_or(false))
1122 .cloned()
1123 .collect())
1124 }
1125
1126 /// Which of `paths` carry local changes the bucket has not seen.
1127 /// The same three states `sync` calls dirty, for the same reason.
1128 fn dirty_among(&self, paths: &[String]) -> LeanResult<Vec<String>> {
1129 if paths.is_empty() {
1130 return Ok(vec![]);
1131 }
1132 let baseline = self.state.load_baseline()?;
1133 let scanned = super::scan::scan_with(&self.cfg.root, self.cfg.scan_threads)?;
1134 let mut c = super::scan::classify(&scanned, &baseline);
1135 super::scan::confirm_racy(&self.cfg.root, &scanned, &baseline, &mut c);
1136 Ok(paths
1137 .iter()
1138 .filter(|p| {
1139 c.uploads.contains(*p)
1140 || c.deletes.contains(*p)
1141 || c.first_absence.contains(*p)
1142 // Unseen: kept, as a dirty path is.
1143 || scanned.holds(p)
1144 })
1145 .cloned()
1146 .collect())
1147 }
1148
1149 async fn apply_scope_intent(
1150 &mut self,
1151 intent: &super::state::ScopeIntent,
1152 ) -> LeanResult<RescopeReport> {
1153 let target = intent.target.clone();
1154 let mut report = RescopeReport { target: target.clone(), ..Default::default() };
1155 let scope = target.as_ref().map(|r| super::sync::Scope::new(r));
1156 let covered = |p: &str| scope.as_ref().map(|s| s.covers(p)).unwrap_or(true);
1157
1158 // §4.4's floor: the whole manifest, scope or no scope.
1159 let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
1160 let (m, metag) = match loaded {
1161 Some(l) => (l.manifest, Some(l.etag)),
1162 None => (Default::default(), None),
1163 };
1164
1165 let mut baseline = self.state.load_baseline()?;
1166 // THE RECORDED set, never re-derived — see `ScopeIntent::drop`.
1167 // Re-deriving from the baseline loses the whole set the moment
1168 // the uncite lands; re-deriving from the tree cannot tell a
1169 // leftover from a file the agent created.
1170 let leaving: Vec<String> = intent.drop.clone();
1171 // Converging posture: a dirty path is KEPT rather than dropped,
1172 // so a replay can never be stuck refusing forever.
1173 //
1174 // A path already uncited by a crashed run reads as dirty here
1175 // (present in scan, absent from the baseline ⇒ `uploads`), and
1176 // keeping it would undo the very step that crashed. So dirt is
1177 // judged against what the path IS: still cited and modified, or
1178 // not cited at all and therefore already half-dropped.
1179 let still_cited: Vec<String> =
1180 leaving.iter().filter(|p| baseline.entries.contains_key(*p)).cloned().collect();
1181 let dirty = self.dirty_among(&still_cited)?;
1182 for p in &dirty {
1183 self.state.append_conflict(&super::state::ConflictRecord {
1184 path: p.clone(),
1185 foreign_etag: String::new(),
1186 preserved_key: None,
1187 kind: "rescope-kept-locally-dirty".into(),
1188 at_unix: super::now_unix(),
1189 })?;
1190 }
1191 report.kept_dirty = dirty.clone();
1192 let drop_set: Vec<String> =
1193 leaving.into_iter().filter(|p| !dirty.contains(p)).collect();
1194
1195 // What the uncite drops, durable with the intent before it lands. A
1196 // replay keeps what an earlier run recorded: those paths are no
1197 // longer in the baseline to read it from.
1198 let mut recorded = intent.clone();
1199 for p in &drop_set {
1200 if let Some(be) = baseline.entries.get(p) {
1201 recorded.held.insert(p.clone(), be.clone());
1202 }
1203 }
1204 if recorded != *intent {
1205 self.state.save_scope_intent(&recorded)?;
1206 }
1207
1208 // UNCITE FIRST, and durably, before a single file leaves the
1209 // tree. Both halves must happen, and the intent guarantees
1210 // they will; this order decides only which way a lost intent
1211 // fails — toward a file that reads as a local add (re-uploaded,
1212 // recoverable) rather than one that reads as a local delete
1213 // (published as a DELETE, not recoverable).
1214 for p in &drop_set {
1215 baseline.entries.remove(p);
1216 baseline.prev_scan.remove(p);
1217 }
1218 report.uncited = drop_set.len();
1219 self.state.save_baseline(&baseline)?;
1220 // The model's RescopeFirst: the uncite durable, what it kept named.
1221 self.trace("rescope", serde_json::json!({"stage": "uncited", "kept": dirty, "uncited": drop_set}));
1222 let mut unlinked: Vec<&String> = vec![];
1223
1224 for p in &drop_set {
1225 let local = self.cfg.root.join(p);
1226 // The same containment the barrier demands: a citation is
1227 // not a licence to unlink whatever a path resolves to.
1228 if contained_path(&self.cfg.root, p).is_err() {
1229 continue;
1230 }
1231 super::barrier::consume_window("rescope-before-unlink", p);
1232 // Only the bytes the uncite dropped. A file that is no longer
1233 // them is the agent's, written since: kept, uncited, recorded;
1234 // the next barrier publishes it as the agent's add.
1235 if std::fs::symlink_metadata(&local).is_ok()
1236 && super::barrier::local_dirty(&local, recorded.held.get(p))
1237 {
1238 self.state.append_conflict(&super::state::ConflictRecord {
1239 path: p.clone(),
1240 foreign_etag: String::new(),
1241 preserved_key: None,
1242 kind: "rescope-kept-agent-write".into(),
1243 at_unix: super::now_unix(),
1244 })?;
1245 report.kept_dirty.push(p.clone());
1246 continue;
1247 }
1248 match std::fs::remove_file(&local) {
1249 Ok(()) => {
1250 report.unlinked += 1;
1251 unlinked.push(p);
1252 // Each unlink as it lands: a crash part-way through the
1253 // loop leaves some done, and the replay does the rest.
1254 self.trace("rescope", serde_json::json!({"stage": "unlink", "path": p}));
1255 }
1256 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1257 Err(e) => return Err(LeanError::Io(e)),
1258 }
1259 }
1260
1261 // WIDEN: admitted citations this workspace does not hold.
1262 let mut add: Vec<(&String, &super::manifest::LeanEntry)> =
1263 m.entries.iter().filter(|(p, _)| covered(p) && !baseline.entries.contains_key(*p)).collect();
1264 // A file already at an admitted path that this tree does not cite
1265 // is the agent's (made since the last barrier; the scan has not
1266 // published it yet). Materialize would fetch the document's
1267 // version over it (checkout's resume rule, which is right only
1268 // for its own half-written fetches). The consume calls such a path
1269 // dirty and leaves it; so does the widen: kept, uncited, and named
1270 // in a record. The next barrier publishes it as the agent's add,
1271 // and the merge records the version it replaces (review
1272 // 2026-09-29, R3). Bytes that ARE the document's are adopted as
1273 // before, which is what lets a crashed widen's replay converge.
1274 let mut kept_local: Vec<String> = Vec::new();
1275 add.retain(|(p, e)| {
1276 let local = self.cfg.root.join(p);
1277 let Ok(st) = std::fs::symlink_metadata(&local) else { return true };
1278 let same = st.is_file()
1279 && st.len() == e.size
1280 && local_crc64_b64(&local).map(|got| got == e.crc64_b64).unwrap_or(false);
1281 if !same {
1282 kept_local.push((*p).clone());
1283 }
1284 same
1285 });
1286 for p in &kept_local {
1287 self.state.append_conflict(&super::state::ConflictRecord {
1288 path: p.clone(),
1289 foreign_etag: m.entries[p].etag.clone(),
1290 preserved_key: None,
1291 kind: "rescope-kept-local-unpublished".into(),
1292 at_unix: super::now_unix(),
1293 })?;
1294 }
1295 report.kept_dirty.extend(kept_local);
1296 report.already_held =
1297 m.entries.keys().filter(|p| covered(p) && baseline.entries.contains_key(*p)).count();
1298 let mut taken: Vec<String> = vec![];
1299 if !add.is_empty() {
1300 // Review 2026-09-18, M3: the rescope holds the fence for its
1301 // whole run, and the widen's fetch is its long stretch. Renew
1302 // before it (deposed here, nothing is fetched yet and the
1303 // baseline above is saved: stop) and after it (best effort: the
1304 // rescope writes nothing to the store, so a fence lost during
1305 // the fetch costs a release, not a byte).
1306 super::lease::renew_if_due(self).await?;
1307 let results = self.materialize(add, m.sole_writer).await;
1308 let _ = super::lease::renew_if_due(self).await;
1309 for r in results {
1310 let f = r?;
1311 if let Some(why) = f.refused {
1312 self.state.append_conflict(&super::state::ConflictRecord {
1313 path: f.path.clone(),
1314 foreign_etag: String::new(),
1315 preserved_key: None,
1316 kind: format!("rescope-refused: {why}"),
1317 at_unix: super::now_unix(),
1318 })?;
1319 continue;
1320 }
1321 if !f.skipped {
1322 report.materialized += 1;
1323 report.bytes += f.bytes;
1324 }
1325 baseline.prev_scan.insert(f.path.clone());
1326 if let Some(be) = f.be {
1327 taken.push(f.path.clone());
1328 baseline.entries.insert(f.path, be);
1329 }
1330 }
1331 }
1332
1333 baseline.seq = m.seq;
1334 baseline.manifest_etag = metag;
1335 // What the held set is owed changed with it: the next consume
1336 // derives in full rather than trusting the cheap path's record
1337 // (a widened path it kept or could not take is owed from here).
1338 baseline.derived_etag = None;
1339 baseline.skipped.clear();
1340
1341 self.state.sync_tree()?;
1342 self.state.commit_baseline(baseline)?;
1343 self.state.save_scope(target.as_deref())?;
1344 // LAST. While this document exists the workspace is mid-rescope
1345 // and `run_barrier` will replay before doing anything else.
1346 self.state.clear_scope_intent()?;
1347 // The model's RescopeSecond: the unlink and the widen, the new scope.
1348 self.trace("rescope", serde_json::json!({"stage": "applied", "unlinked": unlinked, "taken": taken,
1349 "target": target}));
1350 Ok(report)
1351 }
1352}