dig-rpc-protocol 0.11.0

Canonical DIG-node JSON-RPC protocol: request/response types, the method enum + tier classification, the error-code taxonomy, and an OpenRPC 1.2.6 document generator. The single source of truth both DIG node implementations depend on. Pure types — no I/O, no async, no server logic. (Formerly dig-rpc-types.)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
//! Request/response wire types for every DIG-node RPC method.
//!
//! Each type is `serde`-derived and models a method's params or result
//! field-for-field with the canonical implementation (the digstore `dig-node`
//! crate). Fields that appear only in one profile or only on the first window of
//! a paged stream are `Option` and doc-flagged.
//!
//! Hex-encoded identifiers (`store_id`, `root`, `retrieval_key`, `peer_id`) are
//! carried as `String` on the wire — lower-case 64-hex — because the interface
//! crate does no crypto and imposes no byte-array dependency. Callers validate
//! length/charset at their boundary.
//!
//! # Two content profiles, one chunk type
//!
//! [`ContentChunk`] models both the node profile (`dig.getContent` on the local
//! dig-node) and the network profile (`rpc.dig.net`). The network-profile-only
//! fields — [`total_length`](ContentChunk::total_length),
//! [`length`](ContentChunk::length), [`program_hash`](ContentChunk::program_hash),
//! [`offset`](ContentChunk::offset) — are `Option` so one type serves both
//! surfaces with no silent split.

use serde::{Deserialize, Serialize};

/// A lower-case 64-hex identifier on the wire (e.g. a `store_id`, `root`,
/// `retrieval_key`, or `peer_id`). A type alias for documentation; validation is
/// the boundary's job.
pub type HexId = String;

// ===========================================================================
// Shared value objects
// ===========================================================================

/// A peer's dialable network endpoint.
///
/// IPv6-first per the ecosystem networking rule: an address list orders
/// global-unicast IPv6 ahead of IPv4 fallback, and a wildcard bind
/// (`[::]`/`0.0.0.0`) is never advertised.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeerAddress {
    /// The host — an IPv6 or IPv4 literal (never a wildcard).
    pub host: String,
    /// The TCP port.
    pub port: u16,
    /// How the address was discovered: `direct`, `reflexive`, `mapped`, or
    /// `relay`.
    pub kind: String,
}

/// A content provider: a holder's stable `peer_id` plus its candidate addresses.
///
/// The address list is byte-compatible with [`dig.getPeers`](crate::method::Method::GetPeers)
/// and the DHT provider shape.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct Provider {
    /// The holder's stable `peer_id` = `SHA-256(TLS SPKI DER)`, 64-hex.
    pub peer_id: HexId,
    /// The holder's candidate addresses (IPv6-first).
    pub addresses: Vec<PeerAddress>,
}

/// The content item a redirect points at: `store_id` [+ `root` [+
/// `retrieval_key`]], each lower-case 64-hex — the exact item to re-request.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ContentRef {
    /// The store launcher id (always present).
    pub store_id: HexId,
    /// The generation root (present for capsule/resource granularity).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub root: Option<HexId>,
    /// The resource retrieval key (present for resource granularity).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub retrieval_key: Option<HexId>,
}

/// The `error.data.redirect` payload of a
/// [`ContentRedirect`](crate::error::ErrorCode::ContentRedirect) (`-32008`).
///
/// The node does not hold the content but located peers that do; the caller
/// re-requests against one of `providers`, echoing `redirect_depth` in its
/// params so the hop budget stays bounded (stop at `max_redirects`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct RedirectInfo {
    /// The content the caller should re-request.
    pub content: ContentRef,
    /// The holders (peer_id + candidate addresses) to re-request against.
    pub providers: Vec<Provider>,
    /// The hop count the caller must echo on its re-request.
    pub redirect_depth: u64,
    /// The redirect budget — stop redirecting when `redirect_depth` reaches this.
    pub max_redirects: u64,
}

// ===========================================================================
// dig.getContent  (PUBLIC-READ, also peer-reachable)
// ===========================================================================

/// Params for [`dig.getContent`](crate::method::Method::GetContent) — a verified
/// resource-window read.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetContentParams {
    /// The CHIP-0035 singleton launcher id (64-hex).
    pub store_id: HexId,
    /// `SHA-256(urn)` — the only URN-derived value sent to a node (64-hex).
    pub retrieval_key: HexId,
    /// The generation root (64-hex). Empty / `"latest"` / absent ⇒ resolve the
    /// chain tip.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub root: Option<HexId>,
    /// The window start offset (default 0).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub offset: Option<u64>,
    /// Retrieval mode: `"speed"` (default) or `"privacy"` (onion — target).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub mode: Option<String>,
    /// The redirect budget already consumed (echoed from a `-32008` redirect).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub redirect_depth: Option<u64>,
}

/// One window of a resource's ciphertext — the chunk wire object.
///
/// Serves BOTH the node profile (`dig.getContent` on the local dig-node) and the
/// network profile (`rpc.dig.net`). Node-profile responses omit the
/// network-profile-only fields; the doc on each field says which profile
/// populates it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ContentChunk {
    /// This window's bytes, base64. Both profiles.
    pub ciphertext: String,
    /// The resolved generation root (64-hex). Both profiles.
    pub root: HexId,
    /// Whether this window ends the resource. Both profiles.
    pub complete: bool,
    /// The next offset; present iff not complete. Both profiles.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub next_offset: Option<u64>,
    /// Whole-resource merkle proof, base64. First window only (`offset == 0`).
    /// Both profiles.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub inclusion_proof: Option<String>,
    /// Per-chunk ciphertext lengths of the full resource. First window only;
    /// empty ⇒ single chunk. Both profiles.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub chunk_lens: Option<Vec<u64>>,
    /// Where the window was served from: `"local"` (this device's cache) or
    /// `"remote"` (freshly fetched). **Node profile only** — additive tag the
    /// in-process node sets; absent on the network profile.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub source: Option<String>,
    /// The full resource ciphertext length (pre-windowing). **Network profile
    /// only.**
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub total_length: Option<u64>,
    /// This window's byte length. **Network profile only** (the node profile's
    /// length is implicit in `ciphertext`).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub length: Option<u64>,
    /// The window start offset (echoed). **Network profile only.**
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub offset: Option<u64>,
    /// `SHA-256(.dig bytes)` — the on-chain program identity (64-hex).
    /// **Network profile only.**
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub program_hash: Option<HexId>,
}

// ===========================================================================
// dig.getAnchoredRoot  (PUBLIC-READ, also peer-reachable)
// ===========================================================================

/// Params for [`dig.getAnchoredRoot`](crate::method::Method::GetAnchoredRoot).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetAnchoredRootParams {
    /// The store launcher id (64-hex).
    pub store_id: HexId,
}

/// Result for [`dig.getAnchoredRoot`](crate::method::Method::GetAnchoredRoot) —
/// the store's current chain-anchored tip root.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AnchoredRoot {
    /// The store launcher id (echoed, 64-hex).
    pub store_id: HexId,
    /// The chain-anchored tip root (64-hex).
    pub root: HexId,
}

// ===========================================================================
// dig.getCollection / dig.listCollectionItems  (PUBLIC-READ, also peer)
// ===========================================================================

/// Params for [`dig.getCollection`](crate::method::Method::GetCollection).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetCollectionParams {
    /// The NFT launcher ids to resolve. Capped at 10,000 (over-cap ⇒ `-32602`).
    pub launcher_ids: Vec<HexId>,
    /// The optional collection creator DID (64-hex).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub did: Option<HexId>,
}

/// Result for [`dig.getCollection`](crate::method::Method::GetCollection) —
/// collection-level facts computed from DIG's own coinset data.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct Collection {
    /// The resolved creator DID (64-hex), if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub did: Option<HexId>,
    /// The DID declared by the caller / metadata (64-hex), if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub declared_did: Option<HexId>,
    /// The number of launcher ids requested.
    pub item_count: u64,
    /// How many resolved to live NFTs.
    pub resolved_count: u64,
    /// The uniform royalty in basis points, if resolvable.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub royalty_basis_points: Option<u64>,
}

/// Params for
/// [`dig.listCollectionItems`](crate::method::Method::ListCollectionItems).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ListCollectionItemsParams {
    /// The NFT launcher ids. Capped at 10,000 (over-cap ⇒ `-32602`).
    pub launcher_ids: Vec<HexId>,
    /// Page start (default 0).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub offset: Option<u64>,
    /// Page size (default 50, capped at 200).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub limit: Option<u64>,
}

/// CHIP-0007 NFT metadata for one collection item.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct NftMetadata {
    /// Edition ordinal, if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub edition_number: Option<u64>,
    /// Edition total, if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub edition_total: Option<u64>,
    /// Data URIs.
    #[serde(default)]
    pub data_uris: Vec<String>,
    /// `SHA-256` of the data (64-hex), if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub data_hash: Option<HexId>,
    /// Metadata URIs.
    #[serde(default)]
    pub metadata_uris: Vec<String>,
    /// `SHA-256` of the metadata document (64-hex), if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub metadata_hash: Option<HexId>,
    /// License URIs.
    #[serde(default)]
    pub license_uris: Vec<String>,
    /// `SHA-256` of the license (64-hex), if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub license_hash: Option<HexId>,
}

/// One resolved collection item — its current on-chain owner, royalty, and
/// CHIP-0007 metadata.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CollectionItem {
    /// The NFT launcher id (64-hex).
    pub launcher_id: HexId,
    /// The current coin id (64-hex).
    pub coin_id: HexId,
    /// The current owner DID (64-hex), if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub owner_did: Option<HexId>,
    /// The royalty puzzle hash (64-hex).
    pub royalty_puzzle_hash: HexId,
    /// The royalty in basis points.
    pub royalty_basis_points: u64,
    /// The current owner puzzle hash (64-hex).
    pub owner_puzzle_hash: HexId,
    /// The CHIP-0007 metadata, if resolvable.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub metadata: Option<NftMetadata>,
}

/// Result for
/// [`dig.listCollectionItems`](crate::method::Method::ListCollectionItems) — a
/// page of resolved items.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CollectionItemsPage {
    /// This page's items.
    pub items: Vec<CollectionItem>,
    /// The page start (echoed).
    pub offset: u64,
    /// The page size (echoed).
    pub limit: u64,
    /// The total item count across the whole (capped) launcher set.
    pub total: u64,
    /// The next page's offset, or `null` when exhausted.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub next_offset: Option<u64>,
}

// ===========================================================================
// dig.getNetworkInfo  (PEER)
// ===========================================================================

/// The node's relay reservation posture.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct RelayStatus {
    /// The relay endpoint URL (e.g. `wss://relay.dig.net:443`).
    pub url: String,
    /// Whether a relay reservation is currently held.
    pub reserved: bool,
}

/// Result for [`dig.getNetworkInfo`](crate::method::Method::GetNetworkInfo) —
/// this node's own peer-network posture.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct NetworkInfo {
    /// This node's stable `peer_id` = `SHA-256(TLS SPKI DER)` (64-hex), or
    /// `null` when no identity is configured.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub peer_id: Option<HexId>,
    /// The DIG network id (e.g. `DIG_MAINNET`).
    pub network_id: String,
    /// The first advertised (dialable) candidate address, `host:port`.
    pub listen_addr: String,
    /// The STUN-discovered reflexive address, if known.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub reflexive_addr: Option<String>,
    /// All advertised candidate addresses (IPv6-first).
    pub candidate_addresses: Vec<String>,
    /// Reachability posture: `"direct"` or `"relayed"`.
    pub reachability: String,
    /// The relay reservation posture.
    pub relay: RelayStatus,
}

// ===========================================================================
// dig.getPeers  (PEER)
// ===========================================================================

/// Result for [`dig.getPeers`](crate::method::Method::GetPeers) — the peers this
/// node currently knows (peer exchange over RPC).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeersList {
    /// The known peers (peer_id + candidate addresses).
    pub peers: Vec<Provider>,
}

// ===========================================================================
// dig.announce  (PEER)
// ===========================================================================

/// Params for [`dig.announce`](crate::method::Method::Announce).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AnnounceParams {
    /// The announcing peer's `peer_id` (64-hex).
    pub peer_id: HexId,
    /// The announcing peer's candidate addresses.
    pub addresses: Vec<PeerAddress>,
}

/// Result for [`dig.announce`](crate::method::Method::Announce).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AnnounceAck {
    /// Whether the announcement was accepted.
    pub accepted: bool,
    /// How many peers this node now knows.
    pub known_peers: u64,
}

// ===========================================================================
// dig.getAvailability  (PEER)
// ===========================================================================

/// One availability query item. Granularity is inferred from which fields are
/// present: `store_id` only ⇒ which roots are held; `+root` ⇒ a capsule; `+root
/// +retrieval_key` ⇒ a resource.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AvailabilityQuery {
    /// The store launcher id (64-hex, required).
    pub store_id: HexId,
    /// The generation root (64-hex), for capsule/resource granularity.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub root: Option<HexId>,
    /// The resource retrieval key (64-hex), for resource granularity.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub retrieval_key: Option<HexId>,
}

/// Params for [`dig.getAvailability`](crate::method::Method::GetAvailability).
///
/// # Construction
///
/// Like [`FetchRangeParams`], this type is `#[non_exhaustive]`: build it with
/// [`new`](Self::new) plus the `with_*` setters rather than a struct literal, so a
/// future additive field is a PATCH for every consumer instead of a semver cascade.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct GetAvailabilityParams {
    /// The items to check. Capped at 512 per batch (past-cap items are dropped).
    pub items: Vec<AvailabilityQuery>,
    /// The hop budget already consumed by this ask. Absent means zero — read it
    /// through [`hops_consumed`](Self::hops_consumed), never directly.
    ///
    /// # What it means for an availability ask
    ///
    /// An availability answer is not only *this* node's holdings: on a miss it may
    /// name the holders it located, in
    /// [`AvailabilityAnswer::providers`](AvailabilityAnswer::providers) — the same
    /// enrichment a [`RedirectInfo`] carries. A responder that cannot answer from
    /// what it holds MAY ask its own peers, so one caller's question can walk
    /// several hops, and each hop is a node spending someone else's bandwidth.
    /// This field is what bounds that walk.
    ///
    /// It is the SAME budget, counted the SAME way, as
    /// [`RedirectInfo::redirect_depth`] and as the `redirect_depth` that
    /// [`GetContentParams`] and [`FetchRangeParams`] already echo: the number of
    /// hops ALREADY CONSUMED when this ask arrives, counting UP from zero — never a
    /// remaining allowance counting down. A responder that asks onward sends
    /// `hops_consumed() + 1` (saturating at the type maximum), and MUST NOT ask
    /// onward when that would reach the budget it advertises as
    /// [`RedirectInfo::max_redirects`].
    ///
    /// Absent reads as a fresh, unhopped ask, so a client written before this field
    /// existed is served unchanged.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub redirect_depth: Option<u64>,
    /// The wall-clock TIME this ask may still spend, in milliseconds. Absent means
    /// unbudgeted — read it through [`budget_ms`](Self::budget_ms), never directly.
    ///
    /// # Why this is its OWN field and not the hop budget
    ///
    /// [`redirect_depth`](Self::redirect_depth) counts hops UP from zero toward a
    /// ceiling; this counts milliseconds DOWN toward zero. The two move in opposite
    /// directions along different axes, so one integer cannot carry both, and folding
    /// them together would make "one more hop" and "more time" the same request.
    ///
    /// # The contract a relaying responder MUST honour
    ///
    /// A responder that asks its own peers onward MUST pass a value it has decremented
    /// by the time it has itself already spent, and it MUST NOT grant a child less time
    /// than the work it asks that child to do. Concretely: a parent that asks `n`
    /// children SEQUENTIALLY must divide the remaining budget between them, and a
    /// parent with less time left than one round trip needs MUST answer
    /// [`ContentMissInconclusive`](crate::error::ErrorCode::ContentMissInconclusive)
    /// rather than ask onward and then time out.
    ///
    /// This exists because the alternative is measurable: a FIXED per-ask bound with
    /// sequential asks and a fan-out greater than one guarantees the second hop times
    /// out, and a responder that reads that timeout as a miss reports a CONFIDENT
    /// not-found for content it never looked for.
    ///
    /// Absent reads as unbudgeted, so a client written before this field existed is
    /// served exactly as it was.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub budget_ms: Option<u64>,
    /// An opaque identity for this ask, for cross-path dedup. Absent means the caller
    /// opted out of dedup — read it through [`ask_id`](Self::ask_id).
    ///
    /// # What it is for
    ///
    /// A recursive ask walks a graph, not a tree. Two disjoint paths can arrive at the
    /// same responder, and without a shared identity that responder cannot tell a
    /// re-walk from a fresh question — so a diamond in the peer graph does not
    /// terminate. A responder that has already seen an `ask_id` MUST answer from what
    /// it already knows instead of asking onward again.
    ///
    /// # What it is NOT
    ///
    /// It is **not** the JSON-RPC `id`. That field correlates one request with one
    /// response on one connection; it is chosen per-connection, is commonly a small
    /// constant, and says nothing about whether two arrivals are the same ask. An
    /// implementation that reused the JSON-RPC `id` for dedup would either collide
    /// every unrelated ask together or dedup nothing at all.
    ///
    /// # Requirements
    ///
    /// 32 lowercase hex characters: **16 unpredictable random bytes**, freshly drawn
    /// by the ORIGINATOR and copied verbatim by every relaying hop. It MUST be
    /// unpredictable, because a value an attacker can guess lets that attacker
    /// pre-poison a responder dedup memo and suppress an ask that has not happened
    /// yet. A responder MUST NOT derive anything from its value beyond EQUALITY — it
    /// carries no structure, no origin, no timestamp and no ordering.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub ask_id: Option<String>,
}

impl GetAvailabilityParams {
    /// An availability batch for `items`, asked at hop zero.
    pub fn new(items: Vec<AvailabilityQuery>) -> Self {
        GetAvailabilityParams {
            items,
            redirect_depth: None,
            budget_ms: None,
            ask_id: None,
        }
    }

    /// Echo the hop budget already consumed — from a `-32008` redirect, or from the
    /// ask this one is being made on behalf of. See
    /// [`redirect_depth`](Self::redirect_depth).
    pub fn with_redirect_depth(mut self, redirect_depth: u64) -> Self {
        self.redirect_depth = Some(redirect_depth);
        self
    }

    /// The hops already consumed by this ask.
    ///
    /// The single home for the "absent means zero" rule. A responder that reached for
    /// `redirect_depth.is_some()` instead would read every pre-0.8 client's ask as
    /// budget-free and forward it without bound — the amplification the budget exists
    /// to stop.
    pub fn hops_consumed(&self) -> u64 {
        self.redirect_depth.unwrap_or(0)
    }

    /// Set the remaining time budget for this ask. See [`budget_ms`](Self::budget_ms).
    pub fn with_budget_ms(mut self, budget_ms: u64) -> Self {
        self.budget_ms = Some(budget_ms);
        self
    }

    /// The time this ask may still spend, or `None` when the caller sent no budget.
    ///
    /// Deliberately NOT collapsed to a number, unlike
    /// [`hops_consumed`](Self::hops_consumed): there is no safe scalar default. Zero
    /// would refuse every older caller ask outright, and any positive default would
    /// silently impose one node idea of patience on another node question. A responder
    /// that receives `None` applies its OWN policy and passes on what it granted.
    pub fn budget_ms(&self) -> Option<u64> {
        self.budget_ms
    }

    /// Set the cross-path dedup identity. See [`ask_id`](Self::ask_id).
    pub fn with_ask_id(mut self, ask_id: impl Into<String>) -> Self {
        self.ask_id = Some(ask_id.into());
        self
    }

    /// The dedup identity, or `None` when the caller opted out of dedup.
    pub fn ask_id(&self) -> Option<&str> {
        self.ask_id.as_deref()
    }
}

/// One availability answer. Only the fields relevant to the query's granularity
/// are populated.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AvailabilityAnswer {
    /// Whether this node holds the queried item.
    pub available: bool,
    /// The roots held (store-granularity queries only).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub roots: Option<Vec<HexId>>,
    /// The full resource ciphertext length (resource-granularity only).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub total_length: Option<u64>,
    /// The chunk count (resource-granularity only).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub chunk_count: Option<u64>,
    /// Whether the whole item is held (root/resource-granularity only).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub complete: Option<bool>,
    /// Providers that hold the item — present on a miss when holders were
    /// located (enriched answer).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub providers: Option<Vec<Provider>>,
    /// Whether this responder actually ESTABLISHED that nobody holds the item.
    ///
    /// Only meaningful beside `available: false`; on a hit the item is held and there
    /// is nothing to establish.
    ///
    /// # Absent is a THIRD state, not `false`
    ///
    /// - `Some(true)` — the responder looked, reached everything it meant to reach,
    ///   and asserts absence. A client MAY stop searching.
    /// - `Some(false)` — the responder looked and could NOT establish absence: a hop
    ///   timed out, was unreachable, or refused uninformatively. A client MUST keep
    ///   looking. This is the in-band form of
    ///   [`ContentMissInconclusive`](crate::error::ErrorCode::ContentMissInconclusive),
    ///   for a batch where only SOME items were inconclusive and the call itself
    ///   therefore succeeded.
    /// - `None` — the responder predates this field and makes NO claim either way.
    ///   It is NOT `Some(false)`: `Some(false)` is a responder telling you its search
    ///   was incomplete, while `None` is a responder that cannot describe its search at
    ///   all. Conflating them lets an older server every miss be read as a positive
    ///   report of incompleteness; conflating it the other way (`unwrap_or(true)`)
    ///   turns an unknown into an assertion of absence. Read it through
    ///   [`absence_established_or_unknown`](AvailabilityAnswer::absence_established_or_unknown),
    ///   which keeps the three states distinct.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub absence_established: Option<bool>,
}

impl AvailabilityAnswer {
    /// Whether absence was established, as a THREE-state answer: `Some(true)`
    /// asserted, `Some(false)` explicitly not established, `None` unknown because the
    /// responder predates the field.
    ///
    /// A pass-through, and that is the point — it is the named home for the rule that
    /// there is no safe collapse to `bool`. A client that wants to stop searching MUST
    /// require `Some(true)`.
    pub fn absence_established_or_unknown(&self) -> Option<bool> {
        self.absence_established
    }
}

/// Result for [`dig.getAvailability`](crate::method::Method::GetAvailability) —
/// one answer per query item, in order.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AvailabilityBatch {
    /// The per-item answers (index-aligned to the query items served).
    pub items: Vec<AvailabilityAnswer>,
}

// ===========================================================================
// dig.listInventory  (PEER)
// ===========================================================================

/// Params for [`dig.listInventory`](crate::method::Method::ListInventory).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ListInventoryParams {
    /// The store to list roots for (64-hex). Absent ⇒ list all stores served.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub store_id: Option<HexId>,
    /// The maximum number of entries to return.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub limit: Option<u64>,
}

/// Result for [`dig.listInventory`](crate::method::Method::ListInventory).
///
/// With a `store_id` the node returns the roots it holds for that store; without
/// one it returns the stores it serves. `#[serde(untagged)]` keeps the wire flat
/// (`{"roots": …}` or `{"stores": …}`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub enum Inventory {
    /// The roots held for a specific store.
    ForStore {
        /// The store launcher id (echoed, 64-hex).
        store_id: HexId,
        /// The roots this node holds for the store.
        roots: Vec<HexId>,
    },
    /// The stores this node serves (no `store_id` given).
    AllStores {
        /// The store launcher ids served.
        stores: Vec<HexId>,
    },
}

// ===========================================================================
// dig.fetchRange  (PEER)
// ===========================================================================

/// Params for [`dig.fetchRange`](crate::method::Method::FetchRange) — a single
/// range frame of a resource this node holds.
///
/// # Construction
///
/// Like [`RangeFrame`], this type is `#[non_exhaustive]`: build it with
/// [`resource`](Self::resource) plus the `with_*` setters rather than a struct
/// literal, so a future additive field is a PATCH for every consumer instead of a
/// semver cascade.
///
/// # Cross-repo contract
///
/// [`skip_layout`](Self::skip_layout) is byte-identical to
/// `dig_nat::mux::RangeRequest::skip_layout`, pinned in
/// `tests/nat_wire_mirror.rs`. The two enclosing types deliberately differ in every
/// other respect — dig-nat's `RangeRequest` is a length-prefixed stream preamble,
/// this is a JSON-RPC params object with a `redirect_depth` dig-nat has no notion
/// of — so the byte-identical contract here is the FIELD, not the object.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct FetchRangeParams {
    /// The store launcher id (64-hex, required).
    pub store_id: HexId,
    /// The generation root (64-hex, required for a resource fetch).
    pub root: HexId,
    /// `SHA-256(urn)` (64-hex, required for a resource fetch).
    pub retrieval_key: HexId,
    /// The range start (default 0).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub offset: Option<u64>,
    /// The range length in bytes (> 0; clamped to the window cap).
    pub length: u64,
    /// Whole-capsule mode (default false). Capsule range fetch is not yet
    /// served; a `true` here yields `-32004`.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub capsule: Option<bool>,
    /// The redirect budget already consumed (echoed from a `-32008` redirect).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub redirect_depth: Option<u64>,
    /// Suppress the resource-scaling layout metadata (`chunk_lens` +
    /// `inclusion_proof`) on this stream's frames, because the client already holds
    /// the commitment for this `root`.
    ///
    /// A client that has already read the layout once — a resumed download, a second
    /// range of the same resource, a parallel fetch from another holder — does not
    /// need it again, and re-sending it costs a whole paged prologue PER STREAM: a
    /// 1,048,576-chunk layout is roughly 7.3 MB, which a 64-way parallel plan would
    /// otherwise pay 64 times over. Suppressing it is the difference between a
    /// bounded and an unbounded cost on the read path.
    ///
    /// Absent or `false` preserves the pre-0.6.0 behaviour, so an older holder that
    /// ignores this field is never broken by it — it simply sends metadata the client
    /// discards. Read the rule through
    /// [`suppresses_layout`](Self::suppresses_layout) rather than re-deriving it.
    ///
    /// The fixed-size identity fields ([`root`](RangeFrame::root),
    /// [`total_length`](RangeFrame::total_length),
    /// [`chunk_count`](RangeFrame::chunk_count),
    /// [`chunk_index`](RangeFrame::chunk_index)) are NOT suppressed: they are what
    /// detects a wrong-generation holder on arrival, and a client that stopped
    /// receiving them would lose that check on exactly the streams it fetches most.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub skip_layout: Option<bool>,
}

impl FetchRangeParams {
    /// A range request for one content resource: `length` bytes of
    /// `retrieval_key`'s ciphertext at the generation `root`.
    pub fn resource(
        store_id: impl Into<HexId>,
        root: impl Into<HexId>,
        retrieval_key: impl Into<HexId>,
        length: u64,
    ) -> Self {
        FetchRangeParams {
            store_id: store_id.into(),
            root: root.into(),
            retrieval_key: retrieval_key.into(),
            offset: None,
            length,
            capsule: None,
            redirect_depth: None,
            skip_layout: None,
        }
    }

    /// Start the range at `offset` rather than at 0.
    pub fn with_offset(mut self, offset: u64) -> Self {
        self.offset = Some(offset);
        self
    }

    /// Request whole-capsule mode. Capsule range fetch is not yet served — a `true`
    /// here yields
    /// [`ResourceUnavailable`](crate::error::ErrorCode::ResourceUnavailable).
    pub fn with_capsule(mut self, capsule: bool) -> Self {
        self.capsule = Some(capsule);
        self
    }

    /// Echo the redirect budget already consumed, from a `-32008` redirect.
    pub fn with_redirect_depth(mut self, redirect_depth: u64) -> Self {
        self.redirect_depth = Some(redirect_depth);
        self
    }

    /// Ask the holder to omit the resource-scaling layout metadata, because this
    /// client already holds the commitment for this `root`. See
    /// [`skip_layout`](Self::skip_layout).
    pub fn with_skip_layout(mut self, skip_layout: bool) -> Self {
        self.skip_layout = Some(skip_layout);
        self
    }

    /// Whether this request suppresses the resource-scaling layout metadata.
    ///
    /// The single home for the "absent or `false` means SEND the layout" rule. A
    /// serve path that reached for `skip_layout.is_some()` instead would suppress the
    /// layout for a client that had explicitly asked for it — unrecoverable for that
    /// client, since the layout is a decrypt input it cannot obtain any other way on
    /// that stream.
    pub fn suppresses_layout(&self) -> bool {
        self.skip_layout.unwrap_or(false)
    }
}

/// One range frame of a resource: a byte window, plus the per-resource
/// verification metadata that makes the window independently checkable.
///
/// The metadata splits in two by whether it scales with the resource, and the
/// split decides which frames carry it:
///
/// - **The identity set — [`root`](Self::root),
///   [`total_length`](Self::total_length), [`chunk_count`](Self::chunk_count),
///   plus [`chunk_index`](Self::chunk_index) when the window begins on a chunk
///   boundary — rides EVERY frame.** It is fixed-size, so carrying it everywhere
///   costs a bounded number of bytes, and it is what lets a client fetching in
///   parallel from many holders reject a wrong-generation or wrong-layout source
///   the moment a frame arrives, rather than after paying for the whole resource
///   in bandwidth.
/// - **The resource-scaling set — [`chunk_lens`](Self::chunk_lens) and
///   [`inclusion_proof`](Self::inclusion_proof) — rides the first frame, or a
///   paged prologue, once per range stream.** Repeating it per frame would cost
///   proportionally to the resource against a frame budget with no slack; a layout
///   too large to state on one frame is paged instead, each page stamped with the
///   [`chunk_lens_offset`](Self::chunk_lens_offset) it begins at.
///
/// The window is exactly the span the caller requested — never widened.
///
/// # Construction
///
/// This type is [`#[non_exhaustive]`](https://doc.rust-lang.org/reference/attributes/type_system.html):
/// build it with [`data`](Self::data) and the `with_*` setters rather than a struct
/// literal. That is deliberate — the wire form grows as the protocol does, and
/// routing construction through named setters means a future additive field is a
/// PATCH release for every consumer instead of another semver cascade. It also
/// makes the two frame shapes different call chains rather than one call with a
/// pile of `None`s, so a continuation frame cannot accidentally claim a layout it
/// is not stating.
///
/// # Cross-repo contract
///
/// The wire form is **byte-identical** to `dig_nat::mux::RangeFrame`, the
/// streaming implementation of this frame (`SYSTEM.md` → "Canonical DIG-node RPC
/// interface"). Field names, encodings, and the population rule above are pinned
/// against dig-nat's actual output in `tests/nat_wire_mirror.rs`; a change to any
/// of them lands in both crates in the same unit of work or not at all.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct RangeFrame {
    /// The window start offset (echoed).
    pub offset: u64,
    /// This window's byte length.
    pub length: u64,
    /// This window's ciphertext, base64.
    pub bytes: String,
    /// Whether this frame ends the resource.
    pub complete: bool,
    /// The full resource ciphertext length. Part of the fixed-size **identity
    /// set**, so it rides EVERY frame.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub total_length: Option<u64>,
    /// Per-chunk ciphertext lengths of the full resource, in order — the layout a
    /// reader needs before it can decrypt (per-chunk AEAD needs the WHOLE array,
    /// and a reader rejects an array whose sum differs from
    /// [`total_length`](Self::total_length)).
    ///
    /// Resource-scaling, so it rides the first frame or a **paged prologue**, once
    /// per range stream — never repeated on continuation frames. When paged, this
    /// is one page of the array and
    /// [`chunk_lens_offset`](Self::chunk_lens_offset) states the entry it begins
    /// at.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub chunk_lens: Option<Vec<u64>>,
    /// This frame's first chunk index — the pre-existing alias of
    /// [`first_chunk_index`](Self::first_chunk_index), carrying the same value, and
    /// the name dig-nat emits.
    ///
    /// Part of the **identity set**: it rides every frame whose window begins on a
    /// chunk boundary, and is OMITTED (rather than guessed) on a mid-chunk window.
    /// Being fixed-size, it is settable on its own — see
    /// [`with_chunk_index`](Self::with_chunk_index) — precisely so a continuation
    /// frame can state it without dragging along the once-per-stream
    /// [`inclusion_proof`](Self::inclusion_proof).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub chunk_index: Option<u64>,
    /// Whole-resource merkle proof against [`root`](Self::root), base64, relayed
    /// verbatim.
    ///
    /// Resource-scaling, so it rides the first frame or the paged prologue, once
    /// per range stream. A holder MUST NOT repeat it per frame: it is bounded at
    /// 4,096 base64 bytes, which against the frame budget leaves no slack for the
    /// payload the frame exists to carry.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub inclusion_proof: Option<String>,
    /// The chain-anchored root (64-hex) this frame's resource verified against.
    /// Part of the fixed-size **identity set**, so it rides EVERY frame.
    ///
    /// NOT A TRUST ANCHOR BY ITSELF. The client resolves the resource's root from
    /// the URN (chain-anchored) and PINS it before fetching; a peer-declared value
    /// never replaces that pinned root. What this field provides is a
    /// generation-CONSISTENCY check: a frame declaring a root other than the pinned
    /// one is REJECTED and attributed to the offending peer (NC-9 fail-closed). So
    /// a declared root can only ever cause rejection — it can never move the pinned
    /// root, and never makes an unverified frame acceptable.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub root: Option<HexId>,
    /// **RESERVED — not currently derivable; a server MUST NOT emit it.**
    ///
    /// Per-chunk merkle inclusion proofs for the chunks a frame covers. No such
    /// proof exists in the current store format: the generation root's merkle
    /// leaves are per-RESOURCE (a leaf is the SHA-256 of a resource's WHOLE
    /// ciphertext), so a single chunk has no leaf to prove. A client MUST NOT
    /// require this field, and per-range verification instead uses the
    /// whole-resource [`inclusion_proof`](Self::inclusion_proof) together with the
    /// per-frame [`root`](Self::root)/[`chunk_lens`](Self::chunk_lens) metadata.
    ///
    /// Making it derivable requires a per-resource chunk-level commitment in the
    /// store format first (tracked as `dig_ecosystem#1601`). The field is kept in
    /// the wire type, unused, so populating it later is additive (§5.1); each entry
    /// would be an opaque base64 proof blob, since this pure level-00 wire type
    /// MUST NOT depend on the merkle primitive.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub range_proof: Option<Vec<String>>,
    /// The chunk index of the first chunk in this frame (0-based, into the
    /// resource's chunk sequence described by [`chunk_lens`](Self::chunk_lens)).
    ///
    /// Present only when the frame's window begins EXACTLY on a chunk boundary; a
    /// mid-chunk window omits it rather than assert an index the caller's own
    /// alignment check would contradict. The served window is exactly the requested
    /// span — a server MUST NOT widen a range to a chunk boundary — so a frame is
    /// chunk-aligned only when the caller asked for an aligned span.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub first_chunk_index: Option<u64>,
    /// The resource's TOTAL chunk count — how many entries the fully reassembled
    /// [`chunk_lens`](Self::chunk_lens) array has.
    ///
    /// Fixed-size, so it belongs to the **identity set** and rides EVERY frame.
    /// Together with [`root`](Self::root) and
    /// [`total_length`](Self::total_length) it is what lets a reader detect a
    /// wrong-generation or wrong-layout holder on the first frame it receives. It is
    /// also how a reader sizes the array it is paging in, and therefore how it knows
    /// a **paged prologue** is complete: the prologue ends when the reader holds
    /// `chunk_count` entries, which no single page can tell it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub chunk_count: Option<u64>,
    /// The index into the resource's [`chunk_lens`](Self::chunk_lens) array at which
    /// THIS frame's page begins — how a **paged prologue** is located and
    /// reassembled.
    ///
    /// A resource whose layout exceeds the per-frame entry cap cannot state it on
    /// one frame, so the sender pages it: successive frames each carry up to that
    /// many entries, stamped with the offset they start at. A reader places each page
    /// at its offset and holds the whole array once it has
    /// [`chunk_count`](Self::chunk_count) entries.
    ///
    /// Absent means "this frame's `chunk_lens`, if any, begins at entry 0" — the
    /// single-frame layout, which is the shape every pre-0.6.0 producer emits. So an
    /// older frame decodes with exactly its original meaning (§5.1).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub chunk_lens_offset: Option<u64>,
}

impl RangeFrame {
    /// A **data frame**: `length` bytes of base64 ciphertext at `offset`, carrying
    /// no metadata — the bare shape every continuation frame starts from.
    ///
    /// `length` is stated rather than derived because [`bytes`](Self::bytes) is
    /// already base64 on this type, and recovering the raw window length from it
    /// would need a base64 codec this pure level-00 wire crate deliberately does not
    /// depend on. A serve path passes the length it served.
    pub fn data(offset: u64, length: u64, bytes: impl Into<String>) -> Self {
        RangeFrame {
            offset,
            length,
            bytes: bytes.into(),
            complete: false,
            total_length: None,
            chunk_lens: None,
            chunk_index: None,
            inclusion_proof: None,
            root: None,
            range_proof: None,
            first_chunk_index: None,
            chunk_count: None,
            chunk_lens_offset: None,
        }
    }

    /// Mark this as the final frame of the range.
    pub fn with_complete(mut self, complete: bool) -> Self {
        self.complete = complete;
        self
    }

    /// The fixed-size **identity set** every frame of a range carries: the
    /// generation `root` (64-hex) the range is served from, the resource's
    /// ciphertext `total_length`, and its `chunk_count`.
    ///
    /// These three are what let a reader reject a wrong-generation or wrong-layout
    /// holder the moment a frame arrives — which the resource-scaling metadata never
    /// could, since it arrives once. Call this on every frame.
    pub fn with_identity(
        mut self,
        root: impl Into<HexId>,
        total_length: u64,
        chunk_count: u64,
    ) -> Self {
        self.root = Some(root.into());
        self.total_length = Some(total_length);
        self.chunk_count = Some(chunk_count);
        self
    }

    /// State [`chunk_index`](Self::chunk_index) — the chunk this frame's window
    /// begins on — for a chunk-aligned window.
    ///
    /// Separate from [`with_inclusion_proof`](Self::with_inclusion_proof) on purpose:
    /// the index is fixed-size identity metadata that rides every aligned frame,
    /// while the proof is once-per-stream, so binding them together would force a
    /// producer to either repeat a proof it MUST NOT repeat or bypass this API. Omit
    /// the call entirely for a mid-chunk window.
    pub fn with_chunk_index(mut self, chunk_index: u64) -> Self {
        self.chunk_index = Some(chunk_index);
        self
    }

    /// Additionally state [`first_chunk_index`](Self::first_chunk_index), this
    /// crate's v0.4.0 alias of [`chunk_index`](Self::chunk_index).
    ///
    /// Both names carry the same value. dig-nat emits only `chunk_index`, so
    /// [`with_chunk_index`](Self::with_chunk_index) alone is the interoperable
    /// choice; a producer serving readers that expect the newer name states both.
    pub fn with_first_chunk_index(mut self, first_chunk_index: u64) -> Self {
        self.first_chunk_index = Some(first_chunk_index);
        self
    }

    /// One page of the resource's `chunk_lens` array, beginning at entry
    /// `chunk_lens_offset`.
    ///
    /// Call it once with offset `0` for a layout that fits a single frame, or once
    /// per page of a **paged prologue**. A page is only ever useful as part of a
    /// complete set: `chunk_lens` is a decrypt input, and a reader needs all
    /// [`chunk_count`](Self::chunk_count) entries before it can decrypt anything.
    pub fn with_chunk_lens_page(mut self, chunk_lens_offset: u64, chunk_lens: Vec<u64>) -> Self {
        self.chunk_lens_offset = Some(chunk_lens_offset);
        self.chunk_lens = Some(chunk_lens);
        self
    }

    /// The whole-resource merkle inclusion proof against
    /// [`root`](Self::root) (base64, relayed verbatim).
    ///
    /// Resource-scaling: state it on the first frame or the prologue, once per range
    /// stream, never per frame.
    pub fn with_inclusion_proof(mut self, inclusion_proof: impl Into<String>) -> Self {
        self.inclusion_proof = Some(inclusion_proof.into());
        self
    }

    /// State the **RESERVED** [`range_proof`](Self::range_proof) field.
    ///
    /// A server MUST NOT emit it — no per-chunk proof is derivable from the current
    /// store format (see the field's own documentation). The setter exists so the
    /// shape stays constructible for the conformance vectors that pin it, and so no
    /// field of this `#[non_exhaustive]` type is unreachable; it is not a serve-path
    /// call.
    pub fn with_range_proof(mut self, range_proof: Vec<String>) -> Self {
        self.range_proof = Some(range_proof);
        self
    }
}

// ===========================================================================
// dig.getModuleInfo / dig.fetchModuleRange  (PEER — whole-module pull, #1576)
// ===========================================================================

/// Params for [`dig.getModuleInfo`](crate::method::Method::GetModuleInfo) — the
/// handshake a peer reads before range-pulling a whole `.dig` module for
/// `(store, root)`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetModuleInfoParams {
    /// The store launcher id (64-hex, required).
    pub store_id: HexId,
    /// The generation root whose `.dig` module is being pulled (64-hex, required).
    pub root: HexId,
}

/// Result for [`dig.getModuleInfo`](crate::method::Method::GetModuleInfo) — the
/// transfer descriptor of a whole `.dig` module.
///
/// The whole-module blob is content-addressed + immutable (the `.dig` container
/// is byte-identical by construction). [`module_hash`](Self::module_hash) is the
/// content id of the assembled blob; a puller verifies each pulled range against
/// [`chunk_hashes`](Self::chunk_hashes) (per-peer attribution on a multi-source
/// pull) and the fully-assembled blob against `module_hash`, THEN verifies the
/// assembled module against its chain-anchored root before admitting + resharing
/// (NC-9 verified-content-not-safe-content).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ModuleInfo {
    /// The total byte length of the whole `.dig` module blob.
    pub total_size: u64,
    /// The content id of the fully-assembled module blob (64-hex `SHA-256` of the
    /// module bytes). The puller checks the assembled blob against this.
    pub module_hash: HexId,
    /// Per-chunk content hashes (64-hex each) in ascending chunk order, covering
    /// the blob in [`total_size`](Self::total_size)-spanning fixed-size chunks
    /// (the trailing chunk may be short). A puller checks each pulled
    /// [`RangeFrame`] against the covering entries for per-source attribution on a
    /// multi-source pull (a tampered range fails closed before assembly).
    pub chunk_hashes: Vec<HexId>,
    /// Per-chunk byte lengths (in the same order as [`chunk_hashes`](Self::chunk_hashes)).
    /// MUST have the same length as `chunk_hashes` and MUST sum to `total_size`.
    /// A puller uses these to map a fetched byte range to the covering chunk hash(es).
    pub chunk_lens: Vec<u64>,
}

/// Params for [`dig.fetchModuleRange`](crate::method::Method::FetchModuleRange) —
/// a single range frame of the whole `.dig` module blob for `(store, root)`.
///
/// The response reuses [`RangeFrame`]: [`bytes`](RangeFrame::bytes) carries the
/// window of the module blob (base64), [`total_length`](RangeFrame::total_length)
/// echoes the whole-module size on the first frame, and
/// [`complete`](RangeFrame::complete) ends the stream.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct FetchModuleRangeParams {
    /// The store launcher id (64-hex, required).
    pub store_id: HexId,
    /// The generation root whose `.dig` module is being pulled (64-hex, required).
    pub root: HexId,
    /// The range start into the module blob (default 0).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub offset: Option<u64>,
    /// The range length in bytes (> 0; clamped to the window cap).
    pub length: u64,
}

// ===========================================================================
// dig.stage  (CONTROL — loopback / in-process only)
// ===========================================================================

/// Params for [`dig.stage`](crate::method::Method::Stage) — compile a local
/// folder into a capsule `.dig` module in-process.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct StageParams {
    /// The absolute path to the folder to compile.
    pub dir: String,
    /// The target store launcher id (64-hex). Absent ⇒ an ephemeral,
    /// content-derived id (a preview).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub store_id: Option<HexId>,
    /// The store salt (64-hex). Present ⇒ a private store.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub salt: Option<HexId>,
    /// Optional DIGHub-style manifest metadata to embed.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub metadata: Option<serde_json::Value>,
}

/// Result for [`dig.stage`](crate::method::Method::Stage) — the compiled capsule.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct StageResult {
    /// The canonical capsule identity, `storeId:rootHash`.
    pub capsule: String,
    /// The store launcher id (64-hex).
    pub store_id: HexId,
    /// The compiled generation root (64-hex).
    pub root: HexId,
    /// The filesystem path to the compiled `.dig` module.
    pub module_path: String,
    /// The module size in bytes.
    pub size: u64,
    /// The `chia://storeId:rootHash/` content address.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub content_address: Option<String>,
    /// The relative paths compiled into the capsule.
    #[serde(default)]
    pub files: Vec<String>,
    /// Whether this is an ephemeral preview (not advancing a real store).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub ephemeral: Option<bool>,
}

// ===========================================================================
// cache.*  (CONTROL — loopback / in-process only)
// ===========================================================================

/// Result for [`cache.getConfig`](crate::method::Method::CacheGetConfig).
///
/// The canonical field name for the cache path is `cache_dir` everywhere (the
/// shell's historical `dir` is unified onto this name).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CacheConfig {
    /// The on-disk cache size cap in bytes (floored at 64 MiB).
    pub cap_bytes: u64,
    /// The bytes currently used.
    pub used_bytes: u64,
    /// The effective resolved cache directory.
    pub cache_dir: String,
    /// Whether that directory is the canonical shared location (vs a
    /// process-private fallback).
    pub shared: bool,
}

/// Params for [`cache.setCapBytes`](crate::method::Method::CacheSetCapBytes).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct SetCapBytesParams {
    /// The requested cap in bytes (floored at 64 MiB by the node).
    pub cap_bytes: u64,
}

/// Result for [`cache.setCapBytes`](crate::method::Method::CacheSetCapBytes).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct SetCapBytesResult {
    /// The effective cap after flooring.
    pub cap_bytes: u64,
}

/// One durable cached-module entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CachedCapsule {
    /// The canonical capsule identity, `storeId:rootHash`.
    pub capsule: String,
    /// The store launcher id (64-hex).
    pub store_id: HexId,
    /// The generation root (64-hex).
    pub root: HexId,
    /// The module size in bytes.
    pub size_bytes: u64,
    /// When the module was last used (unix ms).
    pub last_used_unix_ms: u64,
}

/// Result for [`cache.listCached`](crate::method::Method::CacheListCached).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CachedList {
    /// The cached capsules.
    pub cached: Vec<CachedCapsule>,
}

/// Params for a capsule-keyed cache op
/// ([`cache.removeCached`](crate::method::Method::CacheRemoveCached),
/// [`cache.fetchAndCache`](crate::method::Method::CacheFetchAndCache)).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CapsuleKey {
    /// The store launcher id (64-hex).
    pub store_id: HexId,
    /// The generation root (64-hex).
    pub root: HexId,
}

/// Result for [`cache.removeCached`](crate::method::Method::CacheRemoveCached).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct RemoveCachedResult {
    /// Whether an entry was removed.
    pub removed: bool,
}

/// Result for [`cache.fetchAndCache`](crate::method::Method::CacheFetchAndCache).
///
/// A failed fetch is reported in-band (`status = "failed"` + `message`) so the
/// caller can show it without treating it as a transport error.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct FetchAndCacheResult {
    /// `"cached"`, `"already_cached"`, or `"failed"`.
    pub status: String,
    /// The fetched module size in bytes (on success).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub size_bytes: Option<u64>,
    /// The served generation root (64-hex, on success).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub served_root: Option<HexId>,
    /// The failure message (on `status = "failed"`).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub message: Option<String>,
}

// ===========================================================================
// control.peerStatus  (CONTROL — loopback / in-process only)
// ===========================================================================

/// Result for [`control.peerStatus`](crate::method::Method::ControlPeerStatus) —
/// a snapshot of the node's L7 peer network. Always safe to call; reports
/// `running: false` on the FFI path.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeerStatusSnapshot {
    /// Whether a peer network is currently active.
    pub running: bool,
    /// This node's `peer_id` (64-hex), if a peer network is running.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub peer_id: Option<HexId>,
    /// The DIG network id.
    pub network_id: String,
    /// The relay reservation posture.
    pub relay: RelayStatus,
    /// The number of currently connected peers.
    pub connected_peers: u64,
    /// The last peer-network error, if any.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub last_error: Option<String>,
}

// ===========================================================================
// cache.stats  (CONTROL — loopback / in-process only)
// ===========================================================================

/// The decoded-content cache hit/miss counters carried in
/// [`CacheStats`](CacheStats::content_cache).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ContentCacheCounters {
    /// Session decoded-content cache hits.
    pub hits: u64,
    /// Session decoded-content cache misses.
    pub misses: u64,
}

/// Result for [`cache.stats`](crate::method::Method::CacheStats) — cache
/// telemetry beside [`cache.getConfig`](crate::method::Method::CacheGetConfig):
/// the reserved cap + live usage, the cached-capsule count + total on-disk
/// bytes, and the session eviction + content-cache counters.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CacheStats {
    /// The on-disk cache size cap in bytes.
    pub cap_bytes: u64,
    /// The bytes currently used on disk.
    pub used_bytes: u64,
    /// The number of durable cached capsules.
    pub entry_count: u64,
    /// The total on-disk bytes across the cached capsules.
    pub total_bytes: u64,
    /// Capsules evicted this session.
    pub evicted_count: u64,
    /// Bytes evicted this session.
    pub evicted_bytes: u64,
    /// The decoded-content cache hit/miss counters.
    pub content_cache: ContentCacheCounters,
}

// ===========================================================================
// control.subscribe / control.unsubscribe / control.listSubscriptions
// (CONTROL — loopback / in-process only)
// ===========================================================================

/// Params for [`control.subscribe`](crate::method::Method::ControlSubscribe) and
/// [`control.unsubscribe`](crate::method::Method::ControlUnsubscribe).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct SubscribeParams {
    /// The store launcher id to (un)subscribe (64-hex).
    pub store_id: HexId,
}

/// Result for [`control.subscribe`](crate::method::Method::ControlSubscribe).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct SubscribeResult {
    /// Always `true` — the store is subscribed after this call.
    pub subscribed: bool,
    /// Whether this call ADDED the subscription (`false` ⇒ already subscribed).
    pub added: bool,
    /// The canonical persisted store id (trimmed + lower-cased, 64-hex).
    pub store_id: HexId,
}

/// Result for [`control.unsubscribe`](crate::method::Method::ControlUnsubscribe).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct UnsubscribeResult {
    /// Always `false` — the store is not subscribed after this call.
    pub subscribed: bool,
    /// Whether this call REMOVED a subscription (`false` ⇒ was not subscribed).
    pub removed: bool,
    /// The canonical persisted store id (trimmed + lower-cased, 64-hex).
    pub store_id: HexId,
}

/// Result for
/// [`control.listSubscriptions`](crate::method::Method::ControlListSubscriptions).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct SubscriptionsList {
    /// The persisted subscribed store ids (64-hex each).
    pub subscriptions: Vec<HexId>,
    /// The subscription count (`subscriptions.len()`).
    pub count: u64,
}

// ===========================================================================
// control.peers.connect / control.peers.disconnect
// (CONTROL — loopback / in-process only)
// ===========================================================================

/// Params for [`control.peers.connect`](crate::method::Method::ControlPeersConnect)
/// and [`control.peers.disconnect`](crate::method::Method::ControlPeersDisconnect).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeerConnectParams {
    /// The peer to dial/drop — a dialable address, or a known peer's `peer_id`
    /// (64-hex) to resolve an already-connected peer.
    pub peer: String,
}

/// Result for
/// [`control.peers.connect`](crate::method::Method::ControlPeersConnect).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeerConnectResult {
    /// Always `true` on success — the peer is a counted, connected pool member.
    pub connected: bool,
    /// The connected peer's stable `peer_id` (64-hex).
    pub peer_id: HexId,
}

/// Result for
/// [`control.peers.disconnect`](crate::method::Method::ControlPeersDisconnect).
///
/// Idempotent: disconnecting a peer that is not connected succeeds as a no-op.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeerDisconnectResult {
    /// Always `true` — the peer is not in the pool after this call.
    pub disconnected: bool,
    /// The dropped peer's `peer_id` (trimmed + lower-cased, 64-hex).
    pub peer_id: HexId,
}

// ===========================================================================
// dig.listRewardDistributors / dig.getRewardProverStatus / dig.getRewardDistributor
// (CONTROL — loopback / in-process only; dig-rewards-coin SPEC.md §2.3 / §2.6)
// ===========================================================================

/// The always-on reward prover loop's state — SPEC §2.3, the closed set.
///
/// `#[non_exhaustive]`-equivalent by convention rather than attribute (the SPEC
/// pins this to an exact nine-member set; a variant needs a SPEC amendment, not
/// a semver-additive appendix). Deserialization is fail-closed: no
/// `#[serde(other)]` catch-all and no `Default` impl, so an unknown wire string
/// (a newer node, a typo) is a hard parse error rather than a silently-coerced
/// state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub enum ProverState {
    /// No distributor assigned; the loop is parked.
    Idle,
    /// A cycle is in progress.
    Running,
    /// The local capsule/store copy this cycle needs is missing.
    LocalCopyMissing,
    /// The chain source (full node / peer) is unreachable this cycle.
    ChainSourceUnavailable,
    /// The distributor is unfunded — no reserve to pay a cycle out of.
    Unfunded,
    /// The fee budget for entry-set writes is exhausted for this cycle.
    FeeBudgetExhausted,
    /// The entry set is at capacity; no further entries can be added.
    EntrySetFull,
    /// Paused by an operator action.
    Paused,
    /// Stopped; the loop will not run again without an explicit restart.
    Stopped,
}

/// The reward prover loop's running counters — SPEC §2.3.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ProverCounters {
    /// Distinct mirrors observed across all cycles.
    pub mirrors_seen: u64,
    /// Ranged capsule challenges issued.
    pub challenges_issued: u64,
    /// Challenges that passed verification.
    pub challenges_passed: u64,
    /// Challenges that failed verification.
    pub challenges_failed: u64,
    /// Entry-set entries added.
    pub entries_added: u64,
    /// Entry-set entries removed.
    pub entries_removed: u64,
    /// The current entry-set size.
    pub entry_count: u64,
    /// The distributor's reserve, in base units.
    pub reserve_base_units: u64,
    /// Total paid out over the loop's lifetime, in base units.
    pub total_paid_out_base_units: u64,
}

/// One distributor's prover-loop status — SPEC §2.3 / §2.4.
///
/// # No health boolean, no pre-computed staleness
///
/// This type carries no `healthy`/`ok`/`up`/`running`/`stale` field and no
/// `seconds_since_last_run`. SPEC §2.4: a wedged loop cannot report its own
/// wedging — a boolean the writer sets on every successful cycle reads `true`
/// forever after exactly the failure it exists to reveal, because the write
/// that would flip it never runs. The reader derives staleness itself from
/// [`last_cycle_completed_at`](Self::last_cycle_completed_at) /
/// [`next_cycle_due_at`](Self::next_cycle_due_at) against
/// [`observed_at`](Self::observed_at) and its own clock. Contrast
/// [`GetRewardDistributorResult::entry_set_stale`], which IS a boolean — it is
/// permitted there because it is computed from the singleton's on-chain spend
/// history by the responder at read time, not self-reported by the writer this
/// type describes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct RewardProverStatus {
    /// The distributor singleton's launcher id (64-hex).
    pub launcher_id: HexId,
    /// The backing store's launcher id (64-hex).
    pub store_id: HexId,
    /// The store's current generation root (64-hex).
    pub root: HexId,
    /// The loop's current state.
    pub prover_state: ProverState,
    /// Unix seconds the loop entered `prover_state`.
    pub prover_state_since: u64,
    /// Unix seconds the current/most recent cycle started, if any has run.
    pub last_cycle_started_at: Option<u64>,
    /// Unix seconds the most recent cycle completed, if any has completed.
    pub last_cycle_completed_at: Option<u64>,
    /// Unix seconds the next cycle is scheduled, if the loop is scheduling one.
    pub next_cycle_due_at: Option<u64>,
    /// Unix seconds of the most recent entry-set write, if any.
    pub last_entry_write_at: Option<u64>,
    /// Consecutive cycle failures (resets to 0 on a completed cycle).
    pub consecutive_cycle_failures: u32,
    /// Entry writes queued but not yet committed.
    pub pending_entry_writes: u32,
    /// Unix seconds this status was assembled (the reader's staleness anchor).
    pub observed_at: u64,
    /// The loop's running counters.
    pub counters: ProverCounters,
}

/// Params for
/// [`dig.getRewardProverStatus`](crate::method::Method::GetRewardProverStatus).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetRewardProverStatusParams {
    /// Restrict to one distributor's launcher id (64-hex). Absent ⇒ every
    /// distributor this node runs a prover loop for.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub launcher_id: Option<HexId>,
}

/// Result for
/// [`dig.getRewardProverStatus`](crate::method::Method::GetRewardProverStatus).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetRewardProverStatusResult {
    /// One entry per prover loop this node runs, in no particular order.
    pub statuses: Vec<RewardProverStatus>,
}

/// A minimal distributor reference — SPEC §2.6.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct RewardDistributorRef {
    /// The distributor singleton's launcher id (64-hex).
    pub launcher_id: HexId,
    /// The backing store's launcher id (64-hex).
    pub store_id: HexId,
    /// The store's current generation root (64-hex).
    pub root: HexId,
}

/// Result for
/// [`dig.listRewardDistributors`](crate::method::Method::ListRewardDistributors)
/// — SPEC §2.6: "the distributors this node funds, and the distributors this
/// node has a claim to as a mirror".
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ListRewardDistributorsResult {
    /// Distributors this node funds (the reserve is this node's).
    pub funded: Vec<RewardDistributorRef>,
    /// Distributors this node has a claim to as a mirror, but does not fund.
    pub claimable: Vec<RewardDistributorRef>,
}

/// Params for
/// [`dig.getRewardDistributor`](crate::method::Method::GetRewardDistributor).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetRewardDistributorParams {
    /// The distributor singleton's launcher id (64-hex, required).
    pub launcher_id: HexId,
}

/// Result for
/// [`dig.getRewardDistributor`](crate::method::Method::GetRewardDistributor) —
/// SPEC §2.6, third method: chain-derived distributor state only, never the
/// local prover loop's own state (see [`RewardProverStatus`] for that).
///
/// # `entry_set_stale` lives HERE, never on `RewardProverStatus`
///
/// This is the one boolean in the reward-distributor surface, and it belongs
/// here specifically: per SPEC §12.4 it is computed by the responder from the
/// distributor singleton's on-chain spend history at read time, not
/// self-reported by a possibly-wedged writer. Copying it onto
/// [`RewardProverStatus`] would reintroduce exactly the self-reported-health
/// failure that type's doc comment forbids — see that type for the full
/// argument.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GetRewardDistributorResult {
    /// The distributor singleton's launcher id (64-hex).
    pub launcher_id: HexId,
    /// The backing store's launcher id (64-hex).
    pub store_id: HexId,
    /// The store's current generation root (64-hex).
    pub root: HexId,
    /// The payout epoch length, in seconds.
    pub epoch_seconds: u64,
    /// Unix seconds the first epoch started.
    pub first_epoch_start: u64,
    /// The reserve threshold, in base units, that triggers a payout.
    pub payout_threshold: u64,
    /// The distributor's fee, in basis points.
    pub fee_bps: u16,
    /// The share of a clawed-back commitment the committer recovers, in basis
    /// points — SPEC §7.5. `withdrawal_share_bps = 9000` means a clawback
    /// returns 90% of the committed value; the remaining 10% is forfeited to
    /// the reserve as a deterrent against the funder (SPEC §7.5 clause 1: it
    /// is priced correctly and is **not** compensation to induced mirrors).
    /// Curried at launch and immutable, same as [`Self::fee_bps`] beside it.
    pub withdrawal_share_bps: u16,
    /// The current reserve, in base units.
    pub reserve_base_units: u64,
    /// The current entry-set size.
    pub entry_count: u64,
    /// The current epoch index (`0`-based from `first_epoch_start`).
    pub current_distributor_epoch: u64,
    /// Unix seconds of the most recent entry-set write on chain, if any.
    /// `None` together with a non-zero `reserve_base_units` **implies
    /// stale**: an entry set that has never been written is maximally
    /// stale, not unknown, and a consumer MUST NOT render it as blank or
    /// "unknown" (SPEC §2.4 cl. 1 — silence is not an acceptable
    /// representation of "not distributing").
    pub last_entry_write_at: Option<u64>,
    /// `true` when the entry set has not changed in
    /// `STALE_ENTRY_SET_SECONDS = 172_800` (48 h) **while the reserve is
    /// non-zero** — SPEC §12.4. A drained distributor with a frozen entry set
    /// is not stale, it is [`Unfunded`](crate::types::ProverState::Unfunded);
    /// the non-zero-reserve conjunct exists to keep the two states distinct.
    /// The responder computes this at read time from the singleton's own
    /// on-chain spend history — it is not self-reported, so a wedged prover
    /// cannot fake it. See the type doc for why this boolean is safe here and
    /// forbidden on [`RewardProverStatus`].
    pub entry_set_stale: bool,
    /// Unix seconds this result was assembled.
    pub observed_at: u64,
}

/// Params for
/// [`dig.listRewardDistributorCommitments`](crate::method::Method::ListRewardDistributorCommitments).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ListRewardDistributorCommitmentsParams {
    /// The distributor singleton's launcher id (64-hex, required).
    pub launcher_id: HexId,
}

/// One clawback commitment slot for a distributor epoch — SPEC §7.4 clause 5.
///
/// # `recoverable_base_units` is NOT `rewards_base_units`
///
/// Committed is not recoverable: SPEC §7.4 clause 4 / §7.5 return only
/// `withdrawal_share_bps / 10000` of the committed value on clawback (the
/// remainder is forfeited to the reserve — see
/// [`GetRewardDistributorResult::withdrawal_share_bps`]). Reporting only
/// `rewards_base_units` and letting a caller label it "recoverable" would
/// overstate every clawback by the forfeit fraction — exactly the
/// one-balance-figure money-honesty failure SPEC §7.4 clause 5 forbids,
/// relocated from a single total into a single per-slot figure. So the
/// **responder** must compute `recoverable_base_units` itself, with integer
/// arithmetic in the order `rewards_base_units * withdrawal_share_bps /
/// 10_000` — multiply then divide, no floats, truncated (never rounded up:
/// rounding up would promise money the chain will not return). This type
/// does not enforce that computation — see below.
///
/// Only the holder of the key for `clawback_puzzle_hash` may claw this slot
/// back — not an operator role, not the manager singleton, and not the
/// launcher (SPEC §7.4 clause 3). This field is the proof of entitlement; a
/// reader must not mistake it for a display label.
///
/// This type does not enforce any of the above: `recoverable_base_units` is
/// a bare `pub u64` with no constructor or validation. The **responder**
/// must compute it in the order described; nothing here checks that it did.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct RewardDistributorCommitment {
    /// The epoch this commitment slot funds.
    pub epoch_start: u64,
    /// The chain's `clawback_ph` (SPEC / `chia-sdk-types`
    /// `RewardDistributorCommitmentSlotValue`): the puzzle hash whose key
    /// holder alone may claw this slot back.
    pub clawback_puzzle_hash: HexId,
    /// The committed amount, in base units.
    pub rewards_base_units: u64,
    /// The amount actually recoverable on clawback, in base units —
    /// `rewards_base_units * withdrawal_share_bps / 10_000`, integer
    /// arithmetic, truncated down. See the type doc for why this must never
    /// be derived by a caller from `rewards_base_units` alone.
    ///
    /// This is share arithmetic only. It is **NOT an eligibility claim**: it
    /// says what fraction of the slot would return, not that the caller may
    /// claw it back. Entitlement is key-holding against
    /// `clawback_puzzle_hash` and nothing else (SPEC §7.4 cl. 3).
    pub recoverable_base_units: u64,
}

/// Result for
/// [`dig.listRewardDistributorCommitments`](crate::method::Method::ListRewardDistributorCommitments)
/// — SPEC §7.4 clause 5: per-epoch commitment slots, never a single balance
/// figure.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ListRewardDistributorCommitmentsResult {
    /// The distributor singleton's launcher id (64-hex).
    pub launcher_id: HexId,
    /// The curried launch constant, echoed so a caller's row math (dividing
    /// `recoverable_base_units` by `rewards_base_units`) is auditable against
    /// the value that actually governs it. A responder MUST use this echoed
    /// value — never a compiled-in constant — when computing each row's
    /// `recoverable_base_units`, because the share is a launch-curried,
    /// per-distributor value that differs across distributors. A conforming
    /// responder MUST NOT emit a value above `10_000`.
    pub withdrawal_share_bps: u16,
    /// The payout epoch length, in seconds — a launch-curried, immutable
    /// distributor constant, echoed here so a caller can compute an epoch's
    /// end (`epoch_start + epoch_seconds`) or place a commitment on a
    /// calendar without a second `dig.getRewardDistributor` call. This is
    /// per-distributor and defaulted, not a fixed 7-day value, so it must
    /// not be hardcoded.
    pub epoch_seconds: u64,
    /// One entry per commitment slot. Empty is legitimate: a distributor
    /// funded only via `AddIncentives` has no clawback-eligible slots at all
    /// — an irrevocable donation, not an error.
    pub commitments: Vec<RewardDistributorCommitment>,
    /// Unix seconds this result was assembled.
    pub observed_at: u64,
}

// ===========================================================================
// dig.health / dig.methods / rpc.discover  (discovery)
// ===========================================================================

/// Result for [`dig.health`](crate::method::Method::Health) — liveness + a
/// capability summary.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct Health {
    /// Liveness — `"ok"` when the node can serve.
    pub status: String,
    /// The node's software version.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub version: Option<String>,
    /// The DIG network id the node serves.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub network_id: Option<String>,
    /// The method names this node implements (its profile).
    #[serde(default)]
    pub methods: Vec<String>,
}

/// Result for [`dig.methods`](crate::method::Method::Methods) — the method names
/// this node implements (agent self-describe).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct Methods {
    /// The implemented method names.
    pub methods: Vec<String>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    /// **Proves:** `ContentChunk` round-trips a node-profile window (no
    /// network-profile fields) without inventing keys.
    /// **Catches:** a missing `skip_serializing_if` that would leak `null`
    /// network-profile fields onto the node profile.
    #[test]
    fn content_chunk_node_profile_is_lean() {
        let c = ContentChunk {
            ciphertext: "AAA=".into(),
            root: "ab".repeat(32),
            complete: false,
            next_offset: Some(3_145_728),
            inclusion_proof: Some("cHJvb2Y=".into()),
            chunk_lens: Some(vec![10, 20]),
            source: Some("local".into()),
            total_length: None,
            length: None,
            offset: None,
            program_hash: None,
        };
        let v = serde_json::to_value(&c).unwrap();
        assert_eq!(v["source"], "local");
        assert!(
            v.get("total_length").is_none(),
            "node profile must omit total_length"
        );
        assert!(v.get("program_hash").is_none());
        assert_eq!(serde_json::from_value::<ContentChunk>(v).unwrap(), c);
    }

    /// **Proves:** the network-profile fields serialize when present.
    #[test]
    fn content_chunk_network_profile_carries_extras() {
        let c = ContentChunk {
            ciphertext: "AAA=".into(),
            root: "cd".repeat(32),
            complete: true,
            next_offset: None,
            inclusion_proof: None,
            chunk_lens: None,
            source: None,
            total_length: Some(100),
            length: Some(100),
            offset: Some(0),
            program_hash: Some("ef".repeat(32)),
        };
        let v = serde_json::to_value(&c).unwrap();
        assert_eq!(v["total_length"], 100);
        assert_eq!(v["length"], 100);
        assert!(v.get("source").is_none());
    }

    /// **Proves:** the untagged `Inventory` picks `ForStore` vs `AllStores` by
    /// shape.
    /// **Catches:** a lost `#[serde(untagged)]` that would tag the variant.
    #[test]
    fn inventory_untagged_by_shape() {
        let for_store = Inventory::ForStore {
            store_id: "ab".repeat(32),
            roots: vec!["cd".repeat(32)],
        };
        let s = serde_json::to_string(&for_store).unwrap();
        assert!(s.contains("\"roots\""));
        assert!(!s.contains("ForStore"));
        assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), for_store);

        let all = Inventory::AllStores {
            stores: vec!["ef".repeat(32)],
        };
        let s = serde_json::to_string(&all).unwrap();
        assert!(s.contains("\"stores\""));
        assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), all);
    }

    /// **Proves:** `RedirectInfo` serializes the full redirect payload the
    /// `-32008` envelope carries.
    #[test]
    fn redirect_info_shape() {
        let r = RedirectInfo {
            content: ContentRef {
                store_id: "ab".repeat(32),
                root: Some("cd".repeat(32)),
                retrieval_key: Some("ef".repeat(32)),
            },
            providers: vec![Provider {
                peer_id: "12".repeat(32),
                addresses: vec![PeerAddress {
                    host: "::1".into(),
                    port: 9444,
                    kind: "direct".into(),
                }],
            }],
            redirect_depth: 1,
            max_redirects: 4,
        };
        let v = serde_json::to_value(&r).unwrap();
        assert_eq!(v["redirect_depth"], 1);
        assert_eq!(v["max_redirects"], 4);
        assert_eq!(v["providers"][0]["addresses"][0]["host"], "::1");
        assert_eq!(serde_json::from_value::<RedirectInfo>(v).unwrap(), r);
    }

    /// **Proves:** `cache.stats` models the live dig-node result field-for-field
    /// (the nested `content_cache{hits,misses}` object included).
    /// **Catches:** a drift from the node's `cache.stats` wire shape (#1075).
    #[test]
    fn cache_stats_wire_shape() {
        let s = CacheStats {
            cap_bytes: 1 << 30,
            used_bytes: 2048,
            entry_count: 3,
            total_bytes: 2048,
            evicted_count: 1,
            evicted_bytes: 512,
            content_cache: ContentCacheCounters { hits: 7, misses: 2 },
        };
        let v = serde_json::to_value(s).unwrap();
        assert_eq!(v["cap_bytes"], 1 << 30);
        assert_eq!(v["entry_count"], 3);
        assert_eq!(v["content_cache"]["hits"], 7);
        assert_eq!(v["content_cache"]["misses"], 2);
        assert_eq!(serde_json::from_value::<CacheStats>(v).unwrap(), s);
    }

    /// **Proves:** the subscription-management results carry the exact
    /// `{subscribed, added|removed, store_id}` / `{subscriptions, count}` shapes
    /// the live node returns.
    #[test]
    fn subscription_result_shapes() {
        let sub = SubscribeResult {
            subscribed: true,
            added: true,
            store_id: "ab".repeat(32),
        };
        let v = serde_json::to_value(&sub).unwrap();
        assert_eq!(v["subscribed"], true);
        assert_eq!(v["added"], true);
        assert_eq!(serde_json::from_value::<SubscribeResult>(v).unwrap(), sub);

        let unsub = UnsubscribeResult {
            subscribed: false,
            removed: true,
            store_id: "cd".repeat(32),
        };
        let v = serde_json::to_value(&unsub).unwrap();
        assert_eq!(v["subscribed"], false);
        assert_eq!(v["removed"], true);
        assert_eq!(
            serde_json::from_value::<UnsubscribeResult>(v).unwrap(),
            unsub
        );

        let list = SubscriptionsList {
            subscriptions: vec!["ef".repeat(32)],
            count: 1,
        };
        let v = serde_json::to_value(&list).unwrap();
        assert_eq!(v["count"], 1);
        assert_eq!(
            serde_json::from_value::<SubscriptionsList>(v).unwrap(),
            list
        );
    }

    /// **Proves:** `ModuleInfo` carries `chunk_lens` covering every chunk, and
    /// round-trips with unknown future fields.
    /// **Catches:** a missing `chunk_lens` field that would leave a puller unable
    /// to map a fetched byte range to its covering chunk hash.
    /// **Invariants enforced by docs:** `chunk_lens` must have the same length as
    /// `chunk_hashes` and must sum to `total_size`.
    #[test]
    fn module_info_chunk_lens_shape() {
        let info = ModuleInfo {
            total_size: 1024,
            module_hash: "ab".repeat(32),
            chunk_hashes: vec!["cd".repeat(32), "ef".repeat(32)],
            chunk_lens: vec![512, 512],
        };
        let v = serde_json::to_value(&info).unwrap();
        assert_eq!(v["total_size"], 1024);
        assert_eq!(v["chunk_hashes"].as_array().unwrap().len(), 2);
        assert_eq!(v["chunk_lens"].as_array().unwrap().len(), 2);
        assert_eq!(v["chunk_lens"][0], 512);
        assert_eq!(v["chunk_lens"][1], 512);
        assert_eq!(serde_json::from_value::<ModuleInfo>(v).unwrap(), info);
    }

    /// **Proves:** `ModuleInfo` deserialization REJECTS missing `chunk_lens` field.
    /// This is a REQUIRED field (not optional) — omitting it from the wire is a
    /// protocol violation and must fail-closed.
    #[test]
    fn module_info_rejects_missing_chunk_lens() {
        let json_str = r#"{"total_size": 2048, "module_hash": "1122334455667788990011223344556677889900112233445566778899001122", "chunk_hashes": []}"#;
        let result: Result<ModuleInfo, _> = serde_json::from_str(json_str);
        assert!(
            result.is_err(),
            "ModuleInfo must reject JSON missing the required chunk_lens field"
        );
        let err = result.unwrap_err();
        assert!(
            err.to_string().contains("chunk_lens"),
            "error message should mention chunk_lens: {}",
            err
        );
    }

    /// **Proves:** the peer connect/disconnect params + results round-trip and
    /// match the node's `{connected|disconnected, peer_id}` shapes.
    #[test]
    fn peer_connect_disconnect_shapes() {
        let p = PeerConnectParams {
            peer: "12".repeat(32),
        };
        let v = serde_json::to_value(&p).unwrap();
        assert_eq!(serde_json::from_value::<PeerConnectParams>(v).unwrap(), p);

        let c = PeerConnectResult {
            connected: true,
            peer_id: "12".repeat(32),
        };
        let v = serde_json::to_value(&c).unwrap();
        assert_eq!(v["connected"], true);
        assert_eq!(serde_json::from_value::<PeerConnectResult>(v).unwrap(), c);

        let d = PeerDisconnectResult {
            disconnected: true,
            peer_id: "34".repeat(32),
        };
        let v = serde_json::to_value(&d).unwrap();
        assert_eq!(v["disconnected"], true);
        assert_eq!(
            serde_json::from_value::<PeerDisconnectResult>(v).unwrap(),
            d
        );
    }

    /// **Proves:** `cache.getConfig` uses the canonical `cache_dir` field name.
    /// **Catches:** a regression to the shell's historical `dir` name.
    #[test]
    fn cache_config_field_name_is_cache_dir() {
        let c = CacheConfig {
            cap_bytes: 1 << 30,
            used_bytes: 0,
            cache_dir: "/var/cache/dig".into(),
            shared: true,
        };
        let v = serde_json::to_value(&c).unwrap();
        assert!(v.get("cache_dir").is_some());
        assert!(v.get("dir").is_none(), "must not use the legacy `dir` name");
    }

    /// **Proves:** an OLDER client's `dig.getAvailability` params — written
    /// before the hop budget existed — still deserialize, and read as a fresh,
    /// unhopped ask.
    /// **Catches:** a `redirect_depth` declared as a required `u64`, which
    /// rejects exactly these params with `missing field redirect_depth` and would
    /// make every pre-0.8 caller's ask a parse error at the peer boundary.
    /// **Guarded by:** the field's `Option` TYPE. `serde`'s derive already reads a
    /// missing `Option` field as `None`, so the `#[serde(default)]` beside it is
    /// parity with the sibling params types rather than the live guard — removing
    /// it alone leaves this test green (mutant-tested). Do not cite the attribute
    /// as the thing that keeps older clients working.
    #[test]
    fn get_availability_params_accepts_an_older_clients_params() {
        let older = json!({
            "items": [ { "store_id": "ab".repeat(32) } ]
        });
        let p: GetAvailabilityParams = serde_json::from_value(older).unwrap();
        assert_eq!(p.items.len(), 1);
        assert_eq!(p.redirect_depth, None, "an absent budget stays absent");
        assert_eq!(p.hops_consumed(), 0, "absent means zero hops consumed");
    }

    /// **Proves:** a hop-zero ask serializes to exactly the pre-0.8 bytes — the
    /// `redirect_depth` key is absent, not `null`.
    /// **Catches:** a bare `#[serde(default)]` without `skip_serializing_if`,
    /// which would add `"redirect_depth": null` to every existing caller's
    /// frame and change the wire for callers that never opted in.
    #[test]
    fn get_availability_params_omits_an_absent_hop_budget() {
        let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
            store_id: "ab".repeat(32),
            root: None,
            retrieval_key: None,
        }]);
        let v = serde_json::to_value(&p).unwrap();
        let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
        assert_eq!(keys, vec!["items"], "hop-zero params carry only `items`");
    }

    /// **Proves:** a hopped ask round-trips its budget under the `redirect_depth`
    /// key, and reads back through `hops_consumed`.
    #[test]
    fn get_availability_params_round_trips_the_hop_budget() {
        let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
            store_id: "cd".repeat(32),
            root: Some("ef".repeat(32)),
            retrieval_key: None,
        }])
        .with_redirect_depth(2);
        let v = serde_json::to_value(&p).unwrap();
        assert_eq!(v["redirect_depth"], 2);
        assert_eq!(p.hops_consumed(), 2);
        assert_eq!(
            serde_json::from_value::<GetAvailabilityParams>(v).unwrap(),
            p
        );
    }

    /// **Proves:** an older client params object — written before ANY of the
    /// recursive-ask fields existed — still deserializes, and every new field reads
    /// as its documented absent value.
    /// **Catches:** any of the three declared as required, which would turn every
    /// pre-0.9 caller ask into `missing field` at the peer boundary.
    #[test]
    fn get_availability_params_accepts_a_client_older_than_the_recursive_ask() {
        let older = json!({ "items": [ { "store_id": "ab".repeat(32) } ] });
        let p: GetAvailabilityParams = serde_json::from_value(older).unwrap();

        assert_eq!(p.budget_ms(), None, "absent budget_ms means unbudgeted");
        assert_eq!(p.ask_id(), None, "absent ask_id means dedup opted out");
        assert_eq!(p.hops_consumed(), 0);
    }

    /// **Proves:** a params object carrying no recursive-ask fields serializes to
    /// exactly the pre-0.9 bytes — the three new keys are ABSENT, not `null`.
    /// **Catches:** a bare `#[serde(default)]` without `skip_serializing_if`, which
    /// would add `"budget_ms": null` and `"ask_id": null` to the frame of every
    /// caller that never opted in.
    #[test]
    fn get_availability_params_omits_absent_recursive_ask_fields() {
        let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
            store_id: "ab".repeat(32),
            root: None,
            retrieval_key: None,
        }]);
        let v = serde_json::to_value(&p).unwrap();
        let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
        assert_eq!(keys, vec!["items"], "a plain ask carries only `items`");
    }

    /// **Proves:** the time budget and the hop budget are two INDEPENDENT fields
    /// under two distinct keys, each round-tripping its own value.
    /// **Catches:** the shape defect this addition exists to prevent — folding the
    /// time budget into `redirect_depth`. The fixture sets them to DIFFERENT values
    /// (2 hops, 9000 ms) precisely so a single backing integer cannot satisfy both
    /// assertions; equal values would pass under either shape.
    #[test]
    fn the_time_budget_is_a_separate_field_from_the_hop_budget() {
        let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
            store_id: "cd".repeat(32),
            root: None,
            retrieval_key: None,
        }])
        .with_redirect_depth(2)
        .with_budget_ms(9_000);

        let v = serde_json::to_value(&p).unwrap();
        assert_eq!(v["redirect_depth"], 2, "hops counted UP from zero");
        assert_eq!(v["budget_ms"], 9_000, "milliseconds counted DOWN to zero");
        assert_eq!(p.hops_consumed(), 2);
        assert_eq!(p.budget_ms(), Some(9_000));
        assert_eq!(
            serde_json::from_value::<GetAvailabilityParams>(v).unwrap(),
            p
        );
    }

    /// **Proves:** a zero time budget survives the wire as `Some(0)` and is NOT
    /// erased into `None`.
    /// **Catches:** a `skip_serializing_if` written over the VALUE rather than the
    /// Option (`is_zero`-style), which would make "you have no time left, do not ask
    /// onward" indistinguishable from "unbudgeted, use your own policy" — exactly
    /// inverting the field on the one hop where it matters most.
    #[test]
    fn a_zero_time_budget_is_not_the_same_as_an_absent_one() {
        let exhausted = GetAvailabilityParams::new(vec![]).with_budget_ms(0);
        let v = serde_json::to_value(&exhausted).unwrap();

        assert_eq!(v["budget_ms"], 0, "an exhausted budget stays on the wire");
        assert_eq!(
            serde_json::from_value::<GetAvailabilityParams>(v)
                .unwrap()
                .budget_ms(),
            Some(0)
        );
        assert_eq!(
            GetAvailabilityParams::new(vec![]).budget_ms(),
            None,
            "unbudgeted is a different state from budget zero"
        );
    }

    /// **Proves:** `ask_id` round-trips verbatim under its own key, and is NOT the
    /// JSON-RPC `id`.
    /// **Catches:** an implementation that reuses the envelope correlator for dedup.
    /// The fixture puts a hardcoded `"id": 1` — the exact value dig-node was sending
    /// — beside a real 16-byte ask id in one envelope, so a reader that took the
    /// correlator would see `1` and disagree with both assertions.
    #[test]
    fn the_ask_id_is_not_the_jsonrpc_correlator() {
        const ASK_ID: &str = "3f9c1a04b7e25d68f0a1c3b5d7e9f012";
        assert_eq!(ASK_ID.len(), 32, "16 random bytes as lowercase hex");

        let envelope = json!({
            "jsonrpc": "2.0",
            "id": 1,
            "method": "dig.getAvailability",
            "params": {
                "items": [ { "store_id": "ab".repeat(32) } ],
                "ask_id": ASK_ID,
            }
        });

        let p: GetAvailabilityParams = serde_json::from_value(envelope["params"].clone()).unwrap();
        assert_eq!(p.ask_id(), Some(ASK_ID));
        assert_ne!(
            p.ask_id(),
            Some("1"),
            "the dedup identity must not be read from the envelope `id`"
        );
        assert_eq!(envelope["id"], 1, "the correlator is untouched beside it");
    }

    /// **Proves:** `absence_established` distinguishes THREE states on the wire —
    /// asserted, explicitly-not-established, and unknown-because-older-server — and
    /// that the unknown state serializes as an ABSENT key rather than `false`.
    /// **Catches:** the collapse this field exists to prevent. The fixture carries
    /// all three answers in ONE batch, so a `bool` with `#[serde(default)]` (which
    /// would read the old server answer as `false`) makes the second and third
    /// answers compare EQUAL and the test fails; a fixture with only one answer
    /// could not see that.
    #[test]
    fn absence_established_keeps_absent_distinct_from_false() {
        let asserted = AvailabilityAnswer {
            available: false,
            absence_established: Some(true),
            ..Default::default()
        };
        let inconclusive = AvailabilityAnswer {
            available: false,
            absence_established: Some(false),
            ..Default::default()
        };
        let older_server = AvailabilityAnswer {
            available: false,
            ..Default::default()
        };

        assert_ne!(
            inconclusive, older_server,
            "an explicit `false` is a claim; an absent field is not"
        );
        assert_eq!(asserted.absence_established_or_unknown(), Some(true));
        assert_eq!(inconclusive.absence_established_or_unknown(), Some(false));
        assert_eq!(
            older_server.absence_established_or_unknown(),
            None,
            "an older server makes no claim either way"
        );

        let batch = serde_json::to_value(AvailabilityBatch {
            items: vec![asserted, inconclusive, older_server],
        })
        .unwrap();
        assert_eq!(batch["items"][0]["absence_established"], true);
        assert_eq!(batch["items"][1]["absence_established"], false);
        assert!(
            batch["items"][2].get("absence_established").is_none(),
            "the unknown state is an absent key, never `false` and never `null`"
        );
    }

    /// **Proves:** an OLDER client can still read a NEWER answer — the added field
    /// does not break the shipped shape (§5.1).
    #[test]
    fn an_answer_carrying_the_new_field_still_parses_as_the_shipped_shape() {
        let newer = json!({
            "items": [ { "available": false, "absence_established": true } ]
        });
        let b: AvailabilityBatch = serde_json::from_value(newer).unwrap();
        assert_eq!(b.items.len(), 1);
        assert!(!b.items[0].available);
        assert_eq!(b.items[0].absence_established_or_unknown(), Some(true));
    }

    /// **Proves:** the hop budget an availability ask carries is the SAME field,
    /// with the same key, type and value, that a `-32008` redirect hands back and
    /// that `dig.getContent` / `dig.fetchRange` already echo — one field, one
    /// interpretation, counted UP toward `max_redirects`.
    /// **Catches:** a second reading of the budget in this crate (a remaining
    /// allowance counting DOWN, a differently-named key, a differently-typed
    /// value) — the byte-drift the shipped redirect contract exists to prevent.
    #[test]
    fn availability_hop_budget_mirrors_the_redirect_budget() {
        let handed_back = RedirectInfo {
            content: ContentRef {
                store_id: "ab".repeat(32),
                root: None,
                retrieval_key: None,
            },
            providers: vec![],
            redirect_depth: 3,
            max_redirects: 4,
        };
        let echoed = handed_back.redirect_depth;

        let availability = serde_json::to_value(
            GetAvailabilityParams::new(vec![AvailabilityQuery {
                store_id: "ab".repeat(32),
                root: None,
                retrieval_key: None,
            }])
            .with_redirect_depth(echoed),
        )
        .unwrap();
        let content = serde_json::to_value(GetContentParams {
            store_id: "ab".repeat(32),
            retrieval_key: "cd".repeat(32),
            root: None,
            offset: None,
            mode: None,
            redirect_depth: Some(echoed),
        })
        .unwrap();
        let range = serde_json::to_value(
            FetchRangeParams::resource("ab".repeat(32), "cd".repeat(32), "ef".repeat(32), 1)
                .with_redirect_depth(echoed),
        )
        .unwrap();

        for (method, params) in [
            ("dig.getAvailability", &availability),
            ("dig.getContent", &content),
            ("dig.fetchRange", &range),
        ] {
            assert_eq!(
                params["redirect_depth"], 3,
                "{method} must carry the echoed depth under `redirect_depth`"
            );
        }
        assert!(
            handed_back.redirect_depth < handed_back.max_redirects,
            "the budget counts UP toward `max_redirects`"
        );
    }

    /// **Proves:** a NEWER client's params — carrying a field this build does not
    /// know — still deserialize, so a hop-bearing ask is never refused outright by
    /// an older responder that simply ignores the budget.
    /// **Catches:** a `#[serde(deny_unknown_fields)]` added to the params type,
    /// which would turn every forward-compatible extension into a hard parse
    /// failure at the peer boundary.
    #[test]
    fn get_availability_params_tolerates_an_unknown_field() {
        let newer = json!({
            "items": [ { "store_id": "ab".repeat(32) } ],
            "redirect_depth": 1,
            "a_field_this_build_does_not_know": true
        });
        let p: GetAvailabilityParams = serde_json::from_value(newer).unwrap();
        assert_eq!(p.hops_consumed(), 1);
    }

    // -----------------------------------------------------------------
    // dig.listRewardDistributors / dig.getRewardProverStatus /
    // dig.getRewardDistributor  (#3250, dig-rewards-coin SPEC §2.3/§2.6)
    // -----------------------------------------------------------------

    fn sample_prover_status() -> RewardProverStatus {
        RewardProverStatus {
            launcher_id: "ab".repeat(32),
            store_id: "cd".repeat(32),
            root: "ef".repeat(32),
            prover_state: ProverState::Running,
            prover_state_since: 1_000,
            last_cycle_started_at: Some(1_050),
            last_cycle_completed_at: None,
            next_cycle_due_at: Some(1_600),
            last_entry_write_at: Some(900),
            consecutive_cycle_failures: 0,
            pending_entry_writes: 2,
            observed_at: 1_700,
            counters: ProverCounters {
                mirrors_seen: 3,
                challenges_issued: 10,
                challenges_passed: 9,
                challenges_failed: 1,
                entries_added: 5,
                entries_removed: 1,
                entry_count: 4,
                reserve_base_units: 12_345,
                total_paid_out_base_units: 6_789,
            },
        }
    }

    /// **Proves:** `RewardProverStatus` round-trips through serde.
    #[test]
    fn reward_prover_status_round_trips() {
        let status = sample_prover_status();
        let json = serde_json::to_string(&status).unwrap();
        let back: RewardProverStatus = serde_json::from_str(&json).unwrap();
        assert_eq!(status, back);
    }

    /// **Proves:** `RewardProverStatus`'s JSON key set is EXACTLY the SPEC §2.3
    /// field list, and contains NEITHER a health boolean NOR a pre-computed
    /// staleness field — SPEC §2.4: a wedged loop cannot report its own
    /// wedging, so a boolean the writer sets reads true forever after the
    /// failure it exists to reveal.
    /// **Catches:** a `healthy`/`ok`/`up`/`running`/`stale`/
    /// `seconds_since_last_run`/`is_healthy` field reintroduced onto the
    /// self-reported prover record.
    #[test]
    fn reward_prover_status_has_no_health_boolean_and_exact_keys() {
        let value = serde_json::to_value(sample_prover_status()).unwrap();
        let obj = value.as_object().unwrap();
        let mut got: Vec<&str> = obj.keys().map(String::as_str).collect();
        got.sort_unstable();

        let mut want = vec![
            "launcher_id",
            "store_id",
            "root",
            "prover_state",
            "prover_state_since",
            "last_cycle_started_at",
            "next_cycle_due_at",
            "last_entry_write_at",
            "consecutive_cycle_failures",
            "pending_entry_writes",
            "observed_at",
            "counters",
        ];
        // `last_cycle_completed_at` is `None` in the fixture and this type has
        // no `skip_serializing_if`, so it still serializes as `null` — include it.
        want.push("last_cycle_completed_at");
        want.sort_unstable();
        assert_eq!(got, want);

        // Recurse into `counters` too — a smuggled health flag could hide one
        // level down, and a substring check on the serialized string would
        // miss it (and would also be actively wrong: `ProverState::Running`
        // legitimately serializes the *value* `"running"`, so a
        // `!s.contains("running")` assertion fails on honest input while
        // still passing a smuggled `isRunning` key).
        let counters_keys: Vec<&str> = value["counters"]
            .as_object()
            .unwrap()
            .keys()
            .map(String::as_str)
            .collect();
        let mut all_keys = got.clone();
        all_keys.extend(counters_keys);

        for forbidden in [
            "healthy",
            "ok",
            "up",
            "running",
            "isRunning",
            "stale",
            "isStale",
            "staleness",
            "secondsSinceLastRun",
            "uptime",
            "alive",
            "live",
            "lastRunSecondsAgo",
        ] {
            assert!(
                !all_keys.contains(&forbidden),
                "RewardProverStatus (incl. counters) must not carry `{forbidden}` (SPEC §2.4)"
            );
        }

        // Also assert directly against the Rust field list, independent of the
        // JSON round-trip, so a `#[serde(rename)]` cannot hide a violation.
        got.retain(|k| *k != "prover_state"); // enum-typed; checked separately below.
    }

    /// **Proves:** `ProverState` deserializes each of the nine named SPEC §2.3
    /// variants and REJECTS an unknown string — fail-closed: no
    /// `#[serde(other)]`, no `Default`.
    #[test]
    fn prover_state_covers_the_closed_set_and_rejects_unknown() {
        let known = [
            ("idle", ProverState::Idle),
            ("running", ProverState::Running),
            ("localCopyMissing", ProverState::LocalCopyMissing),
            (
                "chainSourceUnavailable",
                ProverState::ChainSourceUnavailable,
            ),
            ("unfunded", ProverState::Unfunded),
            ("feeBudgetExhausted", ProverState::FeeBudgetExhausted),
            ("entrySetFull", ProverState::EntrySetFull),
            ("paused", ProverState::Paused),
            ("stopped", ProverState::Stopped),
        ];
        assert_eq!(known.len(), 9, "the SPEC §2.3 set has exactly nine members");
        for (wire, variant) in known {
            let got: ProverState = serde_json::from_value(json!(wire)).unwrap();
            assert_eq!(got, variant, "{wire}");
            assert_eq!(serde_json::to_value(variant).unwrap(), json!(wire));
        }

        let err = serde_json::from_value::<ProverState>(json!("somethingElse"));
        assert!(
            err.is_err(),
            "an unknown ProverState string must be rejected"
        );
    }

    /// **Proves:** `GetRewardProverStatusParams` / `GetRewardProverStatusResult`
    /// round-trip.
    #[test]
    fn get_reward_prover_status_types_round_trip() {
        let params = GetRewardProverStatusParams {
            launcher_id: Some("ab".repeat(32)),
        };
        let back: GetRewardProverStatusParams =
            serde_json::from_str(&serde_json::to_string(&params).unwrap()).unwrap();
        assert_eq!(params, back);

        let result = GetRewardProverStatusResult {
            statuses: vec![sample_prover_status()],
        };
        let back: GetRewardProverStatusResult =
            serde_json::from_str(&serde_json::to_string(&result).unwrap()).unwrap();
        assert_eq!(result, back);
    }

    /// **Proves:** `ListRewardDistributorsResult` round-trips and its JSON keys
    /// are exactly `funded` / `claimable` (SPEC §2.6).
    #[test]
    fn list_reward_distributors_result_round_trips_with_exact_keys() {
        let result = ListRewardDistributorsResult {
            funded: vec![RewardDistributorRef {
                launcher_id: "11".repeat(32),
                store_id: "22".repeat(32),
                root: "33".repeat(32),
            }],
            claimable: vec![],
        };
        let value = serde_json::to_value(&result).unwrap();
        let mut got: Vec<&str> = value
            .as_object()
            .unwrap()
            .keys()
            .map(String::as_str)
            .collect();
        got.sort_unstable();
        assert_eq!(got, vec!["claimable", "funded"]);

        let back: ListRewardDistributorsResult = serde_json::from_value(value).unwrap();
        assert_eq!(result, back);
    }

    /// **Proves:** `GetRewardDistributorResult` round-trips, and `entry_set_stale`
    /// IS present on this chain-derived type (contrast `RewardProverStatus`,
    /// which must never carry it — SPEC §12.4 vs §2.4).
    #[test]
    fn get_reward_distributor_result_round_trips_and_carries_entry_set_stale() {
        let params = GetRewardDistributorParams {
            launcher_id: "ab".repeat(32),
        };
        let back: GetRewardDistributorParams =
            serde_json::from_str(&serde_json::to_string(&params).unwrap()).unwrap();
        assert_eq!(params, back);

        let result = GetRewardDistributorResult {
            launcher_id: "ab".repeat(32),
            store_id: "cd".repeat(32),
            root: "ef".repeat(32),
            epoch_seconds: 86_400,
            first_epoch_start: 1_000,
            payout_threshold: 500_000,
            fee_bps: 250,
            withdrawal_share_bps: 9_000,
            reserve_base_units: 1_000_000,
            entry_count: 42,
            current_distributor_epoch: 7,
            last_entry_write_at: Some(1_650),
            entry_set_stale: true,
            observed_at: 1_700,
        };
        let value = serde_json::to_value(&result).unwrap();
        assert_eq!(value["entry_set_stale"], true);
        let back: GetRewardDistributorResult = serde_json::from_value(value).unwrap();
        assert_eq!(result, back);
    }

    /// **Proves:** `entry_set_stale` is placed on exactly one of the two
    /// reward-distributor result types — present on the chain-derived
    /// `GetRewardDistributorResult`, absent from the self-reported
    /// `RewardProverStatus` — SPEC §12.4 vs §2.4.
    /// **Catches:** the staleness boolean migrating (or being copy-pasted)
    /// onto the self-reported type, which would let a wedged prover fake
    /// liveness by simply never flipping it.
    #[test]
    fn entry_set_stale_is_placed_on_the_chain_derived_result_only() {
        let prover_status = serde_json::to_value(sample_prover_status()).unwrap();
        assert!(
            !prover_status
                .as_object()
                .unwrap()
                .contains_key("entry_set_stale"),
            "RewardProverStatus must never carry entry_set_stale (SPEC §2.4)"
        );

        let distributor_result = GetRewardDistributorResult {
            launcher_id: "ab".repeat(32),
            store_id: "cd".repeat(32),
            root: "ef".repeat(32),
            epoch_seconds: 86_400,
            first_epoch_start: 1_000,
            payout_threshold: 500_000,
            fee_bps: 250,
            withdrawal_share_bps: 9_000,
            reserve_base_units: 1_000_000,
            entry_count: 42,
            current_distributor_epoch: 7,
            last_entry_write_at: None,
            entry_set_stale: false,
            observed_at: 1_700,
        };
        let value = serde_json::to_value(&distributor_result).unwrap();
        assert!(
            value.as_object().unwrap().contains_key("entry_set_stale"),
            "GetRewardDistributorResult must carry entry_set_stale (SPEC §12.4)"
        );
    }

    /// **Proves:** `GetRewardProverStatusParams` with an absent `launcher_id`
    /// deserializes from an empty JSON object to `None` — the field is
    /// genuinely optional on the wire, not merely optional in Rust.
    #[test]
    fn get_reward_prover_status_params_absent_launcher_id_is_none() {
        let parsed: GetRewardProverStatusParams = serde_json::from_value(json!({})).unwrap();
        assert_eq!(parsed.launcher_id, None);

        // And the round trip the other way: `Some` serializes the key back out.
        let with_id = GetRewardProverStatusParams {
            launcher_id: Some("ab".repeat(32)),
        };
        let value = serde_json::to_value(&with_id).unwrap();
        assert_eq!(value["launcher_id"], json!("ab".repeat(32)));
    }

    /// **Proves:** `ListRewardDistributorCommitmentsResult` round-trips,
    /// including the legitimate EMPTY `commitments` case (a distributor
    /// funded only via `AddIncentives` has no clawback slots at all — an
    /// irrevocable donation, not an error) — SPEC §7.4 clause 5.
    #[test]
    fn list_reward_distributor_commitments_round_trips_with_empty_commitments() {
        let params = ListRewardDistributorCommitmentsParams {
            launcher_id: "ab".repeat(32),
        };
        let back: ListRewardDistributorCommitmentsParams =
            serde_json::from_str(&serde_json::to_string(&params).unwrap()).unwrap();
        assert_eq!(params, back);

        let result = ListRewardDistributorCommitmentsResult {
            launcher_id: "ab".repeat(32),
            withdrawal_share_bps: 9_000,
            epoch_seconds: 86_400,
            commitments: vec![],
            observed_at: 1_700,
        };
        let back: ListRewardDistributorCommitmentsResult =
            serde_json::from_str(&serde_json::to_string(&result).unwrap()).unwrap();
        assert_eq!(result, back);
        assert_eq!(back.epoch_seconds, 86_400);
    }

    /// **Proves:** `recoverable_base_units` is `rewards_base_units *
    /// withdrawal_share_bps / 10_000`, computed with integer arithmetic
    /// (multiply then divide) that TRUNCATES rather than rounds up — SPEC
    /// §7.4 clause 4 / §7.5.
    /// **Catches:** a rounded-up recoverable amount, which would promise
    /// money the chain will not return on clawback.
    #[test]
    fn commitment_recoverable_amount_truncates_and_never_exceeds_committed() {
        let withdrawal_share_bps: u64 = 9_000;

        // Evenly divisible: 1_000 * 9000 / 10000 = 900.
        let even = RewardDistributorCommitment {
            epoch_start: 10,
            clawback_puzzle_hash: "aa".repeat(32),
            rewards_base_units: 1_000,
            recoverable_base_units: 1_000 * withdrawal_share_bps / 10_000,
        };
        assert_eq!(even.recoverable_base_units, 900);

        // Not evenly divisible: 1_001 * 9000 / 10000 = 900.9 -> 900, not 901.
        let odd = RewardDistributorCommitment {
            epoch_start: 11,
            clawback_puzzle_hash: "bb".repeat(32),
            rewards_base_units: 1_001,
            recoverable_base_units: 1_001 * withdrawal_share_bps / 10_000,
        };
        assert_eq!(
            odd.recoverable_base_units, 900,
            "a non-evenly-divisible amount must truncate down, never round up"
        );

        for commitment in [even, odd] {
            assert!(
                commitment.recoverable_base_units <= commitment.rewards_base_units,
                "recoverable_base_units must never exceed rewards_base_units"
            );
        }
    }
}