Skip to main content

flint_lean/
sentinel.rs

1//! The boundary verbs: `.flint/publish` and `.flint/sync` (plan §2.1,
2//! §2.2 — D1, D2, D3, D3.1, D12).
3//!
4//! The agent touches a file; the syncer consumes it, honors it with a
5//! real barrier (or a real sync), and answers with an ack. The
6//! discipline is `consume_inbox`'s, mirrored — exactly-once via
7//! integrate → persist → drop, idempotent between — with rename in
8//! place of CAS:
9//!
10//! 1. **Consume** — the sentinel is renamed out of the agent's reach
11//!    into the state dir. Type-checked first (`S_ISREG` only: a FIFO
12//!    would block the body read forever), body bounded at 64 KiB.
13//!    **Settle-before-consume:** the poll never consumes while a
14//!    pending record stands — a surviving pending must be honored,
15//!    acked and retired first, or the consume would clobber it and
16//!    orphan its nonces forever. Coalescing happens INSIDE the pending
17//!    record: touches arriving during the min-interval wait append
18//!    their nonces to the standing record.
19//! 2. **Honor** — run one full fused barrier (which claims the publish
20//!    fence for its commit section only) or one sync.
21//! 3. **Ack** — written atomically AFTER the barrier's manifest CAS and
22//!    baseline rewrite, carrying the FULL covered-nonce set: under
23//!    coalescing an agent whose nonce rode behind a later touch would
24//!    otherwise never see it and would re-touch in a loop, feeding the
25//!    storm the rate limit exists to prevent.
26//! 4. **Retire** — the pending record is removed after the ack rename.
27//!
28//! **The uniform crash rule (D2, replacing the draft's per-crash-point
29//! matrix).** Pending-present-and-no-matching-ack is the SAME
30//! observable state for "crashed before the manifest CAS" and "crashed
31//! after step 7" — the baseline is rewritten only at step 7 — so acking
32//! from persisted state would assert publication of writes that never
33//! uploaded. On restart: pending + no matching ack ⇒ ALWAYS run a full
34//! barrier (idempotent; AdoptOwn/recent-uuids covers a half-published
35//! set), then ack with THAT barrier's installed seq. Pending + matching
36//! ack ⇒ retire only; the ack already names a real install.
37//!
38//! **A fence is a retry (design 2026-09-13 §4).** The lease is held per
39//! barrier, so a commit section deposed mid-way abandons THAT barrier:
40//! nothing was installed, the pending record stands, and the next tick
41//! honors it with a fresh claim. There is no refused-fenced ack and no
42//! fenced marker any more — the life-long lease they answered for is
43//! gone.
44
45use std::path::{Path, PathBuf};
46
47use serde::{Deserialize, Serialize};
48
49use super::control::{self, write_atomic};
50use super::state::ConflictRecord;
51use super::{now_unix, LeanError, LeanResult, Syncer};
52
53/// Bound on a sentinel body read. A larger file is truncated and the
54/// remainder ignored — never a wedge.
55const MAX_BODY: u64 = 64 * 1024;
56
57/// Bound on the covered-nonce set carried in one ack (oldest dropped).
58const MAX_NONCES: usize = 32;
59
60/// Bound on a scoped sync's entry list (§2.2 write containment).
61pub const MAX_SCOPE_ENTRIES: usize = 64;
62pub const MAX_SCOPE_ENTRY_LEN: usize = 1024;
63
64const BUDGET: &str = "sentinel-budget.json";
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Verb {
68    Publish,
69    Sync,
70}
71
72impl Verb {
73    pub fn as_str(&self) -> &'static str {
74        match self {
75            Verb::Publish => "publish",
76            Verb::Sync => "sync",
77        }
78    }
79    pub fn sentinel_name(&self) -> &'static str {
80        match self {
81            Verb::Publish => control::PUBLISH,
82            Verb::Sync => control::SYNC,
83        }
84    }
85    fn ack_name(&self) -> &'static str {
86        match self {
87            Verb::Publish => control::PUBLISH_ACK,
88            Verb::Sync => control::SYNC_ACK,
89        }
90    }
91    /// The standing pending record. `pub` because `flint-sync status`
92    /// reports on it and MUST NOT spell it a second time: it did, it
93    /// spelled it wrong ("pending-publish.json"), and the field that
94    /// answers "is my agent blocked on an ack?" therefore answered no
95    /// forever.
96    pub fn pending_name(&self) -> &'static str {
97        match self {
98            Verb::Publish => "publish.pending.json",
99            Verb::Sync => "sync.pending.json",
100        }
101    }
102    /// The consume staging name. A crash between the rename (which
103    /// removes the sentinel) and the pending write would otherwise lose
104    /// the touch: on restart the staging file is recovered into the
105    /// pending record instead.
106    fn staging_name(&self) -> &'static str {
107        match self {
108            Verb::Publish => "publish.consumed",
109            Verb::Sync => "sync.consumed",
110        }
111    }
112}
113
114/// The agent's optional JSON body.
115#[derive(Debug, Clone, Default, Deserialize)]
116struct SentinelBody {
117    #[serde(default)]
118    nonce: Option<String>,
119    #[serde(default)]
120    note: Option<String>,
121    /// `sync` only: path prefixes and exact paths (D4).
122    #[serde(default)]
123    scope: Option<Vec<String>>,
124}
125
126/// A consumed sentinel awaiting honor (`.flint-sync/<verb>.pending.json`).
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct PendingSentinel {
129    pub verb: String,
130    /// The latest covered touch, in nanoseconds — bare-touch agents
131    /// match their boundary on this.
132    pub consumed_mtime_unix_ns: u128,
133    pub consumed_at: u64,
134    /// EVERY coalesced nonce, oldest dropped past `MAX_NONCES`.
135    pub nonces: Vec<String>,
136    #[serde(default)]
137    pub note: Option<String>,
138    #[serde(default)]
139    pub scope: Option<Vec<String>>,
140    /// An unparsable or oversize body was honored as a bare touch — a
141    /// warning conflict record names it. Never a wedge, never a silent
142    /// drop.
143    #[serde(default)]
144    pub torn: bool,
145    /// A coalesced touch asked for the WHOLE tree (no `scope`); later
146    /// scoped touches must not narrow it (review 2026-09-12, inbox-7).
147    #[serde(default)]
148    pub whole_tree: bool,
149    /// Minted when the record is created and kept across every fold, so
150    /// the carrier (`IntentJournal::carrier`) of an install that carried
151    /// an EARLIER touch of this record still answers when a later touch
152    /// coalesces with it. Fresh on every fold, it orphaned that carrier:
153    /// a peer's delete of a path the first touch declared, between that
154    /// touch's install and its ack, was answered `ok` once a second touch
155    /// folded in (row 15 plan, D7, 2026-10-14). A retired record takes its
156    /// id with it, so a carrier left from it matches nothing. Empty on a
157    /// record written before the field: then nothing matches it and the
158    /// ack reads the honoring barrier's own report.
159    #[serde(default)]
160    pub id: String,
161}
162
163/// The ack document (`.flint/<verb>.ack`).
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct Ack {
166    /// "ok" | "partial" | "refused-scope" | "refused-read-only".
167    ///
168    /// `partial` is the honest answer when the boundary installed but a
169    /// path the agent declared is not in it (D1) —
170    /// `report.dropped` names them. An agent that treats it as failure
171    /// and re-touches is behaving correctly.
172    pub status: String,
173    pub nonces: Vec<String>,
174    pub sentinel_mtime_unix_ns: u128,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub seq: Option<u64>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub manifest_etag: Option<String>,
179    /// "sentinel" | "sentinel-deferred" | "drain".
180    pub boundary: String,
181    pub completed_unix: u64,
182    /// Set on a refusal (`refused-scope`, `refused-read-only`): what was
183    /// wrong with the request, or why it cannot be done here, in words.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub reason: Option<String>,
186    pub report: AckReport,
187}
188
189#[derive(Debug, Clone, Default, Serialize, Deserialize)]
190pub struct AckReport {
191    pub uploaded: usize,
192    pub deleted: usize,
193    pub parked: usize,
194    pub consumed: usize,
195    pub no_change: bool,
196    /// `sync` only: the applied/deleted/conflict transport (§2.2 — the
197    /// conflict report rides the ack in FULL: "never a silent winner"
198    /// must survive the file transport).
199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
200    pub applied: Vec<String>,
201    #[serde(default, skip_serializing_if = "Vec::is_empty")]
202    pub conflicts: Vec<ConflictRecord>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub scope: Option<Vec<String>>,
205    /// Foreign changes seen but left owed to the next consume (D4).
206    #[serde(default)]
207    pub out_of_scope_foreign: usize,
208    /// Declared paths the boundary does NOT carry (a standing park, a
209    /// delete another writer's edit outranked, or an upload that
210    /// published nothing).
211    /// Non-empty ⇒ `status: "partial"`: the §2.2 rule — the
212    /// conflict report rides the ack in full, never a silent loser —
213    /// generalized from `sync` to the publish verb.
214    #[serde(default, skip_serializing_if = "Vec::is_empty")]
215    pub dropped: Vec<String>,
216}
217
218/// The work meter (D3.1 — the hot-loops no-regression rule).
219///
220/// A counted budget charges a hot 2 GiB checkpoint the same one unit as
221/// a 4 KiB file, so a storming agent could drive `dirty_bytes × 60/hour`
222/// of extra upload while staying inside a green budget — a regression on
223/// the one performance sub-axis lean currently maxes (a tight local
224/// loop's republish is coalesced by cadence; sentinels un-coalesce it on
225/// demand). Metering work instead bounds sentinel-driven published bytes
226/// at `budget × whole_put_max` per hour independent of dirty-set size,
227/// and a workspace at the cap degrades to exactly today's cadence
228/// behavior — the definition of no regression.
229#[derive(Debug, Clone, Default, Serialize, Deserialize)]
230pub struct SentinelBudget {
231    /// (unix, units) charges inside the rolling hour.
232    pub charges: Vec<(u64, u64)>,
233    /// The last sentinel-honoring barrier, for the min-interval.
234    pub last_honor_unix: u64,
235}
236
237impl SentinelBudget {
238    fn prune(&mut self, now: u64) {
239        self.charges.retain(|(at, _)| now.saturating_sub(*at) < 3600);
240    }
241    pub fn spent(&self, now: u64) -> u64 {
242        self.charges
243            .iter()
244            .filter(|(at, _)| now.saturating_sub(*at) < 3600)
245            .map(|(_, u)| *u)
246            .sum()
247    }
248    pub fn remaining(&self, now: u64, budget: u64) -> u64 {
249        budget.saturating_sub(self.spent(now))
250    }
251}
252
253/// Why a standing pending sentinel is not being honored right now.
254/// The records an operation produced, given `conflicts_appended` taken
255/// before it, and the dropped count and held records after it.
256///
257/// Counting the HELD records is not enough (review 2026-09-12, ack-7): a
258/// second rotation drops a whole generation, so the held count can come
259/// back to where it was, and skipping that many reports NO conflicts for
260/// an operation that produced a generation's worth, or loses some of
261/// them. The appended total never goes down, so the difference is
262/// exactly what the operation produced; they are the newest records
263/// held, or all of them if a rotation took some. A total that went DOWN
264/// means the log was reset under us: every survivor is reported, since
265/// over-reporting is the safe direction (an ack naming a conflict that
266/// predates the operation costs a reader a second look; an ack naming
267/// none costs them the conflict).
268pub(crate) fn conflicts_since(
269    before: u64,
270    dropped: u64,
271    after: Vec<ConflictRecord>,
272) -> Vec<ConflictRecord> {
273    let total = dropped + after.len() as u64;
274    if total < before {
275        return after;
276    }
277    let new = (total - before) as usize;
278    if new >= after.len() {
279        after
280    } else {
281        after[after.len() - new..].to_vec()
282    }
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub enum Due {
287    /// Honor it on this tick.
288    Ready,
289    /// Inside `sentinel_min_interval_secs` of the last honor: wait, and
290    /// let further touches coalesce.
291    MinInterval,
292    /// The hourly budget is exhausted: the boundary is still honored,
293    /// by the next FLOOR tick, and its ack is stamped
294    /// `sentinel-deferred`. Contents are never thinned (D1's corollary)
295    /// — cost is bounded by deferring the barrier, never by publishing
296    /// less than the boundary covers.
297    BudgetDeferred,
298}
299
300fn mtime_ns(m: &std::fs::Metadata) -> u128 {
301    m.modified()
302        .ok()
303        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
304        .map(|d| d.as_nanos())
305        .unwrap_or(0)
306}
307
308/// Read the sentinel body, bounded, and WITHOUT ever blocking on the
309/// open.
310///
311/// The type check in `consume_sentinel` is an `lstat`, and this is a
312/// second path resolution — so on its own the check is a TOCTOU: swap a
313/// FIFO in between and a plain `File::open` blocks forever waiting for a
314/// writer, wedging the poll arm and, behind it, every boundary this
315/// syncer owes (review: U23).
316///
317/// `O_NONBLOCK` closes it at the syscall rather than by winning a race:
318/// opening a writer-less FIFO returns immediately instead of blocking,
319/// and `O_NOFOLLOW` refuses a symlink swapped in for the same purpose.
320/// Both are no-ops on the regular file this is supposed to be reading.
321pub(crate) fn read_bounded(path: &Path) -> std::io::Result<(Vec<u8>, bool)> {
322    use std::io::Read;
323    use std::os::unix::fs::OpenOptionsExt;
324    let f = std::fs::File::options()
325        .read(true)
326        .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
327        .open(path)?;
328    // A FIFO that a writer DOES hold open reads EAGAIN rather than
329    // blocking; treat it as an empty body, which the torn-body rule
330    // already handles as a bare touch.
331    let meta = f.metadata()?;
332    if !meta.is_file() {
333        return Err(std::io::Error::new(
334            std::io::ErrorKind::InvalidInput,
335            "sentinel is not a regular file",
336        ));
337    }
338    let mut buf = Vec::new();
339    let mut take = f.take(MAX_BODY + 1);
340    take.read_to_end(&mut buf)?;
341    let oversize = buf.len() as u64 > MAX_BODY;
342    buf.truncate(MAX_BODY as usize);
343    Ok((buf, oversize))
344}
345
346impl Syncer {
347    fn pending_path(&self, verb: Verb) -> PathBuf {
348        self.cfg.state_dir().join(verb.pending_name())
349    }
350    fn staging_path(&self, verb: Verb) -> PathBuf {
351        self.cfg.state_dir().join(verb.staging_name())
352    }
353    fn budget_path(&self) -> PathBuf {
354        self.cfg.state_dir().join(BUDGET)
355    }
356
357    pub fn load_pending(&self, verb: Verb) -> LeanResult<Option<PendingSentinel>> {
358        let p = self.pending_path(verb);
359        if !p.exists() {
360            return Ok(None);
361        }
362        let bytes = std::fs::read(&p)?;
363        match serde_json::from_slice(&bytes) {
364            Ok(v) => Ok(Some(v)),
365            // Our own record is torn (a crash mid-rewrite): honor it as
366            // a bare touch rather than wedging on it.
367            Err(_) => Ok(Some(PendingSentinel {
368                verb: verb.as_str().into(),
369                consumed_mtime_unix_ns: 0,
370                consumed_at: now_unix(),
371                nonces: vec![],
372                note: None,
373                scope: None,
374                torn: true,
375                whole_tree: false,
376                id: String::new(),
377            })),
378        }
379    }
380
381    fn save_pending(&self, verb: Verb, p: &PendingSentinel) -> LeanResult<()> {
382        let bytes = serde_json::to_vec_pretty(p)
383            .map_err(|e| LeanError::State(format!("pending: {e}")))?;
384        write_atomic(&self.pending_path(verb), &bytes)
385    }
386
387    fn retire_pending(&self, verb: Verb) -> LeanResult<()> {
388        let p = self.pending_path(verb);
389        if p.exists() {
390            std::fs::remove_file(&p)?;
391        }
392        Ok(())
393    }
394
395    pub fn load_budget(&self) -> LeanResult<SentinelBudget> {
396        let p = self.budget_path();
397        if !p.exists() {
398            return Ok(SentinelBudget::default());
399        }
400        let bytes = std::fs::read(&p)?;
401        Ok(serde_json::from_slice(&bytes).unwrap_or_default())
402    }
403
404    fn save_budget(&self, b: &SentinelBudget) -> LeanResult<()> {
405        let bytes =
406            serde_json::to_vec_pretty(b).map_err(|e| LeanError::State(format!("budget: {e}")))?;
407        write_atomic(&self.budget_path(), &bytes)
408    }
409
410    /// D3.1: charge the meter for a completed sentinel honor.
411    /// `published_bytes == 0` (a no-diff honor) costs NOTHING — the
412    /// budget exists to bound work and a no-diff honor does none; the
413    /// min-interval remains its only bound.
414    pub fn charge_budget(&self, published_bytes: u64) -> LeanResult<u64> {
415        let now = now_unix();
416        let mut b = self.load_budget()?;
417        b.prune(now);
418        let units = if published_bytes == 0 {
419            0
420        } else {
421            published_bytes.div_ceil(self.cfg.whole_put_max.max(1)).max(1)
422        };
423        if units > 0 {
424            b.charges.push((now, units));
425        }
426        b.last_honor_unix = now;
427        self.save_budget(&b)?;
428        Ok(units)
429    }
430
431    /// Whether a standing pending sentinel may be honored on this tick.
432    pub fn sentinel_due(&self) -> LeanResult<Due> {
433        let now = now_unix();
434        let b = self.load_budget()?;
435        if b.last_honor_unix > 0
436            && now.saturating_sub(b.last_honor_unix) < self.cfg.sentinel_min_interval_secs
437        {
438            return Ok(Due::MinInterval);
439        }
440        if b.remaining(now, self.cfg.sentinel_hourly_budget) == 0 {
441            return Ok(Due::BudgetDeferred);
442        }
443        Ok(Due::Ready)
444    }
445
446    /// One poll tick: `lstat` exactly two fixed paths, and stat the two
447    /// staging names — no inotify dependency, no directory scan.
448    ///
449    /// Returns the verbs whose sentinel was consumed (or coalesced) on
450    /// this tick.
451    pub fn poll_sentinels(&mut self) -> LeanResult<Vec<Verb>> {
452        let mut consumed = vec![];
453        for verb in [Verb::Publish, Verb::Sync] {
454            if self.consume_sentinel(verb)? {
455                consumed.push(verb);
456            }
457        }
458        Ok(consumed)
459    }
460
461    /// Recover a consume that crashed between the rename and the
462    /// pending write. Called at startup, before the first poll.
463    pub fn recover_consume_staging(&mut self) -> LeanResult<()> {
464        for verb in [Verb::Publish, Verb::Sync] {
465            self.fold_staging(verb)?;
466        }
467        Ok(())
468    }
469
470    /// Fold a touch left in staging (a crash, or a fold that failed)
471    /// into the pending record, then clear staging. True when it folded.
472    fn fold_staging(&mut self, verb: Verb) -> LeanResult<bool> {
473        let staging = self.staging_path(verb);
474        if !staging.exists() {
475            return Ok(false);
476        }
477        let meta = std::fs::metadata(&staging)?;
478        let (body, oversize) = read_bounded(&staging)?;
479        self.fold_into_pending(verb, mtime_ns(&meta), &body, oversize)?;
480        std::fs::remove_file(&staging)?;
481        Ok(true)
482    }
483
484    pub(crate) fn consume_sentinel(&mut self, verb: Verb) -> LeanResult<bool> {
485        // A fold that failed mid-run (review: U37) left its touch in
486        // staging, and the rename below would clobber it, orphaning its
487        // nonce. So a stranded touch is folded FIRST, on every poll (one
488        // more stat), so it is honored without waiting for another
489        // touch; while that still fails, a new touch stays where the
490        // agent put it.
491        let stranded = self.fold_staging(verb)?;
492        let path = self.control_path(verb.sentinel_name());
493        let meta = match std::fs::symlink_metadata(&path) {
494            Ok(m) => m,
495            Err(_) => return Ok(stranded),
496        };
497        // Type check FIRST: a FIFO would block the body read forever;
498        // a directory, socket or symlink at the sentinel path is not a
499        // touch this syncer will act on.
500        if !meta.is_file() {
501            // ONCE per process, not once per poll tick. A FIFO or dir
502            // parked at the sentinel path is a standing condition, and
503            // appending a record every 10 s grows a file that
504            // `load_conflicts` parses WHOLE — twice per sync honor, and
505            // again per status read and per scrape (review: U23). The
506            // condition is worth recording; the repetition is not.
507            let slot = format!("{}/{}", super::CONTROL_DIR, verb.sentinel_name());
508            if self.noted_not_regular.insert(slot.clone()) {
509                self.state.append_conflict(&ConflictRecord {
510                    path: slot,
511                    foreign_etag: String::new(),
512                    preserved_key: None,
513                    kind: "sentinel-not-regular-file".into(),
514                    at_unix: now_unix(),
515                })?;
516            }
517            return Ok(stranded);
518        }
519        let ns = mtime_ns(&meta);
520        let (body, oversize) = match read_bounded(&path) {
521            Ok(v) => v,
522            Err(_) => (Vec::new(), false),
523        };
524        // The consume act: rename the sentinel out of the agent's reach.
525        // Staging first, pending second — a crash between is recovered
526        // at startup from the staging file, so a touch is never lost
527        // ACROSS A RESTART.
528        // A fold that fails mid-run leaves its touch in staging too; it
529        // was folded at the top of this call, so the rename clobbers
530        // nothing.
531        self.noted_not_regular.remove(&format!("{}/{}", super::CONTROL_DIR, verb.sentinel_name()));
532        let staging = self.staging_path(verb);
533        std::fs::rename(&path, &staging)?;
534        self.fold_into_pending(verb, ns, &body, oversize)?;
535        let _ = std::fs::remove_file(&staging);
536        Ok(true)
537    }
538
539    /// Build or coalesce into the standing pending record.
540    ///
541    /// Settle-before-consume in its positive form: an existing pending
542    /// record is never overwritten. Its nonce set grows, its covered
543    /// mtime advances to the latest touch, and its scope (sync) unions —
544    /// so the honor that eventually runs covers every coalesced touch,
545    /// and every one of their nonces appears in the ack.
546    fn fold_into_pending(
547        &mut self,
548        verb: Verb,
549        ns: u128,
550        body: &[u8],
551        oversize: bool,
552    ) -> LeanResult<()> {
553        let trimmed = body.iter().all(|b| b.is_ascii_whitespace());
554        let (parsed, torn) = if trimmed {
555            (SentinelBody::default(), false) // bare touch
556        } else if oversize {
557            (SentinelBody::default(), true)
558        } else {
559            match serde_json::from_slice::<SentinelBody>(body) {
560                Ok(v) => (v, false),
561                Err(_) => (SentinelBody::default(), true),
562            }
563        };
564        if torn {
565            self.state.append_conflict(&ConflictRecord {
566                path: format!("{}/{}", super::CONTROL_DIR, verb.sentinel_name()),
567                foreign_etag: String::new(),
568                preserved_key: None,
569                kind: "sentinel-torn-body".into(),
570                at_unix: now_unix(),
571            })?;
572        }
573        let mut pending = self.load_pending(verb)?.unwrap_or(PendingSentinel {
574            verb: verb.as_str().into(),
575            consumed_mtime_unix_ns: 0,
576            consumed_at: now_unix(),
577            nonces: vec![],
578            note: None,
579            scope: None,
580            torn: false,
581            whole_tree: false,
582            id: String::new(),
583        });
584        let fresh = pending.id.is_empty();
585        if fresh {
586            pending.id = uuid::Uuid::new_v4().to_string();
587        }
588        pending.consumed_mtime_unix_ns = pending.consumed_mtime_unix_ns.max(ns);
589        pending.consumed_at = now_unix();
590        pending.torn |= torn;
591        if let Some(n) = parsed.nonce {
592            let n: String = n.chars().take(128).collect();
593            if !pending.nonces.contains(&n) {
594                pending.nonces.push(n);
595            }
596            let excess = pending.nonces.len().saturating_sub(MAX_NONCES);
597            if excess > 0 {
598                pending.nonces.drain(..excess);
599            }
600        }
601        if let Some(note) = parsed.note {
602            pending.note = Some(note.chars().take(4096).collect());
603        }
604        // Scope folding (review 2026-09-12, inbox-7 / inbox-10): an
605        // UNSCOPED sync touch means the whole tree and outranks every
606        // scoped one it coalesces with, in either order — the whole-tree
607        // agent matches its nonce on this ack and must get what it asked
608        // for. Scoped touches union, and the union is NOT truncated
609        // here: an oversize or all-invalid scope is answered at the
610        // honor as `refused-scope`, never silently narrowed.
611        match parsed.scope {
612            None if matches!(verb, Verb::Sync) => {
613                pending.scope = None;
614                pending.whole_tree = true;
615            }
616            Some(scope) if !pending.whole_tree => {
617                let mut merged = pending.scope.take().unwrap_or_default();
618                for e in scope {
619                    if e.len() > MAX_SCOPE_ENTRY_LEN {
620                        continue;
621                    }
622                    if !merged.contains(&e) {
623                        merged.push(e);
624                    }
625                }
626                pending.scope = Some(merged);
627            }
628            _ => {}
629        }
630        self.save_pending(verb, &pending)?;
631        // The model's Touch: the request folded into the standing record (a
632        // fresh one when none stood), whichever door it came through.
633        self.trace("fold", serde_json::json!({"verb": format!("{verb:?}"), "id": pending.id,
634            "fresh": fresh, "nonces": pending.nonces}));
635        Ok(())
636    }
637
638    /// A boundary asked for through a door that is NOT the file
639    /// protocol — the gateway's inbox field (§2.5) or the UDS socket.
640    ///
641    /// It folds into the same pending record a `.flint/publish` touch
642    /// would, which is the whole design of the layered doors: the extra
643    /// doors are sugar over one consume path, so min-interval,
644    /// coalescing, the work-metered budget, the covered-nonce ack and
645    /// the crash rules apply to them without a second implementation
646    /// that could disagree with the first.
647    pub fn request_boundary(&mut self, nonce: &str, note: Option<String>) -> LeanResult<()> {
648        let body = serde_json::json!({ "nonce": nonce, "note": note });
649        let ns = std::time::SystemTime::now()
650            .duration_since(std::time::UNIX_EPOCH)
651            .map(|d| d.as_nanos())
652            .unwrap_or(0);
653        self.fold_into_pending(Verb::Publish, ns, body.to_string().as_bytes(), false)
654    }
655
656    fn ack_path(&self, verb: Verb) -> PathBuf {
657        self.control_path(verb.ack_name())
658    }
659
660    pub fn read_ack(&self, verb: Verb) -> Option<Ack> {
661        let bytes = std::fs::read(self.ack_path(verb)).ok()?;
662        serde_json::from_slice(&bytes).ok()
663    }
664
665    fn write_ack(&self, verb: Verb, ack: &Ack) -> LeanResult<()> {
666        let bytes =
667            serde_json::to_vec_pretty(ack).map_err(|e| LeanError::State(format!("ack: {e}")))?;
668        write_atomic(&self.ack_path(verb), &bytes)?;
669        self.trace("ack", serde_json::json!({"verb": format!("{verb:?}"), "nonces": ack.nonces, "status": ack.status,
670            "seq": ack.seq, "dropped": ack.report.dropped, "boundary": ack.boundary}));
671        Ok(())
672    }
673
674    /// Does a standing ack already answer this pending record? Used by
675    /// the restart rule: matching ⇒ retire only (the ack names a real
676    /// install); not matching ⇒ run a full barrier and ack from THAT.
677    pub(crate) fn ack_matches(&self, verb: Verb, pending: &PendingSentinel) -> bool {
678        let Some(ack) = self.read_ack(verb) else { return false };
679        // Compare values the SYNCER minted (review: U22). The ack is an
680        // ordinary file in `.flint/`, writable by every process sharing
681        // the mount, and `sentinel_mtime_unix_ns` is the agent's own
682        // file mtime — which is not monotone even without an adversary:
683        // `touch -t`, a clock step, a restored file and a tar extract
684        // all move it backwards. Deciding "has this boundary already
685        // run?" from it lets a STALE ack retire a FRESH request, and
686        // the request is then retired having never run.
687        //
688        // `consumed_at` and `completed_unix` are both minted here, in
689        // that order, so their ordering answers the question the mtime
690        // was being asked.
691        if ack.completed_unix < pending.consumed_at {
692            return false;
693        }
694        if ack.sentinel_mtime_unix_ns < pending.consumed_mtime_unix_ns {
695            return false;
696        }
697        // A bare touch carries no nonce, so `all()` over an empty set is
698        // vacuously TRUE and the timestamps become the entire test.
699        // Require the ack to name this exact touch instead.
700        if pending.nonces.is_empty() {
701            return ack.sentinel_mtime_unix_ns == pending.consumed_mtime_unix_ns;
702        }
703        pending.nonces.iter().all(|n| ack.nonces.contains(n))
704    }
705
706    /// Honor a standing pending sentinel, if one stands and is due.
707    ///
708    /// `forced` = this is a floor tick picking up a budget-deferred
709    /// boundary; the ack is stamped `sentinel-deferred`. `Ok(None)`
710    /// means nothing was owed (or it is not due yet).
711    pub async fn honor_pending(&mut self, verb: Verb, forced: bool) -> LeanResult<Option<Ack>> {
712        self.honor_pending_as(verb, forced, None).await
713    }
714
715    /// `honor_pending` under an explicit provenance stamp. The drain
716    /// needs it: it rewrites the ack to `drain`, and a manifest still
717    /// stamped `sentinel-deferred` would have the bucket and the ack
718    /// naming two different clocks for one boundary.
719    pub async fn honor_pending_as(
720        &mut self,
721        verb: Verb,
722        forced: bool,
723        source: Option<&str>,
724    ) -> LeanResult<Option<Ack>> {
725        let Some(pending) = self.load_pending(verb)? else { return Ok(None) };
726        if !forced {
727            match self.sentinel_due()? {
728                Due::Ready => {}
729                // Not honored on this tick; the floor tick will (D3).
730                Due::MinInterval | Due::BudgetDeferred => return Ok(None),
731            }
732        }
733        // The restart rule: a matching ack already names a real
734        // install — retire, never re-run.
735        if self.ack_matches(verb, &pending) {
736            self.retire_pending(verb)?;
737            return Ok(None);
738        }
739
740        let ack = match verb {
741            Verb::Publish => self.honor_publish(&pending, forced, source).await,
742            Verb::Sync => self.honor_sync(&pending, forced).await,
743        };
744        match ack {
745            Ok(ack) => {
746                self.write_ack(verb, &ack)?;
747                self.retire_pending(verb)?;
748                Ok(Some(ack))
749            }
750            Err(e) => Err(e),
751        }
752    }
753
754    async fn honor_publish(
755        &mut self,
756        pending: &PendingSentinel,
757        forced: bool,
758        source: Option<&str>,
759    ) -> LeanResult<Ack> {
760        // A reader answers — "nothing is silent" — and publishes nothing.
761        // No budget charge: nothing was published, and a refusal must
762        // not hold back the next sync behind the min-interval.
763        if self.cfg.access.is_read() {
764            return Ok(Ack {
765                status: "refused-read-only".into(),
766                nonces: pending.nonces.clone(),
767                sentinel_mtime_unix_ns: pending.consumed_mtime_unix_ns,
768                seq: None,
769                manifest_etag: None,
770                boundary: if forced { "sentinel-deferred".into() } else { "sentinel".into() },
771                completed_unix: now_unix(),
772                reason: Some(
773                    "this pod has read-only access to the workspace (capabilities `access: read`): \
774                     nothing in its tree is published. `sync` still integrates the bucket's latest \
775                     boundary"
776                        .into(),
777                ),
778                report: AckReport::default(),
779            });
780        }
781        // The DECLARED form (D1): a delete the agent made before the
782        // touch is part of the coherent point it declared, so this
783        // barrier confirms first-absence paths instead of acking a
784        // boundary that withholds them to the next floor tick.
785        // The ack and the manifest must agree on which clock published:
786        // a budget-deferred boundary reads `sentinel-deferred` in both.
787        let before = self.state.conflicts_appended()?;
788        let report = self
789            .declared_barrier_as(
790                source.unwrap_or(if forced { "sentinel-deferred" } else { "sentinel" }),
791            )
792            .await?;
793        // Review 2026-09-12, ack-3: the boundary's own records (a
794        // `consume-dirty` the agent won, a refused removal) ride the
795        // publish ack as they ride the sync ack — AGENTS.md says so.
796        let conflicts = conflicts_since(before, self.state.conflicts_dropped()?, self.state.load_conflicts()?);
797        let units = self.charge_budget(report.published_bytes)?;
798        let _ = units;
799        let manifest_etag = self.state.with_baseline(|b| b.manifest_etag.clone())?;
800        // Review 2026-09-12, inbox-1: a boundary with a standing park does
801        // NOT carry the agent's file — `ok` promised "the boundary is in
802        // the bucket". It is `partial`, and `report.dropped` names the
803        // paths, as it does an upload that published nothing (a transfer
804        // that drifted or was swept) and a path the syncer may not read.
805        let dropped: Vec<String> =
806            report.parked.iter().chain(&report.deferred).chain(&report.held).cloned().collect();
807        // Review 2026-09-18, H10: the installs that CARRIED this declaration
808        // are journalled with their CAS, and are not always this barrier —
809        // a restart past step 7, an ack write that failed, or an honor that
810        // failed before the floor's cadence barrier published the
811        // declaration, each leave the ack to a later barrier. When a peer
812        // deleted a declared path in between, the document that barrier
813        // ended at does not cite it — the tree may still hold the declared
814        // bytes — and `ok` said it was there. Each barrier records which
815        // carried uploads its document does not cite (`Carrier::uncited`).
816        let mut dropped = dropped;
817        let carrier = self
818            .state
819            .load_intent()?
820            .carrier
821            .filter(|c| !pending.id.is_empty() && c.pending_id == pending.id);
822        if let Some(c) = carrier {
823            for p in c.dropped.iter().chain(c.uncited.iter()) {
824                if !dropped.contains(p) {
825                    dropped.push(p.clone());
826                }
827            }
828        }
829        Ok(Ack {
830            status: if dropped.is_empty() { "ok".into() } else { "partial".into() },
831            nonces: pending.nonces.clone(),
832            sentinel_mtime_unix_ns: pending.consumed_mtime_unix_ns,
833            seq: report.seq,
834            manifest_etag,
835            // The clock the CAS stamped (`source`), so the ack and the
836            // bucket name the same one — the drain's included, which used
837            // to be acked `sentinel-deferred` and re-written `drain`.
838            boundary: source.unwrap_or(if forced { "sentinel-deferred" } else { "sentinel" }).into(),
839            completed_unix: now_unix(),
840            reason: None,
841            report: AckReport {
842                uploaded: report.uploaded.len(),
843                deleted: report.deleted.len(),
844                parked: report.parked.len(),
845                consumed: report.consumed,
846                no_change: report.no_change,
847                conflicts,
848                dropped,
849                ..Default::default()
850            },
851        })
852    }
853
854    async fn honor_sync(&mut self, pending: &PendingSentinel, forced: bool) -> LeanResult<Ack> {
855        if let Some(reason) = Self::scope_refusal(&pending.scope) {
856            // Review 2026-09-12, ack-1 / inbox-3: the refusal used to be
857            // an ERROR the loop retried every tick — never acked, never
858            // retired, and returned ahead of the publish honor and the
859            // cadence barrier, so one `{"scope":[]}` stopped every
860            // boundary for the life of the workspace. An invalid request
861            // is the agent's to fix: answered, retired, and nothing else
862            // waits on it.
863            return Ok(Ack {
864                status: "refused-scope".into(),
865                nonces: pending.nonces.clone(),
866                sentinel_mtime_unix_ns: pending.consumed_mtime_unix_ns,
867                seq: None,
868                manifest_etag: None,
869                boundary: if forced { "sentinel-deferred".into() } else { "sentinel".into() },
870                completed_unix: now_unix(),
871                    reason: Some(reason),
872                report: AckReport { scope: pending.scope.clone(), ..Default::default() },
873            });
874        }
875        let before = self.state.conflicts_appended()?;
876        let report = self.sync_scoped(pending.scope.clone()).await?;
877        let conflicts = conflicts_since(before, self.state.conflicts_dropped()?, self.state.load_conflicts()?);
878        // A sync publishes no bytes: it costs no budget units, only the
879        // min-interval (which it shares with publish).
880        self.charge_budget(0)?;
881        Ok(Ack {
882            status: "ok".into(),
883            nonces: pending.nonces.clone(),
884            sentinel_mtime_unix_ns: pending.consumed_mtime_unix_ns,
885            seq: Some(report.seq),
886            manifest_etag: None,
887            boundary: if forced { "sentinel-deferred".into() } else { "sentinel".into() },
888            completed_unix: now_unix(),
889        reason: None,
890            report: AckReport {
891                consumed: report.applied.len(),
892                deleted: report.deleted.len(),
893                applied: report.applied.clone(),
894                conflicts,
895                scope: pending.scope.clone(),
896                out_of_scope_foreign: report.out_of_scope_foreign,
897                ..Default::default()
898            },
899        })
900    }
901
902    /// Review 2026-09-12, ack-1: an invalid sync scope is the AGENT's
903    /// error and is answered as one (`refused-scope`), never retried.
904    /// `None` = the scope is acceptable (or absent: the whole tree).
905    fn scope_refusal(scope: &Option<Vec<String>>) -> Option<String> {
906        let raw = scope.as_ref()?;
907        if raw.len() > MAX_SCOPE_ENTRIES {
908            return Some(format!(
909                "scope names {} entries; at most {MAX_SCOPE_ENTRIES} are allowed per sync",
910                raw.len()
911            ));
912        }
913        if super::sync::Scope::new(raw).is_empty() {
914            return Some(format!(
915                "scope names {} entr{} and none is valid: each must be a relative path inside the \
916                 workspace with no `.` or `..` component (an empty scope is refused, not widened \
917                 to the whole tree)",
918                raw.len(),
919                if raw.len() == 1 { "y" } else { "ies" }
920            ));
921        }
922        None
923    }
924
925    /// The startup settle (the uniform crash rule). Runs before the
926    /// first poll arms: a surviving pending must be honored, acked and
927    /// retired before any fresh sentinel may be consumed.
928    pub async fn settle_pending_at_startup(&mut self) -> LeanResult<()> {
929        self.recover_consume_staging()?;
930        for verb in [Verb::Publish, Verb::Sync] {
931            let Some(pending) = self.load_pending(verb)? else { continue };
932            if self.ack_matches(verb, &pending) {
933                // Crashed after the ack, before the retire.
934                self.retire_pending(verb)?;
935                continue;
936            }
937            // Pending + no matching ack is the same observable state
938            // for crash-before-CAS and crash-after-step-7: run a full
939            // barrier and ack from ITS install, never from persisted
940            // state.
941            self.honor_pending(verb, true).await?;
942        }
943        Ok(())
944    }
945}
946
947/// What a floor tick did.
948#[derive(Debug, Default)]
949pub struct FloorOutcome {
950    pub seq: Option<u64>,
951    pub no_change: bool,
952    /// Acks written because a budget-deferred (or min-interval-held)
953    /// pending sentinel was picked up by this floor tick.
954    pub acks: Vec<Ack>,
955    pub uploaded: usize,
956    pub deleted: usize,
957    pub consumed: usize,
958    /// Why visibility is withheld right now, straight off the gauges —
959    /// so the per-tick stderr line is greppable and structured, which
960    /// until Phase 6 is the ONLY signal surface an operator has.
961    pub withheld_reason: Option<String>,
962    /// A foreign 412 parked at least one path on this tick.
963    pub parked: usize,
964    /// Paths this tick's barrier held because the syncer may not read
965    /// them, or because their names are not UTF-8 (`BarrierReport::held`).
966    pub held: usize,
967    /// Carried out of the barrier only to feed the news ticker.
968    observed_etag: Option<String>,
969}
970
971impl Syncer {
972    /// Move the news ticker from what the barrier already learned (D5).
973    /// Never issues a request of its own.
974    pub(crate) fn ticker_from(&self, seq: Option<u64>, etag: Option<String>) -> LeanResult<()> {
975        // Read in place (lever D): this runs on every tick, and each of
976        // the two reads it replaced was a parse of the whole baseline.
977        let (baseline_seq, derived_etag) = self.state.with_baseline(|b| (b.seq, b.derived_etag.clone()))?;
978        // A merge that carried another writer's change into the manifest
979        // moves the baseline's seq to that document while the tree is still
980        // OWED the change until the next consume derives against it. Until
981        // then the tree is integrated only through what the ticker said
982        // while nothing was owed: waiting = the document named is not the
983        // one last derived against. (A path the agent is working on is its
984        // own, not waited on.)
985        let same = |a: &str, b: &str| a.trim_matches('"') == b.trim_matches('"');
986        let waiting = match (etag.as_deref(), derived_etag.as_deref()) {
987            (Some(e), Some(d)) => !same(e, d),
988            (Some(_), None) => true,
989            (None, _) => false,
990        };
991        let integrated = if waiting {
992            baseline_seq.min(self.load_remote_seq().integrated_seq)
993        } else {
994            baseline_seq
995        };
996        self.touch_remote_seq(seq, etag, integrated)
997    }
998
999    /// One poll tick of the sentinel arm: consume what is there, then
1000    /// honor if the min-interval and the work budget allow.
1001    ///
1002    /// In `cadence` mode the arm still consumes and honors — a sentinel
1003    /// triggers a fused barrier there too; the mode's meaning is that
1004    /// there is no separate citation lane, not that the verbs are dead.
1005    pub async fn sentinel_tick(&mut self) -> LeanResult<Vec<Ack>> {
1006        let posture = self.load_posture()?;
1007        if posture.as_ref().map(|p| !p.enabled).unwrap_or(false) {
1008            return Ok(vec![]);
1009        }
1010        self.poll_sentinels()?;
1011        let mut acks = vec![];
1012        // Sync before publish: a coalesced pair means "pull, then
1013        // publish my coherent point" — the other order would publish
1014        // against a tree the agent expected to have refreshed.
1015        for verb in [Verb::Sync, Verb::Publish] {
1016            match self.honor_pending(verb, false).await {
1017                Ok(Some(a)) => acks.push(a),
1018                Ok(None) => {}
1019                Err(e) => {
1020                    // Review 2026-09-12, ack-1: a non-fence honor error is
1021                    // THIS verb's problem. Returning here left the other
1022                    // verb unhonoured for as long as the error recurred —
1023                    // for a deterministic one, forever. The pending stands
1024                    // and is retried; the tick goes on.
1025                    eprintln!("flint-sync: {verb:?} honor failed (pending kept, retrying): {e}");
1026                }
1027            }
1028        }
1029        if !acks.is_empty() {
1030            let last = acks.last().and_then(|a| a.seq);
1031            self.ticker_from(last, None)?;
1032        }
1033        Ok(acks)
1034    }
1035
1036    /// One floor tick: either honor a standing
1037    /// pending sentinel that the budget or min-interval held back — the
1038    /// boundary is honored by a REAL barrier, its ack stamped
1039    /// `sentinel-deferred` — or run the ordinary cadence barrier.
1040    ///
1041    /// This is the arm B16 regresses on: the interval rewrite must not
1042    /// let the poll arm starve cadence.
1043    pub async fn floor_tick(&mut self) -> LeanResult<FloorOutcome> {
1044        let mut out = FloorOutcome::default();
1045        // A held-back sync pending is settled first, for the same
1046        // reason as in the poll arm.
1047        //
1048        // Only a PUBLISH honor discharges the floor's own barrier: a
1049        // sync ack carries a seq (the manifest it synced against) but
1050        // publishes nothing, so treating it as "the floor ran" would
1051        // skip cadence for a tick — a silent RPO regression.
1052        let mut published = false;
1053        for verb in [Verb::Sync, Verb::Publish] {
1054            if self.load_pending(verb)?.is_some() {
1055                match self.honor_pending(verb, true).await {
1056                    Ok(Some(a)) => {
1057                        out.seq = a.seq.or(out.seq);
1058                        if verb == Verb::Publish && a.status == "ok" {
1059                            published = true;
1060                            out.uploaded = a.report.uploaded;
1061                            out.deleted = a.report.deleted;
1062                            out.consumed = a.report.consumed;
1063                            out.no_change = a.report.no_change;
1064                        }
1065                        out.acks.push(a);
1066                    }
1067                    Ok(None) => {}
1068                    Err(e) => {
1069                        // Review 2026-09-12, ack-1: same rule as the poll
1070                        // arm, and here the stake is the cadence barrier
1071                        // below, which used to go unrun behind the error.
1072                        eprintln!("flint-sync: {verb:?} honor failed on the floor (pending kept): {e}");
1073                    }
1074                }
1075            }
1076        }
1077        // A reader's floor is its pull (`reader.rs`): no barrier, ever.
1078        if self.cfg.access.is_read() {
1079            return self.reader_floor(out).await;
1080        }
1081        // Finding 10's sweep (`untracked.rs`), when due: before this tick's
1082        // barrier, so the barrier consumes what it tracks.
1083        self.sweep_untracked_if_due(now_unix()).await;
1084        // A publish sentinel honored on this tick already ran the fused
1085        // barrier the floor owed; running a second one would be pure
1086        // churn.
1087        if !published {
1088            return self.cadence_floor(out).await;
1089        }
1090        self.ticker_from(out.seq, None)?;
1091        out.withheld_reason = self.write_gauges(None)?.withheld_reason;
1092        Ok(out)
1093    }
1094
1095    /// A reader's floor: its pull, then the ticker and the gauges. The
1096    /// news tick (`news.rs`) runs it too.
1097    pub(crate) async fn reader_floor(&mut self, mut out: FloorOutcome) -> LeanResult<FloorOutcome> {
1098        match self.reader_pull().await {
1099            Ok(p) => {
1100                let _ = self.clear_auth_pause();
1101                match &p.sync {
1102                    Some(r) => {
1103                        out.seq = Some(r.seq);
1104                        out.consumed = r.applied.len();
1105                        out.deleted = r.deleted.len();
1106                    }
1107                    None => {
1108                        out.no_change = true;
1109                        out.seq = Some(self.state.with_baseline(|b| b.seq)?);
1110                    }
1111                }
1112                self.ticker_from(p.observed_seq, p.observed_etag)?;
1113                out.withheld_reason = self.write_gauges(None)?.withheld_reason;
1114                Ok(out)
1115            }
1116            Err(e) => {
1117                if e.is_auth() {
1118                    let _ = self.note_auth_pause();
1119                }
1120                Err(e)
1121            }
1122        }
1123    }
1124
1125    /// A writer's floor barrier, the cadence barrier, then the ticker and
1126    /// the gauges. The news tick (`news.rs`) runs it too, and runs nothing
1127    /// else of the floor: the pending honours stay the floor's and the
1128    /// sentinel arm's, under their interval and budget.
1129    pub(crate) async fn cadence_floor(&mut self, mut out: FloorOutcome) -> LeanResult<FloorOutcome> {
1130        let ran = self.cadence_barrier().await.map(|r| {
1131            out.seq = r.seq;
1132            out.no_change = r.no_change;
1133            out.uploaded = r.uploaded.len();
1134            out.deleted = r.deleted.len();
1135            out.consumed = r.consumed;
1136            out.parked = r.parked.len();
1137            out.held = r.held.len();
1138            out.observed_etag = r.observed_etag.clone();
1139            r.observed_seq
1140        });
1141        match ran {
1142            Ok(observed) => {
1143                // The floor tick is the one scheduled request every
1144                // writer makes, idle or not, so it is where a
1145                // credential pause is seen to END — and, below, to
1146                // begin. A claim sees both too, but an idle writer
1147                // never claims. Best-effort: a gauge that failed to
1148                // write must not fail a tick that succeeded.
1149                let _ = self.clear_auth_pause();
1150                let etag = out.observed_etag.take();
1151                self.ticker_from(observed, etag)?;
1152                // A foreign 412 outranks "waiting for a boundary":
1153                // those paths are not ours to publish at all, and
1154                // saying "quiesce-pending" would send an operator
1155                // looking for a clock instead of a conflict record.
1156                // A held path sends them to the tree's permissions.
1157                let forced = if out.parked > 0 {
1158                    Some(super::gauges::Withheld::Parked412)
1159                } else {
1160                    (out.held > 0).then_some(super::gauges::Withheld::Unreadable)
1161                };
1162                out.withheld_reason = self.write_gauges(forced)?.withheld_reason;
1163                Ok(out)
1164            }
1165            Err(e) => {
1166                if e.is_auth() {
1167                    let _ = self.note_auth_pause();
1168                }
1169                Err(e)
1170            }
1171        }
1172    }
1173
1174    /// The preStop drain (D10 rule 1): settle every owed ack BEFORE the
1175    /// lease release. A pending sentinel at SIGTERM is answered by the
1176    /// drain itself — the container-restart case, where the emptyDir and
1177    /// the agent both survive, must not strand a waiting agent.
1178    pub async fn drain(&mut self) -> LeanResult<Vec<Ack>> {
1179        let mut acks = vec![];
1180        // Only an ok PUBLISH ack means a boundary already ran. A sync
1181        // ack carries a seq too — the manifest it synced AGAINST — while
1182        // publishing nothing, so asking "did any ack carry a seq?" lets
1183        // a pending `.flint/sync` at SIGTERM cancel the drain's own
1184        // cite-everything pass and forfeit every byte since the last
1185        // boundary. The floor arm's `sync_ack_is_not_a_floor` comment
1186        // names this exact trap; the drain repeated it.
1187        let mut published = false;
1188        for verb in [Verb::Sync, Verb::Publish] {
1189            let is_publish = matches!(verb, Verb::Publish);
1190            if self.load_pending(verb)?.is_some() {
1191                match self.honor_pending_as(verb, true, Some("drain")).await {
1192                    Ok(Some(a)) => {
1193                        // A publish ack already names the `drain` clock: its
1194                        // barrier's stamp. A sync ack is re-written with it,
1195                        // so the agent can tell a drained answer from a live
1196                        // one.
1197                        let a = if a.boundary == "drain" {
1198                            a
1199                        } else {
1200                            let mut a = a;
1201                            a.boundary = "drain".into();
1202                            let bytes = serde_json::to_vec_pretty(&a)
1203                                .map_err(|e| LeanError::State(format!("ack: {e}")))?;
1204                            write_atomic(&self.ack_path(verb), &bytes)?;
1205                            a
1206                        };
1207                        published |= is_publish && a.status == "ok";
1208                        acks.push(a);
1209                    }
1210                    Ok(None) => {}
1211                    Err(e) => return Err(e),
1212                }
1213            }
1214        }
1215        // A reader has nothing to drain: what its tree holds is either in
1216        // the bucket already or, by its access, never going there.
1217        if !published && !self.cfg.access.is_read() {
1218            // D10: the drain publishes EVERYTHING it can, as one declared barrier.
1219                let r = self.declared_barrier_as("drain").await?;
1220                self.ticker_from(r.observed_seq, r.observed_etag.clone())?;
1221                // Review 2026-09-12, inbox-1: a drain that leaves a path
1222                // parked has not published the tree; attesting it let the
1223                // node remove the agent's only copy. So is one whose upload
1224                // published nothing, and one whose delete another writer's
1225                // edit outranked: the retry publishes both.
1226                if !r.parked.is_empty() {
1227                    return Err(LeanError::State(format!(
1228                        "drain: {} path(s) could not be published (parked on a foreign version whose \
1229                         preserve failed): {:?} — not attesting",
1230                        r.parked.len(),
1231                        r.parked
1232                    )));
1233                }
1234                if !r.deferred.is_empty() {
1235                    return Err(LeanError::State(format!(
1236                        "drain: not in this boundary — uploads that published nothing {:?} — not \
1237                         attesting",
1238                        r.deferred
1239                    )));
1240                }
1241                // Nor one that held a path: what is under it may be the
1242                // agent's only copy, and the syncer could not read it.
1243                if !r.held.is_empty() {
1244                    return Err(LeanError::State(format!(
1245                        "drain: not in this boundary — paths the syncer may not read or name {:?} — not \
1246                         attesting",
1247                        r.held
1248                    )));
1249                }
1250        }
1251        Ok(acks)
1252    }
1253}