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