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 control;
45pub mod gated;
46pub mod gauges;
47pub use gauges::{status_report, Gauges, StatusReport};
48pub mod inbox;
49pub mod lease;
50pub mod manifest;
51pub mod metrics;
52pub mod scan;
53pub(crate) mod safefs;
54pub mod sentinel;
55pub mod state;
56pub mod sync;
57pub mod uds;
58pub mod verbs;
59
60#[cfg(test)]
61mod tests;
62
63use std::path::PathBuf;
64use std::sync::Arc;
65
66use flint_store::ObjectStore;
67
68/// Reserved control namespace inside the subtree prefix. Never scanned,
69/// never part of a checkout, never GC'd by the barrier.
70pub const LEAN_DIR: &str = ".flint/lean";
71
72/// The syncer's durable bookkeeping directory inside the workspace
73/// (emptyDir-scoped: survives container restarts, dies with the pod).
74pub const STATE_DIR: &str = ".flint-sync";
75
76/// The workspace-local control namespace (boundary-verbs plan D0): the
77/// agent's side of the file protocol. Reserved exactly as the gateway
78/// reserves `.flint/` on the HTTP side (flint-lean-gateway's `path_ok`) — the scan
79/// skips it, `classify` never makes it delete-eligible, and checkout
80/// never materializes a `files/.flint/...` citation into it.
81pub const CONTROL_DIR: &str = ".flint";
82
83/// Protocol version advertised in `.flint/capabilities.json`.
84pub const SENTINEL_PROTOCOL: u32 = 1;
85
86/// This binary's version, echoed to the agent (`capabilities.json`) and
87/// to the operator (the lease-heartbeat echo, §2.6). Both mixed-version
88/// holes — agent↔syncer and operator↔syncer — are detected by
89/// comparing what is RUNNING against what was asked for, and neither
90/// comparison exists without a version on the running side.
91pub const SYNCER_VERSION: &str = env!("CARGO_PKG_VERSION");
92
93/// Default whole-object publish ceiling; larger files go through the
94/// multipart compose path (`tier/flush.rs` uses the same 64 MiB split).
95pub const WHOLE_PUT_MAX: u64 = 64 * 1024 * 1024;
96
97#[derive(Debug, thiserror::Error)]
98pub enum LeanError {
99    #[error("store: {0}")]
100    Store(#[from] flint_store::StoreError),
101    #[error("io: {0}")]
102    Io(#[from] std::io::Error),
103    #[error("state: {0}")]
104    State(String),
105    /// The lease was lost or a higher epoch was observed: the caller
106    /// must stop publishing immediately (self-fence).
107    #[error("fenced: {0}")]
108    Fenced(String),
109    /// A budget refused the operation (checkout bytes/file-count).
110    #[error("budget: {0}")]
111    Budget(String),
112    /// A precondition the OPERATOR must change refused the run (the
113    /// prefix is claimed by another project). A restart cannot fix it,
114    /// so the binary exits `EXIT_REFUSED` — the code the CSI delivery
115    /// reads as final. Any other non-zero exit is retried in place by
116    /// `restartPolicy: OnFailure`, and a refusal sharing that code was a
117    /// crash loop the plugin reported to the tenant as "checkout in
118    /// progress" for as long as it lived (audit 2026-09-03, finding 5,
119    /// leg S22).
120    #[error("refused: {0}")]
121    Refused(String),
122}
123
124/// Process exit code for `LeanError::Refused` (sysexits `EX_CONFIG`).
125/// Mirrored as `SYNCER_EXIT_REFUSED` in the CSI node plugin, which does
126/// not depend on this crate; drill leg S22 is where the two are checked
127/// against each other.
128pub const EXIT_REFUSED: i32 = 78;
129
130impl LeanError {
131    /// The store refused our credentials (401/403, `ExpiredToken`,
132    /// `RequestTimeTooSkewed`): a broker, token or clock fault.
133    ///
134    /// A predicate rather than a `LeanError` variant on purpose.
135    /// `Store` is built by `#[from]`, so every `?` in the crate
136    /// constructs it implicitly; a variant would have to be produced by
137    /// hand at each of those sites and would be missed at exactly the
138    /// one that mattered. Reading through the wrapper cannot be
139    /// bypassed by a conversion nobody remembered to special-case.
140    pub fn is_auth(&self) -> bool {
141        matches!(self, LeanError::Store(flint_store::StoreError::Auth(_)))
142    }
143}
144
145pub type LeanResult<T> = Result<T, LeanError>;
146
147/// Citation policy (boundary-verbs plan D6). `hybrid` is the default:
148/// the fused barrier runs at every floor tick AND at every consumed
149/// publish sentinel, whichever comes first — citation never waits, so
150/// published-view freshness is never later than cadence-only.
151#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
152#[serde(rename_all = "lowercase")]
153pub enum BoundaryMode {
154    /// Exactly pre-boundary behavior (the escape hatch).
155    Cadence,
156    /// Cadence ∪ sentinels. No trade; the default.
157    Hybrid,
158    /// Durability and visibility split: uploads land as uncited object
159    /// versions every floor tick, citation happens at coherent points
160    /// only. Opt-in per workspace; requires the versioning conformance
161    /// probe and a `visibility_lag_bound_secs`.
162    Gated,
163}
164
165impl BoundaryMode {
166    pub fn parse(s: &str) -> Option<BoundaryMode> {
167        match s {
168            "cadence" => Some(BoundaryMode::Cadence),
169            "hybrid" => Some(BoundaryMode::Hybrid),
170            "gated" => Some(BoundaryMode::Gated),
171            _ => None,
172        }
173    }
174    pub fn as_str(&self) -> &'static str {
175        match self {
176            BoundaryMode::Cadence => "cadence",
177            BoundaryMode::Hybrid => "hybrid",
178            BoundaryMode::Gated => "gated",
179        }
180    }
181}
182
183/// Sentinel posture knob (D0.4). `auto` = verbs on unless the
184/// pre-existing-`.flint/` pre-flight trips; `force` accepts consumption
185/// of pre-existing sentinel-named files.
186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
187pub enum SentinelMode {
188    Auto,
189    Off,
190    Force,
191}
192
193impl SentinelMode {
194    pub fn parse(s: &str) -> Option<SentinelMode> {
195        match s {
196            "auto" => Some(SentinelMode::Auto),
197            "off" => Some(SentinelMode::Off),
198            "force" => Some(SentinelMode::Force),
199            _ => None,
200        }
201    }
202    pub fn as_str(&self) -> &'static str {
203        match self {
204            SentinelMode::Auto => "auto",
205            SentinelMode::Off => "off",
206            SentinelMode::Force => "force",
207        }
208    }
209}
210
211#[derive(Clone)]
212pub struct LeanConfig {
213    /// Bucket key prefix for this subtree (project- or subtree-scoped;
214    /// the proxy's tenancy boundary).
215    pub prefix: String,
216    /// Workspace root (the agent's tree).
217    pub root: PathBuf,
218    /// Publish cadence floor, seconds (the durability contract).
219    pub floor_secs: u64,
220    /// Stamp `sole_writer` on every manifest this syncer installs.
221    ///
222    /// Set for a workspace that is PUBLISHED rather than worked in —
223    /// forge's legible export is the shipped case. It tells every later
224    /// reader that an object off its citation was moved by a stranger,
225    /// so the reader refuses instead of adopting.
226    pub sole_writer: bool,
227    /// Whole-object ceiling; larger files use multipart compose.
228    pub whole_put_max: u64,
229    /// Entries per manifest chunk, in expectation, and the floor and
230    /// ceiling around it (chunked design §3). Config rather than
231    /// constants because §4 already anticipates this moving: the
232    /// pointer body is O(entries / target), and past ~10M entries the
233    /// target is one of the two things that can be changed instead of
234    /// adding a second level. Not surfaced on the CRD — nothing has
235    /// needed to tune it per workspace yet, and a knob with no caller
236    /// is a support burden.
237    /// Publish the manifest as a CHUNK LIST rather than one generation
238    /// object per citation (chunked design). Default ON since
239    /// 2026-09-04.
240    ///
241    /// The migration is FAIL-CLOSED and one-way per workspace: a
242    /// pointer-era binary cannot parse a chunked pointer, which is the
243    /// point — every layout change has to make an old reader REFUSE
244    /// rather than conclude the project is empty and re-seed over it.
245    ///
246    /// Small projects are not penalised, which is what made turning it
247    /// on unconditional rather than threshold-gated: under the target a
248    /// project is ONE chunk, so it costs a pointer plus one object and
249    /// two GETs to read — exactly what the single-generation layout
250    /// already cost. The indirection was paid for by step one.
251    ///
252    /// `false` is still a supported READ path forever (a workspace that
253    /// has not published since the migration) and is what the
254    /// single-layout tests set explicitly.
255    pub chunked: bool,
256    /// How long an unreferenced chunk must have sat before the reaper
257    /// may take it. Config rather than a constant because the model
258    /// makes it load-bearing in a workload-dependent way: it must
259    /// outlive the LONGEST publish, not the longest plausible one
260    /// (`LeanChunkGCRacyGrace`).
261    pub orphan_grace_secs: u64,
262    pub chunk_target: usize,
263    pub chunk_min: usize,
264    pub chunk_max: usize,
265    /// Checkout refusal budgets (0 = unlimited).
266    pub max_bytes: u64,
267    pub max_files: u64,
268    /// Window deadline slack beyond the barrier start, seconds. A dead
269    /// syncer's window is ignorable past this deadline.
270    pub window_slack_secs: u64,
271    /// Bounded concurrency for uploads and checkout fetches. The 0b
272    /// rig measured the sequential loops at 561-854 PUTs/s and
273    /// 1,000-2,000 GETs/s; fan-out multiplies directly against those.
274    pub fanout: usize,
275    /// Concurrent UPLOADS. Deliberately separate from `fanout`: the read
276    /// path is additionally bounded by `fetch_inflight_max_bytes`, the
277    /// upload path is bounded by nothing but this number, and
278    /// `UPLOAD_CHUNK_WAVES` multiplies it into the lease-fence window.
279    pub upload_fanout: usize,
280    /// The project this workspace claims to be, stamped from the CR
281    /// (`FLINT_SYNC_PROJECT_ID`). When set, `lease::verify_claim`
282    /// refuses to run over a prefix whose claim cell names another
283    /// project — the operator's refuse-foreign, enforced on the DATA
284    /// plane (audit 2026-09-03, finding 5). `None` = unstamped: no
285    /// check, the pre-operator posture.
286    pub project_id: Option<String>,
287    /// Ceiling on bytes in flight across the checkout fan-out window.
288    ///
289    /// `fanout` bounds the number of concurrent fetches but NOT their
290    /// size, and `get_whole` holds each whole object in RAM before it
291    /// reaches disk — so peak RSS was `fanout x largest object`, an
292    /// unbounded product of two numbers nobody sets together. A 32-wide
293    /// window over 64 MiB objects is 2 GiB in a syncer that ships with
294    /// no memory limit at all. Each entry takes permits proportional to
295    /// its size, so small-file trees still run the full width.
296    pub fetch_inflight_max_bytes: u64,
297    /// Driver tasks for the checkout fan-out (see `materialize`). Each
298    /// runs `fanout / fetch_drivers` fetches at a time over its own
299    /// slice of the tree, so the per-request SDK work spreads over that
300    /// many cores instead of one. Default: the machine's cores, at most
301    /// 8 (`FLINT_SYNC_FETCH_DRIVERS` / `spec.fetchDrivers`, 0 = auto).
302    /// Measured 2026-09-12: one driver is the ~3,300 files/s plateau on
303    /// i4i.xlarge whatever `fanout` says; four are 6,188-6,521 at
304    /// fanout 256, and fanout 256 beats 128 for the first time.
305    pub fetch_drivers: usize,
306
307    /// Materialise an object this size or larger as PARALLEL RANGES
308    /// rather than one whole-object GET. `0` disables it.
309    ///
310    /// Defaults to 8 MiB on the measurement that the repo's rule asked
311    /// for — runcr, i4i.large spot, us-west-1, one AZ, n=3, arms
312    /// interleaved per rep, RANGES not means:
313    ///
314    ///   6 x 1 GiB          71.33-71.88 s  ->  21.24-23.96 s
315    ///   4 GiB + 2k x 16 K  57.02-57.83 s  ->  15.30-18.30 s
316    ///   20k x 8 KiB        16.92-19.44 s  ->  16.40-17.39 s
317    ///
318    /// The third row is the one that justifies a THRESHOLD rather than
319    /// ranging everything: every object there is under it, the ranged
320    /// path never fires, and the arms agree. Before this, the shipped
321    /// path lost a cold 6 GiB checkout to a plain `aws s3 cp` of the
322    /// same prefix on the same node (21.7 s); it now ties it.
323    ///
324    /// Ranging also LOWERS peak RSS for a large object — see below — so
325    /// this default costs memory nothing and returns some.
326    ///
327    /// NOTE the interaction with `range_get_chunk_bytes`: a single part
328    /// is "one whole GET with extra steps" and `fetch_ranged` declines
329    /// it, so an object in [`range_get_min_bytes`, `range_get_chunk_bytes`)
330    /// passes this threshold and still takes the whole-object path. With
331    /// the defaults below the EFFECTIVE threshold is therefore 16 MiB,
332    /// not 8.
333    ///
334    /// Two costs of `get_whole` are paid together and this is what
335    /// removes both: one object is one TCP stream, so a multi-GiB
336    /// checkpoint runs at single-connection throughput no matter how
337    /// wide the fan-out; and the body is held whole in RAM before it
338    /// reaches disk, which is the product `fetch_inflight_max_bytes`
339    /// exists to bound. Ranges write at their offset as they land, so
340    /// peak RSS per object becomes `range_get_chunk_bytes x
341    /// range_get_parallelism` instead of the object's size.
342    pub range_get_min_bytes: u64,
343    /// Bytes per range request. Large enough that per-request overhead
344    /// is noise against S3's first-byte latency; small enough that a
345    /// retried range is cheap.
346    pub range_get_chunk_bytes: u64,
347    /// Ranges in flight FOR ONE OBJECT. The checkout's byte semaphore
348    /// still bounds the whole window, so this multiplies concurrency
349    /// only where the fan-out has run out of objects to work on — which
350    /// is exactly the tail this exists to shorten.
351    pub range_get_parallelism: usize,
352
353    // --- boundary verbs (plan docs/plans/flint-lean-boundary-verbs-plan.md) ---
354    /// Citation policy. Default `hybrid` ≡ today's behavior when the
355    /// agent never touches a sentinel.
356    pub boundary_mode: BoundaryMode,
357    /// Sentinel posture (D0.4 pre-flight override).
358    pub sentinel_mode: SentinelMode,
359    /// A consumed sentinel arriving sooner than this after the previous
360    /// sentinel-honoring barrier waits; touches inside the interval
361    /// coalesce into the pending record.
362    pub sentinel_min_interval_secs: u64,
363    /// Work-metered hourly cap (D3.1): a honor charges
364    /// `max(1, ceil(published_bytes / whole_put_max))` units, or 0 for
365    /// a no-diff honor. Exhaustion defers honors to the floor tick —
366    /// the workspace degrades to exactly cadence behavior.
367    pub sentinel_hourly_budget: u64,
368    /// Sentinel poll cadence (env-only, not a fleet contract).
369    pub sentinel_poll_secs: u64,
370    /// Gated: hard cap on citation staleness. Required iff gated.
371    pub visibility_lag_bound_secs: Option<u64>,
372    /// Gated: scan-to-scan stability window that counts as quiescence.
373    pub quiesce_bound_secs: u64,
374    /// Gated: forced-citation sources bounding the preStop drain.
375    pub staged_backlog_cap_objects: u64,
376    pub staged_backlog_cap_bytes: u64,
377    /// Gated: the noncurrent-version retention the operator provisions
378    /// on `<prefix>/files/` — the crash-window backstop BEHIND flint's
379    /// exact per-citation version GC (D8).
380    pub noncurrent_retention_days: u64,
381}
382
383impl LeanConfig {
384    pub fn new(prefix: &str, root: impl Into<PathBuf>) -> Self {
385        LeanConfig {
386            prefix: prefix.trim_end_matches('/').to_string(),
387            root: root.into(),
388            floor_secs: 60,
389            // Off by default: a workspace an agent WORKS IN wants the
390            // S3-wins arm, where an object past its citation is a human
391            // whose bytes should win. Only a published mirror sets it.
392            sole_writer: false,
393            whole_put_max: WHOLE_PUT_MAX,
394            chunked: true,
395            orphan_grace_secs: manifest::ORPHAN_GRACE_SECS,
396            chunk_target: chunk::CHUNK_TARGET,
397            chunk_min: chunk::CHUNK_MIN,
398            chunk_max: chunk::CHUNK_MAX,
399            max_bytes: 0,
400            max_files: 0,
401            window_slack_secs: 180,
402            fanout: 128,
403            upload_fanout: 32,
404            project_id: None,
405            fetch_inflight_max_bytes: 512 * 1024 * 1024,
406            fetch_drivers: std::thread::available_parallelism()
407                .map(|n| n.get())
408                .unwrap_or(1)
409                .clamp(1, 8),
410            range_get_min_bytes: 8 * 1024 * 1024,
411            range_get_chunk_bytes: 16 * 1024 * 1024,
412            range_get_parallelism: 4,
413            boundary_mode: BoundaryMode::Hybrid,
414            sentinel_mode: SentinelMode::Auto,
415            sentinel_min_interval_secs: 5,
416            sentinel_hourly_budget: 60,
417            sentinel_poll_secs: 1,
418            visibility_lag_bound_secs: None,
419            quiesce_bound_secs: 30,
420            staged_backlog_cap_objects: 5000,
421            staged_backlog_cap_bytes: 2 * 1024 * 1024 * 1024,
422            noncurrent_retention_days: 30,
423        }
424    }
425
426    pub fn file_key(&self, path: &str) -> String {
427        format!("{}/files/{}", self.prefix, path)
428    }
429    /// The LEGACY single-object manifest key. Still read (a workspace
430    /// written before the pointer layout has one) and, once migrated,
431    /// overwritten with a document that cannot parse as a manifest —
432    /// see `manifest::REFUSAL_DOC` for why deleting it would be a
433    /// data-loss shape rather than a tidy-up.
434    pub fn manifest_key(&self) -> String {
435        format!("{}/{}/manifest", self.prefix, LEAN_DIR)
436    }
437    /// The pointer: the ONLY mutable metadata object. Small, CAS'd on
438    /// every citation and on every takeover rotation.
439    pub fn current_key(&self) -> String {
440        format!("{}/{}/current", self.prefix, LEAN_DIR)
441    }
442    /// One immutable generation of the entries.
443    ///
444    /// Zero-padded seq FIRST, so a plain lexical listing is also
445    /// chronological — what the reaper and a human with `mc ls` both
446    /// want — and the writer's flush uuid after it, so the key is
447    /// unique per WRITE rather than per generation. Two writers that
448    /// reach the same seq therefore write two different objects and
449    /// race only at the pointer, which is the one place a race should
450    /// be decided; and a path that legitimately rewrites without
451    /// bumping (the gated lane's version-id backfill) is not refused by
452    /// a write-once key it never meant to collide with.
453    pub fn generation_key(&self, seq: u64, flush_uuid: &str) -> String {
454        format!("{}/{}/manifests/{seq:020}-{flush_uuid}", self.prefix, LEAN_DIR)
455    }
456
457    /// A chunk object, addressed by CONTENT rather than by generation.
458    /// Two generations that share a chunk share the object, which is
459    /// where the O(changed) publish actually comes from — naming chunks
460    /// by seq would rewrite every one of them on every publish even
461    /// when their contents were identical.
462    pub fn chunk_key(&self, addr: &str) -> String {
463        format!("{}/{}/chunks/{addr}", self.prefix, LEAN_DIR)
464    }
465    pub fn inbox_key(&self) -> String {
466        format!("{}/{}/inbox", self.prefix, LEAN_DIR)
467    }
468    pub fn epoch_key(&self) -> String {
469        format!("{}/{}/epoch", self.prefix, LEAN_DIR)
470    }
471    /// The operator's claim cell for this prefix (`lean_operator::
472    /// reconcile::claim_key` writes it; the syncer only READS it).
473    pub fn claim_key(&self) -> String {
474        format!("{}/{}/claim", self.prefix, LEAN_DIR)
475    }
476    pub fn conflict_key(&self, uuid: &str, path: &str) -> String {
477        format!("{}/{}/conflicts/{}/{}", self.prefix, LEAN_DIR, uuid, path)
478    }
479
480    /// A per-user DRAFT: a durable edit that has NOT been published.
481    ///
482    /// Under `LEAN_DIR`, which is the whole safety argument: that
483    /// namespace is never scanned, never checked out, never cited by a
484    /// manifest, and never swept (both sweeps are prefix-scoped —
485    /// `manifest::sweep_generations` to `manifests/`, `sweep_chunks` to
486    /// `chunks/`). So a draft is durable in the bucket while being
487    /// invisible to `classify`, to `checkout`, to every other user, and
488    /// to the agent — which is exactly what "saved but not published"
489    /// has to mean.
490    ///
491    /// Body and meta live under DISJOINT subtrees rather than sharing
492    /// one with a suffix: with `drafts/<u>/<path>.meta` a file legally
493    /// named `notes.meta` would have its BODY at the key that is
494    /// `notes`'s META, and one user's draft would silently read as
495    /// another draft's base etag.
496    pub fn draft_body_key(&self, user: &str, path: &str) -> String {
497        format!("{}/{}/drafts/{}/body/{}", self.prefix, LEAN_DIR, user, path)
498    }
499    /// The draft's base: which version of `files/<path>` it was edited
500    /// against. Written AFTER the body, so the only crash residue is a
501    /// body with no meta — an INCOMPLETE draft, which `promote` refuses
502    /// by name rather than publishing against a base it had to guess.
503    pub fn draft_meta_key(&self, user: &str, path: &str) -> String {
504        format!("{}/{}/drafts/{}/meta/{}", self.prefix, LEAN_DIR, user, path)
505    }
506    /// Listing prefix for one user's draft metas (the resume view).
507    pub fn draft_meta_prefix(&self, user: &str) -> String {
508        format!("{}/{}/drafts/{}/meta/", self.prefix, LEAN_DIR, user)
509    }
510    pub fn state_dir(&self) -> PathBuf {
511        self.root.join(STATE_DIR)
512    }
513    /// The workspace-local control namespace (D0).
514    pub fn control_dir(&self) -> PathBuf {
515        self.root.join(CONTROL_DIR)
516    }
517}
518
519/// Everything a syncer operation needs: store + config + durable state.
520pub struct Syncer {
521    pub store: Arc<dyn ObjectStore>,
522    pub cfg: LeanConfig,
523    pub state: state::SyncerState,
524    /// The held lease, once claimed. Barriers refuse to run without it.
525    pub lease: Option<flint_store::EpochLease>,
526    /// Standing conditions already written to `conflicts.jsonl` by THIS
527    /// process, so a condition that persists across every poll tick is
528    /// recorded once rather than per tick. Deliberately in-memory: a
529    /// restart is a new incarnation and should re-record what it finds.
530    pub noted_not_regular: std::collections::BTreeSet<String>,
531}
532
533/// Seconds since the epoch, saturating at 0 on a clock before it.
534pub fn now_unix() -> u64 {
535    std::time::SystemTime::now()
536        .duration_since(std::time::UNIX_EPOCH)
537        .map(|d| d.as_secs())
538        .unwrap_or(0)
539}