dig-node-control-interface 0.35.0

Canonical client <-> dig-node CONTROL interface contract: the method catalog for controlling/querying a running dig-node (config, status, peers, subscriptions, cache, wallet), transport-agnostic. SSOT so client and node can't drift.
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
# dig-node-control-interface — normative specification

This document is the authoritative contract for the client ⇄ dig-node **control interface**: how a
client (the CLI `dign`, the browser extension, dig-app, hub) controls and queries a running dig-node.
An independent reimplementation of either side MUST conform to this specification byte-for-byte. Where
this document and the code disagree, the conformance KATs in `src/kats.rs` are the tie-breaker.

Layering: this file is the repo's own contract; the cross-repo interaction map is the superproject
`SYSTEM.md`; the session/transport envelope this catalog rides over is `dig-ipc-protocol`'s `SPEC.md`;
the node↔node peer wire is `dig-rpc-protocol`'s `SPEC.md`. All MUST agree.

## 1. Scope and boundary

`dig-node-control-interface` owns the **client↔node CONTROL method catalog**: the method names,
parameter/result types, and error taxonomy for controlling and querying a running dig-node — status,
configuration, cache, hosted/pinned stores, §21 whole-store sync, the peer network, subscription
lifecycle, the auto-update beacon, live log level, and the control-token pairing handshake.

It does **not** own:
- the node↔node peer wire (dig-rpc-protocol's PublicRead + Peer tiers), or
- the local session/signing handshake and transport a client authenticates over (dig-ipc-protocol).

This crate is transport-agnostic: the same method catalog is carried over the dig-ipc-protocol local
session, or over loopback-mTLS + a signed control token for clients reached via HTTP/WebSocket
(CLAUDE.md §5.3). Consumers select the transport; this crate defines only the payload contract that
rides over it.

## 2. Roles and trust boundary

- **Client:** a user-facing surface (CLI, extension, app, hub) that wants to control or query a
  running dig-node. Authenticates over whichever transport is in play before issuing control calls.
- **Node (server):** the identity-agnostic engine dispatching control calls against its live state
  (config, cache, peer table, subscriptions).

### 2.1 Authorization

- Every `control.*` method is **token-gated** EXCEPT the open surface, which is whatever the §4
  method table marks `no` in its Token column — that table is authoritative and this sentence must
  never restate it as a count. Today it is the pairing bootstrap (`pairing.request`,
  `pairing.poll`), the wallet chain reads (`control.wallet.balance` / `.coins` / `.coinById` / `.coinSpend` /
  `.coinsByParent` /
  `.peak` / `.syncStatus`), which need only public chain data, and `control.peerCounts`, which
  discloses three integers about this node's own connectivity. For every other method the caller MUST
  present the node's local control token
  as the `X-Dig-Control-Token` request header (preferred) or a `params._control_token` field. The
  token is a 64-hex value the node mints at first run into its machine-wide state dir with a
  restrictive ACL; possession of the on-disk token is authorization. A call without a valid token MUST
  be rejected with `UNAUTHORIZED` (-32030). Token comparison MUST be constant-time.
- The two **pairing-bootstrap** methods `pairing.request` / `pairing.poll` are **OPEN** (no token), so
  a token-less client (e.g. an MV3 extension that cannot read a local file) can obtain a scoped token
  after local operator approval.
- The **MASTER** control token specifically, never a scoped paired token, is required by every
  method whose effect OUTLIVES the token that invoked it. A paired controller can drive ordinary
  mutations, but anything it could still be holding after `control.pairing.revoke` — the remedy for
  a compromised paired app — is outside its tier. Two groups qualify:
  - the three **pairing-administration** methods (`control.pairing.list` / `.approve` / `.revoke`):
    a paired controller MUST NOT mint more tokens or revoke itself;
  - the two **trusted-Chia-peer mutations** (`control.chiaPeers.add` / `.remove`): `add` writes a
    standing peer entry that is believed WITHOUT corroboration, revocation does not remove it, and
    after the call the caller no longer needs the token at all. `control.chiaPeers.list` is a read
    and stays on the ordinary tier.

## 3. Envelope

Requests and responses are JSON-RPC 2.0.

- **Request:** `{"jsonrpc":"2.0","id":<id>,"method":<name>,"params":<object>}`. `id` is a number,
  string, or null and MUST be echoed on the response. `params` is always an object (`{}` for a
  no-parameter method).
- **Success response:** `{"jsonrpc":"2.0","id":<id>,"result":<value>}` (no `error` key).
- **Error response:** `{"jsonrpc":"2.0","id":<id>,"error":{"code":<int>,"message":<str>,"data":{"code":<SYMBOL>,"origin":<str>}}}`
  (no `result` key). A client MUST branch on `error.data.code` (the stable UPPER_SNAKE symbol), never
  on the human `message`.

## 4. Method catalog

Method names are stable wire contract. `Auth` = requires the control token; `Master` = requires the
master token specifically; `Routing` = how the node resolves it (`owned` by the service shell,
`delegated` to the engine, `open` bootstrap).

| Method | Auth | Routing | Params | Result |
|---|---|---|---|---|
| `control.status` | yes | owned | — | `StatusResult` |
| `control.config.get` | yes | owned | — | `ConfigResult` |
| `control.config.setUpstream` | yes | owned | `{upstream:string}` | `{upstream, requires_restart}` |
| `control.config.setMirrorAdvertiseUrls` | yes | owned | `{urls?:[string]}` (§4.2h) | `{mirror_advertise:MirrorAdvertiseView, requires_restart:bool}` |
| `control.log.setLevel` | yes | owned | `{filter:string}` | `{filter}` |
| `control.cache.get` | yes | owned | — | `CacheView` |
| `control.cache.setCap` | yes | owned | `{cap_bytes:u64}` | `{cap_bytes}` (floored 64 MiB) |
| `control.cache.clear` | yes | owned | — | `{cleared:true}` |
| `control.hostedStores.list` | yes | owned | — | `{stores:[HostedStore]}` |
| `control.hostedStores.pin` | yes | owned | `{store:"storeId[:root]"}` | `{store_id, root, pinned, fetch}` |
| `control.hostedStores.unpin` | yes | owned | `{store}` | `{store_id, unpinned, evicted_capsules}` |
| `control.hostedStores.status` | yes | owned | `{store}` | `{store_id, pinned, capsule_count, total_bytes, capsules}` |
| `control.capsule.fetch` | yes | owned | `{store, root}` | `{store, root, status}` (`"started"`\|`"already_cached"`\|`"unavailable"`) |
| `control.sync.status` | yes | owned | — | `{available, method, pinned_total, pinned_synced, whole_store_trigger_supported}` |
| `control.sync.trigger` | yes | owned | `{store:"storeId:root"}` | `{store_id, root, status, size_bytes, served_root}` |
| `control.updater.status` | yes | owned | — | (proxied beacon status) |
| `control.updater.setChannel` | yes | owned | `{channel:string}` | (proxied) |
| `control.updater.pause` | yes | owned | `{until?:u64}` | (proxied) |
| `control.updater.resume` | yes | owned | — | (proxied) |
| `control.updater.checkNow` | yes | owned | — | (proxied) |
| `control.pairing.list` | master | owned | — | (pending + issued tokens) |
| `control.pairing.approve` | master | owned | `{pairing_id:string}` | `{approved, client_name, token_id}` |
| `control.pairing.revoke` | master | owned | `{token_id:string}` | `{revoked, token_id}` |
| `control.peerStatus` | yes | delegated | — | (peer-pool snapshot; each peer entry carries `software`) |
| `control.peerCounts` | no | delegated | — | `{dig_peer_count:u32\|null, chia_peer_count:u32\|null, known_dig_peer_count:u32\|null}` |
| `control.peers.connect` | yes | delegated | `{peer:string}` | `{connected, peer_id}` |
| `control.peers.disconnect` | yes | delegated | `{peer:string}` | `{disconnected, peer_id}` |
| `control.chiaPeers.add` | master | owned | `{ip:string}` | `{added, ip, port, corroboration_bypassed, notice:string}` |
| `control.chiaPeers.list` | yes | owned | none | `{peers:[{ip,port,peak_height:u32\|null,user_managed,banned}]}` |
| `control.chiaPeers.remove` | master | owned | `{ip:string, ban?:bool}` | `{outcome:"removed"\|"no_such_peer", ip, banned}` |
| `control.subscribe` | yes | delegated | `{store_id:string, kind?:"capsule"\|"profile"}` | `{subscribed, added, store_id, kind}` |
| `control.unsubscribe` | yes | delegated | `{store_id:string}` | `{subscribed, removed, store_id}` |
| `control.listSubscriptions` | yes | delegated | — | `{subscriptions:[string], count}` |
| `control.wallet.balance` | no | delegated | `{address:string, asset:Asset}` | `{balance, pending, source, synced, peak_height}` |
| `control.wallet.coins` | no | delegated | `{address:string, asset:Asset, after_coin_id?:string, limit?:u32}` | `WalletCoinsResult` |
| `control.wallet.coinById` | no | delegated | `{coin_id:string}` | `WalletCoinByIdResult` |
| `control.wallet.coinSpend` | no | delegated | `{coin_id:string}` | `WalletCoinSpendResult` |
| `control.wallet.coinsByParent` | no | delegated | `{parent_coin_id:string, after_coin_id?:string, limit?:u32}` | `WalletCoinsByParentResult` |
| `control.wallet.arrivals` | yes | delegated | `{after_seq:u64=0, limit?:u32}` | `WalletArrivalsResult` |
| `control.wallet.operatorAddress` | yes | owned | — | `{state:"known", address, puzzle_hash}` \| `{state:"unavailable", reason:"not_initialized"\|"unreadable"}` |
| `control.wallet.peak` | no | delegated | — | `{peak_height:u32\|null, synced:bool}` |
| `control.wallet.syncStatus` | no | delegated | — | `{phase:"not_started"\|"syncing"\|"synced"\|"no_wallet_enrolled"\|"wallet_not_unlocked", peak_height:u32\|null, chia_peer_count:u32\|null, watched_addresses:u32\|null, subscription_peer_count:u32\|null, chia_peer_peak_height:u32\|null}` |
| `control.wallet.broadcast` | yes | delegated | `{signed_bundle_hex:string}` | `WalletBroadcastResult` |
| `control.wallet.watch` | yes | delegated | `{public_keys:[string]}` (each lowercase 96-hex, `0x` accepted) | `{added:u32, watched:u32}` |
| `control.wallet.unwatch` | yes | delegated | `{public_keys:[string]}` | `{removed:u32, watched:u32}` |
| `control.wallet.watched` | yes | delegated | — | `{public_keys:[string]}` |
| `control.wallet.reservations.held` | yes | delegated | — | `{reserved:[ReservedCoin], as_of_unix:u64}` |
| `control.wallet.reservations.reserve` | yes | delegated | `{coin_ids:[string], ttl_secs?:u64}` | `{reservation_id, coin_ids, expires_at_unix, ttl_secs}` |
| `control.wallet.reservations.release` | yes | delegated | `{reservation_id:string}` | `{released:bool, coin_ids:[string]}` |
| `control.wallet.resetCoinDb` | yes | delegated | `{confirm:bool=false}` | `{coins_dropped:u64, staged_dropped:u64}`; DESTRUCTIVE -- discards the cached coin database and re-syncs from chain. No key material is affected. Refuses as `INVALID_PARAMS` unless `confirm==true`, and refuses while a spend is in flight. |
| `control.spends.list` | yes | owned | `{since_ms?:u64, until_ms?:u64, store_id?:string, kind?:string, status?:string, after_id?:string, limit?:u32}` | `{spends:[AutomatedSpend], complete:bool, cursor:string\|null, unreadable_lines:u32}` |
| `control.collateral.requirement` | yes | owned | — | `CollateralRequirementResult` (`{state:"known", epoch, protocol_version, required_per_store_dig_base_units, stores, owners, multiplier_micros, handicap_dig_base_units}` \| `{state:"unknown", reason}`) |
| `control.collateral.margin.get` | yes | owned | — | `{margin_bp:u64}` |
| `control.collateral.margin.set` | yes | owned | `{margin_bp:u64}` | `{margin_bp:u64}` |
| `control.collateral.buffer` | yes | owned | — | `CollateralBufferResult` (`{state:"known", epoch, protocol_version, funding_state, recommended_buffer_dig_base_units, spendable_dig_base_units, pairs_served_by_this_node, required_per_store_dig_base_units, margin_bp, overlap_dig_base_units, escalation_headroom_dig_base_units, horizon_epochs, escalation_ceiling_micros}` \| `{state:"unknown", reason}`) |
| `control.mirror.bondStates` | yes | owned | `{after?:{store_id,root}, limit?:u32}` | `MirrorBondStatesResult` (`{state:"known", entries:[{store_id, root, bond_state, urls, url_current, …}], complete, cursor, locked_dig_base_units, epoch, funding_wallet, url_reconcile}` \| `{state:"unknown", reason}`) |
| `control.mirror.reconcile` | yes | owned | `{dry_run?:bool=false}` (§4.2i) | `MirrorReconcileResult` (`{outcome:"planned", capsules_stale, capsules_affordable, collateral_reclaimed_dig_base_units, collateral_relocked_dig_base_units, fee_estimate_mojos, stale_url_sets, url_current, targets_seen_last_7_days}` \| `{outcome:"refused", reason, url_current}` \| `{outcome:"submitted", reclaims_submitted, reclaims_rejected, recreates_owed, left_unchanged, left_reason, url_current}`) |
| `control.profile.putBody` | yes | delegated | `{store_id:string, root:string, body_b64:string}` | `{stored, store_id, root, body_bytes}` |
| `control.profile.getBody` | yes | delegated | `{store_id:string, root:string}` | `{store_id, root, body_b64:string\|null, body_bytes}` |
| `pairing.request` | no | open | `{client_name:string}` | `{pairing_id, pairing_code, expires_ms}` |
| `pairing.poll` | no | open | `{pairing_id:string}` | `{status, token?}` |

### Trusted Chia peers

A trusted Chia peer BYPASSES CORROBORATION. A conforming node treats dialled Chia peers as
untrusted and believes a chain answer only when several independently-queried peers agree
(NC-12); a peer added through `control.chiaPeers.add` is exempted from that agreement and is
believed on its own. An implementation MUST state that cost where a person adds one, and MUST
serve `control.chiaPeers.*` from the SAME peer store its wallet replica reads -- a second peer
list would let the trusted set and the consulted set drift apart silently.

The trust NC-12 authorises is the operator declaring a node THEIR OWN. Every surface an
implementation offers MUST say that, and MUST NOT widen it to a node the operator merely vouches
for: the entry carries unbounded authority over the wallet replica precisely because the operator
controls both ends, which is false of somebody else's node.

`control.chiaPeers.add` and `control.chiaPeers.remove` REQUIRE THE MASTER TOKEN. A paired token
MUST NOT reach them. `add` writes standing authority that outlives the token that wrote it --
after the call the caller no longer needs the token, and `control.pairing.revoke` removes the token
but not the peer entry, so a paired token able to call `add` escapes the remedy for a compromised
paired app. `control.chiaPeers.list` is a read that confers nothing and stays on the ordinary
token tier.

`ip` is a BARE IP LITERAL in canonical form: `IpAddr` display -- dotted-quad for v4, and for v6 the
RFC 5952 lowercase maximally-compressed form, without brackets and without a zone id. A node MUST
canonicalise on the way in, MUST reject anything that is not an IP literal (a hostname, an empty
string, `ip:port`, a CIDR block, a bracketed form), and MUST match `add`, `remove` and `list`
against that one form -- otherwise an operator who spells an address two ways un-trusts nothing.
When an address and a port are joined, v6 MUST be bracketed: `::1` and `8444` written as
`::1:8444` is a DIFFERENT valid address, so the mistake survives validation.

`control.chiaPeers.remove` MUST report whether it removed anything. `outcome` is `"removed"` when a
matching entry existed and `"no_such_peer"` when nothing matched; a client MUST surface the second
as a failure to act. `remove` is the only way to un-trust a peer, so a success it cannot withhold
is a lie about whether a privileged action took effect.

`corroboration_bypassed` reports the RESULTING trust state, not the request: a node MUST report
`false` where the entry did not end up trusted. `notice` carries the node's own warning text and
MUST be non-empty, MUST name the corroboration bypass, and is rendered verbatim -- it exists so a
client quotes the node rather than restating the cost and drifting from it.

A ban is exact-match on one address, local to this node, persisted until removed, bounded at 256
entries (oldest evicted on overflow), enumerated by `control.chiaPeers.list` as `banned: true`, and
cleared by `remove` with `ban: false`. Clearing a ban that way MUST NOT grant trust; `add` also
un-bans, but it confers the bypass, so it MUST NOT be the only route back.

The wallet CHAIN READS (`control.wallet.balance` / `.coins` / `.coinById` / `.coinSpend` /
`.coinsByParent` / `.peak` /
`.syncStatus`)
are served WITHOUT a control token, because each needs only public chain data — an address or a coin
id, never a seed, a key, or a signature. `control.peerCounts` is open for a second reason: it
discloses three integers about this node's own connectivity and no address, endpoint or secret. The
`Token` column above is authoritative; the open set is deliberately named here rather than counted,
so that adding a method cannot leave a stale number behind. Five wallet methods are token-gated.
`control.wallet.broadcast` puts bytes on the network. `control.wallet.watch` / `.unwatch` aim what
this node follows, so they are mutations. `control.wallet.watched` and `control.wallet.arrivals` are
reads and are gated all the same, because each takes nothing from the caller and so answers with this
node's OWN state — the enrolled keys, or the watched puzzle hashes and the receive history behind
them. `control.wallet.arrivals` takes only a cursor,
so its answer names this node's OWN watched puzzle hashes and the receive history behind them: the
chain facts are public, the association between this node and those addresses is not. Membership of
the open set turns on WHO NAMES THE ADDRESS, not on whether the data is on chain. That difference is normative for clients, because the two refusals demand
opposite remedies — see §4.2.

### 4.1 Result field definitions

- **`StatusResult`**: `{running:bool, service:string, version:string, commit:string, protocol:string,
  uptime_secs:u64, addr:string, upstream:string, cache:CacheView, hosted_store_count:u64,
  cached_capsule_count:u64, pinned_store_count:u64, sync:{available:bool}}`.
- **`ConfigResult`**: `{addr:string, port:string, upstream:string, upstream_override:string|null,
  cache_dir:string, cache_shared:bool, config_path:string, sync_available:bool,
  mirror_advertise:MirrorAdvertiseView|null}`.
  `upstream_override` MUST be present as `null` when unset (never omitted). `mirror_advertise` (§4.2h)
  MUST be present as `null` ONLY from a node built before the field existed — an implementation of
  THIS contract version MUST always report a `MirrorAdvertiseView`, never `null`; a client MUST read
  `null` as "the answering node predates this field", never as "nothing to advertise" (that is
  `MirrorAdvertiseState::Off` or `NoPublicAddress`, both of which carry a real view).
- **`MirrorAdvertiseView`** (§4.2h): `{urls:[string], operator_override:[string]|null,
  state:MirrorAdvertiseState}`. Embedded in `ConfigResult`, and embedded in turn in
  `SetMirrorAdvertiseUrlsResult`.
- **`MirrorAdvertiseState`** (§4.2h): one of `"advertising_override"`, `"advertising_derived"`,
  `"off"`, `"no_public_address"`, `"uncorroborated_address"`, `"no_relay"` — the exact six spellings
  dig-node's own `AdvertiseState` uses (dig-node#562), restated here so client and node cannot drift
  on what one of them means.
- **`SetMirrorAdvertiseUrlsResult`** (§4.2h): `{mirror_advertise:MirrorAdvertiseView,
  requires_restart:bool}`. `mirror_advertise` is what a follow-up `config.get` will show ONCE this
  takes effect; `requires_restart` says whether that is NOW or after a restart, and it is a report
  of the answering node's OWN implementation, not a value this contract fixes either way (§4.2h).
- **`CacheView`**: `{cap_bytes:u64, used_bytes:u64, dir:string, shared:bool}`.
- **`HostedStore`**: `{store_id:string, pinned:bool, capsule_count:u64, total_bytes:u64,
  capsules:[CapsuleEntry]}`.
- **`CapsuleEntry`**: `{capsule:"storeId:root", root:string, size_bytes:u64, last_used_unix_ms:u64}`.
- **`pairing.poll` token**: the `token` field MUST be omitted while `status` is not `approved`, and
  present exactly once after approval.
- **`Asset`** — the asset a wallet read is denominated in. Exactly three wire forms are valid:

  | Form | JSON | Meaning |
  |---|---|---|
  | XCH token | `"xch"` | Native Chia, denominated in mojos. |
  | $DIG token | `"dig"` | The $DIG CAT, asset id `a406d3a9de984d03c9591c10d917593b434d5263cabe2b42f6b367df16832f81`. |
  | CAT by asset id | `{"cat":"<64-hex>"}` | Any CAT, named by its asset id (TAIL hash). |

  The asset id is lowercase 64-hex, unprefixed, on the wire. A `0x` prefix and uppercase digits MUST
  be ACCEPTED on input and normalized away; neither MUST ever be emitted. Any other string, an object
  with any other or additional key, and a hex string of any other length MUST be REJECTED — an
  unrecognized asset MUST NOT be defaulted to XCH or to $DIG.

  **$DIG has exactly one identity.** `"dig"` and `{"cat":"a406…2f81"}` MUST denote the SAME asset and
  MUST compare equal. An implementation MUST NOT model $DIG as a value distinct from its own asset
  id: were the two inequal, a balance or coin list filtered by one spelling would silently omit the
  holdings carrying the other, reporting part of a balance as though it were all of it.

  **$DIG MUST still be EMITTED as `"dig"`**, not as its tagged form. `"xch"` and `"dig"` are the only
  forms a node or client built before asset ids were nameable understands, so emitting the tagged
  form for $DIG would break that compatibility direction. The tagged form is for every OTHER CAT.

  Consumers MUST model this as a two-case type — native XCH, or a CAT identified by asset id — with
  $DIG a named constant of the CAT case rather than a third case.

- **`WalletCoinsResult`**: `{coins:[WalletCoinRecord], complete:bool|null, cursor:string|null,
  source:"db"|"fallback"|null, synced:bool, peak_height:u32|null}`.
  `source`/`synced`/`peak_height` carry exactly the meanings defined for `WalletBalanceResult`
  below. `coins` MUST list ONE PAGE of the address's spendable coins for the requested asset (XCH
  coins sit AT the puzzle hash; CAT coins are HINTED to it).

  The read is PAGED. A node MUST return coins in ASCENDING `coin_id` order and MUST keep that order
  stable across the pages of one walk; `after_coin_id` means strictly after that id in that order.
  A node MUST NOT page by OFFSET. An address's unspent set SHRINKS as coins are spent, so against an
  offset every row after a departed coin moves one position earlier and the next page begins one row
  late — a coin the caller never sees, on the read whose whole purpose is selecting coins for a
  spend. Against a cursor the rows before the boundary are simply gone and every row after it still
  follows the cursor.

  `complete` MUST state whether the page carries the LAST coin, derived from whether rows remain
  BEYOND the page. A node MUST NOT report `complete:true` on a page it truncated, and MUST NOT
  derive it from the page LENGTH: a coin count that is an exact multiple of the page size makes the
  final full page indistinguishable from a truncated one. A caller that stops early on that page
  selects a spend from a coin set it believes it saw all of, and refuses with a shortfall that is
  not true while the funds sit in the coins that were withheld.

  `complete:null` MUST mean the node is too old to page this read (pre-0.25) and therefore returned
  the WHOLE set. A caller MUST NOT read it as `false`: such a node also ignores `after_coin_id`, so
  a caller that resumed would be re-served the first page indefinitely. A node that pages MUST emit
  a concrete boolean, so the two are told apart by value and never by convention.

  `cursor` MUST be the `coin_id` of the LAST record actually returned, `null` for an empty page, and
  `null` from a node that does not page. A caller resumes by passing it as `after_coin_id`.

  `limit` MUST be refused as `-32602 INVALID_PARAMS` outside `1..=1000` rather than clamped, for the
  reason `control.wallet.coinsByParent` states: a silently shrunk page hands back a cursor for a
  position the caller did not ask about. The bound is the same frame-derived ceiling, because both
  reads page the same `WalletCoinRecord` over the same transport frame. An omitted `limit` MUST
  resolve to 100.

  Both paging parameters are OPTIONAL and a request naming neither MUST be byte-identical to the
  pre-0.25 request, so adopting the paged form is additive on both sides of the wire.

  `coins:[]` MUST mean the node consulted a chain and the address holds nothing. A node that could
  NOT consult a chain MUST return the matching §5 wallet error instead — never an empty list. This
  is normative and not a quality-of-implementation note: an empty list on an unreachable chain tells
  a holder of funds that they hold nothing, and a spend built on that answer refuses with a
  shortfall that is not true.
- **`WalletCoinRecord`**: `{coin_id:string, asset:Asset|null, amount:u64,
  parent_coin_info:string, puzzle_hash:string, created_height:u32|null, spent_height:u32|null}`. All
  hashes are lowercase 64-hex, unprefixed. `created_height:null` means the coin is known only from
  the mempool; `spent_height:null` means unspent. The first three fields are a strict SUPERSET of
  dig-app's frozen `CoinRecord`.

  `asset:null` MUST mean THE READ DID NOT CLASSIFY THE COIN. It MUST NOT be read as "no asset" and
  MUST NOT be defaulted to XCH. A singleton, a CAT and a plain XCH coin are indistinguishable from a
  coin id alone — telling them apart requires inspecting the puzzle, which a coin-record read does
  not do. `control.wallet.coins` MUST report the concrete asset it was SCOPED to and MUST NOT emit
  `asset:null`: dig-app's frozen `CoinRecord` requires a non-null asset there, so `null` is a hard
  deserialization failure, not a degraded read. `control.wallet.coinById` and `control.wallet.coinsByParent` MUST report `null`: neither a coin id
  nor a parent id scopes a read to an asset.
- **`WalletCoinByIdResult`**: `{coin:WalletCoinRecord|null, source:"db"|"fallback"|null, synced:bool,
  peak_height:u32|null}`. ONE coin, named by its own id, SPENT OR UNSPENT.

  `coin:null` MUST mean the node consulted a chain and it holds no coin with that id. A node that
  could NOT consult a chain MUST return the matching §5 wallet error instead — never `coin:null`.
  These MUST NOT be collapsed: the first says stop waiting, the second says retry the read. A caller
  that conflates them reports a mint whose coin does not exist as pending forever, with the funds
  already spent.

  The `coin` key MUST be present on every response; `null` is a verdict and MUST NOT be conveyed by
  omitting the field.

  `coin_id` MUST be lowercase 64-hex; a `0x` prefix MUST be accepted on input and MUST NOT be
  emitted. Any other value MUST be refused as `-32602 INVALID_PARAMS` BEFORE any chain is consulted,
  so that an unanswerable question never wears the shape of an answer. There is no `asset`
  parameter: a coin id is not asset-scoped.

  `source` names which tier answered, and every freshness field describes THAT tier, exactly as
  for `WalletBalanceResult` below: the bound MUST come from the tier that ANSWERED, so a
  `"fallback"` answer MUST report the oracle's own peak and a `"db"` answer the replica's.
  `synced` is `true` if and only if that reported `peak_height` is the height the answering
  tier reported in the SAME read that produced these figures; a peak carried over from an
  earlier read, or obtained from any other exchange, party or tier, does not satisfy it. A node that cannot bind the two together in one read MUST
  report `peak_height:null` and `synced:false`, and MUST report the same when the answering
  tier tracks no peak and for a cached row. `synced:true` on a `"fallback"` answer asserts only
  that one disclosed oracle answered self-consistently; it is not corroboration by the network.

  A caller needing a height to bound a confirmation against reads THIS answer's own
  `peak_height`. It MAY read `control.wallet.peak` only when that is `null`, and MUST then
  treat the two as possibly different chain views: `WalletPeakResult` carries no `source`, so
  which tier answered it is undisclosed, and a confirmation count computed across the two
  MUST fail closed rather than report a number neither view supports.

  **A node MUST NOT answer `coin:null` from a view that could not have held the coin.** A replica
  that is not caught up, or a local index that is address-scoped rather than a full chain view, has
  not established absence — only its own inability to see. Such a node MUST return `-32040
  WALLET_NO_CHAIN_SOURCE` or `-32042 WALLET_READ_FAILED`. This is normative and load-bearing: the two
  coins this method exists to observe are a created coin sitting at no wallet address and a funding
  coin already spent, both of which an address-scoped view is GUARANTEED to miss, so a `coin:null`
  from one would report a mint that really happened as never having happened, with the funds gone.
  `-32041 WALLET_NOT_SYNCED` is not used here — it names a wallet-scoped branch this method does not
  have — but a node that is not synced still MUST NOT manufacture a negative answer; it errors.

  This method is how a pushed spend becomes OBSERVABLE. `control.wallet.broadcast`'s `accepted:true`
  reports mempool admission only; only a buried confirmation of the created coin is evidence.
  `control.wallet.coins` cannot supply it — it answers by ADDRESS and lists UNSPENT coins only, so it
  sees neither a created coin sitting at no wallet address nor a funding coin the spend consumed.
- **`WalletCoinSpend`**: `{coin:WalletCoinRecord, puzzle_reveal:string, solution:string}`. The chia
  `CoinSpend` in this contract's wire form: the coin that was consumed plus the two programs that
  consumed it, each lowercase hex of its serialized CLVM.

  `puzzle_reveal` MUST tree-hash to `coin.puzzle_hash`. A reveal is supplied by a PEER and a peer can
  lie, so the claim is deliberately self-checking: a node MUST verify it and MUST fail closed with a
  §5 wallet error — never return a spend carrying an unverified reveal — when the hashes disagree or
  the reveal does not parse. A caller MAY re-derive the same check from the two fields it is handed.

  `coin.spent_height` MUST be non-null. A spend exists only because the coin was spent, so a spend
  reporting an unspent coin is a contradiction.
- **`WalletCoinSpendResult`**: `{spend:WalletCoinSpend|null, source:"db"|"fallback"|null,
  synced:bool, peak_height:u32|null}`. THE SPEND that spent one coin, named by that coin's own id.
  A spend has no id of its own on chain, so `coin_id` names the SPENT COIN and takes the identical
  form and validation `control.wallet.coinById` takes.

  `spend:null` MUST mean the node consulted a chain and it holds no spend of that coin. Absence has
  TWO legitimate causes — the coin is UNSPENT, or the chain holds no such coin — and this method
  deliberately does not distinguish them; a caller needing to MUST ask `control.wallet.coinById`,
  whose `coin:null` separates them.

  A node that could NOT consult a chain MUST return the matching §5 wallet error instead — never
  `spend:null`. This is the money-critical distinction in the whole family: a caller following a
  singleton forward reads "no spend" as *this is the current tip* and stops walking, so a failure
  disguised as absence makes a superseded coin look like the tip and the spend built against it is
  invalid. The rule barring a negative answer from a view that could not have held the subject
  (stated for `WalletCoinByIdResult` above) applies here unchanged.

  The `spend` key MUST be present on every response; `null` is a verdict and MUST NOT be conveyed by
  omitting the field.

  `source` and the freshness fields follow the same tier rule as every other wallet read.
- **`WalletCoinsByParentResult`**: `{coins:[WalletCoinRecord], complete:bool, cursor:string|null,
  source:"db"|"fallback"|null, synced:bool, peak_height:u32|null}`. One PAGE of the DIRECT children
  created by spending one coin, named by that parent's coin id.

  Exactly ONE HOP. The list is what the named parent's spend created and nothing further: not a
  lineage, not a subtree, not transitive. A node MUST NOT recurse — an unbounded server-side walk
  over caller-supplied input is work the caller cannot bound, and a partial walk returned as a
  complete one is a lineage with a silent hole in it. A caller composes a lineage from repeated
  single hops.

  `coins:[]` MUST mean the node consulted a chain and that parent created no children it knows of,
  typically because the parent is unspent. A node that could NOT consult a chain MUST return the
  matching §5 wallet error instead — never an empty list. A caller walking a singleton forward reads
  an empty list as *this is the tip*.

  Every record MUST report `asset:null`: naming a coin by its parent classifies nothing.

  **The answer is BOUNDED and PAGED.** This is the only open wallet read whose cardinality is
  unbounded — every other returns a single record or is already paged — and there is NO request rate
  limiting on the control plane, so this bound is the only limit on the work a token-less caller can
  ask for. On the fallback tier the node forwards the caller's identifier to a third-party coinset
  oracle, so an unbounded page is unbounded work against another party's service.

  `limit` MUST be between 1 and `COINS_BY_PARENT_MAX_LIMIT` (1000); an omitted `limit` means
  `COINS_BY_PARENT_DEFAULT_LIMIT` (100). An out-of-range or zero `limit` MUST be REFUSED as `-32602
  INVALID_PARAMS` and MUST NOT be clamped — a caller resumes from a page boundary, so a silently
  shrunk page returns a cursor for a position the caller never asked about. (This deliberately
  differs from `control.wallet.arrivals`, where a node MAY clamp: that read's cursor is a ledger
  position the node owns, whereas this one's is a row the caller was handed.) `0` is refused
  separately: a page that holds nothing never makes progress. The maximum is derived from
  dig-ipc-protocol's `MAX_FRAME_BYTES` (1 MiB) — a worst-case `WalletCoinRecord` is ~350 bytes, so
  1000 records is ~350 KB, about a third of the frame, leaving headroom for the envelope.

  `complete` MUST state whether the page carries the LAST child. A node MUST NOT report
  `complete:true` on a page it truncated. A caller MUST NOT infer completeness from the page length:
  a node may return a short page for its own reasons, and a child set that is an exact multiple of
  the page size makes the final full page indistinguishable from a truncated one. This is normative
  and load-bearing — a caller walking a lineage reads "no more children" as the end of a branch, so a
  truncated page that looks whole presents a partial lineage as a complete one.

  `cursor` MUST be the `coin_id` of the LAST record actually returned, or `null` for an empty page,
  and the key MUST always be present. A caller resumes by passing it as `after_coin_id`. There is
  deliberately no chain-head marker on this type to resume from by mistake.

  A node MUST return children in ASCENDING `coin_id` order and MUST keep that order stable across the
  pages of one walk; `after_coin_id` means strictly after that id in that order. Without a fixed
  order a cursor names no position and a walk silently repeats and skips children. Coin ids are
  fixed-length lowercase hex, so ascending lexicographic and ascending 32-byte numeric order are the
  same order.

  The parameter is spelled `parent_coin_id`, NOT `coin_id`. The coin named is the one being asked
  ABOUT and is never the coin returned; a shared field name would make a recursive reading of the
  method plausible from the request alone.
- **`WalletPeakResult`**: `{peak_height:u32|null, synced:bool}`. The node's chain peak, independent
  of any address. `peak_height:null` means the node tracks NO height — it MUST NOT be read as height
  zero, which every block is trivially above. A caller bounding a claimed confirmation MUST treat
  `null` as unknown and fail closed.

  `synced` here reports ONLY that the replica's initial catch-up completed; it MUST NOT be read as
  "the wallet is caught up AND connected", which is `WalletSyncStatusResult.phase == "synced"` and
  is strictly stronger. Neither is a freshness guarantee.
- **`PeerCountsResult`**: `{dig_peer_count:u32|null, chia_peer_count:u32|null,
  known_dig_peer_count:u32|null}`. How many peers this node holds on EACH network, and how many DIG
  peers it knows of. The two networks are unrelated and their counts move independently.

  `dig_peer_count` MUST be the count of peers on the DIG content/gossip network (port 9445) — the
  node's `connected_peers`, the same figure `control.peerStatus` reports. `chia_peer_count` MUST be
  the count of CHIA full-node peers the wallet's chain sync holds, and MUST be the SAME observation
  `WalletSyncStatusResult.chia_peer_count` reports: a conforming node MUST serve both from ONE
  source, and the two MUST agree within a single node's view.

  Neither field may be named `peers`, `connected_peers` or `peer_count`. A name that does not state
  its network forces a consumer to know which number it is holding, and a consumer that guesses
  wrong fails SILENTLY — a plausible integer in a right-looking place.

  For each count, `0` MUST mean the node observed that network and found nothing connected, and
  `null` MUST mean the node cannot observe the count. A network that is not running is UNKNOWN and
  MUST NOT be reported as `0`, which would assert that nothing is connected to a network never asked.

  `control.peerStatus`'s `relay.peer_count` counts peers connected to THE RELAY, not to this node.
  It is frequently the only non-zero number on a node connected to nothing, and it is NEVER the
  answer to "how many peers does this node have" — `dig_peer_count` is.

  `known_dig_peer_count` MUST be the number of DIG peers this node has LEARNED OF — the size of its
  own discovered-peer address book — whether or not it is connected to them. It exists so that a
  client can tell a REACHABILITY fault (`dig_peer_count: 0` beside a large known count) apart from a
  DISCOVERY fault (`0` beside `0`); those have different remedies and, before this field, rendered
  as the same zero. It MUST NOT be derived from `dig_peer_count`.

  `known_dig_peer_count` is ONE node's local view and is therefore a LOWER BOUND. It MUST NOT be
  presented, by the node or by any client, as the size of the DIG network or as a total peer count:
  it omits every peer this node has not been introduced to, every peer reachable only through a
  relay it does not use, and every address book entry evicted under the node's own bucket limits.
  Two healthy nodes on one network will report different values and neither is wrong. A client MUST
  label it as known/discovered peers.

  `known_dig_peer_count >= dig_peer_count` normally holds, but is NOT an invariant a client may rely
  on: the two are sampled from separate structures and may invert transiently during pool churn.

  The field is OPTIONAL on the wire. A node predating it omits it, and a consumer MUST decode that
  omission as `null` (UNKNOWN) rather than rejecting the payload or defaulting it to `0`.
- **`WalletSyncStatusResult`**: `{phase:"not_started"|"syncing"|"synced"|"no_wallet_enrolled"|
  "wallet_not_unlocked", peak_height:u32|null, chia_peer_count:u32|null, watched_addresses:u32|null,
  subscription_peer_count:u32|null, chia_peer_peak_height:u32|null}`.
  Whether the node's WALLET CHAIN replica is being kept current, how far it has got, how many CHIA
  full-node peers its sync is using, and how many addresses it is actually following. This is NOT
  `control.sync.status`, which reports §21 DIG store sync and is unrelated.

  A conforming node MUST emit one of the five tokens above, spelled exactly. `"synced"` MUST require BOTH that
  the initial catch-up completed AND that at least one Chia peer connection is live at the time of
  the read; a wallet that caught up earlier and has since lost every peer MUST report `"syncing"`.
  `"synced"` is therefore STRICTLY STRONGER than `WalletPeakResult.synced`, which reflects only the
  completed-catch-up flag: `"synced"` implies that flag, the flag does not imply `"synced"`. A
  boolean MUST NOT be substituted for the named phases — "never started" and "synced at height 0"
  are different facts and MUST NOT render the same.

  `peak_height` MUST be the node's OWN replica's height, or `null` when it has none. It MUST NOT
  fall back to the coinset oracle, which `control.wallet.peak` deliberately does: that method answers
  what height the chain is at, this field answers how far this replica has got, and an oracle's
  number here would report a caller's own progress using a height the replica never reached.
  `null` MUST NOT be read as height zero.

  `chia_peer_count` counts CHIA FULL-NODE peers the wallet's chain sync is connected to. `0` is an
  OBSERVED zero and is not itself a phase: a running sync connected to nothing reports `"syncing"`
  with `0`, which a consumer SHOULD render as "syncing — no peers". `null` means the node cannot
  observe the count and licenses no claim about connectivity. This count is NOT the DIG
  gossip/content peer count from `control.peerStatus` (`connected_peers` / `relay_peer_count`); the
  two are unrelated numbers, and a surface labelling either one bare "peers" beside a wallet sync
  status asserts something false. A caller wanting both networks' counts reads
  `control.peerCounts`, whose `chia_peer_count` is this same observation under the same key and MUST
  agree with it. The field is duplicated across the two methods rather than moved, because
  `chia_peer_count:0` beside `"syncing"` is the honest "syncing — no peers" state and a phase
  separated from its count reads as a contradiction; a DIG content-network count, by contrast, is not
  a wallet fact and MUST NOT be added here for symmetry.

  **The two idle phases.** A sync with no addresses to follow MUST report which of two situations it
  is in, because they are opposite claims. `"no_wallet_enrolled"` MUST mean no wallet is enrolled on
  the node: there is nothing to watch, the state is correct and complete, and a consumer MAY present
  it as settled. `"wallet_not_unlocked"` MUST mean a wallet IS enrolled but the node holds no
  addresses derived for it, so the user's coins are being followed by nothing; a consumer MUST NOT
  render it as synced, settled, or up to date, and MUST NOT present a balance read under it as
  complete. A node MUST NOT emit a single token covering both, and MUST NOT report either as
  `"synced"`. The common cause of `"wallet_not_unlocked"` is that address derivation needs key
  material unavailable while the wallet is locked and nothing back-fills it until unlock, which makes
  it the ordinary state after a restart — the token names the OBSERVATION (no addresses held), not
  the lock, because a manifest that never carried the keys reaches the same state unlocked.

  `watched_addresses` MUST be the number of addresses the sync is actually following. `0` is an
  OBSERVED zero; `null` MUST mean the node did not report the number and MUST NOT be rendered as
  zero. It is the second fact that makes an idle sync readable: `0` beside `"no_wallet_enrolled"` is
  a complete picture, `0` beside `"wallet_not_unlocked"` is a wallet nobody is following. A node MUST
  NOT emit `{phase:"synced", watched_addresses:0}` — a sync following no addresses has caught nothing
  up — and a consumer meeting that pair SHOULD trust the count, which is the narrower claim.

  `subscription_peer_count` counts peers the REPLICA's own subscription supervisor is writing
  through. The supervisor holds AT MOST ONE such peer by design, so this is a 0-or-1 fact about
  whether the replica is currently being fed — NEVER a measure of network reach, and it MUST NOT be
  summed with `chia_peer_count`. Before dig_ecosystem#2806, `chia_peer_count` on this method
  incorrectly carried this narrower number, so a node with five wallet-sync peers reported
  `chia_peer_count:1`; the two fields now exist side by side so a conforming node reports both
  observations distinctly. `null` means no supervisor is attached at all.

  `chia_peer_peak_height` is the peak height this node's OWN Chia peers have ANNOUNCED — distinct
  from `peak_height` (the replica's own progress) and from any oracle reading. `null` MUST be used
  until at least one peer has reported a height; a conforming node MUST NOT report `0` as a stand-in
  for unobserved, since `0` is itself a real height a peer could announce.

  Both fields are OPTIONAL on the wire. A node predating them omits both keys, and a consumer MUST
  decode that omission as `null` rather than rejecting the payload or defaulting either to `0`.

  **An UNKNOWN token MUST NOT fail the response.** A consumer MUST accept any string in `phase` and
  represent one outside the five tokens as an explicit unrecognised value carrying the token
  verbatim, never as a deserialization error and never coerced onto a known phase. A closed token set
  is what turned one added phase into a total read failure in every consumer built against an older
  contract (dig_ecosystem#2609); a coercion onto `"synced"` or `"syncing"` would be worse still,
  converting that outage into a confident false statement about the user's funds. A consumer MUST
  render an unrecognised phase as unknown and MUST NOT infer progress, completion, or a trustworthy
  balance from it. Symmetrically, a consumer MUST tolerate a payload that OMITS any of the three
  count fields, reading each absence as unreported rather than zero, so a node predating a field
  stays readable.

  A phase value MUST NOT report itself as unrecognised while spelling itself as a known token.
  Decoding any phase's own wire spelling MUST return that same phase. An implementation that lets a
  caller attach an arbitrary spelling to its unrecognised representation reintroduces the defect this
  tolerance exists to remove — a value that calls itself unknown locally and arrives at the far side
  as `"synced"` — and MUST make that state unconstructible rather than merely discouraged.

  **Which phases may be presented as SETTLED.** Exactly two: `"synced"` and `"no_wallet_enrolled"`.
  A consumer MUST NOT present `"not_started"`, `"syncing"`, `"wallet_not_unlocked"`, or an
  unrecognised token as settled, complete, or nothing-to-do. `"wallet_not_unlocked"` is the one that
  invites the mistake, because it is idle and looks like `"no_wallet_enrolled"` from inside the sync
  loop while meaning the opposite.

  **Rendering an unrecognised token.** The token is untrusted node-supplied text and MUST be escaped
  before it reaches a terminal, a log, or a UI. A node emitting `"\u{1b}[2K\rsynced"` otherwise
  turns a consumer's `unknown phase: <token>` line into one that reads `synced`, because the escape
  sequence erases the prefix; a right-to-left override does the same to a label. A consumer MUST
  bound the rendered length as well: nothing caps the token on the wire, because rejecting an
  over-long one would reintroduce the fail-closed parse. The raw bytes remain available for RELAYING
  a payload on unchanged, which is the only use that needs them.

  **Emission ordering.** Tolerance protects consumers built against a LATER contract than the node;
  it cannot protect one built against an EARLIER contract, because the tolerance lives in the
  consumer. A node MUST NOT emit `"no_wallet_enrolled"` or `"wallet_not_unlocked"` until the
  consumers it serves are known to be at this contract version or later. dig-node and its clients
  update independently on a user's machine, so a node that adopts the new tokens ahead of its
  installed clients reproduces the total-parse-failure this revision fixes, on every one of them.
  The same rule binds every future token: **the contract may grow a phase at any time; a node may
  only emit it once its consumer floor has caught up.**

  `"synced"` MUST NOT be read as a guarantee that the replica's data is FRESH. A live connection to
  a stalled or lagging peer satisfies the predicate while the replica goes stale; the phase reports
  that catch-up finished and a peer is attached, i.e. that nothing KNOWN is preventing the replica
  from being kept current.

  **Field combinations.** `{phase:"synced", peak_height:null}` MUST NOT be emitted: a node records
  its peak before it marks the initial catch-up complete, so a completed catch-up always has a
  height behind it, and the pair describes a state no conforming node can be in.

  `{phase:"not_started", peak_height:<n>}` is NOT a contradiction and MUST be permitted. The height
  is persisted in the wallet database while the phase describes whether a sync is running IN THIS
  PROCESS, so a node that synced earlier and has just RESTARTED reports this pair truthfully: here
  is the height I reached, and no sync is running right now. A node MUST NOT suppress the height or
  fabricate a phase to avoid emitting it. `{phase:"not_started", peak_height:null}` is equally
  legitimate and means a wallet that has never synced.

  The height reported is the height of the LAST EXISTING block the peer view reported
  (`NewPeakWallet.height` / `RespondPuzzleState.height` from a real full node). This surface performs
  no confirmation-depth arithmetic; a consumer computing depth MUST floor its own input rather than
  assume a convention, because `peak_height` means the NEXT height on a simulator and the last
  existing block on a full node.
- **`WalletBroadcastResult`**: `{accepted:bool, transaction_id:string|null, rejection:string|null}`.
  The node pushes an ALREADY-SIGNED bundle; it MUST NOT sign, and MUST NOT accept any parameter it
  could sign with (§4.3).

  A mempool that examined the bundle and refused it is a SUCCESSFUL call reporting
  `accepted:false` with a `rejection` reason. Failing to REACH a mempool MUST be a §5 error instead.
  These MUST NOT be collapsed: the first says build a different bundle, the second says retry this
  one. `accepted:true` reports mempool admission ONLY — it is NOT evidence that anything reached a
  block, and a caller MUST NOT record an outcome from it. Only a buried confirmation of the created
  coin is evidence.
- **`WalletBalanceResult`**: `{balance:u64, pending:u64, source:"db"|"fallback"|null, synced:bool,
  peak_height:u32|null}`. A
  READ-only chain read over the loopback control plane — it reports state, never moves funds. `balance`
  is the CONFIRMED spendable amount in the asset's base unit (mojos for XCH, base units for DIG);
  `pending` is incoming-unconfirmed.

  `source` names the TIER that produced the figures, and every freshness field describes THAT tier:
  `"db"` is the node's own chain replica (`synced:true`, `peak_height` = the replica's peak);
  `"fallback"` is a third-party coinset HTTP oracle. `synced` is a CONCLUSION, and it is defined
  exactly: it is `true` if and only if the reported `peak_height` is the height the tier that ANSWERED
  reported in the SAME read that produced these figures, and that tier reports the figures complete as
  of it. A peak carried over from an earlier read, or obtained from any other exchange, party or tier,
  does NOT satisfy this. `synced:true` on a `"fallback"` answer therefore asserts only that ONE
  disclosed oracle answered self-consistently. It is not corroboration by the network and MUST NOT be
  presented as confirmation by it; a consumer that badges money as current from it MUST also surface
  the tier. `false` and `null` are the honest answer in three cases, and a node MUST emit them there:
  an answering tier that tracks no peak of its own; a CACHED row, which no live tier bounds; and a peak
  the node cannot bind to the same read as the figures. A `"fallback"` answer also means the queried address
  WAS DISCLOSED off-node. `source` is ABSENT/`null` only from a node predating tier disclosure — a
  third state meaning "tier unknown", never a defaulted tier; consumers MUST NOT treat it as either.
  `synced:false` means the figures are STALE, or came from a tier that tracks no peak; `peak_height` is
  the peak of the tier that ANSWERED (present as `null`, never omitted, when that tier tracks none).
  The `asset` request field is an **`Asset`** (see "Asset" below). This result is a strict
  SUPERSET of dig-app's `BalanceResponse {balance}`: a consumer reading only `{balance}` deserializes
  it losslessly (unknown fields ignored), which is the no-consumer-change guarantee pinned by a KAT.

Proxied results (`control.updater.*`, `control.pairing.list`, `control.peerStatus`) carry the
underlying source's shape verbatim and are modelled as an opaque JSON value; consumers MUST NOT freeze
a struct over them.

- **`PeerSoftware`** — a peer's advertised SOFTWARE build, the one member of the otherwise-proxied
  `control.peerStatus` snapshot whose shape this contract owns. Every entry of the snapshot's
  `connected` array MUST carry a `software` member; a peer entry that omits it is a serialization
  defect, NOT a peer of unknown build. Two forms, tagged by `kind`:

  ```json
  {"kind": "unknown"}
  {"kind": "reported", "product": "dig-node", "version": "0.99.1", "raw": "dig-node/0.99.1"}
  ```

  `unknown` MUST carry no `version` member — never `"0.0.0"`, never `""`, never `null`.

  The node derives it from the peer's gossip `Handshake.software_version` string. The mapping is
  normative:

  | Advertised string | Result |
  |---|---|
  | `product/semver`, both parts non-empty, version parsing as semver | `reported` |
  | `""` (the peer advertised nothing, or coarsened its build off) | `unknown` |
  | any advertisement whose version is VERSION ZERO — the LEGACY SENTINEL | `unknown` |
  | anything else unparseable | `unknown` |

  The product/version split is at the LAST `/`, so a product name may itself contain one.
  Surrounding whitespace is trimmed before parsing.

  **Version zero is a CLASS, not a string.** The rule MUST be applied to the parsed
  major/minor/patch triple, ignoring pre-release and build metadata: the bare `0.0.0`, a
  product-qualified `dig-node/0.0.0`, and every decorated form (`0.0.0-rc.1`, `0.0.0+build`,
  `0.0.0-0`) are all `unknown`. A string comparison would let the decorated forms through as real
  builds at version zero.

  **Why version zero is `unknown` and not a version.** Every dig-node built before this contract
  advertises the literal `"0.0.0"`: three of dig-gossip's four handshake send sites hardcoded it. A
  reader that treated it as a version would classify the entire live network as running software
  0.0.0, and any `>=` comparison would call all of it ancient.

  **Version zero MUST NEVER BE ADVERTISED.** It is a value received from a legacy peer, never one a
  conforming node sends — see `SoftwareVersionDetail` below for the one place that constraint
  binds.

  **`PeerSoftware` MUST NOT implement `Ord`, `PartialOrd`, or `Default`.** `unknown` has no position
  on a version line, and most peers are `unknown` today; a comparison is reachable only after
  destructuring `reported`, which forces a caller to decide what `unknown` means for its question.

  **Privacy.** Reporting a peer's exact build is a fingerprinting aid — it identifies which peers run
  a version with a publicly disclosed defect. Accepted for the diagnostic value on a pre-release
  network. A node that declines to advertise sends an empty string, which reads as `unknown` here and
  is indistinguishable from a build predating the field.

- **`SoftwareVersionDetail`** — how much of its own build a node reveals when it advertises. Wire
  tokens `"full"` (default) | `"minor"` | `"off"`, rendering:

  | Mode | Advertised for version `0.99.1` | Read back as |
  |---|---|---|
  | `full` | `dig-node/0.99.1` | `reported`, exact |
  | `minor` | `dig-node/0.99.0` | `reported`, patch level hidden |
  | `off` | `""` | `unknown` |
  | `minor` of a `0.0.x` build | `""` | `unknown` |

  `minor` MUST render `MAJOR.MINOR.0`, never a bare `MAJOR.MINOR`: a two-part version is not valid
  semver, so the coarse setting would be read as `unknown` and become a confusing second spelling of
  `off`. For the same reason, `minor` of a `0.0.x` build MUST render the EMPTY STRING: its
  coarsening is version zero, which is the `unknown` sentinel, and there is no coarser representable
  value — so it advertises nothing rather than advertising the sentinel as if it were a report.

  The binding invariant: **every rendering is either the empty string or a value that reads back as
  `reported`.** Coarsening reduces precision; it never yields a value that reads as `unknown` while
  looking like a report. `minor` MUST also strip pre-release and build metadata — a nightly identifier is more
  precisely identifying than the patch number beside it, so retaining it would coarsen nothing for
  exactly the builds that most want it. A coarsened `1.4.0` is indistinguishable from a genuine
  `1.4.0`; that is the purpose of coarsening, not a defect in it.

  Rendering is specified here, beside the parsing, because they are two halves of one format. A node
  MUST NOT hand-roll its own `product/version` string.

- **`AutomatedSpend`** — one spend the node made WITHOUT per-transaction approval:
  `{id:string, revision:u32, kind:string, purpose:string, authority:{principal:string,
  grant:string}, asset:Asset, amount_mojos:string, fee_mojos:string, store_id:string|null,
  initiated_ms:u64, updated_ms:u64, status:SpendOutcome, funding_coin_ids:[string],
  chain_reference:{coin_id:string, confirmed:bool}|null}`.

  `amount_mojos` and `fee_mojos` are decimal STRINGS — they carry the full `u64` range, which a JSON
  number does not survive through an f64 parser. `chain_reference` MUST be PRESENT as `null` when
  the node knows no coin id yet (never omitted), and its `confirmed` flag says whether the node
  OBSERVED that coin: a client MUST render an unobserved id as an intention, never as a fact.
  `funding_coin_ids` names the coins CONSUMED and is never confirmation evidence — a competing spend
  of the same funding coin consumes it identically while the intended coin never exists.

- **`SpendOutcome`** — internally tagged on `state`. Exactly five forms are valid:

  | JSON | Meaning |
  |---|---|
  | `{"state":"pending"}` | recorded, not yet handed to the network |
  | `{"state":"submitted"}` | accepted by the mempool; NOT a claim that it will confirm |
  | `{"state":"confirmed","height":u32,"coin_id":string}` | the chain shows the coin this spend created |
  | `{"state":"failed","stage":"signing"\|"broadcast"\|"confirmation","reason":string}` | the attempt ended in an observed failure |
  | `{"state":"unresolved","reason":string}` | the node signed and does not know how it ended |

  The height and coin id live INSIDE the `confirmed` form, so a record cannot hold a confirmation
  height without a confirmation. `failed` MUST carry its `stage`, and `unresolved` MUST NOT be
  reported as `failed` — see §4.2d.

- **`StatusResult.version`** already reports THIS node's own build; there is no separate method for
  it, and `control.peerStatus` covers both the point lookup ("what is that peer running") and the
  census (a group-by over the returned array).

### 4.2 `UNAUTHORIZED` on an open read means an OLD NODE, not a permission problem

A client MUST branch on which method it called:

- On an OPEN read — every method the §4 table marks `no`, today `control.wallet.balance` / `.coins` /
  `.coinById` / `.coinSpend` / `.coinsByParent` / `.peak` / `.syncStatus` and `control.peerCounts` — `-32030 UNAUTHORIZED` can only
  come from a node build that predates the method and gates the whole `control.*` namespace. The
  truth is "this node cannot do that yet" and the remedy is an UPGRADE.
- On a GATED method — `control.wallet.broadcast`, `.arrivals`, `.watch`, `.unwatch`, `.watched` — `-32030 UNAUTHORIZED` means exactly what it says, and the remedy is
  the CONTROL TOKEN.

A client that maps both to the same outcome sends a person to fix the wrong thing. `-32601
METHOD_NOT_FOUND` always means the method is absent, on either.

### 4.2a Wallet enrolment (`control.wallet.watch` / `.unwatch` / `.watched`)

Enrolment is how an authorized local client tells the node WHICH addresses to follow, so that a
wallet whose keys live outside the node can still be synced and read.

- The unit of enrolment is a **BLS G1 PUBLIC KEY**, 48 bytes, on the wire as lowercase 96-hex,
  unprefixed; a `0x` prefix MUST be accepted on input and MUST NOT be emitted. A node MUST derive the
  addresses from an enrolled key using the SAME derivation it applies to keys in its own custody, so
  that exactly one key→address mapping exists in the ecosystem. Enrolling puzzle hashes instead would
  require every client to re-derive independently, and a client with a narrower derivation window
  would under-report the funds it owns.
- No private key material is ever carried: a public key is public, and enrolment confers no ability
  to sign. §4.3 is unaffected.
- `control.wallet.watch` MUST be IDEMPOTENT. Re-enrolling an enrolled key is a SUCCESS reporting
  `added: 0`; duplicates within one request count once. `control.wallet.unwatch` mirrors it: a key
  that was never enrolled reports `removed: 0` and is not an error.
- A node MUST refuse the WHOLE request as `-32602 INVALID_PARAMS` when ANY submitted key is
  malformed, never the well-formed subset — a partial enrolment leaves the node following fewer
  addresses than the client believes it asked for, and the client's next balance read would report
  the shortfall as though the money were absent.
- The enrolled set MUST PERSIST across node restarts, and `unwatch` MUST actually stop the following
  — the addresses leave the replica's watched set, not merely the list the node reports.
- `control.wallet.watched` MUST return exactly the keys enrolment added, in the wire form they were
  accepted in, and MUST NOT include the node's own custody keys: a caller reconciling against a
  superset would unwatch keys it never watched.
- **Privacy.** Enrolling makes this node query its peers for those addresses, so its peers can
  associate them with this machine. That is already true of the node's own custody keys; it becomes
  true of the enrolling client's keys too, and a client SHOULD say so where a person can see it.

### 4.2a Coin reservations (`control.wallet.reservations.held` / `.reserve` / `.release`)

A reservation records that a coin is already committed to a spend that has been built but has not
settled. It is BOOKKEEPING: it holds no key, signs nothing, authorizes nothing (§4.3), and only
narrows what a coin selector is willing to choose.

**The window it closes.** Between building a spend and that spend confirming, the chain still reports
its inputs as UNSPENT — the bundle is in a mempool, not a block. A second build in that window sees
the same coins, applies the same selection rule, and picks the same coin. The second bundle can never
be included, and it fails AFTER the money moved.

**Why the method exists rather than a per-process table.** A wallet key can be in use by more than
one process at once — dig-app holding the key, and a dig-node serving the same wallet. Two
independent reservation tables re-create exactly the double-select each of them fixes locally. These
methods let a client back its own reservation seam with the NODE's table, so both processes narrow
against ONE set.

**Authority.** Where a node is reachable, the NODE's set is authoritative and a client MUST defer to
it. A purely local set is a fallback for the no-node case only, and a client using one MUST NOT treat
it as covering another process.

- **All-or-none acquisition.** `reserve` MUST take every coin in `coin_ids` or none. Reading the held
  set, selecting, then reserving is check-then-act, and two callers racing it both take the same
  coin; atomic acquisition is what closes that. On a clash a node MUST have written NOTHING and MUST
  answer `-32046 WALLET_COINS_RESERVED`.
- **A conflict is a WAIT, never a shortfall.** `WALLET_COINS_RESERVED` is deliberately distinct from
  any insufficient-funds code. The user HAS the money; it is briefly committed and returns when that
  spend settles or its hold lapses. Reporting a shortfall sends a person to an exchange to solve a
  five-minute wait.
- **Reservation narrows SELECTION, never BALANCE.** A reserved coin is still the user's money and
  still counts toward what they hold. A client MUST NOT subtract `reserved` from a displayed balance.
- **Every hold expires.** `reserve` MUST apply a finite lifetime whether or not anyone releases, so
  an abandoned or crashed build cannot strand funds. A node clamps the requested `ttl_secs` to its own
  maximum, applies its default when none is given, and MUST return the lifetime it ACTUALLY applied —
  a caller told nothing would release on a schedule the node does not keep. The contract has no way
  to express a hold that never expires.
- **Explicit release, because the TTL alone is not enough.** `release` frees a hold the moment its
  spend is known settled or known dead, rather than holding a person's coins for the rest of a window
  over a question the chain has already answered. Releasing a handle that names no live reservation
  MUST be a SUCCESS reporting `released:false` — a caller releasing on confirmation cannot know
  whether the TTL got there first, and an error there teaches callers to discard the result. Release
  MUST free every coin the handle holds or none of them.
- **An empty `coin_ids` MUST succeed**, yielding a handle that releases nothing. An empty reservation
  can never conflict, so refusing it would make a legitimate no-op selection look malformed.
- **Fail direction: REFUSE.** A node that cannot read its reservation set MUST answer
  `-32047 WALLET_RESERVATIONS_UNAVAILABLE` and MUST NOT answer an empty list. `reserved: []` is a
  positive statement that nothing is held and permits a caller to spend; "I cannot tell" must stop
  one. Collapsing the two restores the double-select these methods exist to prevent.
- **The caller does not supply the time.** `held` takes no parameters. A caller-supplied `now` would
  be a lapse oracle — a far-future value makes every live hold read as expired. The node reads its
  own clock and reports it as `as_of_unix` so a client can SEE skew rather than impose it.
- **`reservation_id` is OPAQUE.** A client stores it and sends it back, and MUST NOT parse, derive or
  construct one. A handle a caller can guess lets it release a reservation it does not own, which is
  the double-select reached through the front door.
- **No key material (§4.3).** A coin id is a public chain fact. There is no seed, key, signature or
  bundle field on any of these methods and there never may be.
- All three are TOKEN-GATED, including `held`: the caller supplies nothing, so the answer names this
  node's OWN in-flight commitments — the same reasoning that gates `control.wallet.watched`.

### 4.2b dig-profile bodies (`control.profile.putBody` / `.getBody`)

A dig-profile is a chain-anchored ROOT plus the body that root commits to. The root is written on
chain by whoever holds the key — never by the node (§4.3). These two methods move only the BODY.

- **`control.profile.putBody` is a trust boundary the node MUST NOT skip.** On every call the node
  MUST independently resolve the profile's root on chain, recompute the root of the supplied body,
  and REFUSE the call unless the two agree and that root is CONFIRMED. The caller's `root` is a
  claim to be checked, never a fact to be trusted.
- **dig-app receives no exemption.** It signs and pushes the root, but the bytes it then hands over
  reach the node exactly as a peer's bytes do, and the same rule binds both: a body is checked
  against the on-chain root and anything that does not match is rejected. A node that stores what it
  is handed can be made to serve arbitrary bytes to the network under another party's profile id.
- **Refusal is an ERROR, never a success.** A rejected body MUST NOT return `Ok` with `stored:
  false`; reaching a successful result asserts both that the root was confirmed and that the body is
  persisted and servable.
- **Bodies are bounded at `MAX_BODY_BYTES` = 4 MiB, on the DECODED bytes.** A larger body MUST be
  refused as `-32602 INVALID_PARAMS` before it is persisted. The bound is half of dig-gossip's
  `WS_MAX_MESSAGE_BYTES` (8 MiB), the frame ceiling the body must fit inside when this node serves
  it to a peer over `PROFILE_BODY` (opcode 225); a body accepted here but unservable there would be
  stored and permanently unsyncable. The contract states the cap so a client can check before it
  sends rather than discovering it as a failed round trip.
- **`control.profile.getBody` answers at the root it was ASKED for.** A node MUST NOT substitute a
  newer body it holds. `body_b64: null` MUST mean "this node was consulted and holds no body at that
  root"; a read that FAILED MUST return a catalogued error instead.

### 4.2c Subscription `kind` is read tolerantly

`control.subscribe` takes an OPTIONAL `kind` of `"capsule"` or `"profile"`. A `profile` subscription
additionally follows the profile ROOT and syncs its body from peers.

- **An ABSENT `kind` MUST mean `"capsule"`,** on the wire and on disk. Every subscription written
  before the field existed is untagged, and a node MUST keep reading those rows: a node that refuses
  its own pre-existing `subscriptions.json` starts with an empty one, and the upgrade silently
  unsubscribes the user from everything they had.
- The same tolerance binds the RESULT: a client MUST parse a `control.subscribe` acknowledgement
  from a node build that predates the field, treating the absent `kind` as `"capsule"`.
- An UNRECOGNISED `kind` token is `-32602 INVALID_PARAMS`; absence is not.

### 4.2d The automated-spend audit record (`control.spends.list`)

`control.spends.list` is the ONE sanctioned way to read the spends a node made without
per-transaction approval. The record itself is node-private (dig-node SPEC §23): it is a file the
node owns, and every other view — dig-app's Activity tab included — reads it through this method. A
second process parsing that file would be a second implementation of a growing append-only format,
which is how two views of "what did the node spend" begin to disagree.

**The method is read-only, and the catalog offers no companion that is not.** A conforming node MUST
NOT let this call initiate, sign, retry, cancel or amend a spend, and MUST NOT expose a control
method that edits or deletes an entry. The record replaces authorization with accountability, and a
record that can be edited accounts for nothing.

**A failure MUST carry the stage it died at.** Only `stage: "signing"` means the money definitely did
not move: no signed bundle ever existed, so nothing could reach a mempool. `"broadcast"` and
`"confirmation"` both happen after a valid signed bundle exists, and neither observation proves
absence — a rejection this node saw does not bind a network it does not fully observe. A node MUST
NOT emit a bare `failed` without a stage, and a client MUST NOT render a `broadcast` or
`confirmation` failure as settled. Collapsing the distinction makes every surface structurally
unable to tell a person the truth about their own money.

**`unresolved` is a state, not a kind of failure.** It means the node signed and does not know how it
ended — a timeout, a restart mid-flight, a producer that dropped the spend. A node MUST NOT report it
as `failed`, and a client MUST NOT fold it into a failure bucket: saying "it did not happen" about a
spend that landed is the same class of lie as claiming an unconfirmed success.

**Money amounts are decimal STRINGS.** `amount_mojos` and `fee_mojos` carry the full `u64` range,
which a JSON number does not survive through an f64 parser.

**A page states its own completeness.** `spends` is bounded by 500 rows (default 50). `complete` MUST
be `false` whenever a matching row was withheld, and MUST NOT be inferred by a client from
`spends.len() < limit` — a node may return a short page for its own reasons, and a matching set that
is an exact multiple of the page size makes the last full page indistinguishable from a truncated
one. Without the flag a caller cannot tell "there are no more spends" from "we stopped telling you",
and on an audit record those read the same and mean opposite things. A `limit` of `0`, or above 500,
MUST be refused as `INVALID_PARAMS` rather than clamped: a silently shrunk page hands back a cursor
for a position the caller did not ask about.

**The order is part of the contract.** Rows are returned by DESCENDING `initiated_ms`, ties broken by
ASCENDING `id`, and the order MUST stay stable across the pages of one walk. `after_id` means
strictly after that row in that order. The tiebreak is required rather than incidental: automated
spends are issued by a cycle and several can share a millisecond, so a time-only order names no
position and a walk would repeat some rows and skip others.

**Unreadable entries are part of the answer.** `unreadable_lines` counts entries the node could not
parse, across the whole record rather than the page — a corrupt entry has no parsed timestamp and no
parsed id, so it can be attributed to neither. A client MUST surface a non-zero value: an audit trail
that lost rows and reads as a tidy shorter one is the same lie as a missing entry, told more
convincingly.

**An empty page is an answer; an unreadable record is an error.** `spends: []` with `complete: true`
means this node moved no money unattended matching the filters, and a record that was never written
answers exactly that way — a node that has never spent automatically is the ordinary case. A record
that could not be read AT ALL is `-32048 SPEND_AUDIT_UNREADABLE`, never an empty page: "nothing to
report" and "I could not look" are different answers, and the first is the one a person stops
investigating on.

**The read is token-gated although it is a read.** The caller supplies no identifier, so the answer
is this node's OWN spending history — the same rule that keeps `control.wallet.arrivals` gated.

### 4.2e Mirror collateral: the epoch requirement and the local safety margin

Two different kinds of value live in this category and MUST NOT be conflated. The **requirement** is
consensus-derived: every node derives the same per-store figure for an epoch from the same census, and
a mirror advertisement is counted in that epoch only if it posts at least that figure. The **safety
margin** is a LOCAL operator preference that changes only how much THIS node chooses to lock over the
requirement.

**The margin MUST NOT be a consensus input.** No value derived from the margin MUST reach a census, a
controller signal, or any value another node derives. `control.collateral.requirement` MUST return the
PRE-margin requirement; a node that returned the margined amount would present its own preference as
the network's price.

**The margin is an unsigned integer count of BASIS POINTS (`100` is +1%), and MUST NOT be converted.**
It is the unit `dig_mirror_collateral::apply_safety_margin` takes and the unit dig-app `SPEC.md` §3.7b
fixes for `AgentConfig.collateral.margin_bp`; the `dign` CLI persists the same integer under the same
key. A percentage or a float is not an alternative spelling of it — `1` bp (0.01%) is a legal margin
that any conversion to whole percent erases.

**The node is the authoritative home for the margin.** The flywheel is headless, so an install with no
GUI MUST be able to set this; `control.collateral.margin.set` is how a graphical client reaches the
same stored value rather than keeping one of its own.

**A margin above `MAX_SAFETY_MARGIN_BP` (10000 bp, +100%) MUST be refused as `-32602 INVALID_PARAMS`,
never clamped.** The bound exists because `.set` is a money-path mutation reachable with an ordinary
paired token, and the margin arithmetic saturates rather than failing, so an unbounded value produces a
silently enormous posting instead of an error. Refusal rather than clamping is required because the
caller is stating an intent now: applying a different number than the one requested would leave the
caller's stored intent and the node's behaviour disagreeing about money. `control.collateral.margin.set`
MUST return the margin actually in force, and for an accepted request that value MUST equal the
requested one.

**A node whose configuration predates the field MUST report `DEFAULT_SAFETY_MARGIN_BP` (100 bp, +1%),
never `0`.** A zero margin is a deliberate choice to post the requirement exactly; reporting it for a
configuration that never expressed one tells an operator they declined a cushion they never declined.
The default errs high because the failure is asymmetric — under-posting likely costs that epoch's
rewards, while over-posting costs only the opportunity cost of the locked $DIG.

**An unknown requirement MUST be stated as unknown, with its reason.** A node that has not censused the
epoch, or that sits inside `CENSUS_FINALITY_DEPTH_BLOCKS` of the chain tip, MUST return
`{state:"unknown", reason}` and MUST NOT return `0`, an error a client would render as "no collateral
required", or a previous epoch's figure presented as this epoch's. The five reasons —
`not_censused`, `behind_finality_depth`, `record_unreadable`, `no_chain_source`, `balance_unreadable` —
name DIFFERENT missing facts with different remedies, and MUST NOT be collapsed into one.

**`balance_unreadable` is the WALLET axis and MUST NOT be spelled as any of the other four.** A node
whose census, record and chain reads all succeed but which cannot read its own $DIG balance does not
know whether it could fund what the record prices. Reporting that as `record_unreadable` or
`no_chain_source` sends an operator to repair machinery that is working. It is also NOT a shortfall: a
node in this state MUST NOT report `unfunded` for the bonds it cannot price (§25.8), because the wallet
may be full and the node holds no evidence either way. `known` and `unknown` are variants of
one tagged union precisely so that no representable value carries a figure the node was not given; this
is what dig-app `SPEC.md` §3.7b requires when it forbids any path that renders an absent requirement as
a zero cost.

**A `known` requirement MUST declare the collateral protocol version that COMPUTED the epoch** — not
the newest version the node's build implements. The two differ exactly when a node upgraded
mid-schedule, which is the one case where a client needs to tell a rule change from a disagreement. A
requirement without its version MUST be rejected by a reader rather than defaulted.

**The census inputs travel with the figure.** `stores` counts qualifying `(owner, store, root)`
advertisements — one owner publishing two roots for one store id contributes two — and `owners` counts
distinct owner puzzle hashes. Neither is a node count or an operator count, and a surface displaying
`owners` MUST say "collateralised owners". `multiplier_micros` is in millionths and
`handicap_dig_base_units` is in DIG base units.

**These methods are TOKEN-GATED although two of them are reads.** The requirement's figure is derivable
from chain by anyone, but its `unknown` branch names this node's OWN census position and the caller
supplies no identifier, so the answer is a fact about this node rather than a relayed public one. They
are NOT open reads (§4.2), and they are NOT master-token methods (§2.1): setting a margin grants the
caller no authority that outlives the token, and the operator can revoke and reset it.

**The count of stores THIS node holds is not served here.** It is served by
`control.collateral.buffer` (§4.2f) as `pairs_served_by_this_node`, together with the buffer that
figure feeds; duplicating it in the requirement response would create a second source of truth for
a value on the money path.

### 4.2f The recommended $DIG buffer and the node's funding state

`control.collateral.buffer` states the $DIG a node recommends HOLDING and where that node stands
against the figure. It is a SEPARATE method from `control.collateral.requirement`, and MUST remain
one: the requirement is consensus-derived and identical on every node, whereas the buffer is LOCAL —
it rests on the `(owner, store, root)` pairs this node serves, on an operator preference (the safety
margin), on this node's unreclaimed collateral, and on a horizon this node chose. Folding it into the
requirement's result would make one node's local position look like a network figure, which is the
same conflation §4.2e forbids for the margin.

**A client MUST NOT derive this figure.** The buffer is
`pairs_served_by_this_node x required_per_store x (1 + margin)` plus the transition overlap plus the
escalation headroom. The first term is THIS node's served set, and the census `stores` figure returned
by `control.collateral.requirement` is a network-wide advertisement count that MUST NOT be substituted
for it; the overlap term needs reclaim state no other method exposes. A client multiplying the census
count by the requirement produces a confident, badly wrong number on a money surface. The node owns
the calculation for two further reasons: `dign` MUST answer the same question on a headless host where
no notification will ever fire, and two independent derivations of one money figure will disagree.

**All amounts are in DIG BASE UNITS**, `$DIG` carrying 3 decimals so that one base unit is
`0.001 DIG`. They are NOT mojos — a mojo is XCH's base unit at `1e-12` XCH, nine orders of magnitude
away. `margin_bp` is in BASIS POINTS (`100` is +1%) and MUST NOT be converted, per §4.2e.

**`recommended_buffer_dig_base_units` is authoritative and the other terms are the working.** A client
MUST render the node's total rather than re-adding the terms and preferring its own sum: the rounding
lives in the node's arithmetic. `pairs_served_by_this_node`, `required_per_store_dig_base_units`,
`margin_bp`, `overlap_dig_base_units` and `escalation_headroom_dig_base_units` exist so a surface can
show WHY the number is what it is; `spendable_dig_base_units` is the balance the verdict was reached
against, and carrying it is what makes `funding_state` checkable rather than merely asserted.

**The horizon MUST travel in the payload and MUST NOT be implied.** Escalation of the per-store
requirement is bounded at `+1/ESCALATION_UP_STEP_DENOM` (`+12.5%`) per epoch and COMPOUNDS — about
x1.12 at one epoch, x1.60 at four, x4.62 at thirteen — so the same buffer quoted over different
horizons answers different questions and neither can be checked without knowing which.
`horizon_epochs` states how many future epochs the headroom covers and `escalation_ceiling_micros`
states the compounded multiplier assumed, in millionths. A reader MUST reject a `known` payload
missing either rather than defaulting it. `escalation_ceiling_micros` is a WORST CASE and MUST NOT be
presented as a forecast: inside the controller's dead band the multiplier does not move at all.

**The node decides the funding state; a client MUST NOT re-derive it from thresholds.** Two clients
choosing their own thresholds will disagree, and the one that disagrees about a funding warning is the
one an operator acts on. The four states are:

| `funding_state` | meaning |
| --- | --- |
| `short_now` | cannot cover the CURRENT epoch; stores this node serves are already going uncollateralised |
| `dangerously_low` | covers now, could not cover the NEXT epoch at the escalation ceiling |
| `below_recommended_buffer` | covers every epoch in the horizon but holds less than the buffer: funded, no cushion |
| `funded` | holds at least the recommended buffer over the stated horizon |

`short_now` and `dangerously_low` leave an epoch uncovered. **`below_recommended_buffer` is a READOUT
and MUST NOT be raised as a recurring notification**: a healthy node sits there much of the time, and a
client that alerted on it would teach an operator to dismiss the two states that matter. Whether a
state warrants interrupting somebody is otherwise the client's decision; which state the node is in is
the node's.

**An undeterminable buffer MUST be stated as unknown, with its reason, and MUST NOT be a zero.** On the
requirement a fabricated zero reads as a free requirement; here it reads as *no buffer needed*, and an
operator acting on it posts nothing and loses the epoch. The four reasons — `requirement_unknown`,
`served_set_unknown`, `reclaim_state_unknown`, `balance_unknown` — name DIFFERENT missing facts with
different remedies and MUST NOT be collapsed. `requirement_unknown` deliberately does not restate
§4.2e's taxonomy; a client needing to know WHICH census fact is missing calls
`control.collateral.requirement`. `known` and `unknown` are variants of one tagged union so that no
representable value carries a figure the node was not given.

**The read is TOKEN-GATED although it is a read.** The caller supplies nothing, so the answer is this
node's own served set, operator preference and balance — an association, not a relayed public fact. It
is NOT an open read (§4.2) and NOT a master-token method (§2.1).

**Nothing on this contract claims a margin guarantees inclusion.** The requirement is re-derived every
epoch and can rise by more than any margin chosen.

### 4.2g The per-`(store, root)` mirror bond state (`control.mirror.bondStates`)

`control.mirror.bondStates` states, for every mirror bond this node holds, whether it is bonded and —
when it is not — WHY, together with the $DIG those bonds have locked. It is the surface dig-node
`SPEC.md` §25.8 requires, and it is served over the control plane before the node adopts it
(release-first).

### 4.2h The mirror advertise-URL override (`control.config.setMirrorAdvertiseUrls`)

Today the URLs a node advertises in its mirror-coin memos are read from a single environment
variable, `DIG_MIRROR_ADVERTISE_URLS`, with no control method and no UI: an operator must know the
variable exists, know its format, and edit a service's environment by hand. This method is how a
client (dig-app first) sets or clears the SAME override, and it is served over the control plane
before dig-node adopts it (release-first, matching `control.mirror.bondStates` above).

**Params carry ONE optional field, `urls`, and its THREE states are normative:**

- `null` or an absent key CLEARS the override. The node reverts to dig-node#562's DERIVED default —
  its own discovered public address and content-serving port.
- A NON-EMPTY list SETS the override to exactly those URLs.
- An EXPLICIT EMPTY list (`urls:[]`) MUST be REFUSED as `-32602 INVALID_PARAMS`. It MUST NOT be
  silently taken to mean either of the above: it is genuinely ambiguous between "advertise nothing"
  (forfeiting every mirror reward this node could otherwise earn that epoch) and "go back to
  automatic", and guessing wrong either forfeits money the operator did not intend to give up, or
  silently overrides a deliberate choice to stop advertising. An implementation MUST perform this
  refusal itself — decoding the params alone enforces nothing beyond their type.

**Validation is well-formedness ONLY — an absolute URL with a scheme and a host.** An implementation
MUST NOT apply dig-node#562's routability or "this-machine-only" checks to REJECT this call.
dig-node#562 established an asymmetry that this method MUST preserve: an OPERATOR's LAN or private
address is a deliberate choice risking only their own stake, while the SAME address DERIVED
automatically is a broken reading of this node's own position, so only the derived path applies the
stricter rule. This contract cannot know which path a given string will take — that decision belongs
to dig-node's own advertise module, applied when it turns this override into what it actually
publishes — so applying the stricter rule here would silently break every LAN-only deployment while
looking like a safety improvement.

**The result MUST state whether the change is live yet — it MUST NOT assume either answer.**
`SetMirrorAdvertiseUrlsResult` is `{mirror_advertise:MirrorAdvertiseView, requires_restart:bool}`
(§4.1); `mirror_advertise` is what a follow-up `config.get` will show ONCE this takes effect, and
`requires_restart` says whether that is NOW or after a restart. `state` MUST be one of the six
values dig-node#562's `AdvertiseState` defines; a client MUST treat it as the authority on *why*
`urls` looks the way it does, and MUST NOT infer a reason from an empty `urls` list alone — four of
the six states produce one, for four different reasons with four different remedies.

**`requires_restart` is a report of the answering node's OWN implementation, and this contract
fixes it in NEITHER direction.** dig-node#562's advertise decision is recomputed every
mirror-coin-creation pass rather than read once at process start, unlike
`control.config.setUpstream`'s override — so LIVE is a real, reachable answer here, and an
implementation MUST NOT be required to answer `requires_restart:true` unconditionally merely
because a sibling method does. It is, however, NOT the answer today: dig-node#562's recomputation
reads `DIG_MIRROR_ADVERTISE_URLS` from the OS ENVIRONMENT of the running process, and no control
call can change the environment of a process already running. An implementation backed by that
variable MUST answer `requires_restart:true`; it MUST answer `false` only once it persists this
override somewhere its own advertise pass actually re-reads (a config-store write this call feeds,
never an environment variable). **Answering `false` while still reading the environment is a
surface lying about whether a privileged action took effect** — dig-app would render "saved" while
the node keeps advertising its old value, which is the one class of defect this contract never
defers regardless of priority (CLAUDE.md §1.2).

**This method is TOKEN-GATED at the ORDINARY tier, never the MASTER tier (§2.1).** Its effect
outlives the token exactly as `control.chiaPeers.add` and `control.config.setUpstream` do, but the
MASTER-tier rule is narrower than "outlives the token": it is whether the effect installs a principal
the node will thereafter BELIEVE, OBEY, or SPEAK TO. This method changes only what THIS node
broadcasts ABOUT ITSELF in its own mirror-coin memo — an implementation MUST NOT dial the configured
value, MUST NOT treat bytes read from it as trusted input, and MUST NOT forward any request to it. A
caller holding only a paired token cannot use this method to make the node trust or obey anyone new,
which is the same reason `control.cache.setCap` and `control.log.setLevel` stay ordinary despite also
persisting past the call that set them.

### `control.wallet.operatorAddress` — which wallet is the MACHINE's

The node has an operator wallet of its own, machine-custody and autoseed-derived, and it is the
wallet that pays mirror-coin collateral. It is NOT the user's wallet. This method names it.

It exists because nothing named it, and the cost of that was measured: a node reported three mirror
bonds `unfunded, short 1010` while the operator's own wallet held 1,015,000 base units of $DIG. Both
statements were true and each was about a different wallet, and no surface anywhere let a person tell
which. An operator who wants to FUND the machine wallet needs its address, and had no way to obtain
one.

The method MUST hold four properties:

* **It returns a DESTINATION and nothing that can spend from it.** An address and its puzzle hash,
  both public in the sense a coin id is. A seed, mnemonic, private or extended key, or derivation
  material MUST NOT appear in the answer in any encoding. The node signs its own mirror spends and no
  key leaves it (§908); a method exporting one would move the node from machine custody to none.
* **It is TOKEN-GATED**, by the same rule as `control.wallet.arrivals`: the CALLER does not name the
  address, so the node volunteers its own node-to-address association. That mapping is not public,
  and a stranger able to ask any node for it learns something no open read discloses.
* **It is OWNED, never delegated.** The question is which wallet THIS node spends from. Forwarding it
  upstream returns another machine's address under field names that still say "operator" — a
  confident wrong answer, and precisely the confusion the method exists to end.
* **It answers `unavailable` with a reason rather than a blank or placeholder address**, and it
  performs no mutation: reading it MUST NOT create, initialise, unseal-and-cache or rotate a wallet.
  A node that has none answers `not_initialized`, which is not a fault; a node whose wallet is
  present and unreadable answers `unreadable`, which is one, and such a node cannot pay collateral
  either.

**Its whole purpose is that "no coin yet" is never one answer.** Seven of the eight states mean there is
no current-epoch coin, and each calls for a different response from a person. A client MUST NOT
collapse any two of them; conflating "out of funds" with "withheld on purpose" produces hourly funding
alarms about a perfectly healthy node, which is the defect this method exists to remove.

| `bond_state` | payload | means | remedy |
| --- | --- | --- | --- |
| `bonded` | `coin_id`, `epoch`, `amount_dig_base_units`, `urls`, `url_current` | a coin for this pair and epoch is on chain | none |
| `pending` | — | a create is submitted and unconfirmed | wait |
| `unfunded` | `short_dig_base_units` | the wallet cannot cover this create | add $DIG |
| `deferred` | `reason` | the epoch requirement is unknown, so no create can be PRICED — including `balance_unreadable`, where the node cannot read its own $DIG balance | none; the wallet may be full |
| `withheld` | — | the capsule has `Relayed` provenance: held, deliberately never advertised | none |
| `disabled` | — | collateralisation is switched OFF for this node | the operator's own switch |
| `unadvertised` | — | collateralisation is ON, but the node has no publishable advertise URL, so it advertises nothing and a coin would bond nothing | publish an advertise URL |
| `reclaiming` | `coin_id`, `epoch`, `amount_dig_base_units` | a live coin is being reclaimed; the money is STILL LOCKED | wait |

**`disabled` and `unadvertised` are node-wide, and only ONE of them is a fault.** Both mean every row
reads the same token together and no coin exists for any bond. They differ in whether the operator
already knows: `disabled` is that operator's own switch, and a client MUST NOT present it as a fault,
while `unadvertised` is that switch ON and the node silently unable to honour it because no entry in
its advertise-URL list is publishable. A client MUST surface `unadvertised`, and an implementation MUST
NOT serve it as `disabled` -- doing so obliges a conforming client to stay silent about the one thing
that is wrong. It MUST NOT serve it as `unfunded` either: the wallet may be full, and no amount of $DIG
creates a coin that would advertise nothing.

**A `known` answer NAMES THE WALLET its amounts are about, in `funding_wallet`.** Every figure on the
page — each `unfunded` shortfall, each bonded amount, and `locked_dig_base_units` — is a statement
about the node's own MACHINE-custody operator wallet and not about the user's. It MUST be the same
value `control.wallet.operatorAddress` reports, carried in the same type so the two surfaces cannot
drift into two spellings of one fact, and it MUST appear on the ANSWER rather than on each row: the
funding wallet is node-wide, so a per-row copy is a field that cannot vary while reading as though it
could, and two rows of one answer could then be written to disagree. It is carried rather than left
to a second call for the same reason `epoch` is: a follow-up call is a second observation, and a page
of amounts can be rendered and acted on before it returns.

**Every `bonded` row carries `urls` and `url_current`, and a `known` answer carries `url_reconcile`
beside `funding_wallet`.** `urls` is the coin's OWN memo, read at the moment it was minted — a coin
created before the node's public address changed still carries its OLD address, and `urls` is how a
client sees that without decoding the memo itself. `url_current` is whether `urls` set-equals what
this node advertises THIS PASS; `false` is not a fault by itself, it is exactly the condition
`control.mirror.reconcile` (§4.2i) exists to fix. `url_reconcile` is this node's URL-reconciliation
standing as a whole: `auto_enabled` (is the daily detector, dig-node#570, armed on this node — `false`
means only that nothing will run reconcile without a manual call, never a reason by itself),
`personal_day_offset_secs` and `next_check_unix_ms` (both `null` together when `auto_enabled` is
`false` — an offset with nothing to offset is not a fact about this node), `last_observation` (what
the daily detector, or the last manual call, most recently saw — `null` if this node has never
observed its own advertise state; and when present, `conclusive: false` means BOTH `urls` and `state`
are `null`, because an inconclusive observation has nothing to report and guessing under either field
would assert a fact the node does not have), `stale_bonds` (the count of `url_current: false` across
the WHOLE bond set, never just the current page), and `auto_reconciled_this_epoch` (lets a client
distinguish "autopilot already handled it" from "everything was already current" — both otherwise read
identically as `stale_bonds: 0`). Both fields are ADDITIVE to a `known` payload consumers destructure
by name; a client MUST decode it with unknown-field tolerance (`..`/`#[serde(default)]` or
equivalent) rather than exhaustively, so a future additive field never breaks it. Without
`url_reconcile` a client could not decide whether "reset mirrors" would do anything without a
`dry_run` round trip on every render, and could not tell whether a `submitted` reconcile it kicked off
earlier has landed without re-deriving `stale_bonds` itself from every row on every page.

**`unfunded` is the ONLY state a client may raise a funding alarm on.** `deferred` in particular is not
one: the node does not know the price, and an operator sending money in response changes nothing.

**`withheld`, `disabled` and `reclaiming` are three different states and this specification names them
apart deliberately.** dig-node's internal `BondState` used `Withheld` for the node-wide switch, while
dig-node `SPEC.md` §25.8 used the same word for `Relayed` provenance. They differ in SCOPE — one switch
for the node, versus one capsule's provenance — and in REMEDY, so an implementation MUST NOT serve one
under the other's name. In this contract `withheld` carries §25.8's meaning, `disabled` is the switch,
and `reclaiming` is §25.8's seventh state, which `BondState` had no variant for even though its money
is still locked. Consequently dig-node MUST rename `BondState::Withheld` to `Disabled` and add
`Withheld` and `Reclaiming`, and dig-node `SPEC.md` §25.8 MUST gain `disabled`.

**A node that enumerates only its desired-bond set can never emit `withheld`.** A `Relayed` capsule is
by construction absent from the `Held` set, so such an implementation answers "no such row" where this
contract promises "withheld on purpose". An implementation MUST enumerate the SERVED set, and one that
cannot MUST say so rather than report the state as satisfied. **Saying so has exactly one spelling:
`{state:"unknown", reason:"provenance_unknown"}` for the WHOLE call.** An implementation that cannot
determine a held capsule's provenance MUST answer that, and MUST NOT return a `known` page — a page
whose withheld rows are silently absent asserts, via `complete`, a completeness the node knows it does
not have. `provenance_unknown` is the only reason of the four that is not an infrastructure failure,
and it exists so that this limitation is REPORTABLE: without it a provenance-blind node has no
conforming answer at all, and `withheld` would be a state a conformance list could tick while no
surface could ever emit it.

**"No bond" and "cannot tell" are answers at DIFFERENT levels.** Every per-row state is a definite
statement. A node that cannot enumerate its bonds, cannot read chain, or cannot read its own in-flight
creates, or cannot determine the provenance of what it holds, MUST answer `{state:"unknown", reason}`
for the WHOLE call — `served_set_unknown`, `chain_unreadable`, `in_flight_unknown` or
`provenance_unknown` — and MUST NOT degrade individual rows or return a shorter
list. There is deliberately no per-row unknown: a truncated list and a complete one read the same, and
the rows a broken read would drop are exactly the bonds nobody is then watching. `entries: []` with
`complete: true` is an ANSWER — this node holds no mirror bonds — and MUST NOT be returned for a fact
the node could not read. The epoch requirement being unknown is NOT one of these reasons; it is the
definite per-row state `deferred`.

**All amounts are in DIG BASE UNITS**, `$DIG` carrying 3 decimals so one base unit is `0.001 DIG`. They
are NOT mojos — a mojo is XCH's base unit at `1e-12` XCH, nine orders of magnitude away. A `bonded` or
`reclaiming` amount MUST be read FROM THE COIN and never from this epoch's requirement: a coin created
under a previous requirement locks the previous amount.

**`locked_dig_base_units` covers the WHOLE bond set, including reclaiming coins, and a client MUST NOT
sum the page instead.** A page sum under-reports the locked total by exactly one page boundary and
shows unspendable money as available. It is the figure a locked-total surface reads.

**The read is PAGED and says so.** Rows come in ascending `(store_id, root)`, an order an
implementation MUST keep stable across the pages of one walk. Both halves of the key are LOWERCASE
64-hex characters, unprefixed, on the wire and in `after`; the order is defined over that canonical
spelling, because uppercase and lowercase hex order differently as strings and two producers spelling
it differently would disagree about what `after` names. A `0x` prefix is TOLERATED on input to `after`
and normalized away; it is never emitted. Any other spelling of either half of `after` MUST be REFUSED
as `-32602 INVALID_PARAMS` and MUST NOT be treated as an absent cursor: a `0x`-prefixed key sorts
before every canonical one, so a node that ignored it would silently RESTART the walk while appearing
to resume, and a repeated page inflates a total a client is accumulating. `complete` states whether the page is the
whole set and MUST NOT be inferred from the page's length. `cursor` is the key of the LAST row actually
handed back, or `null` for an empty page; both keys are REQUIRED on the wire and a reader MUST reject a
payload missing either rather than defaulting it. `after` means *strictly after this key in that
order*, and it names BOTH halves of the key: resuming by `store_id` alone would drop every remaining
root of the store the boundary fell inside. `limit` is bounded by `MIRROR_BOND_STATES_MAX_LIMIT` and an
out-of-range value is REFUSED as `-32602 INVALID_PARAMS` rather than clamped, so the caller's model of
the page and the node's stay identical.

**The read is TOKEN-GATED although it is a read.** The caller supplies nothing, so the answer is this
node's own bond set and funding position — an association, not a relayed public fact. It is NOT an open
read (§4.2) and NOT a master-token method (§2.1).

### 4.2i Reconciling mirror coins to the current advertise URL (`control.mirror.reconcile`)

A mirror coin carries the advertise URL that was current when it was minted. When that URL changes
— a new public IP, an operator override, a corroborated address replacing none — every existing coin
advertises an address the node no longer serves from: it keeps collateral locked while earning
nothing, and the drift is invisible without this method. `control.mirror.reconcile` is the ONE
primitive that reclaims every such coin and recreates it advertising the URL this node advertises
TODAY. It is shared by two triggers — a manual operator-initiated reset and dig-node#570's automatic
epoch-boundary pass — and an implementation MUST serve both through the SAME reconcile logic rather
than maintaining two implementations of a money-moving operation with different guards.

**Params carry ONE optional field, `dry_run` (boolean, default `false` when omitted).** `true` prices
the plan and MUST spend nothing; `false`, and an omitted field, executes the plan for real. There is
no URL parameter: the target is always whatever this node is CURRENTLY configured to advertise
(§4.2h's derived-or-override view). A caller wanting a DIFFERENT target MUST set it first via
`control.config.setMirrorAdvertiseUrls`, then reconcile.

**The result is a tagged union on `outcome`, and a client MUST be able to tell all three apart —
they mean different things about the user's money. There is no `completed` or `partial` outcome, and
an implementation MUST NOT report one:** a reclaim and a create are separate spend bundles, and a
create's $DIG is selected by a chain scan of CONFIRMED coins, so the collateral a reclaim frees
becomes chain-visible only in a LATER pass — never inside this call. An implementation MUST NOT wait
for confirmation inside the call, and MUST NOT report a `created`/`reclaimed`-completed count it
cannot back: that fact does not exist yet, and reporting it would be a money statement about the
future told as though it were the present (§25.8). The honest report of what the node did by the time
it answers is `submitted`.

| `outcome` | when | payload | spent? |
| --- | --- | --- | --- |
| `planned` | `dry_run: true`, and the node believes it CAN execute | `capsules_stale`, `capsules_affordable`, `collateral_reclaimed_dig_base_units`, `collateral_relocked_dig_base_units`, `fee_estimate_mojos`, `stale_url_sets`, `url_current`, `targets_seen_last_7_days` | no |
| `refused` | the node refuses to even start | `reason` (ten tokens, flattened onto the payload — see below), `url_current` (nullable) | **no, unconditionally** |
| `submitted` | the node attempted its sized prefix | `reclaims_submitted`, `reclaims_rejected`, `recreates_owed`, `left_unchanged`, `left_reason` (nullable), `url_current` | yes, for the submitted prefix only |

**`planned` prices the collateral round-trip honestly rather than as one "cost".** Collateral is
reclaimed and RE-LOCKED, not spent: `collateral_reclaimed_dig_base_units` is read FROM the `K` coins
the plan would reclaim, `collateral_relocked_dig_base_units` is `K × this epoch's margined
requirement`, and an implementation MUST report them SEPARATELY rather than net them into one cost
figure — netting them would teach an operator that a reset burns $DIG when the net movement is zero in
the common case (the money-lie class in the reassuring direction's opposite: making a free action
look expensive). `fee_estimate_mojos` is the one figure genuinely spent: the XCH network fee, in
mojos. `stale_url_sets` is the DISTINCT advertise-URL set(s) the `capsules_stale` coins currently
carry, as `[[string]]` rather than one flattened list, because coins minted at different times can
carry different URLs and a single list would either merge them or silently show only one coin's memo.
`targets_seen_last_7_days` is dig-node SPEC §25.13.8's rotating-address warning; a client SHOULD
surface it once it rises above one or two rather than only after an operator has already paid for
several resets.

**The central invariant: establish and validate the new URL BEFORE reclaiming anything, and size the
affordable prefix `K` BEFORE any reclaim is attempted.** If the URL cannot be established, the node
MUST do nothing at all. If it can, the node MUST reclaim EXACTLY `K` — never more. `K` is `Planned`'s
`capsules_affordable` and `Submitted`'s `reclaims_submitted` at once: the SAME quantity, computed the
SAME way, whichever outcome the call reaches, and a real run MUST NOT invent a different `K` than a
prior dry run already priced. Reclaiming more than `K` leaves the node holding uncollateralised
capsules it has stopped advertising — the half-run failure this whole method exists to prevent — so
"no worse off" here means the bond COUNT is unchanged, never merely that no fee was wasted. An
implementation MUST therefore, in this order:

1. **Confirm this node can read mirror coins from chain.** If it cannot, refuse as
   `chain_unreadable` — reconciling from an unreadable chain view risks reclaiming a coin the node
   has misjudged.
2. **Confirm this epoch's collateral requirement is `Known` (§4.2e).** If it is not, refuse as
   `requirement_unknown` carrying the SAME `CollateralUnknownReason` taxonomy §4.2e's own
   `unknown` answer uses — never `insufficient_funds`; the wallet may be full and no create can
   be PRICED regardless.
3. **Confirm this node's collateral advertising is switched ON node-wide** (the operator's own
   switch, §25.7 of dig-node's own SPEC). A node with it off refuses as `disabled` — a client MUST
   NOT present this as a fault.
4. **Confirm this node is currently PUBLISHING an advertise URL.** A node in any of
   `MirrorAdvertiseState` (§4.2h)'s four non-publishing states refuses as `not_publishing`
   carrying that state, rather than inventing a parallel vocabulary for the same fact
   `control.config.get` already serves. Distinct from
   `disabled`: this gate is about WHAT URL to advertise, `disabled` is about whether the node bonds
   anything AT ALL regardless of URL.
5. **Compare the candidate URL against what this node's existing mirror coins advertise today.** If
   they are identical, refuse as `url_unchanged` rather than spend real $DIG to reach the state the
   node is already in.
6. **Confirm this node holds at least one mirror coin to reconcile.** A node with none refuses as
   `no_mirror_coins` — there is no bond whose advertise URL could be stale.
7. **Confirm this node can spend at all** — it can both sign and, if `DIG_WALLET_ENABLE_LIVE_BROADCAST`
   requires it, broadcast. A node that cannot refuses as `wallet_unavailable` naming which capability
   is missing and why (signing, reusing `WalletOperatorAddressResult`'s own reason taxonomy so the
   two surfaces cannot drift into two spellings of one fact; or broadcasting, the same guard
   `-32044 WALLET_NODE_SPEND_DISABLED` reports elsewhere). Retrying cannot help either reason; the
   remedy is repairing the wallet or flipping the flag, never sending $DIG.
8. **Confirm this node has MEASURED what its operator wallet holds** (dig-node SPEC §25.12). A node
   that has not refuses as `funds_unmeasured` — this states only that measurement is missing, never
   affordability; quoting a figure here would be fabricated.
9. **Size the affordable prefix `K` against the WHOLE plan and check `K ≥ 1` BEFORE reclaiming
   anything.** A wallet that cannot afford even `K = 1` refuses as `insufficient_funds` carrying
   `have_dig_base_units` (what the wallet actually holds) and `need_dig_base_units` (the cost of the
   FIRST reclaim+create pair alone — never the whole plan's cost, which a wallet failing at step one
   was never priced against).
10. **Refuse `reconcile_in_progress` when a reconcile this node started earlier is still in flight.**
    Reconciling is NOT idempotent mid-flight: a second call racing the first could reclaim a coin the
    first call is still waiting to recreate. An implementation MUST serialize reconciles rather than
    interleave them.

**`refused` MUST mean nothing was spent, unconditionally.** A client distinguishes `refused` from
`submitted` precisely so it never has to guess whether a "no" cost money; an implementation MUST NOT
spend anything before returning `refused`, on any of the ten reasons above.

**`submitted` is the ONLY outcome once anything has been reclaimed, and `left_unchanged > 0` is a
NORMAL result, not an error** — the caller can tell because the counts say so, never because a
separate "partial" tag does. `reclaims_submitted` and `reclaims_rejected` both count ATTEMPTS against
the SAME sized `K`: a rejected reclaim was attempted and failed at runtime (e.g. a coin spent
elsewhere in a race), which is distinct from `left_unchanged` — the `n − K` capsules never attempted
because `K` was sized before any reclaim ran. `recreates_owed` MUST equal `reclaims_submitted`: only
an ACCEPTED reclaim's collateral will ever confirm and fund its matching create, and an implementation
MUST NOT create anything inside this call — every recreate is owed to the ordinary create pass that
already scans confirmed coins. `left_reason` is `None` exactly when `left_unchanged` is zero; there is
nothing to explain when nothing was left behind.

**A dry run that would in fact refuse MUST report `refused`, never `planned`.** `planned` prices a
plan computed against the SAME validation (chain readable, requirement known, advertising on and
publishing, changed URL, mirror coins exist, wallet can spend, funds measured, affordability, not
already in flight) steps 1-10 perform; it is not a preview that skips them. A caller previewing a
reconcile that would in fact be refused MUST see the same refusal reason a real attempt would
produce.

**This method is TOKEN-GATED at the ORDINARY tier, never the MASTER tier (§2.1),** by the same
reasoning as `control.config.setMirrorAdvertiseUrls` (§4.2h): it moves real $DIG, but it installs no
principal the node will thereafter believe, obey, or forward requests to. It changes only which URL
THIS node's own mirror coins advertise — an implementation MUST NOT dial a value the caller supplies,
MUST NOT treat bytes read from anywhere new as trusted input, and MUST NOT forward any request to a
third party as a result of this call.

### 4.3 The custody boundary (§908)

The node holds no user key and produces no signature. `control.wallet.broadcast` carries signed bytes
and nothing else. `control.wallet.watch` carries PUBLIC keys, which confer no ability to sign. There
`control.profile.putBody` carries body bytes whose root somebody else already signed and confirmed on
chain. There
is no private key, seed, phrase, or unsigned-spend-plus-key parameter in this catalog,
and none may be added. The node's role on the money path is to read chain state and to push what
somebody else signed.

## 5. Error taxonomy

The numeric codes are a published wire contract and never change once assigned. `origin` classifies
where the error was minted.

| Code | Symbol | Origin | Meaning |
|---|---|---|---|
| `-32700` | `PARSE_ERROR` | shell | request body was not valid JSON |
| `-32600` | `INVALID_REQUEST` | shell | not a single JSON-RPC object |
| `-32601` | `METHOD_NOT_FOUND` | boundary | control method is not resolved |
| `-32602` | `INVALID_PARAMS` | node | missing/malformed params |
| `-32000` | `DISPATCH_FAILED` | shell | the node failed to dispatch a well-formed call |
| `-32030` | `UNAUTHORIZED` | shell | a `control.*` method called without a valid token |
| `-32031` | `NOT_SUPPORTED` | shell | control op unsupported on this build (e.g. §21 sync with no identity) |
| `-32032` | `CONTROL_ERROR` | shell | a control op failed at runtime |
| `-32040` | `WALLET_NO_CHAIN_SOURCE` | node | a wallet chain read had no live chain source to answer |
| `-32041` | `WALLET_NOT_SYNCED` | node | a wallet chain read of the wallet's own address is still syncing, with no fallback |
| `-32042` | `WALLET_READ_FAILED` | node | a wallet chain read failed at the DB / chain-source layer |
| `-32043` | `WALLET_RATE_LIMITED` | node | a wallet chain read was refused: the open fallback rate bound is spent |
| `-32044` | `WALLET_NODE_SPEND_DISABLED` | node | `control.wallet.broadcast` refused: the bundle requires a signature from one of the NODE's OWN custodied keys while `DIG_WALLET_ENABLE_LIVE_BROADCAST` is off. The node relays bundles somebody else signed on every install; sending its own money is a separate, default-OFF custody decision, and a caller could otherwise sign through the node and hand the bundle straight back. **Retrying cannot help**: the remedy is a bundle that does not spend the node's coins, or the flag. |
| `-32046` | `WALLET_COINS_RESERVED` | node | coins named by the call are committed to a live in-flight spend; nothing was reserved. A WAIT, never a shortfall |
| `-32047` | `WALLET_RESERVATIONS_UNAVAILABLE` | node | the node's coin-reservation set could not be read, so what is in flight is UNKNOWN |
| `-32048` | `SPEND_AUDIT_UNREADABLE` | shell | the automated-spend audit record could not be read at all, so what this node spent unattended is UNKNOWN. Never an empty page. A record that was never written is NOT this: it is an empty page |

The `-3204x` band began as the wallet's and now also carries the audit record's `-32048`; **this
document owns the whole band**. A node or client MUST NOT mint a
`-3204x` code that is not declared in the table above: a privately-minted code cannot be seen at
allocation time, and two implementations then disagree about what one number means.

The codes in the band do NOT share one disposition, so a client MUST branch on the symbol rather
than on the band. `-32040`..`-32043` and `-32047` say the answer is UNKNOWN — the node could not
look, not that the chain said no — so a client MUST NOT degrade them into an empty or zero result,
and MUST NOT report a mint, a spend or a balance as failed on their strength alone. `-32048` says
the same about the audit record: it could not be read, which a client MUST NOT render as "this node
has spent nothing". `-32046` is a
transient WAIT and a client SHOULD retry. `-32044` is TERMINAL: retrying cannot help, and a client
that treats it as a wait retries forever against a decision that will not change.

The `-32020..-32022` band is RESERVED for onion routing (dig-rpc-protocol); the control-plane errors
use `-32030..-32032`.

## 6. Conformance

The golden known-answer tests in `src/kats.rs` are normative: golden request vectors (typed call →
exact envelope), golden response vectors (node JSON decodes into the typed result and re-encodes
byte-identically), the golden error envelope, and an end-to-end route through the node-facing
`ControlHandler` dispatcher proving every method maps to its typed handler. The node side (T7) and
every client side (T8–T10) MUST pin against these vectors. A change that alters a wire shape MUST
fail a KAT.

## 7. Stability

1. `ControlMethod` and `ControlErrorCode` are `#[non_exhaustive]`; adding a method/code is an additive
   MINOR change.
2. Method wire names and error numeric values never change once assigned.
3. Result fields are additive: a new optional field is MINOR; removing/renaming/repurposing a field
   is a BREAKING change.
4. The catalog MUST mirror the live dig-node surface exactly (`dig-node-service/src/control.rs` owned
   methods + the `dig-node-core` delegated peer/subscription methods); a divergence is a drift bug.

## 8. wasm / JS byte-agreement

The catalog types are plain serde structs with no non-wasm dependencies, so a browser/extension client
(T5's `wasm-bindgen` binding) serializes them to identical JSON. The `serde_json::Value`-typed proxied
results and the `#[serde(untagged)]` `RequestId` are the only shapes needing a JS-side check; T5 adds a
Rust↔wasm/JS byte-identical KAT over the vectors in §6.