Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

<!-- gitnexus:end -->
<!-- gitnexus:end -->
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# is designed, matching the sibling dig-<x>-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"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
15 changes: 11 additions & 4 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
92 changes: 92 additions & 0 deletions src/kats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
MichaelTaylor3d marked this conversation as resolved.

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::<results::MirrorBondState>(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
Expand Down
15 changes: 15 additions & 0 deletions src/results.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
MichaelTaylor3d marked this conversation as resolved.
}

impl CollateralUnknownReason {
Expand All @@ -3163,6 +3176,7 @@ impl CollateralUnknownReason {
CollateralUnknownReason::BehindFinalityDepth,
CollateralUnknownReason::RecordUnreadable,
CollateralUnknownReason::NoChainSource,
CollateralUnknownReason::BalanceUnreadable,
Comment thread
MichaelTaylor3d marked this conversation as resolved.
];

/// The stable snake_case wire token, matching the `reason` field.
Expand All @@ -3172,6 +3186,7 @@ impl CollateralUnknownReason {
CollateralUnknownReason::BehindFinalityDepth => "behind_finality_depth",
CollateralUnknownReason::RecordUnreadable => "record_unreadable",
CollateralUnknownReason::NoChainSource => "no_chain_source",
CollateralUnknownReason::BalanceUnreadable => "balance_unreadable",
}
}
}
Expand Down
Loading