From 327316bb06239c5d8dbfc08c64ae39cead82d58a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 10:45:15 -0700 Subject: [PATCH] feat(mirror): add MirrorBondState::Unadvertised, the switch-on-but-nothing-to-advertise state A node whose advertise-URL list has no publishable entry creates no mirror coin, however much $DIG it holds and however definitely its own collateralisation switch is ON. The enum had no value for that, so dig-node had to choose among values that are all false about it: `unfunded` names a figure and sends the operator to buy $DIG that would bond nothing, every `deferred` reason blames a working subsystem, and `disabled` is the operator's own switch -- which this contract says a client MUST NOT present as a fault, so serving it there obliges a conforming client to stay silent about the one thing actually wrong. `Unadvertised` is that state, and it IS a fault a client must surface. That is the entire distinction from `Disabled`: both are node-wide and both mean no coin, but one is a decision the operator already made and the other is that decision silently not being honoured. Additive: a new variant, a new wire token, no existing token or payload changed. SPEC.md and the trait guidance name the pair apart, and the KAT asserts both the token and that it differs from `disabled` -- the nearest wrong implementation being the interim dig-node mapping this replaces. Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- SPEC.md | 12 +++++++++++- src/kats.rs | 37 +++++++++++++++++++++++++++++++++++-- src/results.rs | 30 +++++++++++++++++++++++++++--- src/traits.rs | 16 ++++++++++++---- 6 files changed, 87 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 39c91d0..368221b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.27.0" +version = "0.28.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index 2c2f3ab..54c4b28 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.27.0" +version = "0.28.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" diff --git a/SPEC.md b/SPEC.md index c5e995c..2332cb7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1091,7 +1091,7 @@ when it is not — WHY, together with the $DIG those bonds have locked. It is th `SPEC.md` §25.8 requires, and it is served over the control plane before the node adopts it (release-first). -**Its whole purpose is that "no coin yet" is never one answer.** Six of the seven states mean there is +**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. @@ -1104,8 +1104,18 @@ alarms about a perfectly healthy node, which is the defect this method exists to | `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. + **`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. diff --git a/src/kats.rs b/src/kats.rs index b51edf4..6c9850e 100644 --- a/src/kats.rs +++ b/src/kats.rs @@ -4891,6 +4891,38 @@ fn every_collateral_unknown_reason_is_listed_in_all() { /// `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. +/// **A node with nothing publishable to advertise is `unadvertised`, and that is NOT `disabled`.** +/// +/// A node whose advertise-URL list is empty (or whose every entry was rejected) creates no mirror +/// coin, no matter how much $DIG it holds and no matter that its own collateralisation switch is +/// ON. dig-node reported that as [`Unfunded`](results::MirrorBondState::Unfunded) and sent an +/// operator to buy $DIG that would have bonded nothing. +/// +/// The nearest wrong implementation maps it onto +/// [`Disabled`](results::MirrorBondState::Disabled) — the only pre-existing variant that does not +/// name a figure. That is why this fixture asserts BOTH halves rather than the new token alone: +/// `disabled` means the operator's own switch and this contract forbids a client presenting it as a +/// fault, so serving it here would oblige a conforming client to stay silent about the one thing +/// actually wrong. The two tokens must differ, and the difference is the assertion. +#[test] +fn an_unadvertised_bond_is_its_own_state_and_not_disabled() { + let unadvertised = serde_json::to_value(results::MirrorBondState::Unadvertised).unwrap(); + let disabled = serde_json::to_value(results::MirrorBondState::Disabled).unwrap(); + + assert_eq!( + unadvertised, + serde_json::json!({ "bond_state": "unadvertised" }), + "the token is the contract, and it is a bare tag with no payload" + ); + assert_ne!( + unadvertised, disabled, + "a node whose switch is ON must not be reported under the switch's own token" + ); + + let parsed: results::MirrorBondState = serde_json::from_value(unadvertised).unwrap(); + assert_eq!(parsed, results::MirrorBondState::Unadvertised); +} + #[test] fn a_balance_unreadable_bond_is_deferred_not_unfunded() { let state = results::MirrorBondState::Deferred { @@ -5411,6 +5443,7 @@ fn the_bond_surface_wire_tokens_are_unique() { }, results::MirrorBondState::Withheld, results::MirrorBondState::Disabled, + results::MirrorBondState::Unadvertised, results::MirrorBondState::Reclaiming { coin_id: BOND_COIN_B.into(), epoch: 6, @@ -5426,10 +5459,10 @@ fn the_bond_surface_wire_tokens_are_unique() { .to_owned() }) .collect(); - assert_eq!(tokens.len(), 7, "all seven states must be represented"); + assert_eq!(tokens.len(), 8, "all eight states must be represented"); tokens.sort(); tokens.dedup(); - assert_eq!(tokens.len(), 7, "bond-state tokens collide"); + assert_eq!(tokens.len(), 8, "bond-state tokens collide"); } /// **An ABSENT `cursor` or `complete` must FAIL to decode — neither may collapse into a value.** diff --git a/src/results.rs b/src/results.rs index 15135a5..58e063a 100644 --- a/src/results.rs +++ b/src/results.rs @@ -2209,9 +2209,10 @@ pub struct SpendsListResult { /// /// # The whole point is that "no coin yet" is never one answer /// -/// Six of these seven variants mean "there is no current-epoch coin", and every one of them calls -/// for a different response from a person: add funds, wait, do nothing, turn a switch back on, or -/// nothing at all because the capsule was never this node's to advertise. Collapsing any two of +/// Seven of these eight variants mean "there is no current-epoch coin", and every one of them calls +/// for a different response from a person: add funds, wait, do nothing, turn a switch back on, +/// publish an advertise URL, or nothing at all because the capsule was never this node's to +/// advertise. Collapsing any two of /// them is what produces an hourly out-of-funds alarm about a perfectly healthy node /// (dig-app#300), which is the defect this method exists to remove. /// @@ -2228,6 +2229,10 @@ pub struct SpendsListResult { /// /// - [`Withheld`](Self::Withheld) carries §25.8's meaning — `Relayed` provenance, per capsule. /// - [`Disabled`](Self::Disabled) is the node-wide switch, which §25.8 could not express at all. +/// - [`Unadvertised`](Self::Unadvertised) is that switch being ON while the node still has nothing +/// publishable to advertise. It is node-wide like `disabled` and is deliberately NOT the same +/// value: `disabled` is the operator's own decision and MUST NOT be shown as a fault, so a node +/// served under it would oblige a conforming client to stay silent about a real failure. /// - [`Reclaiming`](Self::Reclaiming) is §25.8's seventh state, which `BondState` had no variant /// for even though the money is still locked while it lasts. /// @@ -2297,6 +2302,25 @@ pub enum MirrorBondState { /// Node-wide, not per capsule: every row reads `disabled` together. The remedy is a switch, and /// it is the operator's own earlier decision — a client MUST NOT present it as a fault. Disabled, + /// This node has nothing publishable to advertise, so it advertises nothing and creates no + /// mirror coin. + /// + /// The node holds the capsule, its own collateralisation switch is ON, its wallet may be full + /// and the epoch's requirement may be perfectly well known. It simply has no advertise URL a + /// peer could fetch from -- the list is empty, or every entry in it was rejected as + /// non-absolute or reachable only from this machine -- and a mirror coin that advertised no + /// URL would bond nothing. + /// + /// **A client MUST surface this as a fault.** That is the whole difference between it and + /// [`Disabled`](Self::Disabled), which is also node-wide and also means "no coin", but is the + /// operator's own earlier decision and MUST NOT be presented as one. Here the operator decided + /// the opposite -- the switch is ON -- and the node is silently unable to honour it. The remedy + /// is a publishable advertise URL, and it is the only remedy: sending $DIG changes nothing, + /// which is why serving this as [`Unfunded`](Self::Unfunded) is a false statement about money. + /// + /// Node-wide like `disabled`: every row reads `unadvertised` together, because the URL list is + /// one list for the node rather than a property of any capsule. + Unadvertised, /// A live coin is being reclaimed: the bond is going away and the money is not back yet. /// /// Carries the coin because the funds are STILL LOCKED for the duration. A surface that showed diff --git a/src/traits.rs b/src/traits.rs index dbf2092..3c2a5ae 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -559,11 +559,19 @@ pub trait ControlHandler: Sync { /// /// An implementation MUST: /// - /// - **Keep the seven states apart.** `unfunded` is the only genuine out-of-funds state. + /// - **Keep the eight states apart.** `unfunded` is the only genuine out-of-funds state. /// `deferred` (no priced requirement), `pending` (submitted, unconfirmed), `withheld` - /// (`Relayed` provenance) and `disabled` (the node-wide switch) all mean "no coin yet" and - /// none of them means "send money". Collapsing any of them into `unfunded` is the dig-app#300 - /// defect this method exists to remove. + /// (`Relayed` provenance), `disabled` (the node-wide switch) and `unadvertised` (that switch + /// ON, but nothing publishable to advertise) all mean "no coin yet" and none of them means + /// "send money". Collapsing any of them into `unfunded` is the dig-app#300 defect this method + /// exists to remove. + /// - **Answer [`Unadvertised`](results::MirrorBondState::Unadvertised) -- never + /// [`Disabled`](results::MirrorBondState::Disabled) -- when the node's own switch is ON and + /// its advertise-URL list has no publishable entry.** The two are both node-wide and both + /// mean "no coin", but they differ in REMEDY and in whether anything is wrong: `disabled` is + /// the operator's own decision and a client MUST NOT present it as a fault, so serving it + /// here would oblige a conforming client to stay silent about the single reason the node + /// bonds nothing. /// - **Read `bonded` and `reclaiming` amounts FROM THE COIN**, never from this epoch's /// requirement. A coin created under a previous requirement locks the previous amount, and /// the current price is not a fact about an existing coin.