Skip to main content

flint_lean/
lib.rs

1//! flint-lean: the checkout/publish syncer core (plan of record:
2//! docs/plans/flint-lean-plan.md v2).
3//!
4//! Lean is a SEPARATE front end from the hub: no NFS, no daemon in the
5//! data path. An agent pod gets a full local checkout on an emptyDir
6//! (real ext4/APFS POSIX), and a syncer publishes snapshots to the
7//! bucket through a barrier. Durability is RPO-shaped (the flush floor);
8//! coherence is snapshot-level (the subtree manifest CAS is the atomic
9//! commit point). This module reuses `tier::store` (ObjectStore,
10//! GenerationStamps, conditional puts, the epoch cell) as a library and
11//! deliberately does NOT reuse the hub's flush/arbitration machinery —
12//! the plan pins that: the hub's 412 arbitration is LOCAL-WINS-overwrite
13//! (`tier/flush.rs`), which is exactly wrong under HITL second writers.
14//!
15//! The protocol here is the shipped shape `lean/PROTOCOL.md` writes out,
16//! from its model `lean/formal/LeanP1.tla` (the shape since the
17//! simplification of 2026-09-25: P1, P2, M1). PROTOCOL.md has every rule
18//! with the invariant it holds up and the model world that fails without
19//! it; the load-bearing ones, in brief:
20//!
21//! - **Every write lands at a fresh immutable handle**
22//!   (`files/<path>@<flush>`), and **the pointer CAS is the only commit**
23//!   — a writer's and the gateway's alike. Nothing overwrites a handle.
24//! - **The gateway commits each UI verb itself (P2)**: a save PUTs a fresh
25//!   handle, then ONE pointer CAS that lands only over the version the UI
26//!   read (else 412); a rename or a delete is one CAS. It never takes the
27//!   writers' lease. The request cell (`inbox`, `.flint/lean/inbox`)
28//!   carries only the two verb requests, "please publish" and "please
29//!   pull" — no entries, no removals, no window.
30//! - **The barrier**: consume (the cheap path: the scan trigger) → scan →
31//!   upload → claim the lease → verify the uploads (R4a) → install, a
32//!   three-way merge onto the CURRENT document with the baseline as the
33//!   merge base (mine wins modify/modify; R7 records theirs; a delete over
34//!   theirs applies, theirs kept, M3) → collect → finish (the baseline
35//!   follows what landed).
36//! - **What a commit stops citing is retired, not deleted**: logged at
37//!   the CAS and reaped only once G old (M1), so a reader that loaded
38//!   less than G ago can still fetch it. The orphan sweep runs only under
39//!   the lease (R4b) and spares a save in flight (G3).
40//! - **Takeover rotation**: a successor rewrites the document before
41//!   serving, so a deposed holder's pointer CAS 412s; the worker-facing
42//!   verbs are epoch-validated per request.
43//! - **Restart matrix**: marker present ⇒ never re-materialize (a
44//!   re-checkout would resurrect unpublished deletes); reload the
45//!   persisted baseline, rescan, self-recognize the lease via the
46//!   persisted incarnation id (emptyDir-scoped — a replacement pod gets
47//!   a fresh identity and takes the takeover path).
48//! - **Deletion basis**: a path is delete-eligible only if absent in
49//!   two consecutive scans AND present in our own baseline (the
50//!   rename-vs-walk guard).
51
52pub mod barrier;
53pub mod checkout;
54pub mod chunk;
55pub mod chunkcache;
56pub mod conformance;
57pub mod control;
58pub mod docview;
59pub mod gauges;
60pub use gauges::{status_report, Gauges, StatusReport};
61pub mod inbox;
62pub mod lease;
63pub mod manifest;
64pub mod metrics;
65pub mod news;
66pub mod reader;
67pub mod scan;
68pub(crate) mod safefs;
69pub mod sentinel;
70pub mod state;
71pub mod sync;
72pub mod trace;
73pub mod tracked;
74pub mod untracked;
75pub mod uds;
76pub mod verbs;
77
78#[cfg(test)]
79mod tests;
80#[cfg(test)] mod tests_upload_gate;
81#[cfg(test)] mod tests_conformance;
82#[cfg(test)] mod tests_bench;
83#[cfg(test)] mod tests_steps;
84#[cfg(test)] mod tests_cache;
85#[cfg(test)] mod tests_baseline;
86#[cfg(test)] mod tests_walk;
87#[cfg(test)] mod tests_news;
88// The binary's allocator (`fastalloc`, bin/flint_sync.rs) never reached
89// the test binary: the battery and the latency benches ran the system
90// malloc (glibc on the box) while flint-sync ships mimalloc. Under
91// `fastalloc` they run the shipped allocator too.
92#[cfg(all(test, feature = "fastalloc"))]
93#[global_allocator]
94static TEST_GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
95#[cfg(test)] mod tests_rechunk;
96#[cfg(test)] mod tests_docview;
97#[cfg(test)] mod tests_rehash;
98#[cfg(all(test, target_os = "linux"))] mod tests_fuzz;
99
100use std::path::PathBuf;
101use std::sync::Arc;
102
103use flint_store::ObjectStore;
104
105/// Reserved control namespace inside the subtree prefix. Never scanned,
106/// never part of a checkout, never GC'd by the barrier.
107pub const LEAN_DIR: &str = ".flint/lean";
108
109/// The syncer's durable bookkeeping directory inside the workspace
110/// (emptyDir-scoped: survives container restarts, dies with the pod).
111pub const STATE_DIR: &str = ".flint-sync";
112
113/// The chunk cache's directory inside the state dir (`chunkcache`).
114pub const CHUNK_CACHE_DIR: &str = "chunk-cache";
115
116/// Chunk GETs a manifest load keeps in flight (`LeanConfig::
117/// chunk_fetch_parallelism`). A chunk is about 1 MiB, so this many is
118/// 16 MiB in flight; the bodies are all held until the document is
119/// assembled anyway, as they were when they were fetched one at a time.
120pub const CHUNK_FETCH_PARALLELISM: usize = 16;
121
122/// The workspace-local control namespace (boundary-verbs plan D0): the
123/// agent's side of the file protocol. Reserved exactly as the gateway
124/// reserves `.flint/` on the HTTP side (flint-lean-gateway's `path_ok`) — the scan
125/// skips it, `classify` never makes it delete-eligible, and checkout
126/// never materializes a `files/.flint/...` citation into it.
127pub const CONTROL_DIR: &str = ".flint";
128
129/// Protocol version advertised in `.flint/capabilities.json`.
130pub const SENTINEL_PROTOCOL: u32 = 1;
131
132/// This binary's version, echoed to the agent (`capabilities.json`) and
133/// to the operator (the lease cell's echo, §2.6). Both mixed-version
134/// holes — agent↔syncer and operator↔syncer — are detected by
135/// comparing what is RUNNING against what was asked for, and neither
136/// comparison exists without a version on the running side.
137pub const SYNCER_VERSION: &str = env!("CARGO_PKG_VERSION");
138
139/// Default whole-object publish ceiling; larger files go through the
140/// multipart compose path (`tier/flush.rs` uses the same 64 MiB split).
141pub const WHOLE_PUT_MAX: u64 = 64 * 1024 * 1024;
142
143#[derive(Debug, thiserror::Error)]
144pub enum LeanError {
145    #[error("store: {0}")]
146    Store(#[from] flint_store::StoreError),
147    #[error("io: {0}")]
148    Io(#[from] std::io::Error),
149    #[error("state: {0}")]
150    State(String),
151    /// A gateway write's precondition, judged against the version the
152    /// workspace tracked, was lost at the inbox CELL's CAS: a different
153    /// write is tracked at the path now (design 2026-09-19, class 5 —
154    /// L-48's two browsers, decided atomically where the entry lands).
155    #[error("{path} is now tracked at {current}; re-read it and try again")]
156    Lost { path: String, current: String },
157    /// The cell moved under a commit section: this barrier is
158    /// abandoned — its manifest is never installed, its uploads stand
159    /// as uncited generations the next barrier adopts by `flush_uuid`.
160    /// Not a process exit and not a terminal state: the lease is held
161    /// per barrier, so the next one claims again.
162    #[error("fenced: {0}")]
163    Fenced(String),
164    /// A budget refused the operation (checkout bytes/file-count).
165    #[error("budget: {0}")]
166    Budget(String),
167    /// A precondition the OPERATOR must change refused the run (the
168    /// prefix is claimed by another project). A restart cannot fix it,
169    /// so the binary exits `EXIT_REFUSED` — the code the CSI delivery
170    /// reads as final. Any other non-zero exit is retried in place by
171    /// `restartPolicy: OnFailure`, and a refusal sharing that code was a
172    /// crash loop the plugin reported to the tenant as "checkout in
173    /// progress" for as long as it lived (audit 2026-09-03, finding 5,
174    /// leg S22).
175    #[error("refused: {0}")]
176    Refused(String),
177}
178
179/// Process exit code for `LeanError::Refused` (sysexits `EX_CONFIG`).
180/// Mirrored as `SYNCER_EXIT_REFUSED` in the CSI node plugin, which does
181/// not depend on this crate; drill leg S22 is where the two are checked
182/// against each other.
183pub const EXIT_REFUSED: i32 = 78;
184
185impl LeanError {
186    /// The store refused our credentials (401/403, `ExpiredToken`,
187    /// `RequestTimeTooSkewed`): a broker, token or clock fault.
188    ///
189    /// A predicate rather than a `LeanError` variant on purpose.
190    /// `Store` is built by `#[from]`, so every `?` in the crate
191    /// constructs it implicitly; a variant would have to be produced by
192    /// hand at each of those sites and would be missed at exactly the
193    /// one that mattered. Reading through the wrapper cannot be
194    /// bypassed by a conversion nobody remembered to special-case.
195    pub fn is_auth(&self) -> bool {
196        matches!(self, LeanError::Store(flint_store::StoreError::Auth(_)))
197    }
198}
199
200pub type LeanResult<T> = Result<T, LeanError>;
201
202/// Sentinel posture knob (D0.4). `auto` = verbs on unless the
203/// pre-existing-`.flint/` pre-flight trips; `force` accepts consumption
204/// of pre-existing sentinel-named files.
205#[derive(Clone, Copy, Debug, PartialEq, Eq)]
206pub enum SentinelMode {
207    Auto,
208    Off,
209    Force,
210}
211
212impl SentinelMode {
213    pub fn parse(s: &str) -> Option<SentinelMode> {
214        match s {
215            "auto" => Some(SentinelMode::Auto),
216            "off" => Some(SentinelMode::Off),
217            "force" => Some(SentinelMode::Force),
218            _ => None,
219        }
220    }
221    pub fn as_str(&self) -> &'static str {
222        match self {
223            SentinelMode::Auto => "auto",
224            SentinelMode::Off => "off",
225            SentinelMode::Force => "force",
226        }
227    }
228}
229
230/// What this syncer may do to the bucket (per-user access design §4.4).
231///
232/// `Read` is a syncer that follows the workspace and never writes to it:
233/// no fence, no barrier, no inbox consume, no garbage collection. Its
234/// floor tick pulls — the inbox and the pointer, and a `sync` when
235/// either moved — and a `publish` is answered `refused-read-only`. It is
236/// what a read-only credential is SUFFICIENT for; the credential, not
237/// this flag, is what keeps a reader's bytes out of the bucket.
238#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
239pub enum Access {
240    #[default]
241    ReadWrite,
242    Read,
243}
244
245impl Access {
246    /// `FLINT_SYNC_ACCESS`: `read` | `readWrite`.
247    pub fn parse(s: &str) -> Option<Access> {
248        match s {
249            "read" => Some(Access::Read),
250            "readWrite" => Some(Access::ReadWrite),
251            _ => None,
252        }
253    }
254    pub fn as_str(&self) -> &'static str {
255        match self {
256            Access::ReadWrite => "readWrite",
257            Access::Read => "read",
258        }
259    }
260    pub fn is_read(&self) -> bool {
261        *self == Access::Read
262    }
263}
264
265#[derive(Clone)]
266pub struct LeanConfig {
267    /// Bucket key prefix for this subtree (project- or subtree-scoped;
268    /// the proxy's tenancy boundary).
269    pub prefix: String,
270    /// Workspace root (the agent's tree).
271    pub root: PathBuf,
272    /// Publish cadence floor, seconds (the durability contract).
273    pub floor_secs: u64,
274    /// Stamp `sole_writer` on every manifest this syncer installs.
275    ///
276    /// Set for a workspace that is PUBLISHED rather than worked in —
277    /// forge's legible export is the shipped case. It tells every later
278    /// reader that an object off its citation was moved by a stranger,
279    /// so the reader refuses instead of adopting.
280    pub sole_writer: bool,
281    /// Read or read-write (`Access`). Default read-write.
282    pub access: Access,
283    /// Whole-object ceiling; larger files use multipart compose.
284    pub whole_put_max: u64,
285    /// Entries per manifest chunk, in expectation, and the floor and
286    /// ceiling around it (chunked design §3). Config rather than
287    /// constants because §4 already anticipates this moving: the
288    /// pointer body is O(entries / target), and past ~10M entries the
289    /// target is one of the two things that can be changed instead of
290    /// adding a second level. Not surfaced on the CRD — nothing has
291    /// needed to tune it per workspace yet, and a knob with no caller
292    /// is a support burden.
293    /// Publish the manifest as a CHUNK LIST rather than one generation
294    /// object per citation (chunked design). Default ON since
295    /// 2026-09-04.
296    ///
297    /// The migration is FAIL-CLOSED and one-way per workspace: a
298    /// pointer-era binary cannot parse a chunked pointer, which is the
299    /// point — every layout change has to make an old reader REFUSE
300    /// rather than conclude the project is empty and re-seed over it.
301    ///
302    /// Small projects are not penalised, which is what made turning it
303    /// on unconditional rather than threshold-gated: under the target a
304    /// project is ONE chunk, so it costs a pointer plus one object and
305    /// two GETs to read — exactly what the single-generation layout
306    /// already cost. The indirection was paid for by step one.
307    ///
308    /// `false` is still a supported READ path forever (a workspace that
309    /// has not published since the migration) and is what the
310    /// single-layout tests set explicitly.
311    pub chunked: bool,
312    /// How long an unreferenced chunk must have sat before the reaper
313    /// may take it. Config rather than a constant because the model
314    /// makes it load-bearing in a workload-dependent way: it must
315    /// outlive the LONGEST publish, not the longest plausible one
316    /// (`LeanChunkGCRacyGrace`).
317    pub orphan_grace_secs: u64,
318    /// RETIRE-AGE G (M1 of the 2026-09-24 simplification analysis): a
319    /// handle or chunk a commit stopped referencing is collected only once
320    /// its RETIREMENT is this old — not its write, which for a long-lived
321    /// file or chunk is long past the moment a UI save supersedes it. A
322    /// reader that loaded a document less than G ago can fetch everything
323    /// it cites. 0 = collect at once (the shape before; the battery's
324    /// default). `FLINT_SYNC_RETIRE_GRACE_SECS`.
325    pub retire_grace_secs: u64,
326    /// THE COLLECTION CADENCE (latency plan 2026-10-23, lever C): how often
327    /// a writer's commit section runs the collection pass — read the retire
328    /// logs, reap what is due, the orphan sweep when it is due too, the
329    /// generation and chunk sweeps. Each pass is a LIST of `retired/`, of
330    /// `manifests/` and of `chunks/`, and pointer GETs, all under the
331    /// lease; run on every publish they were seven sequential requests
332    /// added to every commit. Between passes nothing is deleted, so what a
333    /// commit retires waits up to G plus this long: storage, never safety
334    /// (the model's reap and sweeps may run at any commit, or never).
335    /// 0 = every commit, as before (the battery's setting).
336    /// `FLINT_SYNC_COLLECT_EVERY_SECS`.
337    pub collect_every_secs: u64,
338    /// Manifest chunks kept locally by content address, verified on every
339    /// use (`chunkcache`; latency plan lever A). `None` fetches every
340    /// chunk of every load, as before. The syncer keeps them under its
341    /// state dir, the gateway in memory.
342    pub chunk_cache: Option<Arc<dyn chunkcache::ChunkCache>>,
343    /// Chunk fetches a load keeps in flight at once: the misses of a
344    /// cached load, every chunk of an uncached one. 1 = one at a time,
345    /// the shape before. `FLINT_SYNC_CHUNK_FETCH_PARALLELISM`.
346    pub chunk_fetch_parallelism: usize,
347    /// A commit reads the document chunk by chunk (latency plan lever G,
348    /// `docview`): only the chunks its paths fall in, and it re-chunks only
349    /// what it changed. `false` loads and merges the whole document, the
350    /// shape before. `FLINT_SYNC_CHUNK_VIEW=off`.
351    pub chunk_view: bool,
352    /// Tests only: every chunk-view commit also runs the whole-document
353    /// merge and asserts the same document, chunk list, retire set and
354    /// per-path decisions (latency plan §12, "Verification").
355    pub g_shadow: bool,
356    /// A checkout measures its entries' racy flags when it opens the
357    /// agent's gate, not when it wrote each file (latency plan §17;
358    /// LeanScanRacy's `MeasureAtGate`), so the first scan hashes only what
359    /// it wrote in its last window. `false` leaves every checked-out entry
360    /// racy, the shape before. `FLINT_SYNC_RACY_AT_GATE=off`.
361    pub racy_at_gate: bool,
362    /// The tree's reserved blocks are this syncer's (the s3csi quota
363    /// image reserves 1/16 of itself for a group only the syncer's worker
364    /// holds): tree CONTENT it writes — a checkout, a consume, a sync — is
365    /// held to what its tenant could still write (`f_bavail`), so the
366    /// reserve is left for the bookkeeping that must outlive a full tree
367    /// (`safefs::leave_the_reserve`). `FLINT_SYNC_TREE_RESERVE=on`, set by
368    /// the plugin on a quota'd tree; off, nothing is checked.
369    pub tree_reserve: bool,
370    /// Tests only: the bytes `leave_the_reserve` reads as the tenant's room
371    /// instead of asking `statvfs` (a full filesystem needs root to build).
372    pub tree_room_for_tests: Option<u64>,
373    /// The fewest files still racy at the gate for which the checkout opens
374    /// it the rest of their window later (at most two seconds), so the
375    /// first scan hashes none of them: about where hashing them costs more
376    /// than the wait. 0 never waits. `FLINT_SYNC_RACY_WAIT_MIN`.
377    pub racy_wait_min: usize,
378    /// This workspace's chunks' aliases, by address (`docview`): what lets
379    /// a commit answer "who else cites this handle" without the document.
380    /// Shared by every clone of the config, so a syncer's views share it.
381    pub alias_cache: Arc<docview::AliasCache>,
382    pub chunk_target: usize,
383    pub chunk_min: usize,
384    pub chunk_max: usize,
385    /// Checkout refusal budgets (0 = unlimited).
386    pub max_bytes: u64,
387    pub max_files: u64,
388    /// Bounded concurrency for uploads and checkout fetches. The 0b
389    /// rig measured the sequential loops at 561-854 PUTs/s and
390    /// 1,000-2,000 GETs/s; fan-out multiplies directly against those.
391    pub fanout: usize,
392    /// Concurrent UPLOADS. Deliberately separate from `fanout`, and
393    /// measured separately: the read path's width is what the checkout
394    /// rig moved, this one is what the publish rig moved. Both paths are
395    /// byte-bounded, by different gates — the read side by
396    /// `fetch_inflight_max_bytes` here, the upload side by the store's
397    /// own gate (`with_upload_inflight_max_bytes`, 512 MiB from the
398    /// binary), which charges every whole body and every multipart part
399    /// from before its read until its PUT returns. So this number sets
400    /// request concurrency, not peak RSS.
401    pub upload_fanout: usize,
402    /// The project this workspace claims to be, stamped from the CR
403    /// (`FLINT_SYNC_PROJECT_ID`). When set, `lease::verify_claim`
404    /// refuses to run over a prefix whose claim cell names another
405    /// project — the operator's refuse-foreign, enforced on the DATA
406    /// plane (audit 2026-09-03, finding 5). `None` = unstamped: no
407    /// check, the pre-operator posture.
408    pub project_id: Option<String>,
409    /// Ceiling on bytes in flight across the checkout fan-out window.
410    ///
411    /// `fanout` bounds the number of concurrent fetches but NOT their
412    /// size, and `get_whole` holds each whole object in RAM before it
413    /// reaches disk — so peak RSS was `fanout x largest object`, an
414    /// unbounded product of two numbers nobody sets together. A 32-wide
415    /// window over 64 MiB objects is 2 GiB in a syncer that ships with
416    /// no memory limit at all. Each entry takes permits proportional to
417    /// its size, so small-file trees still run the full width.
418    pub fetch_inflight_max_bytes: u64,
419    /// Driver tasks for the checkout fan-out (see `materialize`). Each
420    /// runs `fanout / fetch_drivers` fetches at a time over its own
421    /// slice of the tree, so the per-request SDK work spreads over that
422    /// many cores instead of one. Default: the machine's cores, at most
423    /// 8 (`FLINT_SYNC_FETCH_DRIVERS` / `spec.fetchDrivers`, 0 = auto).
424    /// Measured 2026-09-12: one driver is the ~3,300 files/s plateau on
425    /// i4i.xlarge whatever `fanout` says; four are 6,188-6,521 at
426    /// fanout 256, and fanout 256 beats 128 for the first time.
427    pub fetch_drivers: usize,
428
429    /// Materialise an object this size or larger as PARALLEL RANGES
430    /// rather than one whole-object GET. `0` disables it.
431    ///
432    /// Defaults to 8 MiB on the measurement that the repo's rule asked
433    /// for — runcr, i4i.large spot, us-west-1, one AZ, n=3, arms
434    /// interleaved per rep, RANGES not means:
435    ///
436    ///   6 x 1 GiB          71.33-71.88 s  ->  21.24-23.96 s
437    ///   4 GiB + 2k x 16 K  57.02-57.83 s  ->  15.30-18.30 s
438    ///   20k x 8 KiB        16.92-19.44 s  ->  16.40-17.39 s
439    ///
440    /// The third row is the one that justifies a THRESHOLD rather than
441    /// ranging everything: every object there is under it, the ranged
442    /// path never fires, and the arms agree. Before this, the shipped
443    /// path lost a cold 6 GiB checkout to a plain `aws s3 cp` of the
444    /// same prefix on the same node (21.7 s); it now ties it.
445    ///
446    /// Ranging also LOWERS peak RSS for a large object — see below — so
447    /// this default costs memory nothing and returns some.
448    ///
449    /// NOTE the interaction with `range_get_chunk_bytes`: a single part
450    /// is "one whole GET with extra steps" and `fetch_ranged` declines
451    /// it, so an object in [`range_get_min_bytes`, `range_get_chunk_bytes`)
452    /// passes this threshold and still takes the whole-object path. With
453    /// the defaults below the EFFECTIVE threshold is therefore 16 MiB,
454    /// not 8.
455    ///
456    /// Two costs of `get_whole` are paid together and this is what
457    /// removes both: one object is one TCP stream, so a multi-GiB
458    /// checkpoint runs at single-connection throughput no matter how
459    /// wide the fan-out; and the body is held whole in RAM before it
460    /// reaches disk, which is the product `fetch_inflight_max_bytes`
461    /// exists to bound. Ranges write at their offset as they land, so
462    /// peak RSS per object becomes `range_get_chunk_bytes x
463    /// range_get_parallelism` instead of the object's size.
464    pub range_get_min_bytes: u64,
465    /// Bytes per range request. Large enough that per-request overhead
466    /// is noise against S3's first-byte latency; small enough that a
467    /// retried range is cheap.
468    pub range_get_chunk_bytes: u64,
469    /// Ranges in flight FOR ONE OBJECT. The checkout's byte semaphore
470    /// still bounds the whole window, so this multiplies concurrency
471    /// only where the fan-out has run out of objects to work on — which
472    /// is exactly the tail this exists to shorten.
473    pub range_get_parallelism: usize,
474
475    // --- boundary verbs (plan docs/plans/flint-lean-boundary-verbs-plan.md) ---
476    /// Sentinel posture (D0.4 pre-flight override).
477    pub sentinel_mode: SentinelMode,
478    /// A consumed sentinel arriving sooner than this after the previous
479    /// sentinel-honoring barrier waits; touches inside the interval
480    /// coalesce into the pending record.
481    pub sentinel_min_interval_secs: u64,
482    /// Work-metered hourly cap (D3.1): a honor charges
483    /// `max(1, ceil(published_bytes / whole_put_max))` units, or 0 for
484    /// a no-diff honor. Exhaustion defers honors to the floor tick —
485    /// the workspace degrades to exactly cadence behavior.
486    pub sentinel_hourly_budget: u64,
487    /// Sentinel poll cadence (env-only, not a fleet contract).
488    pub sentinel_poll_secs: u64,
489
490    // --- the news poll (latency plan 2026-10-23, lever B; `news.rs`) ---
491    /// While the workspace is active, read the pointer (a writer the cell
492    /// too) this often, and run the floor's barrier early when a peer or
493    /// the gateway moved it. 0 = off: news waits for the floor, as
494    /// before. `FLINT_SYNC_NEWS_POLL_SECS`.
495    pub news_poll_secs: u64,
496    /// How long after the last sign of work (a publish, a consume, an ack,
497    /// a served request, news) the workspace counts as active. Past it
498    /// the poll stops and only the floor reads the bucket.
499    /// `FLINT_SYNC_NEWS_ACTIVE_SECS`.
500    pub news_active_secs: u64,
501    /// The fewest seconds between two news-triggered barriers. News inside
502    /// it waits, so two busy writers cannot answer each other's publishes
503    /// faster than this. `FLINT_SYNC_NEWS_MIN_INTERVAL_SECS`.
504    pub news_min_interval_secs: u64,
505
506    // --- the publish fence (design 2026-09-13 §4; `lease.rs`) ---
507    /// How often a barrier waiting for the cell re-reads it.
508    pub claim_poll_secs: u64,
509    /// The poll cadence of the waiter at the head of the queue
510    /// (`lease::CLAIM_POLL_HEAD_MS`).
511    pub claim_poll_head_ms: u64,
512    /// Minimum spacing between two observations that COUNT toward the
513    /// dead-holder (`QUIET_POLLS`) and abandoned-handoff thresholds.
514    /// The tests set it to 0 so a deposal takes six polls, not a minute.
515    pub claim_quiet_spacing_secs: u64,
516    /// Longest a barrier waits for the cell before it is abandoned and
517    /// retried at the next floor.
518    pub claim_deadline_secs: u64,
519    /// DRILL-ONLY (`FLINT_SYNC_DRILL_HOLD_COMMIT_SECS`, never stamped by
520    /// the operator): sleep this long inside the commit section, right
521    /// after the claim. The one straggler the per-barrier lease still
522    /// has is a holder that stalls INSIDE its commit section, and that
523    /// section is milliseconds long — no drill can hit it by timing, so
524    /// this is how `run-writers.sh` opens the window. 0 = off.
525    pub drill_hold_commit_secs: u64,
526    /// DRILL-ONLY (`FLINT_SYNC_DRILL_HOLD_GC_SECS`, never stamped by the
527    /// operator): sleep this long before the collector's batched DELETE
528    /// of what the install retired, once per barrier. Under immutable
529    /// handles the collector is one unconditional `delete_many` with no
530    /// HEAD before it, so the window a drill wants is the one between
531    /// the CAS and that batch — a peer's sweep, or its commit, running
532    /// while this barrier still owes its tidy-up. 0 = off.
533    pub drill_hold_gc_secs: u64,
534    /// The protocol event trace (`trace.rs`); `None` = off.
535    pub event_trace: Option<trace::Sink>,
536    /// How often a WRITER sweeps for an upload a lost writer left at a
537    /// cited key (`untracked.rs`, finding 10), seconds;
538    /// `FLINT_SYNC_UNTRACKED_SWEEP_SECS`, 0 = off. Each sweep is one LIST of
539    /// the files prefix plus a manifest and an inbox read.
540    pub untracked_sweep_secs: u64,
541    /// How long such an object must have sat untracked before the sweep
542    /// tracks it — the gateway's `UNTRACKED_GRACE_SECS`, past any live
543    /// writer's claim and commit.
544    pub untracked_grace_secs: u64,
545    /// Threads the scan walks the tree with (`scan::scan_with`, lever E);
546    /// `FLINT_SYNC_SCAN_THREADS`. The CPUs this process may use, at most
547    /// 8; 1 walks with one worker.
548    pub scan_threads: usize,
549    /// How old the last cell WRITE (claim or renew) may be before a
550    /// delete in the commit section renews first (review 2026-09-18,
551    /// C2). A deposal needs the token still for six spaced polls (60 s),
552    /// so a write younger than this is proof no deposal has happened;
553    /// an older one is renewed — a conditional write that a deposal has
554    /// moved, so it fences. Comfortably below the threshold. Tests set 0
555    /// to collapse a stall.
556    pub renew_within_secs: u64,
557}
558
559/// The gateway's flush id for a UI write's handle: `ui-<added_unix>-<uuid>`.
560/// The time is the order a consume reads off two UI handles of one path
561/// (design 2026-09-19, R7's "later"): a citation that moved to a LATER
562/// gateway write supersedes an entry, one that moved to an earlier one
563/// leaves it live. One gateway's clock orders its own writes exactly;
564/// two replicas' clocks are the residual, and a tie or an inversion
565/// between them costs a surfaced conflict, never a silent drop.
566pub fn ui_flush(added_unix: u64) -> String {
567    format!("ui-{added_unix}-{}", uuid::Uuid::new_v4())
568}
569
570/// The longest flush id a handle carries: the gateway's `ui-<secs>-<uuid>`
571/// with a `u64` of seconds (a barrier's is a bare uuid; an ingress copy's,
572/// `ingress-<etag>`, adopts an object whose bare key already fit).
573pub const MAX_FLUSH_LEN: usize = "ui-".len() + 20 + "-".len() + 36;
574
575/// The longest key the stores lean runs on take: 1,024 bytes of UTF-8 on
576/// S3 (`KeyTooLongError`) and on GCS.
577pub const MAX_KEY_BYTES: usize = 1024;
578
579/// The `added_unix` a UI flush id carries; `None` for any other flush.
580pub fn ui_flush_added(flush: &str) -> Option<u64> {
581    let rest = flush.strip_prefix("ui-")?;
582    let (t, _) = rest.split_once('-')?;
583    t.parse().ok()
584}
585
586/// Does `flush` read as a flush id this crate or the gateway mints — a
587/// uuid, the `ui-…` form, or the gateway's `gateway-…` forms — rather than
588/// a tail of a path that happens to follow an `@`?
589fn flush_looks_minted(flush: &str) -> bool {
590    if flush.starts_with("ui-")
591        || flush.starts_with("gateway-")
592        || flush.starts_with("ingress-")
593        || flush == "conflict-preserve"
594    {
595        return true;
596    }
597    uuid::Uuid::parse_str(flush).is_ok()
598}
599
600impl LeanConfig {
601    pub fn new(prefix: &str, root: impl Into<PathBuf>) -> Self {
602        let root: PathBuf = root.into();
603        let chunk_cache: Arc<dyn chunkcache::ChunkCache> =
604            Arc::new(chunkcache::DiskChunkCache::new(root.join(STATE_DIR).join(CHUNK_CACHE_DIR)));
605        LeanConfig {
606            prefix: prefix.trim_end_matches('/').to_string(),
607            root,
608            floor_secs: 60,
609            // Off by default: a workspace an agent WORKS IN wants the
610            // S3-wins arm, where an object past its citation is a human
611            // whose bytes should win. Only a published mirror sets it.
612            sole_writer: false,
613            access: Access::ReadWrite,
614            whole_put_max: WHOLE_PUT_MAX,
615            chunked: true,
616            orphan_grace_secs: manifest::ORPHAN_GRACE_SECS,
617            retire_grace_secs: manifest::RETIRE_GRACE_SECS,
618            collect_every_secs: manifest::RETIRE_GRACE_SECS / 2,
619            chunk_cache: Some(chunk_cache),
620            chunk_fetch_parallelism: CHUNK_FETCH_PARALLELISM,
621            chunk_view: true,
622            g_shadow: false,
623            racy_at_gate: true,
624            tree_reserve: false,
625            tree_room_for_tests: None,
626            racy_wait_min: 10_000,
627            alias_cache: Arc::default(),
628            chunk_target: chunk::CHUNK_TARGET,
629            chunk_min: chunk::CHUNK_MIN,
630            chunk_max: chunk::CHUNK_MAX,
631            max_bytes: 0,
632            max_files: 0,
633            fanout: 128,
634            upload_fanout: 32,
635            project_id: None,
636            // 128 MiB (was 512): fits the deployed worker's 1Gi limit on a
637            // tree of 1 GiB objects at no cost in wall time (door drill
638            // 2026-09-12 §5, finding 4).
639            fetch_inflight_max_bytes: 128 * 1024 * 1024,
640            fetch_drivers: std::thread::available_parallelism()
641                .map(|n| n.get())
642                .unwrap_or(1)
643                .clamp(1, 8),
644            range_get_min_bytes: 8 * 1024 * 1024,
645            range_get_chunk_bytes: 16 * 1024 * 1024,
646            range_get_parallelism: 4,
647            sentinel_mode: SentinelMode::Auto,
648            sentinel_min_interval_secs: 5,
649            sentinel_hourly_budget: 60,
650            sentinel_poll_secs: 1,
651            news_poll_secs: 1,
652            news_active_secs: 300,
653            news_min_interval_secs: 5,
654            claim_poll_secs: lease::CLAIM_POLL_SECS,
655            claim_poll_head_ms: lease::CLAIM_POLL_HEAD_MS,
656            claim_quiet_spacing_secs: lease::QUIET_SPACING_SECS,
657            claim_deadline_secs: lease::CLAIM_DEADLINE_SECS,
658            drill_hold_commit_secs: 0,
659            drill_hold_gc_secs: 0,
660            event_trace: None,
661            untracked_sweep_secs: 3600,
662            untracked_grace_secs: untracked::UNTRACKED_GRACE_SECS,
663            scan_threads: scan::default_threads(),
664            renew_within_secs: 20,
665        }
666    }
667
668    /// The INGRESS key of a path: where an outside writer's `aws s3 cp`
669    /// lands, and where every object of this workspace lived before the
670    /// immutable-handles layout (design 2026-09-19, R5). Nothing this
671    /// crate publishes writes it any more; the read door, the consume and
672    /// the checkout fetch HANDLES.
673    pub fn file_key(&self, path: &str) -> String {
674        format!("{}/files/{}", self.prefix, path)
675    }
676    /// A HANDLE: the one key a write lands at, `files/<path>@<flush>`
677    /// (design 2026-09-19, R1). Nobody else ever writes it, the manifest
678    /// cites it by name, and it is deleted only once no document cites it
679    /// (R3). It sorts beside the bare path, so a listing of a directory
680    /// shows every live version of a file together. `flush` is the
681    /// writer's flush id: a barrier's uuid, or the gateway's `ui-…` form
682    /// ([`ui_flush`]), whose ORDER a consume can read off the name.
683    pub fn handle_key(&self, path: &str, flush: &str) -> String {
684        format!("{}/files/{}@{}", self.prefix, path, flush)
685    }
686    /// Whether every handle `path` can have is a key the store takes
687    /// (L-145): its handle under the longest flush id anyone mints
688    /// ([`MAX_FLUSH_LEN`]) within [`MAX_KEY_BYTES`]. One rule for every
689    /// writer, so a path the agent can publish the UI can save too. A
690    /// file whose path does not fit is HELD by the barrier (as a name that
691    /// is not UTF-8 is), and the gateway refuses to save or move one
692    /// there: the PUT would fail on S3 at every attempt.
693    pub fn key_fits(&self, path: &str) -> bool {
694        self.handle_key(path, "").len() + MAX_FLUSH_LEN <= MAX_KEY_BYTES
695    }
696    /// The files namespace this workspace's handles and ingress objects
697    /// live under, with its trailing slash — what a sweep lists.
698    pub fn files_prefix(&self) -> String {
699        format!("{}/files/", self.prefix)
700    }
701    /// `(path, flush)` of a handle under this prefix; `None` for a key
702    /// that is not a handle here — the bare path of an ingress object
703    /// included, so a sweep can tell the two apart by the name alone. A
704    /// path may itself contain `@`: the flush is the LAST `@`-suffix, and
705    /// a flush is a uuid or the gateway's `ui-` form, neither of which a
706    /// path component would be mistaken for in the same position.
707    pub fn handle_parts<'a>(&self, key: &'a str) -> Option<(&'a str, &'a str)> {
708        let rest = key.strip_prefix(&self.files_prefix())?;
709        let (path, flush) = rest.rsplit_once('@')?;
710        if path.is_empty() || !flush_looks_minted(flush) {
711            return None;
712        }
713        Some((path, flush))
714    }
715    /// The LEGACY single-object manifest key. Still read (a workspace
716    /// written before the pointer layout has one) and, once migrated,
717    /// overwritten with a document that cannot parse as a manifest —
718    /// see `manifest::REFUSAL_DOC` for why deleting it would be a
719    /// data-loss shape rather than a tidy-up.
720    pub fn manifest_key(&self) -> String {
721        format!("{}/{}/manifest", self.prefix, LEAN_DIR)
722    }
723    /// The pointer: the ONLY mutable metadata object. Small, CAS'd on
724    /// every citation and on every takeover rotation.
725    pub fn current_key(&self) -> String {
726        format!("{}/{}/current", self.prefix, LEAN_DIR)
727    }
728    /// One immutable generation of the entries.
729    ///
730    /// Zero-padded seq FIRST, so a plain lexical listing is also
731    /// chronological — what the reaper and a human with `mc ls` both
732    /// want — and the writer's flush uuid after it, so the key is
733    /// unique per WRITE rather than per generation. Two writers that
734    /// reach the same seq therefore write two different objects and
735    /// race only at the pointer, which is the one place a race should
736    /// be decided; and a path that legitimately rewrites without
737    /// bumping is not refused by a write-once key it never meant to
738    /// collide with.
739    pub fn generation_key(&self, seq: u64, flush_uuid: &str) -> String {
740        format!("{}/{}/manifests/{seq:020}-{flush_uuid}", self.prefix, LEAN_DIR)
741    }
742
743    /// A chunk object, addressed by CONTENT rather than by generation.
744    /// Two generations that share a chunk share the object, which is
745    /// where the O(changed) publish actually comes from — naming chunks
746    /// by seq would rewrite every one of them on every publish even
747    /// when their contents were identical.
748    pub fn chunk_key(&self, addr: &str) -> String {
749        format!("{}/{}/chunks/{addr}", self.prefix, LEAN_DIR)
750    }
751    pub fn inbox_key(&self) -> String {
752        format!("{}/{}/inbox", self.prefix, LEAN_DIR)
753    }
754    pub fn epoch_key(&self) -> String {
755        format!("{}/{}/epoch", self.prefix, LEAN_DIR)
756    }
757    /// The operator's claim cell for this prefix (`lean_operator::
758    /// reconcile::claim_key` writes it; the syncer only READS it).
759    pub fn claim_key(&self) -> String {
760        format!("{}/{}/claim", self.prefix, LEAN_DIR)
761    }
762    pub fn conflict_key(&self, uuid: &str, path: &str) -> String {
763        format!("{}/{}/conflicts/{}/{}", self.prefix, LEAN_DIR, uuid, path)
764    }
765
766    /// A per-user DRAFT: a durable edit that has NOT been published.
767    ///
768    /// Under `LEAN_DIR`, which is the whole safety argument: that
769    /// namespace is never scanned, never checked out, never cited by a
770    /// manifest, and never swept (both sweeps are prefix-scoped —
771    /// `manifest::sweep_generations` to `manifests/`, `sweep_chunks` to
772    /// `chunks/`). So a draft is durable in the bucket while being
773    /// invisible to `classify`, to `checkout`, to every other user, and
774    /// to the agent — which is exactly what "saved but not published"
775    /// has to mean.
776    ///
777    /// Body and meta live under DISJOINT subtrees rather than sharing
778    /// one with a suffix: with `drafts/<u>/<path>.meta` a file legally
779    /// named `notes.meta` would have its BODY at the key that is
780    /// `notes`'s META, and one user's draft would silently read as
781    /// another draft's base etag.
782    pub fn draft_body_key(&self, user: &str, path: &str) -> String {
783        format!("{}/{}/drafts/{}/body/{}", self.prefix, LEAN_DIR, user, path)
784    }
785    /// The draft's base: which version of `files/<path>` it was edited
786    /// against. Written AFTER the body, so the only crash residue is a
787    /// body with no meta — an INCOMPLETE draft, which `promote` refuses
788    /// by name rather than publishing against a base it had to guess.
789    pub fn draft_meta_key(&self, user: &str, path: &str) -> String {
790        format!("{}/{}/drafts/{}/meta/{}", self.prefix, LEAN_DIR, user, path)
791    }
792    /// Listing prefix for one user's draft metas (the resume view).
793    pub fn draft_meta_prefix(&self, user: &str) -> String {
794        format!("{}/{}/drafts/{}/meta/", self.prefix, LEAN_DIR, user)
795    }
796    pub fn state_dir(&self) -> PathBuf {
797        self.root.join(STATE_DIR)
798    }
799    /// The workspace-local control namespace (D0).
800    pub fn control_dir(&self) -> PathBuf {
801        self.root.join(CONTROL_DIR)
802    }
803}
804
805/// Everything a syncer operation needs: store + config + durable state.
806pub struct Syncer {
807    pub store: Arc<dyn ObjectStore>,
808    pub cfg: LeanConfig,
809    pub state: state::SyncerState,
810    /// The lease held for the CURRENT commit section, if any. `None`
811    /// between barriers: the cell arbitrates who installs a manifest and
812    /// nothing else (design 2026-09-13 §4), so a barrier claims after
813    /// its uploads and releases after its baseline rewrite.
814    pub lease: Option<flint_store::EpochLease>,
815    /// When this process last WROTE the cell (a claim, a renew): the
816    /// clock the commit section's deletes fence on (review 2026-09-18,
817    /// C2). `None` while no write is known — an adopted claim writes
818    /// nothing — so the first delete renews.
819    pub cell_written_at: Option<std::time::Instant>,
820    /// Standing conditions already written to `conflicts.jsonl` by THIS
821    /// process, so a condition that persists across every poll tick is
822    /// recorded once rather than per tick. Deliberately in-memory: a
823    /// restart is a new incarnation and should re-record what it finds.
824    pub noted_not_regular: std::collections::BTreeSet<String>,
825}
826
827/// Seconds since the epoch, saturating at 0 on a clock before it.
828pub fn now_unix() -> u64 {
829    std::time::SystemTime::now()
830        .duration_since(std::time::UNIX_EPOCH)
831        .map(|d| d.as_secs())
832        .unwrap_or(0)
833}