flint-lean 0.10.0

flint lean: the checkout/publish syncer for S3-backed agent workspaces, and the gateway verbs it shares with flint-lean-gateway
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
//! The lean subtree manifest: one JSON document, CAS-written, the
//! atomic commit point every reader trusts (checkout, sync, DR).
//!
//! Deliberately NOT `tier::manifest`: the hub's writer rebuilds the
//! whole document from its local walk and cannot merge (its Entry has
//! no author identity), which is the amputation engine the lean review
//! reproduced three ways. This writer merges three-way:
//! base = the manifest view at OUR last install, mine = this barrier's
//! walk, theirs = bucket-current. Foreign entries are preserved and
//! surfaced for the next consume; a local delete loses to a foreign
//! modify (delete/modify resolves conservative — the formal model's
//! GC-vs-merge counterexample is exactly this case).

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use flint_store::{
    crc64_nvme, GenerationStamps, ObjectMeta, ObjectStore, PutCondition, StoreError,
};

use super::{LeanConfig, LeanError, LeanResult};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LeanEntry {
    /// Object key (under `<prefix>/files/`).
    pub key: String,
    pub etag: String,
    /// Full-object CRC-64/NVME of the cited bytes, in wire (base64)
    /// form — computed by the flint client that MOVED the bytes (the
    /// publisher over what it uploaded, a consume or checkout over what
    /// it wrote), never copied from a backend header. It is the one
    /// integrity check that holds on every backend: S3 attests a
    /// checksum on the wire, Ozone attests none, and the manifest's
    /// value describes the bytes either way. Every reader verifies a
    /// fresh fetch against it before the file becomes visible.
    pub crc64_b64: String,
    pub size: u64,
    pub mode: u32,
    pub mtime_unix: i64,
    pub generation: u64,
    /// The publishing writer's lease epoch (0 = a gateway/HITL write).
    pub epoch: u64,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LeanManifest {
    pub seq: u64,
    /// path -> entry. Paths are workspace-relative, '/'-separated.
    pub entries: BTreeMap<String, LeanEntry>,
    /// This workspace is published by exactly ONE writer, so an object
    /// that has moved off its citation was moved by something that is
    /// not the publisher.
    ///
    /// It exists because a reader's default arm on a 412 is an
    /// explicit S3-wins adopt — right for a lean workspace, where an
    /// object past its citation means a human wrote newer bytes that
    /// ought to win. On a MIRROR that reading is exactly inverted: the
    /// publisher is the only party entitled to write, so "past the
    /// citation" means a stranger did, and adopting copies bytes no
    /// manifest cites into a reader's tree with no error and no
    /// conflict record. Composition drill C4 measured that against
    /// forge's legible export.
    ///
    /// Set by the installing pass from config, never inherited.
    #[serde(default)]
    pub sole_writer: bool,
    /// Which citation source installed this manifest (§2.4.1). Also
    /// stamped on the object's metadata so it is readable by HEAD.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub boundary_source: Option<String>,
    /// What each published delete RETIRED, path -> the etag the document
    /// cited before the delete, kept until the path is cited again (or
    /// `TOMBSTONE_KEEP_SEQS` generations pass). Review 2026-09-18, H1e: a
    /// writer that adopted a UI write while it was still pending, and
    /// merges after another writer cited it and then deleted it knowingly,
    /// otherwise sees only "baseline ≠ merge base, object at the key" —
    /// the same view a delete that RACED the write leaves, where modify
    /// rightly wins — and the citation repair (removed in step 5) resurrected
    /// the deleted generation. The tombstone is the one fact that tells the
    /// two apart.
    /// Empty on every document written before this field existed.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub tombstones: BTreeMap<String, Tombstone>,
}

/// A published delete's record in the document: the generation it retired
/// and the generation of the document that published it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Tombstone {
    pub etag: String,
    pub seq: u64,
}

/// How many document generations a tombstone outlives its delete. A writer
/// whose merge base is older than this can still re-cite what it holds —
/// the residual, named in SAFETY.md §4.14.
pub const TOMBSTONE_KEEP_SEQS: u64 = 10_000;

impl LeanManifest {
    /// COMPACT, not pretty. This document is O(entries) and it moves on
    /// every path that matters: the pre-marker checkout GET, every
    /// barrier merge GET and CAS PUT, and every gateway read verb. The
    /// indentation was ~22% of those bytes and of the parse that
    /// follows. `mc cat | jq` — the operator's and every rig's only
    /// debugging surface for the CAS commit point — is unaffected.
    pub fn to_bytes(&self) -> Vec<u8> {
        serde_json::to_vec(self).expect("manifest serializes")
    }
    pub fn parse(bytes: &[u8]) -> Result<LeanManifest, String> {
        serde_json::from_slice(bytes).map_err(|e| e.to_string())
    }
}

/// The bucket-current manifest + its document ETag (the CAS token).
/// The pointer document at `<prefix>/.flint/lean/current` — the ONLY
/// mutable metadata object under the pointer layout (design of record:
/// `docs/plans/flint-lean-manifest-pointer-design.md`).
///
/// Everything a reader needs before it decides whether to fetch entries
/// at all lives here, so the barrier's idle tick reads a few hundred
/// bytes instead of a document that runs to 264 MiB at 1M entries.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Pointer {
    /// The manifest generation this pointer publishes. Bumped by a
    /// citation AND by a takeover rotation, which is why it is not the
    /// same number as `entries_seq`.
    pub seq: u64,
    /// The single immutable object holding every entry of this
    /// generation — the step-one layout. `None` on a CHUNKED pointer.
    ///
    /// Optional here and REQUIRED in the step-one binary's schema, and
    /// that asymmetry is the migration: a step-one reader handed a
    /// chunked pointer fails to parse it and refuses, rather than
    /// decoding a pointer it half-understands. `manifest::load` maps a
    /// missing object to `Ok(None)`, `None` means FIRST WRITE, and a
    /// barrier answers that with `If-None-Match: *` — so every layout
    /// change has to make an old reader refuse rather than conclude the
    /// project is empty. **Do not give this field a serde default in a
    /// released binary; that is what makes the refusal work.**
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub entries_key: Option<String>,
    /// The `seq` the entries object was written under. A rotation
    /// leaves this ALONE while bumping `seq`, which is exactly what
    /// lets a follower skip the entries GET after a takeover.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub entries_seq: Option<u64>,
    /// The chunk list — the step-two layout. `Some(vec![])` is a
    /// legitimately EMPTY project and is distinguishable from absence,
    /// which is why this is an Option around a Vec rather than a bare
    /// Vec with a default.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub chunks: Option<Vec<super::chunk::ChunkRef>>,
    /// Carried here as well as on the manifest because the pointer is
    /// the authority for the fields it carries, and a reader that
    /// reconstructs a manifest from it must not lose the flag that
    /// decides whether it may adopt.
    #[serde(default)]
    pub sole_writer: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub boundary_source: Option<String>,
    /// The epoch that installed this pointer. Diagnostic; the fence is
    /// the CAS, not this field.
    pub epoch: u64,
    /// The content address of the document's tombstones in the chunked
    /// layout (`chunk::TombstoneBody`, at `chunk_key`), `None` when the
    /// document has none. The single-object layout carries them inline.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tombstones: Option<String>,
}

/// Where a pointer's entries actually live.
#[derive(Debug)]
pub enum Entries<'a> {
    /// One generation object (step one).
    Single { key: &'a str, seq: u64 },
    /// A chunk list (step two). Empty = an empty project.
    Chunked(&'a [super::chunk::ChunkRef]),
}

impl Pointer {
    /// Resolve the layout, refusing anything ambiguous.
    ///
    /// BOTH forms present, or NEITHER, is malformed and must fail —
    /// never pick one. A pointer carrying both would let two readers
    /// that broke the tie differently disagree about the contents of
    /// the same seq, which is the one thing a single visible object was
    /// supposed to make impossible.
    pub fn entries(&self) -> LeanResult<Entries<'_>> {
        match (&self.entries_key, &self.chunks) {
            (Some(k), None) => {
                Ok(Entries::Single { key: k, seq: self.entries_seq.unwrap_or(self.seq) })
            }
            (None, Some(c)) => Ok(Entries::Chunked(c)),
            (Some(_), Some(_)) => Err(super::LeanError::State(format!(
                "manifest pointer at seq {} names BOTH a generation object and a chunk list; \
                 refusing to guess which describes this manifest",
                self.seq
            ))),
            (None, None) => Err(super::LeanError::State(format!(
                "manifest pointer at seq {} names no entries at all — neither a generation \
                 object nor a chunk list; an empty project is an empty chunk LIST, not a \
                 missing one",
                self.seq
            ))),
        }
    }
}

/// What a writer must present to CAS. Carries the LAYOUT as well as the
/// tag, because a workspace mid-migration has a legacy manifest and no
/// pointer yet, and the two cases take different preconditions: an
/// If-Match on the object the writer read, versus an If-None-Match on a
/// pointer that must not already exist.
#[derive(Debug, Clone, PartialEq)]
pub struct ManifestHandle {
    pub etag: String,
    /// True when the document was read from the LEGACY single-object
    /// key, i.e. this write is the migration.
    pub legacy: bool,
    /// The chunk list this write is superseding, empty if the workspace
    /// is not chunked yet. Carried on the handle rather than re-read at
    /// publish time because the caller has just loaded it, and because
    /// a publish that had to GET the pointer again to find out what it
    /// already knew would pay a request per barrier for nothing.
    ///
    /// This is what makes a publish O(changed): a chunk whose address
    /// is already here is not sent.
    pub prev_chunks: Vec<super::chunk::ChunkRef>,
    /// The tombstones object the superseded pointer names, if any: retired
    /// with the chunks when the new pointer names another (M1).
    pub prev_tombstones: Option<String>,
}

/// What the legacy key is overwritten with once a workspace has moved to
/// the pointer layout. It must NOT parse as a manifest: `LeanManifest`
/// gives `seq` and `entries` no serde default, so an OLD `flint-sync`
/// reading this gets `LeanError::State` and refuses.
///
/// Deleting the legacy key instead would be a data-loss shape, not a
/// tidy-up: `manifest::load` maps a missing object to `Ok(None)`, `None`
/// means FIRST WRITE, and a barrier answers that with
/// `IfNoneMatchAny` — so an old syncer would conclude the project is
/// empty and re-seed over it.
pub const REFUSAL_DOC: &str = concat!(
    "{\"moved\":\".flint/lean/current\",",
    "\"note\":\"this workspace uses the manifest pointer layout; upgrade flint-sync\"}"
);

pub struct LoadedManifest {
    pub manifest: LeanManifest,
    /// The CAS handle: the POINTER's etag under the pointer layout, the
    /// legacy object's etag before migration.
    pub etag: String,
    /// The object's own Last-Modified. The GET already carries it; a
    /// caller that wants the coherence stamp must not pay a second
    /// request for what this one threw away (review: U31).
    pub last_modified_unix: Option<u64>,
    /// `None` ⇒ this workspace is still on the legacy single-object
    /// layout and the next write migrates it.
    pub pointer: Option<Pointer>,
}

impl LoadedManifest {
    /// The handle a writer presents to CAS over this read.
    pub fn handle(&self) -> ManifestHandle {
        ManifestHandle {
            etag: self.etag.clone(),
            legacy: self.pointer.is_none(),
            prev_chunks: match self.pointer.as_ref().map(|p| p.entries()) {
                Some(Ok(Entries::Chunked(c))) => c.to_vec(),
                _ => Vec::new(),
            },
            prev_tombstones: self.pointer.as_ref().and_then(|p| p.tombstones.clone()),
        }
    }
}

/// Read the pointer alone. The barrier's idle tick wants exactly this
/// and nothing else: a few hundred bytes that answer "did anything
/// move, and were the ENTRIES part of it".
pub struct LoadedPointer {
    pub pointer: Pointer,
    pub etag: String,
    /// The pointer object's own Last-Modified — the citation's clock,
    /// which /status reports and which the GET already carries.
    pub last_modified_unix: Option<u64>,
}

pub async fn load_pointer(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
) -> LeanResult<Option<LoadedPointer>> {
    match store.get_whole(&cfg.current_key(), None).await {
        Ok((meta, bytes)) => {
            let pointer: Pointer = serde_json::from_slice(&bytes)
                .map_err(|e| super::LeanError::State(format!("manifest pointer parse: {e}")))?;
            Ok(Some(LoadedPointer { pointer, etag: meta.etag, last_modified_unix: meta.last_modified_unix }))
        }
        Err(StoreError::NotFound(_)) => Ok(None),
        Err(e) => Err(e.into()),
    }
}

/// The whole document: pointer, then the generation it names. Falls
/// back to the legacy single-object key for a workspace that has not
/// been migrated — which every existing workspace is, exactly once.
/// How many times `load` will restart onto a newer generation before
/// giving up. A restart happens only when a chunk we needed was swept
/// AND the pointer moved under us, so it is bounded by publish rate,
/// not by anything we control — three is generous for a workspace whose
/// floor is measured in seconds.
const LOAD_ATTEMPTS: u32 = 3;

pub async fn load(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
) -> LeanResult<Option<LoadedManifest>> {
    load_from(store, cfg, None).await
}

/// [`load`], starting from a pointer read the caller ALREADY made
/// (`Some(None)`: it 404'd), so the first attempt does not GET it again —
/// the consume's cheap path reads the pointer and, when it has moved or is
/// absent, needs the document behind that same read. A restart onto a
/// newer generation reads the pointer afresh, as `load` always did.
pub async fn load_from(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
    mut first: Option<Option<LoadedPointer>>,
) -> LeanResult<Option<LoadedManifest>> {
  for attempt in 0..LOAD_ATTEMPTS {
    let read = match first.take() {
        Some(already) => already,
        None => load_pointer(store, cfg).await?,
    };
    if let Some(LoadedPointer { pointer: p, etag, last_modified_unix: pointer_lm }) = read {
        // A pointer naming an object that is not there is not an empty
        // workspace — it is a broken one, and answering `None` would
        // invite a re-seed over a live project. That rule holds one
        // level deeper for chunks: a chunk list with a hole is a
        // manifest missing entries, and a missing entry reads to every
        // consumer as a file the agent deleted.
        let (mut manifest, meta_lm) = match p.entries()? {
            Entries::Single { key, .. } => {
                let (meta, bytes) = match store.get_whole(key, None).await {
                    Ok(v) => v,
                    Err(StoreError::NotFound(_)) => {
                        return Err(super::LeanError::State(format!(
                            "manifest pointer at seq {} names {key}, which does not exist — \
                             refusing to treat a broken pointer as an empty workspace",
                            p.seq
                        )))
                    }
                    Err(e) => return Err(e.into()),
                };
                let m = LeanManifest::parse(&bytes)
                    .map_err(|e| super::LeanError::State(format!("manifest parse: {e}")))?;
                (m, meta.last_modified_unix)
            }
            Entries::Chunked(refs) => {
                // Sequential on purpose for now: a chunk fetch is
                // ordinary and the interesting property is that a hole
                // FAILS rather than silently shortening the manifest.
                let mut bodies = Vec::with_capacity(refs.len());
                let mut raced = false;
                for r in refs {
                    let key = cfg.chunk_key(&r.addr);
                    match store.get_whole(&key, None).await {
                        Ok((_, b)) => bodies.push(b.to_vec()),
                        Err(StoreError::NotFound(_)) => {
                            // A chunk we need is gone. Which of two very
                            // different things happened is decided by
                            // the POINTER, not by the chunk:
                            //
                            // - the pointer MOVED ⇒ we raced a sweep
                            //   that collected a generation we were
                            //   still reading. The current generation is
                            //   a complete snapshot, so start over
                            //   against it: coherent, just newer. This
                            //   is what makes a reader safe for a
                            //   DURATION rather than for `Retain`
                            //   publishes (chunked design §8.2,
                            //   machine-checked by
                            //   `LeanChunkGCSlowReader`).
                            // - the pointer is UNCHANGED ⇒ the live
                            //   manifest genuinely has a hole, and there
                            //   is nothing newer to restart onto. Fail,
                            //   and never as `Ok(None)`, which every
                            //   caller reads as FIRST WRITE.
                            let moved = match load_pointer(store, cfg).await? {
                                Some(now) => now.etag != etag,
                                None => true,
                            };
                            if moved && attempt + 1 < LOAD_ATTEMPTS {
                                raced = true;
                                break;
                            }
                            return Err(super::LeanError::State(format!(
                                "manifest pointer at seq {} names chunk {} (first key {:?}), \
                                 which does not exist — refusing to serve a manifest with a \
                                 hole in it as though those entries had been deleted",
                                p.seq, r.addr, r.first
                            )));
                        }
                        Err(e) => return Err(e.into()),
                    }
                }
                if raced {
                    continue;
                }
                let entries = super::chunk::assemble(refs, &bodies)?;
                let tombstones = match &p.tombstones {
                    None => BTreeMap::new(),
                    Some(addr) => match store.get_whole(&cfg.chunk_key(addr), None).await {
                        Ok((_, b)) => super::chunk::decode_tombstones(&b, addr)?,
                        // The same two cases as a missing chunk, decided the
                        // same way: by whether the pointer moved.
                        Err(StoreError::NotFound(_)) => {
                            let moved = match load_pointer(store, cfg).await? {
                                Some(now) => now.etag != etag,
                                None => true,
                            };
                            if moved && attempt + 1 < LOAD_ATTEMPTS {
                                continue;
                            }
                            return Err(super::LeanError::State(format!(
                                "manifest pointer at seq {} names tombstones {addr}, which do not exist",
                                p.seq
                            )));
                        }
                        Err(e) => return Err(e.into()),
                    },
                };
                (
                    LeanManifest {
                        seq: p.seq,
                        entries,
                        sole_writer: p.sole_writer,
                        boundary_source: p.boundary_source.clone(),
                        tombstones,
                    },
                    None,
                )
            }
        };
        // The pointer is the authority for the fields it carries: a
        // rotation moves `seq` without rewriting the entries object, so
        // the generation's own copy is stale by construction.
        manifest.seq = p.seq;
        manifest.sole_writer = p.sole_writer;
        manifest.boundary_source = p.boundary_source.clone();
        return Ok(Some(LoadedManifest {
            manifest,
            etag,
            // The CITATION's clock is when the pointer moved, not when
            // the entries object happened to be written: a rotation
            // republishes the same entries under a new generation.
            last_modified_unix: pointer_lm.or(meta_lm),
            pointer: Some(p),
        }));
    }
    // No pointer: the pre-migration layout, which has no chunks and so
    // nothing to race a sweep over. Answer it and leave the loop.
    return match store.get_whole(&cfg.manifest_key(), None).await {
        Ok((meta, bytes)) => {
            let manifest = LeanManifest::parse(&bytes)
                .map_err(|e| super::LeanError::State(format!("manifest parse: {e}")))?;
            Ok(Some(LoadedManifest {
                manifest,
                etag: meta.etag,
                last_modified_unix: meta.last_modified_unix,
                pointer: None,
            }))
        }
        Err(StoreError::NotFound(_)) => Ok(None),
        Err(e) => Err(e.into()),
    };
  }
  // Every attempt raced a sweep. Refusing is right: the alternative is
  // to serve a manifest we know is short, and short reads as deleted.
  Err(super::LeanError::State(format!(
      "manifest load restarted {LOAD_ATTEMPTS} times without settling — a sweep is \
       collecting generations faster than this reader can finish one"
  )))
}

/// How many lost races a one-CAS edit re-reads and retries before it gives
/// up `Contended`.
pub const EDIT_CAS_ATTEMPTS: usize = 16;

/// Why a one-CAS edit did not land.
#[derive(Debug)]
pub enum EditError<E> {
    /// The edit refused the document it was shown.
    Refused(E),
    Store(LeanError),
    /// Lost `EDIT_CAS_ATTEMPTS` races in a row.
    Contended,
}

/// ONE manifest CAS for an edit by a party that holds no lease (P2): the
/// gateway's UI saves, deletes and renames, and the ingress sweep's
/// adoptions. Load the current document; let `edit` judge it and change a
/// copy — the next generation, with the installing party's fields reset as
/// `merge` resets them; keep tombstones to `TOMBSTONE_KEEP_SEQS`; CAS it in
/// with epoch 0; on a lost race do it all again against what won, so every
/// precondition is judged against the document the CAS replaces.
pub async fn commit_edit<E>(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
    flush: &str,
    edit: impl Fn(Option<&LoadedManifest>, &mut LeanManifest) -> Result<(), E>,
) -> Result<(), EditError<E>> {
    // The document the last attempt sent, when its CAS answered 412 or
    // 409. An SDK retries a conditional PUT whose response was lost, and
    // the retry meets the object its first attempt wrote: if the document
    // now IS the one sent, the edit committed, and it is answered so rather
    // than judged again against itself (a delete would find its path gone
    // and answer NoSuchFile; the model's GDelete acknowledges what it
    // commits, `udel`). A peer's identical edit at the same seq (the same
    // delete, raced) leaves the same document: committed either way.
    let mut sent: Option<LeanManifest> = None;
    for _ in 0..EDIT_CAS_ATTEMPTS {
        let current = load(store, cfg).await.map_err(EditError::Store)?;
        if let (Some(s), Some(c)) = (sent.as_ref(), current.as_ref()) {
            if same_document(s, &c.manifest) {
                return Ok(());
            }
        }
        let mut doc = current.as_ref().map(|l| l.manifest.clone()).unwrap_or_default();
        doc.seq += 1;
        doc.sole_writer = false;
        doc.boundary_source = None;
        edit(current.as_ref(), &mut doc).map_err(EditError::Refused)?;
        let seq = doc.seq;
        doc.tombstones.retain(|_, t| seq.saturating_sub(t.seq) <= TOMBSTONE_KEEP_SEQS);
        let expected = current.as_ref().map(|l| l.handle());
        let before = current.as_ref().map(|l| &l.manifest);
        match cas_write_retiring(store, cfg, &doc, before, expected.as_ref(), 0, flush, None).await {
            Ok(_) => return Ok(()),
            Err(LeanError::Store(StoreError::PreconditionFailed(_))) | Err(LeanError::Store(StoreError::Conflict(_))) => {
                sent = Some(doc);
                continue;
            }
            Err(e) => return Err(EditError::Store(e)),
        }
    }
    Err(EditError::Contended)
}

/// Is `b` the document `a` is — the same generation with the same
/// citations, tombstones and stamps? (`commit_edit`'s own-write check.)
fn same_document(a: &LeanManifest, b: &LeanManifest) -> bool {
    a.seq == b.seq
        && a.entries == b.entries
        && a.tombstones == b.tombstones
        && a.sole_writer == b.sole_writer
        && a.boundary_source == b.boundary_source
}

/// CAS-write the manifest document. `expected`: None ⇒ first write
/// (If-None-Match:*); Some(etag) ⇒ If-Match. The caller owns 412
/// handling (merge-retry or fence — never blind re-seed).
pub async fn cas_write(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
    m: &LeanManifest,
    expected: Option<&ManifestHandle>,
    epoch: u64,
    flush_uuid: &str,
) -> LeanResult<ObjectMeta> {
    cas_write_stamped(store, cfg, m, expected, epoch, flush_uuid, None).await
}

/// `cas_write` with an explicit boundary-source stamp on the manifest
/// OBJECT (§2.4.1): downstream consumers distinguish a declared-coherent
/// citation from a forced possibly-torn one from the bucket alone, for
/// the price of a HEAD rather than a GET of a manifest that can run to
/// hundreds of MiB.
pub async fn cas_write_stamped(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
    m: &LeanManifest,
    expected: Option<&ManifestHandle>,
    epoch: u64,
    flush_uuid: &str,
    boundary_source: Option<&str>,
) -> LeanResult<ObjectMeta> {
    Ok(cas_write_refs(store, cfg, m, expected, epoch, flush_uuid, boundary_source).await?.0)
}

/// `cas_write_stamped`, also returning the chunk objects the new pointer
/// references (its chunks and its tombstones object) — `None` on the
/// single-object layout.
async fn cas_write_refs(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
    m: &LeanManifest,
    expected: Option<&ManifestHandle>,
    epoch: u64,
    flush_uuid: &str,
    boundary_source: Option<&str>,
) -> LeanResult<(ObjectMeta, Option<Vec<String>>)> {
    // The manifest's own document must agree with its object stamp:
    // a reader that GETs and a reader that HEADs must never disagree
    // about how coherent the citation claims to be.
    // The layout switch. Every publisher routes through here — the
    // barrier, the takeover rotation and the gateway's HITL CAS — so the
    // decision lives at one site rather than three, and §6's "chunk
    // server-side on receipt" falls out rather than needing its own
    // path.
    if cfg.chunked {
        let (meta, refs) = cas_write_chunked_refs(
            store,
            cfg,
            m,
            expected,
            expected.map(|h| h.prev_chunks.as_slice()).unwrap_or(&[]),
            PublishStamps { epoch, flush_uuid, boundary_source },
        )
        .await?;
        return Ok((meta, Some(refs)));
    }
    let mut m = m.clone();
    if boundary_source.is_some() {
        m.boundary_source = boundary_source.map(|s| s.to_string());
    }
    let m = &m;
    let bytes = m.to_bytes();
    let crc = crc64_nvme(&bytes);
    let stamps = GenerationStamps {
        generation: m.seq,
        epoch,
        flush_uuid: flush_uuid.to_string(),
        // Stamp what the DOCUMENT carries, not merely what this call
        // passed. An explicit source has already been written into the
        // document above, so the two agree there either way; the case
        // this closes is a `None` caller over a document that already
        // names its clock — the gateway's HITL CAS relays a document it
        // did not author. Stamping `None` on those left the object
        // saying nothing while the document said `sentinel`, which is
        // exactly the GET/HEAD divergence the contract above forbids.
        boundary_source: m.boundary_source.clone(),
        posix: None,
    };

    // 1. The ENTRIES, to a fresh immutable key. `IfNoneMatchAny` is the
    //    whole safety story for this object: two writers that raced to
    //    the same seq cannot both land, and the loser is told so here
    //    rather than discovering it after it has already published.
    let entries_key = cfg.generation_key(m.seq, flush_uuid);
    match store
        .put_whole(&entries_key, bytes.into(), &PutCondition::IfNoneMatchAny, &stamps, crc)
        .await
    {
        Ok(_) => {}
        // The key carries this writer's flush uuid, so this is not a
        // race with another writer — it is a retry of OUR OWN write
        // whose pointer CAS we never learned the outcome of. The object
        // is byte-identical by construction; fall through to the CAS,
        // which is where the race is actually decided.
        Err(StoreError::PreconditionFailed(_)) | Err(StoreError::Conflict(_)) => {}
        Err(e) => return Err(e.into()),
    }

    // 2. The POINTER, which is what makes the publish visible and what
    //    decides the race. Everything above this line is invisible to
    //    every reader; a crash here leaves an orphan generation object
    //    that nobody reads and the sweep collects.
    let pointer = Pointer {
        seq: m.seq,
        entries_key: Some(entries_key.clone()),
        entries_seq: Some(m.seq),
        // The step-one layout. `cas_write_chunked` is what produces a
        // chunk list; this path stays byte-for-byte what it was.
        chunks: None,
        sole_writer: m.sole_writer,
        boundary_source: m.boundary_source.clone(),
        epoch,
        tombstones: None,
    };
    Ok((put_pointer(store, cfg, &pointer, expected, &stamps).await?, None))
}

/// Who is publishing, and under what coherence claim. Grouped because
/// they travel together through every publish path and are meaningless
/// apart — and because a positional list of three scalars is how an
/// epoch ends up where a flush uuid was meant to go.
pub struct PublishStamps<'a> {
    pub epoch: u64,
    pub flush_uuid: &'a str,
    pub boundary_source: Option<&'a str>,
}

/// Publish a manifest as a CHUNK LIST (chunked design §2).
///
/// The ordering is the atomicity story and is not negotiable: every
/// chunk is durable BEFORE the pointer CAS, and the pointer is the only
/// thing that ever becomes visible. A crash between the two leaves
/// orphan chunks nobody references and no reader can observe — never a
/// half-written manifest.
///
/// `prev` is the chunk list this publish is replacing. Chunks whose
/// address is already in it are skipped ENTIRELY — not uploaded and
/// re-conditioned, but never sent. That is where O(changed) actually
/// comes from: without it, a one-file publish still ships every chunk's
/// bytes over the wire and only the store's 412 saves the storage.
pub async fn cas_write_chunked(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
    m: &LeanManifest,
    expected: Option<&ManifestHandle>,
    prev: &[super::chunk::ChunkRef],
    pub_stamps: PublishStamps<'_>,
) -> LeanResult<ObjectMeta> {
    Ok(cas_write_chunked_refs(store, cfg, m, expected, prev, pub_stamps).await?.0)
}

/// `cas_write_chunked`, also returning the chunk addresses the new pointer
/// references, its tombstones object included.
async fn cas_write_chunked_refs(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
    m: &LeanManifest,
    expected: Option<&ManifestHandle>,
    prev: &[super::chunk::ChunkRef],
    pub_stamps: PublishStamps<'_>,
) -> LeanResult<(ObjectMeta, Vec<String>)> {
    let PublishStamps { epoch, flush_uuid, boundary_source } = pub_stamps;
    let mut m = m.clone();
    if boundary_source.is_some() {
        m.boundary_source = boundary_source.map(|s| s.to_string());
    }
    let m = &m;
    let split =
        super::chunk::split_with(&m.entries, cfg.chunk_target, cfg.chunk_min, cfg.chunk_max)?;
    let have: std::collections::HashSet<&str> = prev.iter().map(|r| r.addr.as_str()).collect();

    let stamps = GenerationStamps {
        generation: m.seq,
        epoch,
        flush_uuid: flush_uuid.to_string(),
        boundary_source: m.boundary_source.clone(),
        posix: None,
    };

    for (r, body) in &split {
        if have.contains(r.addr.as_str()) {
            continue;
        }
        // UNCONDITIONAL, and that is a correctness requirement rather
        // than laziness — `LeanChunkGCAdoptSkips` in the formal model
        // fails without it.
        //
        // This chunk is not in `prev`, so nothing references it yet. It
        // may nonetheless already EXIST: a crashed publish of ours left
        // it, and content addressing means our retry produces the same
        // key. Adopting it by reference alone would make an old object
        // live without making it LOOK live, and the orphan sweep reads
        // age — so the sweep would collect it as the orphan it still
        // appears to be, between this call and our pointer CAS.
        // Rewriting refreshes the age the sweep reads. The bytes are
        // identical either way, so the write is idempotent; the cost
        // falls only on adopted orphans, which are rare by
        // construction, and chunks already in `prev` are skipped above.
        let key = cfg.chunk_key(&r.addr);
        let crc = crc64_nvme(body);
        store.put_whole(&key, body.clone().into(), &PutCondition::Unconditional, &stamps, crc).await?;
    }

    // The tombstones, one object beside the chunks (rewritten like an
    // adopted chunk: the sweep reads age, and the bytes are identical).
    let tombstones = if m.tombstones.is_empty() {
        None
    } else {
        let (addr, body) = super::chunk::encode_tombstones(&m.tombstones)?;
        let crc = crc64_nvme(&body);
        store.put_whole(&cfg.chunk_key(&addr), body.into(), &PutCondition::Unconditional, &stamps, crc).await?;
        Some(addr)
    };
    let pointer = Pointer {
        seq: m.seq,
        entries_key: None,
        entries_seq: None,
        chunks: Some(split.into_iter().map(|(r, _)| r).collect()),
        sole_writer: m.sole_writer,
        boundary_source: m.boundary_source.clone(),
        epoch,
        tombstones: tombstones.clone(),
    };
    let meta = put_pointer(store, cfg, &pointer, expected, &stamps).await?;
    let mut refs: Vec<String> = pointer.chunks.iter().flatten().map(|r| r.addr.clone()).collect();
    refs.extend(tombstones);
    Ok((meta, refs))
}

/// What a commit retired (M1): the handles its document no longer cites
/// and the chunk objects its pointer no longer references. One immutable
/// log object per commit, under `retired/`, keyed by the unix second it
/// was written — the retirement's clock. The sweeps spare everything a
/// log younger than `retire_grace_secs` names; a writer reaps the older
/// ones (`untracked.rs`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct RetireLog {
    pub at_unix: u64,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub handles: Vec<String>,
    /// Chunk ADDRESSES (`LeanConfig::chunk_key` names the object).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub chunks: Vec<String>,
}

pub fn retired_prefix(cfg: &LeanConfig) -> String {
    format!("{}/{}/retired/", cfg.prefix, super::LEAN_DIR)
}

/// The unix second a retire log's KEY carries (`<at:020>-...`): a listing
/// dates every log with no GET.
pub fn retire_log_at(cfg: &LeanConfig, key: &str) -> Option<u64> {
    key.strip_prefix(&retired_prefix(cfg))?.get(..20)?.parse().ok()
}

/// A commit, and the log of what it retired, written right after the CAS.
/// A crash between the two leaves those handles and chunks to the sweeps'
/// write-age rule — the shape before the retire age — and nothing worse.
/// The log is best effort for the same reason: a failure to write it costs
/// a lagging reader a re-resolve, never the commit.
#[allow(clippy::too_many_arguments)]
pub async fn cas_write_retiring(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
    m: &LeanManifest,
    before: Option<&LeanManifest>,
    expected: Option<&ManifestHandle>,
    epoch: u64,
    flush_uuid: &str,
    boundary_source: Option<&str>,
) -> LeanResult<ObjectMeta> {
    let (meta, refs) = cas_write_refs(store, cfg, m, expected, epoch, flush_uuid, boundary_source).await?;
    let still: std::collections::HashSet<&str> = m.entries.values().map(|e| e.key.as_str()).collect();
    let mut handles: Vec<String> = before
        .map(|b| b.entries.values().map(|e| e.key.clone()).filter(|k| !still.contains(k.as_str())).collect())
        .unwrap_or_default();
    handles.sort();
    handles.dedup();
    let mut chunks: Vec<String> = vec![];
    if let (Some(refs), Some(h)) = (refs.as_ref(), expected) {
        let now: std::collections::HashSet<&str> = refs.iter().map(|a| a.as_str()).collect();
        chunks = h
            .prev_chunks
            .iter()
            .map(|r| r.addr.clone())
            .chain(h.prev_tombstones.clone())
            .filter(|a| !now.contains(a.as_str()))
            .collect();
        chunks.sort();
        chunks.dedup();
    }
    if !handles.is_empty() || !chunks.is_empty() {
        let at = super::now_unix();
        let log = RetireLog { at_unix: at, handles, chunks };
        let key = format!("{}{at:020}-{flush_uuid}-{}.json", retired_prefix(cfg), uuid::Uuid::new_v4());
        let body = serde_json::to_vec(&log).map_err(|e| LeanError::State(format!("retire log: {e}")))?;
        let crc = crc64_nvme(&body);
        let stamps = GenerationStamps {
            generation: m.seq,
            epoch,
            flush_uuid: flush_uuid.to_string(),
            boundary_source: None,
            posix: None,
        };
        if let Err(e) = store.put_whole(&key, body.into(), &PutCondition::IfNoneMatchAny, &stamps, crc).await {
            eprintln!("flint-sync: could not log what seq {} retired ({e}); the sweeps judge it by write age", m.seq);
        }
    }
    Ok(meta)
}

/// CAS the pointer. Split out because a takeover rotation writes ONLY
/// this — no entries object, no multi-MB anything.
async fn put_pointer(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
    pointer: &Pointer,
    expected: Option<&ManifestHandle>,
    stamps: &GenerationStamps,
) -> LeanResult<ObjectMeta> {
    let body = serde_json::to_vec(pointer)
        .map_err(|e| super::LeanError::State(format!("pointer encode: {e}")))?;
    let crc = crc64_nvme(&body);
    // A LEGACY handle is a migration: the writer read the old single
    // object and the pointer does not exist yet, so the precondition is
    // "no pointer", not "the pointer I read". A concurrent migration
    // therefore loses here and retries against the layout that won,
    // instead of both of them believing they installed it.
    let cond = match expected {
        Some(h) if !h.legacy => PutCondition::IfMatch(h.etag.clone()),
        _ => PutCondition::IfNoneMatchAny,
    };
    let meta = store.put_whole(&cfg.current_key(), body.into(), &cond, stamps, crc).await?;
    if let Some(h) = expected.filter(|h| h.legacy) {
        // The workspace has moved. Poison the legacy key rather than
        // delete it — see REFUSAL_DOC. Conditional on the very object
        // this writer read, so a racing writer's view is never
        // clobbered, and best effort: the refusal doc is decorative to
        // every new reader, and the ONLY reader it speaks to is an old
        // binary that must refuse rather than re-seed.
        let bytes = REFUSAL_DOC.as_bytes().to_vec();
        let crc = crc64_nvme(&bytes);
        if let Err(e) = store
            .put_whole(
                &cfg.manifest_key(),
                bytes.into(),
                &PutCondition::IfMatch(h.etag.clone()),
                stamps,
                crc,
            )
            .await
        {
            eprintln!("flint-sync: could not poison the legacy manifest key after migration: {e}");
        }
    }
    Ok(meta)
}

/// Bump the generation without touching the entries.
///
/// This is what a takeover does, and under the single-object layout it
/// was a multi-MB GET + PUT per claim that also moved the ETag every
/// other syncer's no-change early exit compares against — so one claim
/// cost every follower a full fetch and parse of a document in which
/// nothing had changed. Here it is one small CAS: `seq` moves, so an
/// outstanding pointer handle goes stale exactly as before, and
/// `entries_seq` does NOT, so a follower can see that the entries are
/// the ones it already has.
pub async fn rotate_for_takeover(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
    epoch: u64,
) -> LeanResult<Option<(LeanManifest, String)>> {
    for _ in 0..3 {
        // The legacy layout has no pointer to rotate: migrate by
        // rewriting the document once, then rotations are cheap forever.
        let Some(LoadedPointer { pointer: p, etag, .. }) = load_pointer(store, cfg).await? else {
            let Some(loaded) = load(store, cfg).await? else {
                return Ok(None);
            };
            let mut rotated = loaded.manifest.clone();
            rotated.seq += 1;
            let h = loaded.handle();
            match cas_write(store, cfg, &rotated, Some(&h), epoch, "takeover-rotation").await {
                Ok(meta) => return Ok(Some((rotated, meta.etag))),
                Err(super::LeanError::Store(StoreError::PreconditionFailed(_) | StoreError::Conflict(_))) => continue,
                Err(e) => return Err(e),
            }
        };
        let next = Pointer { seq: p.seq + 1, epoch, ..p.clone() };
        let stamps = GenerationStamps {
            generation: next.seq,
            epoch,
            flush_uuid: "takeover-rotation".to_string(),
            boundary_source: next.boundary_source.clone(),
            posix: None,
        };
        let h = ManifestHandle { etag, legacy: false, prev_chunks: Vec::new(), prev_tombstones: None };
        match put_pointer(store, cfg, &next, Some(&h), &stamps).await {
            Ok(meta) => {
                // The document the caller gets back is the standing one
                // with the rotated seq — the entries were not read and
                // did not need to be.
                let mut m = LeanManifest::default();
                m.seq = next.seq;
                m.sole_writer = next.sole_writer;
                m.boundary_source = next.boundary_source.clone();
                return Ok(Some((m, meta.etag)));
            }
            // S3 answers a conditional write racing another on the key with 409,
            // and a takeover whose acquire has LANDED must not fail on a lost race
            // (review 2026-09-18, H2).
            Err(super::LeanError::Store(StoreError::PreconditionFailed(_) | StoreError::Conflict(_))) => continue,
            Err(e) => return Err(e),
        }
    }
    Err(super::LeanError::State(
        "takeover rotation lost 3 CAS races — a live writer is still publishing".into(),
    ))
}

/// How many superseded generations to keep behind the live one.
///
/// Not zero, and not one. A reader GETs the pointer and then GETs the
/// object it names, and those are two requests with a gap between them:
/// reaping the generation a reader has just resolved would turn a
/// perfectly ordinary read into a `NotFound`. A handful is a few hundred
/// KiB at any realistic entry count and buys that gap several publishes.
pub const KEEP_GENERATIONS: usize = 5;

/// How long a generation NEWER than the live one is left alone before it
/// is treated as an orphan.
///
/// An object above the live generation is one of two things: a publish
/// that is in flight right now — its entries written, its pointer CAS
/// not yet landed — or the wreckage of one that died in that window. The
/// two are indistinguishable from a listing, and deleting the first
/// breaks a live publish, so age is the only honest discriminator. Well
/// past any plausible manifest PUT, including a 264 MiB one over a slow
/// link.
pub const ORPHAN_GRACE_SECS: u64 = 3600;

/// Retire-age G's default (`LeanConfig::retire_grace_secs`): how long a
/// handle or chunk stays after the commit that stopped referencing it.
/// Past a large checkout's manifest load and a UI read's two GETs; a
/// reader that lags longer re-resolves the pointer, as it always has. It
/// costs storage: every version a commit replaced, kept this long.
pub const RETIRE_GRACE_SECS: u64 = 600;

/// Delete superseded generation objects, and orphans left by a publish
/// that died between its entries PUT and its pointer CAS.
///
/// Immutable metadata that is never collected is a leak, and this one
/// grows by a whole manifest per publish.
///
/// The two cases are NOT symmetric, which the first cut of this got
/// wrong. BELOW the live generation, everything is superseded by
/// definition and the only question is how much slack to leave a reader
/// mid-resolve — that is `KEEP_GENERATIONS`. ABOVE it, an object may be
/// a publish still in flight, and a keep-window is exactly the wrong
/// tool: an orphan at a high seq sorts to the front and the window
/// protects it forever while costing a real generation its slot. Age
/// decides that case instead.
///
/// Best effort and off the critical path: a failure here costs storage,
/// never correctness, so it warns rather than failing a publish.
pub async fn sweep_generations(store: &dyn ObjectStore, cfg: &LeanConfig) -> LeanResult<usize> {
    let Some(lp) = load_pointer(store, cfg).await? else {
        return Ok(0);
    };
    // A chunked pointer names no generation object at all, so there is
    // nothing here to keep a window of. Chunks are shared across
    // generations and are collected by a different rule entirely
    // (chunked design §8.1) — sweeping them by "not named by the live
    // pointer" would delete history the window exists to preserve.
    let Some(live) = lp.pointer.entries_key else {
        // The workspace has MOVED to the chunk layout, so no pointer
        // names a generation object any more and every one of them is
        // superseded. Collect them past the grace rather than leaking
        // the whole pre-migration history forever — a reader still
        // resolving one revalidates and restarts (§8.2), and the grace
        // covers the window in which it might not have noticed yet.
        let prefix = format!("{}/{}/manifests/", cfg.prefix, super::LEAN_DIR);
        let now = super::now_unix();
        let mut removed = 0;
        for o in store.list(&prefix).await? {
            let age = o.last_modified_unix.map(|t| now.saturating_sub(t));
            if !age.is_some_and(|a| a >= cfg.orphan_grace_secs) {
                continue;
            }
            match store.delete(&o.key).await {
                Ok(()) => removed += 1,
                Err(e) => eprintln!("flint-sync: could not reap migrated generation {}: {e}", o.key),
            }
        }
        return Ok(removed);
    };
    let prefix = format!("{}/{}/manifests/", cfg.prefix, super::LEAN_DIR);
    let mut listed = store.list(&prefix).await?;
    // The key is `<seq:020>-<uuid>`, so lexical order IS generation
    // order and a plain sort is enough — the zero padding is doing this
    // job, which is why it is there.
    listed.sort_by(|a, b| a.key.cmp(&b.key));
    let now = super::now_unix();
    let live_ix = listed.iter().position(|o| o.key == live);

    let mut doomed: Vec<String> = Vec::new();
    for (i, o) in listed.iter().enumerate() {
        if o.key == live {
            continue;
        }
        match live_ix {
            // Newer than the live pointer: in flight, or wreckage.
            Some(ix) if i > ix => {
                let age = o.last_modified_unix.map(|t| now.saturating_sub(t));
                // No timestamp ⇒ leave it. A store that cannot date its
                // objects gets a leak, not a deleted live publish.
                if age.is_some_and(|a| a > ORPHAN_GRACE_SECS) {
                    doomed.push(o.key.clone());
                }
            }
            // Older than the live pointer: superseded. Keep a window — by
            // count, and (M1) until the generation that superseded it is
            // `retire_grace_secs` old: at one UI save a second, the count
            // alone is seconds. No date on the successor ⇒ keep it.
            Some(ix) => {
                let superseded_for = listed[i + 1].last_modified_unix.map(|t| now.saturating_sub(t));
                if ix.saturating_sub(i) > KEEP_GENERATIONS
                    && superseded_for.is_some_and(|a| a >= cfg.retire_grace_secs)
                {
                    doomed.push(o.key.clone());
                }
            }
            // The pointer names an object that is not in the listing.
            // `load` already refuses that workspace; sweep nothing.
            None => return Ok(0),
        }
    }
    let mut removed = 0;
    for k in doomed {
        match store.delete(&k).await {
            Ok(()) => removed += 1,
            Err(e) => eprintln!("flint-sync: could not reap superseded generation {k}: {e}"),
        }
    }
    Ok(removed)
}

/// Collect chunk objects that no live pointer references.
///
/// Built to `lean/formal/LeanChunkGC.tla`, which refuted the ordering
/// rule the design originally prescribed. Four things carry the safety
/// here and each has a mutation config that breaks it alone:
///
/// 1. **The reference set must not predate a CAS the delete follows.**
///    S3 cannot make the read and the delete atomic, so this fences
///    instead: the pointer's ETag is read before the listing and again
///    after, and the pass ABORTS if it moved. A publish landed, and we
///    no longer know whether it references a candidate.
/// 2. **A grace.** A chunk written and not yet referenced would
///    otherwise be collected out from under its own publish.
/// 3. **The grace outlives the longest publish** — a timing
///    assumption, which is why `orphan_grace_secs` is config and why
///    this comment names it rather than leaving it implied.
/// 4. **Adoption rewrites what it adopts** (`cas_write_chunked`), so
///    any chunk a concurrent publish could reference is FRESH and the
///    grace covers it. Without that rule an adopted orphan is old, and
///    the age this reads is a lie about whether it is in use.
///
/// Deliberately no history window, unlike `sweep_generations`. That
/// window existed partly so a reader mid-resolve could finish; readers
/// now revalidate against the pointer and restart onto the current
/// generation (§8.2), which removes the reason. Adding one back means
/// retaining pointer SNAPSHOTS so their chunk sets are enumerable
/// (§9) — chunks are shared, so "what the live pointer does not name"
/// is the only reference set available without them.
pub async fn sweep_chunks(store: &dyn ObjectStore, cfg: &LeanConfig) -> LeanResult<usize> {
    sweep_chunks_sparing(store, cfg, &std::collections::HashSet::new()).await
}

/// `sweep_chunks`, sparing the chunk KEYS a young retire log names (M1):
/// a chunk a commit superseded less than `retire_grace_secs` ago, however
/// long ago it was written. A reader mid-load of the previous pointer
/// finds it.
pub async fn sweep_chunks_sparing(
    store: &dyn ObjectStore,
    cfg: &LeanConfig,
    spared: &std::collections::HashSet<String>,
) -> LeanResult<usize> {
    let Some(before) = load_pointer(store, cfg).await? else {
        return Ok(0);
    };
    let mut refs: std::collections::HashSet<String> = match before.pointer.entries()? {
        Entries::Chunked(c) => c.iter().map(|r| r.addr.clone()).collect(),
        // The single-object layout has no chunks; `sweep_generations`
        // owns it, and running both over one workspace would have each
        // reasoning about objects the other manages.
        Entries::Single { .. } => return Ok(0),
    };
    // The tombstone object lives beside the chunks and is referenced
    // the same way.
    if let Some(t) = &before.pointer.tombstones {
        refs.insert(t.clone());
    }

    let prefix = format!("{}/{}/chunks/", cfg.prefix, super::LEAN_DIR);
    let listed = store.list(&prefix).await?;

    // THE FENCE (rule 1). Nothing published across the reference read
    // and the listing, so the candidates and the references describe
    // the same moment. A publish that lands after this still cannot
    // lose a chunk: everything it references is either already in
    // `refs` or was just written or rewritten, and rule 4 guarantees
    // the latter are fresh.
    let after = load_pointer(store, cfg).await?;
    if after.map(|p| p.etag) != Some(before.etag) {
        return Ok(0);
    }

    let now = super::now_unix();
    let mut removed = 0;
    for o in listed {
        let Some(addr) = o.key.rsplit('/').next() else { continue };
        if refs.contains(addr) || spared.contains(&o.key) {
            continue;
        }
        // A candidate the LISTING already shows inside the grace cannot be
        // past it on a HEAD taken now: a rewrite only makes an object
        // younger, so the listing's age bounds the HEAD's from above.
        // Skipping it keeps a chunk longer — a leak, never a deleted live
        // chunk — and every deletion still rests on the HEAD below. Without
        // this every commit HEADed every chunk superseded within the grace,
        // one at a time inside the fence: 700 ms of a 1 s hold after five
        // minutes of six writers (contention drill), growing for the hour.
        if let Some(listed) = o.last_modified_unix {
            if now.saturating_sub(listed) < cfg.orphan_grace_secs {
                continue;
            }
        }
        // The age must come from a HEAD taken NOW, never from the
        // listing above. That listing predates the fence, and rule 4
        // (adoption REWRITES what it adopts) works by refreshing a
        // chunk's age — so judging by the listing's timestamp makes
        // exactly the refresh that rule exists to produce invisible,
        // and the reaper deletes a chunk an in-flight publish is about
        // to name. The model computed this against the store's state at
        // DELETE time; reading it from a pre-fence snapshot was an
        // implementation that did not match the rule it was built to.
        //
        // One HEAD per CANDIDATE past the grace by the listing, not per
        // chunk: a busy workspace supersedes chunks on every publish.
        //
        // Gone already ⇒ nothing to do. Any other HEAD failure, or no
        // timestamp ⇒ leave it: a store that cannot date its objects
        // gets a leak, not a deleted live chunk, the same rule
        // `sweep_generations` follows and for the same reason.
        let fresh = match store.head(&o.key).await {
            Ok(m) => m.last_modified_unix,
            Err(StoreError::NotFound(_)) => continue,
            Err(e) => {
                eprintln!("flint-sync: could not date orphan chunk {} ({e}); leaving it", o.key);
                continue;
            }
        };
        // `>=`, not `>`: the config value is a DURATION the object must
        // have survived, so `orphan_grace_secs = 0` has to mean "no
        // grace". With `>` it would silently mean "collect nothing
        // written this second", which is most of them — a knob whose
        // zero disables the thing it configures is a trap.
        let age = fresh.map(|t| now.saturating_sub(t));
        if !age.is_some_and(|a| a >= cfg.orphan_grace_secs) {
            continue;
        }
        match store.delete(&o.key).await {
            Ok(()) => removed += 1,
            Err(e) => eprintln!("flint-sync: could not reap orphan chunk {}: {e}", o.key),
        }
    }
    Ok(removed)
}

/// The three-way merge (the model's `inst`). Starts from THEIRS so
/// foreign entries survive by construction; applies my upserts; applies
/// my deletes only where theirs is unchanged since my merge base.
///
/// **`mine_upserts` and `mine_deletes` must be DISJOINT**, and that is
/// the caller's job, not this function's: which one wins depends on
/// which the tree saw LAST, and neither set carries that. The fused
/// barrier gets it free (both are computed from one scan). Resolving an
/// overlap here —
/// in either direction — cites a deleted file half the time and
/// amputates a live one the other half. TLC found both halves.
/// Returns (as `Merged`) the merged document, the foreign entries a consume must
/// integrate next (present in theirs, changed vs base, not mine), each
/// declared delete's outcome, and the entries MINE PUBLISHES OVER: theirs' version at a path this barrier
/// uploaded, changed since the merge base. The slot's 412 used to catch
/// those at the PUT; under handles the merge is the one place they show,
/// and the caller decides which it never integrated and must surface
/// (design 2026-09-19, R7).
pub fn merge(
    base: &BTreeMap<String, String>,
    theirs: &LeanManifest,
    mine_upserts: &BTreeMap<String, LeanEntry>,
    mine_deletes: &BTreeSet<String>,
    parked: &BTreeSet<String>,
) -> Merged {
    let mut merged = theirs.clone();
    merged.seq = theirs.seq + 1;
    // Same discipline, and for a sharper reason: inheriting it would
    // let a workspace that once published as a mirror keep refusing
    // adopts forever, and NOT inheriting it would let a mirror silently
    // stop being one the first time anything else wrote a manifest.
    // The installing pass sets it from its own config.
    merged.sole_writer = false;
    merged.boundary_source = None;

    let mut foreign: Vec<(String, LeanEntry)> = vec![];
    let mut overridden: Vec<(String, LeanEntry)> = vec![];
    // The DELETES mine publishes over: a path mine uploads that the merge
    // base cited and theirs no longer does. Named by the version the
    // delete removed — theirs' tombstone, else the base's citation.
    let recreated: Vec<(String, String)> = mine_upserts
        .keys()
        .filter(|p| !theirs.entries.contains_key(*p))
        .filter_map(|p| {
            let base_etag = base.get(p)?;
            let deleted = theirs.tombstones.get(p).map(|t| t.etag.clone()).unwrap_or_else(|| base_etag.clone());
            Some((p.clone(), deleted))
        })
        .collect();
    for (p, e) in &theirs.entries {
        let changed = base.get(p).map(|b| b != &e.etag).unwrap_or(true);
        if changed && !mine_upserts.contains_key(p) && !parked.contains(p) {
            foreign.push((p.clone(), e.clone()));
        }
        if changed && mine_upserts.contains_key(p) {
            overridden.push((p.clone(), e.clone()));
        }
    }

    for (p, e) in mine_upserts {
        merged.entries.insert(p.clone(), e.clone());
        // A path cited again has no delete to remember.
        merged.tombstones.remove(p);
    }
    let mut deletes = BTreeMap::new();
    let mut over_theirs: Vec<(String, LeanEntry)> = vec![];
    for p in mine_deletes {
        let outcome = delete_outcome(base, theirs, p, parked);
        deletes.insert(p.clone(), outcome);
        if outcome == DeleteOutcome::OverTheirs {
            if let Some(e) = theirs.entries.get(p) {
                over_theirs.push((p.clone(), e.clone()));
            }
        }
        if outcome != DeleteOutcome::Parked {
            if let Some(retired) = merged.entries.remove(p) {
                // H1e: the document says what this delete retired.
                merged.tombstones.insert(p.clone(), Tombstone { etag: retired.etag, seq: merged.seq });
            }
        }
    }
    merged.tombstones.retain(|_, t| merged.seq.saturating_sub(t.seq) <= TOMBSTONE_KEEP_SEQS);
    Merged { doc: merged, foreign, overridden, deletes, recreated, over_theirs }
}

/// What `merge` returns: the document, and what it decided per path.
pub struct Merged {
    pub doc: LeanManifest,
    /// Entries a consume must integrate next: present in theirs, changed
    /// vs base, not mine.
    pub foreign: Vec<(String, LeanEntry)>,
    /// Theirs' version at a path this barrier uploaded, changed since the
    /// merge base (R7).
    pub overridden: Vec<(String, LeanEntry)>,
    /// Every declared delete, and what the merge did with it.
    pub deletes: BTreeMap<String, DeleteOutcome>,
    /// The deletes mine publishes over: `(path, the deleted version's
    /// etag)` for a path mine uploads that the merge base cited and theirs
    /// deleted. Mine wins; the caller records each (the user's rule: a
    /// delete over a dirty path lands, with a record).
    pub recreated: Vec<(String, String)>,
    /// The versions mine DELETES over: theirs at a path this barrier
    /// deletes, changed since the merge base (M3, P1-lite). Mine wins; the
    /// caller preserves and records each before the collector takes it.
    pub over_theirs: Vec<(String, LeanEntry)>,
}

/// What the merge does with one of my deletes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteOutcome {
    /// Theirs is unchanged since my merge base, or already gone: the
    /// citation goes.
    Applied,
    /// Theirs moved off my merge base to a version this tree never had
    /// (M3, P1-lite): the delete still applies — mine wins — and theirs is
    /// preserved and recorded (`Merged::over_theirs`). Before P1-lite theirs
    /// stayed cited (outranked), and under a derived owed set that never
    /// settles: the tree lacks the path, the document cites theirs, the
    /// path is dirty so nothing is owed, and every barrier repeats.
    OverTheirs,
    /// Withheld this barrier.
    Parked,
}

/// The ONE place a delete's outcome is decided. `merge` applies it, and
/// a caller that must know whether an install moves a path off its
/// citation asks this with the same base — never re-derives it. Re-deriving
/// it is how the repair rule came to read a different base from the merge
/// (M5, L-124).
pub fn delete_outcome(
    base: &BTreeMap<String, String>,
    theirs: &LeanManifest,
    p: &str,
    parked: &BTreeSet<String>,
) -> DeleteOutcome {
    if parked.contains(p) {
        return DeleteOutcome::Parked;
    }
    match (theirs.entries.get(p), base.get(p)) {
        (Some(e), Some(b)) if &e.etag == b => DeleteOutcome::Applied,
        // Already deleted by someone else: nothing is left to cite.
        (None, _) => DeleteOutcome::Applied,
        // Changed since base, or present in theirs and not in base.
        (Some(_), _) => DeleteOutcome::OverTheirs,
    }
}