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