agentplane 0.45.0

Durable, replayable agent runtime — the journal is the plan of record
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
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
//! Getting the record out, in a form nothing here has to be present to read.
//!
//! # Why this is a deliverable and not a `serde` derive
//!
//! [`audit`](crate::audit) exists because the party under examination must not
//! also be the only party able to examine. That argument has a second half this
//! crate did not have: an auditor who can *check* the history but cannot
//! *obtain* it is still dependent on the operator, and a regulator asking a
//! financial entity to demonstrate an exit is asking about obtaining, not
//! checking. A store nobody can get data out of is a concentration risk with a
//! hash chain on top.
//!
//! So the export is a first-class operation with three properties, and each one
//! is a refusal of an easier design:
//!
//! * **Streaming, one JSON object per line.** A whole-journal `Vec` is a
//!   memory ceiling disguised as an API, and the export that matters most is
//!   the one taken from the largest store. JSON Lines also means an interrupted
//!   export is a *prefix* rather than a corrupt document — which is the failure
//!   an operator actually hits.
//! * **Self-describing.** The first line is a header naming the log, its
//!   checkpoint, and the canonicalization rule the digests were computed under.
//!   Without that, an export is bytes an auditor has to be told how to read,
//!   and being told is the dependency this module exists to remove.
//! * **It says what it did not export.** The trailer carries the counts and any
//!   run that could not be read. A truncated export shaped exactly like a
//!   complete one is the failure this project refuses everywhere else, and it
//!   is worst here: the missing run is the interesting one.
//!
//! # What it deliberately does not do
//!
//! It does not decrypt. With a key ring configured the journal commits to
//! ciphertext, and an export of plaintext would quietly undo
//! [erasure](crate::keyring) — destroying the key would no longer reach the
//! copy somebody exported last month. The export carries what the chain
//! committed to, which is also what verifies.
//!
//! It does not re-verify. [`audit`](crate::audit) answers *is this sound*, this
//! answers *here it is*, and folding them would produce an export that refuses
//! to emit the very history an auditor wants to examine *because* it is
//! suspect.
//!
//! It is scoped to one tenant, because a [`JournalStore`] handle is. There is
//! no argument here that could widen it, which is the same reason the rest of
//! the tenancy story is in keys rather than in filters.

use std::sync::Arc;

use crate::core::{RunId, StoreError};
use crate::journal::{Append, Checkpoint, JournalStore};

/// The export format's own version — see [`Header::version`].
///
/// One constant, because three readers consume it: the writer stamps it, the
/// verifier refuses what it cannot interpret, and the restore refuses what it
/// cannot faithfully replay. A version that only the writer knew about would be
/// a declaration that does nothing — a reader would parse a future format as
/// far as the lines happened to look familiar, and report findings about a
/// file it never understood.
///
/// Two shapes are load-bearing enough to state with the constant, because
/// each was once tempting to do the other way. The case layer is mandatory,
/// never an optional extension: a reader that tolerated its absence could not
/// tell *this plane has no cases* from *the case layer was dropped from this
/// file* — and the second is the finding that matters. And every record line
/// carries `raw`, the **exact bytes the chain hashed**, which is what
/// verification recomputes over: verifying a re-serialization of the parsed
/// body would hold only while this build's canonicalization agreed
/// byte-for-byte with the writer's — the wire-bytes rule the journal itself
/// refuses to bend, bent by its own export.
pub const FORMAT_VERSION: u32 = 1;

/// The first line of an export: what this is and how to read it.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Header {
    /// Always `"agentplane.export"`, so a reader can tell this file from any
    /// other line-delimited JSON without being told what it is.
    pub kind: &'static str,
    /// The export format's own version, which is **not** the crate's.
    ///
    /// A reader pins this. Tying it to the crate version would make every
    /// release look like a format change to anyone parsing defensively.
    pub version: u32,
    /// The log this came from, and its commitment at the moment of export.
    pub checkpoint: Checkpoint,
    /// Which canonicalization rule produced the digests in these records.
    ///
    /// A digest is meaningless without the rule that computed it, and an
    /// export outlives the build that wrote it — so the rule travels with the
    /// digests rather than being whatever the reader happens to implement.
    pub canon: u16,
}

/// One journal record, as an export line.
///
/// Written out explicitly rather than by deriving `Serialize` on
/// [`Record`](crate::journal::Record), and the reason is that this is a
/// **durable format**. A derive makes the wire shape a side effect of the
/// struct's field list, so adding a private field or renaming a public one
/// silently changes what every downstream reader parses. Naming the four parts
/// here means the format changes when somebody edits *this*, which is the only
/// arrangement in which [`Header::version`] can mean anything.
///
/// The chain links travel with the body because an export without them is not
/// checkable: `prev_hash` and `hash` are what let a reader re-walk the chain
/// offline, which is the whole point of taking the record away.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct ExportedRecord<'r> {
    pub seq: crate::core::Seq,
    /// The typed view, for a reader's eyes. Verification never touches it —
    /// see `raw` — and the verifier holds the two to each other so this cannot
    /// quietly say something the hashed bytes do not.
    ///
    /// **Parsed from `raw`, never taken from the store's in-memory record.**
    /// A sealed journal hands reads back *opened* — that is its job for the
    /// runtime, whose own steps must read what they wrote — so an export that
    /// copied the record's `body` field would write every sealed payload's
    /// plaintext into a file, and destroying the key would no longer reach the
    /// copy somebody exported last month. Deriving the display copy from the
    /// hashed bytes makes body-matches-wire true by construction and keeps
    /// sealed payloads sealed, which is the same rule the case layer's export
    /// read states in prose.
    pub body: crate::journal::RecordBody,
    pub prev_hash: &'r crate::core::Digest,
    pub hash: &'r crate::core::Digest,
    /// The plane's workload-key signature over this record's chain hash — who
    /// wrote the record, not a hardware attestation of where. Present only
    /// where the plane was configured to sign. `None` is an ordinary state
    /// and is emitted as such rather than omitted, so a reader can tell
    /// *unsigned* from *a field this export forgot*.
    pub signature: Option<&'r crate::core::KeySignature>,
    /// The exact bytes [`hash`](Self::hash) covers, verbatim.
    ///
    /// This is the wire-bytes rule, applied to the export: the chain is over
    /// history **as written**, and a verifier that re-serialized the parsed
    /// body was holding the file to *this build's* canonicalization rather
    /// than to the bytes the store sealed. Canonical record bytes are UTF-8
    /// JSON, so they travel as a string — escaped, exact, and recoverable
    /// byte-for-byte.
    pub raw: std::borrow::Cow<'r, str>,
}

impl<'r> ExportedRecord<'r> {
    /// Build an export line from a stored record, deriving the display copy
    /// from the wire bytes.
    ///
    /// Fallible on purpose, with no fallback to the record's opened `body`: a
    /// record whose hashed bytes do not parse is corrupt, and substituting the
    /// in-memory view would export exactly the plaintext this constructor
    /// exists to keep out of the file — a silent fallback on the one value two
    /// mechanisms must agree about.
    fn from_stored(r: &'r crate::journal::Record) -> Result<Self, String> {
        let body = serde_json::from_slice::<crate::journal::RecordBody>(r.raw())
            .map_err(|e| format!("record {}'s wire bytes do not parse: {e}", r.seq()))?;
        Ok(Self {
            seq: r.seq(),
            body,
            prev_hash: &r.prev_hash,
            hash: &r.hash,
            signature: r.signature.as_ref(),
            raw: String::from_utf8_lossy(r.raw()),
        })
    }
}

/// A run's header line, emitted before its records.
///
/// It carries the one thing the record stream cannot: **where this run sits in
/// the Merkle log**. That order is store state — a monotonic index assigned at
/// seal time — and it appears in no record, so an export without it can be
/// walked but cannot be checked against the checkpoint in its own header. The
/// difference is between a transcript and evidence: a reader could confirm each
/// chain links to itself and still not know whether a run had been dropped from
/// the middle of the log.
///
/// `index` and `seal` are absent for a run that is still open. An unsealed run
/// is not in the log and has no leaf, which is a state rather than a gap.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct RunBlock {
    /// Always `"agentplane.export.run"`.
    pub kind: &'static str,
    pub run: RunId,
    /// Position in the Merkle log, in seal order.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub index: Option<u64>,
    /// The leaf value: this run's terminal chain hash.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub seal: Option<crate::core::Digest>,
}

/// One case, as an export line — the case layer's whole account of one matter.
///
/// Emitted after the run blocks because the two halves answer different
/// questions: the journal is *what happened*, the case is *what it happened
/// to*. A restore of the journal alone rebuilds every index the journal owns
/// and none of these rows, because case state is not derivable from records —
/// which is exactly why the export has to carry it.
///
/// `state` travels **as stored**: sealed on a sealed plane. Exporting
/// plaintext would quietly undo erasure — see the module docs, which make the
/// same refusal for record payloads.
///
/// `blobs` carries digests, never bytes. Presence and integrity of the bytes
/// are a question about a live blob store, which an offline file cannot
/// answer and honestly reports as unchecked.
///
/// `hold` is always written, `null` for a matter nobody ordered preserved. A
/// restore that brought a held matter back without its hold would hand the
/// next retention pass a closed, old, unheld case to erase.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct CaseBlock {
    /// Always `"agentplane.export.case"`.
    pub kind: &'static str,
    pub case: crate::core::Case,
    pub deadlines: Vec<crate::core::Deadline>,
    pub blobs: Vec<crate::core::Digest>,
    /// The legal hold on this matter: instant, reason and operator.
    pub hold: Option<crate::core::LegalHold>,
}

/// The last line of an export: what it contains, and what it does not.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Trailer {
    /// Always `"agentplane.export.end"`. Its **absence** is the signal that
    /// matters: an export cut short by a crash, a full disk or a killed pipe
    /// ends without one, so a reader can tell a prefix from a whole file
    /// without comparing counts against a source it does not have.
    pub kind: &'static str,
    /// How many runs were asked for.
    pub runs_requested: usize,
    /// How many were read in full.
    pub runs_exported: usize,
    /// How many records were written.
    pub records: usize,
    /// How many cases the case layer contributed.
    ///
    /// Zero means this plane has no case store — a state, not a gap. A plane
    /// *with* one exports every case it holds, so a record stamped with a case
    /// this file does not carry is a finding the verifier makes.
    pub cases: usize,
    /// Runs that could not be read, and why.
    ///
    /// Named rather than counted. A count tells an auditor that something is
    /// missing and not which case to go and ask about, and the run that fails
    /// to read is not a random one.
    pub unreadable: Vec<Unreadable>,
}

/// A run the export could not read.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Unreadable {
    pub run: RunId,
    pub reason: String,
}

/// Write every record of `runs` as JSON Lines, framed by a header and trailer.
///
/// The writer is `std::io::Write` rather than a path so this composes with a
/// file, a pipe, a socket or a buffer, and so the caller owns where the bytes
/// land — an export function that chose the destination would be one an
/// operator has to work around.
///
/// A run that cannot be read is recorded in the trailer and the export
/// continues. Aborting instead would make one damaged run withhold every
/// healthy one, which is the opposite of what an export is for; the trailer is
/// what keeps that from being silent.
///
/// # Errors
///
/// Only for a failure to *write*. A failure to *read* a run is data — it lands
/// in [`Trailer::unreadable`] — because the export still succeeded at the job
/// it was given, and an auditor needs the part that survived.
pub async fn to_jsonl<W: std::io::Write>(
    store: &Arc<dyn JournalStore>,
    cases: Option<&Arc<dyn crate::case::CaseStore>>,
    runs: &[RunId],
    mut out: W,
) -> Result<Trailer, std::io::Error> {
    let checkpoint = store.checkpoint().await.map_err(|e| as_io(&e))?;
    // Held out of the header for the one comparison below: the header owns the
    // checkpoint from here on, and the log size is the half of it every run
    // block is checked against.
    let log_size = checkpoint.size;
    // Read from the build, never from the caller. The rule that computed the
    // digests is a fact about the store's own writes, and a parameter here was
    // a header any embedder could make lie — every caller passed
    // `canon::VERSION` verbatim, which is what a fact looks like when it is
    // asked for as an argument.
    let header = Header {
        kind: "agentplane.export",
        version: FORMAT_VERSION,
        checkpoint,
        canon: crate::core::canon::VERSION,
    };
    writeln!(out, "{}", to_line(&header)?)?;

    let mut records = 0usize;
    let mut exported = 0usize;
    let mut unreadable = Vec::new();

    for &run in runs {
        // Asked before the records so the block heads them, and asked at all
        // because the log position is the half of the evidence the records do
        // not carry. A store that cannot answer leaves the run unsealed rather
        // than failing the export: the position is missing, and the verifier
        // says so, which is better than no export.
        let placed = store.inclusion_proof(run).await.ok().flatten();
        // A run sealed *after* the header's checkpoint was taken is not in that
        // checkpoint. Stamping its position anyway would make the export
        // disagree with its own first line: the verifier rebuilds a tree one
        // leaf larger than the root it compares against, and reports tampering
        // where there was only time. Such a run is exported as still open —
        // true relative to the moment this export describes — and the next
        // export carries it sealed.
        let placed = placed.filter(|i| i.index < log_size);
        writeln!(
            out,
            "{}",
            to_line(&RunBlock {
                kind: "agentplane.export.run",
                run,
                index: placed.as_ref().map(|i| i.index),
                seal: placed.as_ref().map(|i| i.seal),
            })?
        )?;

        match store.read(run, 1).await {
            // A run the store holds nothing for is filed as unreadable, not
            // exported as an empty block. Both backends answer an unknown run
            // with an empty read rather than an error, so without this arm a
            // mistyped run id produced a block with no records under it — a
            // shape the verifier must otherwise treat as records removed after
            // the fact. Naming it here keeps the trailer's accounting honest:
            // an empty block in a file whose trailer does not declare the run
            // unreadable is tampering, and only because no honest writer
            // produces one.
            Ok(found) if found.is_empty() => unreadable.push(Unreadable {
                run,
                reason: "the store holds no records for this run".to_owned(),
            }),
            Ok(found) => {
                // Every line is derived from its wire bytes before any is
                // written, so a record that cannot be derived files the whole
                // run as unreadable instead of leaving a half-written block
                // shaped like a complete one.
                match found
                    .iter()
                    .map(ExportedRecord::from_stored)
                    .collect::<Result<Vec<_>, _>>()
                {
                    Ok(lines) => {
                        for line in &lines {
                            writeln!(out, "{}", to_line(line)?)?;
                            records += 1;
                        }
                        exported += 1;
                    }
                    Err(reason) => unreadable.push(Unreadable { run, reason }),
                }
            }
            Err(e) => unreadable.push(Unreadable {
                run,
                reason: e.to_string(),
            }),
        }
    }

    // The case layer, after the runs and before the trailer. Every case, not
    // the cases these runs touch: a case is the unit an erasure request or a
    // regulator names, and a subset chosen by run membership would silently
    // drop the matter whose runs happened not to be asked for.
    let mut case_count = 0usize;
    if let Some(case_store) = cases {
        let mut after: Option<crate::core::CaseId> = None;
        loop {
            let page = case_store
                .cases(after, CASE_PAGE)
                .await
                .map_err(|e| as_io(&e))?;
            let Some(last) = page.last() else { break };
            after = Some(last.id);
            let full = page.len() >= CASE_PAGE;
            for case in page {
                let deadlines = case_store.deadlines(case.id).await.map_err(|e| as_io(&e))?;
                let blobs = case_store.blobs_of(case.id).await.map_err(|e| as_io(&e))?;
                let hold = case_store.hold(case.id).await.map_err(|e| as_io(&e))?;
                writeln!(
                    out,
                    "{}",
                    to_line(&CaseBlock {
                        kind: "agentplane.export.case",
                        case,
                        deadlines,
                        blobs,
                        hold,
                    })?
                )?;
                case_count += 1;
            }
            if !full {
                break;
            }
        }
    }

    let trailer = Trailer {
        kind: "agentplane.export.end",
        runs_requested: runs.len(),
        runs_exported: exported,
        records,
        cases: case_count,
        unreadable,
    };
    writeln!(out, "{}", to_line(&trailer)?)?;
    out.flush()?;
    Ok(trailer)
}

/// How many cases one enumeration page holds — shared with the live drill
/// ([`crate::drill`]), which walks the same case layer with the same paging.
/// Interior to the crate either way: the stream out is unbounded, and the
/// page only bounds memory. One constant, because two walks that paged
/// differently would be two subtly different definitions of "every case".
pub(crate) const CASE_PAGE: usize = 256;

/// One value as one line, refusing to write a line that is not valid JSON.
fn to_line<T: serde::Serialize>(value: &T) -> Result<String, std::io::Error> {
    serde_json::to_string(value).map_err(|e| std::io::Error::other(e.to_string()))
}

fn as_io(e: &StoreError) -> std::io::Error {
    std::io::Error::other(e.to_string())
}

// ── Reading one back ────────────────────────────────────────────────────────

/// What a verification pass concluded, and what it could not look at.
///
/// The same shape as [`AuditReport`](crate::audit::AuditReport) and for the same
/// reason: a pass that reports only failures tells you about its coverage by
/// omission. An export verified without a public key has not established
/// authorship, and saying so is the difference between *this is sound* and
/// *nothing I checked was wrong*.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct VerifyReport {
    /// The checkpoint the export claims to be a copy of.
    pub checkpoint: Checkpoint,
    /// Runs whose chain recomputed exactly.
    pub sound: Vec<RunId>,
    /// What went wrong, in the order found.
    pub findings: Vec<String>,
    /// Checks that were not performed, and why.
    pub not_checked: Vec<String>,
    /// How many records were read.
    pub records: usize,
    /// How many case blocks were read.
    pub cases: usize,
    /// Whether the file ended with its trailer.
    ///
    /// A truncated export is otherwise a valid prefix: every line parses, every
    /// chain link joins, and the only thing wrong is what is missing.
    pub complete: bool,
}

impl VerifyReport {
    /// Whether every check that ran, passed. See [`Self::not_checked`].
    #[must_use]
    pub fn is_sound(&self) -> bool {
        self.findings.is_empty() && self.complete
    }
}

/// Recompute an export from its own bytes, and check it against its checkpoint.
///
/// **This is the restore drill, and it is the half that makes a restore worth
/// having.** Putting records back into a store proves that bytes moved; it does
/// not prove they are the bytes that were taken, in the order they were taken,
/// with nothing dropped from the middle. That is what this establishes, and it
/// establishes it without the runtime that wrote the data and without the store
/// it came from — an export and this function are the whole dependency.
///
/// Four properties, each checkable only because the export was designed to
/// carry the evidence for it:
///
/// * **Every record's hash is recomputed**, from its body and its predecessor's
///   hash, using the canonicalization rule the header names. A record whose
///   stored hash disagrees was edited after sealing. This is not a comparison of
///   the file against itself: `Record::seal` is the same function the store
///   sealed through, so agreement means the bytes are the ones that were
///   written.
/// * **Chains join, and sequences are contiguous.** A removed record breaks a
///   link; a removed *tail* does not, which is why the sequence is checked too.
/// * **The Merkle root is rebuilt** from the per-run log positions and compared
///   with `expected` — the checkpoint the reader was given by somebody other
///   than whoever wrote this file. This is the one that catches a whole run
///   dropped from the middle of the export: the per-run chains all still
///   verify, and only the tree notices.
///
///   Without `expected` it can only be compared with the file's **own header**,
///   which reads like the same check and is not: an editor who drops a run and
///   rewrites the header's size and root produces a file that agrees with
///   itself perfectly. This crate makes the same argument one level down — a
///   record's `prev_hash` is checked by rehashing the wire bytes, never against
///   the previous line, because "the file agrees with itself" is what a
///   competent editor achieves. So a pass with no `expected` reports the root
///   check under [`VerifyReport::not_checked`]: internal consistency
///   established, deletion not.
/// * **The file is framed.** A missing trailer means the export was cut short,
///   and every line before the cut is still perfectly valid.
/// * **The trailer's own accounting holds.** Its run and record counts are
///   compared against what was actually read, and a run it declares unreadable
///   is reported as *unchecked* rather than as tampering — the writer said at
///   export time that the run's records are not here, which is the opposite of
///   hiding it. An empty run block the trailer does **not** declare unreadable
///   is the tamper case: no honest writer produces one.
///
/// Signatures are checked when a verifier is supplied and reported as unchecked
/// when not.
///
/// # Errors
///
/// Only for a failure to read the input. A malformed or dishonest export is a
/// *finding*, not an error — the whole point is to produce a report about it.
pub fn verify<R: std::io::BufRead>(
    input: R,
    verifier: Option<&dyn crate::core::Verifier>,
    anchors: &[crate::journal::Anchor],
) -> Result<VerifyReport, std::io::Error> {
    use crate::core::Digest;
    use serde_json::Value;

    let mut report = VerifyReport {
        checkpoint: Checkpoint {
            origin: String::new(),
            size: 0,
            root: Digest::ZERO,
        },
        sound: Vec::new(),
        findings: Vec::new(),
        not_checked: Vec::new(),
        records: 0,
        cases: 0,
        complete: false,
    };
    unanswerable(&mut report, verifier.is_some());

    let mut header_seen = false;
    // (index, leaf) for every sealed run, so the tree can be rebuilt in log
    // order rather than in the order the export happened to walk.
    let mut leaves: Vec<(u64, crate::core::merkle::LeafHash)> = Vec::new();
    let mut pass: Option<RunPass> = None;
    // The reader's own tally, held against the trailer's at the end: every run
    // block seen, every block that carried at least one record, and every block
    // that carried none. The trailer adjudicates the empty ones — an export
    // that declared the run unreadable was honest about it, and one that did
    // not has had records removed — which is why they are collected rather
    // than judged on the spot: the trailer is the last line, and an
    // intermediate block closes before it is read.
    let mut run_blocks = 0usize;
    let mut read_runs = 0usize;
    let mut empty_blocks: Vec<RunId> = Vec::new();
    let mut claims = TrailerClaims::default();
    // The two halves of the case cross-check: what the records name, and what
    // the case layer carries. Settled at the end, because either side can
    // arrive first in the file.
    let mut stamped: std::collections::BTreeSet<crate::core::CaseId> =
        std::collections::BTreeSet::new();
    let mut carried: std::collections::BTreeSet<crate::core::CaseId> =
        std::collections::BTreeSet::new();
    let mut blob_digests = 0usize;

    for line in input.lines() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        let Ok(value) = serde_json::from_str::<Value>(&line) else {
            report
                .findings
                .push("a line is not valid JSON, so the export is unreadable from there on".into());
            break;
        };
        if let Some(kind) = value.get("kind").and_then(Value::as_str) {
            note_unknown_members(kind, &value, &mut report);
        }
        match value.get("kind").and_then(Value::as_str) {
            Some("agentplane.export") => {
                header_seen = true;
                read_header(&value, &mut report);
            }
            Some("agentplane.export.case") => {
                read_case_block(&value, &mut report, &mut carried, &mut blob_digests);
            }
            Some("agentplane.export.run") => {
                run_blocks += 1;
                finish_run(
                    &mut report,
                    pass.take(),
                    verifier,
                    &mut read_runs,
                    &mut empty_blocks,
                );
                pass = open_run_block(&value, &mut leaves);
            }
            Some("agentplane.export.end") => read_trailer(&value, &mut report, &mut claims),
            _ => {
                report.records += 1;
                let Some(pass) = pass.as_mut() else {
                    report.findings.push(
                        "a record appears before any run block, so nothing says which run it \
                         belongs to"
                            .into(),
                    );
                    continue;
                };
                read_record(&value, pass, &mut report, &mut stamped);
            }
        }
    }
    finish_run(
        &mut report,
        pass,
        verifier,
        &mut read_runs,
        &mut empty_blocks,
    );

    // Open runs are the blocks that contributed no leaf. Computed here because
    // this is the one place that holds both numbers, and passed on rather than
    // recounted.
    let open_runs = run_blocks.saturating_sub(leaves.len());
    settle(&mut report, header_seen, leaves, anchors);
    settle_trailer(
        &mut report,
        &claims,
        run_blocks,
        read_runs,
        open_runs,
        &empty_blocks,
    );
    settle_cases(&mut report, &stamped, &carried, blob_digests);
    Ok(report)
}

/// What the trailer claims about the file, held for the settlement.
///
/// Collected rather than compared on the spot, for two reasons that are the
/// same reason: the trailer is the last line, so the totals it must be held
/// against only exist once the whole file has been read — and the per-run
/// verdicts it adjudicates (is an empty block an honestly-declared unreadable
/// run, or records removed after the fact?) close *before* it is read, because
/// each run block is finished when the next one starts.
#[derive(Default)]
struct TrailerClaims {
    runs_requested: Option<u64>,
    runs_exported: Option<u64>,
    records: Option<u64>,
    /// Runs the export itself declared unreadable, with the writer's reason.
    unreadable: Vec<(RunId, String)>,
}

/// Read the trailer: the file is complete, and its case count holds.
///
/// The case-count comparison is what catches the case layer stripped *whole*:
/// with every block gone the coverage cross-check has nothing to compare, and
/// the file would read as an export of a plane that simply had no cases —
/// while its trailer still says otherwise. The run and record counts are
/// collected here and compared in [`settle_trailer`], where the totals exist.
fn read_trailer(value: &serde_json::Value, report: &mut VerifyReport, claims: &mut TrailerClaims) {
    report.complete = true;
    if let Some(declared) = value.get("cases").and_then(serde_json::Value::as_u64)
        && declared != report.cases as u64
    {
        report.findings.push(format!(
            "the trailer says {declared} case(s) were exported and this file \
             carries {} — the case layer was cut after the export was taken",
            report.cases
        ));
    }
    claims.runs_requested = value
        .get("runs_requested")
        .and_then(serde_json::Value::as_u64);
    claims.runs_exported = value
        .get("runs_exported")
        .and_then(serde_json::Value::as_u64);
    claims.records = value.get("records").and_then(serde_json::Value::as_u64);
    if let Some(list) = value
        .get("unreadable")
        .and_then(serde_json::Value::as_array)
    {
        for entry in list {
            let Some(run) = entry
                .get("run")
                .and_then(serde_json::Value::as_str)
                .and_then(|s| RunId::parse(s).ok())
            else {
                continue;
            };
            let reason = entry
                .get("reason")
                .and_then(serde_json::Value::as_str)
                .unwrap_or("no reason recorded")
                .to_owned();
            claims.unreadable.push((run, reason));
        }
    }
}

/// Hold the trailer's own accounting to what was actually read.
///
/// Before this settlement existed, only the trailer's `cases` count was ever
/// consulted — `runs_requested`, `runs_exported`, `records` and `unreadable`
/// were fields the writer stamped and no reader read, so deleting an open
/// run's tail records while keeping the trailer verified clean: an open run
/// has no leaf to pin its tail, a chain prefix verifies, and the only witness
/// left is the count.
///
/// The empty blocks are adjudicated here too, and the trailer is what decides
/// which way each one goes. A run the export *declares* unreadable is
/// unchecked, not tampering: the writer said at export time that this run's
/// records are not in the file, which is the opposite of hiding it, and
/// reporting it as "records removed after sealing" would teach an operator
/// that findings are noise. An empty block the trailer does **not** declare is
/// the tamper case — no honest writer produces one, because an unreadable or
/// empty read files the run in the trailer instead.
///
/// What this does NOT cover: a trailer rewritten to match an edited file. The
/// counts are the file's claim about itself, and holding a file to itself
/// never catches an editor who updates both halves — that is the chain, leaf
/// and Merkle-root checks' job, which tie the surviving bytes to history. Nor
/// does it cover an open run's tail cut *before* the export was taken: the
/// store served the shortened history, the writer counted what it served, and
/// no offline file can see past its own writer.
fn settle_trailer(
    report: &mut VerifyReport,
    claims: &TrailerClaims,
    run_blocks: usize,
    read_runs: usize,
    open_runs: usize,
    empty_blocks: &[RunId],
) {
    // Said here because `audit` says it about a live store, and these two
    // answer one question about one history: an open run has no Merkle leaf,
    // so nothing pins its tail and a truncation is undetectable until the run
    // seals. The offline reader is the one an independent auditor holds, so it
    // is the worse of the two to leave silent.
    //
    // Once per file rather than per run. A reader deciding what a clean report
    // is worth needs the count and the reason; a line per run buries both.
    if open_runs > 0 {
        report.not_checked.push(format!(
            "{open_runs} open run(s): a run that has not concluded has no position in the \
             Merkle log, so the root proves nothing about it — its chain and signatures \
             were verified, and records cut from its tail before the export was taken are \
             undetectable from this file"
        ));
    }
    for (run, reason) in &claims.unreadable {
        report.not_checked.push(format!(
            "run {run}: the export declares it unreadable ({reason}), so its records are \
             not in this file and nothing about it was verified"
        ));
    }
    for run in empty_blocks {
        if claims.unreadable.iter().any(|(u, _)| u == run) {
            continue;
        }
        report.findings.push(format!(
            "run {run}: its block carries no records and the export does not declare it \
             unreadable — either the records were removed after the export was taken, or \
             the file was cut short before them"
        ));
    }
    // The counts exist only on a framed file; a missing trailer is already the
    // truncation finding in `settle`, and comparing against nothing would
    // manufacture a second finding about the same cut.
    if !report.complete {
        return;
    }
    match (claims.runs_requested, claims.runs_exported, claims.records) {
        (Some(requested), Some(exported), Some(records)) => {
            if requested != run_blocks as u64 {
                report.findings.push(format!(
                    "the trailer says {requested} run(s) were requested and this file carries \
                     {run_blocks} run block(s) — whole runs were removed or added after the \
                     export was taken"
                ));
            }
            if exported != read_runs as u64 {
                report.findings.push(format!(
                    "the trailer says {exported} run(s) were exported in full and this file \
                     carries records for {read_runs} — a run's records were removed after the \
                     export was taken"
                ));
            }
            if records != report.records as u64 {
                report.findings.push(format!(
                    "the trailer says {records} record(s) were written and this file carries \
                     {} — record lines were removed or added after the export was taken",
                    report.records
                ));
            }
        }
        _ => report.findings.push(
            "the trailer is missing counts this format always writes (runs_requested, \
             runs_exported, records) — a reader cannot hold the file to its own accounting"
                .to_owned(),
        ),
    }
}

/// Read one case block: count it, collect its id for the coverage settlement,
/// and flag the malformations a reader would otherwise trip over silently.
fn read_case_block(
    value: &serde_json::Value,
    report: &mut VerifyReport,
    carried: &mut std::collections::BTreeSet<crate::core::CaseId>,
    blob_digests: &mut usize,
) {
    use serde_json::Value;

    report.cases += 1;
    match serde_json::from_value::<crate::core::Case>(
        value.get("case").cloned().unwrap_or(Value::Null),
    ) {
        Ok(case) => {
            carried.insert(case.id);
        }
        Err(e) => report
            .findings
            .push(format!("a case block is malformed: {e}")),
    }
    if value
        .get("deadlines")
        .is_none_or(|d| serde_json::from_value::<Vec<crate::core::Deadline>>(d.clone()).is_err())
    {
        report
            .findings
            .push("a case block's deadlines are malformed".to_owned());
    }
    if let Err(e) = case_hold(value) {
        report.findings.push(e);
    }
    *blob_digests += value
        .get("blobs")
        .and_then(Value::as_array)
        .map_or(0, Vec::len);
}

/// What this pass cannot answer, whatever the file turns out to contain.
///
/// The twin of the audit's own missing-evidence list, and it exists for the same
/// reason: a pass that quietly skips a question and then reports clean is the
/// reassuring-but-empty artifact this module is built to avoid. Two kinds sit
/// here — a check this invocation lacked an input for, and state the format
/// never carries at all.
fn unanswerable(report: &mut VerifyReport, verifier_supplied: bool) {
    if !verifier_supplied {
        report.not_checked.push(
            "signatures — no public key was supplied, so this pass cannot say who wrote \
             anything"
                .to_owned(),
        );
    }
    report
        .not_checked
        .extend(UNCARRIED.iter().map(|limit| (*limit).to_owned()));
}

/// Operational state this format never carries, named on every pass.
///
/// A restored plane's runs and cases come back; the rows beside them do not.
/// Said unconditionally rather than where something references them, because
/// these are properties of the *format* — a reader meeting a minimal artifact is
/// the one most likely to assume a clean report means a total restore.
///
/// Both losses degrade safely, which is the argument for leaving them out: a
/// cursor costs repetition against receivers that already deduplicate, and a
/// decision is taken again under the same four-eyes and expiry. Stating the
/// argument is what stops it from being a silence.
const UNCARRIED: [&str; 2] = [
    "webhook delivery cursors — this file carries no push registrations, so a restored plane \
     re-delivers from the start of each subscriber's history rather than from where it got \
     to. Receivers deduplicate on the event's own identity, so the cost is repetition rather \
     than loss",
    "worklist decisions no run has consumed — a decision recorded against a task and not yet \
     read back by the run it answers is a store row, not a record, so it does not survive \
     here. The task re-opens and is decided again under the same four-eyes and expiry",
];

/// The case layer's own settlement: coverage, and what a file cannot check.
///
/// The coverage rule has a deliberate asymmetry. A record stamped with a case
/// the file does not carry is a **finding** — this plane had a case layer (the
/// stamp proves it) and the export is missing a matter the journal names. The
/// reverse is not: a case whose runs are absent is the ordinary result of
/// exporting a subset of runs, and every case travels regardless of which runs
/// were asked for.
///
/// A file with records stamped and **no** case blocks at all is reported as
/// unchecked rather than as a finding, because the export may honestly have
/// been taken from a plane whose journal was written by a case-configured
/// runtime while the export ran without the case store — the CLI wires it when
/// present, but the library caller may not. The trailer's `cases` count is
/// what distinguishes *none existed* from *none were asked for*.
fn settle_cases(
    report: &mut VerifyReport,
    stamped: &std::collections::BTreeSet<crate::core::CaseId>,
    carried: &std::collections::BTreeSet<crate::core::CaseId>,
    blob_digests: usize,
) {
    if carried.is_empty() {
        if !stamped.is_empty() {
            report.not_checked.push(format!(
                "the case layer — {} case(s) are stamped on records and this file carries no \
                 case blocks, so either the plane's case store was not supplied to the export \
                 or the layer was dropped; the two cannot be told apart from the file alone",
                stamped.len()
            ));
        }
        return;
    }
    for case in stamped.difference(carried) {
        report.findings.push(format!(
            "case {case} is stamped on exported records and missing from the case layer — \
             the journal names a matter this file does not carry"
        ));
    }
    if blob_digests > 0 {
        report.not_checked.push(format!(
            "blob bytes — the case layer references {blob_digests} blob digest(s) and this \
             file carries digests, not bytes; presence and integrity are a question about a \
             live blob store"
        ));
    }
    report.not_checked.push(
        "sealed-state keys — whether sealed case state can still be opened is a question \
         about a live key ring, which an offline file cannot answer"
            .to_owned(),
    );
}

/// Open a run block: fresh per-run state, and the block's leaf collected for
/// the tree rebuild. Returns `None` for a block whose run id does not parse —
/// the records under it are then flagged as belonging to no run, which is the
/// honest reading of a block nothing can be looked up by.
fn open_run_block(
    value: &serde_json::Value,
    leaves: &mut Vec<(u64, crate::core::merkle::LeafHash)>,
) -> Option<RunPass> {
    use crate::core::{Digest, merkle};
    use serde_json::Value;

    let pass = value
        .get("run")
        .and_then(Value::as_str)
        .and_then(|s| RunId::parse(s).ok())
        .map(|run| RunPass {
            run,
            declared_seal: value
                .get("seal")
                .and_then(|s| serde_json::from_value::<Digest>(s.clone()).ok()),
            prev: Digest::ZERO,
            last_seq: 0,
            records: 0,
            resealed: Vec::new(),
            clean: true,
        });
    if let Some(pass) = &pass
        && let (Some(index), Some(seal)) = (
            value.get("index").and_then(Value::as_u64),
            pass.declared_seal,
        )
    {
        leaves.push((index, merkle::leaf_hash(&seal)));
    }
    pass
}

/// The verifier's working state for the run block it is inside.
///
/// One struct rather than five parallel locals, because they reset together —
/// a new run block replaces all of them at once, and a field that survived the
/// boundary would carry one run's evidence into another's verdict.
struct RunPass {
    run: RunId,
    declared_seal: Option<crate::core::Digest>,
    prev: crate::core::Digest,
    last_seq: u64,
    /// How many record lines this block carried. Zero is a state the trailer
    /// must explain: see [`settle_trailer`].
    records: usize,
    resealed: Vec<crate::journal::Record>,
    /// Whether every record in this block checked out so far.
    ///
    /// [`VerifyReport::sound`] promises *chain recomputed exactly*, and the
    /// leaf comparison alone cannot hold that promise for an **open** run —
    /// there is no leaf, so without this flag an edited record in an unsealed
    /// run produced a finding *and* left the run listed sound.
    clean: bool,
}

/// Hold the export's header to every anchor, and return one it matched.
///
/// Three answers, because the size relation decides which applies: an anchor
/// *above* the file, or of another log, names a history the file cannot be part
/// of; an anchor *at* the file's size either matches it or names a second
/// history of that size; and an anchor *below* it is checked against the
/// file's own first leaves, which the file carries — a mismatch is a finding,
/// and a match anchors the prefix and leaves the rest to the header, which is
/// said.
fn compare_anchors(
    report: &mut VerifyReport,
    header_seen: bool,
    anchors: &[crate::journal::Anchor],
    leaves: &[(u64, crate::core::merkle::LeafHash)],
) -> Option<Checkpoint> {
    let mut matched = None;
    if !header_seen {
        return matched;
    }
    for anchor in anchors {
        let given = &anchor.checkpoint;
        if given.origin != report.checkpoint.origin || given.size > report.checkpoint.size {
            report.findings.push(format!(
                "the export's header names log '{}' at size {} with root {}, and the \
                 checkpoint held by {} names '{}' at size {} with root {} — the file \
                 describes a different history than the one it is being checked against",
                report.checkpoint.origin,
                report.checkpoint.size,
                report.checkpoint.root.to_hex(),
                anchor.obtained_from,
                given.origin,
                given.size,
                given.root.to_hex(),
            ));
        } else if given.size == report.checkpoint.size {
            if given.root == report.checkpoint.root {
                if matched.is_none() {
                    matched = Some(given.clone());
                }
            } else {
                report.findings.push(format!(
                    "the export's header names log '{}' at size {} with root {}, and the \
                     checkpoint held by {} holds that same size with root {} — one tree of \
                     a given size has one root, so these are two histories",
                    report.checkpoint.origin,
                    report.checkpoint.size,
                    report.checkpoint.root.to_hex(),
                    anchor.obtained_from,
                    given.root.to_hex(),
                ));
            }
        } else if prefix_root(leaves, given.size) != Some(given.root) {
            report.findings.push(format!(
                "the checkpoint held by {} commits to the first {} run(s) of log '{}' with \
                 root {}, and this export's first {} run(s) do not rebuild to it — a run \
                 inside that prefix was removed, replaced or moved",
                anchor.obtained_from,
                given.size,
                given.origin,
                given.root.to_hex(),
                given.size,
            ));
        } else {
            report.not_checked.push(format!(
                "the checkpoint held by {} is at size {} and matches this export's first {} \
                 run(s); the {} after it are held only to the file's own header",
                anchor.obtained_from,
                given.size,
                given.size,
                report.checkpoint.size - given.size
            ));
        }
    }
    matched
}

/// The root of the tree of a file's first `size` leaves, when the file carries
/// exactly the positions `0..size` among them.
///
/// An export holds every leaf from position 0, so a checkpoint smaller than
/// the file is a tree the file can rebuild — no consistency proof is needed
/// for a reader who holds the leaves themselves. `leaves` is sorted by
/// position; a missing or duplicated position below `size` answers `None`.
fn prefix_root(
    leaves: &[(u64, crate::core::merkle::LeafHash)],
    size: u64,
) -> Option<crate::core::Digest> {
    let size = usize::try_from(size).ok()?;
    let prefix = leaves.get(..size)?;
    prefix
        .iter()
        .enumerate()
        .all(|(at, (index, _))| u64::try_from(at) == Ok(*index))
        .then(|| {
            crate::core::merkle::root(&prefix.iter().map(|(_, leaf)| *leaf).collect::<Vec<_>>())
        })
}

/// The checks that can only be made once the whole file has been read.
///
/// Separated because they answer a different question from the per-record pass:
/// that one asks *is each record what it says it is*, and every one of these
/// asks *is anything missing* — which no single line can reveal.
fn settle(
    report: &mut VerifyReport,
    header_seen: bool,
    mut leaves: Vec<(u64, crate::core::merkle::LeafHash)>,
    anchors: &[crate::journal::Anchor],
) {
    use crate::core::merkle;

    // Which checkpoint the rebuild is held to, and everything below turns on
    // it. The header's own is a claim by whoever wrote the file; an anchor is
    // one the reader was given by somebody else — printed by an earlier audit,
    // cosigned by a witness, pasted into a ticket. Only the second makes the
    // Merkle rebuild evidence about *deletion*; against the header it is
    // evidence that the file is self-consistent, which an editor who dropped a
    // run and rewrote the header also achieves.
    //
    // **Every anchor is consulted, and what cannot be is said.** Three answers,
    // because the size relation decides which one applies: an anchor *above*
    // the file is a log that shrank, an anchor *at* the file's size either
    // matches it or names a different history, and an anchor *below* it is
    // rebuilt from the file's own first leaves. Collapsing any of them is what
    // lets an operator pick whichever observer their export happens to satisfy.
    leaves.sort_by_key(|(index, _)| *index);
    let matched = compare_anchors(report, header_seen, anchors, &leaves);
    let against = if let Some(checkpoint) = matched {
        checkpoint
    } else {
        if anchors.is_empty() {
            report.not_checked.push(
                "deletion — no checkpoint was supplied, so the Merkle root could only be \
                 rebuilt and compared against this file's own header. That proves the \
                 file is internally consistent, which is also what an editor who dropped \
                 a run and rewrote the header achieves. Pass the checkpoint an earlier \
                 audit printed, or one a witness cosigned"
                    .to_owned(),
            );
        }
        report.checkpoint.clone()
    };

    if !header_seen {
        report
            .findings
            .push("the export has no header, so nothing says which log it came from".into());
    }

    // The tree, rebuilt in log order. This is what notices a whole run dropped
    // from the middle: every per-run chain above still verified, because a chain
    // links records within a run and knows nothing about its neighbours.
    let size = u64::try_from(leaves.len()).unwrap_or(u64::MAX);
    if size == against.size {
        // The positions are part of the claim, not bookkeeping: a checkpoint of
        // size N commits to leaves 0..N, so a duplicated or out-of-range
        // position is a relabelled log. Named here rather than left to surface
        // as a root mismatch, because "the root differs" tells an auditor that
        // something is wrong and not that two runs claim one place in history —
        // and a tree built over duplicated positions would compare garbage
        // against the root and report the wrong defect.
        let contiguous = leaves
            .iter()
            .enumerate()
            .all(|(at, (index, _))| u64::try_from(at) == Ok(*index));
        if contiguous {
            let rebuilt =
                merkle::root(&leaves.into_iter().map(|(_, leaf)| leaf).collect::<Vec<_>>());
            if rebuilt != against.root {
                report.findings.push(
                    "the Merkle root rebuilt from this export does not match the checkpoint it \
                     claims to be a copy of"
                        .to_owned(),
                );
            }
        } else {
            report.findings.push(format!(
                "the run blocks' log positions are not the contiguous 0..{} the checkpoint \
                 commits to — a position is duplicated or missing, so this file describes a \
                 different log than the one it names",
                against.size
            ));
        }
    } else {
        report.findings.push(format!(
            "the export carries {size} sealed run(s) and its checkpoint commits to {} — the \
             difference is runs that were in the log and are not in this file",
            against.size
        ));
    }

    if !report.complete {
        report.findings.push(
            "the export has no trailer, so it was cut short — every line in it is still valid, \
             which is why the frame is the signal"
                .to_owned(),
        );
    }
}

/// Read the header line: which format, which log, at what size, under which rule.
fn read_header(value: &serde_json::Value, report: &mut VerifyReport) {
    let version = value.get("version").and_then(serde_json::Value::as_u64);
    if version != Some(u64::from(FORMAT_VERSION)) {
        report.findings.push(format!(
            "the export claims format version {version:?} and this build reads {FORMAT_VERSION} \
             — the findings below describe the lines this build could interpret, which may not \
             be all of them"
        ));
    }
    // A foreign canonicalization rule is a statement about coverage, not a
    // finding — nothing this pass checks depends on the rule. The chain
    // rehash, the leaf comparison, the Merkle root and the signatures all run
    // over the wire bytes **as written** (`Digest::chain` is plain hashing;
    // it never re-canonicalizes), so they hold under any rule; the counts,
    // the body-vs-wire comparison and the case coverage are byte and value
    // comparisons with no rule in them at all. What a foreign rule *does*
    // take off the table is re-deriving the digests inside the bodies —
    // effect keys, manifest and plan digests — which this pass never
    // recomputes anyway, and which a replaying build would. Filing it as a
    // finding made an honest cross-build export read as tampered, which
    // teaches a reader to ignore the finding that means it.
    let canon = value.get("canon").and_then(serde_json::Value::as_u64);
    if canon != Some(u64::from(crate::core::canon::VERSION)) {
        report.not_checked.push(format!(
            "derived digests — the export was written under canonicalization rule {canon:?} \
             and this build implements {}. The chain, leaf, root and signature checks still \
             ran and still hold (they hash the bytes as written, never a re-serialization); \
             what this build cannot do is re-derive the digests inside the bodies, such as \
             effect keys, under the rule that produced them",
            crate::core::canon::VERSION
        ));
    }
    match value
        .get("checkpoint")
        .and_then(|c| serde_json::from_value::<Checkpoint>(c.clone()).ok())
    {
        Some(c) => report.checkpoint = c,
        None => report
            .findings
            .push("the header carries no readable checkpoint".to_owned()),
    }
}

/// Rehash one record's wire bytes and hold them to the hash it carries.
///
/// The rehash is the whole check, and it runs over `raw` — the exact bytes the
/// store hashed — never over a re-serialization of the parsed body. That is
/// the journal's own wire-bytes rule: re-serializing would hold the file to
/// *this build's* canonicalization instead of to what was written, so an
/// export from a build whose rule differed would report tampering where there
/// was only time, and — worse — an edit that re-serializes identically would
/// pass. Comparing the file's `prev_hash` against the previous line's `hash`
/// would only prove the file agrees with itself, which an editor who
/// recomputed the chain also achieves; rehashing the wire bytes is what makes
/// agreement evidence about them.
fn read_record(
    value: &serde_json::Value,
    pass: &mut RunPass,
    report: &mut VerifyReport,
    stamped: &mut std::collections::BTreeSet<crate::core::CaseId>,
) {
    let current = pass.run;
    pass.records += 1;
    let (Some(raw), Some(claimed)) = (
        value.get("raw").and_then(serde_json::Value::as_str),
        value
            .get("hash")
            .and_then(|h| serde_json::from_value::<crate::core::Digest>(h.clone()).ok()),
    ) else {
        report.findings.push(format!(
            "run {current}: a record line carries no wire bytes or no hash — nothing ties \
             it to the chain"
        ));
        pass.clean = false;
        return;
    };
    let raw_bytes = raw.as_bytes();
    // The hash first, then the parse. Bytes that do not hash to their claim
    // were edited, whatever they parse as; only bytes that do can make a parse
    // failure a statement about the reader rather than about the file.
    if crate::core::Digest::chain(pass.prev, raw_bytes) != claimed {
        return edited_record(raw_bytes, pass, report);
    }
    // The body verification reads is parsed from the wire bytes — the one
    // source the hash actually covers.
    let body = match serde_json::from_slice::<crate::journal::RecordBody>(raw_bytes) {
        Ok(body) => body,
        Err(parse) => return unparsed_record(raw_bytes, parse, pass, report),
    };
    // The readable `body` is a courtesy copy, and it is held to the bytes: a
    // file whose display half says something its hashed half does not is the
    // quiet edit — every hash verifies, and the reader was shown a lie.
    let wire: serde_json::Value = serde_json::from_slice(raw_bytes).unwrap_or_default();
    if value.get("body") != Some(&wire) {
        report.findings.push(format!(
            "run {current}: record {}'s readable body does not match its wire bytes — the \
             display copy was edited, and every hash still verifies over the real one",
            body.seq
        ));
        pass.clean = false;
    }
    // Collected for the case-coverage settlement: a stamp is the journal
    // naming a matter, and the case layer must carry every matter it names.
    if let Some(case) = body.case {
        stamped.insert(case);
    }
    // The record's own body names its run, and it must be the run the block
    // claims. Without this comparison an export could relabel a whole history —
    // run B's records and B's leaf filed under A's id — and every other check
    // would pass, because chain, seal and Merkle all verify B's bytes; only the
    // *label* lied, and the label is what the reader looks a run up by.
    if body.run != current {
        report.findings.push(format!(
            "run {current}: a record in this block belongs to run {} — the block was relabelled, \
             or spliced from another history",
            body.run
        ));
        pass.clean = false;
    }
    // A removed record breaks a link; a removed *tail* does not, which is why
    // the sequence is checked as well as the chain.
    if body.seq != pass.last_seq + 1 {
        report.findings.push(format!(
            "run {current}: seq {} follows {}, so a record is missing from the middle — \
             every chain link either side of the gap still joins",
            body.seq, pass.last_seq
        ));
        pass.clean = false;
    }
    pass.last_seq = body.seq;

    // The sealing record's own claim, held to the chain it sits in — the same
    // check the live audit makes. `RunSealed.chain_head` is the head the
    // conclusion was drawn over, which is by construction its own record's
    // `prev_hash`; `pass.prev` here is that head, recomputed from the wire
    // bytes of every line before this one, so agreement is evidence about the
    // bytes rather than the file agreeing with itself. A mismatch means the
    // conclusion was composed against a different history than the one it was
    // appended to, which no honest writer produces. What this does NOT cover:
    // a run with no sealing record at all — an open run has made no claim,
    // and its absence of one is a state, not a defect.
    if let crate::journal::RecordKind::RunConcluded { chain_head, .. } = &body.kind
        && *chain_head != pass.prev
    {
        report.findings.push(format!(
            "run {current}: the sealing record claims a chain head that is not the head it \
             sits on — the conclusion was drawn over a different history"
        ));
        pass.clean = false;
    }

    let signature = value
        .get("signature")
        .and_then(|a| serde_json::from_value::<Option<crate::core::KeySignature>>(a.clone()).ok())
        .flatten();
    // **Why the failure is matched on rather than summarised.** Not every way
    // this read fails is an incident. The hash was checked before the body was
    // parsed, at the top of this function, so every record reaching this point
    // has bytes the chain commits to — a version
    // this build does not read is then a statement about the reader, not about
    // the file. Collapsing the two spends a tampering verdict on the one
    // artifact this project hands to somebody who does not run it, and an
    // export carries the incident kinds most often: a cancelled run, a
    // withheld authority, a decided quarantine.
    //
    // A *shape* skew cannot arrive here, and the reason is checkable rather
    // than assumed: the body above is parsed from these same bytes into the
    // same struct, so a body this build cannot read has already returned
    // through `unparsed_record`. Only the version survives that gate, because
    // nothing above compares it.
    match crate::journal::Record::from_stored_signed(
        raw_bytes.to_vec(),
        pass.prev,
        claimed,
        signature,
    ) {
        Ok(record) => {
            pass.prev = record.hash;
            pass.resealed.push(record);
        }
        // The hash was verified and the body parsed above, so the version is
        // the one question left unanswered: every failure here is a record at
        // a version this build does not read.
        Err(skew) => {
            report.findings.push(format!(
                "run {current}: record {} is at a version this build does not read, and \
                 its bytes hash as written — this is a build skew rather than an edit: \
                 {skew}",
                pass.last_seq
            ));
            pass.clean = false;
            pass.prev = crate::core::Digest::chain(pass.prev, raw_bytes);
        }
    }
}

/// Members of a framing line this build does not know, reported as unchecked.
///
/// **A verdict is only as wide as the claims the reader understood.** A framing
/// line carries claims that are checked — a checkpoint, a leaf, the trailer's
/// accounting — so a member added by a later writer may carry one more, and a
/// reader that passes over it reports *sound* about a file it read part of.
///
/// This is `not_checked` rather than a finding, and the difference is the one
/// the record path draws for the same question. A record's bytes are hashed, so
/// a member nobody knows is refused: the verdict would otherwise be reached over
/// evidence the reader did not see. A framing line is not hashed and carries no
/// evidence of its own, so an unknown member does not falsify anything already
/// checked — it bounds what the check covered, which is what this field is for.
fn note_unknown_members(kind: &str, value: &serde_json::Value, report: &mut VerifyReport) {
    let known: &[&str] = match kind {
        "agentplane.export" => &["kind", "version", "checkpoint", "canon"],
        "agentplane.export.run" => &["kind", "run", "index", "seal"],
        "agentplane.export.case" => &["kind", "case", "deadlines", "blobs", "hold"],
        "agentplane.export.end" => &[
            "kind",
            "runs_requested",
            "runs_exported",
            "records",
            "cases",
            "unreadable",
        ],
        // Not a frame: a record line carries no top-level `kind`, and its own
        // members are refused rather than noted, one level down.
        _ => return,
    };
    let Some(object) = value.as_object() else {
        return;
    };
    let unknown: Vec<&str> = object
        .keys()
        .map(String::as_str)
        .filter(|member| !known.contains(member))
        .collect();
    if !unknown.is_empty() {
        report.not_checked.push(format!(
            "a {kind} line carries {} this build does not know — whatever they claim was \
             not checked, and a later build wrote this file",
            unknown.join(", ")
        ));
    }
}

/// A record line whose bytes do not hash to the hash it carries.
///
/// Filed before any parse: whatever these bytes parse as, they are not the
/// bytes the chain committed to, so a parse failure here says nothing about
/// the reader's age.
fn edited_record(raw_bytes: &[u8], pass: &mut RunPass, report: &mut VerifyReport) {
    let seq = serde_json::from_slice::<serde_json::Value>(raw_bytes)
        .ok()
        .and_then(|v| v.get("seq").and_then(serde_json::Value::as_u64))
        .unwrap_or(pass.last_seq + 1);
    report.findings.push(format!(
        "run {}: record {seq} does not recompute to the hash it carries — it was edited \
         after it was sealed",
        pass.run
    ));
    pass.clean = false;
    // The head and the sequence walk forward over the bytes actually present,
    // so the leaf comparison at the end of the block speaks about what this
    // file carries rather than about the first mismatch.
    pass.prev = crate::core::Digest::chain(pass.prev, raw_bytes);
    pass.last_seq = seq;
}

/// A record line this reader cannot parse, filed under what that means.
///
/// **A line this reader cannot parse is not the same as a line nobody can.**
/// This is the first gate a record from a newer build meets, so answering
/// *malformed* for both would report an export written one hard cut ahead as a
/// damaged file, record by record, to the one audience that has no other copy.
fn unparsed_record(
    raw_bytes: &[u8],
    parse: serde_json::Error,
    pass: &mut RunPass,
    report: &mut VerifyReport,
) {
    let current = pass.run;
    let classified = crate::journal::unreadable(raw_bytes, parse);
    match &classified {
        crate::core::StoreError::UnreadableRecordShape { .. } => {
            report.findings.push(format!(
                "run {current}: a record is at a shape this build does not read — a build \
                 skew rather than a damaged file: {classified}"
            ));
        }
        _ => report
            .findings
            .push(format!("run {current}: a record line is malformed")),
    }
    pass.clean = false;
    // The head and the sequence walk forward over what the file carries, so the
    // records after this one are compared against the history the file actually
    // holds. Without it one unreadable line makes every later record in the
    // block report a broken link and a gap — a cascade of incident-shaped
    // findings from one old reader.
    pass.prev = crate::core::Digest::chain(pass.prev, raw_bytes);
    if let Some(seq) = serde_json::from_slice::<serde_json::Value>(raw_bytes)
        .ok()
        .and_then(|v| v.get("seq").and_then(serde_json::Value::as_u64))
    {
        pass.last_seq = seq;
    }
}

/// Close out a run block: its terminal hash must be the leaf the log recorded,
/// and its signatures must verify if a key was supplied.
fn finish_run(
    report: &mut VerifyReport,
    pass: Option<RunPass>,
    verifier: Option<&dyn crate::core::Verifier>,
    read_runs: &mut usize,
    empty_blocks: &mut Vec<RunId>,
) {
    let Some(pass) = pass else {
        return;
    };
    let run = pass.run;
    // A block with no records is never sound, and it is never judged here:
    // whether it is an honestly-declared unreadable run (unchecked) or a run
    // emptied after the export was taken (a finding) is written in the
    // trailer, which this pass has not necessarily reached — an intermediate
    // block closes when the next one starts. Judging it now would also raise a
    // false leaf-mismatch for a sealed unreadable run, whose declared leaf is
    // genuine and whose records the writer honestly could not read: `prev` is
    // still `ZERO`, and ZERO not matching the leaf is a fact about the empty
    // walk, not about the history.
    if pass.records == 0 {
        empty_blocks.push(run);
        return;
    }
    *read_runs += 1;
    let mut ok = pass.clean;

    // The one cross-check between the two halves of the export. Without it a
    // file could carry a healthy chain and a leaf belonging to some other
    // history, and each half would verify on its own.
    if let Some(seal) = pass.declared_seal
        && seal != pass.prev
    {
        report.findings.push(format!(
            "run {run}: the log's leaf is not this run's terminal hash, so the chain in this \
             file is not the chain the checkpoint committed to"
        ));
        ok = false;
    }

    // One implementation of *is this signed history sound*, and it is the
    // crate's own. `require_signature` is true because this is the auditor's
    // posture: an unsigned record inside a signed history is the one an
    // attacker who cannot sign would add.
    if let Some(v) = verifier
        && let Err(e) = crate::journal::Record::verify_signed(
            &pass.resealed,
            crate::core::Digest::ZERO,
            v,
            true,
        )
    {
        report.findings.push(format!("run {run}: {e}"));
        ok = false;
    }

    if ok {
        report.sound.push(run);
    }
}

/// What a restore loses beyond the case layer, as sentences a reader can act
/// on.
///
/// Extracted so the restore's control flow is the *writing* and this is the
/// *accounting*. Each entry names one loss: a count of them would tell an
/// operator nothing about which one costs them work, and exactly one of these
/// does.
fn losses(parsed: &Parsed) -> Vec<String> {
    let mut out = Vec::new();
    if parsed.canon != Some(u64::from(crate::core::canon::VERSION)) {
        out.push(format!(
            "the digests — the export was written under canonicalization rule {:?} and this \
             build implements {}, so the rebuilt store re-derives every digest under the new \
             rule and its checkpoint cannot match the export's. The data is restored; \
             `is_faithful` is unprovable, not false",
            parsed.canon,
            crate::core::canon::VERSION
        ));
    }
    if parsed.signed > 0 && !parsed.runs.is_empty() {
        out.push(format!(
            "{} record(s) carried a signature that this store did not reproduce — `append` \
             attests as the restoring store's own signer, so authorship is lost unless it \
             holds the original key. Hashes and the Merkle root are unaffected",
            parsed.signed
        ));
    }
    out.push(
        "activity timestamps — `recent_runs` now orders by restore time rather than by when \
         history happened. It is a discovery index for listing, and nothing derives a decision \
         from it"
            .to_owned(),
    );

    // Named whether or not this export happens to hold a waiting run. The
    // alternative — say it only when `awaiting` is non-empty — makes the
    // absence of the sentence mean two different things, and the reader who
    // needs it most is the one restoring an export they did not write.
    out.push(
        "every wait's registration — a timer, a subscription and any worklist row a wait \
         opened live in stores this export does not carry, so a restored run that was \
         waiting has nothing to wake it: no timer fires, no subscription matches, and its \
         lease was released cleanly when it suspended, so recovery does not see it either. \
         Resuming each run in `awaiting` re-arms the wait from the journal"
            .to_owned(),
    );
    out.push(
        "the worklist, unclaimed inbound events, webhook registrations and their delivery \
         cursors, governed memory, and the batch, quota and standing-authority ledgers — \
         none of these layers is in the export. A decision a run already consumed survives \
         because that run journaled it; one nobody had consumed does not"
            .to_owned(),
    );

    out
}

/// Which of the restored runs came back waiting.
///
/// Read with the same function every other surface answers *what does this
/// run's history say* with. A fourth copy of the match would be the copy that
/// disagrees the day a record kind arrives.
async fn awaiting_runs(
    store: &Arc<dyn JournalStore>,
    runs: &[RestoredRun],
) -> Result<Vec<RunId>, StoreError> {
    let mut awaiting = Vec::new();
    for run in runs {
        let records = store.read(run.run, 1).await?;
        if matches!(
            crate::runtime::observed_status(&records),
            Some(crate::runtime::RunStatus::Suspended(_))
        ) {
            awaiting.push(run.run);
        }
    }
    Ok(awaiting)
}

/// Every run the outcome indexes cannot name: the ones still in flight.
///
/// **Selecting what to export is two questions, and this is the second.**
/// `runs_by_outcome` indexes *conclusions*, so a run that has not concluded is
/// in no outcome, and an export driven by
/// [`OUTCOMES_OF_RECORD`](crate::runtime::OUTCOMES_OF_RECORD) alone carries no
/// run that is working, sleeping, awaiting a message or waiting on a person —
/// which is the work a disaster recovery is for. Nothing downstream can notice:
/// the Merkle log commits to **sealed** runs, so a file missing every in-flight
/// run restores to an equal root at an equal size and reports itself faithful.
///
/// **Paged, and bounded by `limit` like every other listing here.** It walks
/// the activity index — the only one that names a run before it ends — and
/// keeps the runs whose history has not concluded, reading **one record** per
/// candidate to decide: the head's sequence, then that record. A run whose
/// records cannot be read is not silently dropped; it is returned in
/// `unreadable` for the caller to report, on the same principle as the
/// export's own trailer.
///
/// The order is the activity index's, which is rebuilt at restore time and
/// derives no decision. Selection is not a decision about a run — it is which
/// rows to read — so using it here does not widen what that index is for.
///
/// # Errors
///
/// If the activity index cannot be paged. A single unreadable run is reported
/// rather than raised: one damaged run must not cost an operator the export of
/// every other.
pub async fn runs_in_flight(
    store: &Arc<dyn JournalStore>,
    limit: usize,
) -> Result<InFlight, StoreError> {
    let mut found = InFlight::default();
    let mut after: Option<(u64, RunId)> = None;
    // Pages of the activity index, not of the answer: most runs in a healthy
    // plane have concluded, so the page that yields one in-flight run may have
    // held five hundred that had ended.
    while found.runs.len() < limit {
        let page = store.recent_runs(after, CASE_PAGE).await?;
        if page.is_empty() {
            break;
        }
        after = page.last().map(|(run, at)| (*at, *run));
        for (run, _) in page {
            if found.runs.len() == limit {
                found.truncated = true;
                return Ok(found);
            }
            let head = store.head(run).await?;
            if head.seq == 0 {
                continue;
            }
            // The last record alone. `read` is inclusive-from, so this is the
            // cheapest question the store answers about a run's state, and it
            // is the one `observed_status` needs.
            match store.read(run, head.seq).await {
                Ok(last) => {
                    // Working (`None` — the last record is neither a
                    // suspension nor a conclusion) and waiting
                    // (`Suspended`) are both in flight. Everything else has
                    // ended, and the wildcard is the arm that matters: a
                    // conclusion this build cannot interpret is still a
                    // conclusion, which `observed_status` guarantees by
                    // failing an unrecognised outcome closed into
                    // `Quarantined` rather than into `None`.
                    let in_flight = match crate::runtime::observed_status(&last) {
                        None | Some(crate::runtime::RunStatus::Suspended(_)) => true,
                        Some(_) => false,
                    };
                    if in_flight {
                        found.runs.push(run);
                    }
                }
                Err(e) => found.unreadable.push((run, e.to_string())),
            }
        }
    }
    Ok(found)
}

/// What [`runs_in_flight`] found, and what it could not read.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InFlight {
    /// Runs that had not concluded, newest activity first.
    pub runs: Vec<RunId>,
    /// The limit was reached, so this is a page rather than the set.
    pub truncated: bool,
    /// Runs the activity index names and whose records would not read.
    pub unreadable: Vec<(RunId, String)>,
}

// ── Putting one back ────────────────────────────────────────────────────────

/// What a restore did, and what it could not carry across.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct RestoreReport {
    /// The checkpoint the export claimed.
    pub expected: Checkpoint,
    /// The checkpoint the rebuilt store now reports.
    ///
    /// **These matching is the whole result.** Equal roots at equal size means
    /// every record, in every run, in the order the log recorded them, rebuilt
    /// to the same commitment — which is a far stronger statement than "the
    /// rows loaded".
    pub rebuilt: Checkpoint,
    pub runs: usize,
    pub records: usize,
    /// Cases rebuilt from the export's case layer.
    pub cases: usize,
    /// Runs whose history ends in a wait, and which nothing will now wake.
    ///
    /// **Ids rather than a count, because the operator has to act on each
    /// one.** A wait is journaled but what performs it is not: the timer, the
    /// subscription and the task row live in stores this export does not
    /// carry. A restored suspended run has no timer to fire, no subscription
    /// to match, and released its lease cleanly when it suspended — so the
    /// recovery pass does not see it either. Nothing in the system names it,
    /// which is the state the runtime otherwise repairs on sight.
    ///
    /// Resuming each one repairs it: replay reaches the announced wait, finds
    /// no terminal record, and re-arms from the journal. That needs the
    /// agent's own code, so it is the caller's step and not the restore's.
    pub awaiting: Vec<RunId>,
    /// What did not survive, named rather than counted.
    pub not_carried: Vec<String>,
}

impl RestoreReport {
    /// Whether the rebuilt store commits to exactly the history the export did.
    ///
    /// **The commitment, not the label.** A checkpoint carries a log *identity*
    /// beside its size and root, and a recovery routinely changes that: the
    /// realistic restore is into another tenant of a database somebody else is
    /// already using, which is the topology `for_tenant` exists for. Comparing
    /// the identity made a byte-perfect restore report as a failed one exactly
    /// in the case a disaster puts an operator in — and `agentplane restore`
    /// exits on this predicate.
    ///
    /// A relabelling is not silent for being excluded: it is a sentence in
    /// [`not_carried`](Self::not_carried), beside every other thing the file
    /// could not bring across, and both checkpoints are on the report for a
    /// reader who wants to see the names.
    #[must_use]
    pub fn is_faithful(&self) -> bool {
        self.expected.size == self.rebuilt.size && self.expected.root == self.rebuilt.root
    }
}

/// Rebuild a store from an export, then prove it by its own checkpoint.
///
/// # Why this goes through `append` rather than writing rows
///
/// The obvious implementation inserts records verbatim and rebuilds each index
/// beside them. It is also the one that fails quietly: `append` maintains
/// several derived structures — the case index, the exactly-once index, the
/// outcome index and its ordering counter, the admission index, both halves of
/// the activity index — and a restore that reconstructed all but one of them
/// would produce a store that reads perfectly until somebody queries the one it
/// missed.
///
/// Deliberately not a count. A number here is a claim that has to be re-checked
/// on every edit and is not, so it goes stale silently and reads as coverage —
/// the shape this project catalogues and has been bitten by. Going through
/// `append` is what makes the list not need enumerating: whatever `append`
/// maintains, a restore maintains.
///
/// So this replays the ordinary write path, and every constraint the store
/// enforces is enforced here too. Three properties make that reproduce the
/// original bytes rather than merely similar ones:
///
/// * **`seq` is re-derived and lands identically**, because a run restored into
///   an empty store starts from the same genesis and receives the same records
///   in the same order.
/// * **`epoch` is carried, not re-derived.** It is a field of the hashed body,
///   so a run that ever changed hands — the ones a disaster is most likely to
///   involve — would hash differently under a single fresh lease. `append`
///   takes the epoch as a parameter and fences only when a lease row *exists*,
///   so restoring into a store with no leases writes each record under its own
///   original epoch. Records are grouped into runs of equal epoch for exactly
///   this reason.
/// * **Runs are sealed in log-index order**, so the Merkle log is rebuilt in the
///   order the original recorded, which is what makes the roots comparable at
///   all.
///
/// # What does not survive
///
/// Every loss is a sentence in [`RestoreReport::not_carried`], because the
/// reader who needs it is holding the report rather than this page. One of
/// them costs work rather than metadata and has its own field:
/// [`RestoreReport::awaiting`] names the runs that came back waiting, and says
/// there why nothing will wake them and what does.
///
/// # Errors
///
/// If the export cannot be read, or if the store refuses a write. A store that
/// already holds any of these runs will refuse: this rebuilds a history, it does
/// not merge one.
pub async fn from_jsonl<R: std::io::BufRead>(
    store: &Arc<dyn JournalStore>,
    cases: Option<&Arc<dyn crate::case::CaseStore>>,
    input: R,
) -> Result<RestoreReport, StoreError> {
    let parsed = parse(input).map_err(|e| StoreError::Backend(e.to_string()))?;
    restore_parsed(store, cases, parsed).await
}

/// An export, restored into a store that lives only as long as the process.
///
/// The source a strict replay reads when it is handed a file rather than a
/// plane: every run the file holds, rebuilt through [`from_jsonl`] and so
/// checked against the file's own checkpoint, with nothing written to disk.
#[cfg(feature = "redb")]
#[derive(Debug)]
pub struct ReplaySource {
    pub store: Arc<crate::store::RedbStore>,
    /// The runs the file holds, in the order it lists them.
    pub runs: Vec<RunId>,
    pub report: RestoreReport,
}

/// Restore an export into memory for replay.
///
/// # Errors
///
/// If the file cannot be read or is refused by [`from_jsonl`], or if the
/// rebuilt store does not commit to the history the file claims.
#[cfg(feature = "redb")]
pub async fn open_for_replay<R: std::io::BufRead>(input: R) -> Result<ReplaySource, StoreError> {
    let parsed = parse(input).map_err(|e| StoreError::Backend(e.to_string()))?;
    let runs = parsed.runs.iter().map(|r| r.run).collect();
    let store = Arc::new(crate::store::RedbStore::open_in_memory()?);
    let journal = Arc::clone(&store) as Arc<dyn JournalStore>;
    let cases = Arc::clone(&store) as Arc<dyn crate::case::CaseStore>;
    let report = restore_parsed(&journal, Some(&cases), parsed).await?;
    if !report.is_faithful() {
        return Err(StoreError::Backend(format!(
            "the export does not rebuild to its own checkpoint: it claims {} records under \
             root {}, and restoring it produced {} under {}",
            report.expected.size, report.expected.root, report.rebuilt.size, report.rebuilt.root
        )));
    }
    Ok(ReplaySource {
        store,
        runs,
        report,
    })
}

async fn restore_parsed(
    store: &Arc<dyn JournalStore>,
    cases: Option<&Arc<dyn crate::case::CaseStore>>,
    parsed: Parsed,
) -> Result<RestoreReport, StoreError> {
    // A check `parse` leaves to its caller, because it is not about any one
    // line: a format this build does not read cannot be
    // *parsed* completely, and `parse` skips what it does not recognise — so
    // proceeding would restore whatever subset happened to look familiar and
    // report it as the whole file.
    if parsed.version != Some(u64::from(FORMAT_VERSION)) {
        return Err(StoreError::Backend(format!(
            "the export claims format version {:?} and this build reads {FORMAT_VERSION} — \
             restoring a format this build cannot fully parse would rebuild an unknowable \
             subset and call it a history",
            parsed.version
        )));
    }

    // The frame is the completeness signal, and the restore is the reader most
    // exposed to its absence: a truncated export is a *prefix* in which every
    // line is valid, so replaying one rebuilds a partial history shaped
    // exactly like a whole one. The quietest cut is the worst — a file cut
    // after the last record but before the case layer restores a journal that
    // is byte-perfect and `is_faithful`, with every matter it names missing.
    // Refused before any write lands, so a refused restore leaves nothing to
    // clean up. What this does NOT cover: a file truncated *and* given a
    // forged trailer — that is `verify`'s count settlement, and the right
    // order is restore, then verify.
    if !parsed.complete {
        return Err(StoreError::Backend(
            "the export has no trailer, so it was cut short — every line in it is a valid \
             prefix, and restoring a prefix would rebuild a partial history shaped exactly \
             like a whole one. Re-take the export"
                .to_owned(),
        ));
    }

    let mut records = 0usize;
    for run in &parsed.runs {
        // Grouped by epoch, in order. Each group is one `append` carrying that
        // group's own epoch, which is what reproduces the hashed bodies of a run
        // that changed owner mid-flight.
        for batch in run.bodies.chunk_by(|a, b| a.epoch == b.epoch) {
            let Some(epoch) = batch.first().map(|b| b.epoch) else {
                continue;
            };
            let appends: Vec<Append> = batch.iter().cloned().map(Append::from_body).collect();
            records += appends.len();
            store.append(epoch, appends).await?;
        }
    }

    // Sealed last, and in the log's own order, because that order *is* the
    // Merkle log. Sealing as each run finished would rebuild the tree in
    // whatever sequence the file happened to list them, and the roots would
    // differ for a history that is otherwise identical.
    let mut sealed: Vec<&RestoredRun> = parsed
        .runs
        .iter()
        .filter(|r| r.index.is_some())
        .collect::<Vec<_>>();
    sealed.sort_by_key(|r| r.index);
    for run in sealed {
        let (Some(outcome), Some(epoch)) =
            (run.outcome.as_deref(), run.bodies.last().map(|b| b.epoch))
        else {
            continue;
        };
        store.seal(run.run, epoch, outcome).await?;
    }

    // The case layer, after the journal. Order matters only for the operator's
    // mental model — the two halves share no constraint — but the journal is
    // the half whose restore can fail on a constraint, and failing before any
    // case row landed leaves the cleaner wreck.
    let mut imported = 0usize;
    let mut not_carried = Vec::new();
    match (cases, parsed.cases.is_empty()) {
        (Some(case_store), false) => {
            for block in &parsed.cases {
                case_store
                    .import_case(&block.case, &block.deadlines, &block.blobs)
                    .await?;
                if let Some(hold) = &block.hold {
                    case_store.place_hold(block.case.id, hold).await?;
                }
                imported += 1;
            }
            not_carried.push(
                "blob link timestamps — the export carries a case's blob digests without \
                 the instant each link was written, so erasure reachability survives and \
                 the original ordering does not"
                    .to_owned(),
            );
        }
        (None, false) => not_carried.push(format!(
            "the case layer — the export carries {} case(s) and no case store was supplied, \
             so the journal is rebuilt and the matters it names are not",
            parsed.cases.len()
        )),
        (_, true) => {}
    }

    not_carried.extend(losses(&parsed));

    let awaiting = awaiting_runs(store, &parsed.runs).await?;

    let rebuilt = store.checkpoint().await?;
    if rebuilt.origin != parsed.checkpoint.origin {
        not_carried.push(format!(
            "the log identity — this history was written by '{}' and is now held by '{}'. \
             A legitimate recovery: a restore is pointed at a store, and one tenant's \
             history put back under another tenant's name is a different log with the \
             same contents. It is named because a checkpoint an auditor holds from \
             before the disaster will report the new log as the wrong one",
            parsed.checkpoint.origin, rebuilt.origin
        ));
    }

    Ok(RestoreReport {
        expected: parsed.checkpoint,
        rebuilt,
        runs: parsed.runs.len(),
        records,
        cases: imported,
        awaiting,
        not_carried,
    })
}

/// One run, as an export describes it.
struct RestoredRun {
    run: RunId,
    /// Position in the Merkle log; `None` for a run that was still open.
    index: Option<u64>,
    outcome: Option<String>,
    bodies: Vec<crate::journal::RecordBody>,
    /// The chain head over the records read so far, which the next record's
    /// hash must extend.
    prev: crate::core::Digest,
}

/// One record line's body, once its bytes are known to be the ones the chain
/// committed to and at the version this build writes.
///
/// Both are conditions of replay rather than verification. `append` re-derives
/// each hash from what it is handed and stamps this build's version, so bytes
/// the claimed hash does not cover would rebuild a history the file never
/// committed to, and a record at another version would be silently rewritten
/// at this one — either way into a populated store, before the checkpoint
/// comparison could say so.
fn replayable(
    raw: &[u8],
    prev: crate::core::Digest,
    claimed: crate::core::Digest,
) -> Result<crate::journal::RecordBody, std::io::Error> {
    crate::journal::Record::from_stored_signed(raw.to_vec(), prev, claimed, None)
        .map(|record| record.body)
        .map_err(|e| {
            std::io::Error::other(match e {
                StoreError::Corrupt { .. } => format!(
                    "a record's claimed hash does not cover its wire bytes and the chain \
                     before it ({e}) — replaying it would rebuild a history the export never \
                     committed to, so the file is refused before anything is written"
                ),
                StoreError::UnknownRecordVersion { .. } => format!(
                    "a record is at a version this build does not restore ({e}) — `append` \
                     would rewrite it at this build's version, so the file is refused before \
                     anything is written"
                ),
                other => format!(
                    "a record line's wire bytes do not parse ({other}) — the record cannot be \
                     replayed as written, and its display copy is not a substitute"
                ),
            })
        })
}

struct Parsed {
    checkpoint: Checkpoint,
    /// The format version the header claims, `None` when there was no header.
    version: Option<u64>,
    /// The canonicalization rule the header names, `None` when absent.
    canon: Option<u64>,
    /// Whether the file ended with its trailer. A truncated export is a valid
    /// prefix, and a restore must refuse it — see [`from_jsonl`].
    complete: bool,
    runs: Vec<RestoredRun>,
    cases: Vec<RestoredCase>,
    signed: usize,
}

/// One case block, as a restore replays it.
struct RestoredCase {
    case: crate::core::Case,
    deadlines: Vec<crate::core::Deadline>,
    blobs: Vec<crate::core::Digest>,
    hold: Option<crate::core::LegalHold>,
}

/// A case block's hold: `null` for none, a [`LegalHold`](crate::core::LegalHold)
/// otherwise, and an error when the member is missing or unreadable.
///
/// Shared by the verifier and the restore so the two cannot disagree about
/// what a readable hold is.
fn case_hold(value: &serde_json::Value) -> Result<Option<crate::core::LegalHold>, String> {
    let Some(hold) = value.get("hold") else {
        return Err(
            "a case block carries no `hold` member, so whether the matter is under \
                    a legal hold is unknown"
                .to_owned(),
        );
    };
    serde_json::from_value::<Option<crate::core::LegalHold>>(hold.clone())
        .map_err(|e| format!("a case block's legal hold is malformed: {e}"))
}

/// One case block as a restore replays it, or `None` for a malformed block.
///
/// Malformed blocks are skipped and found by `verify`, per [`parse`]'s
/// no-checking rule — except an unreadable hold, which refuses the file.
fn restored_case(value: &serde_json::Value) -> Result<Option<RestoredCase>, std::io::Error> {
    use serde_json::Value;

    // A restore that refused the file would refuse the healthy cases too.
    let (Ok(case), Some(deadlines), Some(blobs)) = (
        serde_json::from_value::<crate::core::Case>(
            value.get("case").cloned().unwrap_or(Value::Null),
        ),
        value
            .get("deadlines")
            .and_then(|d| serde_json::from_value::<Vec<crate::core::Deadline>>(d.clone()).ok()),
        value
            .get("blobs")
            .and_then(|b| serde_json::from_value::<Vec<crate::core::Digest>>(b.clone()).ok()),
    ) else {
        return Ok(None);
    };
    // Refused before anything is written.
    let hold = case_hold(value).map_err(|e| {
        std::io::Error::other(format!(
            "case {}: {e} — restoring the matter without it would let retention \
                         erase it, so the file is refused",
            case.id
        ))
    })?;
    Ok(Some(RestoredCase {
        case,
        deadlines,
        blobs,
        hold,
    }))
}

/// Read an export into the shape a restore replays.
///
/// Checks what replay needs and nothing wider: [`verify`] answers *is this
/// sound* and this answers *what does it say*. Folding them would make a
/// restore refuse the very history an operator is trying to recover, at the
/// moment they most need it — and the right order is restore, then verify the
/// result against its own checkpoint, which [`from_jsonl`] reports.
///
/// One class of line is a hard error rather than a skip: a record line that
/// cannot be *replayed as written*. Its wire bytes must be present and parse —
/// the one available guess otherwise, the editable display copy, is exactly
/// the value the wire-bytes rule exists to keep out of the rebuilt history —
/// and they must be the bytes its hash covers, at the version this build
/// writes; see [`replayable`]. All of it is decided here, before
/// [`from_jsonl`] writes anything.
fn parse<R: std::io::BufRead>(input: R) -> Result<Parsed, std::io::Error> {
    use serde_json::Value;

    let mut parsed = Parsed {
        checkpoint: Checkpoint {
            origin: String::new(),
            size: 0,
            root: crate::core::Digest::ZERO,
        },
        version: None,
        canon: None,
        complete: false,
        runs: Vec::new(),
        cases: Vec::new(),
        signed: 0,
    };
    for line in input.lines() {
        let line = line?;
        let Ok(value) = serde_json::from_str::<Value>(&line) else {
            continue;
        };
        match value.get("kind").and_then(Value::as_str) {
            Some("agentplane.export") => {
                parsed.version = value.get("version").and_then(Value::as_u64);
                parsed.canon = value.get("canon").and_then(Value::as_u64);
                if let Some(c) = value
                    .get("checkpoint")
                    .and_then(|c| serde_json::from_value::<Checkpoint>(c.clone()).ok())
                {
                    parsed.checkpoint = c;
                }
            }
            Some("agentplane.export.run") => {
                if let Some(run) = value
                    .get("run")
                    .and_then(Value::as_str)
                    .and_then(|s| RunId::parse(s).ok())
                {
                    parsed.runs.push(RestoredRun {
                        run,
                        index: value.get("index").and_then(Value::as_u64),
                        outcome: None,
                        bodies: Vec::new(),
                        prev: crate::core::Digest::ZERO,
                    });
                }
            }
            Some("agentplane.export.case") => {
                if let Some(case) = restored_case(&value)? {
                    parsed.cases.push(case);
                }
            }
            Some("agentplane.export.end") => parsed.complete = true,
            // A line carrying a `kind` this build does not recognise is not a
            // record — record lines are the only unkinded lines in the format
            // — so it is skipped per the no-checking rule rather than held to
            // a record's obligations.
            Some(_) => {}
            _ => {
                if value.get("signature").is_some_and(|a| !a.is_null()) {
                    parsed.signed += 1;
                }
                // The wire bytes are the source of truth, exactly as they are
                // for the verifier: the readable `body` is a courtesy copy,
                // and a restore replaying the copy would rebuild whatever the
                // display half said rather than what the chain covered. There
                // is deliberately **no fallback to that copy**: a record line
                // with no `raw`, or whose `raw` does not parse, is a hard
                // error rather than a skip or a guess — silently substituting
                // the one editable value two mechanisms must agree about would
                // rebuild a history the chain never hashed and let the
                // subsequent verify pass bless it.
                let Some(raw) = value.get("raw").and_then(Value::as_str) else {
                    return Err(std::io::Error::other(
                        "a record line carries no wire bytes (`raw`) — restoring its display \
                         copy instead would rebuild what the readable half says rather than \
                         what the chain hashed, so the file is refused instead of guessed at",
                    ));
                };
                let Some(claimed) = value
                    .get("hash")
                    .and_then(|h| serde_json::from_value::<crate::core::Digest>(h.clone()).ok())
                else {
                    return Err(std::io::Error::other(
                        "a record line carries no hash — nothing ties its bytes to the chain, \
                         so the file is refused instead of replayed",
                    ));
                };
                let Some(current) = parsed.runs.last_mut() else {
                    continue;
                };
                let body = replayable(raw.as_bytes(), current.prev, claimed)?;
                current.prev = claimed;
                if let crate::journal::RecordKind::RunConcluded { outcome, .. } = &body.kind {
                    current.outcome = Some(outcome.clone());
                }
                current.bodies.push(body);
            }
        }
    }
    Ok(parsed)
}