Skip to main content

flint_lean/
inbox.rs

1//! The inbox/window cell (plan §2.2): ONE CAS document that is both the
2//! HITL inbox and the barrier-window token.
3//!
4//! - The gateway appends an entry per UI write (object first, then the
5//!   inbox CAS) — never a direct manifest edit.
6//! - The syncer CAS-marks the window open (with a deadline + its
7//!   epoch) at barrier intent time and clears it after the manifest
8//!   CAS; every gateway replica checks the cell before admitting a UI
9//!   write, which closes the stateless-two-replica race the review
10//!   proved.
11//! - A dead syncer cannot wedge HITL forever: the window carries a
12//!   deadline, and a successor epoch may override a stale window.
13
14use serde::{Deserialize, Serialize};
15
16use flint_store::{
17    crc64_nvme, GenerationStamps, ObjectStore, PutCondition, StoreError,
18};
19
20use super::{now_unix, LeanConfig, LeanError, LeanResult};
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct InboxEntry {
24    pub path: String,
25    /// The object ETag the write produced — what consume fetches
26    /// If-Match (a superseded entry is dropped, not an error).
27    pub etag: String,
28    /// Who wrote it (user identity from the gateway; audit surface).
29    pub author: String,
30    pub added_unix: u64,
31    /// CRC-64/NVME (wire form) of the bytes the write produced, computed
32    /// by the writer over what it sent — the gateway over the request
33    /// body, a merge-preserved entry from the manifest it came from.
34    /// Consume verifies the fetched bytes against it before they are
35    /// written, which is the only verification a backend that attests
36    /// no checksum (Ozone) gets. `None` when the writer had no bytes in
37    /// hand (a draft promote is a server-side copy).
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub crc64_b64: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct Window {
44    pub epoch: u64,
45    pub deadline_unix: u64,
46}
47
48/// A verb asked for through the gateway door (§2.5, D14). Idempotent
49/// STATE, not a queue: repeated sets before the syncer acts collapse
50/// to the newest, which is why neither field needs a rate limit, an
51/// exactly-once protocol, or a clearing CAS of its own.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct VerbRequest {
54    pub requested_unix: u64,
55    pub requestor: String,
56}
57
58/// A DECLARED removal (delete/rename design §3): a delete asked for from
59/// OUTSIDE the pod — a UI, a backend embedding the gateway crate. The
60/// caller cannot touch the tree and must never delete the object
61/// itself (§9: a cited object deleted from outside wedges every
62/// checkout), so it records INTENT here and the syncer performs it at
63/// its next barrier: unlink, then cite out, then GC — one manifest
64/// generation, and for a rename the same generation that cites the
65/// destination.
66///
67/// A FIELD of the cell and not a tombstone `InboxEntry`, for the reason
68/// `boundary_request` gives above: `consume_inbox` HEADs every entry's
69/// object and is not taught a second shape.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71pub struct Removal {
72    pub path: String,
73    pub author: String,
74    pub requested_unix: u64,
75    /// For a rename: where the bytes went. Audit trail and conflict
76    /// message; never load-bearing for correctness.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub moved_to: Option<String>,
79    /// Filled in by the syncer when it REFUSED to perform this removal
80    /// — the path had unpublished local edits, was not a file, or
81    /// could not be contained. A refused removal is never retried: it
82    /// stays here, so the human who asked can read why from any
83    /// replica, until a newer removal of the path supersedes it or the
84    /// caller withdraws it. Bounded by `REFUSED_REMOVALS_CAP`.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub refused: Option<Refusal>,
87}
88
89/// Why the syncer did not perform a removal.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct Refusal {
92    /// `removal-refused-dirty` | `removal-refused-not-a-file` |
93    /// `removal-refused-containment`.
94    pub kind: String,
95    pub message: String,
96    pub at_unix: u64,
97}
98
99/// Refused removals kept per cell before the oldest is evicted.
100pub const REFUSED_REMOVALS_CAP: usize = 100;
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct InboxDoc {
104    pub entries: Vec<InboxEntry>,
105    pub window: Option<Window>,
106    /// "Please publish" from outside the pod (§2.5). Deliberately a
107    /// FIELD and not a fake no-object `InboxEntry`: `consume_inbox`
108    /// HEADs `file_key(path)` for every entry, so an entry naming no
109    /// object lands in the NotFound arm as a spurious
110    /// `consume-object-missing` conflict — and special-casing the
111    /// single most safety-critical function in the crate to avoid that
112    /// is worse than either.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub boundary_request: Option<VerbRequest>,
115    /// "Please pull" from outside the pod — CARRIED, never performed
116    /// (D14). A boundary publishes what is already on disk and touches
117    /// no local file; `sync` re-derives the tree against the current
118    /// remote manifest and DELETES local files for remotely-deleted
119    /// paths. Performing that on a remote's say-so would upgrade what a
120    /// leaked gateway bearer can do from "publish, plus hand over these
121    /// N named objects" to "rewrite and delete across a running agent's
122    /// tree, at my timing, under a scope I choose".
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub sync_request: Option<VerbRequest>,
125    /// DECLARED removals, pending or refused (see `Removal`). At most
126    /// one per path: a newer removal of a path supersedes an older one.
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub removals: Vec<Removal>,
129}
130
131impl InboxDoc {
132    /// A removal of `path` that is recorded and not yet performed nor
133    /// refused — what a listing built over the manifest and the inbox
134    /// subtracts, so a deleted or renamed-away file leaves the UI the
135    /// moment the removal is recorded rather than when the syncer gets
136    /// to it.
137    pub fn pending_removal(&self, path: &str) -> Option<&Removal> {
138        self.removals.iter().find(|r| r.path == path && r.refused.is_none())
139    }
140}
141
142pub struct LoadedInbox {
143    pub doc: InboxDoc,
144    /// None ⇒ the cell does not exist yet (first CAS is If-None-Match:*).
145    pub etag: Option<String>,
146}
147
148pub async fn load(store: &dyn ObjectStore, cfg: &LeanConfig) -> LeanResult<LoadedInbox> {
149    match store.get_whole(&cfg.inbox_key(), None).await {
150        Ok((meta, bytes)) => {
151            let doc = serde_json::from_slice(&bytes)
152                .map_err(|e| LeanError::State(format!("inbox parse: {e}")))?;
153            Ok(LoadedInbox { doc, etag: Some(meta.etag) })
154        }
155        Err(StoreError::NotFound(_)) => Ok(LoadedInbox { doc: InboxDoc::default(), etag: None }),
156        Err(e) => Err(e.into()),
157    }
158}
159
160pub async fn cas_write(
161    store: &dyn ObjectStore,
162    cfg: &LeanConfig,
163    doc: &InboxDoc,
164    expected: Option<&str>,
165    epoch: u64,
166) -> LeanResult<String> {
167    let bytes = serde_json::to_vec_pretty(doc)
168        .map_err(|e| LeanError::State(format!("inbox: {e}")))?;
169    let crc = crc64_nvme(&bytes);
170    let cond = match expected {
171        Some(etag) => PutCondition::IfMatch(etag.to_string()),
172        None => PutCondition::IfNoneMatchAny,
173    };
174    let stamps = GenerationStamps {
175        generation: 0,
176        epoch,
177        flush_uuid: "inbox".into(),
178        boundary_source: None,
179        posix: None,
180    };
181    let meta = store.put_whole(&cfg.inbox_key(), bytes.into(), &cond, &stamps, crc).await?;
182    Ok(meta.etag)
183}
184
185/// Whether a gateway may admit a UI write right now. A window past its
186/// deadline does not block (the dead-syncer unwedge).
187pub fn admits_hitl(doc: &InboxDoc) -> bool {
188    match &doc.window {
189        None => true,
190        Some(w) => now_unix() > w.deadline_unix,
191    }
192}
193
194/// The GATEWAY side: land a UI write. The object PUT must already have
195/// happened (object first, inbox second — a crash between leaves an
196/// orphan object, never a tracked-but-absent entry). Refuses while a
197/// live barrier window is open. CAS-retries the append.
198pub async fn gateway_append(
199    store: &dyn ObjectStore,
200    cfg: &LeanConfig,
201    entry: InboxEntry,
202) -> LeanResult<()> {
203    for _ in 0..5 {
204        let loaded = load(store, cfg).await?;
205        if !admits_hitl(&loaded.doc) {
206            return Err(LeanError::State(
207                "barrier window open — retry after the window deadline".into(),
208            ));
209        }
210        let mut doc = loaded.doc;
211        // A newer write to the same path supersedes the queued one.
212        doc.entries.retain(|e| e.path != entry.path);
213        doc.entries.push(entry.clone());
214        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
215            Ok(_) => return Ok(()),
216            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
217            Err(e) => return Err(e),
218        }
219    }
220    Err(LeanError::State("inbox append lost 5 CAS races".into()))
221}
222
223/// One removal per path: a newer one supersedes an older one, pending
224/// or refused — the older is that caller's own earlier intent, which
225/// the newer replaces (the rule `gateway_append` applies to entries).
226fn record_removal(doc: &mut InboxDoc, r: Removal) {
227    doc.removals.retain(|x| x.path != r.path);
228    doc.removals.push(r);
229}
230
231/// The GATEWAY side: record DECLARED removals (delete/rename design
232/// §3), all in ONE CAS. The caller must have verified the paths exist
233/// (cited or tracked) and must never delete the objects itself.
234///
235/// NOT window-gated, deliberately (§12): a write races the barrier that
236/// is about to publish the tree, but a removal touches no object and no
237/// path at the moment it is recorded — the same argument that left
238/// `gateway_request` ungated. A removal recorded while a window is open
239/// is simply performed by the NEXT barrier.
240pub async fn gateway_remove(
241    store: &dyn ObjectStore,
242    cfg: &LeanConfig,
243    removals: Vec<Removal>,
244) -> LeanResult<()> {
245    for _ in 0..5 {
246        let loaded = load(store, cfg).await?;
247        let mut doc = loaded.doc;
248        for r in &removals {
249            record_removal(&mut doc, r.clone());
250        }
251        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
252            Ok(_) => return Ok(()),
253            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
254            Err(e) => return Err(e),
255        }
256    }
257    Err(LeanError::State("inbox removal lost 5 CAS races".into()))
258}
259
260/// The GATEWAY side of a RENAME (§6): the destination entries and the
261/// source removals land in ONE CAS, so the cell never holds half a
262/// rename. The destination objects must already have been copied
263/// (create first, removal second — §5). Window-gated like
264/// `gateway_append`, because it carries entries.
265pub async fn gateway_rename(
266    store: &dyn ObjectStore,
267    cfg: &LeanConfig,
268    entries: Vec<InboxEntry>,
269    removals: Vec<Removal>,
270) -> LeanResult<()> {
271    for _ in 0..5 {
272        let loaded = load(store, cfg).await?;
273        if !admits_hitl(&loaded.doc) {
274            return Err(LeanError::State(
275                "barrier window open — retry after the window deadline".into(),
276            ));
277        }
278        let mut doc = loaded.doc;
279        for e in &entries {
280            doc.entries.retain(|x| x.path != e.path);
281            doc.entries.push(e.clone());
282        }
283        for r in &removals {
284            record_removal(&mut doc, r.clone());
285        }
286        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
287            Ok(_) => return Ok(()),
288            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
289            Err(e) => return Err(e),
290        }
291    }
292    Err(LeanError::State("inbox rename lost 5 CAS races".into()))
293}
294
295/// The GATEWAY side: take back a removal of `path`, pending or refused.
296/// `Ok(false)` when there was none. Best effort against a barrier that
297/// is already performing it: the cell says whether the record was
298/// still there, the listing says what happened.
299pub async fn withdraw_removal(
300    store: &dyn ObjectStore,
301    cfg: &LeanConfig,
302    path: &str,
303) -> LeanResult<bool> {
304    for _ in 0..5 {
305        let loaded = load(store, cfg).await?;
306        let mut doc = loaded.doc;
307        let before = doc.removals.len();
308        doc.removals.retain(|r| r.path != path);
309        if doc.removals.len() == before {
310            return Ok(false);
311        }
312        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
313            Ok(_) => return Ok(true),
314            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
315            Err(e) => return Err(e),
316        }
317    }
318    Err(LeanError::State("inbox withdraw lost 5 CAS races".into()))
319}
320
321/// The SYNCER side: what became of the removals a pass looked at.
322/// `applied` are dropped from the cell; `refused` are annotated in
323/// place (their `refused` field is the answer), never retried, and
324/// capped at `REFUSED_REMOVALS_CAP` with the oldest evicted. A removal
325/// that was superseded meanwhile (same path, newer `requested_unix`)
326/// is left alone — the newer intent gets its own pass.
327pub async fn settle_removals(
328    store: &dyn ObjectStore,
329    cfg: &LeanConfig,
330    epoch: u64,
331    applied: &[Removal],
332    refused: &[Removal],
333) -> LeanResult<()> {
334    if applied.is_empty() && refused.is_empty() {
335        return Ok(());
336    }
337    let same = |a: &Removal, b: &Removal| a.path == b.path && a.requested_unix == b.requested_unix;
338    for _ in 0..5 {
339        let loaded = load(store, cfg).await?;
340        let mut doc = loaded.doc.clone();
341        let mut changed = false;
342        doc.removals.retain(|r| {
343            let drop = applied.iter().any(|a| same(a, r));
344            changed |= drop;
345            !drop
346        });
347        for r in doc.removals.iter_mut() {
348            if r.refused.is_none() {
349                if let Some(f) = refused.iter().find(|f| same(f, r)) {
350                    r.refused = f.refused.clone();
351                    changed = true;
352                }
353            }
354        }
355        loop {
356            let refused_now = doc.removals.iter().filter(|r| r.refused.is_some()).count();
357            if refused_now <= REFUSED_REMOVALS_CAP {
358                break;
359            }
360            let oldest = doc
361                .removals
362                .iter()
363                .enumerate()
364                .filter(|(_, r)| r.refused.is_some())
365                .min_by_key(|(_, r)| r.refused.as_ref().map(|f| f.at_unix).unwrap_or(0))
366                .map(|(i, _)| i);
367            match oldest {
368                Some(i) => {
369                    doc.removals.remove(i);
370                    changed = true;
371                }
372                None => break,
373            }
374        }
375        if !changed {
376            return Ok(());
377        }
378        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
379            Ok(_) => return Ok(()),
380            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
381            Err(e) => return Err(e),
382        }
383    }
384    Err(LeanError::State("inbox removal settle lost 5 CAS races".into()))
385}
386
387/// Which verb a gateway request is asking for.
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum RequestedVerb {
390    Boundary,
391    Sync,
392}
393
394/// The GATEWAY side of §2.5: set one of the two request fields under
395/// the same CAS discipline every other inbox write uses.
396///
397/// Deliberately NOT window-gated. `admits_hitl` exists because a HITL
398/// object write races the barrier that is about to publish the tree; a
399/// verb request touches no object and no path — refusing it during a
400/// window would make "please publish" fail precisely while a publish is
401/// in flight, which is the least useful moment to say no.
402pub async fn gateway_request(
403    store: &dyn ObjectStore,
404    cfg: &LeanConfig,
405    verb: RequestedVerb,
406    requestor: &str,
407) -> LeanResult<VerbRequest> {
408    let req = VerbRequest {
409        requested_unix: now_unix(),
410        requestor: requestor.chars().take(128).collect(),
411    };
412    for _ in 0..5 {
413        let loaded = load(store, cfg).await?;
414        let mut doc = loaded.doc;
415        // Newest wins: the field is state, so a burst collapses instead
416        // of queueing. This is what makes a rate limit unnecessary on
417        // the transport (the HONOR is still min-interval'd and budgeted
418        // like any other sentinel).
419        match verb {
420            RequestedVerb::Boundary => doc.boundary_request = Some(req.clone()),
421            RequestedVerb::Sync => doc.sync_request = Some(req.clone()),
422        }
423        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
424            Ok(_) => return Ok(req),
425            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
426            Err(e) => return Err(e),
427        }
428    }
429    Err(LeanError::State("inbox verb request lost 5 CAS races".into()))
430}
431
432/// The SYNCER side: open the barrier window (the intent). Succeeds
433/// over a closed cell, an expired window, or a LOWER epoch's stale
434/// window; refuses a live window at our own or a higher epoch.
435pub async fn open_window(
436    store: &dyn ObjectStore,
437    cfg: &LeanConfig,
438    epoch: u64,
439    deadline_unix: u64,
440) -> LeanResult<LoadedInbox> {
441    for _ in 0..5 {
442        let loaded = load(store, cfg).await?;
443        if let Some(w) = &loaded.doc.window {
444            let expired = now_unix() > w.deadline_unix;
445            if w.epoch > epoch {
446                return Err(LeanError::Fenced(format!(
447                    "window held by higher epoch {} (ours {})",
448                    w.epoch, epoch
449                )));
450            }
451            if w.epoch == epoch && !expired {
452                // Our own live window (a crashed earlier attempt inside
453                // the deadline): adopt it.
454                return Ok(loaded);
455            }
456        }
457        let mut doc = loaded.doc.clone();
458        doc.window = Some(Window { epoch, deadline_unix });
459        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
460            Ok(etag) => {
461                return Ok(LoadedInbox { doc, etag: Some(etag) });
462            }
463            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
464            Err(e) => return Err(e),
465        }
466    }
467    Err(LeanError::State("window open lost 5 CAS races".into()))
468}
469
470/// Drop integrated entries (after they are durably in the baseline).
471/// Entries that arrived after the consume are preserved.
472pub async fn drop_entries(
473    store: &dyn ObjectStore,
474    cfg: &LeanConfig,
475    epoch: u64,
476    consumed: &[InboxEntry],
477) -> LeanResult<()> {
478    for _ in 0..5 {
479        let loaded = load(store, cfg).await?;
480        let mut doc = loaded.doc.clone();
481        let before = doc.entries.len();
482        doc.entries.retain(|e| !consumed.contains(e));
483        if doc.entries.len() == before {
484            return Ok(());
485        }
486        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
487            Ok(_) => return Ok(()),
488            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
489            Err(e) => return Err(e),
490        }
491    }
492    Err(LeanError::State("inbox drop lost 5 CAS races".into()))
493}
494
495/// How long an object the workspace does not track may sit at a key
496/// before a HITL write may overwrite it anyway, seconds. Past a claim
497/// deadline (150 s) and the next floor's retry, which adopts an abandoned
498/// barrier's uploads, with room to spare.
499pub const UNTRACKED_GRACE_SECS: u64 = 600;
500
501/// May a HITL write — the gateway, for a UI — overwrite the object now at
502/// `path` (etag `current`, last written `last_modified_unix`)?
503///
504/// Only a version the workspace TRACKS: the one the manifest cites, or
505/// one an inbox entry names. Any other object at the key is a writer's
506/// upload that its commit has not cited yet — uploads hold no lease, so
507/// there is no window to hold the gateway off. Overwriting one lost two
508/// writes at once (the model's `Inv_HITLDurable` in the two-writer
509/// sentinel world): the uploading writer's commit re-cites its own
510/// generation over the UI's write after another writer consumed and
511/// cited it, so the manifest names bytes that are gone and the UI's
512/// acked write is uncited, untracked and preserved nowhere. Refused, the
513/// UI retries in a moment and overwrites the version that commit cites,
514/// which every writer's consume already handles.
515///
516/// An untracked object is fair game once it has sat untracked past
517/// `UNTRACKED_GRACE_SECS` — a crashed writer's orphan upload, which no
518/// commit will cite. There used to be a second escape, "no writer has a
519/// live heartbeat"; the heartbeat is gone. Neither escape carries safety
520/// any more: the uploading writer's commit re-reads every citation it
521/// adds and withholds one whose object moved (the model's
522/// `LeanBarrierLeaseHitlOverAnyVerified`), so this rule is defense in
523/// depth and the grace only bounds how long an orphan answers 409.
524pub async fn hitl_may_overwrite(
525    store: &dyn ObjectStore,
526    cfg: &LeanConfig,
527    path: &str,
528    current: &str,
529    last_modified_unix: Option<u64>,
530    now: u64,
531) -> LeanResult<bool> {
532    let same = |a: &str, b: &str| a.trim_matches('"') == b.trim_matches('"');
533    if let Some(m) = super::manifest::load(store, cfg).await? {
534        if m.manifest.entries.get(path).map(|e| same(&e.etag, current)).unwrap_or(false) {
535            return Ok(true);
536        }
537    }
538    let ib = load(store, cfg).await?;
539    if ib.doc.entries.iter().any(|e| e.path == path && same(&e.etag, current)) {
540        return Ok(true);
541    }
542    Ok(last_modified_unix.map(|t| now.saturating_sub(t) > UNTRACKED_GRACE_SECS).unwrap_or(false))
543}
544
545/// Clear the window (after the manifest CAS) and, in the same CAS,
546/// queue `queued` entries (the merge-preserved foreign entries handed
547/// to the next consume). Entries that arrived mid-barrier are
548/// preserved.
549pub async fn clear_window(
550    store: &dyn ObjectStore,
551    cfg: &LeanConfig,
552    epoch: u64,
553    queued: &[InboxEntry],
554) -> LeanResult<()> {
555    clear_window_settling(store, cfg, epoch, queued, &[], &[]).await
556}
557
558/// `clear_window`, and in the SAME CAS drop what this barrier
559/// integrated: the entries it consumed and the DECLARED removals it
560/// performed. Both stay in the cell until here — after the manifest
561/// CAS — for two reasons that are one. A consumed entry's only other
562/// record is the pod's emptyDir baseline, which a pod REPLACEMENT
563/// takes with it; dropped at the window-open commitment, a HITL write
564/// acked and consumed but not yet cited had nothing in the bucket
565/// tracking it through the whole upload phase, and a successor's
566/// checkout was blind to it. And a listing that subtracts pending
567/// removals must keep hiding the path for exactly as long as the
568/// manifest cites it, not reappear it for the length of the uploads.
569/// A crash before this CAS leaves both in the cell; the next
570/// incarnation re-consumes and re-declares, idempotently (the consume
571/// skips an entry its baseline already holds, and a declared path
572/// already absent re-declares as it stands).
573pub async fn clear_window_settling(
574    store: &dyn ObjectStore,
575    cfg: &LeanConfig,
576    epoch: u64,
577    queued: &[InboxEntry],
578    consumed: &[InboxEntry],
579    applied_removals: &[Removal],
580) -> LeanResult<()> {
581    for _ in 0..5 {
582        let loaded = load(store, cfg).await?;
583        let mut doc = loaded.doc.clone();
584        if let Some(w) = &doc.window {
585            if w.epoch > epoch {
586                return Err(LeanError::Fenced(format!(
587                    "window rotated to higher epoch {} (ours {})",
588                    w.epoch, epoch
589                )));
590            }
591        }
592        doc.window = None;
593        doc.entries.retain(|e| !consumed.contains(e));
594        for q in queued {
595            if !doc.entries.iter().any(|e| e.path == q.path && e.etag == q.etag) {
596                doc.entries.push(q.clone());
597            }
598        }
599        doc.removals.retain(|r| {
600            !applied_removals
601                .iter()
602                .any(|a| a.path == r.path && a.requested_unix == r.requested_unix)
603        });
604        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
605            Ok(_) => return Ok(()),
606            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
607            Err(e) => return Err(e),
608        }
609    }
610    Err(LeanError::State("window clear lost 5 CAS races".into()))
611}