Skip to main content

flint_lean/
lease.rs

1//! The subtree lease, held for ONE BARRIER's commit section (design
2//! 2026-09-13, `docs/plans/flint-lean-writer-lease-and-gated-assessment.md`
3//! §4), with a FIFO ticket so W writers wait at most W barriers.
4//!
5//! What the cell arbitrates is who may INSTALL a manifest. Nothing
6//! else: checkout, sync, status and the uploads of a barrier hold
7//! nothing, because every one of those is guarded by the object's own
8//! etag (`If-Match`) and installs no citation. Between barriers nobody
9//! holds the cell, so a second writer on the same workspace is Ready in
10//! checkout time and publishes every floor — the life-long lease this
11//! replaces made it wait the first writer's LIFETIME in
12//! ContainerCreating (`verbs.rs` used to call it "a DEADLOCK dressed as
13//! mutual exclusion", and had already taken checkout out of it).
14//!
15//! Deliberately NOT `tier::epoch::claim`: that path (a) runs the
16//! bucket-wide MPU takeover sweep, which a project-scoped proxy denies
17//! (the sweep is an operator-side job in lean — plan §2.4), and (b)
18//! gates self-recognition on the hub's state-directory occupancy lock.
19//! Lean's self-recognition token is the PERSISTED INCARNATION ID: it is
20//! emptyDir-scoped, so only the same pod's restarted container inherits
21//! it — which is exactly the one case where immediate self-supersede is
22//! safe. A replacement pod gets a fresh id and must wait out the quiet
23//! polls; that observation ({last_token, quiet_polls}) persists so a
24//! container restart RESUMES it instead of resetting the clock.
25//!
26//! The claim step is one read plus at most one CAS. Its verdicts:
27//!
28//! - a FRESH cell: acquire.
29//! - a cell that names THIS incarnation at THIS epoch: our own acquire
30//!   whose response was lost — adopt it.
31//! - a RELEASED cell reserved for nobody, or for us: acquire (a clean
32//!   handoff; no manifest rotation).
33//! - a RELEASED cell reserved for someone else: queue up once, and wait
34//!   — unless the reservation has stood unclaimed across
35//!   `HANDOFF_QUIET_POLLS` spaced observations (the reserved holder
36//!   died), in which case take it.
37//! - a cell HELD by someone else (or by us at an epoch we never
38//!   recorded: an orphaned acquire): queue up once, and wait — unless it
39//!   has stood still across `QUIET_POLLS` spaced observations (the
40//!   holder died inside its commit section), in which case DEPOSE it and
41//!   rotate the manifest, exactly the straggler fence there always was.
42//!
43//! An observation is "spaced" when at least `QUIET_SPACING_SECS` passed
44//! since the previous counted one. The wait loop polls faster than
45//! that (`CLAIM_POLL_SECS`), so a release is seen within a second, but
46//! deadness is still judged over a minute of a token that does not move.
47//! A waiter's own enqueue moves the token; the holder's next renew or
48//! handoff 412s, re-reads, and adopts the longer queue (`renew`,
49//! `release`).
50
51use std::sync::Arc;
52use std::time::{Duration, Instant};
53
54use flint_store::{EpochLease, EpochState, ObjectStore, StoreError};
55
56use super::state::Incarnation;
57use super::{manifest, LeanError, LeanResult, Syncer};
58
59/// Spaced quiet observations before a HELD cell's holder is judged dead
60/// and deposed (6 x 10 s: the takeover threshold the drills were run at).
61pub const QUIET_POLLS: u32 = 6;
62/// Spaced quiet observations before a RELEASED cell's reservation is
63/// judged abandoned (the reserved waiter died before claiming).
64pub const HANDOFF_QUIET_POLLS: u32 = 2;
65/// Minimum spacing between two observations that COUNT toward either
66/// threshold. Polls in between only look for a release or a handoff.
67pub const QUIET_SPACING_SECS: u64 = 10;
68/// The wait loop's poll cadence: one GET of a few-hundred-byte cell.
69pub const CLAIM_POLL_SECS: u64 = 1;
70/// The poll cadence of the waiter at the HEAD of the queue: it is the one
71/// the holder's handoff reserves the cell for, and every millisecond it
72/// takes to notice is a millisecond the cell stands idle with the rest of
73/// the queue behind it. In the writers drill (6 writers, 1 s polls) the
74/// cell sat released a median 614 ms before the next holder claimed it,
75/// on top of a 973 ms hold. Deadness is judged on `QUIET_SPACING_SECS`,
76/// not on this, so the thresholds do not move.
77pub const CLAIM_POLL_HEAD_MS: u64 = 200;
78/// Longest a barrier waits for the cell before failing (and being
79/// retried at the next floor). Comfortably past the deposal threshold
80/// plus the spacing it needs, so a dead holder is always deposed within
81/// one wait; a LIVE holder that never releases is a bug, and the
82/// deadline is what keeps it from being a hang.
83pub const CLAIM_DEADLINE_SECS: u64 = 150;
84
85pub enum ClaimOutcome {
86    /// Fresh, released-for-us, deposed, or adopted: held.
87    Claimed(EpochLease),
88    /// Not ours yet. `quiet_polls` is the spaced-observation count
89    /// against the current verdict's threshold; `behind` names the
90    /// holder or the reserved waiter we are waiting on; `next` says this
91    /// waiter heads the queue behind a held cell, so the holder's handoff
92    /// will name it.
93    Waiting { quiet_polls: u32, behind: Option<String>, next: bool },
94}
95
96/// Say so, loudly, if another product also writes this prefix.
97///
98/// Detection, deliberately NOT enforcement — see the forge twin. A
99/// lean workspace and a forge repository on one prefix arbitrate on
100/// different cells and will never fence each other, so without this
101/// nothing anywhere reports the condition.
102///
103/// Note what is NOT a finding: forge's legible export IS a lean
104/// workspace, published by forge rather than by an agent's syncer, so
105/// a syncer reading an export prefix finds only its own kind of cell.
106/// And an export nested under a repository's prefix puts this cell at
107/// `<prefix>/inner/.flint/lean/epoch`, which is not the key forge
108/// probes — the exact-key probe is what keeps that from reading as a
109/// collision.
110pub async fn warn_if_prefix_is_shared(sc: &Syncer) {
111    // A published mirror does not probe. Two reasons, and the second is
112    // the load-bearing one:
113    //
114    // Forge spawns a barrier per export, so this would be a recurring
115    // read for a condition that cannot change between two barriers of
116    // the same publisher. And forge echoes only the child lines
117    // containing "barrier" (export.rs:389), so a warning raised here
118    // would be read by nobody — a check whose output is discarded is
119    // worse than no check, because it looks like coverage.
120    //
121    // The prefix is still probed, ONCE, by the publisher at startup:
122    // see `warn_if_export_prefix_is_shared` in forge's twin. Moved, not
123    // dropped — an export prefix is a prefix two products both write
124    // legitimately, which makes it likelier than most to have a third
125    // pointed at it.
126    if sc.cfg.sole_writer {
127        return;
128    }
129    let found = flint_store::layout::neighbours(
130        sc.store.as_ref(),
131        &sc.cfg.prefix,
132        flint_store::layout::Writer::LeanWorkspace,
133    )
134    .await
135    .unwrap_or_default();
136    for f in found {
137        eprintln!("flint-sync: {}", f.report());
138    }
139}
140
141/// This pod's identity, minted once and persisted in the state
142/// directory (emptyDir-scoped: a restarted container inherits it, a
143/// replacement pod does not).
144pub fn incarnation(sc: &Syncer) -> LeanResult<Incarnation> {
145    match sc.state.load_incarnation()? {
146        Some(i) => Ok(i),
147        None => {
148            let i = Incarnation {
149                holder_id: format!("lean-{}", uuid::Uuid::new_v4()),
150                epoch: 0,
151                last_token: None,
152                quiet_polls: 0,
153            };
154            sc.state.save_incarnation(&i)?;
155            Ok(i)
156        }
157    }
158}
159
160/// One claim step. The caller loops on `Waiting` at its poll cadence;
161/// each call performs one read and at most one CAS (an acquire, or an
162/// enqueue). `count` says whether this observation is spaced far
163/// enough from the last counted one to advance the quiet thresholds —
164/// the loop decides that from its own clock, so the judgement of
165/// deadness stays tied to wall time however fast the loop polls.
166pub async fn claim_step(sc: &mut Syncer, count: bool) -> LeanResult<ClaimOutcome> {
167    let store: &Arc<dyn ObjectStore> = &sc.store;
168    let key = sc.cfg.epoch_key();
169    let mut inc = incarnation(sc)?;
170
171    let observed = store.epoch_read(&key).await?;
172    let Some(state) = observed else {
173        return match store.epoch_acquire(&key, &inc.holder_id, None).await {
174            Ok(lease) => {
175                inc.epoch = lease.epoch;
176                inc.last_token = None;
177                inc.quiet_polls = 0;
178                sc.state.save_incarnation(&inc)?;
179                sc.lease = Some(lease.clone());
180                sc.cell_written_at = Some(std::time::Instant::now());
181                sc.trace("claim", serde_json::json!({"verdict": "claimed", "how": "fresh", "epoch": lease.epoch}));
182                Ok(ClaimOutcome::Claimed(lease))
183            }
184            Err(StoreError::PreconditionFailed(_)) | Err(StoreError::Conflict(_)) => {
185                Ok(ClaimOutcome::Waiting { quiet_polls: 0, behind: None, next: false })
186            }
187            Err(e) => Err(e.into()),
188        };
189    };
190
191    // Self-recognition needs the EPOCH too (review 2026-09-12, lease-3 /
192    // audit #7): a cell naming this holder at an epoch this incarnation
193    // never recorded is our own acquire whose response was lost, or
194    // whose rotation failed after it landed — the straggler it deposed
195    // may still be mid-barrier, so that path takes the takeover
196    // rotation like any other.
197    let same_holder = state.holder_id == inc.holder_id;
198    let ours = same_holder && state.epoch == inc.epoch && !state.released;
199    let quiet = inc.last_token.as_deref() == Some(state.token.as_str());
200    let counted_quiet = if count {
201        if quiet {
202            inc.quiet_polls + 1
203        } else {
204            0
205        }
206    } else {
207        inc.quiet_polls
208    };
209
210    if ours {
211        // Our own acquire whose response was lost, or a cell a previous
212        // container of this pod is still named on at the epoch we
213        // recorded: adopt it IN PLACE — token and queue as they stand,
214        // no write. (Re-acquiring would move the epoch for nothing and
215        // make every reader of the cell count a barrier that never ran.)
216        let lease = EpochLease {
217            holder_id: state.holder_id,
218            epoch: state.epoch,
219            token: state.token,
220            waiters: state.waiters,
221        };
222        inc.last_token = None;
223        inc.quiet_polls = 0;
224        sc.state.save_incarnation(&inc)?;
225        sc.lease = Some(lease.clone());
226        // Adopted, not written: the commit section's first delete renews
227        // before it trusts the token (review 2026-09-18, C2 and L8).
228        sc.cell_written_at = None;
229        sc.trace("claim", serde_json::json!({"verdict": "claimed", "how": "adopted-own", "epoch": lease.epoch}));
230        return Ok(ClaimOutcome::Claimed(lease));
231    }
232
233    // The verdict. `take` = acquire now; `rotate` = the acquire is a
234    // deposal of a possibly-live straggler and must fence it.
235    let (take, rotate, behind) = if same_holder && !state.released {
236        // Orphaned own: our acquire landed and its response was lost, or
237        // its rotation failed after it landed. The straggler it deposed
238        // may still be mid-commit, so this takes the rotation too.
239        (true, true, None)
240    } else if state.released {
241        match state.handoff.as_deref() {
242            None => (true, false, None),
243            Some(h) if h == inc.holder_id => (true, false, None),
244            Some(h) => (counted_quiet >= HANDOFF_QUIET_POLLS, false, Some(h.to_string())),
245        }
246    } else {
247        // Held by someone else, or by us at an epoch we never recorded.
248        (counted_quiet >= QUIET_POLLS, true, Some(state.holder_id.clone()))
249    };
250
251    if take {
252        // Rotation is needed ONLY for the unreleased-foreign takeover (a
253        // possibly-live straggler mid-commit). A released cell is a
254        // clean handoff — the holder's barrier completed before its
255        // release — and self-recognition means the previous container's
256        // process (and any in-flight write of its) died with it.
257        // Rotating on those paths is pure manifest churn: at 100k+
258        // entries it is a multi-MB GET+PUT per claim, it double-bumps
259        // seq, and it defeats the no-change barrier's early exit
260        // (measured on the 0b rig). Under the per-barrier lease that
261        // would be every boundary.
262        let prior = serde_json::json!({"holder": state.holder_id, "epoch": state.epoch, "released": state.released,
263            "handoff": state.handoff, "waiters": state.waiters});
264        let how = if rotate && same_holder {
265            "orphaned-own"
266        } else if rotate {
267            "deposed"
268        } else if state.handoff.as_deref().map(|h| h != inc.holder_id).unwrap_or(false) {
269            "skipped-handoff"
270        } else {
271            "released"
272        };
273        return match store.epoch_acquire(&key, &inc.holder_id, Some(&state)).await {
274            Ok(lease) => {
275                if rotate {
276                    manifest::rotate_for_takeover(store.as_ref(), &sc.cfg, lease.epoch).await?;
277                }
278                inc.epoch = lease.epoch;
279                inc.last_token = None;
280                inc.quiet_polls = 0;
281                sc.state.save_incarnation(&inc)?;
282                sc.lease = Some(lease.clone());
283                sc.cell_written_at = Some(std::time::Instant::now());
284                sc.trace("claim", serde_json::json!({"verdict": "claimed", "how": how, "epoch": lease.epoch, "prior": prior}));
285                Ok(ClaimOutcome::Claimed(lease))
286            }
287            Err(StoreError::PreconditionFailed(_)) | Err(StoreError::Conflict(_)) => {
288                // Lost the race (a rival acquire, or an enqueue moved the
289                // token); restart the observation.
290                inc.last_token = None;
291                inc.quiet_polls = 0;
292                sc.state.save_incarnation(&inc)?;
293                Ok(ClaimOutcome::Waiting { quiet_polls: 0, behind, next: false })
294            }
295            Err(e) => Err(e.into()),
296        };
297    }
298
299    // Not ours yet: make sure we are in the queue, ONCE. The enqueue
300    // moves the token, so the observation restarts from the token we
301    // wrote — our own append must not read as the holder's renewal,
302    // and it must not reset a count that a rival's append did not.
303    let token = if state.waiters.iter().any(|w| w == &inc.holder_id) {
304        state.token.clone()
305    } else {
306        match store.epoch_enqueue(&key, &state, &inc.holder_id).await {
307            Ok(after) => after.token,
308            Err(StoreError::PreconditionFailed(_)) | Err(StoreError::Conflict(_)) => {
309                // Somebody else moved the cell first; observe again.
310                state.token.clone()
311            }
312            Err(e) => return Err(e.into()),
313        }
314    };
315    if count {
316        inc.quiet_polls = counted_quiet;
317        inc.last_token = Some(token);
318        sc.state.save_incarnation(&inc)?;
319    }
320    // First in the queue behind a HELD cell (our enqueue appended us to
321    // whatever `state.waiters` held, or we were already in it).
322    let next = !state.released
323        && state.waiters.iter().find(|w| **w != state.holder_id).map(|w| w == &inc.holder_id).unwrap_or(true);
324    Ok(ClaimOutcome::Waiting { quiet_polls: counted_quiet, behind, next })
325}
326
327/// The wait loop: claim the cell for a commit section, or give up
328/// after `CLAIM_DEADLINE_SECS` so a holder that never releases turns
329/// into a failed (and retried) barrier rather than a hang.
330pub async fn claim(sc: &mut Syncer) -> LeanResult<EpochLease> {
331    let started = Instant::now();
332    let mut last_counted: Option<Instant> = None;
333    let mut reported: Option<String> = None;
334    let spacing = Duration::from_secs(sc.cfg.claim_quiet_spacing_secs);
335    let deadline = Duration::from_secs(sc.cfg.claim_deadline_secs);
336    let poll = Duration::from_secs(sc.cfg.claim_poll_secs);
337    let head_poll = Duration::from_millis(sc.cfg.claim_poll_head_ms).min(poll);
338    loop {
339        let count = last_counted.map(|t| t.elapsed() >= spacing).unwrap_or(true);
340        match claim_step(sc, count).await? {
341            ClaimOutcome::Claimed(lease) => return Ok(lease),
342            ClaimOutcome::Waiting { quiet_polls, behind, next } => {
343                if count {
344                    last_counted = Some(Instant::now());
345                    sc.trace("claim", serde_json::json!({"verdict": "waiting", "behind": behind, "quiet_polls": quiet_polls,
346                        "waited_ms": started.elapsed().as_millis() as u64}));
347                    // Once per spaced observation, not once per second.
348                    let line = format!(
349                        "flint-sync: waiting for the publish fence behind {} (quiet {quiet_polls})",
350                        behind.as_deref().unwrap_or("a rival claim")
351                    );
352                    if reported.as_deref() != Some(line.as_str()) {
353                        eprintln!("{line}");
354                        reported = Some(line);
355                    }
356                }
357                if started.elapsed() >= deadline {
358                    sc.trace("claim", serde_json::json!({"verdict": "deadline", "behind": behind,
359                        "waited_ms": started.elapsed().as_millis() as u64}));
360                    return Err(LeanError::State(format!(
361                        "could not acquire the publish fence within {}s \
362                         (behind {}); this barrier is abandoned and retried at the next floor — \
363                         its uploads are durable and the next barrier adopts them",
364                        deadline.as_secs(),
365                        behind.as_deref().unwrap_or("a rival claim")
366                    )));
367                }
368                tokio::time::sleep(if next { head_poll } else { poll }).await;
369            }
370        }
371    }
372}
373
374/// What this syncer is OBSERVED to be doing, for the cell's echo
375/// (boundary-verbs plan §2.6). Computed from local files only — the
376/// same store-free discipline as `write_gauges`, and for the same
377/// reason: this rides writes that already happen, so it must not add a
378/// request to the one tick every idle workspace in the fleet pays (leg
379/// B8's oracle counts them).
380fn observed_echo(sc: &Syncer) -> Option<String> {
381    let g = sc.load_gauges().ok()?;
382    let (seq, unix) = g.last_boundary.as_ref().map(|b| (b.seq, b.unix)).unwrap_or((0, 0));
383    // The pre-flight verdict lives in the marker the AGENT reads; the
384    // operator has no other way to see it, so it rides the echo.
385    let caps: Option<super::control::Capabilities> =
386        std::fs::read(sc.cfg.control_dir().join(super::control::CAPABILITIES))
387            .ok()
388            .and_then(|b| serde_json::from_slice(&b).ok());
389    serde_json::to_string(&flint_store::LeaseEcho {
390        syncer_version: super::SYNCER_VERSION.to_string(),
391        protocol: super::SENTINEL_PROTOCOL,
392        last_cited_seq: seq,
393        last_cited_unix: unix,
394        sentinel_verbs_active: caps.map(|c| !c.verbs.is_empty()).unwrap_or(false),
395        metrics_bound: sc.load_metrics_posture().filter(|m| m.enabled).map(|m| m.bound),
396    })
397    .ok()
398}
399
400/// Re-read the cell after a 412 and, if it still names this holder at
401/// this epoch unreleased, hand back its current token and queue: the
402/// only things that move OUR cell's token are our own writes whose
403/// response was lost and the waiters' enqueues, and neither is a
404/// takeover. Anything else is the fence.
405async fn still_ours(sc: &Syncer, key: &str, lease: &EpochLease) -> Option<EpochLease> {
406    match sc.store.epoch_read(key).await {
407        Ok(Some(state))
408            if state.holder_id == lease.holder_id && state.epoch == lease.epoch && !state.released =>
409        {
410            Some(EpochLease {
411                holder_id: state.holder_id,
412                epoch: state.epoch,
413                token: state.token,
414                waiters: state.waiters,
415            })
416        }
417        _ => None,
418    }
419}
420
421/// Renew the held lease inside a commit section; a 412 that is not our
422/// own moved token means deposed — the caller must abandon the barrier
423/// (self-fence).
424///
425/// The renewal also carries the observed-state echo (§2.6).
426pub async fn renew(sc: &mut Syncer) -> LeanResult<()> {
427    let key = sc.cfg.epoch_key();
428    let lease = sc
429        .lease
430        .clone()
431        .ok_or_else(|| LeanError::State("renew without a lease".into()))?;
432    let echo = observed_echo(sc);
433    match sc.store.epoch_renew(&key, &lease, echo.as_deref()).await {
434        Ok(l) => {
435            sc.lease = Some(l);
436            sc.cell_written_at = Some(std::time::Instant::now());
437            // The renewal is a probe of our own credentials, so it is
438            // also a place a pause can be observed to have ENDED.
439            // Best-effort: a gauge that failed to write must not fail a
440            // renewal that succeeded.
441            let _ = sc.clear_auth_pause();
442            Ok(())
443        }
444        Err(StoreError::PreconditionFailed(e)) => {
445            // A 412 is not yet a deposal. The renew CAS is If-Match on
446            // OUR token, and three things move that token: a
447            // successor's acquire, a waiter's enqueue, or our own
448            // previous write whose RESPONSE was lost. Only the first is
449            // a takeover, and treating the others as one made a live
450            // holder fence itself (audit 2026-09-03, finding 2). One
451            // read tells the cases apart.
452            match still_ours(sc, &key, &lease).await {
453                Some(adopted) => {
454                    eprintln!(
455                        "flint-sync: renew 412 on a cell that is still ours (epoch {}, {} queued): \
456                         adopting its token, not fencing",
457                        adopted.epoch,
458                        adopted.waiters.len()
459                    );
460                    // Review 2026-09-12, lease-2: the adoption alone
461                    // writes nothing, so the token would stand still
462                    // for a whole takeover threshold and a waiter could
463                    // count a live holder dead. One renew moves it; if
464                    // that write fails the adopted token stands and the
465                    // next tick tries again.
466                    match sc.store.epoch_renew(&key, &adopted, echo.as_deref()).await {
467                        Ok(l) => {
468                            sc.lease = Some(l);
469                            sc.cell_written_at = Some(std::time::Instant::now());
470                        }
471                        Err(_) => sc.lease = Some(adopted),
472                    }
473                    let _ = sc.clear_auth_pause();
474                    Ok(())
475                }
476                None => {
477                    sc.lease = None;
478                    Err(LeanError::Fenced(format!("deposed at renew: {e}")))
479                }
480            }
481        }
482        // 401/403 is not contention and not a bucket fault; retrying
483        // cannot fix it. Record when the pause began while we still can:
484        // nothing we write to the STORE can carry this, because the
485        // request that would carry it is the one being refused (design
486        // §6.3).
487        Err(e @ StoreError::Auth(_)) => {
488            let _ = sc.note_auth_pause();
489            Err(e.into())
490        }
491        Err(e) => Err(e.into()),
492    }
493}
494
495/// Renew if the cell has not moved for half the takeover threshold
496/// (`QUIET_POLLS` spaced observations: 30 s of 60 at the shipped spacing),
497/// so a waiter never counts a live holder dead however long its commit
498/// section runs (review 2026-09-18, M3) — judged by THIS node's
499/// monotonic clock (`cell_written_at`, stamped at every claim and renew):
500/// a node clock behind the store's never mistakes a stale token for a
501/// fresh one (review 2026-09-18, M4 / lease-4). Only a deposal is an
502/// error; any other failure leaves the token where it was, the next call
503/// retries, and the section goes on — a renew is availability, and the
504/// fence before the CAS (`verify_not_deposed`) is what safety rests on.
505pub async fn renew_if_due(sc: &mut Syncer) -> LeanResult<()> {
506    if sc.lease.is_none() {
507        return Ok(());
508    }
509    let after = Duration::from_secs(QUIET_POLLS as u64 * sc.cfg.claim_quiet_spacing_secs / 2);
510    let due = sc.cell_written_at.map_or(true, |t| t.elapsed() >= after);
511    if !due {
512        return Ok(());
513    }
514    match renew(sc).await {
515        Ok(()) => {
516            sc.trace("renew", serde_json::json!({"epoch": sc.lease.as_ref().map(|l| l.epoch)}));
517            Ok(())
518        }
519        Err(e @ LeanError::Fenced(_)) => {
520            sc.trace("fence", serde_json::json!({"where": "renew", "detail": e.to_string()}));
521            Err(e)
522        }
523        Err(e) => {
524            eprintln!("flint-sync: renew inside the commit section failed ({e}); the next one retries");
525            Ok(())
526        }
527    }
528}
529
530/// The claim precondition (audit 2026-09-03, finding 5). With a project
531/// id stamped (`FLINT_SYNC_PROJECT_ID`), refuse to run over a prefix
532/// whose claim cell names ANOTHER project. The operator's refuse-foreign
533/// was advisory on the data plane: a CR the operator had not yet judged
534/// resolved to its spec, and the syncer checked out and republished over
535/// the foreign project's manifest. The cell is durable in the bucket, so
536/// the data plane can read it — one GET before the first claim step.
537/// Absent cell ⇒ a fresh prefix, claimable; unstamped ⇒ no check (the
538/// pre-operator posture, which the drill runs). A foreign claim is
539/// `LeanError::Refused`: the process exits `EXIT_REFUSED`, which the
540/// delivery treats as final (tear down, name the reason) rather than
541/// as one more crash to restart.
542pub async fn verify_claim(sc: &Syncer) -> LeanResult<()> {
543    let Some(mine) = sc.cfg.project_id.as_deref() else { return Ok(()) };
544    let key = sc.cfg.claim_key();
545    match sc.store.get_whole(&key, None).await {
546        Ok((_, body)) => {
547            let doc: serde_json::Value = serde_json::from_slice(&body)
548                .map_err(|e| LeanError::State(format!("claim cell {key} is unparseable: {e}")))?;
549            match doc.get("project_id").and_then(|v| v.as_str()) {
550                Some(p) if p == mine => Ok(()),
551                Some(p) => Err(LeanError::Refused(format!(
552                    "prefix {} is claimed by project {p:?}; this workspace is project {mine:?} — \
553                     refusing to check out or publish over another project's data (delete the \
554                     standing claim explicitly if this reuse is intended)",
555                    sc.cfg.prefix
556                ))),
557                None => Err(LeanError::State(format!("claim cell {key} names no project_id"))),
558            }
559        }
560        Err(StoreError::NotFound(_)) => Ok(()),
561        Err(e) => Err(e.into()),
562    }
563}
564
565/// Release at the end of a commit section: the cell is handed to the
566/// queue head and keeps this barrier's echo.
567///
568/// Every waiter's enqueue moves the token, so under contention the handoff
569/// can 412 several times in a row, and S3 answers a conditional write that
570/// races another with 409 ConditionalRequestConflict. Each is re-read: while
571/// the cell still names this holder at this epoch, unreleased, the handoff
572/// is retried on the adopted token. Only a cell that is no longer ours ends
573/// the attempt quietly. Returning with the cell still held — what a second
574/// 412 or a 409 used to do — leaves nobody to release it: the queue waits
575/// out the deposal threshold and the takeover rotates the manifest (the
576/// contention drill, 2026-09-14).
577pub async fn release(sc: &mut Syncer) -> LeanResult<()> {
578    let key = sc.cfg.epoch_key();
579    let Some(lease) = sc.lease.take() else { return Ok(()) };
580    let echo = observed_echo(sc);
581    sc.trace("release", serde_json::json!({"epoch": lease.epoch, "waiters_at_claim": lease.waiters}));
582    // `release` above is stamped before the request, `handed_off` after it
583    // lands: the gap between one holder's release and the next claim is
584    // the handoff's own latency plus the reserved waiter's poll delay.
585    let mut current = lease;
586    for attempt in 0..RELEASE_ATTEMPTS {
587        match sc.store.epoch_handoff(&key, &current, echo.as_deref()).await {
588            Ok(()) => {
589                sc.trace("handed_off", serde_json::json!({"epoch": current.epoch, "retried": attempt > 0, "attempts": attempt + 1}));
590                return Ok(());
591            }
592            Err(StoreError::PreconditionFailed(_)) | Err(StoreError::Conflict(_)) => {
593                if attempt > 0 {
594                    // Waiters enqueue in bursts; step out of the burst.
595                    tokio::time::sleep(Duration::from_millis(20 * attempt as u64)).await;
596                }
597                match sc.store.epoch_read(&key).await {
598                    Ok(Some(state))
599                        if state.holder_id == current.holder_id
600                            && state.epoch == current.epoch
601                            && !state.released =>
602                    {
603                        current = EpochLease {
604                            holder_id: state.holder_id,
605                            epoch: state.epoch,
606                            token: state.token,
607                            waiters: state.waiters,
608                        };
609                    }
610                    // Released, rotated or taken: the cell is the successor's.
611                    Ok(_) => return Ok(()),
612                    Err(e) if attempt + 1 < RELEASE_ATTEMPTS => {
613                        eprintln!("flint-sync: re-reading the publish fence for its handoff: {e}; retrying");
614                    }
615                    Err(e) => return Err(e.into()),
616                }
617            }
618            Err(e) => return Err(e.into()),
619        }
620    }
621    Err(LeanError::State(format!(
622        "the publish fence handoff lost {RELEASE_ATTEMPTS} races to the queue; the cell stays ours \
623         until this writer's next claim adopts it, or a waiter deposes it"
624    )))
625}
626
627/// Handoff attempts before a release gives up (each lost to a waiter's
628/// enqueue or a racing conditional write, re-read between them).
629pub const RELEASE_ATTEMPTS: u32 = 8;
630
631/// A restarted container that finds the cell HELD by its own
632/// incarnation releases it: it holds nothing in memory, the intent
633/// journal replays whatever the previous container left, and a cell
634/// left held would cost every other writer a 60 s deposal wait.
635pub async fn release_stale_own(sc: &mut Syncer) -> LeanResult<()> {
636    let key = sc.cfg.epoch_key();
637    let inc = incarnation(sc)?;
638    if let Some(state) = sc.store.epoch_read(&key).await? {
639        if state.holder_id == inc.holder_id && !state.released {
640            eprintln!(
641                "flint-sync: the publish fence was left held by a previous container of this pod \
642                 (epoch {}); releasing it",
643                state.epoch
644            );
645            // Review 2026-09-18, H2: an epoch this incarnation never
646            // recorded is an acquire whose container died before its
647            // takeover rotation — `claim_step`'s orphaned-own arm, which a
648            // restart never reaches. The holder it deposed may still be
649            // mid-commit, and neither this release nor the next claim of
650            // a released cell rotates, so its CAS on the unmoved pointer
651            // landed. Rotate first, as that arm does.
652            let rotated = state.epoch != inc.epoch;
653            if rotated {
654                manifest::rotate_for_takeover(sc.store.as_ref(), &sc.cfg, state.epoch).await?;
655            }
656            // The model's Restart: a new incarnation of this writer, which
657            // drops the barrier a previous one left in flight and frees the
658            // cell it held.
659            sc.trace("start", serde_json::json!({"held": true, "rotated": rotated}));
660            sc.lease = Some(EpochLease {
661                holder_id: state.holder_id,
662                epoch: state.epoch,
663                token: state.token,
664                waiters: state.waiters,
665            });
666            return release(sc).await;
667        }
668    }
669    sc.trace("start", serde_json::json!({"held": false, "rotated": false}));
670    Ok(())
671}
672
673// An observed cell, re-exported for the callers that fence on one.
674pub type Observed = EpochState;