flint_lean/gated.rs
1//! Gated manifest advance: durability split from visibility
2//! (boundary-verbs plan §2.4 — D6, D7, D8, D13).
3//!
4//! In `hybrid` (the default) uploads and citations coincide, so the
5//! uncited-object set is exactly today's crash-window set. `gated`
6//! splits them:
7//!
8//! - **Upload lane (durability), every floor tick.** Renew, verify
9//! epoch, consume the inbox, scan, stage-diff, guarded uploads — but
10//! the PUTs land **in place, as new object versions**, and no
11//! manifest CAS runs. RPO for bytes stays at the floor.
12//! - **Citation lane (visibility), at coherent points only.** ONE CAS
13//! installing the entire pending set — the versions already exist, so
14//! there is no copy phase — then version-scoped GC of superseded
15//! generations and the baseline rewrite.
16//!
17//! **Why versions and not a staging prefix.** The copy design rested on
18//! one premise: `upload_one` PUTs to `files/<path>`, so a gated eager
19//! upload landing in place would DESTROY the cited object. On a
20//! versioned bucket that premise is false — an in-place PUT destroys
21//! nothing, the cited generation survives as a version, and a citation
22//! becomes one CAS naming versions that already exist: O(1) requests
23//! per boundary instead of O(dirty files), atomic by construction
24//! rather than by a recovery protocol. What it deletes is machinery:
25//! the `eager/` namespace, per-citation `CopyObject` (and
26//! `UploadPartCopy` for >5 GiB files), the citation-intent document and
27//! successor roll-forward, the stage-NotFound arm, and the transient
28//! copy-phase reader window.
29//!
30//! **What it costs, stated rather than hidden.** Uncited generations
31//! are the CURRENT version of the real key, so any reader that does not
32//! resolve through the manifest — an import tool, `aws s3 cp`, a human —
33//! sees mid-logical-change bytes where it previously saw the last
34//! boundary. Flint's own readers qualify as manifest-resolving under
35//! D13, so the guarantee holds where it is promised, but the promise is
36//! scoped to manifest-resolving readers (§3 residual 11).
37//!
38//! **The lane opens NO HITL window.** The window exists to fence HITL
39//! out of the upload→CAS span; a lane with no CAS that opened a
40//! 180 s-deadline window every 60 s would refuse HITL admission
41//! essentially forever between citations — and it protects nothing,
42//! because a lane PUT creates a new version and destroys nothing HITL
43//! wrote. Window open/clear belong exclusively to the citation lane.
44
45use std::collections::{BTreeMap, BTreeSet};
46
47use serde::{Deserialize, Serialize};
48
49use flint_store::{crc64_nvme, crc64_to_b64, GenerationStamps, ListedVersion, StoreError};
50
51use super::manifest::{self, LeanEntry};
52use super::state::{BaselineEntry, ConflictRecord};
53use super::{inbox, now_unix, scan, BoundaryMode, LeanError, LeanResult, Syncer};
54
55const PENDING: &str = "pending.json";
56
57/// One staged-but-uncited generation.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct PendingEntry {
60 pub key: String,
61 pub etag: String,
62 /// The publisher's CRC over the bytes it staged (see
63 /// `LeanEntry::crc64_b64`).
64 pub crc64_b64: String,
65 pub size: u64,
66 pub mode: u32,
67 pub mtime_unix: i64,
68 pub generation: u64,
69 pub epoch: u64,
70 /// The version this staging PUT created.
71 pub version_id: Option<String>,
72 /// The version the BASELINE cited when we staged (D7's
73 /// re-validation guard). If the baseline's version has moved by
74 /// citation time, a HITL consume or sync landed after staging, and
75 /// installing our staged version would UNCITE the foreign bytes.
76 pub base_version_id: Option<String>,
77 pub staged_unix: u64,
78}
79
80/// The gated lane's durable bookkeeping — its own file, so
81/// `clear_intent_keys` cannot take it with the intent journal.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct PendingStage {
84 pub entries: BTreeMap<String, PendingEntry>,
85 /// Delete classifications WITHHELD from the manifest until a
86 /// citation pass (§2.4.2): a rename r→s must not become
87 /// reader-visible as r-gone/s-absent at an undeclared point.
88 #[serde(default)]
89 pub withheld_deletes: BTreeSet<String>,
90 pub last_citation_unix: u64,
91 /// Scan-to-scan stability tracking (§2.4.1's quiescence, as
92 /// corrected: "no scan diff vs baseline" could never fire once
93 /// anything was pending, since pending paths stay
94 /// classified-changed until citation — quiescence would have been
95 /// dead code and every citation would ride the lag cap).
96 pub last_scan_fingerprint: Option<String>,
97 pub stable_since_unix: u64,
98 /// Legacy-cited paths (no `version_id`) whose cited etag matched no
99 /// surviving version, so the backfill below could not make them
100 /// addressable. Remembered so one unresolvable entry does not buy a
101 /// whole version listing at every subsequent citation.
102 #[serde(default)]
103 pub legacy_unresolved: BTreeSet<String>,
104 /// Staged paths the last lane pass did not see on disk. A staged
105 /// path that was never CITED has no baseline entry, so
106 /// `scan::classify` cannot call it a delete — it cannot see it at
107 /// all — and the stage would carry its version into a boundary for
108 /// a file the agent removed. This is that path's own
109 /// two-observations memory, the rename-vs-walk guard applied where
110 /// classify cannot reach.
111 #[serde(default)]
112 pub staged_absent_once: BTreeSet<String>,
113 /// Versions this workspace superseded or cancelled between
114 /// citations, per path — RECORDED by the lane, DELETED only by the
115 /// guarded citation-time reaper.
116 ///
117 /// The lane used to delete these itself, with one guard ("not the
118 /// version I just wrote") and no reference to the installed
119 /// manifest. `citation_pass` clears the stage LAST — after the CAS,
120 /// the reaper, the baseline save and the intent clear — and four
121 /// ordinary `?` returns sit in that window, so one transient store
122 /// error leaves a stage naming versions the boundary now CITES. The
123 /// next lane pass then deleted the cited version. One guarded
124 /// delete site was the fix; this ledger is what keeps the
125 /// intermediate versions NAMEABLE while it is the only one.
126 ///
127 /// Without it, `base_version_id` is pinned to the cited generation
128 /// (it comes from the baseline, which gated mode advances only at
129 /// citation), so a path re-staged N times between citations leaves
130 /// versions 2..N named by nothing at all — falling through to
131 /// `noncurrentRetentionDays`, which D8 is explicit is a crash-window
132 /// backstop and not a GC policy, because it runs its clock against
133 /// live cited data.
134 #[serde(default)]
135 pub pending_reclaims: BTreeMap<String, Vec<String>>,
136}
137
138/// Which coherent point fired.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum CitationSource {
141 Sentinel,
142 Quiescence,
143 ForcedLagCap,
144 ForcedBacklogCap,
145 Cadence,
146 Drain,
147 Recovered,
148 /// A repair-only pass: nothing of the agent's is staged, but the
149 /// manifest cites an older object than the one this syncer has
150 /// already INTEGRATED (a consumed HITL write, checkout's S3-wins
151 /// arm). §2.4.2 exempts these from gating, and D13's promise to
152 /// pinned readers — "within one floor" — is this source.
153 Repair,
154}
155
156impl CitationSource {
157 pub fn as_str(&self) -> &'static str {
158 match self {
159 CitationSource::Sentinel => "sentinel",
160 CitationSource::Quiescence => "quiescence",
161 CitationSource::ForcedLagCap => "forced-lag-cap",
162 CitationSource::ForcedBacklogCap => "forced-backlog-cap",
163 CitationSource::Cadence => "cadence",
164 CitationSource::Drain => "drain",
165 CitationSource::Recovered => "recovered",
166 CitationSource::Repair => "repair",
167 }
168 }
169}
170
171/// Re-verify the lease every N paths inside the reaper. The loop is
172/// O(cited) sequential round trips with no renewal arm behind it, so a
173/// long sweep both starves the lease and outlives the holder's right to
174/// delete. 8 keeps the added epoch reads under ~12% of the LISTs the
175/// sweep already pays for.
176const RECLAIM_FENCE_EVERY: usize = 8;
177
178#[derive(Debug, Default)]
179pub struct LaneReport {
180 pub staged: Vec<String>,
181 pub parked: Vec<String>,
182 pub consumed: usize,
183 pub staged_bytes: u64,
184 /// Superseded uncited versions reclaimed by the lane itself (free,
185 /// version-scoped): uncited work holds at most one version per path
186 /// plus crash remnants.
187 /// Versions the lane RECORDED for the reaper to reclaim at the
188 /// next citation. The lane deletes nothing.
189 pub superseded_recorded: usize,
190 pub withheld_deletes: usize,
191 /// Paths this lane pass saw absent for the FIRST time — withheld to
192 /// the next scan by the rename-vs-walk guard, and so NOT part of
193 /// the boundary a citation from this pass would install.
194 pub first_absence: Vec<String>,
195 /// First-absence paths a DECLARED lane pass confirmed gone by
196 /// direct lstat and staged as ordinary withheld deletes.
197 pub absences_confirmed: usize,
198}
199
200#[derive(Debug, Default)]
201pub struct CitationReport {
202 pub seq: Option<u64>,
203 pub source: Option<String>,
204 pub cited: usize,
205 pub dropped_stale_base: Vec<String>,
206 /// Paths dropped because a HITL write landed between the lane's
207 /// consume and this citation's window. Unlike the stale-base drop,
208 /// the agent's latest bytes are NOT in the installed boundary — so
209 /// an ack for a declared point that names one of these is a lie
210 /// (D1's at-least guarantee).
211 pub dropped_inflight: Vec<String>,
212 /// Paths re-cited onto bytes this syncer had already integrated —
213 /// no data moved (§2.4.2's ungated repair).
214 pub repaired: Vec<String>,
215 /// Inherited entries this boundary made version-addressable before
216 /// stamping `pinned_reads` over them.
217 pub backfilled: Vec<String>,
218 pub deleted: Vec<String>,
219 /// Versions reaped by flint's EXACT per-citation GC — the reaper.
220 /// Lifecycle is only the backstop (D8), and on `files/` it cannot
221 /// tell cited from uncited at all.
222 pub versions_reclaimed: usize,
223 pub no_change: bool,
224}
225
226/// What one `recover-staged` pass found and installed (D9).
227#[derive(Debug, Default)]
228pub struct RecoverReport {
229 /// Whether D9's durable summary drove this pass (the cheap path) or
230 /// it fell back to the prefix-wide version LIST.
231 pub from_summary: bool,
232 /// Paths re-cited onto their surviving current version.
233 pub recited: Vec<String>,
234 /// Paths whose CITED version no longer exists — the D8
235 /// abandoned-mid-stage endgame, where the noncurrent backstop
236 /// reaped live cited data. A subset of `recited` when a newer
237 /// version survives to roll forward onto.
238 pub dangling: Vec<String>,
239 /// Dangling with NOTHING left to cite: the backstop reaped the
240 /// cited version and no newer generation exists. Named loudly
241 /// because no verb can fix it — the bytes are gone.
242 pub unrecoverable: Vec<String>,
243 pub seq: Option<u64>,
244 pub no_change: bool,
245}
246
247/// D9's durable orphan summary — the bucket-side answer to "is there
248/// work here that no manifest cites?", readable by the operator, a
249/// sibling cluster, or a human, long after the pod that staged it.
250#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
251pub struct OrphanDoc {
252 pub written_unix: u64,
253 /// The epoch that staged them — a summary from a deposed holder is
254 /// still true about the bytes, and saying whose it was lets a
255 /// reader tell a live backlog from a dead one.
256 pub epoch: u64,
257 pub boundary_mode: String,
258 pub candidates: Vec<OrphanCandidate>,
259}
260
261#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
262pub struct OrphanCandidate {
263 pub path: String,
264 pub version_id: Option<String>,
265 pub size: u64,
266 pub generation: u64,
267 pub epoch: u64,
268 pub staged_unix: u64,
269}
270
271impl Syncer {
272 fn stage_path(&self) -> std::path::PathBuf {
273 self.cfg.state_dir().join(PENDING)
274 }
275
276 pub fn load_stage(&self) -> LeanResult<PendingStage> {
277 let p = self.stage_path();
278 if !p.exists() {
279 return Ok(PendingStage::default());
280 }
281 let bytes = std::fs::read(&p)?;
282 Ok(serde_json::from_slice(&bytes).unwrap_or_default())
283 }
284
285 pub fn save_stage(&self, s: &PendingStage) -> LeanResult<()> {
286 let bytes =
287 serde_json::to_vec_pretty(s).map_err(|e| LeanError::State(format!("pending: {e}")))?;
288 super::control::write_atomic(&self.stage_path(), &bytes)
289 }
290
291 /// The key D9's durable orphan summary lives at.
292 pub fn orphans_key(&self) -> String {
293 format!("{}/{}/orphans.json", self.cfg.prefix, super::LEAN_DIR)
294 }
295
296 /// Surface the staged-but-uncited set into the BUCKET (D9).
297 ///
298 /// `pending.json` lives on the emptyDir, which is exactly the thing
299 /// a pure-spot replacement destroys — so on the routine failure of
300 /// this fleet the pending record can name nothing, and the operator
301 /// (a different process, often a different cluster) has no way to
302 /// know that durable work is sitting uncited. This is that summary,
303 /// and it is written where it survives cluster loss.
304 ///
305 /// Written only when the candidate set CHANGES: a re-PUT per tick
306 /// would be a request the design does not need, and an empty doc
307 /// rewritten forever would be worse than none. The clearing write
308 /// matters as much as the surfacing one — a stale summary claiming
309 /// candidates pages an operator about work that was cited minutes
310 /// ago.
311 pub async fn surface_orphans(&self, stage: &PendingStage) -> LeanResult<bool> {
312 let epoch = self.lease.as_ref().map(|l| l.epoch).unwrap_or(0);
313 let candidates: Vec<OrphanCandidate> = stage
314 .entries
315 .iter()
316 .map(|(path, e)| OrphanCandidate {
317 path: path.clone(),
318 version_id: e.version_id.clone(),
319 size: e.size,
320 generation: e.generation,
321 epoch: e.epoch,
322 staged_unix: e.staged_unix,
323 })
324 .collect();
325 let fingerprint = {
326 let mut h = flint_store::Crc64Nvme::new();
327 for c in &candidates {
328 h.update(c.path.as_bytes());
329 h.update(c.version_id.as_deref().unwrap_or("").as_bytes());
330 }
331 format!("{:016x}", h.finalize())
332 };
333 let marker = self.cfg.state_dir().join("orphans-fingerprint");
334 if std::fs::read_to_string(&marker).ok().as_deref() == Some(fingerprint.as_str()) {
335 return Ok(false);
336 }
337 let doc = OrphanDoc {
338 written_unix: super::now_unix(),
339 epoch,
340 boundary_mode: self.cfg.boundary_mode.as_str().to_string(),
341 candidates,
342 };
343 let bytes = bytes::Bytes::from(
344 serde_json::to_vec_pretty(&doc).map_err(|e| LeanError::State(format!("orphans: {e}")))?,
345 );
346 let crc = flint_store::crc64_nvme(&bytes);
347 let stamps = GenerationStamps {
348 generation: 0,
349 epoch,
350 flush_uuid: "orphan-summary".into(),
351 boundary_source: None,
352 posix: None,
353 };
354 // Unconditional: this is a summary, not a protocol cell. A
355 // successor's summary must overwrite a dead holder's, and a
356 // 412-parked orphan doc would strand the very information the
357 // successor needs.
358 let key = self.orphans_key();
359 let cond = match self.store.head(&key).await {
360 Ok(m) => flint_store::PutCondition::IfMatch(m.etag),
361 Err(_) => flint_store::PutCondition::IfNoneMatchAny,
362 };
363 match self.store.put_whole(&key, bytes, &cond, &stamps, crc).await {
364 Ok(_) => {}
365 // Losing the race is fine: whoever won wrote a summary at
366 // least as new as ours, and the next change re-runs this.
367 Err(StoreError::PreconditionFailed(_)) => return Ok(false),
368 Err(e) => return Err(e.into()),
369 }
370 let _ = super::control::write_atomic(&marker, fingerprint.as_bytes());
371 Ok(true)
372 }
373
374 /// The upload lane: durability, every floor tick, no citation.
375 pub async fn upload_lane(&mut self) -> LeanResult<LaneReport> {
376 self.lane_inner(false).await
377 }
378
379 /// The lane pass behind a DECLARED boundary — a publish sentinel
380 /// (D1 × D6) or the preStop drain (D10). The citation that follows
381 /// is going to be acked, so a delete the agent made before the
382 /// touch may not sit out the boundary waiting for a second walk:
383 /// `confirm_absences` takes that observation by lstat instead.
384 pub async fn declared_lane(&mut self) -> LeanResult<LaneReport> {
385 self.lane_inner(true).await
386 }
387
388 async fn lane_inner(&mut self, declared: bool) -> LeanResult<LaneReport> {
389 let epoch = self
390 .lease
391 .as_ref()
392 .map(|l| l.epoch)
393 .ok_or_else(|| LeanError::State("upload lane without a held lease".into()))?;
394 let mut report = LaneReport::default();
395 self.verify_not_deposed_pub().await?;
396
397 // Inbound HITL adoption stays exactly today's behavior: a HITL
398 // write is already a coherent, whole-object act by a foreign
399 // author, so it is not gated.
400 let inbox_doc = inbox::load(self.store.as_ref(), &self.cfg).await?.doc;
401 let consumed = self.consume_inbox_doc(&inbox_doc).await?;
402 report.consumed = consumed.len();
403 // NOT dropped here (see `clear_window_settling`): a consumed
404 // entry leaves the cell at the citation that cites it, so a
405 // pod replacement between the two cannot orphan it.
406 // DECLARED removals (delete/rename design): unlinked here and
407 // WITHHELD like every other delete under gating, so they become
408 // reader-visible at a citation and nowhere else. Refusals are
409 // settled now; a performed removal stays in the cell until the
410 // citation that cites it out (`cite_pass` settles it), so a
411 // listing keeps hiding the path meanwhile. The pass is
412 // idempotent tick to tick: an already-unlinked path just
413 // re-declares.
414 let removals = self.apply_removals(&inbox_doc).await?;
415 inbox::settle_removals(self.store.as_ref(), &self.cfg, epoch, &[], &removals.refused)
416 .await?;
417
418 let mut baseline = self.state.load_baseline()?;
419 let scanned = scan::scan(&self.cfg.root)?;
420 let mut classified = scan::classify(&scanned, &baseline);
421 if declared {
422 report.absences_confirmed = self.confirm_absences(&mut classified)?;
423 }
424 for p in &removals.declared {
425 classified.first_absence.remove(p);
426 classified.deletes.insert(p.clone());
427 }
428 report.first_absence = classified.first_absence.iter().cloned().collect();
429 let mut stage = self.load_stage()?;
430
431 // Stage-diff base is baseline ∪ PENDING. Diffing against the
432 // baseline alone — which gated mode leaves un-advanced until
433 // citation — would re-PUT every staged-but-quiet file every
434 // tick: 50 quiet files × 60 ticks = 3,000 PUTs/hour vs today's
435 // 50, which would falsify the plan's own economics.
436 let to_stage: Vec<String> = classified
437 .uploads
438 .iter()
439 .filter(|p| match stage.entries.get(*p) {
440 None => true,
441 Some(pe) => {
442 let s = &scanned[*p];
443 pe.size != s.size || pe.mtime_unix != s.mtime_unix
444 }
445 })
446 .cloned()
447 .collect();
448
449 let flush_uuid = uuid::Uuid::new_v4().to_string();
450 let mut intent = self.state.load_intent()?;
451 let prior_uuids = {
452 let mut v = intent.recent_uuids.clone();
453 if !intent.flush_uuid.is_empty() {
454 v.push(intent.flush_uuid.clone());
455 }
456 v
457 };
458 intent = super::state::IntentJournal {
459 flush_uuid: flush_uuid.clone(),
460 keys: to_stage.iter().map(|p| self.cfg.file_key(p)).collect(),
461 recent_uuids: prior_uuids.clone(),
462 installed_etag: intent.installed_etag.clone(),
463 // The lane's declared deletes live in the persisted stage
464 // (`withheld_deletes`), not here.
465 declared_deletes: Vec::new(),
466 };
467 self.state.save_intent(&intent)?;
468
469 for path in &to_stage {
470 let scanned_entry = &scanned[path];
471 let base = baseline.entries.get(path).cloned();
472 let superseded = stage.entries.get(path).and_then(|p| p.version_id.clone());
473 let outcome = self
474 .stage_one(path, scanned_entry, base.as_ref(), epoch, &flush_uuid, &prior_uuids)
475 .await?;
476 match outcome {
477 Some(entry) => {
478 report.staged_bytes += entry.size;
479 report.staged.push(path.clone());
480 // The lane reclaims the version it just superseded:
481 // free, version-scoped, and it keeps uncited work at
482 // one version per path plus crash remnants.
483 // RECORD, never delete. The lane cannot prove a
484 // version is uncited: it does not read the installed
485 // manifest, and its own stage may have outlived a
486 // citation. The reaper can, and does, under four
487 // guards.
488 if let Some(vid) = superseded {
489 if Some(&vid) != entry.version_id.as_ref() {
490 stage
491 .pending_reclaims
492 .entry(path.clone())
493 .or_default()
494 .push(vid);
495 report.superseded_recorded += 1;
496 }
497 }
498 // The path is back. A tombstone standing from an
499 // earlier tick would otherwise ride into the same
500 // citation as this upsert, and merge applies
501 // deletes last: delete-then-recreate inside one
502 // citation interval would install a boundary that
503 // omits a file present on disk and staged this
504 // pass.
505 stage.withheld_deletes.remove(path);
506 stage.entries.insert(path.clone(), entry);
507 }
508 None => report.parked.push(path.clone()),
509 }
510 }
511
512 // A staged version is cancelled by the file's departure, the
513 // mirror of the tombstone cancellation above. The lane has now
514 // seen the path GONE, later than it saw it present; a citation
515 // carrying both the stage entry and the tombstone would install
516 // one of them by accident of merge order, and merge has no way
517 // to know which came last. Reclaiming the version costs
518 // nothing — it was never cited.
519 let mut absent_now: BTreeSet<String> = BTreeSet::new();
520 let staged_paths: Vec<String> = stage.entries.keys().cloned().collect();
521 let mut cancel: Vec<String> = vec![];
522 for path in staged_paths {
523 if scanned.contains_key(&path) {
524 continue;
525 }
526 if classified.deletes.contains(&path) {
527 cancel.push(path);
528 } else if stage.staged_absent_once.contains(&path)
529 || (declared
530 && std::fs::symlink_metadata(self.cfg.root.join(&path)).is_err())
531 {
532 // Never cited, so classify cannot see it: two lane
533 // observations, or one direct lstat on a DECLARED
534 // boundary, stand in for the two-scan rule.
535 cancel.push(path);
536 } else {
537 absent_now.insert(path);
538 }
539 }
540 for path in cancel {
541 if let Some(stale) = stage.entries.remove(&path) {
542 // Same rule as the supersede site above: a cancelled
543 // path's staged version can be the CITED one if this
544 // stage outlived a citation, and this site had no
545 // manifest reference at all.
546 if let Some(vid) = stale.version_id {
547 stage.pending_reclaims.entry(path.clone()).or_default().push(vid);
548 report.superseded_recorded += 1;
549 }
550 }
551 }
552 stage.staged_absent_once = absent_now;
553
554 // Deletes are WITHHELD until a citation pass (unchanged rule).
555 for p in &classified.deletes {
556 stage.withheld_deletes.insert(p.clone());
557 }
558 report.withheld_deletes = stage.withheld_deletes.len();
559
560 // Quiescence = scan-to-scan STABILITY: no path's (size, mtime)
561 // changed between consecutive lane ticks and nothing new
562 // appeared.
563 let fingerprint = scan_fingerprint(&scanned);
564 if stage.last_scan_fingerprint.as_deref() == Some(fingerprint.as_str()) {
565 if stage.stable_since_unix == 0 {
566 stage.stable_since_unix = now_unix();
567 }
568 } else {
569 stage.last_scan_fingerprint = Some(fingerprint);
570 stage.stable_since_unix = 0;
571 }
572 if stage.last_citation_unix == 0 {
573 stage.last_citation_unix = now_unix();
574 }
575
576 // Mirror the cadence path's guard (barrier.rs, the no-change
577 // arm): `prev_scan` advances the two-consecutive-scans clock,
578 // but the baseline DOCUMENT is O(files) and this lane rewrote
579 // the whole thing every tick whether or not the scan set had
580 // moved — ~46 MB of emptyDir writeback per tick at the 250k cap,
581 // for an idle workspace. `prev_scan` is the ONLY field this
582 // function mutates, so the save is guarded on it alone.
583 // Both sides iterate in sorted order, so the sets compare
584 // without building one.
585 if scanned.len() != baseline.prev_scan.len()
586 || !scanned.keys().eq(baseline.prev_scan.iter())
587 {
588 baseline.prev_scan = scanned.keys().cloned().collect();
589 self.state.save_baseline(&baseline)?;
590 }
591 self.save_stage(&stage)?;
592 // D9: the same set, surfaced where a pure-spot replacement
593 // cannot destroy it. Best-effort by construction — a failed
594 // summary must never fail the lane that made the bytes durable.
595 if let Err(e) = self.surface_orphans(&stage).await {
596 eprintln!("flint-sync: orphan summary not written (retrying next tick): {e}");
597 }
598 // Durability moved even though visibility did not — the whole
599 // point of the split, and the number `rpo_secs` must track.
600 if !report.staged.is_empty() {
601 self.note_durable()?;
602 }
603 Ok(report)
604 }
605
606 /// One in-place staging PUT. The guard chain is unchanged (If-Match
607 /// on the recognized baseline etag, AdoptOwn, park on foreign); what
608 /// changes is that the response's version id is KEPT.
609 async fn stage_one(
610 &self,
611 path: &str,
612 scanned: &scan::ScanEntry,
613 base: Option<&BaselineEntry>,
614 epoch: u64,
615 flush_uuid: &str,
616 prior_uuids: &[String],
617 ) -> LeanResult<Option<PendingEntry>> {
618 let key = self.cfg.file_key(path);
619 match self.upload_one_pub(path, scanned, base, epoch, flush_uuid, prior_uuids).await? {
620 Some((entry, _be)) => Ok(Some(PendingEntry {
621 key,
622 etag: entry.etag,
623 crc64_b64: entry.crc64_b64,
624 size: entry.size,
625 mode: entry.mode,
626 mtime_unix: entry.mtime_unix,
627 generation: entry.generation,
628 epoch: entry.epoch,
629 version_id: entry.version_id,
630 base_version_id: base.and_then(|b| b.version_id.clone()),
631 staged_unix: now_unix(),
632 })),
633 None => {
634 self.state.append_conflict(&ConflictRecord {
635 path: path.to_string(),
636 foreign_etag: String::new(),
637 preserved_key: None,
638 kind: "stage-412-parked".into(),
639 at_unix: now_unix(),
640 })?;
641 Ok(None)
642 }
643 }
644 }
645
646 /// Paths whose INTEGRATED object (the baseline) differs from the
647 /// manifest's citation: a consumed HITL write, or checkout's
648 /// S3-wins adoption. The fused barrier has carried this repair
649 /// since the amputation leg; gated mode never runs the fused
650 /// barrier, so without the same pass here an acked HITL write is
651 /// adopted into the tree, scans clean forever after, and stays
652 /// cited at its PREVIOUS version — invisible to exactly the
653 /// pinned readers D13 governs.
654 pub fn repair_candidates(&self) -> LeanResult<Vec<String>> {
655 let baseline = self.state.load_baseline()?;
656 let stage = self.load_stage()?;
657 Ok(baseline
658 .entries
659 .iter()
660 .filter(|(p, be)| {
661 be.size != u64::MAX // consume-dirty sentinel: publishes via the lane
662 && !stage.entries.contains_key(*p)
663 && !stage.withheld_deletes.contains(*p)
664 && baseline.inst_base.get(*p) != Some(&be.etag)
665 })
666 .map(|(p, _)| p.clone())
667 .collect())
668 }
669
670 /// `(key, etag)` -> version id, over this subtree's whole version
671 /// history. One listing answers for every legacy entry at once,
672 /// including paths whose CURRENT version has already moved past
673 /// the citation — which a HEAD, by construction, cannot.
674 async fn version_index(&self) -> LeanResult<BTreeMap<(String, String), String>> {
675 let files_prefix = format!("{}/files/", self.cfg.prefix);
676 let mut ix: BTreeMap<(String, String), String> = BTreeMap::new();
677 // A backend that cannot list versions leaves every legacy entry
678 // unresolved, which the reader rule then refuses rather than
679 // adopts. Gated already refuses such a backend at startup.
680 for v in self.store.list_versions(&files_prefix).await.unwrap_or_default() {
681 if v.is_delete_marker {
682 continue;
683 }
684 ix.entry((v.key, v.etag)).or_insert(v.version_id);
685 }
686 Ok(ix)
687 }
688
689 /// Which coherent point, if any, is due right now.
690 pub fn citation_due(&self, sentinel_pending: bool) -> LeanResult<Option<CitationSource>> {
691 let stage = self.load_stage()?;
692 let now = now_unix();
693 if stage.entries.is_empty() && stage.withheld_deletes.is_empty() {
694 // Nothing of the agent's is staged — but a repair still
695 // owes the readers a boundary. Not a forced cap: nothing is
696 // late, and stamping these "forced" would misread the
697 // operator's own tell.
698 if !self.repair_candidates()?.is_empty()
699 && now.saturating_sub(stage.last_citation_unix) >= self.cfg.floor_secs
700 {
701 return Ok(Some(CitationSource::Repair));
702 }
703 return Ok(None);
704 }
705 if sentinel_pending {
706 return Ok(Some(CitationSource::Sentinel));
707 }
708 // The lag cap forces one even mid-change: unbounded citation
709 // staleness is impossible by construction, which is why `gated`
710 // is REFUSED without a bound rather than defaulted.
711 if let Some(bound) = self.cfg.visibility_lag_bound_secs {
712 if now.saturating_sub(stage.last_citation_unix) >= bound {
713 return Ok(Some(CitationSource::ForcedLagCap));
714 }
715 }
716 // The backlog cap bounds the preStop drain BY CONSTRUCTION.
717 let bytes: u64 = stage.entries.values().map(|e| e.size).sum();
718 // Recorded reclaims are counted as OBJECTS, because they are
719 // exactly the drain work this cap exists to bound: the citation
720 // the drain runs issues one `delete_version` per recorded id.
721 // Counting only staged PATHS would let one hot file rewritten
722 // every tick accumulate a version per tick forever — one stage
723 // entry, one size, and a cap that never fires — while
724 // `drain_need_secs` sized the grace from that same cap.
725 let recorded: u64 =
726 stage.pending_reclaims.values().map(|v| v.len() as u64).sum();
727 if stage.entries.len() as u64 + recorded >= self.cfg.staged_backlog_cap_objects
728 || bytes >= self.cfg.staged_backlog_cap_bytes
729 {
730 return Ok(Some(CitationSource::ForcedBacklogCap));
731 }
732 if stage.stable_since_unix > 0
733 && now.saturating_sub(stage.stable_since_unix) >= self.cfg.quiesce_bound_secs
734 {
735 return Ok(Some(CitationSource::Quiescence));
736 }
737 Ok(None)
738 }
739
740 /// The citation lane: ONE CAS installing the entire pending set.
741 ///
742 /// The versions already exist, so there is no copy phase — hence no
743 /// half-boundary, no intent document, no roll-forward, and no
744 /// interval in which a version-resolving reader can see
745 /// boundary-new bytes under old citations. `Inv_BoundaryAtomic`
746 /// holds by construction.
747 /// EXACT version reclamation (D8) — the reaper, not lifecycle.
748 ///
749 /// Reclaims ONLY the generations this workspace itself superseded:
750 /// for each cited path, the version its own pending record names as
751 /// `base_version_id`. Everything else — crash remnants, foreign
752 /// writes, a successor's work — is left to the noncurrent backstop.
753 ///
754 /// THE RULE USED TO BE "delete every version of a touched key that
755 /// is neither `keep` nor `is_current`", and that destroys committed
756 /// data. The `is_current` guard protects exactly ONE version, and
757 /// its reasoning ("a foreign write landed between the lane and this
758 /// citation") assumes at most one foreign generation. A successor in
759 /// gated mode does not stop at one: its cadence is stage → cite →
760 /// stage. So a straggler whose CAS landed and which then stalled in
761 /// this loop would find, on resuming, the successor's CITED version
762 /// sitting noncurrent-and-not-`keep` — and delete it. Every cited
763 /// version surviving a deposed straggler is the premise §8 Q2 chose
764 /// versioned staging on ("they destroy nothing"), so the old rule
765 /// falsified the reason the mode exists.
766 ///
767 /// Lifecycle cannot do this job on `files/`: gated staging makes the
768 /// CITED version noncurrent the moment a newer generation is staged,
769 /// so a `NoncurrentVersionExpiration` rule runs a clock against live
770 /// cited data and never reaches the newest uncited bytes, which are
771 /// current. That inversion is why the standing retention is a long
772 /// BACKSTOP and a shorter fleet-wide rule is a refusal condition.
773 pub(crate) async fn reclaim_superseded(
774 &mut self,
775 upserts: &BTreeMap<String, LeanEntry>,
776 installed: &super::manifest::LeanManifest,
777 stage: &PendingStage,
778 report: &mut CitationReport,
779 ) -> LeanResult<()> {
780 for (i, path) in upserts.keys().enumerate() {
781 // The loop is O(cited) round trips and the run loop's
782 // renewal arm cannot fire while it runs (one `select!`, the
783 // chosen branch awaited inline). Renew on our own account,
784 // and re-verify: a straggler that lost the lease mid-loop
785 // must stop deleting, not finish the sweep.
786 if i % RECLAIM_FENCE_EVERY == 0 {
787 self.renew_if_due().await?;
788 self.verify_not_deposed_pub().await?;
789 }
790 // The ONLY version this pass is entitled to reclaim: the one
791 // our own pending record says we superseded. If we cannot
792 // name it we reclaim nothing — the backstop exists for
793 // exactly the remnants we cannot name.
794 let Some(superseded) =
795 stage.entries.get(path).and_then(|pe| pe.base_version_id.clone())
796 else {
797 continue;
798 };
799 if superseded.is_empty() {
800 continue;
801 }
802 // FAIL CLOSED. If the installed manifest does not name a
803 // version for this path — an unversioned backend, a proxy
804 // that stripped the header, a merge that resolved
805 // foreign-wins — then we do not know what is cited, and
806 // reclaiming against it is guesswork.
807 let Some(keep) = installed
808 .entries
809 .get(path)
810 .and_then(|e| e.version_id.clone())
811 .filter(|v| !v.is_empty())
812 else {
813 continue;
814 };
815 // The merge may have resolved foreign-wins onto the very
816 // version we were about to reclaim.
817 if superseded == keep {
818 continue;
819 }
820 let key = self.cfg.file_key(path);
821 let Ok(versions) = self.store.list_versions(&key).await else { continue };
822 let Some(v) = versions
823 .iter()
824 .find(|v| v.key == key && !v.is_delete_marker && v.version_id == superseded)
825 else {
826 continue;
827 };
828 // NEVER the current version. If what we superseded is
829 // somehow current again, a foreign writer restored it and
830 // those are live bytes somebody is about to read.
831 if v.is_current {
832 continue;
833 }
834 let _ = self.store.delete_version(&key, &superseded).await;
835 report.versions_reclaimed += 1;
836 }
837
838 // Then everything the LANE recorded and did not delete. Same
839 // four guards; the only difference is where the candidate came
840 // from. `base_version_id` names exactly one version — the one
841 // superseded relative to the CITED generation — so without this
842 // pass every intermediate generation between two citations
843 // would be named by nothing and left to the retention backstop.
844 let recorded: Vec<(String, Vec<String>)> =
845 stage.pending_reclaims.iter().map(|(p, v)| (p.clone(), v.clone())).collect();
846 for (i, (path, vids)) in recorded.iter().enumerate() {
847 if i % RECLAIM_FENCE_EVERY == 0 {
848 self.renew_if_due().await?;
849 self.verify_not_deposed_pub().await?;
850 }
851 // Two distinct "no keep" cases, and they are NOT the same:
852 //
853 // - the installed manifest does not cite this path AT ALL
854 // (a withheld delete installed, a path that never made a
855 // boundary) — there is no cited version to protect, so
856 // the recorded ids are reclaimable;
857 // - it cites the path but names no version — an unversioned
858 // backend, a stripping proxy, a foreign-wins merge. We do
859 // not know what is cited, so FAIL CLOSED, exactly as the
860 // pass above does.
861 let keep = match installed.entries.get(path) {
862 None => None,
863 Some(e) => match e.version_id.clone().filter(|v| !v.is_empty()) {
864 Some(v) => Some(v),
865 None => continue,
866 },
867 };
868 let key = self.cfg.file_key(path);
869 let Ok(versions) = self.store.list_versions(&key).await else { continue };
870 for vid in vids {
871 if vid.is_empty() || Some(vid) == keep.as_ref() {
872 continue;
873 }
874 let Some(v) = versions
875 .iter()
876 .find(|v| v.key == key && !v.is_delete_marker && &v.version_id == vid)
877 else {
878 continue;
879 };
880 // The `is_current` guard is CONDITIONAL here, and the
881 // asymmetry is deliberate. Where the boundary cites this
882 // path, a recorded id that is current again means a
883 // foreign writer restored it and those are live bytes —
884 // skip, exactly as the pass above does. Where the
885 // boundary does not cite the path AT ALL — a withheld
886 // delete that installed, a scratch file that never made
887 // a boundary — current is the ORDINARY state of the last
888 // version this workspace staged before the file went
889 // away, and skipping it would leave precisely the litter
890 // this pass exists to collect. We delete by exact
891 // version id, so a foreign write is still safe: it would
892 // be a different id, and this one would not be current.
893 if keep.is_some() && v.is_current {
894 continue;
895 }
896 let _ = self.store.delete_version(&key, vid).await;
897 report.versions_reclaimed += 1;
898 }
899 }
900 Ok(())
901 }
902
903 pub async fn citation_pass(&mut self, source: CitationSource) -> LeanResult<CitationReport> {
904 let epoch = self
905 .lease
906 .as_ref()
907 .map(|l| l.epoch)
908 .ok_or_else(|| LeanError::State("citation without a held lease".into()))?;
909 let mut report = CitationReport { source: Some(source.as_str().into()), ..Default::default() };
910 self.verify_not_deposed_pub().await?;
911
912 let mut stage = self.load_stage()?;
913 let mut baseline = self.state.load_baseline()?;
914 let repairs = self.repair_candidates()?;
915 if stage.entries.is_empty() && stage.withheld_deletes.is_empty() && repairs.is_empty() {
916 report.no_change = true;
917 return Ok(report);
918 }
919
920 // Base-version re-validation (D7). If the baseline's cited
921 // version moved between staging and now, a HITL consume or sync
922 // landed in between; installing our staged version would UNCITE
923 // the foreign bytes. The damage class is softer than the copy
924 // design's — there the amputation DESTROYED the foreign object,
925 // here it only hides it and the version stays fetchable — but
926 // the rule stands.
927 let scanned = scan::scan(&self.cfg.root)?;
928 let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
929 let mut drop_paths: Vec<String> = vec![];
930 for (path, pe) in &stage.entries {
931 let cited_now = baseline.entries.get(path).and_then(|b| b.version_id.clone());
932 let stale = pe.base_version_id.is_some() && cited_now != pe.base_version_id;
933 if stale {
934 let still_differs = match (scanned.get(path), baseline.entries.get(path)) {
935 (Some(s), Some(b)) => s.size != b.size || s.mtime_unix != b.mtime_unix,
936 (Some(_), None) => true,
937 _ => false,
938 };
939 self.state.append_conflict(&ConflictRecord {
940 path: path.clone(),
941 foreign_etag: baseline
942 .entries
943 .get(path)
944 .map(|b| b.etag.clone())
945 .unwrap_or_default(),
946 preserved_key: None,
947 kind: format!(
948 "citation-stale-base (superseded generation {}, still-differs={})",
949 pe.generation, still_differs
950 ),
951 at_unix: now_unix(),
952 })?;
953 if !still_differs {
954 // The foreign bytes ARE the local bytes now: drop
955 // the staged citation rather than uncite them. The
956 // next lane tick re-stages if the tree moves again.
957 drop_paths.push(path.clone());
958 report.dropped_stale_base.push(path.clone());
959 continue;
960 }
961 }
962 upserts.insert(
963 path.clone(),
964 LeanEntry {
965 key: pe.key.clone(),
966 etag: pe.etag.clone(),
967 crc64_b64: pe.crc64_b64.clone(),
968 size: pe.size,
969 mode: pe.mode,
970 mtime_unix: pe.mtime_unix,
971 generation: pe.generation,
972 epoch: pe.epoch,
973 version_id: pe.version_id.clone(),
974 },
975 );
976 }
977 for p in &drop_paths {
978 stage.entries.remove(p);
979 }
980
981 // The ungated repair (§2.4.2). No bytes move: the object IS
982 // what this syncer already integrated, and the citation is
983 // catching up to it. The HEAD is also what supplies the VERSION
984 // id — a consume records none, and under `pinned_reads` a
985 // citation without one is unreadable by the very readers this
986 // pass exists for.
987 for path in &repairs {
988 if upserts.contains_key(path) {
989 continue;
990 }
991 let Some(be) = baseline.entries.get(path).cloned() else { continue };
992 let key = self.cfg.file_key(path);
993 let meta = match self.store.head(&key).await {
994 Ok(m) if m.etag == be.etag => m,
995 // Moved again or gone: citing a version this workspace
996 // has not integrated would be the lie the repair exists
997 // to prevent. The next consume reconciles it.
998 _ => continue,
999 };
1000 let crc = super::barrier::repair_crc(path, &be, &meta)?;
1001 let stamps = GenerationStamps::from_meta(&meta.meta);
1002 let scan_entry = scanned.get(path);
1003 upserts.insert(
1004 path.clone(),
1005 LeanEntry {
1006 key,
1007 etag: meta.etag.clone(),
1008 crc64_b64: crc,
1009 size: meta.size,
1010 mode: stamps
1011 .as_ref()
1012 .and_then(|s| s.posix)
1013 .map(|p| p.mode)
1014 .or(scan_entry.map(|s| s.mode))
1015 .unwrap_or(0o644),
1016 mtime_unix: scan_entry.map(|s| s.mtime_unix).unwrap_or(be.mtime_unix),
1017 generation: stamps.map(|s| s.generation).unwrap_or(be.generation),
1018 epoch,
1019 version_id: meta.version_id.clone(),
1020 },
1021 );
1022 report.repaired.push(path.clone());
1023 }
1024
1025 // Window open/clear belong to THIS lane only.
1026 let deadline = now_unix() + self.cfg.window_slack_secs;
1027 let opened =
1028 inbox::open_window(self.store.as_ref(), &self.cfg, epoch, deadline).await?;
1029
1030 // A HITL write that landed AFTER the lane staged this path.
1031 //
1032 // The base-version check above cannot see it: it reads the
1033 // BASELINE, and the citation lane consumes nothing, so the
1034 // baseline has not moved. And the upload lane opens no window —
1035 // deliberately, because a lane that fenced HITL out every floor
1036 // tick would refuse admission essentially forever between
1037 // citations. So the gap is real and it is the mode's own doing.
1038 //
1039 // The inbox is what can see it, and the window CAS just loaded
1040 // it: zero added requests. Drop the path rather than cite bytes
1041 // that PREDATE the user's write over it. The entry stays queued,
1042 // so the next lane consumes it the ordinary way.
1043 //
1044 // (Found by the formal model — tranche 3 product 2. Before the
1045 // companion rule below, the reaper then DELETED the user's
1046 // version, because it was not the one the manifest cited.)
1047 //
1048 // NOT every entry in the cell: since consumed entries leave the
1049 // cell at the citation rather than at the consume, an entry
1050 // this lane has already integrated — the baseline holds its
1051 // etag, or the consume-dirty sentinel for it — is the citation's
1052 // own business, not a write that landed after the stage.
1053 let inflight: BTreeSet<String> = opened
1054 .doc
1055 .entries
1056 .iter()
1057 .filter(|e| {
1058 !baseline
1059 .entries
1060 .get(&e.path)
1061 .map(|b| b.etag == e.etag || b.size == u64::MAX)
1062 .unwrap_or(false)
1063 })
1064 .map(|e| e.path.clone())
1065 .collect();
1066 for path in &inflight {
1067 if upserts.remove(path).is_some() {
1068 stage.entries.remove(path);
1069 // Kept separate from the stale-base drop on purpose:
1070 // this one, and only this one, removes the agent's
1071 // LATEST bytes from a boundary it may be acked for.
1072 // The stale-base drop fires only when the foreign bytes
1073 // ARE the local bytes, so the point still carries the
1074 // agent's content.
1075 report.dropped_inflight.push(path.clone());
1076 self.state.append_conflict(&ConflictRecord {
1077 path: path.clone(),
1078 foreign_etag: String::new(),
1079 preserved_key: None,
1080 kind: "citation-hitl-inflight".into(),
1081 at_unix: now_unix(),
1082 })?;
1083 }
1084 }
1085
1086 let deletes = stage.withheld_deletes.clone();
1087 let parked: BTreeSet<String> = BTreeSet::new();
1088 let flush_uuid = uuid::Uuid::new_v4().to_string();
1089 let mut intent = self.state.load_intent()?;
1090 let prev_installed = intent.installed_etag.clone();
1091 let mut attempt = 0;
1092 let mut version_index: Option<BTreeMap<(String, String), String>> = None;
1093 let mut backfilled: Vec<String> = vec![];
1094 let mut unresolved: BTreeSet<String> = BTreeSet::new();
1095 let (installed, installed_etag, foreign) = loop {
1096 attempt += 1;
1097 if attempt > 4 {
1098 return Err(LeanError::State(
1099 "citation CAS lost 4 merge races — refusing this boundary".into(),
1100 ));
1101 }
1102 let current = manifest::load(self.store.as_ref(), &self.cfg).await?;
1103 let (theirs, expected) = match ¤t {
1104 // The handle carries the layout as well as the tag —
1105 // see the fused barrier's copy of this.
1106 Some(l) => (l.manifest.clone(), Some(l.handle())),
1107 None => (Default::default(), None),
1108 };
1109 // Same rule as the fused barrier: if the bucket is still at
1110 // the document we installed, that document IS the merge
1111 // base (`IntentJournal::installed_etag`). A citation pass
1112 // that crashed between its CAS and the baseline rewrite
1113 // would otherwise read its own boundary as foreign.
1114 let own_base;
1115 let base: &BTreeMap<String, String> =
1116 if prev_installed.is_some()
1117 && prev_installed.as_deref() == expected.as_ref().map(|h| h.etag.as_str())
1118 {
1119 own_base = theirs
1120 .entries
1121 .iter()
1122 .map(|(p, e)| (p.clone(), e.etag.clone()))
1123 .collect();
1124 &own_base
1125 } else {
1126 &baseline.inst_base
1127 };
1128 let (mut merged, foreign) =
1129 manifest::merge(base, &theirs, &upserts, &deletes, &parked);
1130 // D13: this citation's readers resolve by version, never by
1131 // S3-wins adoption of the current version.
1132 merged.pinned_reads = true;
1133 // Independent of D13 and cleared by `merge`, so it is
1134 // restated here for the same reason the cadence barrier
1135 // restates it: it describes the deployment, not the
1136 // citation.
1137 merged.sole_writer = self.cfg.sole_writer;
1138 // The mixed-manifest cell, where D7's entry schema and D13
1139 // collide. Stamping `pinned_reads` over an entry a pre-D7
1140 // writer cited leaves it addressable only by etag — and the
1141 // moment the lane stages that path, its current version is
1142 // uncited mid-change bytes that checkout's fallback arm
1143 // would adopt. Resolve the CITED etag in the bucket's own
1144 // version history before installing: one listing, and it
1145 // answers even where the current version has already moved.
1146 let legacy: Vec<String> = merged
1147 .entries
1148 .iter()
1149 .filter(|(p, e)| {
1150 e.version_id.is_none() && !stage.legacy_unresolved.contains(*p)
1151 })
1152 .map(|(p, _)| p.clone())
1153 .collect();
1154 if !legacy.is_empty() {
1155 if version_index.is_none() {
1156 version_index = Some(self.version_index().await?);
1157 }
1158 let ix = version_index.as_ref().expect("just built");
1159 for path in legacy {
1160 let Some(e) = merged.entries.get_mut(&path) else { continue };
1161 match ix.get(&(e.key.clone(), e.etag.clone())) {
1162 Some(vid) => {
1163 e.version_id = Some(vid.clone());
1164 backfilled.push(path);
1165 }
1166 None => {
1167 unresolved.insert(path);
1168 }
1169 }
1170 }
1171 }
1172 match manifest::cas_write_stamped(
1173 self.store.as_ref(),
1174 &self.cfg,
1175 &merged,
1176 expected.as_ref(),
1177 epoch,
1178 &flush_uuid,
1179 Some(source.as_str()),
1180 )
1181 .await
1182 {
1183 Ok(meta) => {
1184 intent.installed_etag = Some(meta.etag.clone());
1185 self.state.save_intent(&intent)?;
1186 break (merged, meta.etag, foreign);
1187 }
1188 Err(LeanError::Store(StoreError::PreconditionFailed(_)))
1189 | Err(LeanError::Store(StoreError::Conflict(_))) => {
1190 self.verify_not_deposed_pub().await?;
1191 continue;
1192 }
1193 Err(e) => return Err(e),
1194 }
1195 };
1196 report.seq = Some(installed.seq);
1197 report.cited = upserts.len();
1198 backfilled.sort();
1199 backfilled.dedup();
1200 report.backfilled = backfilled;
1201 stage.legacy_unresolved.extend(unresolved);
1202 self.note_boundary(source.as_str(), installed.seq)?;
1203
1204 // GC of keys the NEW manifest no longer references — deletes
1205 // LAST, HEAD-guarded on the recognized ETag, unchanged.
1206 for path in &deletes {
1207 if installed.entries.contains_key(path) {
1208 continue;
1209 }
1210 let key = self.cfg.file_key(path);
1211 let recognized = baseline.entries.get(path).map(|b| b.etag.clone());
1212 match self.store.head(&key).await {
1213 Err(StoreError::NotFound(_)) => report.deleted.push(path.clone()),
1214 Ok(meta) if Some(&meta.etag) == recognized.as_ref() => {
1215 self.store.delete(&key).await?;
1216 report.deleted.push(path.clone());
1217 }
1218 Ok(meta) => {
1219 self.state.append_conflict(&ConflictRecord {
1220 path: path.clone(),
1221 foreign_etag: meta.etag,
1222 preserved_key: None,
1223 kind: "gc-skip".into(),
1224 at_unix: now_unix(),
1225 })?;
1226 }
1227 Err(e) => return Err(e.into()),
1228 }
1229 }
1230
1231 // EXACT version reclamation (D8) — this is the reaper, not
1232 // lifecycle. After the CAS, every version of a touched key that
1233 // neither the installed manifest nor the (now empty) pending set
1234 // names is deleted, version-scoped and free. Steady state ⇒ one
1235 // version per key.
1236 //
1237 // Lifecycle cannot do this job on `files/`: gated staging makes
1238 // the CITED version noncurrent the moment a newer generation is
1239 // staged, so a `NoncurrentVersionExpiration` rule runs a clock
1240 // against live cited data and never reaches the newest uncited
1241 // bytes, which are current. That inversion is why the standing
1242 // retention is a long BACKSTOP and a shorter fleet-wide rule is
1243 // a refusal condition.
1244 self.reclaim_superseded(&upserts, &installed, &stage, &mut report).await?;
1245
1246 // Baseline rewrite.
1247 for (path, e) in &installed.entries {
1248 // A repaired path keeps its integrated stat; only the
1249 // version id it is now cited by is new.
1250 if report.repaired.contains(path) {
1251 if let Some(be) = baseline.entries.get_mut(path) {
1252 be.version_id = e.version_id.clone();
1253 }
1254 }
1255 if let Some(pe) = stage.entries.get(path) {
1256 baseline.entries.insert(
1257 path.clone(),
1258 BaselineEntry {
1259 etag: e.etag.clone(),
1260 generation: e.generation,
1261 size: pe.size,
1262 mtime_unix: pe.mtime_unix,
1263 version_id: e.version_id.clone(),
1264 crc64_b64: Some(e.crc64_b64.clone()),
1265 },
1266 );
1267 }
1268 }
1269 for path in &report.deleted {
1270 baseline.entries.remove(path);
1271 }
1272 baseline.inst_base =
1273 installed.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
1274 baseline.seq = installed.seq;
1275 baseline.manifest_etag = Some(installed_etag);
1276 self.state.save_baseline(&baseline)?;
1277 self.state.clear_intent_keys()?;
1278
1279 // What this citation installed leaves the cell now — after the
1280 // CAS, never before it: the consumed entries the manifest now
1281 // cites (or that the agent's own version has since superseded),
1282 // and the DECLARED removals whose deletion it installed.
1283 let cell = inbox::load(self.store.as_ref(), &self.cfg).await?.doc;
1284 let cited_entries: Vec<inbox::InboxEntry> = cell
1285 .entries
1286 .iter()
1287 .filter(|e| {
1288 baseline.entries.get(&e.path).map(|b| b.etag == e.etag || b.size == u64::MAX).unwrap_or(false)
1289 })
1290 .cloned()
1291 .collect();
1292 if !cited_entries.is_empty() {
1293 inbox::drop_entries(self.store.as_ref(), &self.cfg, epoch, &cited_entries).await?;
1294 }
1295 let cited_out: Vec<inbox::Removal> = cell
1296 .removals
1297 .into_iter()
1298 .filter(|r| r.refused.is_none() && deletes.contains(&r.path))
1299 .collect();
1300 inbox::settle_removals(self.store.as_ref(), &self.cfg, epoch, &cited_out, &[]).await?;
1301
1302 stage.entries.clear();
1303 stage.withheld_deletes.clear();
1304 stage.pending_reclaims.clear();
1305 stage.last_citation_unix = now_unix();
1306 stage.stable_since_unix = 0;
1307 self.save_stage(&stage)?;
1308 // The CLEARING write matters as much as the surfacing one: a
1309 // stale summary claiming candidates pages an operator about
1310 // work that was cited minutes ago (D9).
1311 if let Err(e) = self.surface_orphans(&stage).await {
1312 eprintln!("flint-sync: orphan summary not cleared (retrying next tick): {e}");
1313 }
1314
1315 let queue: Vec<inbox::InboxEntry> = foreign
1316 .into_iter()
1317 .map(|(path, e)| inbox::InboxEntry {
1318 path,
1319 etag: e.etag,
1320 author: "merge-preserved".into(),
1321 added_unix: now_unix(),
1322 crc64_b64: Some(e.crc64_b64),
1323 })
1324 .collect();
1325 inbox::clear_window(self.store.as_ref(), &self.cfg, epoch, &queue).await?;
1326 Ok(report)
1327 }
1328
1329 /// The gated floor tick: lane, then a citation if one is due.
1330 ///
1331 /// Both halves are reported because the floor arm has to describe
1332 /// them separately — a tick that staged 40 files and cited nothing
1333 /// is the mode working, and a tick that staged nothing and cited
1334 /// nothing is idle. Collapsing them into one "no change" would make
1335 /// gated mode indistinguishable from a wedged one in the log.
1336 pub async fn gated_tick(
1337 &mut self,
1338 sentinel_pending: bool,
1339 ) -> LeanResult<(LaneReport, CitationReport)> {
1340 let lane = self.upload_lane().await?;
1341 let cite = match self.citation_due(sentinel_pending)? {
1342 Some(source) => self.citation_pass(source).await?,
1343 None => CitationReport { no_change: true, ..Default::default() },
1344 };
1345 Ok((lane, cite))
1346 }
1347
1348 /// The startup gate for gated mode (D8, D11): probe the version
1349 /// surface BEFORE a single byte is staged, and refuse rather than
1350 /// degrade. Inert in `cadence`/`hybrid`, which need no version
1351 /// surface at all — a gate that took every default workspace down
1352 /// with it would be worse than the hazard.
1353 pub async fn gated_startup_check(&mut self) -> LeanResult<()> {
1354 if !self.is_gated() {
1355 return Ok(());
1356 }
1357 self.versioning_conformance().await
1358 }
1359
1360 /// `flint-sync recover-staged` (D9): re-cite durable-but-uncited
1361 /// work as ONE flagged boundary — a manifest CAS with no data
1362 /// movement, because the versions already exist.
1363 ///
1364 /// The routine path into this verb is a pure-spot pod replacement:
1365 /// the emptyDir that held `pending.json` is gone, so the pending
1366 /// record can name nothing and **the bucket is the only source of
1367 /// truth**. Two shapes are recovered, and they overlap:
1368 ///
1369 /// - *uncited work* — the key's current version is not the one the
1370 /// manifest cites (or the key is not cited at all: a brand-new
1371 /// file staged and never installed);
1372 /// - *a dangling citation* — the cited version no longer exists,
1373 /// because gated staging made it NONCURRENT and the retention
1374 /// backstop ran its clock against live cited data (D8's
1375 /// inversion). Checkout refuses on this rather than serving a
1376 /// hole, so recovery is what makes the workspace usable again.
1377 ///
1378 /// Recovery rolls **forward**, onto the newer bytes, and says so:
1379 /// every re-citation writes a conflict record. It is deliberately
1380 /// NOT a three-way merge — a replacement pod has no merge base, and
1381 /// merging against an empty one would classify the entire existing
1382 /// manifest as foreign and queue the whole tree into the HITL
1383 /// inbox.
1384 pub async fn recover_staged(&mut self) -> LeanResult<RecoverReport> {
1385 let epoch = self
1386 .lease
1387 .as_ref()
1388 .map(|l| l.epoch)
1389 .ok_or_else(|| LeanError::State("recover-staged without a held lease".into()))?;
1390 let mut report = RecoverReport::default();
1391 let files_prefix = format!("{}/files/", self.cfg.prefix);
1392
1393 // D9's durable summary is the MECHANISM; the prefix-wide version
1394 // LIST is the fallback (review: U1). `flint-store`'s own trait
1395 // doc has said so since the version surface landed — "the
1396 // claim-time/DR fallback when `orphans.json` is missing or
1397 // stale — the expensive path, which is why the durable summary
1398 // is written eagerly" — but nothing in the syncer read the
1399 // summary, so recovery always took the expensive path and the
1400 // eager write bought nothing here.
1401 //
1402 // The summary narrows WHICH keys we list, never what we
1403 // conclude about them: every candidate is still resolved
1404 // against the bucket below. A stale or partial summary
1405 // therefore costs coverage, not correctness — so a missing one,
1406 // or one written under a different boundary mode, falls back to
1407 // the full sweep rather than trusting a narrower answer.
1408 let summary = match self.store.get_whole(&self.orphans_key(), None).await {
1409 Ok((_, bytes)) => serde_json::from_slice::<OrphanDoc>(&bytes).ok(),
1410 Err(_) => None,
1411 };
1412 // The manifest is read BEFORE the narrowing, because the
1413 // narrowed key set is the union of the summary's candidates and
1414 // everything the manifest already CITES.
1415 //
1416 // Getting that wrong is a data-loss-shaped bug, and leg B11b
1417 // caught it: `orphans.json` names only the STAGED candidates, so
1418 // narrowing to those alone left every cited-but-not-staged path
1419 // with no listed version at all — and recovery classifies a path
1420 // with no surviving version as UNRECOVERABLE. A workspace with
1421 // one quiet cited file would have been told its data was gone.
1422 let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
1423 let cited_now = loaded.as_ref().map(|l| l.manifest.clone()).unwrap_or_default();
1424
1425 let narrowed: Option<BTreeSet<String>> = summary.as_ref().and_then(|d| {
1426 if d.boundary_mode != self.cfg.boundary_mode.as_str() || d.candidates.is_empty() {
1427 return None;
1428 }
1429 let mut keys: BTreeSet<String> =
1430 d.candidates.iter().map(|c| self.cfg.file_key(&c.path)).collect();
1431 keys.extend(cited_now.entries.keys().map(|p| self.cfg.file_key(p)));
1432 Some(keys)
1433 });
1434 report.from_summary = narrowed.is_some();
1435
1436 let listed: Vec<ListedVersion> = match &narrowed {
1437 Some(keys) => {
1438 let mut out = vec![];
1439 for key in keys {
1440 out.extend(self.store.list_versions(key).await.unwrap_or_default());
1441 }
1442 out
1443 }
1444 None => self.store.list_versions(&files_prefix).await?,
1445 };
1446 let mut current: BTreeMap<String, ListedVersion> = BTreeMap::new();
1447 let mut known: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1448 for v in listed {
1449 known.entry(v.key.clone()).or_default().insert(v.version_id.clone());
1450 if v.is_current {
1451 current.insert(v.key.clone(), v);
1452 }
1453 }
1454
1455 let mut paths: BTreeSet<String> = cited_now.entries.keys().cloned().collect();
1456 for key in current.keys() {
1457 if let Some(p) = key.strip_prefix(&files_prefix) {
1458 // D0.2 again: a legacy `files/.flint/` citation must not
1459 // be recovered INTO the control namespace.
1460 if !p.is_empty() && !scan::is_control_path(p) {
1461 paths.insert(p.to_string());
1462 }
1463 }
1464 }
1465
1466 let mut upserts: BTreeMap<String, LeanEntry> = BTreeMap::new();
1467 for path in paths {
1468 let key = self.cfg.file_key(&path);
1469 let cited = cited_now.entries.get(&path).cloned();
1470 let live = current.get(&key).filter(|v| !v.is_delete_marker).cloned();
1471
1472 // A citation dangles only if it names a version BY ID that
1473 // the bucket no longer holds. A legacy entry citing no
1474 // version resolves by etag and cannot dangle this way.
1475 let dangles = match &cited {
1476 Some(e) => match &e.version_id {
1477 Some(v) => !known.get(&key).map(|s| s.contains(v)).unwrap_or(false),
1478 None => false,
1479 },
1480 None => false,
1481 };
1482 if dangles {
1483 report.dangling.push(path.clone());
1484 }
1485 let Some(live) = live else {
1486 if dangles {
1487 report.unrecoverable.push(path.clone());
1488 }
1489 continue;
1490 };
1491 let already = match &cited {
1492 None => false,
1493 Some(e) => match &e.version_id {
1494 Some(v) => v == &live.version_id,
1495 None => e.etag == live.etag,
1496 },
1497 };
1498 if already {
1499 continue;
1500 }
1501
1502 // One HEAD per re-cited path to recover the stamps the
1503 // manifest entry needs. A HEAD is not data movement.
1504 let meta = self.store.head_version(&key, &live.version_id).await?;
1505 // The CRC the re-citation carries: the backend's attestation
1506 // when it offers one. A replacement pod has no baseline to
1507 // ask, so on a backend that attests nothing (Ozone; a HITL
1508 // upload sent without one) the bytes are the only source —
1509 // the one recovery step that moves data, and only there.
1510 let crc = match meta.crc64_b64.clone() {
1511 Some(c) => c,
1512 None => {
1513 let (_, body) = self.store.get_version(&key, &live.version_id).await?;
1514 crc64_to_b64(crc64_nvme(&body))
1515 }
1516 };
1517 let stamps = GenerationStamps::from_meta(&meta.meta);
1518 let posix = stamps.as_ref().and_then(|s| s.posix);
1519 upserts.insert(
1520 path.clone(),
1521 LeanEntry {
1522 key: key.clone(),
1523 etag: meta.etag.clone(),
1524 crc64_b64: crc,
1525 size: meta.size,
1526 mode: posix
1527 .map(|p| p.mode)
1528 .or_else(|| cited.as_ref().map(|e| e.mode))
1529 .unwrap_or(0o100_644),
1530 mtime_unix: posix
1531 .map(|p| p.mtime_unix)
1532 .or_else(|| meta.last_modified_unix.map(|u| u as i64))
1533 .unwrap_or(0),
1534 generation: stamps
1535 .as_ref()
1536 .map(|s| s.generation)
1537 .or_else(|| cited.as_ref().map(|e| e.generation + 1))
1538 .unwrap_or(1),
1539 epoch: stamps.as_ref().map(|s| s.epoch).unwrap_or(epoch),
1540 version_id: Some(live.version_id.clone()),
1541 },
1542 );
1543 self.state.append_conflict(&ConflictRecord {
1544 path: path.clone(),
1545 foreign_etag: cited.as_ref().map(|e| e.etag.clone()).unwrap_or_default(),
1546 preserved_key: None,
1547 kind: if dangles {
1548 "recovered-staged (dangling citation rolled forward)".into()
1549 } else {
1550 "recovered-staged".to_string()
1551 },
1552 at_unix: now_unix(),
1553 })?;
1554 report.recited.push(path.clone());
1555 }
1556
1557 if upserts.is_empty() {
1558 report.no_change = true;
1559 report.seq = loaded.map(|l| l.manifest.seq);
1560 return Ok(report);
1561 }
1562
1563 // The upserts are ABSOLUTE — derived from bucket versions, not
1564 // from a diff — so a lost CAS re-applies them onto whatever is
1565 // current without recomputing anything.
1566 let flush_uuid = uuid::Uuid::new_v4().to_string();
1567 let mut fresh = loaded;
1568 for attempt in 0..4u32 {
1569 let (mut m, expected) = match &fresh {
1570 Some(l) => (l.manifest.clone(), Some(l.handle())),
1571 None => (manifest::LeanManifest::default(), None),
1572 };
1573 for (p, e) in &upserts {
1574 m.entries.insert(p.clone(), e.clone());
1575 }
1576 m.seq += 1;
1577 m.pinned_reads = self.is_gated();
1578 match manifest::cas_write_stamped(
1579 self.store.as_ref(),
1580 &self.cfg,
1581 &m,
1582 expected.as_ref(),
1583 epoch,
1584 &flush_uuid,
1585 Some(CitationSource::Recovered.as_str()),
1586 )
1587 .await
1588 {
1589 Ok(_) => {
1590 report.seq = Some(m.seq);
1591 self.note_boundary(CitationSource::Recovered.as_str(), m.seq)?;
1592 return Ok(report);
1593 }
1594 Err(LeanError::Store(StoreError::PreconditionFailed(_)))
1595 | Err(LeanError::Store(StoreError::Conflict(_))) if attempt < 3 => {
1596 self.verify_not_deposed_pub().await?;
1597 fresh = manifest::load(self.store.as_ref(), &self.cfg).await?;
1598 }
1599 Err(e) => return Err(e),
1600 }
1601 }
1602 Err(LeanError::State(
1603 "recover-staged lost 4 CAS races — a live writer is still publishing".into(),
1604 ))
1605 }
1606
1607 /// Is this workspace gated?
1608 pub fn is_gated(&self) -> bool {
1609 self.cfg.boundary_mode == BoundaryMode::Gated
1610 }
1611
1612 /// The versioning conformance probe (D8) — the SHARED
1613 /// implementation in `flint_store::probe`, because the operator
1614 /// runs the same probe on its reconcile cadence and the two
1615 /// verdicts must never disagree. A workspace the operator calls
1616 /// conformant and the syncer refuses to start is worse than
1617 /// either answer alone.
1618 ///
1619 /// A project-scoped proxy that strips `x-amz-version-id` degrades
1620 /// gated mode SILENTLY — pending entries would carry `None`, and
1621 /// citation would fall back to etag semantics on a key whose
1622 /// current version is uncited, which is precisely the torn view the
1623 /// mode exists to prevent. So the probe REFUSES rather than
1624 /// degrading, and is re-run at startup and on the operator cadence:
1625 /// proxies upgrade, and a bucket's posture changes under you.
1626 pub async fn versioning_conformance(&self) -> LeanResult<()> {
1627 let key = format!("{}/{}/probe/versioning", self.cfg.prefix, super::LEAN_DIR);
1628 flint_store::probe::probe_version_surface(self.store.as_ref(), &key)
1629 .await
1630 .map_err(|m| LeanError::State(format!("versioning conformance probe FAILED: {m}")))
1631 }
1632}
1633
1634/// A stable fingerprint of a scan: quiescence is scan-to-scan
1635/// STABILITY, so what matters is that this value stops changing.
1636fn scan_fingerprint(scanned: &BTreeMap<String, scan::ScanEntry>) -> String {
1637 let mut h = flint_store::Crc64Nvme::new();
1638 for (p, e) in scanned {
1639 h.update(p.as_bytes());
1640 h.update(&e.size.to_be_bytes());
1641 h.update(&e.mtime_unix.to_be_bytes());
1642 }
1643 format!("{:016x}", h.finalize())
1644}