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