flint_lean/sync.rs
1//! The sync verb (v1, HITL — decided §9 Q4), plus the scoped form the
2//! `.flint/sync` sentinel invokes (boundary-verbs plan §2.2, D4).
3//! Harness- or sentinel-invoked, never background.
4//!
5//! Sync BEGINS with a full scan: "locally dirty" means dirty per THAT
6//! scan against the baseline — never per the last barrier's snapshot
7//! (otherwise sync honors a remote delete over the agent's un-scanned
8//! latest work: the review's steady-state destruction finding). Policy:
9//! locally-dirty wins; remote deletions apply only to locally-clean
10//! paths; adds/changes fetch through the store; every skipped apply is
11//! a surfaced conflict, never silent.
12//!
13//! **The scope rule (D4) is a correctness rule, not an optimization.**
14//! A scoped sync moves the baseline — which is also the merge base since
15//! P1-lite — only for the paths it applied or verified in scope, and
16//! leaves `baseline.seq` / `baseline.manifest_etag` UNTOUCHED. What it
17//! leaves out stays OWED: the next barrier's consume derives it and takes
18//! it, untouched from today. And a path the WORKSPACE'S scope declined
19//! (a scoped checkout) is never fetched here either: the baseline does
20//! not hold it, and only the held set and the scope's cover are owed.
21
22use std::collections::{BTreeMap, BTreeSet};
23
24use serde::Serialize;
25
26use flint_store::{crc64_nvme, crc64_to_b64, GenerationStamps, PosixStamps, StoreError};
27
28use super::barrier::{mtime_nanos_of, mtime_of};
29use super::state::{BaselineEntry, ConflictRecord};
30use super::{manifest, now_unix, scan, LeanError, LeanResult, Syncer};
31
32#[derive(Debug, Default, Serialize)]
33pub struct SyncReport {
34 pub applied: Vec<String>,
35 pub deleted: Vec<String>,
36 pub conflicts: Vec<String>,
37 pub seq: u64,
38 /// Remote changes seen but left owed because they fell outside the
39 /// requested scope (D4): the next barrier's consume takes those the
40 /// tree holds. Zero for a whole-tree
41 /// sync.
42 #[serde(default)]
43 pub out_of_scope_foreign: usize,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub scope: Option<Vec<String>>,
46}
47
48/// A normalized scope: path prefixes and exact paths, matched on
49/// COMPONENT boundaries (`"in"` never matches `internal/`).
50#[derive(Debug, Clone)]
51pub struct Scope {
52 entries: Vec<String>,
53}
54
55impl Scope {
56 pub fn new(raw: &[String]) -> Scope {
57 let mut entries = vec![];
58 for e in raw.iter().take(super::sentinel::MAX_SCOPE_ENTRIES) {
59 if e.len() > super::sentinel::MAX_SCOPE_ENTRY_LEN {
60 continue;
61 }
62 let norm = e.trim_matches('/').replace('\\', "/");
63 if norm.is_empty() || norm.split('/').any(|c| c == ".." || c == ".") {
64 continue;
65 }
66 entries.push(norm);
67 }
68 Scope { entries }
69 }
70
71 pub fn is_empty(&self) -> bool {
72 self.entries.is_empty()
73 }
74
75 pub fn entries(&self) -> &[String] {
76 &self.entries
77 }
78
79 /// Component-boundary match: an entry covers itself exactly and
80 /// everything beneath it, never a sibling sharing a name prefix.
81 pub fn covers(&self, path: &str) -> bool {
82 self.entries.iter().any(|e| {
83 path == e.as_str()
84 || path.strip_prefix(e.as_str()).map(|r| r.starts_with('/')).unwrap_or(false)
85 })
86 }
87}
88
89impl Syncer {
90 /// Whole-tree sync: advances `seq` and the cheap path's record once it
91 /// has taken everything owed; `manifest_etag` stays where the last
92 /// barrier left it (`reader.rs` relies on that).
93 pub async fn sync(&mut self) -> LeanResult<SyncReport> {
94 self.sync_scoped(None).await
95 }
96
97 pub async fn sync_scoped(&mut self, scope: Option<Vec<String>>) -> LeanResult<SyncReport> {
98 // An all-rejected scope is REFUSED, never widened. `Scope::new`
99 // silently drops entries that are too long, past
100 // MAX_SCOPE_ENTRIES, or contain `.`/`..` — so a caller naming
101 // three malformed paths used to get `None`, and `in_scope`'s
102 // `.unwrap_or(true)` turned that into the WHOLE TREE. A
103 // whole-tree sync re-derives against the remote manifest and
104 // DELETES local files for remotely-deleted paths, so the failure
105 // mode of a typo was maximum privilege. An error must not
106 // return a legal value.
107 let scope = match scope {
108 None => None,
109 Some(raw) => {
110 let s = Scope::new(&raw);
111 if s.is_empty() {
112 return Err(LeanError::State(format!(
113 "sync scope named {} entr{} and NONE survived validation — \
114 refusing, because an empty scope widens to the WHOLE TREE",
115 raw.len(),
116 if raw.len() == 1 { "y" } else { "ies" }
117 )));
118 }
119 Some(s)
120 }
121 };
122 let in_scope = |path: &str| scope.as_ref().map(|s| s.covers(path)).unwrap_or(true);
123 let mut report = SyncReport::default();
124 report.scope = scope.as_ref().map(|s| s.entries().to_vec());
125 let mut baseline = self.state.load_baseline()?;
126 // What the baseline cited before, for the trace (only when it is on):
127 // the paths this sync took are the ones whose citation it moved.
128 let before: Option<BTreeMap<String, String>> = self
129 .cfg
130 .event_trace
131 .is_some()
132 .then(|| baseline.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect());
133
134 // 1. The scan comes FIRST; dirt is judged against it alone.
135 let scanned = scan::scan(&self.cfg.root)?;
136 let classified = scan::classify(&scanned, &baseline);
137 let locally_dirty = |path: &str| {
138 classified.uploads.contains(path)
139 || classified.deletes.contains(path)
140 || classified.first_absence.contains(path)
141 };
142
143 // 2. Remote truth: the committed document (since P2 the gateway
144 // commits each UI verb, so there is no inbox overlay).
145 let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
146 // The agent's paths this sync leaves untaken (dirty), for the
147 // consume's cheap path: owed again if the agent backs out.
148 let mut dirty_skips: BTreeSet<String> = BTreeSet::new();
149 let (theirs, metag) = match loaded {
150 Some(l) => (l.manifest, Some(l.etag)),
151 // No document at all. For a tree that holds nothing that is a
152 // new workspace, and there is nothing to do. For one that
153 // holds paths it is a wiped or re-pointed prefix, and reading
154 // it as an EMPTY document would delete every clean file here —
155 // perhaps the last copy. The consume takes nothing in this
156 // case; the sync refuses (review 2026-09-29, S1).
157 None if baseline.entries.is_empty() => (Default::default(), None),
158 None => {
159 return Err(LeanError::State(format!(
160 "sync: the workspace has no document (no pointer, no manifest) but this tree's \
161 baseline holds {} paths — refusing to read a missing document as an empty \
162 one, which would delete every clean file here",
163 baseline.entries.len()
164 )))
165 }
166 };
167 // Something owed could not be taken now (a fetch that lost to a
168 // newer document, a failed write): the next consume derives again
169 // even if the pointer does not move — exactly the consume's `left`
170 // (review 2026-09-29, S2/S3).
171 let mut left = false;
172 // The paths `left` names, for the trace.
173 let mut failed: BTreeSet<String> = BTreeSet::new();
174 // Path -> (etag, HANDLE): the entry's own key, which is what the
175 // fetch below reads (design 2026-09-19: a handle is fetched by
176 // name; the bare path is nobody's to read).
177 let remote: BTreeMap<String, (String, String)> =
178 theirs.entries.iter().map(|(p, e)| (p.clone(), (e.etag.clone(), e.key.clone()))).collect();
179 // The writer's CRC for each remote etag: the manifest's.
180 let remote_crc: BTreeMap<String, Option<String>> = theirs
181 .entries
182 .iter()
183 .map(|(p, e)| (p.clone(), Some(e.crc64_b64.clone())))
184 .collect();
185
186 // The workspace's own scope (a scoped checkout): a path it neither
187 // holds nor covers is not this tree's to fetch.
188 let held_scope = self.state.load_scope()?.map(|v| Scope::new(&v));
189 let held = |b: &super::state::Baseline, p: &str| {
190 b.entries.contains_key(p) || held_scope.as_ref().is_none_or(|s| s.covers(p))
191 };
192
193 // 3. Remote deletions: in the baseline, gone from the manifest —
194 // apply only on locally-clean paths.
195 //
196 // BEFORE the adds, and that order is a correctness rule, not
197 // a tidiness one. A remote generation that turned the file
198 // `build` into the directory `build/log.txt` sends both a
199 // deletion and an add; with the adds first, the add is
200 // refused for containment (`sync-refused-containment`) while
201 // the file is still there, and the deletion then takes the
202 // file away — one sync leaves the workspace without either,
203 // and sync is harness-invoked, never background, so "the next
204 // sync fixes it" may be never. The two passes act on disjoint
205 // path sets (this one takes what is NOT in `remote`, that one
206 // what IS), so nothing else moves with the order.
207 let base_paths: Vec<String> = baseline.entries.keys().cloned().collect();
208 for path in base_paths {
209 if remote.contains_key(&path) {
210 continue;
211 }
212 if !in_scope(&path) {
213 report.out_of_scope_foreign += 1;
214 continue;
215 }
216 let local = self.cfg.root.join(&path);
217 if !local.exists() {
218 baseline.entries.remove(&path);
219 continue;
220 }
221 super::barrier::consume_window("before-delete", &path);
222 // H7: judged by the step-1 scan AND by a fresh stat now — a
223 // write since the scan is the agent's, and it stays.
224 if locally_dirty(&path) || super::barrier::local_dirty(&local, baseline.entries.get(&path)) {
225 self.state.append_conflict(&ConflictRecord {
226 path: path.clone(),
227 foreign_etag: String::new(),
228 preserved_key: None,
229 kind: "sync-remote-delete-vs-dirty".into(),
230 at_unix: now_unix(),
231 })?;
232 report.conflicts.push(path.clone());
233 dirty_skips.insert(path.clone());
234 continue;
235 }
236 std::fs::remove_file(&local)?;
237 baseline.entries.remove(&path);
238 report.deleted.push(path.clone());
239 }
240
241 // 4. Apply adds/changes (remote differs from the baseline).
242 for (path, (etag, key)) in &remote {
243 if baseline.entries.get(path).map(|b| &b.etag == etag).unwrap_or(false) {
244 continue;
245 }
246 if !held(&baseline, path) {
247 continue;
248 }
249 if !in_scope(path) {
250 // D4: NOT integrated — it stays owed, and the next
251 // barrier's consume takes it.
252 report.out_of_scope_foreign += 1;
253 continue;
254 }
255 if locally_dirty(path) {
256 // The phantom-conflict rule (§2.2): `sync` saves the
257 // baseline only at the end, so a crash mid-apply
258 // followed by a re-honor makes already-applied paths
259 // scan dirty against the stale baseline. Declaring a
260 // conflict there would report a conflict for a path
261 // whose local bytes ARE the remote bytes, and the path
262 // would then re-publish as a spurious generation bump.
263 // Compare content identity first.
264 // The remote's crc, read off the object we would apply
265 // (the backend's attestation) before the manifest's.
266 // One HEAD, only on the dirty-vs-remote path.
267 let remote_meta = match self.store.head(key).await {
268 Ok(m) if m.etag == *etag => Some(m),
269 _ => None,
270 };
271 // The remote's CRC: the backend's attestation when it
272 // offers one, else the writer's for this etag — client-
273 // computed, so it exists on a backend that attests none
274 // (Ozone). Without either the bytes cannot be judged
275 // identical, and the path is a conflict as before.
276 let want: Option<String> = remote_meta.as_ref().and_then(|m| {
277 m.crc64_b64.clone().or_else(|| remote_crc.get(path).cloned().flatten())
278 });
279 let local_path = self.cfg.root.join(path);
280 let local_crc =
281 std::fs::read(&local_path).ok().map(|b| crc64_to_b64(crc64_nvme(&b)));
282 let identical = want.is_some() && want == local_crc;
283 if identical {
284 let st = std::fs::metadata(&local_path)?;
285 let stamps =
286 remote_meta.as_ref().and_then(|m| GenerationStamps::from_meta(&m.meta));
287 baseline.entries.insert(
288 path.clone(),
289 BaselineEntry {
290 etag: etag.clone(),
291 key: Some(key.clone()),
292 generation: stamps
293 .map(|s| s.generation)
294 .or_else(|| theirs.entries.get(path).map(|e| e.generation))
295 .unwrap_or(0),
296 size: st.len(),
297 mtime_unix: mtime_of(&st),
298 mtime_nanos: Some(mtime_nanos_of(&st)),
299 crc64_b64: local_crc,
300 },
301 );
302 report.applied.push(path.clone());
303 continue;
304 }
305 self.state.append_conflict(&ConflictRecord {
306 path: path.clone(),
307 foreign_etag: etag.clone(),
308 preserved_key: None, // remote version stays in the bucket
309 kind: "sync-dirty".into(),
310 at_unix: now_unix(),
311 })?;
312 report.conflicts.push(path.clone());
313 dirty_skips.insert(path.clone());
314 continue;
315 }
316 let fetched = self.store.get_whole(key, Some(etag)).await;
317 let (meta, body) = match fetched {
318 Ok(ok) => ok,
319 Err(StoreError::PreconditionFailed(_)) | Err(StoreError::NotFound(_)) => {
320 // Superseded mid-sync, or not readable yet: owed still.
321 left = true;
322 failed.insert(path.clone());
323 continue;
324 }
325 Err(e) => return Err(e.into()),
326 };
327 let mode = PosixStamps::from_meta(&meta.meta).map(|p| p.mode);
328 // VERIFIED before it is written, exactly as checkout's fresh
329 // fetch is: against the writer's CRC for this etag (the
330 // manifest's), else the backend's attestation when it offers
331 // one. What the baseline records is OURS, over the bytes
332 // written.
333 let got = crc64_to_b64(crc64_nvme(&body));
334 let want =
335 remote_crc.get(path).cloned().flatten().or_else(|| meta.crc64_b64.clone());
336 if let Some(want) = want {
337 if want != got {
338 return Err(LeanError::State(format!(
339 "sync: {path} (etag {etag}) is cited with CRC-64 {want}, but the bytes \
340 fetched under that etag hash to {got} — the object is corrupt or the \
341 store returned the wrong bytes; refusing to apply it (nothing written)"
342 )));
343 }
344 }
345 // A path this tree can never safely materialise is refused and
346 // NOT left owed (as the consume does); a write that fails for
347 // any other reason below is transient and stays owed.
348 if let Err(e) = super::barrier::check_contained(&self.cfg.root, path) {
349 self.state.append_conflict(&ConflictRecord {
350 path: path.clone(),
351 foreign_etag: etag.clone(),
352 preserved_key: None,
353 kind: format!("sync-refused-containment: {e}"),
354 at_unix: now_unix(),
355 })?;
356 report.conflicts.push(path.clone());
357 continue;
358 }
359 super::barrier::consume_window("before-write", path);
360 // Review 2026-09-18, H7: dirt was judged by the scan at step 1,
361 // and a whole-tree sync fetches for minutes after it; a path
362 // the agent writes since then is modified, and its version
363 // wins. So the licence is checked once more against a fresh
364 // stat, with the temp written, immediately before the rename.
365 let local = self.cfg.root.join(path);
366 let base = baseline.entries.get(path).cloned();
367 let still_clean = || !super::barrier::local_dirty(&local, base.as_ref());
368 let written = super::barrier::contained_path(&self.cfg.root, path)
369 .and_then(|target| super::barrier::write_file_atomic_if(&target, &body, mode, &still_clean));
370 let st = match written {
371 Ok(Some(st)) => st,
372 Ok(None) => {
373 self.state.append_conflict(&ConflictRecord {
374 path: path.clone(),
375 foreign_etag: etag.clone(),
376 preserved_key: None, // remote version stays in the bucket
377 kind: "sync-dirty".into(),
378 at_unix: now_unix(),
379 })?;
380 report.conflicts.push(path.clone());
381 dirty_skips.insert(path.clone());
382 continue;
383 }
384 Err(e) => {
385 // TRANSIENT (a full disk, a directory it cannot write
386 // to): surfaced, never a wedge, and still owed.
387 self.state.append_conflict(&ConflictRecord {
388 path: path.clone(),
389 foreign_etag: etag.clone(),
390 preserved_key: None,
391 kind: format!("sync-write-failed (will retry): {e}"),
392 at_unix: now_unix(),
393 })?;
394 report.conflicts.push(path.clone());
395 left = true;
396 failed.insert(path.clone());
397 continue;
398 }
399 };
400 // The baseline records the inode this sync wrote (its fstat),
401 // never a fresh stat of the path an agent may have written
402 // since (H7).
403 super::barrier::consume_window("after-rename", path);
404 let stamps = GenerationStamps::from_meta(&meta.meta);
405 baseline.entries.insert(
406 path.clone(),
407 BaselineEntry {
408 etag: meta.etag.clone(),
409 key: Some(key.clone()),
410 generation: stamps.map(|s| s.generation).unwrap_or(0),
411 size: st.len(),
412 mtime_unix: mtime_of(&st),
413 mtime_nanos: Some(mtime_nanos_of(&st)),
414 crc64_b64: Some(got),
415 },
416 );
417 report.applied.push(path.clone());
418 }
419
420 // 5. What this sync leaves owed. Whole-tree, the sync derived against
421 // this document: what it left is the agent's (dirty) and is owed
422 // the moment the agent backs out — the consume's cheap path record,
423 // as a consume would write it. Scoped, it derived only part: the
424 // next consume derives again (D4 keeps manifest_etag where it was).
425 match &scope {
426 None if !left => {
427 baseline.seq = theirs.seq;
428 baseline.derived_etag = Some(metag.clone().unwrap_or_default());
429 baseline.skipped = dirty_skips;
430 report.seq = theirs.seq;
431 }
432 // Something is still owed: nothing is recorded as derived, and
433 // the tree is not integrated with this document yet.
434 None => {
435 baseline.derived_etag = None;
436 baseline.skipped.clear();
437 report.seq = baseline.seq;
438 }
439 Some(_) => {
440 baseline.derived_etag = None;
441 baseline.skipped.clear();
442 report.seq = baseline.seq;
443 }
444 }
445 let rescan = scan::scan(&self.cfg.root)?;
446 baseline.prev_scan = rescan.keys().cloned().collect();
447 // Materialised files before the baseline that vouches for them.
448 self.state.sync_tree()?;
449 self.state.save_baseline(&baseline)?;
450 let taken: Vec<&String> = before
451 .as_ref()
452 .map(|before| {
453 before
454 .keys()
455 .chain(baseline.entries.keys())
456 .collect::<BTreeSet<_>>()
457 .into_iter()
458 .filter(|p| before.get(*p) != baseline.entries.get(*p).map(|e| &e.etag))
459 .collect()
460 })
461 .unwrap_or_default();
462 self.trace("sync", serde_json::json!({"scoped": scope.is_some(), "applied": report.applied.len(),
463 "deleted": report.deleted.len(), "conflicts": report.conflicts.len(), "seq": report.seq,
464 "taken": taken, "skipped": baseline.skipped, "left": left, "failed": failed,
465 "reader": self.cfg.access.is_read()}));
466 Ok(report)
467 }
468}