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}
150
151/// The ack document (`.flint/<verb>.ack`).
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct Ack {
154    /// "ok" | "partial" | "refused-scope".
155    ///
156    /// `partial` is the honest answer when the boundary installed but a
157    /// path the agent declared is not in it (D1) —
158    /// `report.dropped` names them. An agent that treats it as failure
159    /// and re-touches is behaving correctly.
160    pub status: String,
161    pub nonces: Vec<String>,
162    pub sentinel_mtime_unix_ns: u128,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub seq: Option<u64>,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub manifest_etag: Option<String>,
167    /// "sentinel" | "sentinel-deferred" | "drain" | "recovered".
168    pub boundary: String,
169    pub completed_unix: u64,
170    /// Set on a refusal that is the AGENT's to fix (`refused-scope`):
171    /// what was wrong with the request, in words.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub reason: Option<String>,
174    pub report: AckReport,
175}
176
177#[derive(Debug, Clone, Default, Serialize, Deserialize)]
178pub struct AckReport {
179    pub uploaded: usize,
180    pub deleted: usize,
181    pub parked: usize,
182    pub consumed: usize,
183    pub no_change: bool,
184    /// `sync` only: the applied/deleted/conflict transport (§2.2 — the
185    /// conflict report rides the ack in FULL: "never a silent winner"
186    /// must survive the file transport).
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub applied: Vec<String>,
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub conflicts: Vec<ConflictRecord>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub scope: Option<Vec<String>>,
193    /// Foreign changes seen but deferred to the inbox flow (D4).
194    #[serde(default)]
195    pub out_of_scope_foreign: usize,
196    /// Declared paths the boundary does NOT carry (a standing park).
197    /// Non-empty ⇒ `status: "partial"`: the §2.2 rule — the
198    /// conflict report rides the ack in full, never a silent loser —
199    /// generalized from `sync` to the publish verb.
200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
201    pub dropped: Vec<String>,
202}
203
204/// The work meter (D3.1 — the hot-loops no-regression rule).
205///
206/// A counted budget charges a hot 2 GiB checkpoint the same one unit as
207/// a 4 KiB file, so a storming agent could drive `dirty_bytes × 60/hour`
208/// of extra upload while staying inside a green budget — a regression on
209/// the one performance sub-axis lean currently maxes (a tight local
210/// loop's republish is coalesced by cadence; sentinels un-coalesce it on
211/// demand). Metering work instead bounds sentinel-driven published bytes
212/// at `budget × whole_put_max` per hour independent of dirty-set size,
213/// and a workspace at the cap degrades to exactly today's cadence
214/// behavior — the definition of no regression.
215#[derive(Debug, Clone, Default, Serialize, Deserialize)]
216pub struct SentinelBudget {
217    /// (unix, units) charges inside the rolling hour.
218    pub charges: Vec<(u64, u64)>,
219    /// The last sentinel-honoring barrier, for the min-interval.
220    pub last_honor_unix: u64,
221}
222
223impl SentinelBudget {
224    fn prune(&mut self, now: u64) {
225        self.charges.retain(|(at, _)| now.saturating_sub(*at) < 3600);
226    }
227    pub fn spent(&self, now: u64) -> u64 {
228        self.charges
229            .iter()
230            .filter(|(at, _)| now.saturating_sub(*at) < 3600)
231            .map(|(_, u)| *u)
232            .sum()
233    }
234    pub fn remaining(&self, now: u64, budget: u64) -> u64 {
235        budget.saturating_sub(self.spent(now))
236    }
237}
238
239/// Why a standing pending sentinel is not being honored right now.
240/// The records a sync produced, given how many the log held before it.
241///
242/// `skip(before)` alone is only sound while the log GREW. A rotation
243/// during the sync can SHORTEN it, and skipping past a shorter list
244/// reports NO conflicts for a sync that produced some — the one answer
245/// this ack must never give. Over-reporting the survivors is the safe
246/// direction: an ack naming a conflict that predates the sync costs a
247/// reader a second look, an ack naming none costs them the conflict.
248pub(crate) fn conflicts_since(
249    before: usize,
250    after: Vec<ConflictRecord>,
251) -> Vec<ConflictRecord> {
252    if after.len() >= before {
253        after[before..].to_vec()
254    } else {
255        after
256    }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum Due {
261    /// Honor it on this tick.
262    Ready,
263    /// Inside `sentinel_min_interval_secs` of the last honor: wait, and
264    /// let further touches coalesce.
265    MinInterval,
266    /// The hourly budget is exhausted: the boundary is still honored,
267    /// by the next FLOOR tick, and its ack is stamped
268    /// `sentinel-deferred`. Contents are never thinned (D1's corollary)
269    /// — cost is bounded by deferring the barrier, never by publishing
270    /// less than the boundary covers.
271    BudgetDeferred,
272}
273
274fn mtime_ns(m: &std::fs::Metadata) -> u128 {
275    m.modified()
276        .ok()
277        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
278        .map(|d| d.as_nanos())
279        .unwrap_or(0)
280}
281
282/// Read the sentinel body, bounded, and WITHOUT ever blocking on the
283/// open.
284///
285/// The type check in `consume_sentinel` is an `lstat`, and this is a
286/// second path resolution — so on its own the check is a TOCTOU: swap a
287/// FIFO in between and a plain `File::open` blocks forever waiting for a
288/// writer, wedging the poll arm and, behind it, every boundary this
289/// syncer owes (review: U23).
290///
291/// `O_NONBLOCK` closes it at the syscall rather than by winning a race:
292/// opening a writer-less FIFO returns immediately instead of blocking,
293/// and `O_NOFOLLOW` refuses a symlink swapped in for the same purpose.
294/// Both are no-ops on the regular file this is supposed to be reading.
295pub(crate) fn read_bounded(path: &Path) -> std::io::Result<(Vec<u8>, bool)> {
296    use std::io::Read;
297    use std::os::unix::fs::OpenOptionsExt;
298    let f = std::fs::File::options()
299        .read(true)
300        .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
301        .open(path)?;
302    // A FIFO that a writer DOES hold open reads EAGAIN rather than
303    // blocking; treat it as an empty body, which the torn-body rule
304    // already handles as a bare touch.
305    let meta = f.metadata()?;
306    if !meta.is_file() {
307        return Err(std::io::Error::new(
308            std::io::ErrorKind::InvalidInput,
309            "sentinel is not a regular file",
310        ));
311    }
312    let mut buf = Vec::new();
313    let mut take = f.take(MAX_BODY + 1);
314    take.read_to_end(&mut buf)?;
315    let oversize = buf.len() as u64 > MAX_BODY;
316    buf.truncate(MAX_BODY as usize);
317    Ok((buf, oversize))
318}
319
320impl Syncer {
321    fn pending_path(&self, verb: Verb) -> PathBuf {
322        self.cfg.state_dir().join(verb.pending_name())
323    }
324    fn staging_path(&self, verb: Verb) -> PathBuf {
325        self.cfg.state_dir().join(verb.staging_name())
326    }
327    fn budget_path(&self) -> PathBuf {
328        self.cfg.state_dir().join(BUDGET)
329    }
330
331    pub fn load_pending(&self, verb: Verb) -> LeanResult<Option<PendingSentinel>> {
332        let p = self.pending_path(verb);
333        if !p.exists() {
334            return Ok(None);
335        }
336        let bytes = std::fs::read(&p)?;
337        match serde_json::from_slice(&bytes) {
338            Ok(v) => Ok(Some(v)),
339            // Our own record is torn (a crash mid-rewrite): honor it as
340            // a bare touch rather than wedging on it.
341            Err(_) => Ok(Some(PendingSentinel {
342                verb: verb.as_str().into(),
343                consumed_mtime_unix_ns: 0,
344                consumed_at: now_unix(),
345                nonces: vec![],
346                note: None,
347                scope: None,
348                torn: true,
349                whole_tree: false,
350            })),
351        }
352    }
353
354    fn save_pending(&self, verb: Verb, p: &PendingSentinel) -> LeanResult<()> {
355        let bytes = serde_json::to_vec_pretty(p)
356            .map_err(|e| LeanError::State(format!("pending: {e}")))?;
357        write_atomic(&self.pending_path(verb), &bytes)
358    }
359
360    fn retire_pending(&self, verb: Verb) -> LeanResult<()> {
361        let p = self.pending_path(verb);
362        if p.exists() {
363            std::fs::remove_file(&p)?;
364        }
365        Ok(())
366    }
367
368    pub fn load_budget(&self) -> LeanResult<SentinelBudget> {
369        let p = self.budget_path();
370        if !p.exists() {
371            return Ok(SentinelBudget::default());
372        }
373        let bytes = std::fs::read(&p)?;
374        Ok(serde_json::from_slice(&bytes).unwrap_or_default())
375    }
376
377    fn save_budget(&self, b: &SentinelBudget) -> LeanResult<()> {
378        let bytes =
379            serde_json::to_vec_pretty(b).map_err(|e| LeanError::State(format!("budget: {e}")))?;
380        write_atomic(&self.budget_path(), &bytes)
381    }
382
383    /// D3.1: charge the meter for a completed sentinel honor.
384    /// `published_bytes == 0` (a no-diff honor) costs NOTHING — the
385    /// budget exists to bound work and a no-diff honor does none; the
386    /// min-interval remains its only bound.
387    pub fn charge_budget(&self, published_bytes: u64) -> LeanResult<u64> {
388        let now = now_unix();
389        let mut b = self.load_budget()?;
390        b.prune(now);
391        let units = if published_bytes == 0 {
392            0
393        } else {
394            published_bytes.div_ceil(self.cfg.whole_put_max.max(1)).max(1)
395        };
396        if units > 0 {
397            b.charges.push((now, units));
398        }
399        b.last_honor_unix = now;
400        self.save_budget(&b)?;
401        Ok(units)
402    }
403
404    /// Whether a standing pending sentinel may be honored on this tick.
405    pub fn sentinel_due(&self) -> LeanResult<Due> {
406        let now = now_unix();
407        let b = self.load_budget()?;
408        if b.last_honor_unix > 0
409            && now.saturating_sub(b.last_honor_unix) < self.cfg.sentinel_min_interval_secs
410        {
411            return Ok(Due::MinInterval);
412        }
413        if b.remaining(now, self.cfg.sentinel_hourly_budget) == 0 {
414            return Ok(Due::BudgetDeferred);
415        }
416        Ok(Due::Ready)
417    }
418
419    /// One poll tick: `lstat` exactly two fixed paths — no inotify
420    /// dependency, no directory scan, ~2 lstats/s when idle.
421    ///
422    /// Returns the verbs whose sentinel was consumed (or coalesced) on
423    /// this tick.
424    pub fn poll_sentinels(&mut self) -> LeanResult<Vec<Verb>> {
425        let mut consumed = vec![];
426        for verb in [Verb::Publish, Verb::Sync] {
427            if self.consume_sentinel(verb)? {
428                consumed.push(verb);
429            }
430        }
431        Ok(consumed)
432    }
433
434    /// Recover a consume that crashed between the rename and the
435    /// pending write. Called at startup, before the first poll.
436    pub fn recover_consume_staging(&mut self) -> LeanResult<()> {
437        for verb in [Verb::Publish, Verb::Sync] {
438            let staging = self.staging_path(verb);
439            if staging.exists() {
440                let meta = std::fs::metadata(&staging)?;
441                let (body, oversize) = read_bounded(&staging)?;
442                self.fold_into_pending(verb, mtime_ns(&meta), &body, oversize)?;
443                std::fs::remove_file(&staging)?;
444            }
445        }
446        Ok(())
447    }
448
449    pub(crate) fn consume_sentinel(&mut self, verb: Verb) -> LeanResult<bool> {
450        let path = self.control_path(verb.sentinel_name());
451        let meta = match std::fs::symlink_metadata(&path) {
452            Ok(m) => m,
453            Err(_) => return Ok(false),
454        };
455        // Type check FIRST: a FIFO would block the body read forever;
456        // a directory, socket or symlink at the sentinel path is not a
457        // touch this syncer will act on.
458        if !meta.is_file() {
459            // ONCE per process, not once per poll tick. A FIFO or dir
460            // parked at the sentinel path is a standing condition, and
461            // appending a record every 10 s grows a file that
462            // `load_conflicts` parses WHOLE — twice per sync honor, and
463            // again per status read and per scrape (review: U23). The
464            // condition is worth recording; the repetition is not.
465            let slot = format!("{}/{}", super::CONTROL_DIR, verb.sentinel_name());
466            if self.noted_not_regular.insert(slot.clone()) {
467                self.state.append_conflict(&ConflictRecord {
468                    path: slot,
469                    foreign_etag: String::new(),
470                    preserved_key: None,
471                    kind: "sentinel-not-regular-file".into(),
472                    at_unix: now_unix(),
473                })?;
474            }
475            return Ok(false);
476        }
477        let ns = mtime_ns(&meta);
478        let (body, oversize) = match read_bounded(&path) {
479            Ok(v) => v,
480            Err(_) => (Vec::new(), false),
481        };
482        // The consume act: rename the sentinel out of the agent's reach.
483        // Staging first, pending second — a crash between is recovered
484        // at startup from the staging file, so a touch is never lost
485        // ACROSS A RESTART.
486        //
487        // Scope worth naming (review: U37): recovery runs ONLY at
488        // startup (`settle_pending_at_startup`, called once before the
489        // run loop). A *transient* fold failure mid-run therefore leaves
490        // a consumed touch stranded in the staging file until the
491        // process restarts, and the next consume's rename clobbers it,
492        // orphaning its nonce. Recovering in the poll arm as well would
493        // close it; nothing does today.
494        self.noted_not_regular.remove(&format!("{}/{}", super::CONTROL_DIR, verb.sentinel_name()));
495        let staging = self.staging_path(verb);
496        std::fs::rename(&path, &staging)?;
497        self.fold_into_pending(verb, ns, &body, oversize)?;
498        let _ = std::fs::remove_file(&staging);
499        Ok(true)
500    }
501
502    /// Build or coalesce into the standing pending record.
503    ///
504    /// Settle-before-consume in its positive form: an existing pending
505    /// record is never overwritten. Its nonce set grows, its covered
506    /// mtime advances to the latest touch, and its scope (sync) unions —
507    /// so the honor that eventually runs covers every coalesced touch,
508    /// and every one of their nonces appears in the ack.
509    fn fold_into_pending(
510        &mut self,
511        verb: Verb,
512        ns: u128,
513        body: &[u8],
514        oversize: bool,
515    ) -> LeanResult<()> {
516        let trimmed = body.iter().all(|b| b.is_ascii_whitespace());
517        let (parsed, torn) = if trimmed {
518            (SentinelBody::default(), false) // bare touch
519        } else if oversize {
520            (SentinelBody::default(), true)
521        } else {
522            match serde_json::from_slice::<SentinelBody>(body) {
523                Ok(v) => (v, false),
524                Err(_) => (SentinelBody::default(), true),
525            }
526        };
527        if torn {
528            self.state.append_conflict(&ConflictRecord {
529                path: format!("{}/{}", super::CONTROL_DIR, verb.sentinel_name()),
530                foreign_etag: String::new(),
531                preserved_key: None,
532                kind: "sentinel-torn-body".into(),
533                at_unix: now_unix(),
534            })?;
535        }
536        let mut pending = self.load_pending(verb)?.unwrap_or(PendingSentinel {
537            verb: verb.as_str().into(),
538            consumed_mtime_unix_ns: 0,
539            consumed_at: now_unix(),
540            nonces: vec![],
541            note: None,
542            scope: None,
543            torn: false,
544            whole_tree: false,
545        });
546        pending.consumed_mtime_unix_ns = pending.consumed_mtime_unix_ns.max(ns);
547        pending.consumed_at = now_unix();
548        pending.torn |= torn;
549        if let Some(n) = parsed.nonce {
550            let n: String = n.chars().take(128).collect();
551            if !pending.nonces.contains(&n) {
552                pending.nonces.push(n);
553            }
554            let excess = pending.nonces.len().saturating_sub(MAX_NONCES);
555            if excess > 0 {
556                pending.nonces.drain(..excess);
557            }
558        }
559        if let Some(note) = parsed.note {
560            pending.note = Some(note.chars().take(4096).collect());
561        }
562        // Scope folding (review 2026-09-12, inbox-7 / inbox-10): an
563        // UNSCOPED sync touch means the whole tree and outranks every
564        // scoped one it coalesces with, in either order — the whole-tree
565        // agent matches its nonce on this ack and must get what it asked
566        // for. Scoped touches union, and the union is NOT truncated
567        // here: an oversize or all-invalid scope is answered at the
568        // honor as `refused-scope`, never silently narrowed.
569        match parsed.scope {
570            None if matches!(verb, Verb::Sync) => {
571                pending.scope = None;
572                pending.whole_tree = true;
573            }
574            Some(scope) if !pending.whole_tree => {
575                let mut merged = pending.scope.take().unwrap_or_default();
576                for e in scope {
577                    if e.len() > MAX_SCOPE_ENTRY_LEN {
578                        continue;
579                    }
580                    if !merged.contains(&e) {
581                        merged.push(e);
582                    }
583                }
584                pending.scope = Some(merged);
585            }
586            _ => {}
587        }
588        self.save_pending(verb, &pending)?;
589        Ok(())
590    }
591
592    /// A boundary asked for through a door that is NOT the file
593    /// protocol — the gateway's inbox field (§2.5) or the UDS socket.
594    ///
595    /// It folds into the same pending record a `.flint/publish` touch
596    /// would, which is the whole design of the layered doors: the extra
597    /// doors are sugar over one consume path, so min-interval,
598    /// coalescing, the work-metered budget, the covered-nonce ack and
599    /// the crash rules apply to them without a second implementation
600    /// that could disagree with the first.
601    pub fn request_boundary(&mut self, nonce: &str, note: Option<String>) -> LeanResult<()> {
602        let body = serde_json::json!({ "nonce": nonce, "note": note });
603        let ns = std::time::SystemTime::now()
604            .duration_since(std::time::UNIX_EPOCH)
605            .map(|d| d.as_nanos())
606            .unwrap_or(0);
607        self.fold_into_pending(Verb::Publish, ns, body.to_string().as_bytes(), false)
608    }
609
610    fn ack_path(&self, verb: Verb) -> PathBuf {
611        self.control_path(verb.ack_name())
612    }
613
614    pub fn read_ack(&self, verb: Verb) -> Option<Ack> {
615        let bytes = std::fs::read(self.ack_path(verb)).ok()?;
616        serde_json::from_slice(&bytes).ok()
617    }
618
619    fn write_ack(&self, verb: Verb, ack: &Ack) -> LeanResult<()> {
620        let bytes =
621            serde_json::to_vec_pretty(ack).map_err(|e| LeanError::State(format!("ack: {e}")))?;
622        write_atomic(&self.ack_path(verb), &bytes)?;
623        self.trace("ack", serde_json::json!({"verb": format!("{verb:?}"), "nonces": ack.nonces, "status": ack.status,
624            "seq": ack.seq, "dropped": ack.report.dropped, "boundary": ack.boundary}));
625        Ok(())
626    }
627
628    /// Does a standing ack already answer this pending record? Used by
629    /// the restart rule: matching ⇒ retire only (the ack names a real
630    /// install); not matching ⇒ run a full barrier and ack from THAT.
631    pub(crate) fn ack_matches(&self, verb: Verb, pending: &PendingSentinel) -> bool {
632        let Some(ack) = self.read_ack(verb) else { return false };
633        // Compare values the SYNCER minted (review: U22). The ack is an
634        // ordinary file in `.flint/`, writable by every process sharing
635        // the mount, and `sentinel_mtime_unix_ns` is the agent's own
636        // file mtime — which is not monotone even without an adversary:
637        // `touch -t`, a clock step, a restored file and a tar extract
638        // all move it backwards. Deciding "has this boundary already
639        // run?" from it lets a STALE ack retire a FRESH request, and
640        // the request is then retired having never run.
641        //
642        // `consumed_at` and `completed_unix` are both minted here, in
643        // that order, so their ordering answers the question the mtime
644        // was being asked.
645        if ack.completed_unix < pending.consumed_at {
646            return false;
647        }
648        if ack.sentinel_mtime_unix_ns < pending.consumed_mtime_unix_ns {
649            return false;
650        }
651        // A bare touch carries no nonce, so `all()` over an empty set is
652        // vacuously TRUE and the timestamps become the entire test.
653        // Require the ack to name this exact touch instead.
654        if pending.nonces.is_empty() {
655            return ack.sentinel_mtime_unix_ns == pending.consumed_mtime_unix_ns;
656        }
657        pending.nonces.iter().all(|n| ack.nonces.contains(n))
658    }
659
660    /// Honor a standing pending sentinel, if one stands and is due.
661    ///
662    /// `forced` = this is a floor tick picking up a budget-deferred
663    /// boundary; the ack is stamped `sentinel-deferred`. `Ok(None)`
664    /// means nothing was owed (or it is not due yet).
665    pub async fn honor_pending(&mut self, verb: Verb, forced: bool) -> LeanResult<Option<Ack>> {
666        self.honor_pending_as(verb, forced, None).await
667    }
668
669    /// `honor_pending` under an explicit provenance stamp. The drain
670    /// needs it: it rewrites the ack to `drain`, and a manifest still
671    /// stamped `sentinel-deferred` would have the bucket and the ack
672    /// naming two different clocks for one boundary.
673    pub async fn honor_pending_as(
674        &mut self,
675        verb: Verb,
676        forced: bool,
677        source: Option<&str>,
678    ) -> LeanResult<Option<Ack>> {
679        let Some(pending) = self.load_pending(verb)? else { return Ok(None) };
680        if !forced {
681            match self.sentinel_due()? {
682                Due::Ready => {}
683                // Not honored on this tick; the floor tick will (D3).
684                Due::MinInterval | Due::BudgetDeferred => return Ok(None),
685            }
686        }
687        // The restart rule: a matching ack already names a real
688        // install — retire, never re-run.
689        if self.ack_matches(verb, &pending) {
690            self.retire_pending(verb)?;
691            return Ok(None);
692        }
693
694        let ack = match verb {
695            Verb::Publish => self.honor_publish(&pending, forced, source).await,
696            Verb::Sync => self.honor_sync(&pending, forced).await,
697        };
698        match ack {
699            Ok(ack) => {
700                self.write_ack(verb, &ack)?;
701                self.retire_pending(verb)?;
702                Ok(Some(ack))
703            }
704            Err(e) => Err(e),
705        }
706    }
707
708    async fn honor_publish(
709        &mut self,
710        pending: &PendingSentinel,
711        forced: bool,
712        source: Option<&str>,
713    ) -> LeanResult<Ack> {
714        // The DECLARED form (D1): a delete the agent made before the
715        // touch is part of the coherent point it declared, so this
716        // barrier confirms first-absence paths instead of acking a
717        // boundary that withholds them to the next floor tick.
718        // The ack and the manifest must agree on which clock published:
719        // a budget-deferred boundary reads `sentinel-deferred` in both.
720        let before = self.state.load_conflicts()?.len();
721        let report = self
722            .declared_barrier_as(
723                source.unwrap_or(if forced { "sentinel-deferred" } else { "sentinel" }),
724            )
725            .await?;
726        // Review 2026-09-12, ack-3: the boundary's own records (a
727        // `consume-dirty` the agent won, a refused removal) ride the
728        // publish ack as they ride the sync ack — AGENTS.md says so.
729        let conflicts = conflicts_since(before, self.state.load_conflicts()?);
730        let units = self.charge_budget(report.published_bytes)?;
731        let _ = units;
732        let baseline = self.state.load_baseline()?;
733        // Review 2026-09-12, inbox-1: a boundary with a standing park does
734        // NOT carry the agent's file — `ok` promised "the boundary is in
735        // the bucket". It is `partial`, and `report.dropped` names the
736        // paths.
737        Ok(Ack {
738            status: if report.parked.is_empty() { "ok".into() } else { "partial".into() },
739            nonces: pending.nonces.clone(),
740            sentinel_mtime_unix_ns: pending.consumed_mtime_unix_ns,
741            seq: report.seq,
742            manifest_etag: baseline.manifest_etag.clone(),
743            boundary: if forced { "sentinel-deferred".into() } else { "sentinel".into() },
744            completed_unix: now_unix(),
745        reason: None,
746            report: AckReport {
747                uploaded: report.uploaded.len(),
748                deleted: report.deleted.len(),
749                parked: report.parked.len(),
750                consumed: report.consumed,
751                no_change: report.no_change,
752                conflicts,
753                dropped: report.parked.clone(),
754                ..Default::default()
755            },
756        })
757    }
758
759    async fn honor_sync(&mut self, pending: &PendingSentinel, forced: bool) -> LeanResult<Ack> {
760        if let Some(reason) = Self::scope_refusal(&pending.scope) {
761            // Review 2026-09-12, ack-1 / inbox-3: the refusal used to be
762            // an ERROR the loop retried every tick — never acked, never
763            // retired, and returned ahead of the publish honor and the
764            // cadence barrier, so one `{"scope":[]}` stopped every
765            // boundary for the life of the workspace. An invalid request
766            // is the agent's to fix: answered, retired, and nothing else
767            // waits on it.
768            return Ok(Ack {
769                status: "refused-scope".into(),
770                nonces: pending.nonces.clone(),
771                sentinel_mtime_unix_ns: pending.consumed_mtime_unix_ns,
772                seq: None,
773                manifest_etag: None,
774                boundary: if forced { "sentinel-deferred".into() } else { "sentinel".into() },
775                completed_unix: now_unix(),
776                    reason: Some(reason),
777                report: AckReport { scope: pending.scope.clone(), ..Default::default() },
778            });
779        }
780        let before = self.state.load_conflicts()?.len();
781        let report = self.sync_scoped(pending.scope.clone()).await?;
782        let conflicts = conflicts_since(before, self.state.load_conflicts()?);
783        // A sync publishes no bytes: it costs no budget units, only the
784        // min-interval (which it shares with publish).
785        self.charge_budget(0)?;
786        Ok(Ack {
787            status: "ok".into(),
788            nonces: pending.nonces.clone(),
789            sentinel_mtime_unix_ns: pending.consumed_mtime_unix_ns,
790            seq: Some(report.seq),
791            manifest_etag: None,
792            boundary: if forced { "sentinel-deferred".into() } else { "sentinel".into() },
793            completed_unix: now_unix(),
794        reason: None,
795            report: AckReport {
796                consumed: report.applied.len(),
797                deleted: report.deleted.len(),
798                applied: report.applied.clone(),
799                conflicts,
800                scope: pending.scope.clone(),
801                out_of_scope_foreign: report.out_of_scope_foreign,
802                ..Default::default()
803            },
804        })
805    }
806
807    /// Review 2026-09-12, ack-1: an invalid sync scope is the AGENT's
808    /// error and is answered as one (`refused-scope`), never retried.
809    /// `None` = the scope is acceptable (or absent: the whole tree).
810    fn scope_refusal(scope: &Option<Vec<String>>) -> Option<String> {
811        let raw = scope.as_ref()?;
812        if raw.len() > MAX_SCOPE_ENTRIES {
813            return Some(format!(
814                "scope names {} entries; at most {MAX_SCOPE_ENTRIES} are allowed per sync",
815                raw.len()
816            ));
817        }
818        if super::sync::Scope::new(raw).is_empty() {
819            return Some(format!(
820                "scope names {} entr{} and none is valid: each must be a relative path inside the \
821                 workspace with no `.` or `..` component (an empty scope is refused, not widened \
822                 to the whole tree)",
823                raw.len(),
824                if raw.len() == 1 { "y" } else { "ies" }
825            ));
826        }
827        None
828    }
829
830    /// The startup settle (the uniform crash rule). Runs before the
831    /// first poll arms: a surviving pending must be honored, acked and
832    /// retired before any fresh sentinel may be consumed.
833    pub async fn settle_pending_at_startup(&mut self) -> LeanResult<()> {
834        self.recover_consume_staging()?;
835        for verb in [Verb::Publish, Verb::Sync] {
836            let Some(pending) = self.load_pending(verb)? else { continue };
837            if self.ack_matches(verb, &pending) {
838                // Crashed after the ack, before the retire.
839                self.retire_pending(verb)?;
840                continue;
841            }
842            // Pending + no matching ack is the same observable state
843            // for crash-before-CAS and crash-after-step-7: run a full
844            // barrier and ack from ITS install, never from persisted
845            // state.
846            self.honor_pending(verb, true).await?;
847        }
848        Ok(())
849    }
850}
851
852/// What a floor tick did.
853#[derive(Debug, Default)]
854pub struct FloorOutcome {
855    pub seq: Option<u64>,
856    pub no_change: bool,
857    /// Acks written because a budget-deferred (or min-interval-held)
858    /// pending sentinel was picked up by this floor tick.
859    pub acks: Vec<Ack>,
860    pub uploaded: usize,
861    pub deleted: usize,
862    pub consumed: usize,
863    /// Why visibility is withheld right now, straight off the gauges —
864    /// so the per-tick stderr line is greppable and structured, which
865    /// until Phase 6 is the ONLY signal surface an operator has.
866    pub withheld_reason: Option<String>,
867    /// A foreign 412 parked at least one path on this tick.
868    pub parked: usize,
869    /// Carried out of the barrier only to feed the news ticker.
870    observed_etag: Option<String>,
871}
872
873impl Syncer {
874    /// Move the news ticker from what the barrier already learned (D5).
875    /// Never issues a request of its own.
876    fn ticker_from(&self, seq: Option<u64>, etag: Option<String>) -> LeanResult<()> {
877        let baseline_seq = self.state.load_baseline()?.seq;
878        // A merge that carried another writer's change into the manifest
879        // moves the baseline to that document while the change waits in
880        // this writer's queue (or, after a restart, its intent journal) for
881        // the next consume. Until then the tree is integrated only through
882        // what the ticker said while nothing waited.
883        let waiting = !self.state.load_foreign_queue()?.is_empty()
884            || !self.state.load_intent()?.installed_foreign.is_empty();
885        let integrated = if waiting {
886            baseline_seq.min(self.load_remote_seq().integrated_seq)
887        } else {
888            baseline_seq
889        };
890        self.touch_remote_seq(seq, etag, integrated)
891    }
892
893    /// One poll tick of the sentinel arm: consume what is there, then
894    /// honor if the min-interval and the work budget allow.
895    ///
896    /// In `cadence` mode the arm still consumes and honors — a sentinel
897    /// triggers a fused barrier there too; the mode's meaning is that
898    /// there is no separate citation lane, not that the verbs are dead.
899    pub async fn sentinel_tick(&mut self) -> LeanResult<Vec<Ack>> {
900        let posture = self.load_posture()?;
901        if posture.as_ref().map(|p| !p.enabled).unwrap_or(false) {
902            return Ok(vec![]);
903        }
904        self.poll_sentinels()?;
905        let mut acks = vec![];
906        // Sync before publish: a coalesced pair means "pull, then
907        // publish my coherent point" — the other order would publish
908        // against a tree the agent expected to have refreshed.
909        for verb in [Verb::Sync, Verb::Publish] {
910            match self.honor_pending(verb, false).await {
911                Ok(Some(a)) => acks.push(a),
912                Ok(None) => {}
913                Err(e) => {
914                    // Review 2026-09-12, ack-1: a non-fence honor error is
915                    // THIS verb's problem. Returning here left the other
916                    // verb unhonoured for as long as the error recurred —
917                    // for a deterministic one, forever. The pending stands
918                    // and is retried; the tick goes on.
919                    eprintln!("flint-sync: {verb:?} honor failed (pending kept, retrying): {e}");
920                }
921            }
922        }
923        if !acks.is_empty() {
924            let last = acks.last().and_then(|a| a.seq);
925            self.ticker_from(last, None)?;
926        }
927        Ok(acks)
928    }
929
930    /// One floor tick: either honor a standing
931    /// pending sentinel that the budget or min-interval held back — the
932    /// boundary is honored by a REAL barrier, its ack stamped
933    /// `sentinel-deferred` — or run the ordinary cadence barrier.
934    ///
935    /// This is the arm B16 regresses on: the interval rewrite must not
936    /// let the poll arm starve cadence.
937    pub async fn floor_tick(&mut self) -> LeanResult<FloorOutcome> {
938        let mut out = FloorOutcome::default();
939        // A held-back sync pending is settled first, for the same
940        // reason as in the poll arm.
941        //
942        // Only a PUBLISH honor discharges the floor's own barrier: a
943        // sync ack carries a seq (the manifest it synced against) but
944        // publishes nothing, so treating it as "the floor ran" would
945        // skip cadence for a tick — a silent RPO regression.
946        let mut published = false;
947        for verb in [Verb::Sync, Verb::Publish] {
948            if self.load_pending(verb)?.is_some() {
949                match self.honor_pending(verb, true).await {
950                    Ok(Some(a)) => {
951                        out.seq = a.seq.or(out.seq);
952                        if verb == Verb::Publish && a.status == "ok" {
953                            published = true;
954                            out.uploaded = a.report.uploaded;
955                            out.deleted = a.report.deleted;
956                            out.consumed = a.report.consumed;
957                            out.no_change = a.report.no_change;
958                        }
959                        out.acks.push(a);
960                    }
961                    Ok(None) => {}
962                    Err(e) => {
963                        // Review 2026-09-12, ack-1: same rule as the poll
964                        // arm, and here the stake is the cadence barrier
965                        // below, which used to go unrun behind the error.
966                        eprintln!("flint-sync: {verb:?} honor failed on the floor (pending kept): {e}");
967                    }
968                }
969            }
970        }
971        // A publish sentinel honored on this tick already ran the fused
972        // barrier the floor owed; running a second one would be pure
973        // churn.
974        if !published {
975            let ran = self.cadence_barrier().await.map(|r| {
976                out.seq = r.seq;
977                out.no_change = r.no_change;
978                out.uploaded = r.uploaded.len();
979                out.deleted = r.deleted.len();
980                out.consumed = r.consumed;
981                out.parked = r.parked.len();
982                out.observed_etag = r.observed_etag.clone();
983                r.observed_seq
984            });
985            match ran {
986                Ok(observed) => {
987                    // The floor tick is the one scheduled request every
988                    // writer makes, idle or not, so it is where a
989                    // credential pause is seen to END — and, below, to
990                    // begin. A claim sees both too, but an idle writer
991                    // never claims. Best-effort: a gauge that failed to
992                    // write must not fail a tick that succeeded.
993                    let _ = self.clear_auth_pause();
994                    let etag = out.observed_etag.take();
995                    self.ticker_from(observed, etag)?;
996                    // A foreign 412 outranks "waiting for a boundary":
997                    // those paths are not ours to publish at all, and
998                    // saying "quiesce-pending" would send an operator
999                    // looking for a clock instead of a conflict record.
1000                    let forced = (out.parked > 0).then_some(super::gauges::Withheld::Parked412);
1001                    out.withheld_reason = self.write_gauges(forced)?.withheld_reason;
1002                    return Ok(out);
1003                }
1004                Err(e) => {
1005                    if e.is_auth() {
1006                        let _ = self.note_auth_pause();
1007                    }
1008                    return Err(e);
1009                }
1010            }
1011        }
1012        self.ticker_from(out.seq, None)?;
1013        out.withheld_reason = self.write_gauges(None)?.withheld_reason;
1014        Ok(out)
1015    }
1016
1017    /// The preStop drain (D10 rule 1): settle every owed ack BEFORE the
1018    /// lease release. A pending sentinel at SIGTERM is answered by the
1019    /// drain itself — the container-restart case, where the emptyDir and
1020    /// the agent both survive, must not strand a waiting agent.
1021    pub async fn drain(&mut self) -> LeanResult<Vec<Ack>> {
1022        let mut acks = vec![];
1023        // Only an ok PUBLISH ack means a boundary already ran. A sync
1024        // ack carries a seq too — the manifest it synced AGAINST — while
1025        // publishing nothing, so asking "did any ack carry a seq?" lets
1026        // a pending `.flint/sync` at SIGTERM cancel the drain's own
1027        // cite-everything pass and forfeit every byte since the last
1028        // boundary. The floor arm's `sync_ack_is_not_a_floor` comment
1029        // names this exact trap; the drain repeated it.
1030        let mut published = false;
1031        for verb in [Verb::Sync, Verb::Publish] {
1032            let is_publish = matches!(verb, Verb::Publish);
1033            if self.load_pending(verb)?.is_some() {
1034                match self.honor_pending_as(verb, true, Some("drain")).await {
1035                    Ok(Some(mut a)) => {
1036                        a.boundary = "drain".into();
1037                        // Re-write with the drain stamp so the agent can
1038                        // tell a drained boundary from a live one.
1039                        let bytes = serde_json::to_vec_pretty(&a)
1040                            .map_err(|e| LeanError::State(format!("ack: {e}")))?;
1041                        write_atomic(&self.ack_path(verb), &bytes)?;
1042                        published |= is_publish && a.status == "ok";
1043                        acks.push(a);
1044                    }
1045                    Ok(None) => {}
1046                    Err(e) => return Err(e),
1047                }
1048            }
1049        }
1050        if !published {
1051            // D10: the drain publishes EVERYTHING it can, as one declared barrier.
1052                let r = self.declared_barrier_as("drain").await?;
1053                self.ticker_from(r.observed_seq, r.observed_etag.clone())?;
1054                // Review 2026-09-12, inbox-1: a drain that leaves a path
1055                // parked has not published the tree; attesting it let the
1056                // node remove the agent's only copy.
1057                if !r.parked.is_empty() {
1058                    return Err(LeanError::State(format!(
1059                        "drain: {} path(s) could not be published (parked on a foreign version whose \
1060                         preserve failed): {:?} — not attesting",
1061                        r.parked.len(),
1062                        r.parked
1063                    )));
1064                }
1065        }
1066        Ok(acks)
1067    }
1068}