Skip to content

chore: open lane for #3269 (reward RPC methods, unit 3) - #609

Draft
MichaelTaylor3d wants to merge 14 commits into
developfrom
feat/3269-reward-rpc-methods
Draft

chore: open lane for #3269 (reward RPC methods, unit 3)#609
MichaelTaylor3d wants to merge 14 commits into
developfrom
feat/3269-reward-rpc-methods

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Refs #3269

DO NOT MERGE — gate round in progress.


The test this PR exists to make possible

commitment_money_figures_stay_attributed_to_their_own_distributor — two distributors, 4,500 and 6,300, each figure returned under its own launcher_id, with the summed value (10,800) and the swapped values asserted absent from the response.

That assertion is the structural answer to this epic's central defect. A sibling gate found total_paid_out_base_units — a figure paid to all mirrors, sourced from the funder's record — rendered as one operator's personal earnings, overstating by up to 250x. It passed security review and 11/11 CI. The adversarial leg's explanation of why three legs missed it:

no test in this repo asserts who a number is about.

A number can be correctly typed, read-only, in range, perfectly formatted — and false about whose money it is. Type checks, lints and coverage are all silent on that property, because it is not a property of the value. It is a property of the mapping from subject to value, and only an assertion that names two subjects and denies their sum can see it.

This PR's handlers are where that subject is established on the wire, so the test lives here.

What this PR does and does not deliver

Serves two methods, both Tier::Control: dig.getRewardDistributor and dig.listRewardDistributorCommitments.

In production both answer chain-unavailable, because nothing constructs a RewardsChainPort yet — the adapter and its install_reward_chain_port call site are tracked in #3310. This is groundwork, and the failure is honest: a named unavailability, never a zero and never an empty list.

Two methods deliberately stay -32601, with no stub and no todo!():

  • dig.listRewardDistributors — its result type derived Default with bare Vec halves, so a funder-only answer had to emit claimable: [], indistinguishable from "you have no mirror claims". Fixed upstream in dig-rpc-protocol 0.12.0 by a dated-absence field; this PR waits for that publish.
  • dig.getPayeeRewardClaimStatus — no Method variant and no result type existed in v0.11.0. Also added in 0.12.0.

Money handling

withdraw_committed_incentives is never called and its recovered_base_units never read: it returns #3286's wrapped u64 uncorrected (#3303), and the exact chia-* pins stop that overflow moving without correcting it. The recoverable figure is computed with recoverable_base_units, whose u128 intermediate is correct exactly where the driver wraps. withdrawal_share_bps is echoed from chain-derived state, never recomputed (SPEC 2.6 clause 2 forbids it by name).

An unhonest figure refuses the whole call rather than emitting a value — never a zero, never a field omitted and defaulted. Safe wholesale because bps is launch-curried per distributor, so no good row is ever blinded by the refusal.

MichaelTaylor3d and others added 5 commits September 10, 2026 19:55
Adds the reward-distributor chain reader (read_distributor,
DistributorSnapshot, ChainObservation) as a dependency, unpinned beyond the
0.4 line dig-rewards-coin ships it on. Resolves alongside chia-sdk-driver
0.36.0 / chia-sdk-types 0.36.0 / chia-puzzle-types 0.36.1, matching the
cohort_lock_guard pins dig-rewards-coin itself asserts.

No consumer yet -- see the accompanying finding on the PR: dig-node-core has
no reachable dig_chainsource_interface::ChainSource implementation, so none
of #3269's four RPC methods can be wired to a live chain read from this
crate without a further architecture decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…orCommitments

Both at Tier::Control, behind dig-node-core's own RewardsChainPort. The adapter
that talks to chain (CorroboratedChainSource + dig_rewards_coin::read_distributor)
is built in dig-node-service and injected downward, so no dependency cycle is
created and dig_chainsource_interface::ChainSource never enters this crate.

- port: one additive `distributor_report` method feeding both handlers.
  DistributorChainState is deliberately NOT widened -- the prover cycle engine
  consumes it. UnavailableChainPort returns the chain-unavailable error, never
  an empty report: a failed read must not render as "no entries".
- dispatch: both arms route through the Method::from_name enum match, never the
  string pre-match, so the tier guard cannot be bypassed.
- deps: dig-rewards-coin removed from dig-node-core; it belongs to the service
  crate that builds the adapter. No chia-* pin touched.
- #3261: the guard predicate is duplicated in peer.rs and in
  tests/reward_methods_tier_guard.rs; both widened, with a count assertion so
  the filter cannot silently start matching fewer methods than exist.

Money handling for the recoverable figure: withdraw_committed_incentives is
never called -- it returns #3286's wrapped u64 uncorrected (#3303). The figure
is computed with dig_rewards_coin::recoverable_base_units, whose u128
intermediate is correct exactly where the driver wraps. withdrawal_share_bps is
echoed from chain-derived state, never recomputed (SPEC 2.6 clause 2), and
narrowed with u16::try_from rather than `as` -- a silent cast wraps 65_536 to 0.

When the figure cannot be honestly computed the WHOLE CALL refuses. It is never
a zero and never an omitted field left to default. bps is launch-curried per
distributor, so a refusal cannot blind one row while sparing another.

listRewardDistributors and getPayeeRewardClaimStatus remain -32601 with no stub:
the first would have to emit `claimable: []`, indistinguishable from "no claims",
until #3268's mirror-admission registry exists; the second has no Method variant
in dig-rpc-protocol v0.11.0.

Checkpoint push by the orchestrator; CI is the compile.

Refs #3269

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo fmt --all` touched only `crates/dig-node-core/src/lib.rs` -- the eight
diffs CI reported in the pushed test block. No other file in the workspace
wanted reformatting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e age

`get_reward_distributor_threads_entry_set_stale_both_ways` installed a chain
port twice on one `Node`, so the second `install_reward_chain_port` correctly
returned false and its `assert!` fired. `install_reward_chain_port` is
single-shot on purpose and stays that way -- loosening it so a test could pass
would let a real double-install through in production.

Instead the fake port, which is already keyed per launcher id, now serves both
cases from ONE install on ONE node, answered by the requested launcher id. Two
reports straddle the staleness bound: one whose last entry write is exactly
`STALE_ENTRY_SET_SECONDS` old (stale), one a second inside it (fresh). The test
asserts the fixtures really do straddle the bound before it asserts anything
about the wire.

Both expectations are DERIVED through `rewards::staleness::is_entry_set_stale`
against the exported constant, so the bound's numeric value (172_800) appears
nowhere in the test and the fixture can no longer carry a staleness flag its own
chain-derived times contradict. That derivation also removes the
`seed % 2 == 0` parity trick clippy flagged as a manual `is_multiple_of`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial gate (third leg) — MERGE-WITH-CONDITIONS

Head read: a77ed70756bf55c3e3251cdde80feb52a2054f54. Read-only; no code written. I am the lane that prescribed this architecture, so I have tried to break it rather than defend it.

Verdict: MERGE-WITH-CONDITIONS. Two pre-merge edits (one shared re-gate), one ticket to file before merge, one follow-up. Not "wait for the adapter" — see 1.

1. Working feature, or a handler nothing can call? — GROUNDWORK. Report it as groundwork.

Nothing in the production graph installs a RewardsChainPort. Node::install_reward_chain_port is #[cfg_attr(not(test), allow(dead_code))] (crates/dig-node-core/src/lib.rs, new impl Node block) and the only non-test implementor in the workspace is UnavailableChainPort, whose distributor_report returns Err(Unavailable) (crates/dig-node-core/src/rewards/port.rs). In production both methods answer -32032 / data.code = REWARD_CHAIN_UNAVAILABLE on every call, forever, until the dig-node-service adapter lands. dig-app 15.6.0's pane therefore gains no affordance from this merge.

That is not a reason to hold it. The failure is honest — a named unavailability, never a zero, never an empty commitments list — which is the epic's subtractive remedy pattern applied correctly, and it is exactly what the two withheld methods do not do (see 4). Merging is safe. Claiming a feature is not. Any close/release note on this must read "seam + handlers landed; no chain data until the adapter", and the PR title/body must stop saying chore: open lane / DO NOT MERGE before the squash.

2. Third allow(dead_code)-shaped rewards/ merge — the rule APPLIES and is NOT satisfied.

It is the third. The rule ("no fully unreachable rewards/ module merges without an OPEN ticket naming the exact function that will call it") is not met, and worse, the diff repeats the defect #3292 was filed for. lib.rs's new field doc states the adapter "lives in dig-node-service (dig_ecosystem#3268)"; rewards/port.rs's DistributorReport/CommitmentSlot docs repeat it. #3268's body is the peer claim loop + ClaimStatus, and it routes the real chain adapter to #3249. Neither #3268 nor #3249 names RewardsChainPort::distributor_report or a call site for install_reward_chain_port. So the doc is born-false in the commit that writes it — the same shape as "#3292: nothing installs FundedDistributorRegistry at startup, and the docs claim #3268 does".

A sequencing intention is not enough. #3291 and #3292 exist because the last two times it wasn't.

3. Whole-call refusal — premise VERIFIED, and it is stronger than I argued.

The original reasoning (bps launch-curried per distributor) holds, but the decisive fact is narrower and does not depend on currying at all: distributor_report(launcher_id) is scoped to one distributor, so a refusal can never blind a second distributor's row. Within one report every CommitmentSlot shares the one withdrawal_share_bps, so there is no path where one slot is undeterminable while another is fine. Refusal blinds zero good rows; it is not an outage. No wire change needed. reward_distributor_methods_refuse_whole_call_on_invalid_withdrawal_share asserts no result key at all on both methods and carries a stated mutation probe (flip the arm to a withdrawal_share_bps: 0 result -> RED), so it is not vacuously green. commitment_money_figures_stay_attributed_to_their_own_distributor covers the cross-distributor attribution risk. The decision was right; keep it.

Where the defence against #3303 is incomplete, plainly: core echoes recoverable_base_units and cannot verify it. The doc says the adapter must compute it via dig_rewards_coin::recoverable_base_units, never withdraw_committed_incentives — but nothing enforces that, and there is no cheap core-side invariant that would catch it: #3286's wrap makes a large clawback's share come out tiny, so a recoverable <= rewards_base_units assertion passes under the defect. The money-shaped risk is deferred to the adapter, not closed. That is acceptable only if the pin rides the adapter's ticket, which is condition C3.

4. The two withheld methods — right reasons, DISHONEST signal. Pre-existing, not introduced here.

Both withholding reasons survive. listRewardDistributors would have to emit claimable: [] for a funder-only answer, indistinguishable from "no claims" — correct to withhold. getPayeeRewardClaimStatus has no Method variant in v0.11.0 — confirmed.

The signal is wrong, though. dispatch.rs:822-824's _ => arm answers {"code":-32601,"message":"method not found"} for Method::ListRewardDistributors, a variant this node's catalogue does recognize. -32601 means "not implemented — get a newer node", and dig-app will route a user to an upgrade that cannot fix an absence the responder is choosing. That is the epic's fourth defect, again. The honest code already exists in the taxonomy and is sitting unused: CONTROL_NOT_SUPPORTED = -32031 (lib.rs:270, #[allow(dead_code)]) — dig-rpc-protocol's ErrorCode::NotSupported, "control-plane method recognized but not supported here". dispatch.rs:190 shows the team already knows -32601 misleads here.

This PR does not create that arm, so it is not a merge blocker — but by pinning the reward-method count to 4 while serving 2, it makes the withholding a decided, gate-blessed state. It needs a named owner: follow-up F1.

5. Not on the other legs' lists — an unregistered wire error code.

dispatch.rs mints const REWARD_INVALID_WITHDRAWAL_SHARE: i64 = -32033. -32033 does not exist in dig-rpc-protocol 0.11.0's ErrorCode (src/error.rs table: …-32030/-32031/-32032, then -32050), and lib.rs:250-262 states the control-plane numbering is the byte-identical shared wire contract "both DIG node implementations track", asserted by control_error_codes_match_dig_rpc_types. So this diff unilaterally coins a number in the node that the protocol crate does not own — a typed client deserializing into ErrorCode cannot parse it, and digstore's responder has never heard of it. The diff is also internally inconsistent about this: Unavailable deliberately reuses -32032 and distinguishes itself by data.code, which is the right pattern, and then the sibling error doesn't. Condition C1.

Smaller, all clean: parse_launcher_id_arg rejects short/long/non-hex and 0x-prefixed input correctly; -32602 for bad params is right; hex::encode is lowercase-unprefixed, consistent with the surrounding handlers; dig-node-core is unpublished, so adding a required trait method to pub trait RewardsChainPort carries no semver hazard; both new methods are absent from is_peer_reachable_method and the peer.rs + reward_methods_tier_guard.rs count pins make the guard fail loudly rather than narrow silently. Tier::Control holds.

Conditions

Pre-merge, one shared re-gate cycle (worth it — both are wire/doc facts that get expensive after a release):

  • C1 Remove -32033. Answer InvalidWithdrawalShare with CONTROL_ERROR (-32032) and data.code = "REWARD_INVALID_WITHDRAWAL_SHARE", matching the Unavailable arm. (Or land an ErrorCode variant in dig-rpc-protocol first and consume it — release-first, slower.) Checkable: grep -c 32033 crates/dig-node-core/src = 0; the existing refusal test asserts data.code and only error.code != 0, so it stays green unmodified.
  • C2 Correct the attribution in the reward_chain_port field doc (lib.rs) and in port.rs's DistributorReport / CommitmentSlot docs: the adapter is not #3268's. Point them at C3's ticket. Checkable: no #3268 remains as the owner of the chain adapter in this diff.

Pre-merge but not a re-gate (filing only, ~2 min):

  • C3 File an OPEN ticket naming, exactly: implement RewardsChainPort::distributor_report in dig-node-service (CorroboratedChainSource + read_distributor) and call Node::install_reward_chain_port at startup — and carrying the #3303 pin verbatim: recoverable_base_units MUST be computed with dig_rewards_coin::recoverable_base_units; withdraw_committed_incentives MUST NOT be on this path; note that no core-side invariant can catch the wrap. This is what makes 2's rule satisfied and 3's deferred risk owned.

Follow-up ticket, no re-gate:

  • F1 Recognized-but-unserved methods must answer -32031 NOT_SUPPORTED with a data.code, not -32601; scope it to dispatch.rs's _ => arm for catalogue-known variants, with dig.listRewardDistributors / dig.getPayeeRewardClaimStatus as the named cases, so dig-app can tell "withheld here" from "upgrade me".

With C1-C3 done, merge. Without C3, do not — that is the one I would carry rather than waive.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES-REQUIRED - head a77ed707

Correctness gate for #3269 unit 3. CI at this SHA is 13/13 green and the Test + coverage job reports 3402 tests run: 3402 passed (17 slow), 4 skipped, TOTAL line coverage 90.13% against the >=80% gate. Blast radius is exactly the five intended files under crates/dig-node-core/**; crates/dig-node-service/**, Cargo.toml, Cargo.lock and tests/dependency_tree.rs are untouched, and no chia-* pin moved. The shape is right, the refusal discipline is right, and the money figure never touches the wrapped path. Two things block the merge, both on the wire contract, plus two doc claims that are false as merged.

Blocking

  1. -32033 is already taken, on this node, by an unrelated condition. dispatch.rs:51 mints REWARD_INVALID_WITHDRAWAL_SHARE = -32033, which is ErrorCode::ControlIngressLimited in crates/dig-node-service/src/meta.rs:840 (asserted at crates/dig-node-service/src/server.rs:3480) - the shell that fronts this very dispatch. One number, two opposite instructions to the caller: "you were rate-limited at ingress, back off and retry" versus "this distributor's own curried constant is out of range, never retry". dig-app 15.6.0's clawback is the caller.
  2. The guard that exists to catch exactly that was not told about the new number. LOCAL_WIRE_CODES (lib.rs:5434) is documented as "every JSON-RPC error number this node PUTS ON THE WIRE"; -32033 is absent, which is why no_local_wire_code_collides_with_a_different_canonical_code (job log 1134/3402, PASS) is green over the collision in (1).

Doc truth (blocking, per this epic's five born-false claims)

  1. port.rs:151-153 asserts in the present tense that dig-node-service is the crate that depends on dig-rewards-coin and computes the figure with dig_rewards_coin::recoverable_base_units. At this SHA crates/dig-node-service/Cargo.toml has no dig-rewards-coin dependency and no such adapter exists.
  2. Two test doc comments (lib.rs:9899, lib.rs:9994) cite "the accompanying report" for their mutation-probe evidence - an out-of-tree artifact a reader holding only the repo cannot check.

Answers to the six checks

  1. Money figure never from the wrapped path - HOLDS. withdraw_committed_incentives, recovered_base_units and read_distributor appear nowhere in crates/dig-node-core (grep at this SHA); there is no dig-rewards-coin dependency and no dig_chainsource_interface::ChainSource reference. All three architectural negatives hold. recoverable_base_units is carried verbatim from CommitmentSlot to the wire row and never recomputed in this crate.
  2. withdrawal_share_bps echoed, never recomputed - HOLDS, but the narrowing is not in this PR at all. DistributorReport.withdrawal_share_bps is already u16 and the port's contract puts both the u16::try_from and the <= 10_000 check on the adapter (port.rs:139, port.rs:151-166). So there is no as u16 here to get wrong - and equally no u16::try_from in-tree to verify. The 65_536 -> 0 risk (#3284) transfers whole to #3268; the port doc states the obligation, which is the most this crate can do.
  3. Refusal is whole-call and never a zero - HOLDS for both paths reachable from this crate. No-adapter-installed and Unavailable both return an error with data.code = REWARD_CHAIN_UNAVAILABLE and no result member; InvalidWithdrawalShare returns an error with its own machine string. Both handlers build their result with a total struct literal (no ..Default::default(), every field assigned), so no field can be silently defaulted. The two out-of-range sub-cases (> u16::MAX and > 10_000) collapse into one wire code, which is acceptable: the whole-call-refusal reasoning still holds in the code, because withdrawal_share_bps is read once per distributor and every row of a single response belongs to that one distributor.
  4. entrySetStale chain-derived - HOLDS. The handler echoes report.entry_set_stale; 172_800 appears nowhere in the diff; the fixture derives both sides of the bound through rewards::staleness::is_entry_set_stale against rewards::spec_constants::STALE_ENTRY_SET_SECONDS, so the numeric bound is written down nowhere in the test.
  5. Single-install invariant - the TEST was fixed, not the install. install_reward_chain_port still reads self.reward_chain_port.set(port).is_ok() over a OnceLock (lib.rs:684-689). get_reward_distributor_threads_entry_set_stale_both_ways now installs ONE port holding a two-entry HashMap keyed by launcher id and asserts each launcher gets its own staleness - strictly stronger than two installs, since it also catches a handler answering a different distributor's row.
  6. #3261 guards in both places - HOLD. Both are rule-scans over Method::ALL filtered on .contains("Reward"), so they cover the new surface without naming it; both are non-vacuous (!is_empty()) and both now pin the count at 4, which matches dig-rpc-protocol v0.11.0 exactly (method.rs:352-355). Both new arms are in the Method::from_name(method) match at dispatch.rs:384; neither name appears in the string pre-match at dispatch.rs:335-373 (chat.send, chat.poll, cache.pushCapsule, dig.getPublicManifest), so neither bypasses the tier guard.

Executed reward tests, from the job log (not the check mark)

# test result
235/3402 peer::tests::reward_methods_are_absent_from_the_node_peer_allowlist PASS
1014/3402 tests::commitment_money_figures_stay_attributed_to_their_own_distributor PASS
1097/3402 tests::get_reward_distributor_answers_with_real_values_through_dispatch PASS
1098/3402 tests::get_reward_distributor_threads_entry_set_stale_both_ways PASS
1125/3402 tests::list_reward_distributor_commitments_answers_with_real_values_through_dispatch PASS
1134/3402 tests::no_local_wire_code_collides_with_a_different_canonical_code PASS (vacuous over the new code - see blocking 2)
1162/3402 tests::reward_distributor_methods_chain_unavailable_is_a_distinct_error_never_a_zero_or_empty PASS
1163/3402 tests::reward_distributor_methods_refuse_whole_call_on_invalid_withdrawal_share PASS
1309/3402 reward_methods_tier_guard::every_reward_method_is_tier_control PASS
1310/3402 reward_methods_tier_guard::no_reward_method_is_peer_reachable PASS
1311/3402 reward_methods_tier_guard::reward_methods_exist_and_are_found_by_the_prefix_scan PASS
1243-1245/3402 dig-node-core::dependency_tree (unmodified) PASS

The subject test in the two-distributor shape exists and passes (1014/3402): distinct recoverable_base_units of 4_500 and 6_300 read back under their own launcher ids, with the summed (10_800) and swapped values asserted absent from the field. All assertions in the new tests are on the serialized JSON body - key SETs, not substrings - and the expected keys are snake_case, which is correct: v0.11.0's GetRewardDistributorResult, ListRewardDistributorCommitmentsResult and RewardDistributorCommitment carry no rename_all. No internal type is serialized onto the wire; every field crosses through a dig_rpc_protocol::types struct.

Stubs

Neither dig.listRewardDistributors nor dig.getPayeeRewardClaimStatus has a stub, a todo!() or an unimplemented!() anywhere in dispatch.rs or port.rs. Nothing to flag.

Not run

No local build, no local test run, no mutation probe re-executed. Findings are from the diff, the blobs at a77ed707, the dig-rpc-protocol v0.11.0 tag source, and the Test + coverage job log for this SHA.

/// `0..=10_000` range. Refuses the WHOLE call — see `rewards::port::ChainPortError::InvalidWithdrawalShare`'s
/// doc for why a per-distributor curried value makes that the correct shape, never a `0` or an
/// omitted field.
const REWARD_INVALID_WITHDRAWAL_SHARE: i64 = -32033;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING - -32033 is already assigned, in this repo, to a different condition.

ErrorCode::ControlIngressLimited is -32033 at crates/dig-node-service/src/meta.rs:840, asserted at crates/dig-node-service/src/server.rs:3480. That is the service shell that fronts this dispatch, so both numbers reach the same client on the same wire.

Consequence: dig-app 15.6.0's clawback receives -32033 and cannot choose between two opposite instructions - "an open, token-less control read was refused at ingress, back off and retry" and "this distributor's curried withdrawal_share_bps is out of range, never retry". The ambiguity is in the contract, so no retry policy recovers it, and the surface is money.

Fix: either mint a number in the -32030..-32039 control band that nothing in this workspace emits, or do what the arm two lines below already does and reuse CONTROL_ERROR (-32032) while keeping the distinct data.code machine string - the chain-unavailable arm proves that pattern is sufficient for a client to branch.

The fix must NOT renumber ControlIngressLimited (released, and consumer-held), and must NOT merely document the overlap.

/// `0..=10_000` range. Refuses the WHOLE call — see `rewards::port::ChainPortError::InvalidWithdrawalShare`'s
/// doc for why a per-distributor curried value makes that the correct shape, never a `0` or an
/// omitted field.
const REWARD_INVALID_WITHDRAWAL_SHARE: i64 = -32033;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING (second half of the collision) - register the new number in LOCAL_WIRE_CODES.

LOCAL_WIRE_CODES at lib.rs:5434 is documented as "Every JSON-RPC error number this node PUTS ON THE WIRE, paired with the condition it names", and its test no_local_wire_code_collides_with_a_different_canonical_code exists, per its own doc, because this defect class was found twice in one review. -32033 is not in the table, so that test passed at 1134/3402 while the collision above shipped.

Add the row ((REWARD_INVALID_WITHDRAWAL_SHARE, "REWARD_INVALID_WITHDRAWAL_SHARE")) alongside the fix, so the guard fails on a collision rather than being told about it afterwards.

The fix must NOT be to raise the len() >= 10 floor instead of adding the row - the floor measures the table's size, not its completeness.

///
/// `recoverable_base_units` is the adapter's PRE-COMPUTED share — never restated by a caller of
/// this port, and never recomputed by `dig-node-core` itself. The production adapter
/// (`dig-node-service`, dig_ecosystem#3268) is the one crate in this seam that depends on

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING (doc truth) - false as merged.

"The production adapter (dig-node-service, dig_ecosystem#3268) is the one crate in this seam that depends on dig-rewards-coin ... it computes this figure with dig_rewards_coin::recoverable_base_units".

At this SHA, crates/dig-node-service/Cargo.toml declares no dig-rewards-coin dependency (its chain dep is dig-chainsource-interface = "0.3", line 129) and no such adapter exists in the tree. A reader holding only this repo checks the claim and finds it false - the born-false-doc class this epic has already shipped five times.

Rewrite as an obligation rather than a description: the adapter that WILL implement this port (#3268, in dig-node-service) must compute the figure with dig_rewards_coin::recoverable_base_units. Keep the arithmetic argument and the u128-intermediate reference verbatim; only the tense and the existence claim change.

The fix must NOT add a dig-rewards-coin dependency to either crate to make the sentence true.

Comment thread crates/dig-node-core/src/lib.rs Outdated
/// side: doesn't fit `u16`, the caller narrows before calling this port, or the adapter's own
/// `0..=10_000` domain check), BOTH methods refuse the WHOLE call with a distinct machine code
/// — never a `0`, never an empty `commitments` list standing in for the refusal.
/// **Mutation-probe:** flipping `reward_chain_port_error_response`'s

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING (doc truth) - cites an out-of-tree artifact.

"see the accompanying report for the before/after run" points at something no reader of this repo can open, so the mutation-probe claim is unverifiable where it is written. Same at lib.rs:9994.

Make it checkable in place: name the mutation and the assertion it breaks (e.g. "replacing the InvalidWithdrawalShare arm of reward_chain_port_error_response with a withdrawal_share_bps: 0 result fails the resp.get(\"result\").is_none() assertion below, for both methods"), and drop the citation.

The fix must NOT delete the mutation-probe note altogether - the property it records is the reason the test is not vacuous.

Comment thread crates/dig-node-core/src/lib.rs Outdated
/// dig_ecosystem#3268's files (`dig-node-service`), so clippy's non-test lib target sees no
/// production caller yet.
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn install_reward_chain_port(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM - the seam as shipped is unreachable from the crate its own doc says installs it.

install_reward_chain_port is pub(crate), and the doc above says "the startup path that would install the real adapter belongs to dig_ecosystem#3268's files (dig-node-service)". dig-node-service cannot call a pub(crate) function of dig-node-core, so as merged there is no path by which the injected adapter this PR was designed around can ever be installed - and #[cfg_attr(not(test), allow(dead_code))] suppresses the compiler's own report of that.

It also creates a single-writer problem: #3268 would have to re-edit this file, which this lane owns.

Either widen to pub now (the OnceLock keeps the one-shot invariant regardless of visibility, and reward_chain_port() can stay pub(crate)), or say explicitly in the doc that widening the visibility is part of #3268's diff. The fix must NOT replace the OnceLock or add a second installer.

Comment thread crates/dig-node-core/src/seams/dig_rpc/dispatch.rs
Comment thread crates/dig-node-core/src/seams/dig_rpc/dispatch.rs
Comment thread crates/dig-node-core/src/lib.rs
…unregistered wire code

The reward-distributor refusal path minted its own numeric error code
locally. That number is not registered in the shared cross-implementation
wire taxonomy, whose canonical catalogue in lib.rs stops at -32032, and
lib.rs states that this numbering IS the byte-identical contract this node
declares it follows. Minting a code locally puts the node outside that
contract: another implementation would have no way to read it. The number
is not even free -- dig-node-service already spends it on an unrelated
ingress refusal, so two surfaces of the same product would have disagreed
about what it means.

Return -32032 with a REWARD_INVALID_WITHDRAWAL_SHARE data.code instead,
which is exactly the shape the chain-unavailable arm beside it already
uses. The two refusals stay fully distinguishable in the body, and the
refusal test needed no change -- it asserts the absence of `result`, not
a numeral.

Also corrects a born-false doc attribution: three comments asserted that
the chain adapter and its install_reward_chain_port call site belong to
#3268. #3268's scope is the claim loop and ClaimStatus, and it names
neither distributor_report nor this installer, so those comments pointed
a reader at a ticket that will not do the work. They now say the ownership
is an open question tracked separately rather than naming a number nobody
verified. Same defect shape as #3292.

Refs #3269

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Security gate: PASS

Head audited: a77ed70756bf55c3e3251cdde80feb52a2054f54 (merge-base cd8ce7b8, base develop).
Scope: the whole diff — crates/dig-node-core/src/{lib.rs, peer.rs, rewards/port.rs, seams/dig_rpc/dispatch.rs}, crates/dig-node-core/tests/reward_methods_tier_guard.rs (+816/-0, 5 files). Read-only; no LIVE vulnerability found. Four defence-in-depth findings below, none gating, three worth tickets against #3268 (the adapter unit) rather than this PR.

Severity limiter, stated up front: nothing constructs a RewardsChainPort adapter in production. Node::reward_chain_port() is an empty OnceLock on every production path (install_reward_chain_port is #[cfg_attr(not(test), allow(dead_code))]), so today both methods answer REWARD_CHAIN_UNAVAILABLE for every input. Every money figure below is audited as the code that becomes reachable when #3268 wires the adapter — which is the next unit.


1. #3303 — a wrapped figure reaching a money surface: clear

dig-node-core has no dig-rewards-coin dependency at this SHA (crates/dig-node-core/Cargo.toml; tests/dependency_tree.rs asserts it). withdraw_committed_incentives and recovered_base_units appear nowhere in the crate — repo-wide grep at a77ed707 returns zero hits for either symbol outside prose. The wrapping multiply is therefore unreachable from this diff by construction, not by discipline.

The figure that reaches the wire is CommitmentSlot::recoverable_base_units, echoed verbatim by the handler (dispatch.rs, ListRewardDistributorCommitments arm — the .map() copies four fields and computes nothing). rewards/port.rs:149-160 directs the adapter at dig_rewards_coin::recoverable_base_units (u128 intermediate) and names the wrapping sibling as the thing not to call. The test list_reward_distributor_commitments_answers_with_real_values_through_dispatch asserts the emitted 900 is the port's 900 — it catches a handler that restates the share.

The residue: that direction is a doc obligation on #3268, not an enforced one. Nothing in this crate can tell which rewards-coin function produced the number. Correct layering for unit 3; name it in #3268's brief as the one thing its gate must read.

2. The u64 to u16 narrowing on withdrawal_share_bps: closed as a type, NOT closed as a check — F2 below

The wire and the port are both u16, so the as u16 wrap cannot happen inside this diff — there is no as u16 and no u16::try_from in the diff at all, because the narrowing boundary lives in the un-landed adapter. ChainPortError::InvalidWithdrawalShare exists, is wired to its own code (-32033) and its own machine string, and is proven to refuse the whole call on both methods (reward_distributor_methods_refuse_whole_call_on_invalid_withdrawal_share). That is the right shape.

What is missing is the belt: DistributorReport::withdrawal_share_bps is a bare pub u16 with no constructor, and the handler emits report.withdrawal_share_bps with no > 10_000 guard in either arm. See F2.

3. A plausible zero on any refusal path: clear

All four refusal paths return a JSON-RPC error object with no result key:

  • no adapter installed — the let Some(port) = node.reward_chain_port() else { ... } arm maps to ChainPortError::Unavailable (both methods);
  • ChainPortError::UnavailableCONTROL_ERROR plus data.code = "REWARD_CHAIN_UNAVAILABLE";
  • InvalidWithdrawalShare-32033 plus its own data.code;
  • Other(msg)CONTROL_ERROR.

reward_distributor_methods_chain_unavailable_is_a_distinct_error_never_a_zero_or_empty asserts result is absent and error.code != 0 on both methods. Serde cannot re-introduce a default: I read dig-rpc-protocol 0.11.0 src/types.rs:1628-1676 and 1741-1797 field by field — neither result type carries skip_serializing_if or serde(default) on any field, so last_entry_write_at: None serialises as an explicit null and no field can be omitted-then-defaulted. (Health, a few hundred lines below, does use skip_serializing_if; these two deliberately do not.)

4. Is Tier::Control enforced, or only asserted? Enforced on the peer surface; asserted-only on the loopback HTTP surface — F1 below

  • Routing: both arms are Some(Method::GetRewardDistributor) / Some(Method::ListRewardDistributorCommitments) inside the match Method::from_name(method) block. The string pre-match above it contains exactly four names — chat.send, chat.poll, cache.pushCapsule, dig.getPublicManifest — and neither reward method is among them. Confirmed by reading the pre-match, not by trusting the comment.
  • Peer surface: dig_rpc_protocol::Method::is_peer_reachable (0.11.0 method.rs:239-255) is a 12-variant allowlist; neither reward method is in it. Method::tier() (method.rs:213-228) puts both in Tier::Control.
  • dig-node's own wrapper, peer::is_peer_reachable_method (peer.rs:1353), is the one that special-cases methods locally. Its three local returns are dig.getProviderSnapshot, dig.resolveCapsule and cache.pushCapsule (the last behind DIG_NODE_PUSH_OPEN); it then delegates to Method::from_name(..).is_some_and(|m| m.is_peer_reachable()), fail-closed on an unknown name. No reward method can slip through. NodeResponder::handle_json_rpc (peer.rs:1523) applies it before any dispatch, returning -32601.
  • #3261's blanket rule is now count-pinned, which is the part I most wanted to see: the diff adds assert_eq!(reward_methods.len(), 4, ...) to both peer.rs:5655 and tests/reward_methods_tier_guard.rs:45. Without it, a .contains("Reward") filter that stopped matching a renamed variant would have left the guard policing three of four methods and still passing — the "a gate over an enumeration only checks the enumeration" failure. Correctly closed in both places.

The gap is authorization on the local HTTP plane, not the peer plane: see F1.

5. Subject confusion: the wire is unambiguous; the aggregation risk moves to dig-app

Establishing whose each figure is, from the code:

  • reserve_base_units, payout_threshold, entry_count, current_distributor_epoch, fee_bps, withdrawal_share_bps, epoch_seconds, first_epoch_start — all the distributor singleton's, scoped to the caller-named launcher_id, which both results echo back. No operator, node or funder identity enters either result. No earnings figure is emitted by either method, so the "funder's total rendered as one operator's earnings" substitution has no field to land in here.
  • Per-slot rewards_base_units / recoverable_base_units are one funder's, and whose is carried by clawback_puzzle_hash — an entitlement fact, with dig-rpc-protocol types.rs:1704-1711 stating explicitly that a reader must not mistake it for a display label and that the share figure is "NOT an eligibility claim".
  • commitment_money_figures_stay_attributed_to_their_own_distributor drives two distinct distributors through the real dispatch and asserts neither the summed nor the swapped figure appears in either response — a test that asserts who a number is about, which is what was missing when this class last passed review.

Residual, out of this PR's scope but worth writing down (F5): listRewardDistributorCommitments returns every slot for the distributor, not the caller's. A consumer that sums recoverable_base_units across rows and labels it "your recoverable" reproduces the overstatement class exactly. dig-app 15.6.0's clawback-authority gate must filter rows by key-holding against clawback_puzzle_hash, never aggregate the list.

6. Input validation on the launcher id: clear, with one hygiene nit

parse_launcher_id_arg (dispatch.rs) rejects with -32602 before the port is touched, on: missing key, non-string, post-0x-trim length not equal to 64, and non-hex. The length check precedes hex::decode, so odd-length input cannot reach the decoder. No unwrap/expect/panic path; try_into is mapped. Repeated-prefix input (0x0x...) is stripped by trim_start_matches but then fails the length check — it cannot be massaged into a valid 64-hex id.

  • Zero id: [0u8; 32] is well-formed and is passed to the port. Correct — "is this launcher real" is a chain question, not a parser question.
  • Unknown id: today it yields Unavailable (an error, never an empty success). But the port has no way to say "no such distributor" — see F3. This is the one place the "absence is not silence" defect could re-enter, and it enters through #3268, not here.

Findings — ranked. All defence-in-depth; none LIVE; none gating.

F1 — Tier::Control is not an authorization tier on the loopback HTTP surface (MEDIUM, defence-in-depth, pre-existing posture).
dig-node-service/src/control.rs:99-101 is pub fn is_control_method(method: &str) -> bool { method.starts_with("control.") }, and control::is_authorized returns true immediately for any method that predicate rejects (control.rs:731-734). server.rs:1133 states it plainly: "The READ methods below are NOT token-gated." Both new methods are named dig.*, so they are answered on the token-less read plane despite being Tier::Control.
Exploit: with the adapter wired, any other local process or user on the operator's machine, any installed browser extension, or any page served from a canonical local name (is_local_origin is reflected on the whole surface per server.rs:319-326) POSTs {"method":"dig.listRewardDistributorCommitments","params":{"launcher_id":"<known id>"}} to the loopback RPC port with no control token and reads that distributor's reserve, every epoch's committed amount, every clawback amount and every clawback_puzzle_hash.
Why it is not gating: every figure returned is derivable from chain by anyone already holding the launcher_id, and launcher_id is caller-supplied — these two methods enumerate nothing (dig.listRewardDistributors, already on develop, is the enumerating one). So the disclosure is a convenience, not a new secret, and the posture is identical for the two sibling reward methods shipped in earlier units of this epic. Arbitrary internet origins are additionally blocked by preflight, being outside both reflected families.
The trap to record in the ticket: this prefix gate fails open for any future dig.* reward MUTATION — a dig.clawbackCommitment would be token-less on this plane while its control.* siblings are gated. A tier enforced on one plane and not the other is not enforced; server.rs:1175-1179 already says this about control.* versus the Sage-parity names, but it has not been applied to Tier::Control as a whole.

F2 — the <= 10_000 range check is a doc obligation with no enforcement at this boundary (MEDIUM now, HIGH the day #3268 lands).
rewards/port.rs:181 documents withdrawal_share_bps as "Already narrowed ... and validated <= 10_000 by the adapter"; dispatch.rs emits report.withdrawal_share_bps in both new arms with no check, and DistributorReport has no constructor that could impose one.
Exploit: #3268's adapter writes bps as u16 instead of u16::try_from. A puzzle constant of 74_536 narrows to 9_000; the wire, the app and the funder all read an ordinary 90% share. The funder claws back a slot expecting 0.9 x committed and the chain returns a different amount, with nothing in the response — or in any test in this repo — able to have flagged it. 65_536 narrows to 0, which is at least visibly absurd; 74_536 is the dangerous one precisely because it is plausible.
Fix (3 lines, subtractive, in this crate): in both arms, after Ok(report), refuse when report.withdrawal_share_bps > 10_000 via the existing reward_chain_port_error_response(&id, &ChainPortError::InvalidWithdrawalShare). The refusal path, its distinct code and its test already exist; this only makes the wire contract (types.rs:1749-1750, "A conforming responder MUST NOT emit a value above 10_000") self-enforcing instead of adapter-enforced. I recommend it and do not gate on it: no adapter exists, and the u16 port type makes the narrowing visible at #3268's own review.

F3 — ChainPortError cannot express "no such distributor", so a zeroed report is the adapter's path of least resistance (MEDIUM, defence-in-depth).
rewards/port.rs:126-145 — the enum is Unavailable | InvalidWithdrawalShare | Other(String). There is no NotFound. Other maps to CONTROL_ERROR with data.code = "CONTROL_ERROR" (dispatch.rs), so a client cannot machine-distinguish "this launcher id does not exist" from "the chain hiccuped" — and an adapter author facing a launcher with no coin record has no honest variant to return.
Exploit: the adapter returns DistributorReport with reserve_base_units: 0, commitments: vec![], entry_count: 0, observed_at: now for a launcher that does not exist, or one whose singleton it failed to follow. Both handlers serialise that as a successful answer. An operator reads "reserve 0, no commitments" — indistinguishable from a real drained distributor — and concludes there is nothing to claw back. dig-rewards-coin SPEC §2.4 clause 1 ("absence is not silence") is the rule this shape leaves to convention.
Fix: add ChainPortError::NotFound with its own machine code before #3268 wires the adapter, and state on distributor_report that an unresolvable launcher id MUST be that error and never a zeroed report.

F4 — parse_launcher_id_arg reflects the caller's raw string unbounded into three error messages (LOW, hygiene).
dispatch.rs formats the caller's string into all three failure messages. The existing parse_store_id_arg (lib.rs:4700-4708) returns Err(()) and echoes nothing, so this diverges from the established convention in the same crate, and SPEC §3.7 clause 4 asks for attacker-adjacent strings to be bounded before they can reach a log.
Consequence: bounded in practice — the HTTP body limit caps the input and serde_json escapes the value, so there is no injection and no meaningful amplification. Truncate to roughly 16 chars plus an ellipsis and it matches its neighbour.

F5 — see item 5: the commitments list is the distributor's, not the caller's; consumer-side aggregation is where the subject-confusion class re-enters. Out of scope for this PR; belongs in dig-app's clawback pane review.


Test evidence — from the job log, not the check mark

Job 103179651123 (Test + coverage, cargo llvm-cov nextest, --retries 2, fail-fast):
Summary [931.777s] 3402 tests run: 3402 passed (17 slow), 4 skippedzero FAIL and zero TRY n FAILED lines in the log, so nothing was left unrun behind a fail-fast (#3287). 169 PASS lines carry a reward/commitment-named test.

All six new dispatch tests confirmed executed and passed by name in the log, plus all three tier-guard integration tests:
get_reward_distributor_answers_with_real_values_through_dispatch · list_reward_distributor_commitments_answers_with_real_values_through_dispatch · get_reward_distributor_threads_entry_set_stale_both_ways · reward_distributor_methods_chain_unavailable_is_a_distinct_error_never_a_zero_or_empty · reward_distributor_methods_refuse_whole_call_on_invalid_withdrawal_share · commitment_money_figures_stay_attributed_to_their_own_distributor · reward_methods_tier_guard::{every_reward_method_is_tier_control, no_reward_method_is_peer_reachable, reward_methods_exist_and_are_found_by_the_prefix_scan}.
Coverage: dig-node-core/src/rewards/port.rs 100.00% lines / 100.00% branches.

Not covered

The #3268 service-side adapter (does not exist at this SHA) — F2 and F3 are the two things its gate must check. dig-rewards-coin 0.4.0's own recoverable_base_units arithmetic, read only as the doc reference it is here, never linked by this crate. dig-app 15.6.0's clawback pane (F5). The pre-existing loopback/CORS posture beyond establishing F1's reach. dig-rpc-protocol 0.11.0 was read from the local cargo registry cache, never docs.rs.

Read-only audit. I mutated the shared modules/apps/dig-node checkout in exactly one additive way: a git fetch of refs/pull/609/head into one remote-tracking ref. No checkout, HEAD, index, working tree or branch was touched; nothing needed restoring.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial condition C3 satisfied by filing #3310 — the adapter and its install_reward_chain_port call site, carrying the #3303 pin. Follow-up F1 filed as #3311-32031 for catalogue-recognized but unserved methods.

Both are ticket-only; neither requires a code change at this PR's head.

MichaelTaylor3d and others added 8 commits September 11, 2026 01:16
…istributor seam

dig_ecosystem#3284. Both `dig.getRewardDistributor` and
`dig.listRewardDistributorCommitments` emitted `report.withdrawal_share_bps`
with no range check. The port's adapter is contracted to refuse a value outside
`0..=10_000`, but no adapter exists yet and the contract was enforced nowhere at
this seam, so `10_001..=65_535` reached the wire unexamined: a chain constant of
`74_536` narrowed into the wire's `u16` renders as `9_000` (an ordinary-looking
90% share) and `65_536` renders as `0`. Either is a figure a funder reads before
deciding whether to claw back, and neither looks wrong.

Refuses the whole call through the existing `InvalidWithdrawalShare` path
(`CONTROL_ERROR` with `data.code = REWARD_INVALID_WITHDRAWAL_SHARE`) rather than
clamping: clamping `74_536` to `10_000` would report a confident 100%
recoverable share for a distributor whose real constant is nonsense. A
whole-call refusal costs no good data - `withdrawal_share_bps` is curried once
per distributor at launch, so every commitment slot in one response shares the
one invalid value.

Two tests, a selectivity pair: `10_001` refuses on both methods with neither the
raw figure nor a clamped `10_000` anywhere in the body, and `10_000` - a
legitimate 100% - still answers on both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fake

TEMPORARY, reverted in the next commit. Proves
`commitment_money_figures_stay_attributed_to_their_own_distributor` is not
vacuously green: with the two reports swapped behind their launcher ids the test
must go RED against the green baseline at a77ed70.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… in the fake"

Reverts 0b07be6. The probe commit was pushed onto a branch a SECOND writer is
committing to concurrently (3dd1d67, d5e1509 appeared under this worktree's
HEAD mid-task), so the probe is withdrawn rather than left to redden that
writer's CI. The tree is byte-identical to d5e1509.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing one fails

`no_local_wire_code_collides_with_a_different_canonical_code` passed at
1134/3402 while this crate really was emitting `-32033`, a number that is
already `dig-node-service`'s `ControlIngressLimited`. It passed because the
number was never added to `LOCAL_WIRE_CODES` - and the guard's own side-effect
assertion was `len() >= 10`, which measures the table's SIZE, not its
completeness. A code nobody registers is invisible to a guard built over the
registry.

Adds `every_wire_code_this_crate_mentions_is_classified`, which SCANS this
crate's sources for every `-32xxx` in a non-comment line and requires each to be
either declared by the taxonomy owner (resolved through
`seams::dig_rpc::errors::taxonomy_code`, so the taxonomy is not restated) or
registered in `LOCAL_WIRE_CODES`. The list is therefore derived from the emitting
sites and cannot fall behind them: forgetting to register a number is what
fails. Chose the derived form over a hand-maintained "everything we emit" list
because such a list is the same defect one level up.

Also: registers the two numbers this crate occupies that upstream does not
declare (`-32001` push authorization, `-32002` `ENGINE_WARMING`), both previously
unregistered; replaces the `len() >= 10` floor with an exact count; and drops the
table doc's now-false claim that the `-32050..-32052` chat band is undeclared
upstream - `dig-rpc-protocol` 0.11 declares all three.

The test carries its own non-vacuity checks: a scanner-liveness floor plus three
codes known to be present, and a proof that the classifier rejects the number
that slipped through, built by arithmetic because writing it as a literal would
itself be a mention the scan must classify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he service crate

Two repairs to the two commits before this one, then three doc-truth findings.

Repairs:

* the wire-code assertion message lost its line continuations, baking a 10+
  space run into the string - the exact defect `continuation_guard` exists to
  catch, and the only red on the branch. Restated as one line, so there is no
  continuation to lose. The message a reader gets at the moment they are told a
  wire code is unregistered is now a clean sentence.
* the out-of-range withdrawal-share test asserted the digits `10000` appear
  nowhere in the body, which the refusal MESSAGE legitimately contains ("must
  fit u16 and be <= 10000"). Matched in a JSON value position (`:10000`,
  `:10001`) instead, so the assertion is about the figure rather than the prose.

Findings:

* `install_reward_chain_port` is now `pub`. Its own doc said the installing
  startup path lives in `dig-node-service`, while `pub(crate)` meant that crate
  could not call it - both could not be true. The decided architecture builds
  the adapter in the service crate and injects it downward, so the setter is the
  injection point and says so, citing dig_ecosystem#3310 (which names both
  `distributor_report` and this function). The single-install `OnceLock`
  discipline is untouched: `set(port).is_ok()` stays, and a second install still
  returns `false` and changes nothing. The `allow(dead_code)` is dropped - it was
  hiding the unreachability, not standing in for an absent caller.
* the two mutation-probe doc comments cited "the accompanying report", an
  artifact that does not exist in this repository. Each now states the mechanism
  and the expected outcome - which edit, which command, which assertion fails -
  so a reader holding only the repo can re-run it.
* `rewards::port` asserted in the present tense that `dig-node-service` depends
  on `dig-rewards-coin` and computes `recoverable_base_units` with it. It does
  not: that manifest has no such dependency and no adapter exists. Rewritten in
  the future tense against #3310, and it now also names the seam-side range
  check so the two statements of that rule cannot be read as rivals.
* three comments attributed the funder-registry installer to #3268, whose scope
  is the claim loop and `ClaimStatus` and which names neither the registry nor
  the call. They now say the owner is tracked separately and is not #3268; the
  installer work itself stays out of this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-- repoint port.rs

Finding E's defect class, in the one file of the three it was reported in that
this lane owns.

`rewards/port.rs`'s module doc was written against `dig-rewards-coin` 0.2.0 and
still asserted, in the present tense, that the crate "ships no chain reader --
blocker 1". It does. Verified first-hand rather than from a report:
`index.crates.io` lists 0.4.1 as the latest publish, and 0.4.0 in the local
registry cache carries `state::read_distributor(&impl ChainSource, launcher_id)
-> Result<Option<DistributorSnapshot>, RewardsError>` and
`clawback::recoverable_base_units(rewards_base_units, withdrawal_share_bps) ->
Option<u64>`, the latter refusing above `10_000` bps exactly as this seam's own
range check does. No dependency is added: the crate stays absent from
`dig-node-core`'s manifest, which is the injected-downward architecture, and the
doc now says so as an instruction rather than as a pending chore.

The two `UnavailableChainPort` citations deferred the production adapter to
#3249, the `dig-rewards-coin` DRIVER ticket. That is a dead pointer, not a stale
one: the driver does no socket I/O -- its reader takes a caller-supplied
`ChainSource` -- so it can never be the adapter, and a blocker filed on a ticket
that structurally cannot ship the change is never read. Repointed to #3310,
which owns the `RewardsChainPort` adapter in `dig-node-service` and names both
`distributor_report` and `install_reward_chain_port`; #3307's claim seam is a
different trait and is not what these sentences are about. The correction is
recorded in the doc rather than silently applied, because the next reader will
otherwise re-derive the same wrong pointer from the ticket title.

Two dependent claims corrected with it: the paragraph that said no adapter could
answer any of the four trait methods "yet" now makes the narrower true claim
(none can live in THIS crate, which holds no `ChainSource`), and the settlement
condition that waited on "a reader (0.3.0+) and the funder-ownership registry"
now records that the reader half is met and blocker 2 is not.

Out of fence, reported upward rather than edited: the same dead #3249 pointer
sits in `rewards/mod.rs:11,:16`, `rewards/spec_constants.rs:7` and
`rewards/writes.rs:244`, and the first two also carry the SPEC-only claim about
`dig-rewards-coin` that this commit retires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Salvage, not new work. The derived scan landed in 2f81a0e, but the three hunks
it depends on - the two unregistered codes it needs registered (`-32001` push
authorization, `-32002` `ENGINE_WARMING`), the exact-count assertion replacing
the `len() >= 10` floor, and the table doc - were swept into a sibling lane's
probe commit (0b07be6) and removed again by its revert (a937f81) while this
lane's edits were uncommitted. The scan was therefore correct and RED: `-32002`
really was unclassified.

Verified locally at this tree rather than from a CI log:
`every_wire_code_this_crate_mentions_is_classified`,
`no_local_wire_code_collides_with_a_different_canonical_code` and
`no_lost_string_continuation_leaves_a_multi_space_run_mid_sentence` all pass,
and `cargo fmt --check` is clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nly claim

The four cited sites pointed at #3249, the `dig-rewards-coin` DRIVER ticket.
That crate does no socket I/O -- `state::read_distributor` takes a
caller-supplied `&impl ChainSource` -- so it structurally cannot ever ship the
chain adapter these comments were waiting on. Repointed by seam:

- `writes.rs` and `mod.rs` (the `RewardsChainPort` seam) -> #3310, which names
  both `distributor_report` and `install_reward_chain_port`.
- `mod.rs` also notes the claim-side chain adapter is tracked in #3307.

`mod.rs` and `spec_constants.rs` additionally claimed the crate is "SPEC-only"
with an empty `pub mod distributor {}`. False since 0.2.0 and three releases
stale: 0.4.1 ships `state::read_distributor(&impl ChainSource, launcher_id)
-> Result<Option<DistributorSnapshot>, RewardsError>`, `DistributorSnapshot`,
`ChainObservation` and `clawback::recoverable_base_units`, and has no
`distributor` module at all. Both now say what is true: the crate ships the
reader and the share arithmetic; what dig-node lacks is the adapter wiring that
reader to a `ChainSource`.

The `spec_constants.rs` migration note no longer attributes the constants to
#3249 either: 0.4.1's `constants` module carries distributor-side values only,
not these prover-side ones, so no ticket is claimed to be about to publish them.

Comments only -- no code, test or dependency change.

Refs #3269

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant