Skip to main content

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                                change: Some(super::scan::ChangeStamp::of(&st)),
489                                crc64_b64: Some(entry.crc64_b64.clone()),
490                            }),
491                            skipped: true,
492                            bytes: 0,
493                            refused: None,
494                        });
495                    }
496                    // Fall through and re-materialize.
497                }
498                // Before a byte is fetched: on a quota'd tree a body the
499                // tenant could not have written itself would spend the
500                // syncer's reserve (`safefs::leave_the_reserve`).
501                if tree_reserve {
502                    super::safefs::leave_the_reserve(&root, &path, entry.size, tree_room)?;
503                }
504                // RANGED FIRST, when the object is big enough to
505                // pay for it.
506                let ranged_bytes = if range_min > 0 && entry.size >= range_min {
507                    let tmp = target.with_file_name(format!(
508                        "{}.flint-sync-tmp",
509                        target.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()
510                    ));
511                    let want = flint_store::crc64_from_b64(&entry.crc64_b64).ok_or_else(|| {
512                        LeanError::State(format!(
513                            "manifest cites {} with a CRC-64 that is not one: {:?}",
514                            entry.key, entry.crc64_b64
515                        ))
516                    })?;
517                    fetch_ranged(
518                        &store,
519                        &entry.key,
520                        &entry.etag,
521                        entry.size,
522                        &target,
523                        &tmp,
524                        Some(entry.mode),
525                        range_chunk,
526                        range_par,
527                        want,
528                    )
529                    .await?
530                } else {
531                    None
532                };
533                if let Some(n) = ranged_bytes {
534                    let st = std::fs::metadata(&local)?;
535                    return Ok(Fetched {
536                        ranged: true,
537                        path: path.clone(),
538                        be: Some(BaselineEntry {
539                            etag: entry.etag.clone(),
540                            key: Some(entry.key.clone()),
541                            generation: entry.generation,
542                            size: st.len(),
543                            mtime_unix: mtime_of(&st),
544                            mtime_nanos: Some(mtime_nanos_of(&st)),
545                            mode: Some(entry.mode & super::scan::PERM_BITS),
546                            racy: super::barrier::racy_stat(&st),
547                            change: Some(super::scan::ChangeStamp::of(&st)),
548                            // The fold above equalled it, or we would
549                            // not be here.
550                            crc64_b64: Some(entry.crc64_b64.clone()),
551                        }),
552                        skipped: false,
553                        bytes: n,
554                        refused: None,
555                    });
556                }
557
558                // TOP UP BEFORE BUFFERING THE WHOLE OBJECT.
559                //
560                // Reaching here having been charged the ranged rate
561                // means `fetch_ranged` declined — either the object is
562                // one chunk (harmless: it holds less than the ranged
563                // charge) or it hit the 412/404 policy fallback, which
564                // hands the whole object to `get_whole` below. In that
565                // second case we are about to hold `entry.size` while
566                // charged for 64 MiB, and N concurrent fallbacks would
567                // walk RSS straight through the bound this semaphore
568                // exists to enforce.
569                //
570                // Released BEFORE re-acquiring, never upgraded in
571                // place: holding `want` while waiting for
572                // `whole_units` is a deadlock the moment two entries
573                // do it at once against a full budget.
574                if want < whole_units {
575                    drop(permit);
576                    permit = gate
577                        .clone()
578                        .acquire_many_owned(whole_units)
579                        .await
580                        .map_err(|_| LeanError::State("fetch budget closed".into()))?;
581                }
582                let _permit = permit;
583
584                let (meta, body) = match store.get_whole(&entry.key, Some(&entry.etag)).await {
585                        Ok(ok) => ok,
586                        // A cited HANDLE is immutable: nothing in the
587                        // protocol writes one twice (P2: a UI save goes to
588                        // a fresh handle and commits), so a handle whose
589                        // etag moved was written by something outside it.
590                        // Refused, never adopted — adopting bytes no
591                        // manifest cites was a citation-repair source
592                        // (M4), and the S3-wins arm that did it served a
593                        // HITL write the inbox had not re-cited yet, which
594                        // no longer exists.
595                        Err(StoreError::PreconditionFailed(_)) => {
596                            let who = if sole_writer {
597                                " This workspace is published by a SOLE WRITER — if this is \
598                                 forge's legible export, look for a read-write mount over its \
599                                 prefix; the export republishes only what git changed and will \
600                                 not repair this on its own."
601                            } else {
602                                ""
603                            };
604                            return Err(LeanError::State(format!(
605                                "manifest cites {} at an etag the object no longer carries: \
606                                 something outside the protocol overwrote an immutable handle. \
607                                 Refusing to adopt bytes no manifest cites.{who}",
608                                entry.key
609                            )));
610                        }
611                        Err(StoreError::NotFound(_)) => {
612                            return Err(LeanError::State(format!(
613                                "manifest cites {} but the object is gone — refusing a \
614                                 silent hole (mixed-writer bucket?)",
615                                entry.key
616                            )));
617                        }
618                        Err(e) => return Err(e.into()),
619                };
620                // OFF the fan-out task, for the same reason the ranged arm
621                // is: write_file_atomic is a blocking create+write+fsync+
622                // rename, and running it inline here froze every other
623                // in-flight fetch sharing this task. Small files ALL come
624                // through this arm — 8 KiB is far below the 16 MiB
625                // effective ranged threshold — which is why raising fanout
626                // stopped paying above 128 with half a core sitting idle:
627                // 3,200 files/s at 1.29 of 2 cores, flat from 128 to 512.
628                //
629                // And VERIFIED against the manifest before it is written.
630                // This is the one integrity check that does not depend on
631                // the backend: S3 returns a checksum header the SDK
632                // validates, Ozone returns no CRC-64 at all so the SDK
633                // validates nothing, and a body that is wrong under the
634                // cited etag — bit-rot, a broken gateway, a cache serving
635                // the wrong object — passes If-Match either way. Until
636                // 2026-09-12 the manifest's CRC was compared only on the
637                // RESUME path, so a corrupt fresh fetch was written, cited
638                // in the baseline and read by the agent as the file.
639                let expect_crc = Some(entry.crc64_b64.clone());
640                let (st, got) = {
641                    let target_w = target.clone();
642                    let body_w = body.clone(); // Bytes: a refcount bump, not a copy
643                    let mode_w = entry.mode;
644                    let key_err = entry.key.clone();
645                    let key_crc = entry.key.clone();
646                    let etag_err = entry.etag.clone();
647                    let rel_err = path.clone();
648                    tokio::task::spawn_blocking(
649                        move || -> LeanResult<(std::fs::Metadata, String)> {
650                            let got = flint_store::crc64_to_b64(flint_store::crc64_nvme(&body_w));
651                            if let Some(want) = expect_crc {
652                                if got != want {
653                                    let who = format!("manifest cites {rel_err} ({key_crc} at etag {etag_err}) with");
654                                    return Err(LeanError::State(format!(
655                                        "{who} CRC-64 {want}, but the bytes fetched under that \
656                                         etag hash to {got} — the object is corrupt or the store \
657                                         returned the wrong bytes; refusing to materialise it \
658                                         (nothing written)"
659                                    )));
660                                }
661                            }
662                            let st = write_file_atomic(&target_w, &body_w, Some(mode_w))?;
663                            Ok((st, got))
664                        },
665                    )
666                    .await
667                    .map_err(|e| {
668                        LeanError::State(format!(
669                            "write task for {key_err} did not complete: {e}"
670                        ))
671                    })??
672                };
673                Ok(Fetched {
674                    ranged: false,
675                    path: path.clone(),
676                    be: Some(BaselineEntry {
677                        etag: meta.etag.clone(),
678                        key: Some(entry.key.clone()),
679                        generation: entry.generation,
680                        size: st.len(),
681                        mtime_unix: mtime_of(&st),
682                        mtime_nanos: Some(mtime_nanos_of(&st)),
683                        mode: Some(entry.mode & super::scan::PERM_BITS),
684                        racy: super::barrier::racy_stat(&st),
685                        change: Some(super::scan::ChangeStamp::of(&st)),
686                        crc64_b64: Some(got),
687                    }),
688                    skipped: false,
689                    bytes: body.len() as u64,
690                    refused: None,
691                })
692            }
693        }))
694        .buffer_unordered(per_driver)
695        .collect::<Vec<LeanResult<Fetched>>>()
696        .await
697            });
698        }
699        let mut out = Vec::new();
700        while let Some(r) = set.join_next().await {
701            match r {
702                Ok(v) => out.extend(v),
703                Err(e) => out.push(Err(LeanError::State(format!(
704                    "fetch driver did not complete: {e}"
705                )))),
706            }
707        }
708        out
709    }
710
711    /// Materialize the workspace from the manifest. Idempotent across
712    /// crashes (resume skips present paths); refuses over budget
713    /// BEFORE the first byte.
714    pub async fn checkout(&mut self) -> LeanResult<CheckoutReport> {
715        self.checkout_scoped(None).await
716    }
717
718    /// Materialize only the citations an explicit scope admits.
719    ///
720    /// The safety argument is `classify` (`scan.rs`), which derives
721    /// deletions by iterating `baseline.entries.keys()`: a path this
722    /// checkout never materialized is never cited, so it can never be
723    /// classified absent and can never have its object DELETEd. So the
724    /// DELETE sites need no scope filter: the admission filter here is
725    /// that whole argument. What the tree is OWED does need one, below.
726    ///
727    /// The held set is what the baseline cites plus what the scope covers.
728    /// A path outside both that changes REMOTELY is not owed: the consume
729    /// and the sync decline it (`Prop_ScopeRespected` in `LeanP1.tla`).
730    /// A file the AGENT writes outside the scope publishes like any other,
731    /// and from then on the baseline holds it. `rescope` narrows or widens
732    /// the set later.
733    /// Name the right culprit when a fetch could not be served.
734    ///
735    /// Two of `materialize`'s refusals — the SOLE WRITER 412 and the
736    /// cited-object-is-gone 404 — read the same
737    /// evidence ("this object is not what the manifest said") and
738    /// accuse a stranger of writing the bucket. There is a second
739    /// explanation, and after 2026-09-11 it is the LIKELIER one:
740    /// `checkout` no longer holds the epoch, so the workspace's own
741    /// publisher may have published a new generation between the
742    /// manifest we loaded and the object we asked for. A reader is then
743    /// citing a manifest one generation stale, and every word of
744    /// "something other than its publisher wrote that object" is false.
745    ///
746    /// One GET of the pointer tells them apart, and it is paid only on
747    /// a path that is already returning an error. A pointer that has
748    /// not moved leaves the original refusal exactly as written — the
749    /// stranger accusation is still the right one, and softening it for
750    /// every caller to buy this would be the worse trade.
751    /// Has the pointer moved past the document this checkout read? One
752    /// GET of the pointer; unreadable reads as "no", so nothing is
753    /// retried on a store that cannot answer.
754    async fn pointer_moved_past(&self, read_seq: u64) -> bool {
755        match manifest::load_pointer(self.store.as_ref(), &self.cfg).await {
756            Ok(Some(p)) => p.pointer.seq > read_seq,
757            Ok(None) | Err(_) => false,
758        }
759    }
760
761    async fn say_who_moved_it(&self, read_seq: u64, e: LeanError) -> LeanError {
762        let now = match manifest::load_pointer(self.store.as_ref(), &self.cfg).await {
763            Ok(Some(p)) => p.pointer.seq,
764            // No pointer, or we cannot read one. Either way we have
765            // learned nothing, so we must not overwrite what we know.
766            Ok(None) | Err(_) => return e,
767        };
768        if now <= read_seq {
769            return e;
770        }
771        LeanError::State(format!(
772            "this checkout read manifest seq {read_seq} and the workspace is now at \
773             seq {now}: its publisher published while the checkout was running, so \
774             the citation it was still fetching is one generation stale. Re-run \
775             checkout. (The underlying refusal, which names a stranger and in this \
776             case means nothing of the kind: {e})"
777        ))
778    }
779
780    pub async fn checkout_scoped(
781        &mut self,
782        scope: Option<Vec<String>>,
783    ) -> LeanResult<CheckoutReport> {
784        // Validated BEFORE the live-tree row, because a malformed scope
785        // is a configuration error whether or not this tree is already
786        // up, and the one thing it must never do is quietly widen.
787        // `Scope::new` silently drops entries that are too long, past
788        // MAX_SCOPE_ENTRIES, or contain `.`/`..`; three malformed paths
789        // normalize to an EMPTY scope, and an empty scope reads
790        // everywhere as "no restriction" = the whole manifest. Same rule
791        // and same shape as `sync_scoped`.
792        let scope = match scope {
793            None => None,
794            Some(raw) => {
795                let s = super::sync::Scope::new(&raw);
796                if s.is_empty() {
797                    return Err(LeanError::State(format!(
798                        "checkout scope named {} entr{} and NONE survived validation — \
799                         refusing, because an empty scope widens to the WHOLE MANIFEST",
800                        raw.len(),
801                        if raw.len() == 1 { "y" } else { "ies" }
802                    )));
803                }
804                Some(s)
805            }
806        };
807        let requested: Option<Vec<String>> = scope.as_ref().map(|s| s.entries().to_vec());
808
809        let mut report = CheckoutReport::default();
810        report.scope = requested.clone();
811        if self.state.marker_present() {
812            // The live-tree row: never re-materialize. But a live tree
813            // holds what it holds, and `checkout` cannot change that —
814            // so a caller whose scope disagrees with the one on disk
815            // gets an error, never a success naming a set it did not
816            // get. The dangerous direction is the quiet one: a caller
817            // that asks for the whole tree and resumes a 3-file
818            // workspace believes it is holding 2001 paths.
819            let held = self.state.load_scope()?;
820            if held != requested {
821                return Err(LeanError::State(format!(
822                    "this workspace is already checked out {}, and the request asks for \
823                     {} — checkout cannot change the admitted set of a live tree, and \
824                     resuming it would return success for a set you did not get",
825                    describe_scope(held.as_deref()),
826                    describe_scope(requested.as_deref()),
827                )));
828            }
829            report.resumed_live_tree = true;
830            return Ok(report);
831        }
832
833        let mut baseline = self.state.take_baseline()?;
834        let mut present: BTreeSet<String> = BTreeSet::new();
835        let mut attempt = 0u32;
836        let (m, metag, results) = loop {
837            attempt += 1;
838            let t_start = std::time::Instant::now();
839            let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
840            report.manifest_secs = t_start.elapsed().as_secs_f64();
841            let (m, metag) = match loaded {
842                Some(l) => (l.manifest, Some(l.etag)),
843                None => (Default::default(), None),
844            };
845
846            // ADMISSION FIRST, budgets second. The order is load-bearing:
847            // a budget is a promise about what THIS checkout will write, and
848            // a 3-file scoped checkout summed over the whole 2001-file
849            // manifest is refused for bytes it was never going to fetch.
850            let admission: Vec<(&String, &super::manifest::LeanEntry)> = match &scope {
851                None => m.entries.iter().collect(),
852                Some(s) => m.entries.iter().filter(|(p, _)| s.covers(p)).collect(),
853            };
854            report.out_of_scope = m.entries.len() - admission.len();
855
856            // Budgets: refuse before materializing anything.
857            let total_bytes: u64 = admission.iter().map(|(_, e)| e.size).sum();
858            if self.cfg.max_bytes > 0 && total_bytes > self.cfg.max_bytes {
859                return Err(LeanError::Budget(format!(
860                    "checkout is {} bytes; budget {}",
861                    total_bytes, self.cfg.max_bytes
862                )));
863            }
864            if self.cfg.max_files > 0 && admission.len() as u64 > self.cfg.max_files {
865                return Err(LeanError::Budget(format!(
866                    "checkout is {} files; budget {}",
867                    admission.len(),
868                    self.cfg.max_files
869                )));
870            }
871
872            let t_fetch = std::time::Instant::now();
873            // A mirror's publisher is the only party entitled to write it,
874            // so an object off its citation was moved by a stranger.
875            // Adopting it would copy bytes no manifest cites into this
876            // tree, silently — drill C4.
877            let results = self.materialize(admission, m.sole_writer).await;
878            report.fetch_secs += t_fetch.elapsed().as_secs_f64();
879            // A citation that could not be served has a second explanation
880            // under handles (design 2026-09-19): the workspace's own
881            // publisher moved the pointer past the document this pass read
882            // and its collector took the handle. Then the pass is simply
883            // re-run against the new document — bounded, and cheap: every
884            // file already on disk at the cited bytes is adopted, not
885            // fetched again — instead of telling the caller to. A pointer
886            // that has not moved leaves the refusal exactly as written.
887            let moved = results.iter().any(|r| r.is_err()) && self.pointer_moved_past(m.seq).await;
888            if moved && attempt < 3 {
889                report.reresolved += 1;
890                continue;
891            }
892            break (m, metag, results);
893        };
894        let t_commit = std::time::Instant::now();
895        for r in results {
896            let f = match r {
897                Ok(f) => f,
898                Err(e) => return Err(self.say_who_moved_it(m.seq, e).await),
899            };
900            if let Some(why) = f.refused {
901                self.state.append_conflict(&super::state::ConflictRecord {
902                    path: f.path.clone(),
903                    foreign_etag: String::new(),
904                    preserved_key: None,
905                    kind: format!("checkout-refused: {why}"),
906                    at_unix: super::now_unix(),
907                })?;
908                report.refused += 1;
909                continue;
910            }
911            if f.ranged {
912                report.ranged += 1;
913            }
914            if f.skipped {
915                report.skipped_present += 1;
916            } else {
917                report.materialized += 1;
918                report.bytes += f.bytes;
919            }
920            present.insert(f.path.clone());
921            if let Some(be) = f.be {
922                baseline.entries.insert(f.path, be);
923            }
924        }
925
926        baseline.seq = m.seq;
927        baseline.manifest_etag = metag;
928        // Checked out whole or scoped, the tree owes nothing: the baseline
929        // (the merge base since P1-lite) holds what was materialised, and the
930        // consume never owes a path the scope declined. So this document is
931        // derived, and nothing is skipped.
932        baseline.derived_etag = Some(baseline.manifest_etag.clone().unwrap_or_default());
933        baseline.skipped.clear();
934        baseline.prev_scan.sync_to(&present);
935        // Every materialised file reaches stable storage BEFORE the
936        // baseline and the marker that vouch for it: after a power loss
937        // the tree is then at least as durable as its description, so
938        // the next scan cannot read a zero-length survivor as a local
939        // edit and publish it over the good version.
940        self.state.sync_tree()?;
941        // THE GATE (latency plan §17; LeanScanRacy's `Open`). Every file
942        // above was stat'ed in the tick it was written, so it is racy, and
943        // the first scan would hash the whole tree. But nothing writes the
944        // tree before the marker below exists (the CSI node plugin binds it
945        // into the pod only then), so each flag is measured now: a file
946        // written a window before the gate cannot hide a write, which can
947        // only come after it. What this checkout wrote in its last window
948        // stays racy, and the first scan hashes only that. Measured before
949        // the marker, so earlier than the gate: the safe direction. After
950        // the sync above, so its time counts toward the window for free.
951        super::barrier::consume_window("before-gate", "");
952        let mut gate = super::scan::now_nanos();
953        // When what it wrote in its last window is many files, the gate
954        // opens the rest of their window later instead: at most a window on
955        // the agent's start, against hashing every one of them in the first
956        // scan. Nothing writes before the gate, so it may open at any tick.
957        if self.cfg.racy_at_gate && self.cfg.racy_wait_min > 0 {
958            let (n, newest) = baseline
959                .entries
960                .values()
961                .filter(|e| e.racy && super::scan::racy_entry(e, gate))
962                // From the later stamp, as `racy_entry` measures: a
963                // materialised file's ctime is its chmod and rename, after
964                // its mtime.
965                .map(|e| {
966                    let (sec, nanos) =
967                        super::scan::later(e.mtime_unix, e.mtime_nanos.unwrap_or(999_999_999), e.change.as_ref());
968                    sec as i128 * 1_000_000_000 + nanos as i128
969                })
970                .fold((0usize, i128::MIN), |(n, m), t| (n + 1, m.max(t)));
971            if n >= self.cfg.racy_wait_min {
972                // A clock that runs backwards, or an mtime in the future,
973                // leaves those entries racy: the safe direction.
974                let rest = (newest + super::scan::RACY_WINDOW_NANOS - gate).clamp(0, super::scan::RACY_WINDOW_NANOS);
975                tokio::time::sleep(std::time::Duration::from_nanos(rest as u64 + 1_000_000)).await;
976                gate = super::scan::now_nanos();
977            }
978        }
979        let settled: Vec<String> = baseline
980            .entries
981            .iter()
982            .filter(|_| self.cfg.racy_at_gate)
983            .filter(|(_, e)| e.racy && !super::scan::racy_entry(e, gate))
984            .map(|(p, _)| p.clone())
985            .collect();
986        for p in settled {
987            if let Some(e) = baseline.entries.get_mut(&p) {
988                e.racy = false;
989            }
990        }
991        self.state.commit_baseline(baseline)?;
992        // Before the marker, always — including the `None` case, which
993        // REMOVES any scope a crashed predecessor left behind. The
994        // marker is the agent-start gate; a scope that landed after it
995        // would leave a window in which an agent is cleared to run
996        // against a tree whose admitted set is not yet durable, and a
997        // crash there yields a workspace holding three files that
998        // claims, by the absence of any scope, to hold all of them.
999        self.state.save_scope(requested.as_deref())?;
1000        // D11: the capability marker and the gauges exist BEFORE the
1001        // agent-start gate opens, so the first thing the agent does can
1002        // be to read them. `run` writes capabilities around checkout
1003        // too; doing it here as well covers the standalone `checkout`
1004        // subcommand, which otherwise leaves an agent with no marker to
1005        // read and therefore no way to know the verbs exist.
1006        let posture = self.sentinel_preflight()?;
1007        self.write_capabilities(&posture)?;
1008        self.write_gauges(None)?;
1009        // The marker is written LAST: the agent-start gate.
1010        self.state.write_marker()?;
1011        report.commit_secs = t_commit.elapsed().as_secs_f64();
1012        Ok(report)
1013    }
1014}
1015
1016// ── the narrow / widen verb (scoped-read design §4) ──────────────────
1017
1018#[derive(Debug, Default)]
1019pub struct RescopeReport {
1020    /// The scope this workspace now holds. `None` = the whole tree.
1021    pub target: Option<Vec<String>>,
1022    /// Citations removed from the held set — the NARROW half.
1023    pub uncited: usize,
1024    /// Files removed from the tree. Normally equal to `uncited`; lower
1025    /// when a path was already gone.
1026    pub unlinked: usize,
1027    /// Newly admitted citations fetched — the WIDEN half.
1028    pub materialized: usize,
1029    pub bytes: u64,
1030    /// Admitted and already held: the scope grew over paths the
1031    /// workspace had. Zero work, and the number that tells a widen that
1032    /// did nothing from a widen that had nothing to do.
1033    pub already_held: usize,
1034    /// Paths whose unpublished local changes a rescope kept, each with a
1035    /// conflict record: on a narrow, paths that should have left the held
1036    /// set (KEPT, cited — a narrow may unwatch a file, never discard an
1037    /// edit); on a widen, admitted paths where the agent had made a file
1038    /// the tree does not cite yet (kept, uncited, published by the next
1039    /// barrier).
1040    pub kept_dirty: Vec<String>,
1041    /// This call finished a rescope a crash had left half-applied.
1042    pub replayed: bool,
1043}
1044
1045impl Syncer {
1046    /// Move the workspace to `target`: stop holding what it no longer
1047    /// admits, and fetch what it newly does.
1048    ///
1049    /// ## Why this cannot be `rm` plus a scope edit
1050    ///
1051    /// `classify` reads exactly the two states a narrow passes through:
1052    /// present-in-scan-absent-from-baseline is an **upload**, and
1053    /// present-in-baseline-absent-from-scan-and-`prev_scan` is a
1054    /// **delete**. So unlink-then-uncite crashes into publishing
1055    /// deletions, and uncite-then-unlink crashes into re-uploading
1056    /// identical bytes and re-citing everything just dropped. Neither
1057    /// order is safe alone. The invariant, stated for the model:
1058    /// **a narrow is an unwatch, never an absence.**
1059    ///
1060    /// What makes it safe is the INTENT, not the order: the target is
1061    /// durable before the first mutation and cleared after the last,
1062    /// `run_barrier` replays any intent it finds before it does
1063    /// anything else, and the replay is idempotent. The order within
1064    /// still matters for the window where the intent itself is lost —
1065    /// uncite first, so the failure mode is a file that gets
1066    /// re-uploaded rather than one that gets DELETED from the bucket.
1067    ///
1068    /// ## Why the verb takes a SET
1069    ///
1070    /// `manifest::load` fetches the pointer and every chunk regardless
1071    /// of scope, so the floor is one whole-manifest load per call.
1072    /// A per-path verb would pay that floor per path.
1073    pub async fn rescope(&mut self, target: Option<Vec<String>>) -> LeanResult<RescopeReport> {
1074        if let Some(raw) = &target {
1075            // An all-rejected scope is REFUSED, never widened to the
1076            // whole tree — `Scope::new` silently drops malformed
1077            // entries, and `sync.rs` already shipped the bug where that
1078            // turned a typo into maximum privilege.
1079            if super::sync::Scope::new(raw).is_empty() {
1080                return Err(LeanError::State(format!(
1081                    "every entry of the requested scope was rejected ({raw:?}); refusing to \
1082                     read that as the whole tree"
1083                )));
1084            }
1085        }
1086        if !self.state.marker_present() {
1087            return Err(LeanError::State(
1088                "rescope needs a checked-out workspace — run checkout first".into(),
1089            ));
1090        }
1091
1092        // The strict check belongs at the DOOR, before anything is
1093        // written: a caller asking to drop a path it has unpublished
1094        // edits to gets told, and the old scope stands untouched. The
1095        // replay below cannot be this strict — it must converge — so it
1096        // keeps such paths instead. Two postures, deliberately.
1097        let leaving = self.paths_leaving(&target)?;
1098        let dirty = self.dirty_among(&leaving)?;
1099        if !dirty.is_empty() {
1100            return Err(LeanError::State(format!(
1101                "these paths would leave the scope but have unpublished local changes, or the \
1102                 syncer may not read them: {:?} — publish or discard them, or make them readable, \
1103                 first",
1104                dirty
1105            )));
1106        }
1107
1108        let intent = super::state::ScopeIntent { target, drop: leaving, held: Default::default() };
1109        self.state.save_scope_intent(&intent)?;
1110        // The model's RescopeBegin: the intent durable, nothing applied.
1111        self.trace("rescope", serde_json::json!({"stage": "begin", "target": intent.target, "drop": intent.drop}));
1112        self.apply_scope_intent(&intent).await
1113    }
1114
1115    /// Finish a rescope a crash left half-applied. `None` when there is
1116    /// none in flight. Idempotent: running it twice is running it once.
1117    pub async fn replay_scope_intent(&mut self) -> LeanResult<Option<RescopeReport>> {
1118        let Some(intent) = self.state.load_scope_intent()? else { return Ok(None) };
1119        let mut report = self.apply_scope_intent(&intent).await?;
1120        report.replayed = true;
1121        Ok(Some(report))
1122    }
1123
1124    /// Which held paths the target scope would stop admitting.
1125    fn paths_leaving(&self, target: &Option<Vec<String>>) -> LeanResult<Vec<String>> {
1126        let baseline = self.state.load_baseline()?;
1127        let scope = target.as_ref().map(|r| super::sync::Scope::new(r));
1128        Ok(baseline
1129            .entries
1130            .keys()
1131            .filter(|p| scope.as_ref().map(|s| !s.covers(p)).unwrap_or(false))
1132            .cloned()
1133            .collect())
1134    }
1135
1136    /// Which of `paths` carry local changes the bucket has not seen.
1137    /// The same three states `sync` calls dirty, for the same reason.
1138    fn dirty_among(&self, paths: &[String]) -> LeanResult<Vec<String>> {
1139        if paths.is_empty() {
1140            return Ok(vec![]);
1141        }
1142        let baseline = self.state.load_baseline()?;
1143        let scanned = super::scan::scan_with(&self.cfg.root, self.cfg.scan_threads)?;
1144        let mut c = super::scan::classify(&scanned, &baseline);
1145        super::scan::confirm_racy(&self.cfg.root, &scanned, &baseline, &mut c);
1146        Ok(paths
1147            .iter()
1148            .filter(|p| {
1149                c.uploads.contains(*p)
1150                    || c.deletes.contains(*p)
1151                    || c.first_absence.contains(*p)
1152                    // Unseen: kept, as a dirty path is.
1153                    || scanned.holds(p)
1154            })
1155            .cloned()
1156            .collect())
1157    }
1158
1159    async fn apply_scope_intent(
1160        &mut self,
1161        intent: &super::state::ScopeIntent,
1162    ) -> LeanResult<RescopeReport> {
1163        let target = intent.target.clone();
1164        let mut report = RescopeReport { target: target.clone(), ..Default::default() };
1165        let scope = target.as_ref().map(|r| super::sync::Scope::new(r));
1166        let covered = |p: &str| scope.as_ref().map(|s| s.covers(p)).unwrap_or(true);
1167
1168        // §4.4's floor: the whole manifest, scope or no scope.
1169        let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
1170        let (m, metag) = match loaded {
1171            Some(l) => (l.manifest, Some(l.etag)),
1172            None => (Default::default(), None),
1173        };
1174
1175        let mut baseline = self.state.load_baseline()?;
1176        // THE RECORDED set, never re-derived — see `ScopeIntent::drop`.
1177        // Re-deriving from the baseline loses the whole set the moment
1178        // the uncite lands; re-deriving from the tree cannot tell a
1179        // leftover from a file the agent created.
1180        let leaving: Vec<String> = intent.drop.clone();
1181        // Converging posture: a dirty path is KEPT rather than dropped,
1182        // so a replay can never be stuck refusing forever.
1183        //
1184        // A path already uncited by a crashed run reads as dirty here
1185        // (present in scan, absent from the baseline ⇒ `uploads`), and
1186        // keeping it would undo the very step that crashed. So dirt is
1187        // judged against what the path IS: still cited and modified, or
1188        // not cited at all and therefore already half-dropped.
1189        let still_cited: Vec<String> =
1190            leaving.iter().filter(|p| baseline.entries.contains_key(*p)).cloned().collect();
1191        let dirty = self.dirty_among(&still_cited)?;
1192        for p in &dirty {
1193            self.state.append_conflict(&super::state::ConflictRecord {
1194                path: p.clone(),
1195                foreign_etag: String::new(),
1196                preserved_key: None,
1197                kind: "rescope-kept-locally-dirty".into(),
1198                at_unix: super::now_unix(),
1199            })?;
1200        }
1201        report.kept_dirty = dirty.clone();
1202        let drop_set: Vec<String> =
1203            leaving.into_iter().filter(|p| !dirty.contains(p)).collect();
1204
1205        // What the uncite drops, durable with the intent before it lands. A
1206        // replay keeps what an earlier run recorded: those paths are no
1207        // longer in the baseline to read it from.
1208        let mut recorded = intent.clone();
1209        for p in &drop_set {
1210            if let Some(be) = baseline.entries.get(p) {
1211                recorded.held.insert(p.clone(), be.clone());
1212            }
1213        }
1214        if recorded != *intent {
1215            self.state.save_scope_intent(&recorded)?;
1216        }
1217
1218        // UNCITE FIRST, and durably, before a single file leaves the
1219        // tree. Both halves must happen, and the intent guarantees
1220        // they will; this order decides only which way a lost intent
1221        // fails — toward a file that reads as a local add (re-uploaded,
1222        // recoverable) rather than one that reads as a local delete
1223        // (published as a DELETE, not recoverable).
1224        for p in &drop_set {
1225            baseline.entries.remove(p);
1226            baseline.prev_scan.remove(p);
1227        }
1228        report.uncited = drop_set.len();
1229        self.state.save_baseline(&baseline)?;
1230        // The model's RescopeFirst: the uncite durable, what it kept named.
1231        self.trace("rescope", serde_json::json!({"stage": "uncited", "kept": dirty, "uncited": drop_set}));
1232        let mut unlinked: Vec<&String> = vec![];
1233
1234        for p in &drop_set {
1235            let local = self.cfg.root.join(p);
1236            // The same containment the barrier demands: a citation is
1237            // not a licence to unlink whatever a path resolves to.
1238            if contained_path(&self.cfg.root, p).is_err() {
1239                continue;
1240            }
1241            super::barrier::consume_window("rescope-before-unlink", p);
1242            // Only the bytes the uncite dropped. A file that is no longer
1243            // them is the agent's, written since: kept, uncited, recorded;
1244            // the next barrier publishes it as the agent's add.
1245            if std::fs::symlink_metadata(&local).is_ok()
1246                && super::barrier::local_dirty(&local, recorded.held.get(p))
1247            {
1248                self.state.append_conflict(&super::state::ConflictRecord {
1249                    path: p.clone(),
1250                    foreign_etag: String::new(),
1251                    preserved_key: None,
1252                    kind: "rescope-kept-agent-write".into(),
1253                    at_unix: super::now_unix(),
1254                })?;
1255                report.kept_dirty.push(p.clone());
1256                continue;
1257            }
1258            match std::fs::remove_file(&local) {
1259                Ok(()) => {
1260                    report.unlinked += 1;
1261                    unlinked.push(p);
1262                    // Each unlink as it lands: a crash part-way through the
1263                    // loop leaves some done, and the replay does the rest.
1264                    self.trace("rescope", serde_json::json!({"stage": "unlink", "path": p}));
1265                }
1266                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1267                Err(e) => return Err(LeanError::Io(e)),
1268            }
1269        }
1270
1271        // WIDEN: admitted citations this workspace does not hold.
1272        let mut add: Vec<(&String, &super::manifest::LeanEntry)> =
1273            m.entries.iter().filter(|(p, _)| covered(p) && !baseline.entries.contains_key(*p)).collect();
1274        // A file already at an admitted path that this tree does not cite
1275        // is the agent's (made since the last barrier; the scan has not
1276        // published it yet). Materialize would fetch the document's
1277        // version over it (checkout's resume rule, which is right only
1278        // for its own half-written fetches). The consume calls such a path
1279        // dirty and leaves it; so does the widen: kept, uncited, and named
1280        // in a record. The next barrier publishes it as the agent's add,
1281        // and the merge records the version it replaces (review
1282        // 2026-09-29, R3). Bytes that ARE the document's are adopted as
1283        // before, which is what lets a crashed widen's replay converge.
1284        let mut kept_local: Vec<String> = Vec::new();
1285        add.retain(|(p, e)| {
1286            let local = self.cfg.root.join(p);
1287            let Ok(st) = std::fs::symlink_metadata(&local) else { return true };
1288            let same = st.is_file()
1289                && st.len() == e.size
1290                && local_crc64_b64(&local).map(|got| got == e.crc64_b64).unwrap_or(false);
1291            if !same {
1292                kept_local.push((*p).clone());
1293            }
1294            same
1295        });
1296        for p in &kept_local {
1297            self.state.append_conflict(&super::state::ConflictRecord {
1298                path: p.clone(),
1299                foreign_etag: m.entries[p].etag.clone(),
1300                preserved_key: None,
1301                kind: "rescope-kept-local-unpublished".into(),
1302                at_unix: super::now_unix(),
1303            })?;
1304        }
1305        report.kept_dirty.extend(kept_local);
1306        report.already_held =
1307            m.entries.keys().filter(|p| covered(p) && baseline.entries.contains_key(*p)).count();
1308        let mut taken: Vec<String> = vec![];
1309        if !add.is_empty() {
1310            // Review 2026-09-18, M3: the rescope holds the fence for its
1311            // whole run, and the widen's fetch is its long stretch. Renew
1312            // before it (deposed here, nothing is fetched yet and the
1313            // baseline above is saved: stop) and after it (best effort: the
1314            // rescope writes nothing to the store, so a fence lost during
1315            // the fetch costs a release, not a byte).
1316            super::lease::renew_if_due(self).await?;
1317            let results = self.materialize(add, m.sole_writer).await;
1318            let _ = super::lease::renew_if_due(self).await;
1319            for r in results {
1320                let f = r?;
1321                if let Some(why) = f.refused {
1322                    self.state.append_conflict(&super::state::ConflictRecord {
1323                        path: f.path.clone(),
1324                        foreign_etag: String::new(),
1325                        preserved_key: None,
1326                        kind: format!("rescope-refused: {why}"),
1327                        at_unix: super::now_unix(),
1328                    })?;
1329                    continue;
1330                }
1331                if !f.skipped {
1332                    report.materialized += 1;
1333                    report.bytes += f.bytes;
1334                }
1335                baseline.prev_scan.insert(f.path.clone());
1336                if let Some(be) = f.be {
1337                    taken.push(f.path.clone());
1338                    baseline.entries.insert(f.path, be);
1339                }
1340            }
1341        }
1342
1343        baseline.seq = m.seq;
1344        baseline.manifest_etag = metag;
1345        // What the held set is owed changed with it: the next consume
1346        // derives in full rather than trusting the cheap path's record
1347        // (a widened path it kept or could not take is owed from here).
1348        baseline.derived_etag = None;
1349        baseline.skipped.clear();
1350
1351        self.state.sync_tree()?;
1352        self.state.commit_baseline(baseline)?;
1353        self.state.save_scope(target.as_deref())?;
1354        // LAST. While this document exists the workspace is mid-rescope
1355        // and `run_barrier` will replay before doing anything else.
1356        self.state.clear_scope_intent()?;
1357        // The model's RescopeSecond: the unlink and the widen, the new scope.
1358        self.trace("rescope", serde_json::json!({"stage": "applied", "unlinked": unlinked, "taken": taken,
1359            "target": target}));
1360        Ok(report)
1361    }
1362}