feat(mirror): declare control.mirror.bondState, the per-(store, root) bond state - #38
Conversation
…) bond state Declares the surface dig-node SPEC.md 25.8 requires, release-first: the interface publishes before the node serves it. Seven states, six of which mean "no coin yet" for different reasons and different remedies. Conflating "out of funds" with "withheld on purpose" is what produces hourly funding alarms about a healthy node. Reconciles a vocabulary mismatch rather than inheriting it: 25.8's `withheld` (Relayed provenance) and dig-node's BondState::Withheld (the node-wide switch) were the same word for different states, and 25.8's `reclaiming` had no variant at all. The contract keeps all three apart as withheld / disabled / reclaiming, and names which side must move. Unblocks DIG-Network/dig-app#300 and the locked-total half of DIG-Network/dig-app#289. Step 6 of DIG-Network/dig-node#412. Co-Authored-By: Claude <noreply@anthropic.com>
|
IN PROGRESS — not the verdict. Gate on head Established so far, so it survives an interruption:
Two findings forming, posted inline shortly: the unexpressible partial served set, and |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
CHANGES-REQUIRED
Head reviewed: 8101744b98defed94eb3dc47e3212c87fe878f40 (resolved from the remote myself). Fresh context, no prior involvement.
(GitHub returns 422 for a REQUEST_CHANGES event on a same-identity PR, so this verdict is recorded as a comment review. The two open inline threads are what bar the merge, via required_conversation_resolution.)
The three-states judgement: the split is RIGHT. Do not re-merge them.
withheld / disabled / reclaiming are three states, and the lane's argument holds on both axes it names. They differ in scope — one capsule's Relayed provenance versus one node-wide switch — and decisively in remedy: an operator told "withheld" about a disabled node goes looking at content when the answer is a switch. reclaiming is not a bookkeeping nicety either; its money is still locked, so folding it into "unbonded" would report unspendable funds as available, which is the same money lie from the other end. Conflating any two reproduces dig-app#300's actual defect. The PR is also right that neither existing use survives unchanged — dig-node must rename BondState::Withheld to Disabled and add the other two, and §25.8 must gain disabled — and that consequence is stated normatively in SPEC.md §4.2g rather than only in the PR body, which is the right place for it.
The vacuity IS recorded normatively, and that is where finding 1 lives
It is written in three places in normative voice (SPEC.md §4.2g, MirrorBondState's rustdoc, a MUST bullet on ControlHandler::mirror_bond_states), and it names what the serving half must do: enumerate the served set, not the Held set. That clears the bar.
But the contract's own escape clause has no wire representation. §4.2g says a producer that cannot enumerate the served set "MUST say so", and all three MirrorBondStatesUnknownReason variants describe infrastructure failures — none describes a producer that enumerates bonds fine but is blind to provenance. Its only conforming-looking move is a known page with complete: true and every withheld row silently missing: the exact "no such row" failure §4.2g forbids, with complete: true asserting a falsehood. That leaves withheld tickable on a conformance list while unreachable. Gating, because release-first means this file decides the producer's options. Two acceptable fixes in the inline thread.
Verified
- Call-level-only unknown is enforced by the types.
MirrorBondStatesResultis a two-variant tagged union with no per-row unknown anywhere inMirrorBondEntry; requirement-unknown correctly stays the per-rowdeferred { reason: CollateralUnknownReason }and is explicitly excluded from the call-level reason set.entries: []withcomplete: trueis a genuine answer, documented as such in the type, the SPEC and the trait — not a degenerate unknown. - The mutation spot-check is real, and it hit the reachable target. In my own worktree at
8101744, changingdeserialize_with = "required_option"to#[serde(default)]oncursormadean_absent_paging_key_never_becomes_a_definite_answerFAIL. Restored, worktree removed, shared checkout untouched (git status --porcelainempty). The lane's stated reason for mutating the field attribute rather thanrequired_option's body is correct: an absent key never reaches adeserialize_withfn, so the two cover disjoint inputs and only the attribute is under test here. - The other KATs are load-bearing by construction, not decoration.
golden_bond_state_vectors_pin_every_statecarries two roots of the same store in different states, so a store-keyed surface fails.the_bond_surface_wire_tokens_are_uniquereads tokens back through serde rather than off a second list.the_locked_total_spans_pages_and_is_never_the_page_sumuses a truncated page whose visible sum is strictly less than the total, and asserts that inequality, so a fixture drift making them equal fails loudly rather than going vacuous.the_bond_page_bound_is_enforced_from_both_sidesprovesMAXpasses as well asMAX+1and0refusing — both directions, which is what a one-sided bound proof hides. locked_dig_base_unitsis unambiguously the whole-set total including reclaiming coins, in the rustdoc,SPEC.md§4.2g, the README row and the trait MUST bullet, each saying a client MUST NOT sum the page. The KAT distinguishes the two implementations rather than merely exercising the field.- Additive-only, and 0.26.0 is right. No existing type, field, token or constant changed shape;
ControlMethodis#[non_exhaustive](src/method.rs:80) andSPEC.md§7.1 makes a new method an additive MINOR. Full suite green at head, including doctests. ControlHandler's new required method is safe here. The only implementor is this crate'sMockNode; dig-node dispatches manually incrates/dig-node-service/src/control.rsand has noimpl ControlHandler. It matches the precedent set bycollateral_buffer, likewise required with no default.- The forcing function is stated as intentional in the PR body — adopting 0.26.0 turns dig-node's
control_contract_conformance.rsred until the node serves the method — so it will not read as a regression. closingIssuesReferencesis[]. Asked the parser, not the prose. Closes nothing, as intended for step 6 of #412.- Unit naming is clean.
short_dig_base_units,amount_dig_base_units,locked_dig_base_units; no field on a $DIG quantity says mojos, and the 3-decimal, not-a-mojo distinction is restated wherever an amount appears.
Findings, ranked
src/results.rs:2365— GATING. The provenance-blind served set is unreportable;withheldis vacuous with no way for a producer to say so.src/params.rs:1724— GATING.afteris an unvalidated, unnormalized hex key, breaking the crate's own convention (WalletCoinsParams::validatednormalizes its cursor); a0x-prefixed key silently restarts the walk, and the ascending order is never specified as lowercase.
Non-gating, no thread opened
control.mirror.bondStatesis the first method whose wire-name prefix (control.mirror.*) does not match itsCategory(Collateral). Defensible — the category is about what the method is about, and the collateral category already owns the requirement/margin/buffer trio this completes — but it is a new precedent worth being deliberate about, since a futurecontrol.mirror.*method that is not collateral-shaped will have to follow it or break it.src/method.rs:25runs well past the file's wrapping width; reflow when the file is next touched.
Re-review on push: I will re-verify both findings against the new head and resolve each thread that is genuinely addressed. Not editing, not merging, not undrafting.
…e a malformed bond cursor Two gating findings on the control.mirror.bondStates contract. SPEC.md 4.2g told a Held-only producer it MUST say it cannot enumerate the served set, while every MirrorBondStatesUnknownReason was an infrastructure failure that did not fit it. A node that enumerates its pairs perfectly well but cannot see provenance therefore had no conforming answer, and its only conforming-LOOKING move was a `known` page with `complete: true` and every `withheld` row silently absent -- the exact failure 4.2g forbids, asserting a completeness the node knows it lacks, and leaving `withheld` tickable on a conformance list while unreachable on the wire. Adds `provenance_unknown` as a fourth whole-call reason and binds it normatively: a producer that cannot determine provenance MUST answer it and MUST NOT return a `known` page. 4.2g's MUST is unchanged and is not weakened to a SHOULD, and no per-row unknown is introduced -- the call-level-only design stands, because a truncated page and a complete one read identically. MirrorBondStatesParams::validated checked only `limit`, leaving `after.store_id` and `after.root` unvalidated while the sibling WalletCoinsParams::validated normalizes its cursor and refuses a malformed one. The order is ascending over the key's STRING form, so a `0x`-prefixed key sorts before every canonical one: a node that ignored it would RESTART the walk while appearing to resume, and a repeated page inflates the running locked-$DIG total dig-app#289 accumulates -- wrong in the reassuring direction and indistinguishable from a correct answer. Both halves now go through the existing `normalize_coin_id` rather than a second copy of the rule: `0x` tolerated and stripped, anything else refused as -32602 INVALID_PARAMS, on the way IN as well as through `validated`. An unparseable cursor is never coerced to start-of-set. The canonical form is pinned as LOWERCASE unprefixed 64-hex in 4.2g, on MirrorBondKey's fields and on the trait's ordering bullet, so the ascending order is defined over one spelling. Two KATs, each proved load-bearing by mutation: - a_provenance_blind_producer_can_say_so_instead_of_shipping_a_short_page - a_malformed_bond_cursor_is_refused_rather_than_restarting_the_walk Co-Authored-By: Claude <noreply@anthropic.com>
|
IN PROGRESS — not the verdict. Scoped re-gate of Confirmed by reading so far:
Still running: baseline suite + the two mutations. |
PASS — scoped re-gate of
|
| mutation | result |
|---|---|
as_wire: ProvenanceUnknown => "served_set_unknown" |
FAILED — a_provenance_blind_producer_can_say_so_instead_of_shipping_a_short_page (kats.rs:5551) and the_bond_surface_wire_tokens_are_unique (kats.rs:5303). 183 passed / 2 failed, exactly as claimed. |
validated: self.after.and_then(|k| normalize_bond_key(k).ok()) — the silent restart |
FAILED at kats.rs:5643 with a malformed cursor must be refused: MirrorBondStatesParams { after: None, ... }. The panic names the defect itself — the cursor became None, i.e. start-of-set. |
The provenance fixture is built against the nearest wrong implementation, not a spelling variant: the truthful answer at kats.rs:5519-5530 contains a withheld row, and the forbidden short page decodes as Known { complete: true, entries.len() == 1 } and is asserted != the sanctioned refusal. Two observably different answers about the same set. That is the property, not an outcome.
Both mutations were applied in C:\tmp\worktrees\gate-dnci-38 (my own worktree, cut at bcbf9da), restored from a file copy, and git status --porcelain was empty after each restore. No shared checkout was mutated.
Non-gating (1) — posted inline and resolved by me
kats.rs:5654 — assert!(decoded.map(|p| p.after).unwrap_or(None).is_none(), ...) is unconditionally true given the assert!(decoded.is_err()) two lines above: Err(_).map(..).unwrap_or(None) is None for every input. The property it names (never coerced to None) is genuinely proven by the validated() arm above it, so nothing is untested — the assertion just cannot fail. Cosmetic; not worth a round-trip.
Verdict: PASS. Nothing gating. Merge and publish 0.26.0.
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Non-gating nit (I am resolving this myself; it does not block).
DO NOT MERGE — gate round in progress.
Declares
control.mirror.bondStates, the per-(store, root)mirror bond state surface dig-nodeSPEC.md§25.8 requires. Release-first: the interface publishes before the node serves it.Step 6 of DIG-Network/dig-node#412. Epic DIG-Network/dig_ecosystem#3166.
Unblocks (does NOT close) DIG-Network/dig-app#300 and the locked-total half of
DIG-Network/dig-app#289. This PR closes nothing — verified with
gh pr view 38 --json closingIssuesReferences→[].The shape
control.mirror.bondStates· TOKEN-GATED ·Category::Collateral·Routing::OwnedSeven bond states, six of which mean "no coin yet" for different reasons and different remedies:
bond_statebondedcoin_id,epoch,amount_dig_base_unitspendingunfundedshort_dig_base_unitsdeferredreason(CollateralUnknownReason, reused)withheldRelayedprovenancedisabledreclaimingcoin_id,epoch,amount_dig_base_unitsThe vocabulary reconciliation, and which side must move
§25.8's
withheld(aRelayedcapsule) and dig-node'sBondState::Withheld(the node-widecollateralisation switch) were the same word for different states, and §25.8's
reclaiminghadno
BondStatevariant at all. They differ in SCOPE and in REMEDY: an operator told "withheld" abouta disabled node goes looking at content, one told "withheld" about a relayed capsule goes looking
for a switch. This contract keeps three states apart, and neither existing use survives
unchanged:
BondState::Withheld→Disabled, and addWithheld(Relayedprovenance) +
Reclaiming.SPEC.md§25.8 MUST gaindisabled, which it could not express at all.Recorded normatively in this crate's
SPEC.md§4.2g and onMirrorBondState's doc.Stated vacuity:
withheldis unreachable until dig-node's surface enumerates its SERVED setrather than its
Heldset — aRelayedcapsule is by construction absent from the desired-bondset, so a
Held-keyed derivation answers "no such row" where the contract promises "withheld onpurpose". Declared here deliberately; the serving half must reach it or say it cannot.
"No bond" vs "cannot tell", on the wire
They live at different levels, and that is the design. Every per-row state is a DEFINITE
statement — including the six that mean no coin. A fact the node could not read makes the whole
call
{state:"unknown", reason}; there is no per-row unknown and no empty-list fallback, becausea truncated list and a complete one read identically and the rows a broken read drops are exactly
the bonds nobody is then watching.
entries: []+complete: trueis an ANSWER (this node holds nobonds), never a read failure. Requirement-unknown is NOT a "cannot tell" reason — it is the definite
per-row
deferred.Design constraints, discharged
cursorcarriesdeserialize_with = "required_option"andcompletecarries nodefault; an absent key is a decode FAILURE. No#[serde(default)]sitson any field whose absence carries meaning.
short_dig_base_units,amount_dig_base_units,locked_dig_base_units— $DIG has 3 decimals; never mojos.bonded/reclaimingamounts areread FROM THE COIN, never from today's requirement.
CollateralUnknownReasonis reused verbatim fordeferredratherthan copied.
MIRROR_BOND_STATES_MAX_LIMITis defined asCOINS_BY_PARENT_MAX_LIMIT, not asan equal literal.
Category::Collateral,required_option,control_call!, therefuse-don't-clamp limit idiom and the
Known/Unknowntagged-union shape all follow the housepattern.
locked_dig_base_unitsis the WHOLE-SET total including reclaiming coins, node-computed. Aclient summing the page under-reports locked money by a page boundary and shows unspendable funds
as available. This is what dig-app#289 reads.
Blast radius checked
gitnexus has no index in this worktree; blast radius taken by
git grepacrossorigin/mainofdig-node and dig-app plus a direct read — an allowed §2.0 fallback, stated here rather than implied.
0.25.0 payload still decodes; verified by the existing 182 KATs, all still green.
ControlHandlergains one required method. Zero implementors exist outside this crate's ownMockNode— dig-node dispatches manually incrates/dig-node-service/src/control.rsand has noimpl ControlHandler(thewin_service.rshit isservice_control_handler, unrelated).crates/dig-node-service/tests/control_contract_conformance.rsiteratesControlMethod::ALL, soadopting 0.26.0 goes RED until the node serves the method and adds the
dignverb — which is step6's node half, by design.
Verification
cargo test --lib— 183 passed, 0 failed (182 pre-existing + the new KATs).cargo fmt --checkclean;cargo clippy --all-targets -- -D warningsclean.deserialize_with = "required_option"→#[serde(default)]oncursor⇒an_absent_paging_key_never_becomes_a_definite_answerFAILS. The mutation targets the FIELDATTRIBUTE, not
required_option's body: an absent key never reaches adeserialize_withfunction, so those cover disjoint inputs and only the attribute is under test.
#[serde(rename = "withheld")]onDisabled⇒the_bond_surface_wire_tokens_are_uniqueFAILS.MIRROR_BOND_STATES_MAX_LIMITmust PASS,MAX + 1and0must be refused asINVALID_PARAMS, and the refusal is enforced on the way IN.TRUNCATED page whose visible amounts sum to less than
locked_dig_base_units(a page thathappened to sum to the total would pass against a client re-deriving it); the state vectors carry
two roots of the SAME store in different states (a store-keyed surface would merge them); the
wire-token uniqueness test reads tokens back through serde rather than off a second list that
could agree with itself.
Version
0.25.0 → 0.26.0. On a
0.xline the minor slot is the breaking slot, and additive-only is stilla minor here. Nothing existing changed shape, so a 0.25 consumer keeps decoding.
§2.4b
This crate declares no
dig-*orchia-*dependencies —serde,serde_json,async-trait,semverand dev-onlyfutures, all at1/0.xmajors already current. Nothing to bump.Docs
SPEC.md§4.2g (new, normative) + its method table row;README.mdcatalog row. The crate's ownexhaustiveness KAT (
the_spec_and_readme_name_every_catalogued_method) enforces both and caught thegap before I did.
Gate round 1 — both GATING findings fixed (
bcbf9da)provenance_unknown— a fourth whole-call unknown reason, so a producer that enumerates itspairs but cannot see PROVENANCE has a conforming answer instead of a
knownpage with itswithheldrows silently absent. §4.2g's MUST is unchanged (not softened to SHOULD) and no per-rowunknown was added; the new text only gives "say so" its one spelling.
afteris normalized and validated through the existingnormalize_coin_id—0xtoleratedand stripped, uppercase / wrong-length / non-hex refused as
-32602 INVALID_PARAMSin eitherhalf, on the way IN, and never coerced to start-of-set. Canonical lowercase unprefixed 64-hex is
now pinned in §4.2g, on both
MirrorBondKeyfields and on the trait's ordering bullet, so theascending order is defined over one spelling.
Two new KATs, each proved load-bearing by mutation: aliasing
as_wire's new arm toserved_set_unknownfails the provenance KAT and the token-uniqueness KAT; replacing the cursorcheck with
.and_then(|k| normalize_bond_key(k).ok())— the silent-restart behaviour — fails thecursor KAT. Restored after each;
cargo test --lib185 passed, 0 failed, fmt + clippy clean.