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