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