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 + 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/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 | 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", } } }