acdp-client 0.14.2

Consumer client (RegistryClient, VerifiedContext, CrossRegistryResolver) for the Agent Context Distribution Protocol (ACDP)
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
//! VerifiedContext: retrieve + verify in one call.

use super::data_ref::{fetch_and_verify_data_ref, DataRefFetcher};
use super::registry::{DiscoveryBudget, RegistryClient};
use acdp_did::WebResolver;
use acdp_primitives::error::AcdpError;
use acdp_types::{body::FullContext, primitives::CtxId};
use acdp_verify::Verifier;
use std::time::Duration;

/// Consumer-tunable strictness for [`VerifiedContext::fetch_with_policy`],
/// [`VerifiedContext::fetch_current_with_policy`], and the
/// `fetch_report*` family ([`VerifiedContext::fetch_report`],
/// [`VerifiedContext::fetch_report_with_fetcher`],
/// [`VerifiedContext::fetch_report_diagnose`]). All three surfaces
/// consult the same policy fields through the same `verify_retrieved`
/// spine — but they do not always *agree*, because
/// [`VerifiedContext::fetch_report_diagnose`] differs in more than just
/// how a failure surfaces:
///
/// - [`VerifiedContext::fetch_report`] and
///   [`VerifiedContext::fetch_report_with_fetcher`] run
///   `verify_retrieved` directly once their own top-level probes pass,
///   and surface a phase failure as `Err`.
/// - [`VerifiedContext::fetch_report_diagnose`] runs its own
///   independent, strict, assertionMethod-only signature *probe* first
///   (recorded as `VerificationReport::signature_ok`). That probe has
///   no historical-key fallback and runs *before* `verify_retrieved` is
///   ever invoked. If it fails, `diagnose` withholds the
///   [`VerifiedContext`] handle with `policy_phase_error: None` — the
///   spine never ran, so there is no phase error to record — even in
///   cases where `verify_retrieved` itself, as run by `fetch_report`,
///   would have accepted the key historically under the default
///   `historical_keys: HistoricalKeyPolicy::AcceptWithReceipt` plus a
///   verified receipt. Concretely: for a key rotated out of
///   `assertionMethod` with a valid receipt, `fetch_report` returns
///   `Ok` with [`KeyAuthorization::HistoricallyAuthorized`], while
///   `diagnose` returns no handle at all for the same input and policy.
///   Only once `diagnose`'s own probes all pass does it fall through to
///   `verify_retrieved` and, from that point on, withhold the handle /
///   record [`VerificationReport::policy_phase_error`] instead of
///   returning `Err` — that part of the behavior *is* shared with the
///   other two.
///
/// For ACDP v0.1.0 the verification profile is **always strict**:
///
/// - `did:web` is required for every producer identity — enforced
///   unconditionally by `verify_signature_envelope`
///   (RFC-ACDP-0001 §5.4), regardless of any policy field.
/// - Embedded `DataRef` hashes are verified by
///   [`acdp_validation::validate_body`] whenever `validate_body_schema`
///   is set.
///
/// Only the fields below have real effect in this version; there are no
/// relaxed-mode `did:web` or embedded-hash knobs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerificationPolicy {
    /// If true, run [`acdp_validation::validate_body`] (structural
    /// schema checks plus embedded-`DataRef` hash verification) before
    /// any cryptographic check. Default `true`. Set `false` only in
    /// diagnostic paths that want to attempt signature verification
    /// despite a body known to fail structural checks.
    ///
    /// The `fetch_report*` family forces this field off unconditionally
    /// on the internal policy it derives from the caller's — they run
    /// `validate_body_structural` (schema only) themselves and record
    /// per-`DataRef` embedded-hash outcomes in
    /// [`VerificationReport::data_ref_embedded`] instead of treating a
    /// mismatch as fatal. This field's value as set by the caller is
    /// otherwise irrelevant to the report family.
    pub validate_body_schema: bool,

    /// If true, accept `Status::Other` values (degrade to active per
    /// RFC-ACDP-0004 §4.1). When false, reject unknown statuses.
    /// Default `true`.
    pub allow_unknown_status: bool,

    /// Registry-receipt handling (ACDP 0.2, RFC-ACDP-0010).
    /// Default [`ReceiptPolicy::VerifyIfPresent`].
    pub receipts: ReceiptPolicy,

    /// Historical-key handling (ACDP 0.2, WS-B). Default
    /// [`HistoricalKeyPolicy::AcceptWithReceipt`].
    pub historical_keys: HistoricalKeyPolicy,

    /// Lineage-head receipt handling on `/current` fetches (ACDP 0.3,
    /// RFC-ACDP-0011). Only consulted by
    /// [`VerifiedContext::fetch_current_with_policy`]; plain retrieval
    /// preserves any `lineage_head_receipt` verbatim without verifying
    /// it. Default [`LineageHeadPolicy::default`]. This is the ONE
    /// field on this struct with restricted scope — `allow_unknown_status`,
    /// `receipts`, `historical_keys`, and `revocations` above and below
    /// are each honored by every entry point that accepts a
    /// [`VerificationPolicy`], including the `fetch_report*` family.
    pub lineage_head: LineageHeadPolicy,

    /// Key-revocation handling (ACDP 0.3, RFC-ACDP-0014 §7). Default:
    /// no known revocations — the phase is inert.
    pub revocations: RevocationPolicy,
}

impl Default for VerificationPolicy {
    fn default() -> Self {
        Self {
            validate_body_schema: true,
            allow_unknown_status: true,
            receipts: ReceiptPolicy::VerifyIfPresent,
            historical_keys: HistoricalKeyPolicy::AcceptWithReceipt,
            lineage_head: LineageHeadPolicy::default(),
            revocations: RevocationPolicy::default(),
        }
    }
}

/// Consumer-held key revocations to enforce during verification
/// (ACDP 0.3, RFC-ACDP-0014 §7).
///
/// [`Self::known`] is **pull-based**: the pipeline does not go looking
/// for those revocations on its own — the caller supplies the
/// **verified** revocations it holds (from
/// [`find_revocations`](crate::revocation::find_revocations),
/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations),
/// an out-of-band channel, or its own indefinite cache — the statement
/// is permanent, cache accordingly). [`Self::discover`] (RFC-ACDP-0014
/// §8) is the opt-in complement: when set, `verify_retrieved` itself
/// runs those same two lookups and unions their result with `known`
/// (see [`RevocationDiscovery`]). When `known` is empty, `discover` is
/// `None`, AND no [`crate::RevocationCache`] is attached to the client,
/// the phase is inert and verification behaves exactly as before
/// RFC-ACDP-0014.
///
/// Issue #257 gives `discover`'s own caching a first-class home:
/// [`crate::RevocationCache`], attached to the [`crate::RegistryClient`]
/// passed to `verify_retrieved` via
/// [`crate::RegistryClient::with_revocation_cache`], persists exactly the
/// "own indefinite cache" a caller would otherwise have to hand-roll
/// around `known` — verified revocations discovered on one call are
/// unioned into every later call against the same client, unconditionally
/// and indefinitely, independent of [`RevocationDiscovery::freshness`], and
/// **regardless of whether that later call itself sets `discover`** —
/// attaching a cache is itself the opt-in (MATERIAL-3, fresh-Opus review
/// of Phase 2): a call made with `discover: None` still seeds
/// producer-signed facts from the cache (never registry-attested ones —
/// see below), which is what extends this anti-rollback protection to
/// [`VerifiedContext::fetch`]/[`VerifiedContext::fetch_current`] (LIM-2),
/// the two entry points that can never set `discover` at all. A
/// registry-attested fact, by contrast, is additionally scoped to the
/// vantage that minted it (BLOCKER-1, RFC-ACDP-0014 §6): reading it back
/// through a client talking to a different authority never applies it,
/// even under [`RevocationDiscovery::include_registry_attested`]. See
/// that field's doc and `crate::revocation_cache` for the full model,
/// including the separate, opt-in, TTL-bounded marker that can additionally
/// skip a repeat lookup.
///
/// When the body's signing key matches a supplied revocation, §7
/// applies: a receipt-attested publish time strictly before the
/// (earliest, §4) `compromised_since` boundary verifies as
/// [`KeyAuthorization::HistoricallyAuthorizedPreCompromise`]; at/after
/// the boundary, or with no verified receipt to place the context at
/// all, verification **fails closed** with `key_not_authorized` —
/// regardless of DID-document state and regardless of the receipt's
/// own validity. Note the interaction with [`ReceiptPolicy::Ignore`]:
/// an unverified receipt provides no publish time, so a revoked key's
/// contexts all fail closed under it.
///
/// This applies uniformly to every entry point that *accepts* a
/// [`VerificationPolicy`] — [`VerifiedContext::fetch_with_policy`],
/// [`VerifiedContext::fetch_current_with_policy`], and the
/// `fetch_report*` family (five entry points in total) — since they all
/// reach this phase through the same internal pipeline. On
/// [`VerifiedContext::fetch_report_diagnose`] specifically, "fails
/// closed" means the returned [`VerifiedContext`] handle is withheld
/// and the cause is recorded in `VerificationReport::policy_phase_error`,
/// rather than the call returning `Err` — that method never
/// short-circuits on a policy-phase failure by design.
///
/// "Uniformly" has one remaining carve-out, structural rather than a
/// policy choice: [`VerifiedContext::fetch`] and
/// [`VerifiedContext::fetch_current`] hardcode
/// [`VerificationPolicy::default`] and so can never carry a non-empty
/// `known` or a `discover`; callers wanting either use the
/// `_with_policy` forms instead. This is recorded as a known limitation
/// (issue #248 LIM-2) rather than silently true. Phase 2's cache still
/// extends anti-rollback protection to these two entry points — see
/// [`Self::discover`]'s doc — but they can never *configure* discovery
/// themselves.
///
/// **Issue #260 closed the sibling limitation, LIM-1.**
/// [`crate::CrossRegistryResolver::with_revocation_policy`] now injects a
/// [`RevocationPolicy`] into every node a cross-registry walk verifies,
/// and [`crate::CrossRegistryResolver::with_revocation_cache`] shares one
/// [`crate::RevocationCache`] across the walk (walk-scoped by default —
/// see that method's doc). The resolver derives `receipts` itself, per
/// node, from that node's advertised capabilities, so it takes a
/// [`RevocationPolicy`], never a caller-supplied [`VerificationPolicy`] —
/// see `CrossRegistryResolver::with_revocation_policy`'s doc for why.
///
/// Only put revocations here that you have verified (strict body
/// pipeline + the §5 not-self-signed rule) and, per §6, that you have
/// decided to act on: producer-signed ones unconditionally;
/// registry-attested ones ([`RevocationTrustClass::RegistryAttested`](acdp_types::revocation::RevocationTrustClass))
/// by default only for contexts served by or receipted by that same
/// registry, with corroboration before global application.
///
/// [`find_revocations`](crate::revocation::find_revocations) itself
/// pre-filters its output to [`RevocationTrustClass::ProducerSigned`](acdp_types::revocation::RevocationTrustClass)
/// entries actually published by the queried producer, so §6
/// registry-attested attestations never arrive through it — obtain
/// those from
/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
/// instead.
///
/// `#[non_exhaustive]`: this struct grew a second field
/// ([`Self::discover`], RFC-ACDP-0014 §8 auto-discovery) after having
/// shipped with exactly one, and a caller constructing this with a
/// bare struct literal would otherwise break on every future field the
/// same way. Construct with [`Self::new`] (equivalent to today's
/// `RevocationPolicy { known }`) and, when opting into discovery,
/// [`Self::with_discovery`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct RevocationPolicy {
    /// Verified revocations to enforce, matched against the signing
    /// key's RFC-ACDP-0010 §6 fingerprint. The §4 earliest-
    /// `compromised_since` rule is applied across entries naming the
    /// same fingerprint, so include *every* revocation of a lineage,
    /// superseded (and retracted) ones too — a later member can only
    /// widen the compromise window, never narrow it, and dropping an
    /// earlier one is exactly how that window gets quietly (and
    /// wrongly) shrunk. This is no longer an unassisted obligation:
    /// [`find_revocations`](crate::revocation::find_revocations) and
    /// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
    /// each walk the full lineage of every candidate they find
    /// (search-visible or not, including all-retracted lineages) and
    /// already return the complete set for their respective trust
    /// class; [`find_revocations_in_lineage`](crate::revocation::find_revocations_in_lineage)
    /// does the same directly from a known `lineage_id`, with no
    /// producer/trust-class scope filter. Populate `known` from one of
    /// these rather than hand-assembling a lineage.
    pub known: Vec<acdp_types::revocation::KeyRevocation>,

    /// RFC-ACDP-0014 §8 auto-discovery configuration. `None` (the
    /// default) is exactly today's behavior: the caller supplies
    /// everything via [`Self::known`] and this phase does no network
    /// I/O of its own. `Some` opts into running
    /// [`find_revocations`](crate::revocation::find_revocations) and,
    /// depending on [`RevocationDiscovery::include_registry_attested`],
    /// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
    /// as part of verification, merging their results with
    /// [`Self::known`].
    pub discover: Option<RevocationDiscovery>,
}

impl RevocationPolicy {
    /// Construct a policy from a caller-supplied revocation set with
    /// discovery left off (`discover: None`) — the same shape as the
    /// bare `RevocationPolicy { known }` literal this struct's
    /// `#[non_exhaustive]` retires.
    #[must_use]
    pub fn new(known: Vec<acdp_types::revocation::KeyRevocation>) -> Self {
        Self {
            known,
            discover: None,
        }
    }

    /// Opt into RFC-ACDP-0014 §8 auto-discovery on top of any
    /// caller-supplied [`Self::known`] revocations. See
    /// [`RevocationDiscovery`] for cost and the required explicit
    /// trust-class choice.
    #[must_use]
    pub fn with_discovery(mut self, discovery: RevocationDiscovery) -> Self {
        self.discover = Some(discovery);
        self
    }
}

/// RFC-ACDP-0014 §8 revocation auto-discovery configuration.
///
/// When set on [`RevocationPolicy::discover`], this instructs
/// verification to look up revocations itself — via
/// [`find_revocations`](crate::revocation::find_revocations) and,
/// when [`Self::include_registry_attested`] is `true`, additionally
/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
/// — instead of relying solely on [`RevocationPolicy::known`].
///
/// # Cost
///
/// Discovery is expensive, and every request it issues is **serial**.
/// `MAX_SEARCH_PAGES = 10` (`crate::revocation`) bounds search
/// round-trips *per `(type_form, status)` pair*, and there are **6**
/// such pairs (2 type forms × 3 statuses) — so up to 60 search
/// requests, each of which can name up to 100 per-candidate context
/// retrieves (`GET /contexts/{id}`, capped at 1 MB apiece), for up to
/// **6,000 + 60 + 100 = 6,160 requests / ~6.1 GB** in the worst case
/// for *one* of the two discovery functions. The retrieve fan-out is
/// **not** bounded by `MAX_LINEAGE_WALKS = 100` — that cap is only
/// checked *after* the retrieves have already gone out. With
/// [`Self::include_registry_attested`] set, both functions run:
/// **≈12,321 requests / ~12.2 GB** worst case for the pair (the extra
/// 1 is the unconditional `client.capabilities()` fetch
/// `find_registry_attested_revocations` makes).
///
/// [`Self::total_timeout`] is an **availability bound, not a bytes or
/// memory bound**: it stops verification from hanging forever against
/// a slow or hostile registry, but a hostile registry on a fast link
/// can still serve gigabytes of legitimate-looking traffic inside the
/// window — the 1 MB cap applies per request, not in aggregate, and
/// verified revocations accumulate in a `Vec` for the call's duration.
/// [`Self::max_requests`] and [`Self::max_bytes`] (issue #258) close
/// that gap: set either (or both) to bound the two lookups **combined**
/// — enabling [`Self::include_registry_attested`] does not double the
/// ceiling — checked **before** each request is issued, so a would-be
/// request that would exceed the budget is never sent. Exhaustion
/// raises `AcdpError::RevocationDiscoveryBudgetExceeded` through the
/// same [`Self::on_failure`] path as `AcdpError::SearchTruncated` — it
/// is permanent for the same request shape and is never transient.
/// Both knobs bound **registry** traffic only: DID-document fetches
/// issued via `WebResolver` are not counted. [`Self::max_bytes`] counts
/// only **successfully-parsed response bodies** — an error-envelope
/// read on a non-success response is not charged. [`Self::max_requests`]
/// has no such exemption: the request slot is reserved *before* the
/// request is issued, so a 503, a parse failure, or a `PayloadTooLarge`
/// still consumes it. Leaving both `None` (as both
/// [`Self::producer_signed_only`] and [`Self::all_trust_classes`] do)
/// preserves pre-#258 behavior exactly: unbounded requests and bytes,
/// bounded only by [`Self::total_timeout`].
///
/// Issue #257 adds an opt-in cache ([`crate::RevocationCache`], attached
/// to the [`crate::RegistryClient`] passed in via
/// [`crate::RegistryClient::with_revocation_cache`]) — but attaching one
/// does NOT, by itself, turn "re-discovers from scratch" into "sometimes
/// skips discovery." It is two independent things: verified revocations
/// ("facts") are always unioned into classification, indefinitely,
/// regardless of [`Self::freshness`] — a caller verifying many contexts
/// against the same producer benefits from this immediately, with zero
/// extra configuration, simply by attaching a cache and reusing the
/// client. Whether a repeat lookup is skipped entirely (saving requests)
/// is governed separately by [`Self::freshness`], which defaults to
/// `Duration::ZERO` — i.e. off. See [`Self::freshness`]'s own doc and
/// `crate::revocation_cache` for the full model. Without a cache attached
/// at all, this crate behaves exactly as before #257: every call
/// re-discovers from scratch, budgeted or not. A caller that does not
/// want to manage a `RevocationCache` can still discover once itself and
/// pass the results via [`RevocationPolicy::known`] instead of setting
/// `discover` on every call — the same hoisting guidance
/// `crate::revocation`'s `find_registry_attested_revocations` doc already
/// gives callers of that function directly (see its "Cost note for
/// callers verifying many contexts").
///
/// # Reentrancy
///
/// Discovery calls back into verification, and that reentrancy has two
/// consequences worth stating explicitly rather than leaving implicit:
///
/// 1. Each candidate body [`find_revocations`](crate::revocation::find_revocations)
///    turns up is verified via `Verifier::new(resolver).verify_body` —
///    **not** `verify_retrieved` — so discovered revocation bodies are
///    themselves checked *without* revocation checking of their own.
///    That is defensible under RFC-ACDP-0014 §5 step 1's "currently
///    authorized key," but it is an assumption this type is making on
///    the caller's behalf, not an accident.
/// 2. Verifying a `key-revocation` context now *also* triggers
///    discovery against the same producer, so the revocation-fetch path
///    itself becomes fragile under [`DiscoveryFailurePolicy::FailClosed`]:
///    a producer whose revocation search is briefly unreachable can no
///    longer be verified as revoked, either.
///
/// # No `Default`
///
/// This type deliberately has **no [`Default`] impl** — construct it
/// via [`Self::producer_signed_only`] or [`Self::all_trust_classes`].
/// RFC-ACDP-0014 §6's "lost-everything" fallback means a producer that
/// has lost every key it could sign a revocation with can *only* be
/// revoked registry-attested — so the catastrophic case is exactly the
/// one a silently-defaulted-off trust class would skip. A quiet
/// `Default::default()` that leaves `include_registry_attested: false`
/// would make that skip invisible at every call site; forcing a named
/// constructor puts the choice at the type level instead, where a
/// reviewer (and `git grep`) can see it. Callers protecting against
/// key loss, or otherwise unwilling to assume a producer always
/// retains signing capacity, MUST use [`Self::all_trust_classes`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct RevocationDiscovery {
    /// Whether to also run
    /// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
    /// (the §6 registry-attested trust class), in addition to the
    /// producer-signed search every discovery configuration runs.
    /// Requires the registry to serve `/.well-known/acdp.json`; under
    /// [`DiscoveryFailurePolicy::FailClosed`] a registry that serves no
    /// capabilities document fails verification when this is `true`.
    pub include_registry_attested: bool,
    /// What to do when discovery itself fails (a transient transport
    /// error from either search, or the search-safety-cap error
    /// `AcdpError::SearchTruncated`). Default [`DiscoveryFailurePolicy::FailClosed`].
    ///
    /// **`SearchTruncated` and a transport error (e.g. a 503) are NOT
    /// equivalent, even though both take this same `on_failure` path.**
    /// `SearchTruncated` means "this producer has more revocations than
    /// we will page through" (`MAX_SEARCH_PAGES`) — an
    /// **attacker-inducible security downgrade**, since a hostile
    /// producer or registry can pad the result set specifically to
    /// exhaust the page cap and hide a real revocation from discovery.
    /// A 503 is an ordinary availability blip. **A third case is
    /// attacker-inducible the same way `SearchTruncated` is (issue
    /// #258):** `AcdpError::RevocationDiscoveryBudgetExceeded`, raised
    /// when [`Self::max_requests`] or [`Self::max_bytes`] is exhausted —
    /// a hostile registry that learns a caller's budget can pad
    /// harmless-looking traffic specifically to exhaust it before a
    /// real revocation is found, just as padding the result set exhausts
    /// `MAX_SEARCH_PAGES`. Under [`DiscoveryFailurePolicy::ProceedWithKnown`]
    /// all three are treated the same way (proceed on
    /// [`RevocationPolicy::known`] alone, record the failure) — choose
    /// `ProceedWithKnown` knowing it also waives truncation and budget
    /// exhaustion, not just transient unavailability.
    pub on_failure: DiscoveryFailurePolicy,
    /// Wall-clock budget for the whole discovery step (both searches,
    /// if [`Self::include_registry_attested`] is set). An **availability**
    /// bound only — see the type-level cost section above. Matches this
    /// crate's existing `ResolverOptions::total_timeout` precedent
    /// (`crate::cross_registry`), defaulting to the same 30 s rather
    /// than exceeding it on the core verify path.
    ///
    /// Enforced via [`tokio::time::timeout`], which requires the
    /// executing Tokio runtime to have its **time driver enabled**
    /// (`#[tokio::main]` and `#[tokio::test]` enable it by default;
    /// a hand-built `Builder::new_current_thread()` runtime does
    /// **not** unless `.enable_time()` or `.enable_all()` is called).
    /// Calling `verify_retrieved` with `discover: Some(..)` from a
    /// runtime without the time driver **panics** — it does not
    /// return `Err` — the same requirement `ResolverOptions::total_timeout`
    /// (`crate::cross_registry`) already carries on its opt-in walk,
    /// but here it sits on the core verify path whenever discovery is
    /// configured, not just on an explicit cross-registry walk.
    pub total_timeout: Duration,
    /// Issue #258: cap on the total number of registry requests the
    /// discovery step may issue, **combined across both lookups** (the
    /// producer-signed search always, plus the registry-attested search
    /// when [`Self::include_registry_attested`] is set) — not a ceiling
    /// per lookup, so turning on the second trust class does not double
    /// the allowance. `None` (the default from both named constructors)
    /// is unbounded, matching every version before #258. Checked
    /// **before** each request is issued (`RegistryClient::capabilities`,
    /// `::retrieve`, `::lineage`, `::search`); exceeding it raises
    /// `AcdpError::RevocationDiscoveryBudgetExceeded` through
    /// [`Self::on_failure`], the same path `AcdpError::SearchTruncated`
    /// already takes. Counts registry requests only — DID-document
    /// fetches via `WebResolver` are not counted.
    pub max_requests: Option<std::num::NonZeroUsize>,
    /// Issue #258: cap on the cumulative bytes of *successfully-parsed*
    /// response bodies the discovery step may read, **combined across
    /// both lookups**, same combination rule as [`Self::max_requests`].
    /// `None` (the default from both named constructors) is unbounded,
    /// matching every version before #258. Checked **before** each
    /// request is issued, using the running total from requests that
    /// already completed — the size of an in-flight request cannot be
    /// known (and therefore reserved) in advance, so **up to two**
    /// requests can push the total past `max_bytes` before the next
    /// check observes the overrun: the two trust-class lookups run
    /// concurrently under `tokio::try_join!`, and both can pass a
    /// not-yet-updated check before either's response is recorded — see
    /// [`Self::max_requests`]'s doc for the same race on the request
    /// count, where it is closed by an atomic reservation; there is no
    /// equivalent reservation for bytes, since a response's size is not
    /// known until after it is read. Counts a non-success response's
    /// error-envelope read *not at all* — only bytes read on the
    /// success path are charged.
    ///
    /// `Some(0)` is representable (unlike [`Self::max_requests`], which
    /// is guarded by `NonZeroUsize`) and is not special-cased: it trips
    /// the `>=` check on the very first request of either lookup,
    /// before that request is ever issued, so discovery fails
    /// immediately with zero registry traffic. This is a deliberate
    /// consequence of keeping this field a plain `u64` (matching the
    /// wave plan's chosen types) rather than a reason to reach for
    /// `NonZeroU64`.
    pub max_bytes: Option<u64>,
    /// Issue #257: how long a discovery-freshness marker stays valid on
    /// a [`crate::RevocationCache`] attached to the [`crate::RegistryClient`]
    /// passed to `verify_retrieved` (via
    /// [`crate::RegistryClient::with_revocation_cache`]). A marker records
    /// "vantage V completed a full, untruncated discovery for this
    /// producer/trust-class at time T"; while one is within `freshness` of
    /// T, that lookup is skipped entirely (zero registry requests) rather
    /// than merely supplemented.
    ///
    /// Default `Duration::ZERO` from both named constructors — markers
    /// never suppress a lookup unless a caller explicitly raises this
    /// above zero. This is the safe default per RFC-ACDP-0014 §8's own
    /// warning ("absence of search results is not evidence of absence"): a
    /// cached *absence* is not licensed the way a cached, verified
    /// revocation is (§7:114 licenses only the latter, indefinitely). No
    /// cache attached makes this field inert regardless of its value.
    ///
    /// Distinct from — and orthogonal to — caching verified revocations
    /// themselves ("facts"), which a [`crate::RevocationCache`] does
    /// **unconditionally** and **indefinitely** whenever one is attached,
    /// independent of this field: facts are always unioned into
    /// classification (never gated behind `freshness`), because a
    /// revocation is monotone (more revocations ⇒ an earlier effective
    /// boundary ⇒ strictly more fail-closed verdicts), so seeding from
    /// them can only tighten a verdict, never loosen one. `freshness`
    /// governs only whether a lookup that would otherwise re-confirm "no
    /// NEW revocation" is skipped. See `crate::revocation_cache` for the
    /// full two-object model.
    ///
    /// That "unconditionally" is exact for a producer-signed fact (§8:
    /// self-contained, applies at any vantage) but is scoped for a
    /// registry-attested one (§6): the cache additionally filters those to
    /// the vantage that minted them, so attaching one cache to clients for
    /// two different registries does not let registry A's attestation
    /// apply to a context served by registry B.
    ///
    /// A recommended ceiling, not enforced: RFC-ACDP-0006 §4.2 caps
    /// `WebResolver`'s own DID-document cache TTL at 3600 s, and that is a
    /// reasonable order-of-magnitude anchor for this field too — this
    /// crate already accepts bounded key-material staleness at that
    /// order. Left unenforced deliberately: a hard cap on a knob whose
    /// safe default is `ZERO` would add a failure mode without adding
    /// safety.
    pub freshness: Duration,
}

impl RevocationDiscovery {
    /// Discover producer-signed revocations only
    /// (`include_registry_attested: false`). Cheapest of the two
    /// constructors, and the default choice for callers that are not
    /// specifically defending against a producer that has lost every
    /// signing key — see the "No `Default`" section above for who must
    /// NOT stop here.
    #[must_use]
    pub fn producer_signed_only() -> Self {
        Self {
            include_registry_attested: false,
            on_failure: DiscoveryFailurePolicy::FailClosed,
            total_timeout: Duration::from_secs(30),
            max_requests: None,
            max_bytes: None,
            freshness: Duration::ZERO,
        }
    }

    /// Discover both trust classes: producer-signed AND registry-attested
    /// (`include_registry_attested: true`). Required to catch RFC-ACDP-0014
    /// §6's "lost-everything" fallback, where a producer with no signing
    /// key left can only be revoked registry-attested. Requires the
    /// registry to serve a capabilities document.
    #[must_use]
    pub fn all_trust_classes() -> Self {
        Self {
            include_registry_attested: true,
            on_failure: DiscoveryFailurePolicy::FailClosed,
            total_timeout: Duration::from_secs(30),
            max_requests: None,
            max_bytes: None,
            freshness: Duration::ZERO,
        }
    }
}

/// What to do when RFC-ACDP-0014 §8 auto-discovery itself fails (a
/// transient search/lookup error, or the discovery search functions'
/// own safety-cap error).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum DiscoveryFailurePolicy {
    /// Treat a discovery failure as a verification failure
    /// (`AcdpError::RevocationDiscoveryFailed`). Default — matches this
    /// crate's existing bias (§7's own lineage-walk and
    /// verification-failure paths already fail closed; see
    /// `crate::revocation`'s doc for the parallel reasoning) that an
    /// authorization phase which could not run is not the same thing
    /// as one that ran and found nothing.
    #[default]
    FailClosed,
    /// Proceed using only [`RevocationPolicy::known`] when discovery
    /// fails, silently dropping whatever discovery could not complete.
    /// Use only when availability matters more than catching a
    /// revocation that discovery would otherwise have found.
    ProceedWithKnown,
}

/// What RFC-ACDP-0014 §8 auto-discovery actually did, distinguishing
/// "this trust class was not queried" from "it was queried and found
/// nothing" — the same distinction
/// [`RevocationDiscovery`]'s no-`Default` design protects at the
/// config level, carried through to the outcome.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct DiscoveryOutcome {
    /// Count of producer-signed revocations discovery found.
    pub producer_signed: usize,
    /// Count of registry-attested revocations discovery found, or
    /// `None` if [`RevocationDiscovery::include_registry_attested`]
    /// was `false` and that trust class was never queried.
    pub registry_attested: Option<usize>,
}

/// How to treat the optional `registry_receipt` on retrieval
/// (RFC-ACDP-0010).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReceiptPolicy {
    /// Skip receipt verification entirely (0.1.0 behavior). The
    /// receipt value is still preserved verbatim on the context.
    Ignore,
    /// Verify the receipt when one is present; absence is not an
    /// error (the registry may simply be a 0.1.0 registry). Default.
    #[default]
    VerifyIfPresent,
    /// Fail closed unless a receipt is present AND verifies. Use when
    /// the deployment requires audit-grade provenance — registry
    /// claims (`ctx_id`, `created_at`, `origin_registry`) are
    /// assertions, not proofs, without a receipt.
    ///
    /// Honored identically by every entry point that accepts a
    /// [`VerificationPolicy`] — `fetch_with_policy`,
    /// `fetch_current_with_policy` (via [`LineageHeadPolicy::receipts`]),
    /// and the `fetch_report*` family. On
    /// [`VerifiedContext::fetch_report_diagnose`] the failure surfaces as
    /// a withheld handle plus `VerificationReport::policy_phase_error`,
    /// not an `Err` — see that method's doc.
    Require,
}

/// How to treat the optional `lineage_head_receipt` on
/// `GET /lineages/{id}/current` responses (ACDP 0.3, RFC-ACDP-0011).
///
/// The presence handling reuses the [`ReceiptPolicy`] vocabulary; the
/// two numeric knobs are the RFC's consumer-side parameters:
///
/// - `max_clock_skew_seconds` — §7 step 6's forward-skew allowance. A
///   receipt whose `as_of` is further in the future **fails
///   verification** (`invalid_receipt`, fixture `lhr-004`). RFC
///   RECOMMENDED: 120.
/// - `max_age_seconds` — §6's freshness policy. A receipt older than
///   this is still *verified* (it may be perfectly genuine — merely
///   old); it is reported distinctly via
///   [`VerifiedContext::head_receipt_stale`], never as a verification
///   failure. RFC RECOMMENDED default: 300. `None` disables the
///   staleness verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineageHeadPolicy {
    /// Presence handling: `Ignore` (skip verification, preserve
    /// verbatim), `VerifyIfPresent` (default), or `Require` (fail
    /// closed unless present AND verified — appropriate when the
    /// registry advertises `acdp-registry-head-receipts`, under which
    /// a head receipt on `/current` is REQUIRED, RFC-ACDP-0011 §6).
    pub receipts: ReceiptPolicy,
    /// RFC-ACDP-0011 §7 step 6 clock-skew allowance (default 120 s).
    pub max_clock_skew_seconds: u32,
    /// RFC-ACDP-0011 §6 maximum acceptable receipt age for the
    /// staleness verdict (default `Some(300)`).
    pub max_age_seconds: Option<u32>,
}

impl Default for LineageHeadPolicy {
    fn default() -> Self {
        Self {
            receipts: ReceiptPolicy::VerifyIfPresent,
            max_clock_skew_seconds: 120,
            max_age_seconds: Some(300),
        }
    }
}

/// How to treat a producer key that is present in the DID document's
/// `verificationMethod` but no longer in `assertionMethod` — i.e. a
/// key the producer rotated out but retained per the RFC-ACDP-0010
/// key-retention rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HistoricalKeyPolicy {
    /// Strict 0.1.0 behavior: only `assertionMethod` keys verify.
    /// Every context signed by a rotated-out key fails.
    Reject,
    /// Accept a retained key **only** when a verified registry receipt
    /// attests (via `key_fingerprint`) that this exact key was the
    /// authorized one at publish time. Without a verified receipt the
    /// historical path never activates — fail closed. Default.
    #[default]
    AcceptWithReceipt,
}

/// How the producer key that verified the body relates to the
/// producer's *current* DID document.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyAuthorization {
    /// The signing key is currently listed in `assertionMethod`.
    CurrentlyAuthorized,
    /// The signing key was rotated out of `assertionMethod` but is
    /// retained in `verificationMethod`, and a verified registry
    /// receipt attests it was the authorized key at publish time
    /// (RFC-ACDP-0010). Weigh accordingly: valid history, not a
    /// current endorsement.
    HistoricallyAuthorized,
    /// The signing key is **revoked** (a verified RFC-ACDP-0014
    /// revocation names its fingerprint), but a verified registry
    /// receipt attests the context was published strictly *before* the
    /// compromise boundary `compromised_since` — it was signed while
    /// the key was still the producer's, and verified under the
    /// RFC-ACDP-0010 §10 historical rule (RFC-ACDP-0014 §7 step 2).
    ///
    /// Deliberately distinguishable from BOTH
    /// [`Self::CurrentlyAuthorized`] and the no-revocation
    /// [`Self::HistoricallyAuthorized`]: the revocation and its
    /// boundary MUST be visible in the verdict — even a key still
    /// listed in `assertionMethod` MUST NOT be reported as fully
    /// current once revoked. This holds for every entry point that
    /// accepts a [`VerificationPolicy`], including the `fetch_report*`
    /// family: they derive `key_status` from the same `verify_retrieved`
    /// phase `fetch_with_policy` uses, so a revoked key cannot silently
    /// surface as [`Self::CurrentlyAuthorized`] on any of them. Contexts
    /// by the same key at/after the boundary — or with no verifiable
    /// publish time — never reach a status at all: they fail closed
    /// with `key_not_authorized` (§7 steps 3–4).
    HistoricallyAuthorizedPreCompromise,
}

impl VerificationPolicy {
    /// The v0.1.0 strict verification profile (RFC-ACDP-0001 §5.11, §9.2).
    ///
    /// Runs the full §5.11 pipeline: body schema validation, `content_hash`
    /// recomputation, `did:web` key resolution, signature verification, and
    /// embedded `data_ref.content_hash` checks. Returns on the first failure.
    ///
    /// This is the **only** mode covered by the `acdp-consumer` conformance
    /// profile. Relaxed modes (`Diagnostic`, `UnsafeForTests`) are NOT
    /// available in this crate in v0.1.0 — they would be separately-named
    /// opt-ins per §9.2, and are not currently implemented.
    ///
    /// NOT identical to [`Default::default()`] as of 0.2: the default
    /// policy is receipt-aware (`VerifyIfPresent` + `AcceptWithReceipt`),
    /// while this named profile preserves the exact v0.1.0 semantics —
    /// receipts inert ([`ReceiptPolicy::Ignore`]) and only
    /// `assertionMethod` keys accepted
    /// ([`HistoricalKeyPolicy::Reject`]). Callers pinned to this
    /// constructor keep v0.1.0 behavior across the 0.2 upgrade.
    pub fn strict_v0_1_0() -> Self {
        Self {
            validate_body_schema: true,
            allow_unknown_status: true,
            receipts: ReceiptPolicy::Ignore,
            historical_keys: HistoricalKeyPolicy::Reject,
            lineage_head: LineageHeadPolicy {
                receipts: ReceiptPolicy::Ignore,
                ..LineageHeadPolicy::default()
            },
            // A 0.1.0-pinned consumer predates RFC-ACDP-0014 and is
            // unaffected by it (§10): no revocations enforced.
            revocations: RevocationPolicy::default(),
        }
    }

    /// The policy the report family (`fetch_report`,
    /// `fetch_report_with_fetcher`, `fetch_report_diagnose`) passes to
    /// [`VerifiedContext::verify_retrieved`].
    ///
    /// `validate_body_schema` is forced `false` unconditionally,
    /// independent of the caller: P1 (schema) is always handled by the
    /// report path itself — `validate_body_structural` plus per-`DataRef`
    /// non-fatal recording of embedded-hash outcomes into
    /// `VerificationReport::data_ref_embedded` — so the spine must always
    /// skip its own full `validate_body` (structural + fatal embedded-hash
    /// check) here. Every other field passes through verbatim. Do **not**
    /// pass the caller's policy directly to `verify_retrieved` from a
    /// report entry point; doing so reinstates the fatal embedded-hash
    /// check the report path deliberately downgrades to non-fatal (see
    /// `tests/tls_conformance.rs`'s
    /// `fetch_report_records_embedded_hash_failure`).
    fn derived_for_report(&self) -> Self {
        Self {
            validate_body_schema: false,
            ..self.clone()
        }
    }
}

/// A retrieved context that has been cryptographically verified.
///
/// Every value of this type is the output of one of the
/// `VerifiedContext::fetch*` pipelines, each of which independently
/// recomputes `content_hash` (RFC-ACDP-0001 §5.11) and verifies the
/// producer signature before the value is constructed. The fields are
/// **private** precisely so this "cryptographically verified" invariant
/// cannot be forged: there is no way to construct a `VerifiedContext`
/// around an unverified [`FullContext`]. Downstream code can therefore
/// trust the accessors below without re-deriving anything.
#[derive(Debug)]
pub struct VerifiedContext {
    inner: FullContext,
    /// Whether the body verified against a currently authorized key or
    /// a receipt-attested historical one (ACDP 0.2, WS-B).
    key_status: KeyAuthorization,
    /// The verified registry receipt, when one was present and the
    /// policy verified it (RFC-ACDP-0010). `None` under
    /// [`ReceiptPolicy::Ignore`] or when the registry minted none.
    verified_receipt: Option<acdp_types::receipt::RegistryReceipt>,
    /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
    /// when one was present and the policy verified it. Only populated
    /// by [`Self::fetch_current`] / [`Self::fetch_current_with_policy`]
    /// — plain retrieval preserves the raw value verbatim without
    /// verification. Per §7 this verdict is independent of the body
    /// verdict and the RFC-ACDP-0010 receipt verdict.
    verified_head_receipt: Option<acdp_types::receipt::LineageHeadReceipt>,
    /// RFC-ACDP-0011 §6 freshness verdict for the verified head
    /// receipt, reported distinctly from verification: `Some(true)`
    /// when the (genuine, verified) receipt's `as_of` is older than
    /// [`LineageHeadPolicy::max_age_seconds`]; `Some(false)` when
    /// within policy; `None` when there is no verified head receipt or
    /// the max-age knob is disabled.
    head_receipt_stale: Option<bool>,
    /// RFC-ACDP-0014 §8 auto-discovery failure, when
    /// `policy.revocations.discover` was `Some`, discovery failed, and
    /// [`DiscoveryFailurePolicy::ProceedWithKnown`] let verification
    /// proceed on [`RevocationPolicy::known`] alone anyway. `None` when
    /// discovery was off, succeeded, or was never attempted because
    /// [`DiscoveryFailurePolicy::FailClosed`] turned the failure into
    /// this call's `Err` instead (so no `VerifiedContext` was ever
    /// constructed to carry it). Exposed so `ProceedWithKnown` is never
    /// silent on the plain `fetch*` paths — see
    /// [`Self::revocation_discovery_failure`].
    revocation_discovery_failure: Option<AcdpError>,
}

/// `verify_retrieved`'s return type — private and free to shape (issue
/// #248 Phase 4 grew a third tuple element for the discovery outcome).
/// Named only to keep clippy's `type_complexity` lint quiet; every
/// caller destructures it positionally exactly as before.
type VerifyRetrievedResult = Result<
    (
        KeyAuthorization,
        Option<acdp_types::receipt::RegistryReceipt>,
        Option<Result<DiscoveryOutcome, AcdpError>>,
    ),
    AcdpError,
>;

impl VerifiedContext {
    /// Retrieve a context and verify its signature using the strict
    /// default [`VerificationPolicy`].
    pub async fn fetch(
        client: &RegistryClient,
        resolver: &WebResolver,
        ctx_id: &CtxId,
    ) -> Result<Self, AcdpError> {
        Self::fetch_with_policy(client, resolver, ctx_id, &VerificationPolicy::default()).await
    }

    /// Retrieve a context and verify its signature with caller-controlled
    /// strictness.
    ///
    /// 1. Fetches `body + registry_state` from the registry.
    /// 2. Refuses a served body whose `ctx_id` differs from the one
    ///    requested (`AcdpError::ContextIdMismatch`) — this implements
    ///    RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind the resolved
    ///    identity"): neither the signature check (step 5) nor the
    ///    `content_hash` recomputation (step 6) can supply this binding,
    ///    because `ctx_id` sits in the RFC-ACDP-0001 §5.7 registry-assigned
    ///    exclusion set and is therefore stripped from ProducerContent
    ///    before hashing. See RFC-ACDP-0008 §9.1 for the threat this
    ///    closes: without it, a registry can serve any other
    ///    validly-signed body by the same producer under the requested
    ///    context's URL, and both preceding checks still pass. Step 7
    ///    permits a consumer to surface "an equivalent typed error" in
    ///    place of the registry-side `cross_registry_resolution_failed`
    ///    wire code — `ContextIdMismatch` is that typed error. This
    ///    generalizes the receipt-path analogue at RFC-ACDP-0010 §8 step 3
    ///    to the receipt-less core-profile path, where it is the only
    ///    binding available. It does **not** close §9.1 in full: a
    ///    registry that genuinely republishes the same content under a
    ///    new `ctx_id` still passes; only serve-time substitution — a
    ///    different id claimed to be the one requested — is caught.
    /// 3. Optionally runs `validate_body` — structural schema checks
    ///    plus embedded-`DataRef` hash verification (policy-controlled).
    /// 4. Recomputes `content_hash` over ProducerContent.
    /// 5. Resolves the producer's DID document. `did:web` is required
    ///    unconditionally for v0.1.0 (RFC-ACDP-0001 §5.4).
    /// 6. Verifies the Ed25519 signature (or other supported algorithm).
    /// 7. Optionally verifies the `registry_receipt` placeholder.
    /// 8. Optionally rejects unknown statuses.
    pub async fn fetch_with_policy(
        client: &RegistryClient,
        resolver: &WebResolver,
        ctx_id: &CtxId,
        policy: &VerificationPolicy,
    ) -> Result<Self, AcdpError> {
        let ctx = client.retrieve(ctx_id).await?;
        let (key_status, verified_receipt, revocation_discovery) =
            Self::verify_retrieved(client, resolver, &ctx, ctx_id, policy).await?;
        let revocation_discovery_failure = match revocation_discovery {
            Some(Err(e)) => Some(e),
            _ => None,
        };
        Ok(Self {
            inner: ctx,
            key_status,
            verified_receipt,
            verified_head_receipt: None,
            head_receipt_stale: None,
            revocation_discovery_failure,
        })
    }

    /// Retrieve the current head of a lineage
    /// (`GET /lineages/{lineage_id}/current`) and verify it with the
    /// strict default [`VerificationPolicy`] — including the
    /// lineage-head receipt when the registry minted one (ACDP 0.3,
    /// RFC-ACDP-0011).
    pub async fn fetch_current(
        client: &RegistryClient,
        resolver: &WebResolver,
        lineage_id: &acdp_types::primitives::LineageId,
    ) -> Result<Self, AcdpError> {
        Self::fetch_current_with_policy(
            client,
            resolver,
            lineage_id,
            &VerificationPolicy::default(),
        )
        .await
    }

    /// Retrieve + verify the current head of a lineage with
    /// caller-controlled strictness.
    ///
    /// Runs the same pipeline as [`Self::fetch_with_policy`] against
    /// the `/current` response (the expected `ctx_id` is the served
    /// body's own — there is no requested identifier on this endpoint;
    /// the head receipt's §7 step 5 byte-match is what binds it), then
    /// applies `policy.lineage_head` to the response's
    /// `lineage_head_receipt` per RFC-ACDP-0011 §7:
    ///
    /// - [`ReceiptPolicy::Ignore`] — the raw value is preserved
    ///   verbatim, unverified.
    /// - [`ReceiptPolicy::VerifyIfPresent`] — verified when present
    ///   (absence is fine: the registry may not advertise
    ///   `acdp-registry-head-receipts`).
    /// - [`ReceiptPolicy::Require`] — fail closed with
    ///   `invalid_receipt` unless present AND verified.
    ///
    /// Verification fetches the registry's capabilities document for
    /// the §7 step 3 `capabilities.registry_did` binding. Staleness
    /// beyond `policy.lineage_head.max_age_seconds` is a *freshness*
    /// verdict reported via [`Self::head_receipt_stale`], never a
    /// verification failure (§6).
    ///
    /// [`Self::fetch_with_policy`] now additionally refuses a served body
    /// whose `ctx_id` is not the one requested (RFC-ACDP-0008 §9.1). This
    /// endpoint has no requested identifier to compare against — the
    /// served head's `ctx_id` is trivially "the one requested" — so on a
    /// receipt-less registry the served head's identity rests entirely on
    /// registry honesty (RFC-ACDP-0008 §9.1). Use [`ReceiptPolicy::Require`]
    /// where that matters.
    pub async fn fetch_current_with_policy(
        client: &RegistryClient,
        resolver: &WebResolver,
        lineage_id: &acdp_types::primitives::LineageId,
        policy: &VerificationPolicy,
    ) -> Result<Self, AcdpError> {
        let ctx = client.current(lineage_id).await?;
        let served_ctx_id = ctx.body.ctx_id.clone();
        let (key_status, verified_receipt, revocation_discovery) =
            Self::verify_retrieved(client, resolver, &ctx, &served_ctx_id, policy).await?;
        let revocation_discovery_failure = match revocation_discovery {
            Some(Err(e)) => Some(e),
            _ => None,
        };

        // ── Lineage-head receipt phase (RFC-ACDP-0011) ──────────────
        let (verified_head_receipt, head_receipt_stale) =
            match (policy.lineage_head.receipts, &ctx.lineage_head_receipt) {
                (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => (None, None),
                (ReceiptPolicy::Require, None) => {
                    return Err(AcdpError::InvalidReceipt(
                        "policy requires a lineage-head receipt but the /current response \
                         carries none (registry without the acdp-registry-head-receipts \
                         profile?)"
                            .into(),
                    ));
                }
                (_, Some(value)) => {
                    let serving_authority = client
                        .authority()
                        .unwrap_or_else(|| served_ctx_id.authority().to_string());
                    // §7 step 3 needs capabilities.registry_did — fetched
                    // from the same authority the context came from.
                    let caps = client.capabilities().await?;
                    let receipt = super::receipt::verify_lineage_head_receipt_value(
                        value,
                        lineage_id,
                        &served_ctx_id,
                        ctx.body.version,
                        &ctx.registry_state.status,
                        true, // /current always serves the attested head
                        &serving_authority,
                        &caps.registry_did,
                        chrono::Duration::seconds(
                            policy.lineage_head.max_clock_skew_seconds as i64,
                        ),
                        resolver,
                    )
                    .await?;
                    let stale = policy.lineage_head.max_age_seconds.map(|max| {
                        receipt.age_at(chrono::Utc::now()) > chrono::Duration::seconds(max as i64)
                    });
                    (Some(receipt), stale)
                }
            };

        Ok(Self {
            inner: ctx,
            key_status,
            verified_receipt,
            verified_head_receipt,
            head_receipt_stale,
            revocation_discovery_failure,
        })
    }

    /// The shared retrieve-side verification pipeline: body schema,
    /// hash recomputation, RFC-ACDP-0010 receipt phase, signature
    /// phase (with the receipt-gated historical-key fallback), and the
    /// unknown-status policy check.
    #[cfg_attr(
        feature = "tracing",
        tracing::instrument(
            name = "acdp.verify_retrieved",
            skip_all,
            fields(ctx_id = %expected_ctx_id),
            err(Display)
        )
    )]
    async fn verify_retrieved(
        client: &RegistryClient,
        resolver: &WebResolver,
        ctx: &FullContext,
        expected_ctx_id: &CtxId,
        policy: &VerificationPolicy,
    ) -> VerifyRetrievedResult {
        // Identifier binding — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind
        // the resolved identity"): refuse a served body whose `ctx_id`
        // differs from the one requested, before any crypto or network
        // work. `ctx_id` is registry-assigned and outside both the
        // `content_hash` and signature coverage (RFC-ACDP-0001 §5.7's
        // exclusion set), so this equality check is the only binding
        // available when no receipt is served. See RFC-ACDP-0008 §9.1 for
        // the threat this closes; it does not close §9.1 in full (a
        // genuine republish under a new `ctx_id` still passes — only
        // serve-time substitution is caught). Step 7 permits a
        // consumer-side "equivalent typed error" in place of the
        // registry-side `cross_registry_resolution_failed` wire code —
        // `ContextIdMismatch` is that typed error.
        if ctx.body.ctx_id != *expected_ctx_id {
            return Err(AcdpError::ContextIdMismatch {
                requested: expected_ctx_id.as_str().to_string(),
                served: ctx.body.ctx_id.as_str().to_string(),
            });
        }

        if policy.validate_body_schema {
            acdp_validation::validate_body(&ctx.body)?;
        }

        // Hash recomputation first: from here on `ctx.body.content_hash`
        // IS the independently recomputed value, which the receipt
        // cross-check below relies on.
        let verifier = Verifier::new(resolver);
        verifier.verify_body_hash(&ctx.body)?;

        // ── Receipt phase (RFC-ACDP-0010) ───────────────────────────
        // Verified BEFORE the signature phase because the historical-
        // key path is gated on a verified receipt.
        let serving_authority = client
            .authority()
            .unwrap_or_else(|| expected_ctx_id.authority().to_string());
        let verified_receipt = match (policy.receipts, &ctx.registry_receipt) {
            (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => None,
            (ReceiptPolicy::Require, None) => {
                return Err(AcdpError::InvalidReceipt(
                    "policy requires a registry receipt but the response carries none \
                     (registry without the acdp-registry-receipts profile, or a \
                     pre-receipts context)"
                        .into(),
                ));
            }
            (_, Some(value)) => {
                let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
                    &ctx.body.signature.key_id,
                    &ctx.body.signature.algorithm,
                    resolver,
                )
                .await?;
                Some(
                    super::receipt::verify_receipt_value(
                        value,
                        expected_ctx_id,
                        &ctx.body,
                        &ctx.body.content_hash,
                        &fingerprint,
                        &serving_authority,
                        resolver,
                    )
                    .await?,
                )
            }
        };

        // ── Revocation discovery phase (RFC-ACDP-0014 §8) ────────────
        // Runs `policy.revocations.discover`'s two independent lookups
        // — producer-signed always, registry-attested only when
        // `include_registry_attested` — CONCURRENTLY under
        // `tokio::try_join!`, with the pair wrapped in a single
        // `tokio::time::timeout(discover.total_timeout, ..)` (D4). This
        // puts a Tokio **time-driver requirement** (`enable_time`) on
        // the core verify path, gated behind `discover: Some` — the
        // same requirement `CrossRegistryResolver::walk_derived_from`
        // already carries on its opt-in walk (`cross_registry.rs`).
        //
        // Reentrancy: `find_revocations` verifies each candidate body
        // via `Verifier::new(resolver).verify_body` — never
        // `verify_retrieved` — so discovered revocation bodies are
        // themselves checked WITHOUT revocation checking. That is
        // defensible under §5 step 1's "currently authorized key," but
        // it is an assumption, not an accident: verifying a
        // `key-revocation` context now *also* triggers discovery
        // against the same producer, so the revocation-fetch path
        // itself becomes fragile under `FailClosed` (a producer whose
        // revocation search is briefly unreachable can no longer be
        // verified as revoked, either).
        //
        // A `SearchTruncated` failure and a 503 both take the
        // `on_failure` path, but they are NOT equivalent: truncation
        // means "this producer has more revocations than we will page
        // through" — an attacker-inducible security downgrade, since a
        // hostile producer/registry can pad the result set specifically
        // to exhaust `MAX_SEARCH_PAGES` — whereas a 503 is an ordinary
        // availability blip. `RevocationDiscoveryBudgetExceeded` (issue
        // #258, `max_requests`/`max_bytes`) is a THIRD case in the same
        // attacker-inducible bucket as truncation: a hostile registry
        // that learns a caller's budget can pad harmless-looking traffic
        // to exhaust it before a real revocation is found.
        // `ProceedWithKnown` treats all three the same way (proceed on
        // `known` alone, record the failure); naming the asymmetry here
        // is so that choice is made with open eyes.
        // Issue #257 (D-A / B1): `seeded_facts` is read from whatever
        // `RevocationCache` is attached to `client` — BEFORE any of the
        // discovery attempt below runs — and threaded through every arm
        // below UNCHANGED, including the failure arms that reset
        // `discovered` to `Vec::new()`. This is the load-bearing placement:
        // facts are a verified, permanent record (RFC-ACDP-0014 §7:114)
        // and MUST survive a 503, a `SearchTruncated`, a budget
        // exhaustion, or a `total_timeout` trip on THIS call — exactly the
        // failure modes an attacker can induce to try to make a client
        // "forget" a revocation it already saw. Computing this once, up
        // front, and never touching it again inside the match is what
        // makes that survival structural rather than a discipline: there
        // is no code path below that can zero it out the way `discovered`
        // legitimately is on failure.
        let (discovered, revocation_discovery, seeded_facts) = match &policy.revocations.discover {
            // MATERIAL-3 (fresh-Opus review of Phase 2): attaching a
            // `RevocationCache` IS the opt-in for anti-rollback, independent
            // of whether `discover` itself is configured — seed producer-
            // signed facts here too, not only in the `Some` arm below. This
            // is what extends anti-rollback protection to
            // `VerifiedContext::fetch`/`fetch_current` (LIM-2), which
            // hardcode `VerificationPolicy::default()` and so can NEVER
            // set `discover` at all — without this, those two entry points
            // would get zero benefit from an attached cache. Seeding is
            // monotone (facts can only tighten a verdict, never loosen
            // one), so doing it unconditionally is safe. With `discover:
            // None` there is no `include_registry_attested` to consult, so
            // seed producer-signed facts ONLY: registry-attested requires
            // the explicit opt-in `RevocationDiscovery::include_registry_attested`
            // carries (D6), and BLOCKER-1's origin filter would need this
            // call's serving vantage regardless, so staying conservative
            // here costs nothing.
            None => {
                let agent_id = &ctx.body.agent_id;
                let vantage = client.authority();
                let seeded_facts = client
                    .revocation_cache()
                    .map(|(cache, _freshness)| {
                        cache.facts_for(agent_id.as_str(), false, vantage.as_deref())
                    })
                    .unwrap_or_default();
                (Vec::new(), None, seeded_facts)
            }
            Some(discovery) => {
                let agent_id = &ctx.body.agent_id;
                let include_attested = discovery.include_registry_attested;
                // BLOCKER-1: pass this call's own vantage through so
                // `facts_for` can filter registry-attested facts to
                // `origin == current vantage` (RFC-ACDP-0014 §6) —
                // producer-signed facts are unaffected either way (§8).
                let vantage = client.authority();
                let seeded_facts = client
                    .revocation_cache()
                    .map(|(cache, _freshness)| {
                        cache.facts_for(agent_id.as_str(), include_attested, vantage.as_deref())
                    })
                    .unwrap_or_default();
                // Issue #258 (D-B): a combined request/byte budget is
                // enforced inside `RegistryClient`'s request methods, on
                // a client clone created HERE and handed to BOTH lookups
                // below — never on `client` itself, so a caller sharing
                // that original client across concurrent work never has
                // unrelated traffic charged to this discovery's budget.
                // One `DiscoveryBudget` per call means one combined
                // ceiling: enabling `include_registry_attested` cannot
                // silently double it, since both `try_join!` arms below
                // draw down the same counters. Issue #257: the SAME clone
                // additionally carries `client`'s attached
                // `RevocationCache` (if any, unchanged by
                // `with_discovery_budget`) with `freshness` overridden from
                // this call's `discovery.freshness` — never a
                // caller-facing knob, only ever set here from the
                // extracted `discovery` value (spine-lock safe).
                let budget = DiscoveryBudget::new(discovery.max_requests, discovery.max_bytes);
                let client = client
                    .with_discovery_budget(budget)
                    .with_revocation_freshness(discovery.freshness);
                let discovery_fut = async {
                    tokio::try_join!(
                        super::revocation::find_revocations(&client, resolver, agent_id),
                        async {
                            if include_attested {
                                super::revocation::find_registry_attested_revocations(
                                    &client, resolver, agent_id,
                                )
                                .await
                            } else {
                                Ok(Vec::new())
                            }
                        },
                    )
                };
                match tokio::time::timeout(discovery.total_timeout, discovery_fut).await {
                    Ok(Ok((producer_signed, registry_attested))) => {
                        let outcome = DiscoveryOutcome {
                            producer_signed: producer_signed.len(),
                            registry_attested: if include_attested {
                                Some(registry_attested.len())
                            } else {
                                None
                            },
                        };
                        let mut merged = producer_signed;
                        merged.extend(registry_attested);
                        (merged, Some(Ok(outcome)), seeded_facts)
                    }
                    Ok(Err(e)) => {
                        let wrapped = AcdpError::RevocationDiscoveryFailed {
                            source: Box::new(e),
                        };
                        match discovery.on_failure {
                            DiscoveryFailurePolicy::FailClosed => return Err(wrapped),
                            DiscoveryFailurePolicy::ProceedWithKnown => {
                                (Vec::new(), Some(Err(wrapped)), seeded_facts)
                            }
                        }
                    }
                    Err(_elapsed) => {
                        let wrapped = AcdpError::RevocationDiscoveryFailed {
                            source: Box::new(AcdpError::CrossRegistryResolutionFailed(format!(
                                "revocation auto-discovery exceeded total_timeout={:?}",
                                discovery.total_timeout
                            ))),
                        };
                        match discovery.on_failure {
                            DiscoveryFailurePolicy::FailClosed => return Err(wrapped),
                            DiscoveryFailurePolicy::ProceedWithKnown => {
                                (Vec::new(), Some(Err(wrapped)), seeded_facts)
                            }
                        }
                    }
                }
            }
        };

        // ── Revocation phase (RFC-ACDP-0014 §7) ─────────────────────
        // Runs after the receipt phase because the boundary comparison
        // accepts ONLY a receipt-attested publish time (§7 step 1 —
        // the bare body created_at is registry-assigned and MUST NOT
        // be used). The verified receipt's key_fingerprint was already
        // cross-checked against the body's signing key above (§8 step
        // 5), so `verified_receipt.created_at` genuinely places THIS
        // key's signature in time.
        //
        // `effective` is the union of `policy.revocations.known`,
        // `seeded_facts` (issue #257 — the attached `RevocationCache`'s
        // permanent record for this producer, ALWAYS unioned in
        // regardless of whether this call's discovery attempt above
        // succeeded, failed, or was never configured at all), and
        // whatever `discovered` above (empty when `discover` is `None`,
        // or when discovery failed under `ProceedWithKnown`) —
        // deliberately WITHOUT deduplication: `effective_boundary` is a
        // `filter().map().min()` fold, so duplicate entries are inert
        // and two sources disagreeing resolves to the earliest
        // boundary, the fail-closed direction §4 mandates. Dedup is
        // unavailable anyway — `KeyRevocation` is not `Hash`. (The cache
        // itself still dedups on insert — see
        // `RevocationCache::record_success` — so `seeded_facts` alone
        // does not grow unboundedly across repeated calls; this `chain`
        // is simply not where that bound lives.)
        let effective: Vec<acdp_types::revocation::KeyRevocation> = policy
            .revocations
            .known
            .iter()
            .cloned()
            .chain(seeded_facts)
            .chain(discovered)
            .collect();
        let revocation_verdict = if effective.is_empty() {
            None
        } else {
            let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
                &ctx.body.signature.key_id,
                &ctx.body.signature.algorithm,
                resolver,
            )
            .await?;
            super::revocation::classify_under_revocation(
                &effective,
                &fingerprint,
                verified_receipt.as_ref().map(|r| r.created_at),
            )?
        };

        // ── Signature phase ──────────────────────────────────────────
        // Standard path enforces assertionMethod membership. A
        // KeyNotAuthorized failure falls back to the historical path
        // only under AcceptWithReceipt AND a verified receipt — the
        // receipt's key_fingerprint (already cross-checked against this
        // exact key above) is what attests publish-time authorization.
        let key_status = match revocation_verdict {
            // Pre-compromise (§7 step 2): the signature is verified
            // under the RFC-ACDP-0010 §10 historical rule — the key may
            // legitimately have left assertionMethod (and SHOULD, §9),
            // and even a key still in assertionMethod MUST NOT be
            // reported as fully current once revoked. did:key material
            // cannot rotate, so it takes the plain envelope path.
            Some(pre_compromise) => {
                if ctx.body.agent_id.as_str().starts_with("did:key:") {
                    verifier.verify_body_signature(&ctx.body).await?;
                } else {
                    acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
                }
                pre_compromise
            }
            None => match verifier.verify_body_signature(&ctx.body).await {
                Ok(()) => KeyAuthorization::CurrentlyAuthorized,
                Err(AcdpError::KeyNotAuthorized(_))
                    if policy.historical_keys == HistoricalKeyPolicy::AcceptWithReceipt
                        && verified_receipt.is_some() =>
                {
                    acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
                    KeyAuthorization::HistoricallyAuthorized
                }
                Err(e) => return Err(e),
            },
        };

        if !policy.allow_unknown_status {
            if let Some(other) = ctx.registry_state.status.as_other() {
                return Err(AcdpError::SchemaViolation(format!(
                    "policy.allow_unknown_status=false; registry returned '{other}'"
                )));
            }
        }

        Ok((key_status, verified_receipt, revocation_discovery))
    }

    /// Retrieve + verify, returning a structured [`VerificationReport`]
    /// alongside the verified context. Does NOT attempt external
    /// `DataRef` fetches — use [`Self::fetch_report_with_fetcher`] for
    /// that. Each `data_ref_external` slot in the returned report is
    /// `None`.
    ///
    /// Unlike [`Self::fetch_with_policy`], per-`DataRef` embedded-hash
    /// failures are recorded in the report instead of aborting the
    /// verification. The top-level checks (schema, body hash,
    /// signature) remain hard-fail: if any of them fails, the method
    /// returns an `AcdpError` and produces no report.
    ///
    /// For diagnostic callers that want a populated report even when
    /// a top-level check fails (e.g. an audit walker that needs to
    /// distinguish "wrong hash" from "wrong signature"), use
    /// [`Self::fetch_report_diagnose`] instead.
    pub async fn fetch_report(
        client: &RegistryClient,
        resolver: &WebResolver,
        ctx_id: &CtxId,
        policy: &VerificationPolicy,
    ) -> Result<(Self, VerificationReport), AcdpError> {
        Self::fetch_report_inner::<NoFetcher>(client, resolver, ctx_id, policy, None).await
    }

    /// Diagnostic variant of [`Self::fetch_report`] that never
    /// short-circuits on a top-level failure — schema, body-hash, and
    /// signature outcomes are each recorded individually in the
    /// returned [`VerificationReport`]. Returns `Ok((None, report))`
    /// when any top-level probe failed (the report shows which one);
    /// `Ok((Some(verified), report))` only when every check passed
    /// (FEAT-05) — and "every check" now genuinely means every
    /// authorization phase (receipt, revocation, signature/
    /// historical-key, unknown-status), not just the top-level probes:
    /// once the probes pass, this method additionally runs the same
    /// `verify_retrieved` phase `fetch_with_policy` does, and withholds
    /// the handle — recording the cause in
    /// [`VerificationReport::policy_phase_error`] — if that phase fails
    /// too. Either way the method still returns `Ok`; it never converts
    /// a policy-phase failure into an `Err`.
    ///
    /// Use cases:
    /// - Audit walkers that need to classify failures by stage.
    /// - Admin tooling that wants to distinguish "hash mismatch"
    ///   (probable tampering / encoding drift) from "signature
    ///   verification failed" (key compromise / DID resolution
    ///   problem).
    ///
    /// Network errors from the initial retrieval still propagate as
    /// `Err` — there's no body to inspect when the registry is
    /// unreachable. But network/DID-resolution errors that occur
    /// *inside* the `verify_retrieved` phase (e.g. resolving the
    /// fingerprint for a receipt cross-check, or the historical-key
    /// fallback) are caught there and land in
    /// [`VerificationReport::policy_phase_error`] instead of `Err`,
    /// same as any other phase failure — this method never
    /// short-circuits once retrieval has succeeded. That means a
    /// transient network flake at that stage can read as a policy
    /// rejection (`Ok((None, report))`) rather than an `Err`. A caller
    /// that needs to tell a flake from a genuine rejection should
    /// inspect `policy_phase_error`'s [`AcdpError::is_transient`].
    ///
    /// Stage-classification caveat: `verify_retrieved` (and therefore the
    /// receipt/revocation/signature/historical-key phases it runs) is only
    /// attempted once ALL top-level probes pass, `ctx_id_ok` included — see
    /// `all_top_level_pass` below. So when `ctx_id_ok` is `false`,
    /// `policy_phase_error` stays `None` even if the served body also
    /// carries a receipt that would independently fail its own cross-check
    /// (e.g. a receipt genuinely bound to the *served*, substituted body —
    /// RFC-ACDP-0010 §8 step 3 — rather than the requested one). A caller
    /// classifying a `ctx_id_ok: false` report should not read a `None`
    /// `policy_phase_error` as "the receipt/signature phases passed" — they
    /// were never attempted.
    pub async fn fetch_report_diagnose(
        client: &RegistryClient,
        resolver: &WebResolver,
        ctx_id: &CtxId,
        policy: &VerificationPolicy,
    ) -> Result<(Option<Self>, VerificationReport), AcdpError> {
        let ctx = client.retrieve(ctx_id).await?;
        let mut report = VerificationReport {
            body_hash_ok: false,
            signature_ok: false,
            schema_ok: false,
            data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
            data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
            ctx_id_ok: ctx.body.ctx_id == *ctx_id,
            key_status: None,
            policy_phase_error: None,
            revocation_discovery: None,
        };

        // Schema (structural) — record pass/fail.
        if policy.validate_body_schema {
            match acdp_validation::validate_body_structural(&ctx.body) {
                Ok(()) => report.schema_ok = true,
                Err(_) => { /* keep schema_ok=false; continue collecting */ }
            }
        } else {
            report.schema_ok = true;
        }

        // Per-DataRef embedded hashes — same as fetch_report_inner.
        for dr in &ctx.body.data_refs {
            if let Some(emb) = &dr.embedded {
                if dr.content_hash.is_some() || emb.content_hash.is_some() {
                    let outcome = acdp_validation::verify_embedded_hash(dr).and_then(|()| {
                        acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len())
                    });
                    report.data_ref_embedded.push(outcome);
                } else {
                    report.data_ref_embedded.push(Ok(0));
                }
            } else {
                report.data_ref_embedded.push(Ok(0));
            }
        }

        // Hash + signature recorded independently (FEAT-05).
        let verifier = Verifier::new(resolver);
        report.body_hash_ok = verifier.verify_body_hash(&ctx.body).is_ok();
        report.signature_ok = verifier.verify_body_signature(&ctx.body).await.is_ok();

        // External fetches were not attempted (this method has no
        // fetcher param — diagnostic callers can wire their own).
        for _ in &ctx.body.data_refs {
            report.data_ref_external.push(None);
        }

        // Decide whether to surface the verified handle. The probes above
        // are diagnostic — their whole value is continuing past failure —
        // but the handle is a trust assertion (`VerifiedContext`'s
        // invariant: "the accessors below can be trusted without
        // re-deriving anything"), so it is only ever issued once the real
        // authorization phases (receipt, revocation, signature/historical,
        // unknown-status) have actually run and passed through
        // `verify_retrieved` — never on the probes alone.
        let all_top_level_pass =
            report.schema_ok && report.body_hash_ok && report.signature_ok && report.ctx_id_ok;
        let verified = if all_top_level_pass {
            // The call MUST be hoisted out of the `match` scrutinee: in a
            // match, scrutinee temporaries live to the end of the match,
            // so the awaited future would still be holding `&ctx` inside
            // the arms and `Self { inner: ctx, .. }` below would fail
            // borrowck (E0505).
            let outcome = Self::verify_retrieved(
                client,
                resolver,
                &ctx,
                ctx_id,
                &policy.derived_for_report(),
            )
            .await; // borrow of `ctx` ends here
            match outcome {
                Ok((key_status, verified_receipt, revocation_discovery)) => {
                    report.key_status = Some(key_status);
                    let revocation_discovery_failure = match &revocation_discovery {
                        Some(Err(e)) => Some(e.clone()),
                        _ => None,
                    };
                    report.revocation_discovery = revocation_discovery;
                    Some(Self {
                        inner: ctx,
                        key_status,
                        verified_receipt,
                        verified_head_receipt: None,
                        head_receipt_stale: None,
                        revocation_discovery_failure,
                    })
                }
                Err(e) => {
                    // Reports; never short-circuits — `fetch_report_diagnose`
                    // still returns `Ok` in every case it does today.
                    report.policy_phase_error = Some(e);
                    None
                }
            }
        } else {
            None
        };
        Ok((verified, report))
    }

    /// Retrieve + verify like [`Self::fetch_report`], and additionally
    /// fetch every `DataRef` whose `location` resolves through `fetcher`.
    /// Each external fetch outcome is recorded in `report.data_ref_external`.
    pub async fn fetch_report_with_fetcher<F: DataRefFetcher>(
        client: &RegistryClient,
        resolver: &WebResolver,
        ctx_id: &CtxId,
        policy: &VerificationPolicy,
        fetcher: &F,
    ) -> Result<(Self, VerificationReport), AcdpError> {
        Self::fetch_report_inner(client, resolver, ctx_id, policy, Some(fetcher)).await
    }

    async fn fetch_report_inner<F: DataRefFetcher>(
        client: &RegistryClient,
        resolver: &WebResolver,
        ctx_id: &CtxId,
        policy: &VerificationPolicy,
        fetcher: Option<&F>,
    ) -> Result<(Self, VerificationReport), AcdpError> {
        let ctx = client.retrieve(ctx_id).await?;

        // Identifier binding — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind
        // the resolved identity"). Same check as `verify_retrieved`,
        // applied here (in addition to `verify_retrieved`'s own re-check
        // below) so this fails before schema validation too — the early
        // copy preserves fail-before-schema ordering that
        // `tests/receipts.rs` depends on.
        if ctx.body.ctx_id != *ctx_id {
            return Err(AcdpError::ContextIdMismatch {
                requested: ctx_id.as_str().to_string(),
                served: ctx.body.ctx_id.as_str().to_string(),
            });
        }

        let mut report = VerificationReport {
            body_hash_ok: false,
            signature_ok: false,
            schema_ok: false,
            data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
            data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
            ctx_id_ok: true,
            key_status: None,
            policy_phase_error: None,
            revocation_discovery: None,
        };

        // Structural-only schema validation — embedded-hash checks are
        // intentionally skipped here so per-DataRef hash failures land
        // in the report (below) instead of short-circuiting the whole
        // verification. That's the diagnostic shape `fetch_report`
        // promises in its docstring.
        if policy.validate_body_schema {
            acdp_validation::validate_body_structural(&ctx.body)?;
        }
        report.schema_ok = true;

        // Per-DataRef embedded-hash outcomes — recorded individually.
        for dr in &ctx.body.data_refs {
            if let Some(emb) = &dr.embedded {
                if dr.content_hash.is_some() || emb.content_hash.is_some() {
                    let outcome = acdp_validation::verify_embedded_hash(dr).and_then(|()| {
                        acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len())
                    });
                    report.data_ref_embedded.push(outcome);
                } else {
                    report.data_ref_embedded.push(Ok(0));
                }
            } else {
                report.data_ref_embedded.push(Ok(0));
            }
        }

        // Delegate the remaining phases — content_hash recomputation,
        // RFC-ACDP-0010 receipt, RFC-ACDP-0014 revocation, signature (with
        // the historical-key fallback), and the unknown-status check — to
        // `verify_retrieved`, the sole reader of those policy fields. The
        // derived policy forces `validate_body_schema` off (P1 was already
        // handled, structurally-only, above) and passes everything else
        // through verbatim — see `VerificationPolicy::derived_for_report`.
        let (key_status, verified_receipt, revocation_discovery) =
            Self::verify_retrieved(client, resolver, &ctx, ctx_id, &policy.derived_for_report())
                .await?;
        report.body_hash_ok = true;
        report.signature_ok = true;
        report.key_status = Some(key_status);
        let revocation_discovery_failure = match &revocation_discovery {
            Some(Err(e)) => Some(e.clone()),
            _ => None,
        };
        report.revocation_discovery = revocation_discovery;

        // External fetches — record per-ref outcomes when a fetcher is
        // supplied; otherwise leave each slot as `None` so callers can
        // distinguish "skipped" from "failed".
        for dr in &ctx.body.data_refs {
            let slot: Option<Result<usize, AcdpError>> = match (fetcher, &dr.location) {
                (Some(f), Some(_)) => Some(fetch_and_verify_data_ref(dr, f).await.map(|b| b.len())),
                _ => None,
            };
            report.data_ref_external.push(slot);
        }

        Ok((
            Self {
                inner: ctx,
                key_status,
                verified_receipt,
                verified_head_receipt: None,
                head_receipt_stale: None,
                revocation_discovery_failure,
            },
            report,
        ))
    }

    pub fn body(&self) -> &acdp_types::body::Body {
        &self.inner.body
    }

    pub fn registry_state(&self) -> &acdp_types::body::RegistryState {
        &self.inner.registry_state
    }

    /// The verified [`FullContext`] (body + registry state + any
    /// receipts) in its retrieval shape. Every field was reached only
    /// after this context's hash + signature were verified.
    pub fn full_context(&self) -> &FullContext {
        &self.inner
    }

    /// Whether the body verified against a currently authorized key, a
    /// receipt-attested historical one, or a receipt-attested
    /// pre-compromise one (ACDP 0.2 WS-B / RFC-ACDP-0014 §7). This is
    /// the real verdict regardless of which `fetch*`/`fetch_report*`
    /// entry point produced this `VerifiedContext` — every construction
    /// path runs the same `verify_retrieved` phase to derive it.
    pub fn key_status(&self) -> KeyAuthorization {
        self.key_status
    }

    /// The verified registry receipt (RFC-ACDP-0010), when one was
    /// present and the policy verified it. `None` under
    /// [`ReceiptPolicy::Ignore`] or when the registry minted none — this
    /// is exhaustive; there is no additional "or you used a report path"
    /// carve-out, since `fetch_report`/`fetch_report_with_fetcher`/
    /// `fetch_report_diagnose` verify the receipt exactly like
    /// `fetch_with_policy` does. For the raw on-wire value see
    /// [`Self::receipt`].
    pub fn verified_receipt(&self) -> Option<&acdp_types::receipt::RegistryReceipt> {
        self.verified_receipt.as_ref()
    }

    /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
    /// populated only by [`Self::fetch_current`] /
    /// [`Self::fetch_current_with_policy`] when one was present and the
    /// policy verified it. For the raw on-wire value see
    /// [`Self::lineage_head_receipt`].
    pub fn verified_head_receipt(&self) -> Option<&acdp_types::receipt::LineageHeadReceipt> {
        self.verified_head_receipt.as_ref()
    }

    /// RFC-ACDP-0011 §6 freshness verdict for the verified head
    /// receipt: `Some(true)` when the (genuine, verified) receipt's
    /// `as_of` is older than [`LineageHeadPolicy::max_age_seconds`];
    /// `Some(false)` when within policy; `None` when there is no
    /// verified head receipt or the max-age knob is disabled.
    pub fn head_receipt_stale(&self) -> Option<bool> {
        self.head_receipt_stale
    }

    /// RFC-ACDP-0014 §8 auto-discovery failure that
    /// [`DiscoveryFailurePolicy::ProceedWithKnown`] swallowed to let
    /// this `VerifiedContext` exist at all. `None` when discovery was
    /// off ([`RevocationPolicy::discover`] is `None`), succeeded, or
    /// was never attempted — a [`DiscoveryFailurePolicy::FailClosed`]
    /// failure turns into this call's `Err` instead, so there is no
    /// `VerifiedContext` to carry it in that case. See
    /// [`VerificationReport::revocation_discovery`] for the twin
    /// surface on the report family, populated from the same event on
    /// the `fetch_report*` paths.
    pub fn revocation_discovery_failure(&self) -> Option<&AcdpError> {
        self.revocation_discovery_failure.as_ref()
    }

    /// Raw registry receipt value as served on the wire
    /// (RFC-ACDP-0010), preserved verbatim. For the verified, typed
    /// form see [`Self::verified_receipt`].
    pub fn receipt(&self) -> Option<&serde_json::Value> {
        self.inner.registry_receipt.as_ref()
    }

    /// Raw lineage-head receipt value as served on the wire
    /// (RFC-ACDP-0011), preserved verbatim. For the verified, typed
    /// form see [`Self::verified_head_receipt`].
    pub fn lineage_head_receipt(&self) -> Option<&serde_json::Value> {
        self.inner.lineage_head_receipt.as_ref()
    }

    /// Verify the registry receipt, when one is present
    /// (RFC-ACDP-0010).
    ///
    /// Standalone variant for contexts obtained via the report paths;
    /// `fetch_with_policy` already does this under
    /// [`ReceiptPolicy::VerifyIfPresent`]/`Require`. The serving
    /// authority is taken from the context's own `ctx_id` — this method
    /// performs no requested-id binding of its own (it has no requested
    /// id to compare against; it only ever sees `self.inner.body.ctx_id`),
    /// so deriving the serving authority this way is sound only for a
    /// `VerifiedContext` obtained through a pipeline that already bound
    /// the served `ctx_id` to the one requested. Every construction path
    /// does: `fetch_with_policy` and `CrossRegistryResolver::resolve`
    /// check it directly; `fetch_current_with_policy` does too,
    /// tautologically, since `/current` has no requested id to diverge
    /// from; `fetch_report`/`fetch_report_with_fetcher` check it and
    /// return `ContextIdMismatch` on failure; and `fetch_report_diagnose`
    /// folds it into its `all_top_level_pass` gate, so it only ever
    /// hands back `Some(VerifiedContext)` when `ctx_id_ok` held. All of
    /// these implement RFC-ACDP-0006 §4.1 step 7, so the type invariant
    /// — every `VerifiedContext` was bound to its requested `ctx_id` —
    /// holds unconditionally.
    ///
    /// Returns `Ok(None)` when no receipt is present, `Ok(Some(_))`
    /// with the verified receipt otherwise.
    ///
    /// The receipt cross-check (RFC-ACDP-0010 §8 step 4) relies on
    /// `body.content_hash` being the independently recomputed value.
    /// That is guaranteed by the type invariant — every
    /// `VerifiedContext` is built only after its constructing pipeline
    /// verified the body hash (`Verifier::verify_body_hash` /
    /// `verify_body_signed`), and the fields are private so no caller
    /// can substitute an unverified body — so no re-derivation is
    /// needed here.
    pub async fn verify_receipt(
        &self,
        resolver: &WebResolver,
    ) -> Result<Option<acdp_types::receipt::RegistryReceipt>, AcdpError> {
        let Some(value) = &self.inner.registry_receipt else {
            return Ok(None);
        };
        let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
            &self.inner.body.signature.key_id,
            &self.inner.body.signature.algorithm,
            resolver,
        )
        .await?;
        let receipt = super::receipt::verify_receipt_value(
            value,
            &self.inner.body.ctx_id,
            &self.inner.body,
            &self.inner.body.content_hash,
            &fingerprint,
            self.inner.body.ctx_id.authority(),
            resolver,
        )
        .await?;
        Ok(Some(receipt))
    }
}

/// Structured diagnostic outcome from [`VerifiedContext::fetch_report`].
///
/// Top-level booleans report the per-stage outcome of the verification
/// pipeline. Per-`DataRef` slots track outcomes for each entry in
/// `body.data_refs`, in declaration order:
///
/// - `data_ref_embedded[i]` — `Ok(decoded_size_bytes)` when the embedded
///   payload's `content_hash` matched; `Err` when it didn't (or the
///   embedded was malformed). Refs without an embedded payload or
///   without a declared `content_hash` produce `Ok(0)`.
/// - `data_ref_external[i]` — `None` when no external fetch was
///   attempted (either no `location` or no `fetcher` was provided);
///   `Some(Ok(bytes_len))` when the fetch + hash succeeded;
///   `Some(Err(_))` on any failure (SSRF rejection, hash mismatch,
///   timeout, …).
///
/// `AcdpError` implements `Clone` (see its doc), which is what lets
/// [`Self::revocation_discovery`]'s failure also be independently owned
/// by [`VerifiedContext::revocation_discovery_failure`] from the same
/// `fetch_report*` call; `AcdpError` still has no `PartialEq`, so
/// asserting on any `AcdpError`-carrying field here wants `matches!`.
///
/// `#[non_exhaustive]`: this struct has already gained a field once as a
/// non-optional consequence of a security fix (the RFC-ACDP-0006 §4.1
/// context-identity binding), and it is output-only — constructed solely
/// inside this crate (`verified.rs`) — so downstream loses nothing by
/// being unable to construct it directly. Same rationale as `SsrfReason`
/// in `crates/acdp-safe-http/src/lib.rs` ("future spec revisions may add
/// ranges"): future fields stop being breaking changes for callers that
/// only read this report.
#[derive(Debug)]
#[non_exhaustive]
pub struct VerificationReport {
    /// `content_hash` recomputed from the body matches the declared one.
    pub body_hash_ok: bool,
    /// The producer signature verified against the resolved DID key.
    pub signature_ok: bool,
    /// `validate_body` passed (or was disabled by policy).
    pub schema_ok: bool,
    /// Per-`DataRef` embedded-hash outcome, in `body.data_refs` order.
    pub data_ref_embedded: Vec<Result<usize, AcdpError>>,
    /// Per-`DataRef` external-fetch outcome, in `body.data_refs` order.
    /// `None` indicates "not attempted" (no fetcher provided or no
    /// `location` to fetch from).
    pub data_ref_external: Vec<Option<Result<usize, AcdpError>>>,
    /// The served body's `ctx_id` equals the one requested
    /// (RFC-ACDP-0006 §4.1 step 7, NORMATIVE — "Bind the resolved
    /// identity"). `false` means the registry served a different,
    /// validly-signed body under the requested id (context
    /// substitution); see `VerifiedContext::verify_retrieved`'s doc for
    /// the full rationale. This flag gates whether
    /// [`VerifiedContext::fetch_report_diagnose`] hands back a
    /// `Some(VerifiedContext)` — appended last so any positional
    /// construction fails loudly rather than silently binding the wrong
    /// field.
    pub ctx_id_ok: bool,
    /// The real P3-P6 verdict from `verify_retrieved`'s authorization
    /// phases (receipt, revocation, signature/historical, unknown-status),
    /// when they ran and all passed. `None` means either "not reached"
    /// (a top-level probe — schema, body hash, signature, ctx_id — failed
    /// first, so `verify_retrieved` was never invoked) or "the phase ran
    /// and failed" (see [`Self::policy_phase_error`] for which one).
    pub key_status: Option<KeyAuthorization>,
    /// Which of `verify_retrieved`'s policy-governed phases (receipt,
    /// revocation, signature/historical-key, unknown-status) failed, when
    /// one did. `None` when every phase passed, or when `verify_retrieved`
    /// was never invoked because a top-level probe failed first.
    /// `AcdpError` derives `Clone` (see its doc — added for this field's
    /// and [`VerifiedContext::revocation_discovery_failure`]'s sake), but
    /// asserting on it still wants `matches!` over `==`/`assert_eq!`:
    /// `AcdpError` has no `PartialEq`.
    pub policy_phase_error: Option<AcdpError>,
    /// What RFC-ACDP-0014 §8 auto-discovery did, when
    /// `policy.revocations.discover` was `Some` and `verify_retrieved`
    /// was reached (a top-level probe failure or a
    /// [`DiscoveryFailurePolicy::FailClosed`] discovery failure both
    /// leave this `None` — the latter surfaces via
    /// [`Self::policy_phase_error`] instead, since `verify_retrieved`
    /// returned `Err` before there was any outcome to record). `Some(Ok(_))`
    /// on success; `Some(Err(_))` when
    /// [`DiscoveryFailurePolicy::ProceedWithKnown`] swallowed a
    /// discovery failure and verification proceeded on
    /// [`RevocationPolicy::known`] alone — see
    /// [`VerifiedContext::revocation_discovery_failure`] for the twin
    /// surface on the verified handle itself, populated from the same
    /// event. The counts inside [`DiscoveryOutcome`] are discovery
    /// output only, never `known` — appended last, after
    /// `policy_phase_error`, for the same "fail loudly on stale
    /// positional construction" reason that field was.
    pub revocation_discovery: Option<Result<DiscoveryOutcome, AcdpError>>,
}

/// Sentinel `DataRefFetcher` used as the type parameter for
/// `fetch_report_inner` when no fetcher is supplied. `fetch` is never
/// actually called — the option is matched out before that — but
/// providing a real impl lets the generic monomorphize cleanly without
/// requiring `fetch_report`'s callers to name a type.
struct NoFetcher;

impl DataRefFetcher for NoFetcher {
    async fn fetch(
        &self,
        _location: &acdp_types::data_ref::Location,
    ) -> Result<Vec<u8>, AcdpError> {
        Err(AcdpError::NotImplemented(
            "NoFetcher should never be called — this is a fetch_report sentinel".into(),
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::{
        DiscoveryFailurePolicy, HistoricalKeyPolicy, ReceiptPolicy, RevocationDiscovery,
        RevocationPolicy, VerificationPolicy,
    };
    use acdp_primitives::error::AcdpError;
    use std::time::Duration;

    /// The RFC-ACDP-0001 §9.2 named constructor preserves exact v0.1.0
    /// semantics: receipts inert, assertionMethod-only keys. It is
    /// deliberately NOT the 0.2 default (which is receipt-aware).
    #[test]
    fn strict_v0_1_0_preserves_v0_1_0_semantics() {
        let strict = VerificationPolicy::strict_v0_1_0();
        assert!(strict.validate_body_schema);
        assert!(strict.allow_unknown_status);
        assert_eq!(strict.receipts, ReceiptPolicy::Ignore);
        assert_eq!(strict.historical_keys, HistoricalKeyPolicy::Reject);
        assert!(
            strict.revocations.known.is_empty(),
            "a 0.1.0-pinned consumer is unaffected by RFC-ACDP-0014"
        );
        assert_ne!(
            strict,
            VerificationPolicy::default(),
            "the 0.2 default is receipt-aware; the v0.1.0 profile is not"
        );
    }

    /// Phase 2 acceptance criterion 6 — the spine lock.
    ///
    /// `verify_retrieved` must be the SOLE reader of the four
    /// authorization-policy fields (`receipts`, `revocations`,
    /// `historical_keys`, `allow_unknown_status`) anywhere in this file.
    /// Every public entry point (the four `fetch*` forms plus the three
    /// report forms) reaches every authorization phase through that one
    /// function, so a future RFC phase added anywhere else — instead of
    /// inside `verify_retrieved` — trips this test instead of silently
    /// reintroducing the exact divergence this phase fixed.
    ///
    /// Implemented as a plain `str` scan (no `regex` — it is not a
    /// dependency of `acdp-client`) over this file's own source, read via
    /// `include_str!`. `verify_retrieved`'s body span is located by
    /// brace-counting from its own opening brace (its signature has no
    /// braces of its own — only angle brackets in the return type — so
    /// the first `{` after the `fn` keyword IS the body's opening brace),
    /// not by hard-coded line numbers, so the check survives any diff.
    /// Lines whose trimmed start is `//` (covers `///` too), and matches
    /// that fall inside a string literal (detected by an odd count of
    /// unescaped `"` before the match on its line — this file's one
    /// in-string occurrence, the `allow_unknown_status=false` error
    /// message, already lives inside `verify_retrieved` regardless), are
    /// excluded.
    ///
    /// The four search patterns are built by runtime concatenation
    /// (`policy.` + each field name) rather than written as contiguous
    /// `"policy.receipts"`-style literals, so this test's own source —
    /// included verbatim via `include_str!` — does not self-match its
    /// own patterns.
    #[test]
    fn verify_retrieved_is_sole_reader_of_authorization_policy_fields() {
        const SRC: &str = include_str!("verified.rs");

        let policy_prefix = "policy.";
        let fields = [
            "receipts",
            "revocations",
            "historical_keys",
            "allow_unknown_status",
        ];
        let patterns: Vec<String> = fields
            .iter()
            .map(|f| format!("{policy_prefix}{f}"))
            .collect();

        // Locate `verify_retrieved`'s body span.
        let fn_start = SRC
            .find("async fn verify_retrieved(")
            .expect("verify_retrieved must exist in verified.rs");
        let body_open = fn_start
            + SRC[fn_start..]
                .find('{')
                .expect("verify_retrieved must have a body");
        let mut depth = 0i32;
        let mut body_close = None;
        for (i, ch) in SRC[body_open..].char_indices() {
            match ch {
                '{' => depth += 1,
                '}' => {
                    depth -= 1;
                    if depth == 0 {
                        body_close = Some(body_open + i);
                        break;
                    }
                }
                _ => {}
            }
        }
        let body_close =
            body_close.expect("verify_retrieved's matching closing brace must be found");
        assert!(
            body_close > body_open,
            "sanity: verify_retrieved's body must be non-empty"
        );

        // Scan the whole file, tracking byte offsets so each match's
        // position can be tested against the body span.
        let mut offset = 0usize;
        let mut checked_any = false;
        for line in SRC.split_inclusive('\n') {
            let trimmed = line.trim_start();
            let is_comment_line = trimmed.starts_with("//");
            if !is_comment_line {
                for pattern in &patterns {
                    let mut search_from = 0usize;
                    while let Some(rel) = line[search_from..].find(pattern.as_str()) {
                        let match_col = search_from + rel;
                        let match_start = offset + match_col;
                        let before = &line[..match_col];
                        let in_string_literal = before.matches('"').count() % 2 == 1;
                        if !in_string_literal {
                            checked_any = true;
                            assert!(
                                match_start >= body_open && match_start < body_close,
                                "found `{pattern}` outside verify_retrieved's body \
                                 (byte offset {match_start}, line: {line:?}) — every \
                                 authorization-policy-field read must live inside \
                                 verify_retrieved, the sole reader"
                            );
                        }
                        search_from = match_col + pattern.len();
                    }
                }
            }
            offset += line.len();
        }

        // Second pass — whitespace-normalized, to catch a read rustfmt
        // has wrapped across lines (e.g. a `policy` / `.revocations` /
        // `.known` chain on three separate lines), which the
        // line-by-line pass above cannot see since the pattern never
        // sits contiguously on any single line. Build a copy of `SRC`
        // with whitespace immediately touching a `.` removed — folding
        // any such wrapped chain back to its unwrapped spelling — while
        // recording, for every byte kept, the byte offset it came from
        // in the original `SRC`. Every match here is re-validated
        // against its ORIGINAL line for the same comment / string
        // exclusions the first pass applies, so this pass only adds
        // coverage; it does not relax anything the first pass enforces.
        let mut normalized = String::with_capacity(SRC.len());
        let mut orig_offsets: Vec<usize> = Vec::with_capacity(SRC.len());
        let mut after_dot = false;
        for (i, ch) in SRC.char_indices() {
            if ch == '.' {
                while let Some(last) = normalized.chars().last() {
                    if !last.is_whitespace() {
                        break;
                    }
                    normalized.pop();
                    let new_len = orig_offsets.len() - last.len_utf8();
                    orig_offsets.truncate(new_len);
                }
                normalized.push(ch);
                orig_offsets.push(i);
                after_dot = true;
                continue;
            }
            if after_dot && ch.is_whitespace() {
                continue; // swallow whitespace immediately after a dot
            }
            after_dot = false;
            normalized.push(ch);
            for _ in 0..ch.len_utf8() {
                orig_offsets.push(i);
            }
        }
        debug_assert_eq!(normalized.len(), orig_offsets.len());

        let is_comment_line_at = |pos: usize| -> bool {
            let line_start = SRC[..pos].rfind('\n').map_or(0, |i| i + 1);
            let line_end = SRC[pos..].find('\n').map_or(SRC.len(), |i| pos + i);
            SRC[line_start..line_end].trim_start().starts_with("//")
        };
        let in_string_literal_at = |pos: usize| -> bool {
            let line_start = SRC[..pos].rfind('\n').map_or(0, |i| i + 1);
            SRC[line_start..pos].matches('"').count() % 2 == 1
        };

        for pattern in &patterns {
            let mut search_from = 0usize;
            while let Some(rel) = normalized[search_from..].find(pattern.as_str()) {
                let match_col = search_from + rel;
                let orig_start = orig_offsets[match_col];
                if !is_comment_line_at(orig_start) && !in_string_literal_at(orig_start) {
                    checked_any = true;
                    assert!(
                        orig_start >= body_open && orig_start < body_close,
                        "found `{pattern}` (whitespace-normalized) outside \
                         verify_retrieved's body (original byte offset {orig_start}) \
                         — every authorization-policy-field read must live inside \
                         verify_retrieved, the sole reader, even when rustfmt has \
                         wrapped the field-access chain across multiple lines"
                    );
                }
                search_from = match_col + pattern.len();
            }
        }

        assert!(
            checked_any,
            "sanity: the scan must find at least one non-comment, non-string-literal \
             match for at least one pattern (verify_retrieved itself reads these \
             fields) — zero hits would mean the patterns are miscomputed, not that \
             the invariant holds"
        );
    }

    /// issue #248 Phase 2, acceptance criterion 1 — `RevocationPolicy::default()`
    /// stays behaviorally identical to the pre-Phase-2 shape: no known
    /// revocations, discovery off.
    #[test]
    fn revocation_policy_default_is_unchanged() {
        let policy = RevocationPolicy::default();
        assert!(policy.known.is_empty());
        assert!(policy.discover.is_none());
    }

    /// issue #248 Phase 2, acceptance criterion 1 (continued) — `::new`
    /// produces the same shape as the old bare-literal `RevocationPolicy { known }`
    /// this struct's `#[non_exhaustive]` retires.
    #[test]
    fn revocation_policy_new_leaves_discovery_off() {
        let policy = RevocationPolicy::new(vec![]);
        assert!(policy.known.is_empty());
        assert!(policy.discover.is_none());
    }

    /// issue #248 Phase 2, acceptance criterion 3.
    #[test]
    fn discovery_failure_policy_defaults_to_fail_closed() {
        assert_eq!(
            DiscoveryFailurePolicy::default(),
            DiscoveryFailurePolicy::FailClosed
        );
    }

    /// issue #248 Phase 2, acceptance criterion 3.
    #[test]
    fn revocation_discovery_constructors_set_the_right_trust_classes() {
        let producer_only = RevocationDiscovery::producer_signed_only();
        assert!(!producer_only.include_registry_attested);
        assert_eq!(producer_only.on_failure, DiscoveryFailurePolicy::FailClosed);
        assert_eq!(producer_only.total_timeout, Duration::from_secs(30));
        assert_eq!(
            producer_only.freshness,
            Duration::ZERO,
            "issue #257: freshness defaults to ZERO (off) from both named constructors"
        );

        let all = RevocationDiscovery::all_trust_classes();
        assert!(all.include_registry_attested);
        assert_eq!(all.on_failure, DiscoveryFailurePolicy::FailClosed);
        assert_eq!(all.total_timeout, Duration::from_secs(30));
        assert_eq!(all.freshness, Duration::ZERO);
    }

    /// issue #248 Phase 2, acceptance criterion 3 (continued) — D6:
    /// `RevocationDiscovery` has no `Default` impl. This is a
    /// compile-time property, not something a runtime assertion can
    /// check; the doc comment on `RevocationDiscovery` records why.
    /// This test exists to make that guarantee discoverable from the
    /// test suite: if a future change adds `Default`, this comment is
    /// the tripwire a reviewer reads, since nothing here would fail.
    /// (An attempted `RevocationDiscovery::default()` call would be a
    /// compile error today — that IS the enforcement.)
    #[test]
    fn revocation_discovery_has_no_default_by_design() {
        // Deliberately empty: the guarantee is enforced by the type
        // system (no `Default` impl exists), not by this test body.
    }

    /// issue #248 Phase 2, acceptance criterion 4 — `RevocationDiscoveryFailed`
    /// delegates `is_transient` to its `source`, both ways.
    #[test]
    fn revocation_discovery_failed_delegates_is_transient_to_source() {
        let transient = AcdpError::RevocationDiscoveryFailed {
            source: Box::new(AcdpError::KeyResolutionUnreachable(
                "did:web host unreachable".into(),
            )),
        };
        assert!(
            transient.is_transient(),
            "a transient source must make the wrapper transient too"
        );

        let permanent = AcdpError::RevocationDiscoveryFailed {
            source: Box::new(AcdpError::InvalidSignature("bad signature".into())),
        };
        assert!(
            !permanent.is_transient(),
            "a permanent source must make the wrapper permanent too"
        );
    }
}