From a19a2e063b26b2547af11ea92f49aa0b2f334ecf Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 22:28:05 -0700 Subject: [PATCH 1/3] chore(contract): open the lane for the balance-unreadable reason Co-Authored-By: Claude --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c75b821..33be779 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,4 +41,4 @@ This project is indexed by GitNexus as **dig-node-control-interface** (22 symbol | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - \ No newline at end of file + From b6737f912d70b6bd2ab71eaa665f8f1f648d12b0 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 22:32:28 -0700 Subject: [PATCH 2/3] feat(collateral): add BalanceUnreadable to CollateralUnknownReason dig-node's mirror pass has a BondState::FundsUnknown for the case where the node reads chain fine but cannot read its own $DIG balance. The contract could not express it: MirrorBondState has no fitting variant, and all four existing CollateralUnknownReason variants are about the census, the record, or the chain. Reusing one of the four would tell an operator their census is broken while it is working -- the remedy misdirection the withheld/disabled/reclaiming split exists to prevent. Reporting Unfunded would assert a shortfall the node has no evidence for, on the surface dig-app#300 reads to decide whether to alarm. The state maps onto the existing per-row Deferred, keeping the answer per-row: a call-level unknown would blank a whole page including known-good Bonded rows when only one input was unreadable. ALL is a hand-written list that as_wire's exhaustive match does not force to grow, so a second KAT indexes the variants through an exhaustive match: a new variant now fails to compile until it is listed. Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- SPEC.md | 15 +++++--- src/kats.rs | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/results.rs | 15 ++++++++ 5 files changed, 120 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ab3077..39c91d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.26.0" +version = "0.27.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index 3adfa62..2c2f3ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ # is designed, matching the sibling dig--protocol crates' bootstrap order. [package] name = "dig-node-control-interface" -version = "0.26.0" +version = "0.27.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" diff --git a/SPEC.md b/SPEC.md index fbe9470..c5e995c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -976,9 +976,16 @@ 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 four reasons — -`not_censused`, `behind_finality_depth`, `record_unreadable`, `no_chain_source` — name DIFFERENT missing -facts with different remedies, and MUST NOT be collapsed into one. `known` and `unknown` are variants of +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. @@ -1094,7 +1101,7 @@ alarms about a perfectly healthy node, which is the defect this method exists to | `bonded` | `coin_id`, `epoch`, `amount_dig_base_units` | 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 | none; the wallet may be full | +| `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 | | `reclaiming` | `coin_id`, `epoch`, `amount_dig_base_units` | a live coin is being reclaimed; the money is STILL LOCKED | wait | diff --git a/src/kats.rs b/src/kats.rs index 73f8dab..b51edf4 100644 --- a/src/kats.rs +++ b/src/kats.rs @@ -4841,6 +4841,98 @@ fn an_unknown_requirement_carries_a_reason_and_no_number() { assert_eq!(tokens.len(), results::CollateralUnknownReason::ALL.len()); } +/// **`ALL` cannot silently miss a variant.** +/// +/// `ALL` is a hand-written list, so the uniqueness KAT above proves only that the variants SOMEONE +/// REMEMBERED to list have distinct tokens — a variant added without being listed is invisible to +/// it, and `as_wire`'s exhaustive match does not force the list to grow with it. +/// +/// The match below closes that: it is exhaustive over the enum, so a new variant fails to COMPILE +/// here until it is given an index, and the count assertion then fails until `ALL` holds it too. +/// Membership is checked per variant rather than by length alone, because two entries for one +/// variant would satisfy a length check while leaving another absent. +#[test] +fn every_collateral_unknown_reason_is_listed_in_all() { + use results::CollateralUnknownReason as R; + + // Exhaustive on purpose. Adding a variant MUST break this match. + const fn index(reason: R) -> usize { + match reason { + R::NotCensused => 0, + R::BehindFinalityDepth => 1, + R::RecordUnreadable => 2, + R::NoChainSource => 3, + R::BalanceUnreadable => 4, + } + } + const VARIANT_COUNT: usize = 5; + + assert_eq!( + R::ALL.len(), + VARIANT_COUNT, + "ALL has drifted from the variant set" + ); + for i in 0..VARIANT_COUNT { + assert!( + R::ALL.iter().any(|r| index(*r) == i), + "variant with index {i} is missing from ALL" + ); + } +} + +/// **The wallet-unreadable reason is its OWN token, and it rides `deferred` rather than a shortfall.** +/// +/// dig-node's mirror pass has a state for "chain reads fine, but this node cannot read its own $DIG +/// balance". Reporting that as [`MirrorBondState::Unfunded`] would assert a shortfall the node has +/// no evidence for, on the very surface an operator uses to decide whether to alarm (dig-app#300); +/// reporting it as `record_unreadable` would send that operator to repair a census that is working. +/// +/// So the fixture pins the EXACT bytes both halves produce: the `deferred` tag, and the +/// `balance_unreadable` token beside it. The nearest wrong implementation reuses one of the four +/// census/record/chain reasons, and it is distinguishable here only because the token itself is +/// asserted — a test that merely round-tripped the value would pass against every one of them. +#[test] +fn a_balance_unreadable_bond_is_deferred_not_unfunded() { + let state = results::MirrorBondState::Deferred { + reason: results::CollateralUnknownReason::BalanceUnreadable, + }; + + assert_eq!( + serde_json::to_value(&state).unwrap(), + json!({"bond_state": "deferred", "reason": "balance_unreadable"}), + "the wallet-unreadable bond's wire bytes are pinned" + ); + + // And the same token decodes back into the same variant, so a client and a node reading this + // contract cannot disagree about which fact is missing. + let round: results::MirrorBondState = + serde_json::from_value(json!({"bond_state": "deferred", "reason": "balance_unreadable"})) + .unwrap(); + assert_eq!(round, state); + + // The reason is REQUIRED: `deferred` alone names no missing fact and no remedy. + assert!( + serde_json::from_value::(json!({"bond_state": "deferred"})) + .is_err(), + "a deferred bond without a reason must fail to decode" + ); + + // The token is distinct from every census/record/chain reason, which is the whole point of + // adding it rather than reusing one. + assert_eq!( + results::CollateralUnknownReason::BalanceUnreadable.as_wire(), + "balance_unreadable" + ); + assert!( + results::CollateralUnknownReason::ALL + .iter() + .filter(|r| r.as_wire() == "balance_unreadable") + .count() + == 1, + "exactly one variant owns the balance_unreadable token" + ); +} + /// **A known requirement cannot omit the protocol version that produced it.** /// /// The collateral model is versioned and upgradable, and the version that computed an epoch travels diff --git a/src/results.rs b/src/results.rs index 97ae789..15135a5 100644 --- a/src/results.rs +++ b/src/results.rs @@ -3154,6 +3154,19 @@ pub enum CollateralUnknownReason { RecordUnreadable, /// The node cannot see the chain at all, so it cannot know whether a record should exist. NoChainSource, + /// The node can read the epoch's record, but cannot read its OWN $DIG balance, so it cannot + /// tell whether it could fund what the record prices. + /// + /// The one WALLET-shaped reason, and it exists because every other reason in this enum points + /// an operator at the census, the record, or the chain. A node whose census is healthy and + /// whose wallet read failed, reported as [`RecordUnreadable`](Self::RecordUnreadable), tells + /// that operator to repair a census that is working — the same remedy misdirection the + /// `withheld`/`disabled`/`reclaiming` split exists to prevent. + /// + /// It is emphatically NOT a shortfall. Answering + /// [`Unfunded`](crate::results::MirrorBondState::Unfunded) here would assert a gap the node has + /// no evidence for, on the surface an operator uses to decide whether to alarm. + BalanceUnreadable, } impl CollateralUnknownReason { @@ -3163,6 +3176,7 @@ impl CollateralUnknownReason { CollateralUnknownReason::BehindFinalityDepth, CollateralUnknownReason::RecordUnreadable, CollateralUnknownReason::NoChainSource, + CollateralUnknownReason::BalanceUnreadable, ]; /// The stable snake_case wire token, matching the `reason` field. @@ -3172,6 +3186,7 @@ impl CollateralUnknownReason { CollateralUnknownReason::BehindFinalityDepth => "behind_finality_depth", CollateralUnknownReason::RecordUnreadable => "record_unreadable", CollateralUnknownReason::NoChainSource => "no_chain_source", + CollateralUnknownReason::BalanceUnreadable => "balance_unreadable", } } } From de0c86d76fd1894de609fd199f44c5e646f3e499 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 22:51:26 -0700 Subject: [PATCH 3/3] docs(contract): list balance_unreadable in the README taxonomy The CollateralUnknownReason taxonomy for control.collateral.requirement was updated in SPEC.md to include five reasons, with balance_unreadable added. README.md:154 still enumerated only the original four; bring it into alignment with the current SPEC.md:980 contract. Co-Authored-By: Claude Haiku --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c15755c..5c7ed8b 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ and pushes bytes somebody else signed. | Method | Auth | Route | Params | Result | |---|---|---|---|---| | `control.spends.list` | T | own | `{since_ms?, until_ms?, store_id?, kind?, status?, after_id?, limit?:u32}` | `{spends:[AutomatedSpend], complete:bool, cursor:string\|null, unreadable_lines:u32}`; READ-ONLY — the audit record of spends this node made WITHOUT per-transaction approval. It never initiates, signs, cancels or alters a spend. A `failed` row carries the STAGE it died at, because only `signing` means the money definitely did not move; `unresolved` is its own state and MUST NOT be folded into `failed`. `complete` states truncation explicitly — never infer it from the page length. A non-zero `unreadable_lines` means the trail is INCOMPLETE and must be surfaced; a record that cannot be read at all is `-32048`, never an empty page | -| `control.collateral.requirement` | T | own | — | `{state:"known", epoch, protocol_version, required_per_store_dig_base_units, stores, owners, multiplier_micros, handicap_dig_base_units}` \| `{state:"unknown", reason}`; this epoch's PRE-margin per-store collateral requirement with the census inputs that produced it, so a client can show why the figure moved. `unknown` is a first-class answer with a named reason (`not_censused` \| `behind_finality_depth` \| `record_unreadable` \| `no_chain_source`) — NEVER a zero, which would read as no collateral required. `protocol_version` is the version that COMPUTED the epoch, not the newest the build implements. `stores` counts advertisements and `owners` counts collateralised owners; neither is a node count | +| `control.collateral.requirement` | T | own | — | `{state:"known", epoch, protocol_version, required_per_store_dig_base_units, stores, owners, multiplier_micros, handicap_dig_base_units}` \| `{state:"unknown", reason}`; this epoch's PRE-margin per-store collateral requirement with the census inputs that produced it, so a client can show why the figure moved. `unknown` is a first-class answer with a named reason (`not_censused` \| `behind_finality_depth` \| `record_unreadable` \| `no_chain_source` \| `balance_unreadable`) — NEVER a zero, which would read as no collateral required. `protocol_version` is the version that COMPUTED the epoch, not the newest the build implements. `stores` counts advertisements and `owners` counts collateralised owners; neither is a node count | | `control.collateral.margin.get` | T | own | — | `{margin_bp:u64}`; the node's LOCAL safety margin in BASIS POINTS (`100` = +1%), never a percentage. A config predating the field reports the `100` default, never `0` | | `control.collateral.margin.set` | T | own | `{margin_bp:u64}` | `{margin_bp:u64}`; persists the margin and returns what is now in force. Bounded at `10000` bp (+100%) and REFUSED as `-32602` above it rather than clamped, so the applied value never differs silently from the requested one. The margin is local: it MUST NOT reach any census or signal, and it does not guarantee a store is counted | | `control.collateral.buffer` | T | own | — | `{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}`; the $DIG this node recommends HOLDING and its position against that figure. Amounts are DIG base units (3 decimals, `0.001 DIG`), never mojos; `margin_bp` is basis points. A client MUST NOT derive this: `pairs_served_by_this_node` is THIS node's served set, not the census `stores` count, and the overlap term needs reclaim state nothing else exposes. The horizon travels in the payload and is never implied — escalation is capped at +12.5%/epoch and COMPOUNDS (x1.12 at 1, x1.60 at 4, x4.62 at 13) — and `escalation_ceiling_micros` is a worst case, not a forecast. `funding_state` is the node's verdict, not a threshold for a client to re-derive: `short_now` \| `dangerously_low` leave an epoch uncovered, `below_recommended_buffer` is a READOUT and never a recurring notification, `funded` holds the buffer. `unknown` is a first-class answer with a named reason (`requirement_unknown` \| `served_set_unknown` \| `reclaim_state_unknown` \| `balance_unknown`) — NEVER a zero, which here reads as no buffer needed |