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