Skip to content

feat: serve dig.getRewardProverStatus at Tier::Control - #595

Merged
MichaelTaylor3d merged 13 commits into
developfrom
feat/3269-reward-prover-status-rpc
Sep 10, 2026
Merged

feat: serve dig.getRewardProverStatus at Tier::Control#595
MichaelTaylor3d merged 13 commits into
developfrom
feat/3269-reward-prover-status-rpc

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

DO NOT MERGE — gate round in progress.

Refs #3269

Serves exactly ONE of the ticket's four methods: dig.getRewardProverStatus. The other three (listRewardDistributors, getRewardDistributor, listRewardDistributorCommitments) are blocked on #3249 (dig-rewards-coin chain driver, not yet published) and are intentionally absent — no stub, no todo!().

Work in progress; will update this description as commits land.

MichaelTaylor3d and others added 2 commits September 9, 2026 05:44
Bump dig-rpc-protocol to 0.11.0 (adds dig.getRewardProverStatus and
the other reward RPC methods to the wire).

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

- dependency_tree.rs: assert the resolved dig-rpc-protocol line is 0.11 (was 0.10);
  documents the known-red two-version state pending the dig-peer 0.14.0 /
  dig-download 0.23.0 cascade (#3269).
- reward_methods_tier_guard.rs: fail-closed guard over the live Method::ALL
  catalogue -- every Reward-named method must be Tier::Control and not
  peer-reachable, so a fifth reward method added later is caught at the wrong
  tier automatically rather than inheriting a wrong default (binds #3261's rule
  node-side).
- peer.rs: sibling unit test exercising the real (pub(crate)) is_peer_reachable_method,
  since an external integration test cannot see it -- same guard, executed against
  this node's own allowlist rather than only the shared crate's.

Refs #3269

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the feat/3269-reward-prover-status-rpc branch from d1b24b5 to 8844ca5 Compare September 9, 2026 14:05
MichaelTaylor3d and others added 6 commits September 9, 2026 14:03
Adds the missing handler for PR#595: a new reward_prover_statuses
registry + accessors on Node (empty until #3265 spawns a prover loop,
so the registry read is real, not a stub), a dispatch.rs arm inside
the Method enum match (never the string pre-match), and a
field-for-field mapping from dig-node-core's internal
rewards::state::RewardProverStatus (camelCase-tagged) onto
dig-rpc-protocol 0.11's wire RewardProverStatus (snake_case-tagged
struct, camelCase-tagged ProverState value), widening entry_count
u32 -> u64 explicitly and hex-encoding the three [u8; 32] identity
fields.

An all-zero launcher_id (what an uninitialised registry slot
hex-encodes to) is omitted at this boundary rather than rendered as
a real distributor with a plausible-looking id -- the money-hole
class the dig-rewards-coin driver's adversarial gates found three
times.

Tests (in dig-node-core::lib.rs's existing test module, where the
pub(crate) registry accessors are visible) drive the real dispatch
entry point (handle_rpc -> RpcDispatch::dispatch -> the Method arm)
and assert field-for-field on the serialized JSON body: populated
registry, empty registry (-> {"statuses": []}), zero-id omission,
tier/peer-reachability, enum-match-not-string-prematch, and
launcher_id filtering. The no-health-boolean / no-staleness
assertion is by key set, not substring.

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

Closes the two-versions-of-dig-rpc-protocol split (#3269): dig-download 0.23.0
and dig-peer 0.14.0 both now resolve dig-rpc-protocol ^0.11, matching
dig-node-core's own dig-rpc-protocol = "0.11.0" line, and dig-node-service's
two 0.10 lines (main dep + dev-dependency restatement for
openrpc_drift_guard.rs) move to 0.11 to match.

Manifests only. cargo update / Cargo.lock intentionally NOT run yet: a fourth
capper, dig-peer-selector ("0.11" in dig-node-core/Cargo.toml), still requires
dig-peer = "^0.13" in every published version through 0.11.1, so the tree
cannot fully resolve until a dig-peer-selector release picks up dig-peer 0.14.
CI will stay red on this commit for that reason, which is expected.

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

Bump dig-peer-selector 0.11 -> 0.12 (the release that moves onto dig-peer
^0.14) and run cargo update, closing the two-versions-of-dig-rpc-protocol
split: Cargo.lock now resolves exactly one dig-rpc-protocol, at 0.11.0,
alongside dig-peer 0.14.0, dig-download 0.23.0, dig-peer-selector 0.12.0.

Add a subject-attribution test and doc comments to
reward_prover_status_to_wire: total_paid_out_base_units and
reserve_base_units are per-distributor totals (this distributor's payout to
ALL its mirrors, and this distributor's own reserve), never the querying
node's own earnings and never summed/cross-attributed across distributors.
This is the defect class a sibling adversarial gate found in dig-app#403's
rewards pane, which rendered a distributor total as one mirror operator's
personal earnings and overstated by up to 250x.

Checked: rewards/state.rs, rewards/port.rs and rewards/mod.rs contain no
Eligible/verdict/payout_hash symbol, so nothing in this mapping surfaces a
persisted EligiblePayoutHash verdict.

Refs #3269

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

Clippy's non-test lib target has no production caller for
register_reward_prover_status yet, because #3265 (the always-on prover loop
that would call it from bring-up) has not landed -- only tests call it today.
cfg_attr(not(test), allow(dead_code)) stands in for that missing caller
until #3265 wires a real one.

Refs #3269

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the feat/3269-reward-prover-status-rpc branch from ecd5fd7 to 6be80c9 Compare September 9, 2026 21:04
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial leg (loop-decider) — dig_ecosystem#3269 triple gate

Head audited: 6be80c978c208d0ee1d54bea9b33552ed0af3797 (unchanged during the read). Sources read directly: this diff, dig-rewards-coin/SPEC.md at tag v0.1.3 (§2.3, §2.4, §2.6, §7.4), dig-rpc-protocol 0.11.0 types.rs/method.rs, dig_ecosystem#3274, dig-app#403 + origin/main of dig-app.

Verdict: MERGE-WITH-CONDITIONS

Two conditions, both checkable, one before merge and one on the ticket graph. The premise attack does not sink the PR; it does sink the story being told about it.

Why this is not the inert pattern. The "nothing executes yet" argument was retired because money arithmetic in rewards/ was landing that could only be validated against a chain. This PR has no arithmetic and no chain dependency: the handler is reachable over the loopback control surface the moment it ships, and every behaviour it has (tier, enum dispatch, explicit field mapping, u32→u64 widening, per-launcher attribution) is exercised in-process by the tests in lib.rs:9041+. Its correctness does not become more knowable by waiting for #3265. That is the opposite of the #593 situation.

Positions on the five items

1. Registry with no production writer. The orchestrator's reasoning holds, with one correction. {"statuses": []} is true today under SPEC §2.4 clause 1: a funded distributor with no record renders "not distributing", and this node build is in fact not distributing. A hardcoded vec![] would have identical output today and diverge silently the day #3265 lands; the registry read does not. #[cfg_attr(not(test), allow(dead_code))] does not launder anything — the fn is pub(crate), its doc names #3265 as the writer, and the attribute is the only way to keep -D warnings green without a fake caller. Not a reason to hold.

2. Does it reach a user? No — and the urgency premise in the brief is false. dig-app#403 is OPEN and unmerged (head 61321969); dig-app's latest release is v15.4.0, there is no v15.5.0 tag; rewards::pane::rewards_sections() has zero callers outside src/rewards/ on dig-app origin/main (the #403 body itself says the store-row mount is "explicitly NOT shipped"). Clawback additionally needs dig.listRewardDistributorCommitments, which this PR does not serve. Merging #595 unblocks no user-visible function. Report it upward as "the first Control-tier reward read is served; nothing user-facing changes until #3265 (writer) and the commitments method land" — not as progress on the pane.

3. The all-zero launcher_id omission is the dangerous branch. Condition A: remove it. Facts: rewards/cycle.rs:121's idle_status([0;32], …) is inside #[cfg(test)] mod tests (cycle.rs:115-116); no production code constructs a zero-id record — the filter defends against nothing that exists. If #3265 ever does register one, that is a real defect and this filter turns it into "no record" → dig-app renders "Not distributing" — exactly the silence §2.4 clause 1 forbids. It also violates §2.6's contract that the method returns "the §2.3 record" (the handler is silently editing the set, the same class of handler-side correction the orchestrator correctly refused for #3274). It is asymmetric (zero store_id/root pass through). Remedy: delete the .filter(|s| s.launcher_id != [0u8; 32]) at dispatch.rs and the test get_reward_prover_status_omits_an_all_zero_launcher_id; if a guard is wanted, put it at the writer (register_reward_prover_status refusing/panicking on a zero id, which is #3265's concern) — never as a silent read-side drop. Checkable: grep -n '\[0u8; 32\]' crates/dig-node-core/src/seams/dig_rpc/dispatch.rs returns nothing.

4. Subject of the money numbers. The attribution test proves each figure travels under its own launcher_id and neither sum nor swap appears — that is the strongest thing this boundary can prove. It cannot stop the 250x class: the wire carries no subject, and the 0.11.0 wire docs (types.rs:1505-1507) say only "the distributor's reserve" / "over the loop's lifetime". The consumer-side fix (label as the distributor's totals, never the operator's) belongs in dig-app#403's gate, not here. Sufficient at this boundary; not a merge blocker.

5. #3274 does not block this merge; filing is sufficient, conditional on linkage. Condition B. With no production writer the counters are never non-zero, so the restart-reset cannot manifest until #3265 spawns a loop. The defect lives in the record (#3250's file set) and §2.4 rightly forbids this handler correcting it. Note two things for whoever owns it: the 0.11.0 wire doc already says "over the loop's lifetime", which leans toward #3274's option 2 (since-process-start) and makes a SPEC/wire alignment cheaper than persistence; and #3265's body currently does not reference #3274 (grep on its body for 3274/persist/restart returns nothing). Condition B: #3274 is recorded as a hard blocker on #3265 (a "Blocked by #3274" line in #3265's body or a pinned comment) before #3265 registers any production writer. Checkable by reading #3265.

One further note, not a condition

The launcher_id filter path returns [] for a malformed (non-64-hex) param instead of a JSON-RPC invalid-params error. Under §2.4 clause 1 an empty list reads as "not distributing", so a typo in a caller's id silently reads as a state claim. Worth a follow-up ticket for whoever next touches dispatch.rs; not blocking because the only consumer is Control-tier and dig-app currently sends no filter.

Failure direction of the wrong call

Holding this PR until #3265 lands would gate a chain-free, fully-testable boundary on chain-dependent work — reintroducing the coupling the inert-pattern rule was meant to break. Merging with the zero-id filter in place ships a silent read-side data drop on a money surface that the driver has now seen bite three times.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Security audit — CHANGES-REQUIRED

Head SHA audited: 6be80c978c208d0ee1d54bea9b33552ed0af3797 (matches gh pr view 595 --json headRefOid)

Scope

crates/dig-node-core/src/{lib.rs, seams/dig_rpc/dispatch.rs, peer.rs}, crates/dig-node-core/Cargo.toml, crates/dig-node-service/Cargo.toml, Cargo.lock, tests/{dependency_tree.rs, reward_methods_tier_guard.rs}, plus the vendored dig-rpc-protocol 0.11.0 source (method.rs, types.rs) read from the local cargo registry cache to verify tier/reachability at the crate that actually defines them.

1. Peer reachability — CLEAR, verified independently, not just by the new tests

  • dispatch.rs:285Method::from_name(method) is the only place Some(Method::GetRewardProverStatus) is matched (added at dispatch.rs:732). The string pre-match block above it (dispatch.rs:236-268: chat.send, chat.poll, cache.pushCapsule, dig.getPublicManifest) ends in _ => {} and does not name the reward method — confirmed by reading the block directly, not inferring from the comment.
  • dig-rpc-protocol 0.11.0 method.rs::tier() (read from ~/.cargo/registry/src/.../dig-rpc-protocol-0.11.0/src/method.rs:184-228) is an exhaustive match with no wildcard arm over Method's variants — GetRewardProverStatus sits in the Tier::Control arm alongside the other three reward methods. Because the match has no _, a fifth reward-named variant added later without an explicit tier arm fails to compile, and if given the wrong tier deliberately, reward_methods_tier_guard.rs::every_reward_method_is_tier_control catches it at the crate's own enumeration (Method::ALL), independent of naming. Answer to the brief's fifth-method question: yes, it would be caught — at compile time if omitted, at test time if mistiered.
  • method.rs::is_peer_reachable() (lines 239-253) is likewise a closed matches! allowlist (12 named methods, none reward-named); adding a reward method there is impossible without editing this function, which the ecosystem does not control (crates.io pin).
  • peer.rs:1353-1382 (is_peer_reachable_method, node-local wrapper): read in full. The two pre-existing local special-cases (dig.getProviderSnapshot, dig.resolveCapsule) and the conditional one (cache.pushCapsule) are unchanged by this diff; the reward method is not named anywhere in this function, so it falls through to dig_rpc_protocol::Method::from_name(method).is_some_and(|m| m.is_peer_reachable()) -> false. The diff adds only the test (peer.rs:5636+), confirmed by diff — no allowlist line was touched.
  • peer.rs:1519-1525 — the actual inbound-peer gate (NodeResponder's JSON-RPC entry) calls is_peer_reachable_method(method) and returns -32601 before any call reaches dispatch/handle_rpc. This gate is untouched by the PR; the new tests prove the reward method fails it.
  • Verdict on item 1: no live path exists for a peer to reach dig.getRewardProverStatus. CLEAR.

2. Subject-attribution — CLEAR

reward_prover_status_to_wire (dispatch.rs:152-196) passes reserve_base_units/total_paid_out_base_units through unmodified and unaggregated, one call per registered StatusHandle, no cross-record fold anywhere in dispatch.rs or rewards/*.rs (grepped for sum(/fold(/+= near the handler — the only sum() hit in the crate is an unrelated challenge byte-length total in challenge.rs:131). The added test get_reward_prover_status_attributes_payout_figures_to_their_own_distributor (lib.rs) uses two distributors with deliberately non-additive, distinguishable totals (999,000 and 7) and asserts neither the sum nor a swap appears — a stronger proof than a same-value fixture would give. CLEAR.

3. All-zero launcher_id/store_id/root — LIVE FINDING, CHANGES-REQUIRED

dispatch.rs, the new Method::GetRewardProverStatus arm:

.filter(|s| s.launcher_id != [0u8; 32])

This filters only launcher_id. RewardProverStatus (rewards/state.rs:50-53) has three independently-uninitializable [u8; 32] fields — launcher_id, store_id, root — and nothing in state.rs, StatusHandle::new, or the dispatch filter checks store_id/root.

Concrete exploit / defect scenario: once #3265 lands and registers a real StatusHandle per distributor, any bug in that registration path (e.g. the manager identifies the distributor before it has resolved the store's current root, or before a store binding is confirmed) that leaves store_id or root at [0; 32] while launcher_id is already the real, non-zero distributor id will sail through this filter untouched. The RPC caller (an operator's own tooling, or a future dig-app pane per the brief's dig-app#403 precedent) receives a dig.getRewardProverStatus record with a plausible non-zero launcher_id and a store_id/root of "0000...0000" — 64 hex characters, indistinguishable in shape from a real id to any consumer that doesn't specifically check for the all-zero pattern (the brief states shipped dig-app 15.5.0 does not). That reads as "this distributor is bound to store 0000...0000" — a fabricated-looking but not obviously-wrong binding — rather than surfacing the uninitialised-registration bug it actually is.

This is the same shape the brief names from the dig-rewards-coin driver's adversarial history (a zero manager_singleton_launcher_id accepted unchecked), just on a sibling field the PR's own fix didn't cover. The PR's remedy (omission) is also the wrong shape here even where it's complete: silently dropping a record whose launcher_id is zero destroys the evidence that a registration went wrong. If a real prover loop ever registers a zero-anything record, the operator gets {"statuses": []} or a shortened list with no signal that something is wrong — the exact "reads fine and costs money" pattern (here, "reads as if that distributor doesn't exist" rather than "reads as a healthy default").

Recommendation: validate store_id, root alongside launcher_id (a record with any required id field at [0; 32] is not a valid distributor status), and prefer refusing/flagging over silent omission — e.g. surface a distinguishable prover_state or log/metric on filtering, so a zero-anything registration is visible to whoever owns #3265's registration path rather than swallowed. Silent omission is also inconsistent with SPEC §2.4's stated honesty stance the rest of this PR takes seriously (no field a stalled/buggy writer can quietly make look fine).

4. Section 2.4 honesty (no health boolean / no precomputed staleness) — CLEAR

rewards/state.rs:50-68 (RewardProverStatus) carries no alive/healthy/ok/up/running/staleness field; observed_at is the only timestamp signal, and is_wedged (cycle.rs:110-113) is explicitly reader-side, comparing the reader's own clock. The added test get_reward_prover_status_answers_a_real_request_with_real_values (lib.rs) asserts this on the serialized JSON key set (BTreeSet<&str> from s.as_object().keys()), banning alive/healthy/ok/up/running/stale/seconds_since_last_run by key membership, not contains() substring — correctly immune to the superstring false-pass this class of check is normally vulnerable to. CLEAR.

5. Registry reachability / lifecycle — CLEAR

register_reward_prover_status (lib.rs, new) is pub(crate), #[cfg_attr(not(test), allow(dead_code))], and has no call site in this diff outside #[cfg(test)] modules — confirmed by grep; no RPC method, FFI export, or peer frame handler calls it. Nothing untrusted (a peer, a remote RPC caller) can reach it; only an in-crate production caller landing in #3265 will be able to, and that is out of scope here as stated. The read path (reward_prover_status_snapshots, lib.rs) takes a std::sync::RwLock::read() and immediately .iter().map(..).collect()s synchronously with no .await inside the locked scope — no lock is held across an await point, so no caller can stall the node by holding this read. CLEAR.

6. EligiblePayoutHash staleness — CLEAR, confirmed independently

Grepped rewards/{state,mod,port}.rs, lib.rs, and seams/dig_rpc/dispatch.rs for Eligible|verdict|payout_hash|PayoutHash (case-sensitive substrings) across the full PR diff and the current tree at this SHA: zero matches. This handler surfaces no verdict type and cannot leak a stale payout-eligibility judgment. Confirmed: no such path exists.

Other notes (not gating)

  • tests/dependency_tree.rs's updated doc comment describes the 0.11 resolution as "known-red... stays red until... a separate, already-dispatched lane [republishes]" — but this same PR's Cargo.toml/Cargo.lock diff already bumps dig-peer to 0.14.0 and dig-download to 0.23.0, i.e., the cascade this comment says is still pending is bundled in this very PR. Stale doc text, not a security defect — flagging for the correctness gate / follow-up tweak, not gating here.
  • Dependency bumps' own correctness (dig-peer/dig-download/dig-peer-selector) are out of scope per the brief; not independently re-audited here beyond confirming the resolved dig-rpc-protocol tier/reachability tables above.
  • I did not re-derive or re-audit rewards/** internals beyond the two structs (RewardProverStatus, StatusHandle) and the one call site (cycle.rs:121, confirmed #[cfg(test)]-only, not production) this handler's diff touches.

Note on tooling

Two WebFetch calls against docs.rs pages for dig-rpc-protocol 0.11.0 each returned content containing an embedded prompt-injection fragment (garbled directive-like text appended after the legitimate source). Both were identified and ignored; findings above were re-verified by reading the actual crate source directly from the local cargo registry cache (~/.cargo/registry/src/.../dig-rpc-protocol-0.11.0/src/method.rs, types.rs) rather than trusted from the fetched pages.

Verdict: CHANGES-REQUIRED

One live finding (item 3: all-zero store_id/root unfiltered, and omission-as-remedy swallows evidence of a bad registration). Every other numbered item in the brief is CLEAR with file:line evidence above. No peer-reachable path to any *Reward* method exists.

@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.

Verdict: PASS

Head SHA 6be80c978c208d0ee1d54bea9b33552ed0af3797 (confirmed unmoved throughout review).

CI final state (all green)

Analyze (actions), Analyze (javascript-typescript), Analyze (rust), Clippy, CodeQL, Lint commit messages, Release-script tests, Rustfmt, build .deb (linux-amd64), build .deb (linux-arm64), build .msi (windows-x64), build .pkg (macos-universal), Test + coverage — all pass. Coverage TOTAL 89.99% lines (gated >=80%). Test + coverage ran 3293 tests, 3293 passed (4 skipped), including all 7 named reward tests plus the 3 reward_methods_tier_guard tests and the peer::tests::reward_methods_are_absent_from_the_node_peer_allowlist test — verified from the job's own nextest log, not asserted from the green checkmark alone.

The six items

  1. No health boolean / staleness (SPEC §2.4). Confirmed. get_reward_prover_status_answers_a_real_request_with_real_values asserts on the serialized JSON's key set (BTreeSet<&str> from s.as_object().keys()), checking absence of alive/healthy/ok/up/running/stale/seconds_since_last_run — not a substring check, so it isn't satisfiable by an unrelated superstring. dig-rpc-protocol 0.11.0's RewardProverStatus/ProverCounters carry no such field either.

  2. Money-figure subject. Confirmed. reward_prover_status_to_wire's doc comment states explicitly: this distributor's own reserve, the total this distributor has paid out to ALL of its mirrors combined, neither being the querying node's own earnings, referencing the dig-app#403 250x overstatement by name. get_reward_prover_status_attributes_payout_figures_to_their_own_distributor registers two distributors with distinguishable totals (999,000 vs 7), asserts each stays under its own launcher_id, and explicitly asserts against the summed (999,007) and swapped values. This genuinely pins the property, not just the wire round-trip.

  3. Wire casing / widening. Confirmed against the shipped 0.11.0 source: RewardProverStatus/ProverCounters/GetRewardProverStatusResult carry no rename_all; only ProverState has #[serde(rename_all = "camelCase")] (types.rs:1464). The internal rewards::state::RewardProverStatus IS #[serde(rename_all = "camelCase")] (state.rs:49), so reward_prover_status_to_wire constructing the wire struct field-by-field (never serializing the internal type) is load-bearing, not stylistic. entry_count is internal u32 (state.rs:41) widened via u64::from(...) explicitly; the test sets 3_000_000_000 (> u32::MAX/2, so a truncating re-narrowing would visibly fail) and asserts json!(3_000_000_000u64).

  4. Real read, not a shortcut. Confirmed. reward_prover_status_snapshots() reads self.reward_prover_statuses (an Arc<RwLock<Vec<StatusHandle>>> field on Node) and the dispatch arm calls it — never a literal vec![]. register_reward_prover_status is #[cfg_attr(not(test), allow(dead_code))] today (no #3265 caller yet), exactly as described.

  5. All-zero launcher_id omission. The .filter(|s| s.launcher_id != [0u8; 32]) is defensible as implemented — the internal record type gives no way today to distinguish "never assigned" from a filled slot, and a real launcher_id is a singleton coin ID (probability of a genuine zero is negligible), so surfacing it would misrepresent a code bug as a real distributor with a spendable-looking 64-hex ID, which is a worse failure mode for a money-adjacent read than silence. That said: omission with only a source comment and no tracing::warn! means the one condition this filter exists to catch — a registration bug — currently produces zero observable signal anywhere, which sits close to the "silent cap on a globally writable set" pattern this codebase has been burned by before. Since register_reward_prover_status has no production caller yet, this is not a merge blocker, but a log line (or metric) alongside #3265's registration call site would close the gap so an operator or on-call sees it the day this can actually happen. Non-blocking suggestion, not a defect in this diff.

  6. Tier::Control routing via the enum, not the string pre-match. Confirmed by reading dispatch.rs's pre-Method::from_name string match (lines ~232-275): it only special-cases chat.send, chat.poll, cache.pushCapsule, and dig.getPublicManifestdig.getRewardProverStatus is absent from it, so it can only be reached through the enum match. get_reward_prover_status_is_served_via_the_method_enum_not_the_string_prematch asserts Method::from_name(...) == Some(Method::GetRewardProverStatus) and .tier() == Tier::Control, which is a real (if indirect) proof given the pre-match's absence — a reviewer has to read the pre-match block itself to know the test's premise holds, which this review did.

Also checked

  • #3261 guards non-vacuous: reward_methods_are_absent_from_the_node_peer_allowlist (peer.rs) asserts Method::ALL contains at least one Reward-named method before checking allowlist absence; reward_methods_tier_guard.rs's reward_methods_exist_and_are_found_by_the_prefix_scan does the same. Neither is weakened from what the PR diff shows created fresh.
  • dependency_tree.rs: the_workspace_carries_exactly_one_module_wire_crate asserts versions[0].starts_with("0.11.") — exact line, not a range/set (matches the #836/#1576 rule). However, the doc comment added above it says the test "stays red until the dig-peer 0.14.0 / dig-download 0.23.0 cascade... republishes" — this is stale/wrong as merged: the same PR's Cargo.toml diffs bump dig-node-core's own dig-peer to 0.14 and dig-download to 0.23, Cargo.lock resolves exactly one dig-rpc-protocol (0.11.0) and one dig-peer (0.14.0), and CI's Test + coverage job shows this exact test PASSING (( 1224/3293) dig-node-core::dependency_tree the_workspace_carries_exactly_one_module_wire_crate PASS). The "known-red" paragraph describes a pre-bump intermediate state that never shipped in this diff. Non-blocking (comment-only, doesn't affect behavior or the assertion's correctness) but worth a follow-up doc fix so a future reader doesn't chase a redness that isn't there.
  • Dependency cascade / Cargo.lock: confirmed exactly one dig-rpc-protocol (0.11.0) and one dig-peer (0.14.0) resolve workspace-wide.
  • Real entry point: handle_rpc -> handle_rpc_as -> RpcDispatch::dispatch confirmed as the actual delegation chain in lib.rs (not a helper beside it); the trait doc states dig-node-service/dig-runtime/the peer-RPC server all go through the free-function entry points, which thinly delegate to the same trait method the new arm lives in.
  • Out-of-scope reward methods: ListRewardDistributors, GetRewardDistributor, ListRewardDistributorCommitments have no arm anywhere in dispatch.rs — they fall through to the _ => -32601 method not found default. No stub, todo!(), or empty-list arm to flag.
  • dig-rewards-coin SPEC.md 0.1.3 §2.3/§2.4 cross-checked against the wire types and doc comments above; consistent on the closed nine-state set, the no-health-boolean rule, and the distributor-not-operator attribution of the payout figures.

Findings

No blocking findings. One non-blocking note (item 5, logging suggestion) and one non-blocking doc-accuracy note (dependency_tree.rs's stale "known-red" comment) — both left as PR-body prose per this review's own read, not inline threads, since neither is a defect in the shipped behavior or test correctness and there is nothing to drive to zero.

🤖 Generated with Claude Code

…hree fields

Security (blocking) and the adversarial leg both found the same defect in the
zero-launcher_id filter: it checked only launcher_id, so a registration bug
that zeroed store_id or root beside a valid launcher_id would pass through as
a plausible record, and dropping the bad record silently destroyed the
evidence a registration bug happened at all -- SPEC Sec2.4 clause 1's exact
prohibition.

zeroed_identity_fields() now checks launcher_id, store_id AND root. The
dispatch filter still excludes a record with any zeroed field (never renders
an uninitialised slot as a real distributor), but first fires a
tracing::warn! naming which field(s) were zero, so a bad registration is
observable rather than swallowed. Kept isolated in dispatch.rs rather than
woven into the wire mapping, since this belongs at #3265's writer once that
lands.

Replaced get_reward_prover_status_omits_an_all_zero_launcher_id (which
proved the omission but not the observability, and never exercised a zeroed
store_id/root beside a valid launcher_id) with
get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field, covering
both a zeroed launcher_id and a zeroed store_id beside a valid launcher_id,
and asserting the tracing::warn! output via the crate's existing
capture_sync_logs test utility.

Fixed a now-false "Known-red" doc comment on
tests/dependency_tree.rs::the_workspace_carries_exactly_one_module_wire_crate:
the dig-peer 0.14.0 / dig-download 0.23.0 / dig-peer-selector 0.12.0 cascade
already landed in this PR's Cargo.toml/Cargo.lock, so the assertion is green,
not red. Assertion itself untouched -- still exact-version.

Refs #3269

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

Copy link
Copy Markdown
Contributor Author

Adversarial leg (loop-decider) — re-judgement of my MERGE-WITH-CONDITIONS (dig_ecosystem#3269)

Head audited: 3a9538bd982776c86ba4291165471dc7bf2529b5. Read: the 6be80c97..3a9538bd diff (dispatch.rs, lib.rs test, tests/dependency_tree.rs), the new handler body at dispatch.rs:767-806, capture_sync_logs at lib.rs:7725.

Verdict: MERGE-WITH-CONDITIONS (one mechanical condition, then MERGE)

Condition A — satisfied in substance, and I accept the override. My objection was that the filter turned a hypothetical registration defect into silence (§2.4 cl.1) and edited the §2.6 record set where nobody could see it. At 3a9538bd the drop is preceded by a tracing::warn! that names the zeroed field(s) and all three hex ids, and the test asserts one warning per bad record plus selectivity over a valid record. The silence is gone. The store_id/root gap security named was real and my remedy (delete) would have left it open once #3265 registers a writer; the L1's pick dominates mine. I do not hold that a logging handler-side guard is wrong per se — a guard here is a detector of a writer bug, and with #3274 now a hard blocker on #3265 the writer-side guard is committed, so this one is a documented, relocatable stopgap rather than the design. No two-leg disagreement to carry upward.

Did non-silence introduce a worse problem? Log volume: the warn fires on every dig.getRewardProverStatus call for every zeroed record, so a persistently bad registration under a polling UI produces one warn per poll for as long as the bug lives. I judge this acceptable and not worse than silence: it is proportional to the defect, warn-level is the operator channel journald/stdout already rate-limit, and dedup state (warn-once-per-record) would be machinery for a writer that does not exist. Surfacing it in the response is the wrong channel — the wire struct is fixed at dig-rpc-protocol 0.11.0 and a UI cannot act on it. Revisit only if #3265's writer can produce a zeroed record on a non-bug path (it must not; that is #3274's job).

Condition B — met (#3265 comment linking #3274 as hard blocker).

The one remaining condition (before merge; haiku tweak)

crates/dig-node-core/src/seams/dig_rpc/dispatch.rs:156 — the new zeroed_identity_fields doc comment says the zero id reads as valid to "every consumer including shipped dig-app 15.5.0". There is no dig-app 15.5.0 (latest tag v15.4.0; dig-app#403 is open). This is the exact false urgency premise the L1 already retracted, now written into source in the same commit that documents the guard. Replace with "…including the dig-app consumer in dig-app#403 (unmerged)". Check: grep -n '15\.5' dispatch.rs returns nothing. Then MERGE on green CI (Test + coverage, Clippy, Analyze (rust) were still pending at the time of this read).

Non-blocking, carried forward

A malformed or 0x-prefixed launcher_id param at dispatch.rs:769-772 can never match and returns {"statuses": []}, which a caller reads as "not distributing" rather than "you sent garbage". Does not block: the only consumer is unmerged, the surface is loopback control, and the fix (validate 64-hex, return -32602) is a wire-contract question to settle alongside #3265. File it as a follow-up ticket linked to #3265; do not fold it into this PR.

Unchanged from 6be80c97

Tier::Control, withdrawal_share_bps echoed not recomputed, base units, dependency_tree.rs exact-one/0.11 (the Known-red comment correction is accurate as of the cascade landing). rewards/** untouched.

dig-app's latest release is v15.4.0 -- there is no v15.5.0 tag -- and
dig-app#403 (the pane that would consume dig.getRewardProverStatus) is OPEN
and unmerged. Point the doc comment at the real, unmerged consumer instead
so a future reader doesn't take this as evidence a shipped consumer depends
on the guard, which would wrongly discourage relocating it to #3265's writer.

Refs #3269

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

Copy link
Copy Markdown
Contributor Author

Security gate: PASS

Head audited: 134864a9d2dd3b73e89efd13aac3b17308f005ad

Scope: 6be80c97..134864a9 — 3 files (crates/dig-node-core/src/lib.rs, tests-only; crates/dig-node-core/src/seams/dig_rpc/dispatch.rs; crates/dig-node-core/tests/dependency_tree.rs), read from git objects. Re-audit of the prior round's single finding plus a disturbance check on the areas previously cleared.

1. Is the prior finding closed? — Yes

dispatch.rs:162-186 zeroed_identity_fields() checks all three identity fields (launcher_id, store_id, root) against [0u8; 32] and returns the names of those that are zero. The dispatch arm at dispatch.rs:786-798 calls it and emits tracing::warn! naming zeroed_fields plus all three hex ids before returning zeroed.is_empty() as the filter predicate. The drop is no longer silent and no longer launcher_id-only. Verified from code, not from the PR description.

2. Did the fix introduce a worse problem? — No; flag-then-exclude is accepted here

Polarity is not inverted. .filter(|s| ... zeroed.is_empty()) keeps a record when no field is zero. That is the intended sense, and the new test pins it from the outside: the fully-valid 0xaa… record is returned while both bad records are not. The "a condition's fix inverts the defect it closed" shape does not apply.

Who loses what. The excluded record is by construction one whose identity is unassigned — a caller (dig-app#403, unmerged) has nothing it can key on, render or attribute, so it loses no actionable information. The party who needs the evidence is the operator, and the operator now gets it at warn level with all three hex ids, which is enough to locate the bad registration. dig-rpc-protocol 0.11.0's GetRewardProverStatusResult (types.rs:1573-1576) carries only statuses: Vec<RewardProverStatus> — no total or count field — so exclusion cannot create a length-vs-declared-total mismatch, which is the honesty hole a silent filter usually opens. Flag-then-exclude is the right call at this boundary; the SPEC §2.4 clause-1 concern (the remedy destroying the evidence) is answered.

3. Is the log safe? — Yes; one defence-in-depth item

  • Injection: clear. Every interpolated value is hex::encode(&[u8; 32]), i.e. [0-9a-f]{64} — no newline, no control byte, no field separator can be smuggled in. zeroed_fields is a Vec<&'static str> built from three source literals. The message is a static literal. There is no attacker-controlled path into the log line's structure.
  • Sensitivity: clear. launcher_id, store_id and root are public on-chain identifiers, and Tier::Control is loopback / first-party and not peer-reachable, so this is strictly narrower exposure than the wire response already carries for a valid record.
  • Volume — defence-in-depth, NOT gating. The warn fires once per bad record per request, with no dedupe and no rate limit. A caller looping dig.getRewardProverStatus against a registry holding N zeroed records writes N lines per call to the operator's disk. It requires local Control-tier access and is unreachable today (below), so it is not a live vulnerability. Recommend a ticket alongside #3265: dedupe per record identity, or fire once per registration at the writer rather than once per read.

The severity limiter for all of the above: git grep register_reward_prover_status at this SHA returns one definition (lib.rs:592, pub(crate)) and zero non-test callers — every call site is inside mod tests. reward_prover_status_snapshots (lib.rs:602) is likewise pub(crate). In production at this SHA the registry cannot be populated at all, so the warn cannot fire, the flood cannot be triggered, and no figure can be rendered. The guard is a forward-looking detector for #3265's writer, which is where its own doc comment says it belongs.

4. Does the new test prove what it claims? — Yes, on the strength of two of its three log assertions

capture_sync_logs (lib.rs:7678-7691) installs a real tracing_subscriber::fmt at Level::TRACE with with_ansi(false), so the assertions read the formatted event text including field values — a genuine capture, not a stub.

  • Would fail if the warn were removed: yes. All three log assertions read logs, which would contain no such line.
  • Would fail if the guard reverted to launcher_id-only: yes — case 2 (valid 0xcc… launcher, zeroed store_id) would survive the filter, making statuses.len() == 2 and failing the first assertion. This is a real regression test for the exact gap named, asserted above the decision (at the RPC response), not below it.
  • Fire-count is real, not incidental: logs.matches("all-zero identity field").count() >= 2 over a fixture with exactly 2 bad and 1 good record. It cannot pass unless both warns fired.
  • One weak conjunct (non-gating). assert!(logs.contains("all-zero identity field") && logs.contains("launcher_id"), …) — the contains("launcher_id") half is satisfied by the field key launcher_id=…, which is emitted on every warn regardless of which field was zero. It therefore does not prove that case 1's zeroed launcher_id was named as zeroed; this is the contains("HOME") passes on "XDG_DATA_HOME" shape. The proof is not lost — assertion 2 pins case 2 by its distinctive cccc… hex and assertion 3 pins the count — but the intended assertion is logs.contains("zeroed_fields=[\"launcher_id\"]") or equivalent. Worth tightening; not a gate.

5. Untouched invariants — all confirmed

The delta touches only the three files above, so peer.rs (both #3261 guards, the node-local wrapper at :1353, the -32601 pre-dispatch gate at :1519), is_peer_reachable_method, rewards/** and module_transport.rs are byte-identical to 6be80c97. Filtering the dependency_tree.rs diff to non-comment lines yields nothing — the change is comment-only, and the_workspace_carries_exactly_one_module_wire_crate still asserts versions.len() == 1 and versions[0].starts_with("0.11."), never widened to accept a set. The comment's new "cascade closed" claim is not a born-false doc claim: the test executed and passed in this run, which is the claim's own evidence.

The previously-cleared areas (peer reachability, §2.4 key-set honesty, registry lifecycle, subject attribution) were checked for disturbance rather than re-derived, per scope.

Thesis A — a number correctly typed and still false about whose money it is

get_reward_prover_status_attributes_payout_figures_to_their_own_distributor (lib.rs:9348-9400) is a genuine test of this class at this boundary: two distributors with deliberately asymmetric figures, each located by its own launcher_id, asserting each figure equals its own owner's value, plus explicit assert_ne! against the sum (999_007) and against a swap. It closes the mapping half — reward_prover_status_to_wire cannot sum, fold or cross-attribute without going red.

It does not close the producer half, and cannot from here. rewards/state.rs:43 declares pub total_paid_out_base_units: u64 with no doc comment defining whose payouts it counts — precisely the ambiguity that let the sibling repo render a funder-scoped total as one operator's earnings at up to 250x. Nothing at this SHA can populate it (zero non-test writers), so there is no live misstatement. The subject has to be established at #3265's writer, and that is where a gate must ask "whose money is this field about". Recommend it as an explicit acceptance item on #3265 rather than leaving it to the next reader of an undocumented u64.

Thesis B — EligiblePayoutHash

Checked: no. git grep -E "EligiblePayout|payout_hash" at this SHA over crates/ returns two hits, both unrelated test names (rewards/admission.rs:225, rewards/writes.rs:500); the type EligiblePayoutHash appears nowhere. git grep -i eligible over rewards/{state,mod,port}.rs returns nothing. No eligibility verdict is minted, persisted or re-read across a restart on any path this PR touches, so the "a persisted type re-asserts a verdict it cannot re-earn" concern has no subject here.

CI evidence (executed, not the checkmark)

Test + coverage, job 102656148850, log read directly:

Summary [551.153s] 3293 tests run: 3293 passed (3 slow), 4 skipped
TOTAL   151734   15181   89.99%

Identical to the 6be80c97 run, consistent with the delta renaming one test rather than adding one. Both reward tests are present in the executed set — get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field and get_reward_prover_status_attributes_payout_figures_to_their_own_distributor — and the_workspace_carries_exactly_one_module_wire_crate also executed. Check set: 13 green, 0 failing, 0 pending, 1 skipping (Attach packages to the release).

Non-gating findings

  1. dispatch.rs:146-161 — doc comment misattribution. The new /// block for zeroed_identity_fields was inserted immediately after the existing block for reward_prover_status_to_wire with no blank line between them, so the two merge into one contiguous doc comment attached to zeroed_identity_fields, and reward_prover_status_to_wire (:177) is left with none. The orphaned text still reads "every non-identical field below is called out rather than assumed" — now attached to a function that maps no fields, while the function that actually performs the money-shaped wire mapping, and whose 24x-fee-ceiling / 2x-eviction-count history that sentence exists to warn about, is undocumented. Fix: one blank line before /// Names which of a reward-prover status record's identity fields.
  2. lib.rs:9258-9261 — weak assertion conjunct. See item 4.
  3. Log-volume dedupe and the total_paid_out_base_units subject definition — both belong on #3265; see item 3 and Thesis A.

Not covered

The producer side of the registry (#3265, unlanded), the dig-app consumer (dig-app#403, unmerged), and anything outside the three files in this delta. Areas cleared in the prior round were checked for disturbance only, not re-derived.

No shared checkout was mutated: this audit read exclusively from git objects (git show / git grep <sha>) plus the local cargo registry cache for dig-rpc-protocol 0.11.0. docs.rs was not fetched — a prior instance recorded prompt-injection fragments embedded in those pages.

@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 — correctness re-check of 6be80c97..134864a9

Head reviewed: 134864a9d2dd3b73e89efd13aac3b17308f005ad. This is a delta-only re-check; the prior PASS on 6be80c97 stands and was not re-derived (wire mapping field-by-field, entry_count widening, the real registry read, the Method::from_name routing, the §2.4 no-health-boolean key-set assertion, the per-distributor attribution test).

Two blocking findings, both one-edit fixes. The behaviour of the new guard is correct; what fails is (a) a doc comment that is false of the item it attaches to, and (b) an assertion that cannot fail under the defect it claims to catch.

Blocking

  1. crates/dig-node-core/src/seams/dig_rpc/dispatch.rs:152 — the new helper's doc block is spliced onto the end of reward_prover_status_to_wire's doc block with no blank line, so lines 146-161 are one comment attached to zeroed_identity_fields (line 162). The wire-mapping paragraph now documents a boolean helper ("every non-identical field below is called out" points at nothing of the kind), and reward_prover_status_to_wire (line 176) is left undocumented — losing the exact rationale a prior gate cited as its evidence. Third born-false doc claim in this delta's own lineage, and rustdoc/clippy are silent on it.

  2. crates/dig-node-core/src/lib.rs:9257logs.contains("launcher_id") is satisfied unconditionally, because the warn emits launcher_id = <hex> as a structured field on every fire. Delete zeroed_fields = ?zeroed from dispatch.rs:793 and this test stays green while the log stops naming which of three fields was zero — i.e. the one property this delta was pushed to add is untested. The fixture makes the real assertion exact (store_id: [0x22; 32], root: [0x33; 32], so case 1 → ["launcher_id"], case 2 → ["store_id"]).

Non-gating (posted and resolved by me)

  1. dispatch.rs:170root == 0 is treated as proof of a bad registration, but root is an observed value, not an identity: a registered-but-not-yet-cycled prover plausibly has no root, and #3265's writer will most likely express that as a zeroed array. Unreachable today (nothing writes the registry outside tests); recorded as a constraint on #3265, where this guard is meant to move.
  2. dispatch.rs:789 — the warn is inside the filter, so a persistent bad registration re-logs on every poll of a UI-polled endpoint rather than alarming once. §2.4's evidence requirement is met either way.

Answers to the five questions asked

1. Does the new test prove what it claims? Partly. It does prove the widened guard: with a launcher_id-only filter the zeroed-store_id record is returned and statuses.len() == 1 fails — the gap nothing previously covered is now genuinely closed. It does prove the drop is not silent: logs.contains("all-zero identity field") is a distinctive phrase from the warn message with no superstring hazard (the doc comments are not in the log stream), so removing the tracing::warn! reddens it; capture_sync_logs (lib.rs:7725) installs a real TRACE-level subscriber over the awaited future, so the capture is genuine, not a stub. The fire-count assertion is real — the closure runs once per record, two bad records, count() >= 2 (== 2 would be tighter and would also catch a double-log; not gating). What it does not prove is the field-naming, per finding 2.

2. Is the guard correct and selective? Yes. All three identity fields are checked (dispatch.rs:164/167/170); the predicate is zeroed.is_empty(), so a record with all-valid ids is unaffected and passes through to the existing filter_launcher_id arm unchanged; no record with any zeroed field reaches reward_prover_status_to_wire. The test's fully-valid 0xaa… record proves selectivity rather than an empty registry. No stub, no todo!(), no unimplemented!() anywhere in the delta; the other three reward methods still have no arm and still fall through to -32601.

3. Did the delta disturb anything the prior pass covered? No. git diff --name-only 6be80c97..134864a9 is exactly three files: lib.rs, dispatch.rs, tests/dependency_tree.rs. is_peer_reachable_method does not appear anywhere in the delta (zero matches) and is unwidened. The two #3261 guards and the §2.4 key-set assertion are untouched — the lib.rs delta is confined to the single replaced test. rewards/** and module_transport.rs are untouched. In dependency_tree.rs only the doc comment changed; the asserts in the_workspace_carries_exactly_one_module_wire_crate are byte-identical, still exactly-one/0.11, never widened to a set.

4. Are both corrected comments true as merged?

  • dependency_tree.rs "Cascade closed (#3269)": true. Cargo.lock at this SHA contains exactly one dig-rpc-protocol entry, 0.11.0, and the three versions the comment names are the ones locked — dig-peer 0.14.0, dig-download 0.23.0, dig-peer-selector 0.12.0. The test is green in this run, so the assertion is no longer known-red, as the old text claimed.
  • dispatch.rs dig-app reference: true. dig-app#403 is OPEN, mergedAt: null; dig-app's latest release is v15.4.0 and no v15.5.0 exists. Sweep of crates/ + tests/ for 15\.5|shipped dig-app at this SHA returns one hit only — crates/dig-wallet/src/ui.html:187, a CSS font-size:15.5px, unrelated and outside this diff. No other occurrence survives.
  • Caveat: the placement of the replacement text is finding 1 — the sentence is true of the wire mapping but is now attached to the helper.

5. Executed evidence at this SHA (CI run 34408143846, job Test + coverage 102656148850, log read — not the checkmark):

  • Summary [551.153s] 3293 tests run: 3293 passed (3 slow), 4 skipped
  • TOTAL … 89.99% lines (gate ≥80%), 86.94% branch/region, 90.64% functions
  • Seven reward tests present and PASS, including the replacement: PASS [0.021s] (1085/3293) dig-node-core tests::get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field and PASS [0.007s] (1084/3293) …_is_served_via_the_method_enum_not_the_string_prematch, alongside …_answers_a_real_request_with_real_values, …_attributes_payout_figures_to_their_own_distributor, …_filters_by_launcher_id_when_given, …_is_not_peer_reachable, …_with_no_registered_handle_is_explicit_empty. Test count is unchanged at 3293, consistent with a 1:1 replacement rather than a net addition.

What I did not run

No local build, test or coverage run — all executed evidence above is from the CI job log at this SHA. I did not re-verify the base PR (6be80c97 and earlier), the dependency bumps (separately gated), rewards/**, module_transport.rs, or dig-app#403's consumer code. I did not read docs.rs for dig-rpc-protocol 0.11.0; version facts come from Cargo.lock at this SHA.

Comment thread crates/dig-node-core/src/seams/dig_rpc/dispatch.rs Outdated
Comment thread crates/dig-node-core/src/lib.rs Outdated
Comment thread crates/dig-node-core/src/seams/dig_rpc/dispatch.rs
Comment thread crates/dig-node-core/src/seams/dig_rpc/dispatch.rs
MichaelTaylor3d and others added 2 commits September 9, 2026 18:24
…at root as an observation

Three findings from the correctness gate on PR#595 at 134864a.

1. The zeroed-identity helper's doc block was spliced onto the end of
   reward_prover_status_to_wire's block with no separator, so the wire-mapping
   rationale documented a boolean predicate and the mapping function was left
   with no doc at all. Each doc block now sits above the item it describes.

2. The log assertion `logs.contains("launcher_id")` was a tautology: the warn
   emits launcher_id as a structured field on every fire, so the property the
   guard exists to add -- naming which field was zeroed -- was unasserted.
   Deleting `zeroed_fields = ?zeroed` from the warn left every assertion green.
   The test now asserts the zeroed_fields value itself, which the fixture makes
   exact and disjoint across cases.

3. `root` is an observation, not an identity. A registered prover that has not
   completed its first cycle plausibly has no root, and a writer that zero-inits
   it would have made a healthy prover invisible. A zeroed launcher_id or
   store_id still excludes the record; a zeroed root alone warns and returns.

Refs #3269

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

The previous commit (080be3d) landed with `zeroed_fields = ?zeroed` missing
from the tracing::warn! call in the GetRewardProverStatus filter -- a
one-line regression introduced while proving the new log assertion goes red
without it, never restored before the commit was made. Without this field
the log line never names WHICH field was zero, so an operator sees only
that something was excluded, and the test asserting `zeroed_fields=[...]`
per case would fail. Restored; all 7 reward-prover-status tests green.

Refs #3269

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

Copy link
Copy Markdown
Contributor Author

loop-security RE-VERDICT: CHANGES-REQUIRED

Head audited: 080be3df10ca3e5b73a16c68099243e21fb70014
Scope: 134864a9..080be3df (2 files, +109/-52): crates/dig-node-core/src/lib.rs, crates/dig-node-core/src/seams/dig_rpc/dispatch.rs. Read-only, read from git objects; no checkout mutated.

Supersedes the prior PASS at 134864a9.

Finding 1 (LIVE for the property the prior PASS was conditioned on) — the warn no longer names WHICH field was zero

crates/dig-node-core/src/seams/dig_rpc/dispatch.rs:812-818

The delta removed the zeroed_fields = ?zeroed structured field from the tracing::warn! while keeping the detection. Exact greps on the head blob: zeroed_fields = ?zeroed occurs 0 times in dispatch.rs; the 4 surviving zeroed_fields occurrences are the fn definition (:163), two doc references (:177, :792) and the local binding (:809). No tracing field, in any macro, carries it.

The message literal was simultaneously changed from "has an all-zero identity field; excluding it" to "has an all-zero field; excluding it if the zeroed field is an identity field". So the emitted line is now byte-identical for all three cases and states a conditional it does not resolve. An operator reading it cannot tell:

  • a healthy, never-cycled prover (zeroed root only — returned), from
  • a registration bug that zeroed store_id beside a real launcher_id (excluded), from
  • a fully-unassigned slot (excluded).

To distinguish them the operator must hand-decode three 64-hex fields per line and compare against 64 zeros. That is precisely the §2.4-clause-1 property the first version of this filter violated and that the prior PASS was granted because the warn resolved: "a tracing::warn! fires naming which field(s) were zero". After the narrowing, exclusion is no longer silent but it is now unattributed, and the narrowing is what makes that fatal — before, every warned record was dropped, so one bit sufficed; now the same line means two opposite outcomes.

The delta's own doc block still promises the removed behaviour: dispatch.rs:155-157 ("the same detection drives both the exclusion decision ... and the tracing::warn!"), and lib.rs:9192-9195 ("a tracing::warn! fires naming the SPECIFIC zeroed field(s)"). Both are false at this SHA.

Fix: restore zeroed_fields = ?zeroed to the warn, and make the message state the outcome that actually occurred (two branches, or an excluded = %is_missing_identity(&zeroed) field). One line.

Finding 2 (LIVE, blocks CI) — the new test asserts on a string the log can no longer contain

crates/dig-node-core/src/lib.rs:9287, :9291, :9295 assert, respectively, that the captured logs contain the literals zeroed_fields=["launcher_id"], zeroed_fields=["store_id"] and zeroed_fields=["root"].

tracing's fmt layer emits only the fields passed to the macro. zeroed_fields is not one of them (Finding 1), so all three assertions are unconditionally false and get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field cannot pass at this SHA. The replacement of the tautological logs.contains("launcher_id") was the right correction; it landed against a log line that no longer emits the value.

Note the fourth assertion (logs.matches("all-zero field").count() == 3) does match the new message, so the test fails on the field assertions only — the failure names a missing log field, which is the honest signal.

CI evidence — no executed count exists at this head

Per the brief I read the job, not the checkmark. At 080be3df the CI run (34425335592) was cancelled: Rustfmt success, Clippy success, Test + coverage cancelled, Release-script tests success. There is therefore no reward-test pass count and no coverage figure at this SHA; the expected 3294 is unverified. The only counts on record (3293 passed, 89.99%) belong to 134864a9, which does not contain these assertions. A re-run is pending (34425540451, Analyze jobs only so far). I predict Test + coverage fails on the three assertions above.


Answers to the five questions

1. Does narrowing the exclusion set reopen the original finding? Partly — and the wire already carries the honest marker.

A returned record with real launcher_id, real store_id and all-zero root is not a money-honesty hole on its own, and I can ground that in the wire rather than in preference. dig-rpc-protocol 0.11.0 types::RewardProverStatus (registry source, src/types.rs:1529-1551) declares root: HexId (non-optional) but also last_cycle_started_at: Option<u64>, last_cycle_completed_at: Option<u64>, last_entry_write_at: Option<u64>. A prover that has never cycled has last_cycle_completed_at: null, and reward_prover_status_to_wire copies all three through unchanged in this delta. So the consumer can distinguish "no root observed yet" from "a real root that happens to be zero-valued" — root == 64 zeros && last_cycle_completed_at == null is the honest reading, and a real store root being exactly 32 zero bytes is not reachable.

The concrete consequence of the narrowing is therefore bounded: a consumer that renders root without consulting last_cycle_completed_at shows 64 zeros as a generation root. That is a display defect in the consumer (dig-app#403, unmerged), not an unattributable money record — the record names its distributor, and nothing about it is spent against. Your ordered narrowing is the right call: excluding the record would have made a healthy prover invisible, which is the strictly worse failure and is not recoverable by any consumer-side logic, whereas this one is.

The residual risk is a correlation risk and belongs on #3265, not here: if that writer zero-inits root and stamps last_cycle_completed_at: Some(...), the distinguishing signal collapses and the 64-zeros-as-a-root shape becomes genuinely unattributable. Defence-in-depth — file a ticket against #3265's writer: a zeroed root must imply last_cycle_completed_at == None. Do not gate this PR on it.

2. Is warn-only sufficient observability for the returned case? Yes for the wire, no for the log — and the wire is not the limitation you assumed.

The wire is not unable to express it. root itself cannot be marked absent (HexId, non-optional, fixed at 0.11.0 — so no per-field nullability without a protocol bump), but the correlated absence signal already exists and is already emitted (see answer 1). §2.4 needs no new vocabulary and no wire bump. "No wire can feed it" is not the answer here; the honest marker shipped in 0.11.0.

The insufficiency is in the log, and it is Finding 1: warn-only would be sufficient observability if the warn resolved which field and which outcome. It does not, at this SHA.

3. Log safety re-checked.

Injection-free: still holds, and is strictly stronger than before. Every interpolated value is hex::encode of a [u8; 32], whose codomain is [0-9a-f]{64} — no newline, no =, no quote, no control byte, so no forged field and no forged line. The message is a &'static str literal. Removing zeroed_fields = ?zeroed removed the only non-hex-valued field; that field was itself safe (a Vec<&'static str> of three literals), so the property was already sound and restoring it per Finding 1 does not weaken it.

Placement: the warn is still inside the .filter(|s| { ... }) closure (dispatch.rs:808-820) — i.e. once per registry record per dispatch, ahead of the filter_launcher_id narrowing, so a caller filtering to one launcher still pays the full-registry log cost.

Volume: the returned-but-warned case makes it materially worse. Before, a warned record was always a bug and always dropped, so steady-state warn volume was zero. Now a zeroed root is an expected, legitimate state for every registered-but-not-yet-cycled prover, and it warns on every poll. On a polled endpoint that is (uncycled provers) x (poll rate) warn lines, each ~220 bytes of message plus three 64-char hex fields — and each one indistinguishable from a genuine identity bug (Finding 1). The genuine signal gets buried in expected noise: classic alarm fatigue, plus a modest log-amplification / disk-cost asymmetry driven by a caller-controlled poll rate at Tier::Control.

Not gating, because of answer 5, but it is the reason Finding 1 must be fixed rather than deferred: the moment #3265 lands, the noise arrives with it. Recommend (defence-in-depth, ticketable): warn on identity-missing unconditionally; for a root-only zero either drop to debug! or rate-limit/dedupe per launcher, so the returned-and-benign case does not compete with the excluded-and-broken case.

4. Untouched invariants — all confirmed.

git diff --name-only 134864a9..080be3df returns exactly the two named files, so dependency_tree.rs, rewards/** and module_transport.rs are untouched in this delta by construction. Within the two files: is_peer_reachable_method appears 0 times in the dispatch.rs diff — unwidened. The lib.rs hunks are confined to the one test's doc block and body (:9190-9305); both #3261 guards and the §2.4 key-set assertion are outside every hunk and unmodified, and remain non-vacuous. The dispatch.rs hunks are the doc reorganisation, the zeroed_identity_fields to zeroed_fields rename, the new is_missing_identity, and the filter closure. Nothing else.

Doc reorganisation confirmed correct: the wire-mapping doc block now sits immediately above fn reward_prover_status_to_wire (:181-186) instead of above zeroed_fields, which was the prior non-gating note.

5. Severity limiter — still holds.

git grep register_reward_prover_status 080be3df -- crates returns the pub(crate) definition at lib.rs:592, one doc reference at :576, and nine call sites, all inside mod tests (:9100, :9227, :9236, :9246, :9253, :9347, :9350, :9401, :9402). Zero non-test callers. The registry cannot be populated in production at this SHA, so no finding here is exploitable by a live caller today. Both findings are gated on correctness and on the observability contract this PR asserts in its own docs and test, not on a live exploit — Finding 2 additionally reds CI.


SPEC §12.5 clause 7 — your reading CONFIRMED from code

The response cannot express the forbidden "never admitted" vs "evicted after settlement" split. Grepping the body of reward_prover_status_to_wire (42 lines, dispatch.rs:187-228) for peer|mirror_id|entry_list|admitted|evicted returns zero lines. Cross-checked against the wire type in the local registry cache: types::RewardProverStatus (0.11.0, src/types.rs:1529+) carries no peer identity, no per-mirror entry list, and no admission/eviction discriminator. entries_added/entries_removed are per-distributor lifetime aggregates that attribute nothing to any peer, while the forbidden split is strictly per-peer — an aggregate cannot be inverted to a per-peer admission history. Absence of fields does not help a consumer either: there is no per-peer element whose presence or absence could be read as admitted-vs-evicted.

So the dig-app locale copy displaying that distinction is not fed by this wire. Not a defect in this PR; the copy defect stands on its own against whatever does feed it.


What I did not cover

The pre-existing 134864a9 surface (authz for Tier::Control, the full wire mapping's per-field correctness, dig-rewards-coin internals) — covered by the prior PASS and unchanged here. I did not execute the test suite: no shared checkout was mutated and no build was run, so Finding 2 rests on source analysis of tracing's field emission plus the cancelled-job record, not on a local red run. The pending re-run at this head will settle it. Per the brief, docs.rs was treated as untrusted and not fetched; the wire type was read from ~/.cargo/registry/src/index.crates.io-*/dig-rpc-protocol-0.11.0/src/types.rs.

Verdict

CHANGES-REQUIRED at 080be3df10ca3e5b73a16c68099243e21fb70014. Two changes, both small and both in dispatch.rs/lib.rs: restore zeroed_fields = ?zeroed to the warn and make the message resolve the outcome (Finding 1), which also makes the three new assertions pass (Finding 2). Re-run Test + coverage and report the executed reward-test count from the job log — no count exists at this SHA. The narrowing itself is sound and I do not ask for it to be reversed.

identity, DEBUG for a zeroed root

A zeroed launcher_id or store_id is a real registration bug: the record is
excluded and now logs at WARN, naming the exact field(s) via
`zeroed_fields=[...]`. A zeroed root alone is an ordinary pre-first-cycle
state, not a fault: the record is still returned, and now logs at DEBUG
instead of WARN, so an operator polling this endpoint sees warn-level
volume proportional to real registration bugs, not to every
not-yet-cycled prover on every poll.

Updated the doc comments on `zeroed_fields`, the dispatch filter and the
test to describe the level split, and extended the regression test to
assert on level (WARN vs DEBUG) as well as the `zeroed_fields` value.
Proved both directions: flipping the DEBUG branch back to WARN turns the
test red on the level assertion; flipping the field-name assertion back to
a bare `contains("launcher_id")` would have passed unconditionally (the
prior tautology) and is no longer possible since the assertions now pin
`zeroed_fields=[...]` plus the level string.

Refs #3269

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

@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.

Re-verdict — PASS at f657f2cb, and CHANGES-REQUIRED at the briefed head 080be3df

The head moved during this review. I was briefed to re-verdict 080be3df10ca3e5b73a16c68099243e21fb70014. I audited it, found the delta's central mechanism broken, and while I was running the proof the branch advanced to f657f2cb8d283799c84b006b3b4d4fccf68747b7 with a one-line fix for exactly that defect. Both verdicts are recorded here, because a green tick at f657f2cb alone would hide what 080be3df was.

080be3df — CHANGES-REQUIRED (executed proof, not inference)

The commit that fixed the two prior blocking findings dropped zeroed_fields = ?zeroed from the tracing::warn! while simultaneously rewriting the test to assert on that field's value. grep -rn zeroed_fields crates/ at 080be3df returns the three test assertions and no emitter. The result is the fifth born-false claim in this epic, in the commit that fixed the fourth: dispatch.rs:799 read "a tracing::warn! fires naming which field(s) were zero" over a warn that named nothing.

That is not a doc nit — the test was RED. Executed locally at 080be3df (cargo test -p dig-node-core --lib get_reward_prover_status):

test result: FAILED. 6 passed; 1 failed
panicked at crates/dig-node-core/src/lib.rs:9286:9:
expected a warning naming exactly launcher_id as zeroed, got: ... launcher_id=0000... store_id=2222... root=3333...

CI never showed this: the Test + coverage run for 080be3df (job 102709258821) was cancelled by the superseding push, so there was no green tick to be wrong — but there was also no evidence. This is the "a compile is not evidence" case in its sharpest form; the delta's one new property was unimplemented, and the only thing that would have caught it was the assertion the previous round demanded.

f657f2cb — PASS

git diff 080be3df f657f2cb is exactly + zeroed_fields = ?zeroed, at dispatch.rs:815. Re-run at that SHA: 7 passed, 0 failed.

Prior finding 1 (doc attached to the wrong item) — fixed, resolving the thread. dispatch.rs:146-162 now documents zeroed_fields (renamed from zeroed_identity_fields, correctly — it no longer names only identity fields); :177-180 documents the new is_missing_identity; and :186-191 carries the field-by-field wire-mapping rationale back above reward_prover_status_to_wire at :192, which is where it belongs and where it had no doc at all. Each block now sits above the item it describes.

Prior finding 2 (tautological log assertion) — fixed, resolving the thread. The assertions are now on the zeroed_fields=[...] value, and the fixture makes the three cases exact and disjoint (store_id: [0x22;32], root: [0x33;32]), so each case yields a single-element list. Verified by the nearest-wrong-implementation rather than by reading: deleting zeroed_fields = ?zeroed turns the test RED — that is not a thought experiment, it is literally what 080be3df was, with the panic quoted above. The count assertion also tightened from >= 2 to == 3, so a warn that fires twice for one record now fails too.

The third change (root as an observation, not an identity) — correct, and non-vacuous. All four parts are present: is_missing_identity covers launcher_id || store_id only (:181-183); the filter returns on !is_missing_identity(&zeroed) while still warning whenever !zeroed.is_empty() (:809-818); the rationale is stated at :802-807 and in both helper docs; and the fourth test case (launcher_id = [0xbb;32], store_id valid, root zeroed) asserts both that the record is returned and that zeroed_fields=["root"] appears. I probed the acceptance criterion by mutation in my own worktree — adding || zeroed.contains(&"root") to is_missing_identity:

assertion `left == right` failed: the fully-valid entry AND the zeroed-root-only entry are both returned
  left: 1
 right: 2

The security gate's want survives the split: a zeroed root still fires the warn and still names root in zeroed_fields, so all three fields remain observable — the record is now returned and logged rather than dropped and logged, which strictly increases what an operator can see. Your judgement to promote this above non-gating was right: under the old predicate a #3265 writer that zero-inits root would have made every registered-but-not-yet-cycled prover invisible, which is a worse failure than the one the guard was built for.

SPEC §12.5 clause 7 — your reading CONFIRMED from code

Clause 7 forbids distinguishing "never admitted" from "evicted after settlement" from an absent slot, and forbids building or displaying a heuristic that guesses. This wire cannot express the distinction, and the reason is structural rather than incidental: the split clause 7 protects is per-peer, and this response has no peer axis at all. reward_prover_status_to_wire (dispatch.rs:192-231) emits only the §2.3 per-distributor fields, and a grep of the mapping body for peer|mirror_id|entry_list|admitted|evicted returns zero — there is no peer identity, no per-mirror entry list, and no per-peer entry state on the wire, so no consumer has anything to key a guess on. entries_added / entries_removed / mirrors_seen are this distributor's lifetime aggregates: they say how many entry writes and removals happened in total, attributing none of them to any peer, and even entries_added - entries_removed against entry_count yields aggregate churn, never "this peer was never admitted" versus "this peer was evicted". The dig-app copy the sibling gate found in 14 locales is inventing the distinction downstream, not rendering one this wire handed it. No defect to fix here, and nothing to file against this wire.

Scope, and what I checked one by one

The 134864a9..f657f2cb delta touches only crates/dig-node-core/src/{lib.rs, seams/dig_rpc/dispatch.rs}, which settles most of the scope questions mechanically. is_peer_reachable_method is unwidened — peer.rs's only change across the whole PR is an added test that asserts every Reward-named method is absent from the local allowlist, and it guards its own vacuity by asserting the scan found at least one such method first. Both #3261 guards in tests/reward_methods_tier_guard.rs are untouched and non-vacuous the same way (reward_methods_exist_and_are_found_by_the_prefix_scan asserts the prefix scan is non-empty before the other two iterate it). dependency_tree.rs is unchanged in this delta; its the_workspace_carries_exactly_one_module_wire_crate still asserts versions.len() == 1 and versions[0].starts_with("0.11."), deliberately not widened to a set. rewards/** and module_transport.rs are untouched across the entire PR. No stub was added for the other three reward methods — dispatch.rs has exactly one Some(Method::GetRewardProverStatus) arm, so the rest still fall through to the catalogued -32601, which is what reward_methods_tier_guard and the peer-allowlist test pin from the other side.

Every comment in the delta is true as merged at f657f2cb. I read each one against the item it lands on: the dispatch.rs:799 claim that the warn names the zeroed fields is true again once zeroed_fields is emitted, and the zeroed_fields doc's promise that an operator benefits from seeing the field named is likewise satisfied.

Executed evidence at f657f2cb

From the Test + coverage job log (job 102709962014), not the checkmark:

Summary [932.425s] 3293 tests run: 3293 passed (17 slow), 4 skipped
TOTAL  151762  15178  90.00%   (lines; gated at >= 80%)

All seven reward tests PASS in that run — get_reward_prover_status_{answers_a_real_request_with_real_values, attributes_payout_figures_to_their_own_distributor, filters_by_launcher_id_when_given, is_not_peer_reachable, is_served_via_the_method_enum_not_the_string_prematch, logs_and_excludes_a_zeroed_identity_field, with_no_registered_handle_is_explicit_empty} (1080-1086/3293), plus peer::tests::reward_methods_are_absent_from_the_node_peer_allowlist (234/3293), the three reward_methods_tier_guard tests (1287-1289/3293) and dependency_tree::the_workspace_carries_exactly_one_module_wire_crate (1224/3293).

The count is 3293, not the 3294 predicted — correctly so, and worth stating rather than letting it read as a stale log: the root case is a fourth case inside an existing #[test], not a new test function, so the total is unchanged from 134864a9. Coverage moved 89.99% to 90.00%.

One non-gating note, recorded here rather than as a thread

For the root-only case the warn's message text reads "excluding it ... if the zeroed field is an identity field", which is conditional and therefore true, but it makes an operator read the zeroed_fields value to learn whether this particular record was actually dropped. That is acceptable as merged — the structured field disambiguates it — and it is not worth another round.

Verdict

PASS at f657f2cb8d283799c84b006b3b4d4fccf68747b7. Both prior blocking threads are resolved. This PR is still a draft; marking it ready is the orchestrator's call, not mine.

I did not run the .msi / .deb / .pkg packaging jobs (pending at review time and unrelated to this delta), did not audit rewards/**, module_transport.rs, the dependency bumps or dig-app#403, and did not exercise the endpoint against a live #3265 writer, which does not exist yet.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Security gate (loop-security) — IN PROGRESS, not the verdict

Re-audit anchored at head 0ae5f30a74fdbc927a812c2512af6ece545998d4, not at the f657f2cb I was
briefed on. The head moved under the brief: 0ae5f30a ("split zeroed-field logging by level") is a
new commit that lands the warn!/debug! split. I am auditing the real head; the volume ruling I
was asked for is moot because the fix shipped.

Both prior findings are closed.

  1. zeroed_fields = ?zeroed is present in both emitters
    (crates/dig-node-core/src/seams/dig_rpc/dispatch.rs:814-828) — the warn! for a missing
    identity field and the debug! for a zeroed root each carry it. Read from the blob at
    0ae5f30a, not from a description.
  2. The assertions are satisfiable. capture_sync_logs
    (crates/dig-node-core/src/lib.rs:7725) installs the capture subscriber with
    .with_max_level(tracing::Level::TRACE), so the new debug! line reaches the buffer; and
    .with_ansi(false) means the rendered level token really is the bare string DEBUG /WARN
    the assertions match on.

Volume: the ordinary case is back to zero warn-level lines. Both emitters remain inside the
.filter(|s| …) closure on the reward_prover_status_snapshots() iterator, so the log fires
per-record per-request exactly as before — but a record whose only zeroed field is root now takes
the else if arm and emits at debug!. Steady-state warn! volume for the pre-first-cycle case is
zero. is_missing_identity gates both the level choice and the exclusion from one expression, so
the two can no longer disagree: the arm that warns is textually the arm that excludes
(!is_missing_identity(&zeroed) is the closure's return).

One new finding, defence-in-depth (not a gate).

crates/dig-node-core/src/lib.rs:9298-9303 — the level assertion is not anchored to the line it
is about
:

logs.contains("DEBUG") && logs.contains(r#"zeroed_fields=["root"]"#)

logs is the whole captured buffer. The two conjuncts are independent: the first says some DEBUG
line exists anywhere in the buffer, the second says some line names root as the sole zeroed
field. Nothing ties them to the same line. Consider the mutation the doc says this test defends
against — flipping the root branch back to warn!:

  • logs.contains(r#"zeroed_fields=["root"]"#) — still true (the field is still emitted).
  • logs.matches("zeroed root").count() == 1 — still true; that message text is unchanged by a
    level flip, so it counts the same either way.
  • logs.matches("missing an identity field").count() == 2 — still true; the identity branch's
    message is untouched.
  • logs.contains("DEBUG") — the only level-sensitive conjunct, and it fails only if no other
    DEBUG line exists anywhere in the handle_rpc call under capture.

So the test goes red on that mutation only incidentally: dispatch.rs today contains exactly one
debug! (the new one) and no trace!, and the GetRewardProverStatus arm does little else. The
day anyone adds a debug! or trace! anywhere in the dispatch path — the tier check, the rate
limiter, the registry read — contains("DEBUG") becomes unconditionally true and the level half of
this test goes vacuous silently, while its doc block keeps claiming (entry 5) that it catches
"a fix that returns the zeroed-root record but logs it at the SAME level". At that point the level
split is undefended and the doc is the sixth born-false claim in this epic.

This is defence-in-depth, not a live vulnerability: the code at this SHA is correct, and
register_reward_prover_status still has zero non-test callers (pub(crate), ten references,
all inside mod tests — verified at this SHA), so the registry has no production writer and the
volume surface is zero until #3265 lands. Recommend a ticket, or a one-line strengthening: scan
logs.lines() and assert the single line containing zeroed_fields=["root"] starts with DEBUG
and no line containing it starts with WARN. I did not prove the mutation by execution — a
cold dig-node-core compile was outside this gate's budget — so treat "incidentally red today" as
a structural reading, not a measured one.

Doc truth at 0ae5f30a. Every touched comment checks out against the code beneath it, with the
one exception above:

  • dispatch.rs:152-160 — the level-split claim on zeroed_fields' doc matches both arms.
  • dispatch.rs:799-812 — now says the identity case warns and is excluded (the 080be3df
    version claimed the warn "names which field(s) were zero" over an emitter that named nothing;
    fixed at f657f2cb and still true here), and the root case is debug! with the record still
    returned. Both true.
  • lib.rs:9201-9221 — the failure-mode list's new entry 5 is the one claim that overstates, per
    the finding above; entries 1-4 and 6 are accurate.
  • lib.rs:9245 — "a DEBUG (never WARN) still fires naming root" is true of the code.

Returned zeroed-root record is not an honesty hole. The wire mapping
(dispatch.rs:196-230) passes prover_state, prover_state_since, last_cycle_started_at,
last_cycle_completed_at and observed_at through, so a consumer receiving root as 64 zeros has
independent fields telling it no cycle has completed. Returning the record does not force a
consumer to read an all-zero root as a real root.

Untouched invariants re-confirmed at this SHA. is_peer_reachable_method is unwidened — the
whole peer.rs delta against base d562aad8 is a test addition
(reward_methods_are_absent_from_the_node_peer_allowlist), no change to the function.
dependency_tree.rs is unchanged in the f657f2cb..0ae5f30a delta (which is exactly lib.rs +
dispatch.rs) and still carries versions.len() == 1 plus versions[0].starts_with("0.11."),
never widened to a set. rewards/** and module_transport.rs are untouched across the entire PR
(base..head touches 8 files: Cargo.lock, two Cargo.toml, lib.rs, peer.rs, dispatch.rs,
tests/dependency_tree.rs, tests/reward_methods_tier_guard.rs).

§12.5 clause 7 stays settled — the forbidden split is per-peer, the response has no peer axis.

Verdict follows once the Test + coverage job at 0ae5f30a reaches a terminal state. I will not
carry the 3293 passed count from f657f2cb forward; the level-split assertions are new here and
only an executed run at this SHA is evidence about them.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Security gate (loop-security) — PASS

Head SHA audited: 0ae5f30a74fdbc927a812c2512af6ece545998d4

Not the f657f2cb8d283799c84b006b3b4d4fccf68747b7 in my brief — the head moved while I was being
briefed, and 0ae5f30a is the commit that lands the warn!/debug! level split. I re-anchored and
audited the real head; every statement below is read from blobs at 0ae5f30a. Detail is in my
in-progress comment above; this is the verdict.

Both prior findings closed. zeroed_fields = ?zeroed is present in both emitters
(crates/dig-node-core/src/seams/dig_rpc/dispatch.rs:814-828), and the assertions are satisfiable
because capture_sync_logs (crates/dig-node-core/src/lib.rs:7725) captures at
Level::TRACE with .with_ansi(false).

The volume ruling I was asked for is moot — the fix shipped. A record whose only zeroed field is
root now takes the else if arm at debug!, so steady-state warn! volume for the ordinary
pre-first-cycle case is zero rather than (uncycled provers) x (poll rate). is_missing_identity
drives the level choice, the exclusion, and the closure's return value from one expression, so the
arm that warns is textually the arm that excludes. Had the split not landed I would have ruled it
non-blocking and carried it to #3265 (no production writer, so no volume today) — but that is no
longer the question.

One finding, defence-in-depth, NOT gating. crates/dig-node-core/src/lib.rs:9298-9303 asserts
the level over the whole log buffer, unanchored to the line it is about:
logs.contains("DEBUG") && logs.contains(r#"zeroed_fields=["root"]"#). The conjuncts are
independent. Flipping the root branch back to warn! leaves the "zeroed root" count, the
"missing an identity field" count and the zeroed_fields=["root"] substring all satisfied; only
contains("DEBUG") can fail, and only because dispatch.rs happens to contain exactly one
debug! and no trace! today. Add a debug! anywhere in the dispatch path — tier check, rate
limiter, registry read — and the level half of this test goes vacuous silently while the doc block's
new failure-mode entry 5 keeps claiming it catches exactly that mutation. Fix is one line: scan
logs.lines(), assert the single line containing zeroed_fields=["root"] starts with DEBUG and
that no line containing it starts with WARN. I did not prove the mutation by execution (a cold
dig-node-core compile was outside this gate's budget), so "incidentally red today" is structural,
not measured. Recommend a ticket on #3265 alongside the other acceptance items.

Severity limiter holds: register_reward_prover_status is pub(crate) with zero non-test
callers
at this SHA — ten references, all inside mod tests. The registry has no production
writer, so nothing above is reachable in a shipped node until #3265 lands.

Doc truth. Every touched comment is true against the code beneath it —
dispatch.rs:152-160, dispatch.rs:799-812 (the 080be3df born-false claim is gone and stayed
gone), lib.rs:9245 — with the single exception of failure-mode entry 5 at lib.rs:9201-9221,
which claims strength the assertion does not have. That is the finding above, not a separate one.

Returned zeroed-root record is not an honesty hole. The wire mapping (dispatch.rs:196-230)
passes prover_state, prover_state_since, last_cycle_started_at, last_cycle_completed_at and
observed_at through, so a consumer seeing root as 64 zeros has independent fields telling it no
cycle has completed.

Untouched invariants, re-confirmed at 0ae5f30a. is_peer_reachable_method unwidened — the
entire peer.rs delta against base d562aad8 is one added test. dependency_tree.rs unchanged in
the f657f2cb..0ae5f30a delta (which is exactly lib.rs + dispatch.rs) and still holds
versions.len() == 1 plus versions[0].starts_with("0.11."), never widened to a set. rewards/**
and module_transport.rs untouched across the whole PR (base..head = 8 files: Cargo.lock, two
Cargo.toml, lib.rs, peer.rs, dispatch.rs, tests/dependency_tree.rs,
tests/reward_methods_tier_guard.rs). §12.5 clause 7 stays settled.

CI evidence, executed at this SHA — run 34426912937, job 102713997021, not carried forward
from f657f2cb: Summary [926.029s] 3293 tests run: 3293 passed, coverage TOTAL 89.98% (line
90.63%, region 89.98%, function 86.94%). Named: (1085/3293)
get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field PASS — the test carrying the new
level assertions; (234/3293) reward_methods_are_absent_from_the_node_peer_allowlist PASS;
(1224/3293) the_workspace_carries_exactly_one_module_wire_crate PASS. All 14 checks SUCCESS
including Clippy, Rustfmt, CodeQL and the three Analyze jobs.

Two discrepancies against my brief, both benign and both explained. (1) The total is 3293
here, the same as at f657f2cb, because 0ae5f30a added assertions inside the existing #[test]
rather than a new test fn — so a green count identical to the previous SHA's is expected, not
evidence the new assertions did not run; the per-test PASS line at (1085/3293) is that evidence.
(2) Coverage reads 89.98%, not the 90.00% in my brief; the job's own >=80% gate passes and the
delta is two hundredths on a different SHA.

Scope audited: f657f2cb..0ae5f30a in full (55/28 across lib.rs, dispatch.rs), plus
d562aad8..0ae5f30a for the untouched-invariant re-confirmation, all from git objects. Not
covered:
execution of the level mutation probe; the Cargo.lock/Cargo.toml dependency delta
beyond the single-dig-rpc-protocol-line assertion, which earlier rounds covered. Shared
checkout:
I read only from git objects in D:\worktrees\dig-node-595 and ran one git fetch
there; no checkout, branch or working tree was modified, nothing to restore.

Merge is not blocked by security. Carry the lib.rs:9298-9303 assertion strengthening as an
acceptance item on #3265.

@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.

Re-verdict — PASS at 0ae5f30a74fdbc927a812c2512af6ece545998d4

Third head this PR has had under review (080be3df CHANGES-REQUIRED, f657f2cb PASS, now 0ae5f30a). The f657f2cb..0ae5f30a delta is 55 insertions / 28 deletions across crates/dig-node-core/src/{lib.rs, seams/dig_rpc/dispatch.rs} and it implements the log-level split. My PASS at f657f2cb did not carry over, and this one is re-derived from the code at this SHA.

1. The level split holds, and it is non-vacuous — proven by mutation, not inspection

The implementation at dispatch.rs:813-830 is a clean two-branch split on the same detection:

let zeroed = zeroed_fields(s);
if is_missing_identity(&zeroed) {
    tracing::warn!(..., zeroed_fields = ?zeroed, "... is missing an identity field; excluding it ...");
} else if !zeroed.is_empty() {
    tracing::debug!(..., zeroed_fields = ?zeroed, "... has a zeroed root; likely no cycle observed yet, returning it anyway");
}
!is_missing_identity(&zeroed)

Since root is the only non-identity member of zeroed_fields, the else if branch can only ever be reached with zeroed == ["root"], so the DEBUG line's message is true by construction rather than by coincidence. A record with both a zeroed launcher_id and a zeroed root takes the WARN branch once and carries zeroed_fields=["launcher_id","root"] — no double-logging, and the more serious level wins, which is the right precedence.

The level is genuinely asserted. I reverted the debug! to warn! in a scratch worktree, keeping the message identical (the nearest wrong implementation — the fix that "reverts the level and nothing else"), committed it, and ran the test:

test result: FAILED. 0 passed; 1 failed
panicked at crates/dig-node-core/src/lib.rs:9303:9:
expected a DEBUG line naming exactly root as zeroed, distinct from the WARN level used for a
missing identity field, even though the record is still returned:
  WARN ... is missing an identity field ... zeroed_fields=["launcher_id"]
  WARN ... is missing an identity field ... zeroed_fields=["store_id"]
  WARN ... has a zeroed root; likely no cycle observed yet, returning it anyway ... zeroed_fields=["root"]

So failure mode (5) — "returns the zeroed-root record but logs it at the SAME level" — is a claim the test actually enforces, not a claim about itself. Baseline at this SHA is 7/7 green, so the RED is attributable to the mutation.

Worth recording precisely which assertion caught it, because it is only one of them. logs.matches("missing an identity field").count() == 2 did not fire (the mutation left that message untouched), and neither did logs.matches("zeroed root").count() == 1. The entire level property rests on the logs.contains("DEBUG") conjunct in the third assertion. That conjunct is a real discriminator today — I confirmed from the captured output that the getRewardProverStatus dispatch under capture_sync_logs (a TRACE-level scoped subscriber, lib.rs:7723-7737) emits no other DEBUG or TRACE line, so "DEBUG" appears in the buffer only when this branch puts it there.

2. Doc truth at this SHA

Every touched claim read against the code beneath it, since this is the epic that has shipped a born-false doc claim in the commit fixing the previous one four times over:

  • lib.rs:9204-9210 — "logged at DEBUG, never WARN, and never causes exclusion on its own; see the third case below". True, and the cross-reference is now right: the case is labelled // Case 3 at lib.rs:9245, where the previous revision said "fourth case" for the same case. That off-by-one was fixed here rather than propagated.
  • lib.rs:9216-9218, failure mode (5) — true, and proven true by the mutation above rather than asserted.
  • lib.rs:9219-9224, failure mode (6) — the old (5), renumbered and extended to "the zeroed_fields=[...] value AND the level". Accurate description of what the assertions now do.
  • dispatch.rs:152-158 — the zeroed_fields doc now states the split and its reasoning, and its cross-reference changed from "reward_prover_status_to_wire's call site" to "the GetRewardProverStatus filter below". The second is the correct target and it is genuinely below (:163 vs :813); the first was subtly wrong, since the filter is not that function's call site. Another latent doc error fixed rather than carried.
  • dispatch.rs:799-802 — "a tracing::warn! fires naming which field(s) were zero, making a bad registration observable, and the record is excluded". True on both halves.
  • dispatch.rs:806-812 — "So this case is tracing::debug!, not warn! ... kept out of warn!-level volume". True.

The reworded WARN message also retires the one non-gating note I left at f657f2cb: it previously read "excluding it ... if the zeroed field is an identity field", which was conditional and made an operator read zeroed_fields to learn whether the record was actually dropped. It now reads "is missing an identity field; excluding it ... rather than presenting it as a real distributor", which is unconditional and true on the only branch that emits it.

One wording nit, not a defect and not worth a round. The dispatch.rs:812 sentence "The record is still returned either way" closes a paragraph about the root-only case, so "either way" reads naturally as "at either log level" — true. It can also be misread as "in both branches", which is false for the identity branch. The preceding paragraph says that record "is excluded" three lines up, so a reader is not actually left with the wrong belief. Flagging it only because this epic's failure mode is exactly a sentence that reads fine and is not true.

3. Nothing else disturbed

git diff --name-only f657f2cb 0ae5f30a is exactly the two files, which settles most of this mechanically. Checked individually anyway:

  • is_peer_reachable_method unwidened — peer.rs is not in the delta; its only change across the whole PR remains the added test asserting every Reward-named method is absent from the local allowlist, vacuity-guarded by asserting the scan is non-empty first.
  • Both #3261 guards untouched and still non-vacuous — tests/reward_methods_tier_guard.rs is not in the delta, and reward_methods_exist_and_are_found_by_the_prefix_scan still asserts the prefix scan found something before the other two iterate it.
  • §2.4 key-set assertion untouched — it lives at lib.rs:9144-9163 and every delta hunk in that file starts at 9204 or later (git diff -U0 hunk headers: @@ -9204 @@, -9213, -9242, -9287, -9291, -9293, -9295, -9300). It still bans by KEY SET over {alive, healthy, ok, up, running, stale, seconds_since_last_run}, not by substring.
  • dependency_tree.rs unchanged in the delta; the_workspace_carries_exactly_one_module_wire_crate still asserts versions.len() == 1 and versions[0].starts_with("0.11."), not widened to a set.
  • rewards/** and module_transport.rs untouched across the entire PR.
  • No stub for the other three reward methods — grep -n 'Some(Method::GetReward' returns exactly one arm, dispatch.rs:784, so the rest still fall through to the catalogued -32601.

4. Executed evidence at THIS SHA

Test + coverage finished after your last look. From job 102713997021's log, not the checkmark:

Summary [926.029s] 3293 tests run: 3293 passed (17 slow), 4 skipped
TOTAL  151777  15201  89.98%   (lines; gated at >= 80%)

3293 as you predicted — the level split changed assertions inside an existing #[test] and added no new test function, so the total is unchanged from f657f2cb. Coverage moved 90.00% -> 89.98% on 15 more countable lines, comfortably above the 80% gate.

All eleven reward-surface tests PASS in that run: the seven get_reward_prover_status_* (1080-1086/3293, including logs_and_excludes_a_zeroed_identity_field at 1085), peer::tests::reward_methods_are_absent_from_the_node_peer_allowlist (234/3293), the three reward_methods_tier_guard tests (1287-1289/3293), plus dependency_tree::the_workspace_carries_exactly_one_module_wire_crate (1224/3293). Locally at this SHA: 7 passed, 0 failed.

Every other required check is green at 0ae5f30a — Clippy, Rustfmt, CodeQL, all three Analyze jobs, Release-script tests, and all four packaging builds (.msi, both .deb, .pkg), which were pending at the previous head and have now completed successfully.

5. Threads

All four prior threads remain resolved; open count is 0. I have opened none this round — the level split is correct, non-vacuous and truthfully documented, and the two observations above (the single-conjunct level assertion, the "either way" wording) are robustness notes recorded here rather than defects to fix, so a thread would only cost a round.

Verdict

PASS at 0ae5f30a74fdbc927a812c2512af6ece545998d4. Zero open threads. Settled and not re-derived: SPEC §12.5 clause 7 (the forbidden split is per-peer and this response has no peer axis) and the exclusion-set narrowing itself.

The PR stays DRAFT; marking it ready and merging are the orchestrator's.

I did not re-audit rewards/**, module_transport.rs, the dependency bumps or dig-app#403, and did not exercise the endpoint against a live #3265 writer, which still does not exist.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 10, 2026 02:47
@MichaelTaylor3d
MichaelTaylor3d merged commit 8573ecf into develop Sep 10, 2026
14 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the feat/3269-reward-prover-status-rpc branch September 10, 2026 02:47
MichaelTaylor3d added a commit that referenced this pull request Sep 10, 2026
…p, prover-status RPC (#602)

* feat(mirror): persist mirror-bond coin ids (#575)

* chore: open lane for #574

* feat(mirror): persist mirror-bond coin ids so a restart cannot double-create

Bond identity was reconstructed from a live chain scan on every read
(`mirror/observe.rs`), with no persistence of its own. A restart, a cold
replica, or a lagging/flaky chain source all rendered a real, unspent,
confirmed bond as "no bonds" -- and because the in-flight suppression is
keyed on pending/submitted audit entries, a bond whose create had already
CONFIRMED was not suppressed either, so the same short scan that emptied
the read surface also cleared the one thing that would have stopped a
second coin being paid for collateral that already exists (dig-node#574).

Persist the (store, root, epoch) -> coin_id mapping in the EXISTING spend
audit record (spend-audit.jsonl) rather than a new store: a mirror-coin
create already writes store_id + AuditedBond{root, epoch} + amount there,
and the coin id itself becomes durable the moment resolve_landed_spends
confirms it. This adds the one missing piece -- the advertised URL a
create carries -- and a read-side query, confirmed_mirror_bond, that
returns the newest CONFIRMED record naming a triple.

Chain stays authoritative. mirror::local_bond::recheck_missing_bonds
never trusts the record: for a held bond the live scan did not cover, it
asks the record for a candidate coin id, then re-verifies that SPECIFIC
coin against chain via the same independent check (chain_bond_verdict)
that verifies an untrusted peer's claimed bond. Only a fresh `Bonded`
verdict is folded back in, as covered; `Unbonded`/`Unverified` fall
through to an ordinary create, exactly as if no record existed.

Version: 0.254.86 (patch -- per #522 the MSI ProductVersion minor field
is exhausted and the counter lives in patch).

Co-Authored-By: Claude <noreply@anthropic.com>

* test(mirror): prove the recovery wiring end to end through PassRunner::run

Adds two integration-level tests over the REAL pass pipeline, not just the
isolated recheck_missing_bonds unit tests: a bond missing from the live
scan with a chain-reverified durable record is recovered (no double
create, correct Bonded state reported), and the control -- the same
record but chain disproves it -- correctly falls through to an ordinary
create. Together these are the concrete regression test for the
cold-start/lagging-chain-source double-create scenario the ticket asked
to have measured.

Also refactors in_flight_creates to take the already-folded SpendLedger
instead of re-reading the log itself, so PassRunner::run reads the audit
file once per pass and shares it with the new recovery step, and fixes a
doc comment on in_flight_creates that the recovery step would otherwise
have made stale on landing ("a Confirmed create has a coin the chain
observation already sees" is no longer unconditionally true).

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(fmt): wrap long test signatures to satisfy rustfmt

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(clippy): use slice::from_ref instead of cloning for a single-element slice

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(release): bump to v0.254.89

Base branch moved to develop after PR #576 merged there at v0.254.88
(main and develop are currently identical), leaving this branch's
carried-forward .88 as a zero-increment against the new base. Bumped
to the next free integer after fetching and verifying both origin/main
and origin/develop tip at .88.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(peer): count accepted relayed circuits in the connected pool (#579)

serve_accepted_relay_conn served every accepted relayed circuit (full mTLS
auth, full L7 peer RPC) while registering it nowhere, so connected_peers
under-reported every relayed inbound peer -- the relay-leg twin of the
direct-inbound defect #402/#523 already fixed.

adopt_inbound_peer_in_pool now dispatches by TraversalKind: Relayed routes to
dig-gossip's already-published adopt_relayed_inbound_handle (v0.32.0, the rev
this repo already pins), every other tier keeps the unchanged
adopt_direct_inbound_handle path. serve_accepted_relay_conn adopts before
serving and releases after, mirroring the direct listener exactly.

Refs: https://github.com/DIG-Network/dig_ecosystem/issues/3124

* fix(cli): guard the exit-code namespace shared with diga against collisions (#582)

* chore: open lane for #3189

* fix(cli): guard the exit-code namespace shared with diga against collisions

dign and diga deliberately share one process exit-code numbering (dig-app's
outcome.rs says so in its own doc comment), so a number is free only if it
is unoccupied ecosystem-wide. dig-node#407 assigned exit 7 to
NODE_UNREACHABLE by checking only this repo's own table, where 7 genuinely
was free -- and collided with diga's NOT_CONNECTED. A reviewer caught it by
hand; nothing failed automatically.

Adds scripts/check-exit-code-collisions.sh: parses both enums' code()/name()
match arms straight from their own source -- this repo's ExitCode, and a
live fetch of dig-app's outcome.rs at its default branch -- and fails if a
number carries two different names, or if either side draws a number from
the reserved shell signal range (126, 127, 128+N). Ships with an 18-case
hermetic test harness (scripts/tests/check-exit-code-collisions.test.sh)
covering the actual #407 collision shape, arm-order independence, arm-count
mismatch, the reserved-range boundary from both sides, the live-fetch path
itself, and fail-closed behaviour on an empty/missing/unreachable table.

Wires a real (unstubbed) invocation into ci.yml's existing "Release-script
tests" job so a collision introduced by a future PR, on either side, is a
red required check on that PR -- not a note a reviewer has to catch. The
fetch retries twice (2s backoff) since this becomes a required, network-
dependent check; a fetch failure still fails closed after retrying, never
silently passing as "diga has no codes".

Updates SPEC.md 8.4 to point at the mechanical guard instead of leaving
"re-check both tables" as unenforced prose, and records that the
extension's WALLET_WS_ERR.NOT_CONNECTED = -33001 is a separate JSON-RPC
error-code space, not a rival of this one. Adds a doc-comment to the
existing transcribed collision test pointing future readers at the live
script as the authoritative check; the transcription remains as a narrower,
hermetic regression pin for the #407 shape specifically.

No renumbering: every currently-assigned code is unchanged.

Refs #3189

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 corrupted strings (#3190) (#583)

* chore: open lane for #3190

* fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 corrupted strings

Replicates dig-node-service::continuation_guard (dig-node#526/#501) into dig-node-core,
dig-wallet, dig-runtime and dig-chat-protocol, line-for-line apart from crate-specific
constants -- ported rather than reinvented, per dig_ecosystem#3190.

Wiring the guard in surfaced 48 pre-existing lost-continuation defects the ticket's own
"no measured corruption in these four crates" note did not anticipate: 36 in dig-node-core,
12 in dig-wallet, mostly test-assertion prose where a multi-line message lost its `\`
continuation and shipped the source's own indentation as a mid-sentence space run (one
as the worse `\n`-plus-indentation variant). All 48 are collapsed to the single space the
sentence always meant, with surrounding indentation and wording otherwise untouched.

Two lines are real column-alignment, not defects, and get a targeted EXCLUDED_LINE_RANGES
entry on dig-node-core instead of a rewrite: download.rs's `claimed(...)` fixture-table
trailing comments, and net.rs's `label : value` debug-print alignment.

Refs https://github.com/DIG-Network/dig_ecosystem/issues/3190
Refs https://github.com/DIG-Network/dig_ecosystem/issues/3130

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(mirror): detect an IP change daily and reconcile mirror coins to the current advertise URL

Automatic half (D1-D4) of the daily mirror-URL reconcile: derived personal-day offset, two-observation hysteresis, nine ordered gates with K sized as a self-funding prefix before any reclaim, `submitted` never `completed`, audit lines gain `reclaim_reason` + `trigger`.

Gates at b4c09866: loop-reviewer PASS (review 5129272285), loop-security PASS @ 175304e1 (tree byte-identical, `git diff 175304e1 b4c09866` empty), adversarial loop-decider PASS (comment 5567083644; SHOULD-FIX findings ticketed separately).

Refs DIG-Network/dig-node#570
Refs DIG-Network/dig_ecosystem#3203

* feat(serve): content hosting + serve path batch, v0.255.0 (dig_ecosystem#3212)

Nine commits from the #3212 serve-path lane, gated at 899cc68f (reviewer review 5130425808,
security comment 5568662836), plus the single semver bump to 0.255.0 for the develop -> main batch.

- store_id/root case normalised at the CapsuleKey boundary; cache delete targets the matched entry
- tier-0 occupancy reads the eviction-aware ledger
- profile-sync outbound budget in bytes; announcer asked first
- melt confirmation depth on the terminal spend, fail-closed
- EngineWarming (-32002) while the peer tier attaches, never -32004
- window completeness derived from the bytes read
- deps: dig-stun 0.2, chia-query 0.24.3, dig-nat 0.21.2, dig-logging 0.2.2

Refs DIG-Network/dig_ecosystem#3212

* chore: untrack gitnexus-generated agent files (#590)

* chore: untrack gitnexus-generated agent files

These files were generated by `gitnexus analyze` as a side effect of
indexing this repository. They are development-loop private tooling
output, not product code, and carry no secrets. They are removed from
tracking going forward via .gitignore; history is deliberately NOT
rewritten.

Refs #3177

* chore: drop private-repo reference from gitignore comment

The ignore comment named a private repository and an internal issue
number in a public file, which is the same disclosure class this
change set exists to remove; the reference is dropped and the
guidance kept.

* feat(rewards): always-on prover loop engine -- honest liveness, type-enforced self-exclusion, bounded spend (#593)

The always-on reward-prover engine: ~2,000 lines under
`crates/dig-node-core/src/rewards/`, built against the merged `dig-rewards-coin`
SPEC. Library only -- nothing spawns it, and the sole production
`RewardsChainPort` refuses every call, so it cannot spend. Wiring the composed
system is #3265, which carries its own gate.

The epic's premise -- "anytime the process isn't running, rewards are not being
distributed" -- is half wrong, and the false half is the dangerous one. `Sync`,
`NewEpoch` and `InitiatePayout` need no manager authority, so funder downtime does
not stop rewards: it FREEZES THE ENTRY SET while accrual and payouts continue.
Peers that stopped mirroring keep earning; peers that started cannot begin. That
shaped the whole design.

Liveness honesty (SPEC 2.4). The status record carries no `healthy`/`ok`/`up`
boolean and no precomputed staleness, because a wedged loop cannot report its own
wedging -- whatever it last wrote stays there, so a writer-set flag reads true
forever after the failure it exists to reveal. The reader derives staleness from
`last_cycle_completed_at` against `observed_at` and its own clock. A recursive
JSON-key test enforces the absence at every nesting depth; asserting on keys and
never substrings, since `ProverState::Running` legitimately serializes the VALUE
"running". The one legal staleness signal is chain-derived (SPEC 12.4: 48 hours
AND a non-zero reserve, from the singleton's own spend history) and lives on the
distributor read, where a wedged prover cannot fake it.

Self-exclusion is a compile error, not a habit. dig-node#261's lesson is that an
invariant enforced on some paths is not an invariant. `admit` is the single
admission point, checks both SPEC 5.2 coordinates (own peer_id OR a payout puzzle
hash this wallet controls), and mints an `AdmittedPeer` with private fields and no
public constructor -- so `EntryAction::Add` cannot be built by a path that skipped
admission.

A prover's own fault can never strike a peer. `GateError` is a distinct type from
`GateIneligibleReason` and `record_prover_fault` takes `&self`, so SPEC 3.6 clause
4 is enforced by the borrow checker rather than by comment. Without that, a
misconfigured operator -- one missing mirror-collateral epoch ordinal -- would
strike every peer at once and evict its entire 250-entry set in three hours, each
eviction a fee it pays plus a settlement out of its own reserve.

The money bounds are stated where a human reads them (`rewards/mod.rs`): 24
bundles/day, 192 entry actions/day, a fee ceiling of 24x the configured standard
fee, 192 removals/day worst case with 96/day sustained churn. Recorded honestly:
SPEC 6.3's rate bound and fee ceiling are ONE control, not two.

Three gate rounds, every leg fresh-context. Round 3 at this head: reviewer PASS,
adversarial decider RATIFY (leg closed), security CHANGES-REQUIRED on a finding
the decider ratified deliberately -- adjudicated in
https://github.com/DIG-Network/dig-node/pull/593#issuecomment-5601784815 and
carried to #3265 with the remedy corrected, because the proposed fix would have
persisted a poison flag to the very store whose writes were failing.

Found and fixed under gate: a census ordinal off by one in both directions (SPEC
4.6 requires n-1 exactly); an unreachable grace window leaving a named constant
with no reader; a missing `NewEpoch` spend; an absent-ordinal path attributing a
prover fault to peers; and a daily fee ceiling 24x too high because a per-bundle
fee was consumed as a daily ceiling.

Refs DIG-Network/dig_ecosystem#3250

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

* feat: serve dig.getRewardProverStatus at Tier::Control (#595)

* chore: open lane for #3269

Bump dig-rpc-protocol to 0.11.0 (adds dig.getRewardProverStatus and
the other reward RPC methods to the wire).

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

* test(rewards): fail-closed Reward-tier guard + dig-rpc-protocol 0.11 line assertion

- dependency_tree.rs: assert the resolved dig-rpc-protocol line is 0.11 (was 0.10);
  documents the known-red two-version state pending the dig-peer 0.14.0 /
  dig-download 0.23.0 cascade (#3269).
- reward_methods_tier_guard.rs: fail-closed guard over the live Method::ALL
  catalogue -- every Reward-named method must be Tier::Control and not
  peer-reachable, so a fifth reward method added later is caught at the wrong
  tier automatically rather than inheriting a wrong default (binds #3261's rule
  node-side).
- peer.rs: sibling unit test exercising the real (pub(crate)) is_peer_reachable_method,
  since an external integration test cannot see it -- same guard, executed against
  this node's own allowlist rather than only the shared crate's.

Refs #3269

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

* style: remove trailing blank line in reward_methods_tier_guard.rs

* feat(rpc): serve dig.getRewardProverStatus at Tier::Control

Adds the missing handler for PR#595: a new reward_prover_statuses
registry + accessors on Node (empty until #3265 spawns a prover loop,
so the registry read is real, not a stub), a dispatch.rs arm inside
the Method enum match (never the string pre-match), and a
field-for-field mapping from dig-node-core's internal
rewards::state::RewardProverStatus (camelCase-tagged) onto
dig-rpc-protocol 0.11's wire RewardProverStatus (snake_case-tagged
struct, camelCase-tagged ProverState value), widening entry_count
u32 -> u64 explicitly and hex-encoding the three [u8; 32] identity
fields.

An all-zero launcher_id (what an uninitialised registry slot
hex-encodes to) is omitted at this boundary rather than rendered as
a real distributor with a plausible-looking id -- the money-hole
class the dig-rewards-coin driver's adversarial gates found three
times.

Tests (in dig-node-core::lib.rs's existing test module, where the
pub(crate) registry accessors are visible) drive the real dispatch
entry point (handle_rpc -> RpcDispatch::dispatch -> the Method arm)
and assert field-for-field on the serialized JSON body: populated
registry, empty registry (-> {"statuses": []}), zero-id omission,
tier/peer-reachability, enum-match-not-string-prematch, and
launcher_id filtering. The no-health-boolean / no-staleness
assertion is by key set, not substring.

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

* style: rustfmt the reward-prover-status registry + tests

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

* chore(deps): bump dig-download 0.23, dig-peer 0.14, dig-rpc-protocol 0.11

Closes the two-versions-of-dig-rpc-protocol split (#3269): dig-download 0.23.0
and dig-peer 0.14.0 both now resolve dig-rpc-protocol ^0.11, matching
dig-node-core's own dig-rpc-protocol = "0.11.0" line, and dig-node-service's
two 0.10 lines (main dep + dev-dependency restatement for
openrpc_drift_guard.rs) move to 0.11 to match.

Manifests only. cargo update / Cargo.lock intentionally NOT run yet: a fourth
capper, dig-peer-selector ("0.11" in dig-node-core/Cargo.toml), still requires
dig-peer = "^0.13" in every published version through 0.11.1, so the tree
cannot fully resolve until a dig-peer-selector release picks up dig-peer 0.14.
CI will stay red on this commit for that reason, which is expected.

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

* feat(rpc): close dig-rpc-protocol 0.11 cascade + attribute payout figures

Bump dig-peer-selector 0.11 -> 0.12 (the release that moves onto dig-peer
^0.14) and run cargo update, closing the two-versions-of-dig-rpc-protocol
split: Cargo.lock now resolves exactly one dig-rpc-protocol, at 0.11.0,
alongside dig-peer 0.14.0, dig-download 0.23.0, dig-peer-selector 0.12.0.

Add a subject-attribution test and doc comments to
reward_prover_status_to_wire: total_paid_out_base_units and
reserve_base_units are per-distributor totals (this distributor's payout to
ALL its mirrors, and this distributor's own reserve), never the querying
node's own earnings and never summed/cross-attributed across distributors.
This is the defect class a sibling adversarial gate found in dig-app#403's
rewards pane, which rendered a distributor total as one mirror operator's
personal earnings and overstated by up to 250x.

Checked: rewards/state.rs, rewards/port.rs and rewards/mod.rs contain no
Eligible/verdict/payout_hash symbol, so nothing in this mapping surfaces a
persisted EligiblePayoutHash verdict.

Refs #3269

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

* fix(rpc): silence dead_code on register_reward_prover_status pending #3265

Clippy's non-test lib target has no production caller for
register_reward_prover_status yet, because #3265 (the always-on prover loop
that would call it from bring-up) has not landed -- only tests call it today.
cfg_attr(not(test), allow(dead_code)) stands in for that missing caller
until #3265 wires a real one.

Refs #3269

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

* fix(rpc): make the all-zero identity guard non-silent and cover all three fields

Security (blocking) and the adversarial leg both found the same defect in the
zero-launcher_id filter: it checked only launcher_id, so a registration bug
that zeroed store_id or root beside a valid launcher_id would pass through as
a plausible record, and dropping the bad record silently destroyed the
evidence a registration bug happened at all -- SPEC Sec2.4 clause 1's exact
prohibition.

zeroed_identity_fields() now checks launcher_id, store_id AND root. The
dispatch filter still excludes a record with any zeroed field (never renders
an uninitialised slot as a real distributor), but first fires a
tracing::warn! naming which field(s) were zero, so a bad registration is
observable rather than swallowed. Kept isolated in dispatch.rs rather than
woven into the wire mapping, since this belongs at #3265's writer once that
lands.

Replaced get_reward_prover_status_omits_an_all_zero_launcher_id (which
proved the omission but not the observability, and never exercised a zeroed
store_id/root beside a valid launcher_id) with
get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field, covering
both a zeroed launcher_id and a zeroed store_id beside a valid launcher_id,
and asserting the tracing::warn! output via the crate's existing
capture_sync_logs test utility.

Fixed a now-false "Known-red" doc comment on
tests/dependency_tree.rs::the_workspace_carries_exactly_one_module_wire_crate:
the dig-peer 0.14.0 / dig-download 0.23.0 / dig-peer-selector 0.12.0 cascade
already landed in this PR's Cargo.toml/Cargo.lock, so the assertion is green,
not red. Assertion itself untouched -- still exact-version.

Refs #3269

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

* fix(rpc): correct a born-false "shipped dig-app 15.5.0" doc claim

dig-app's latest release is v15.4.0 -- there is no v15.5.0 tag -- and
dig-app#403 (the pane that would consume dig.getRewardProverStatus) is OPEN
and unmerged. Point the doc comment at the real, unmerged consumer instead
so a future reader doesn't take this as evidence a shipped consumer depends
on the guard, which would wrongly discourage relocating it to #3265's writer.

Refs #3269

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

* fix(rpc): correct doc placement, assert the zeroed field by name, treat root as an observation

Three findings from the correctness gate on PR#595 at 134864a9.

1. The zeroed-identity helper's doc block was spliced onto the end of
   reward_prover_status_to_wire's block with no separator, so the wire-mapping
   rationale documented a boolean predicate and the mapping function was left
   with no doc at all. Each doc block now sits above the item it describes.

2. The log assertion `logs.contains("launcher_id")` was a tautology: the warn
   emits launcher_id as a structured field on every fire, so the property the
   guard exists to add -- naming which field was zeroed -- was unasserted.
   Deleting `zeroed_fields = ?zeroed` from the warn left every assertion green.
   The test now asserts the zeroed_fields value itself, which the fixture makes
   exact and disjoint across cases.

3. `root` is an observation, not an identity. A registered prover that has not
   completed its first cycle plausibly has no root, and a writer that zero-inits
   it would have made a healthy prover invisible. A zeroed launcher_id or
   store_id still excludes the record; a zeroed root alone warns and returns.

Refs #3269

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

* fix(rpc): restore zeroed_fields structured field dropped from the pushed warn

The previous commit (080be3df) landed with `zeroed_fields = ?zeroed` missing
from the tracing::warn! call in the GetRewardProverStatus filter -- a
one-line regression introduced while proving the new log assertion goes red
without it, never restored before the commit was made. Without this field
the log line never names WHICH field was zero, so an operator sees only
that something was excluded, and the test asserting `zeroed_fields=[...]`
per case would fail. Restored; all 7 reward-prover-status tests green.

Refs #3269

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

* fix(rpc): split zeroed-field logging by level -- WARN for a missing
identity, DEBUG for a zeroed root

A zeroed launcher_id or store_id is a real registration bug: the record is
excluded and now logs at WARN, naming the exact field(s) via
`zeroed_fields=[...]`. A zeroed root alone is an ordinary pre-first-cycle
state, not a fault: the record is still returned, and now logs at DEBUG
instead of WARN, so an operator polling this endpoint sees warn-level
volume proportional to real registration bugs, not to every
not-yet-cycled prover on every poll.

Updated the doc comments on `zeroed_fields`, the dispatch filter and the
test to describe the level split, and extended the regression test to
assert on level (WARN vs DEBUG) as well as the `zeroed_fields` value.
Proved both directions: flipping the DEBUG branch back to WARN turns the
test red on the level assertion; flipping the field-name assertion back to
a bare `contains("launcher_id")` would have passed unconditionally (the
prior tautology) and is no longer possible since the assertions now pin
`zeroed_fields=[...]` plus the level string.

Refs #3269

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat(rewards): peer-side claim loop -- watch distributors, claim on cadence (#3251) (#594)

* feat(rewards): peer claim loop skeleton -- discovery, cadence, config, claim port

* test(rewards): write all twelve acceptance tests for the peer claim loop

* feat(rewards): wire the seven rewards_claim submodules into the crate

mod.rs declared no submodules, so types.rs/port.rs/config.rs/cadence.rs/
parser.rs/engine.rs/hints.rs (1,435 lines, 25 tests) were never part of the
crate and never compiled. Declare them and re-export the public surface.

* style(rewards): cargo fmt the rewards_claim submodules

* chore(deps): bump dig-node-control-interface 0.33->0.35, dig-rpc-protocol 0.10->0.11.0

dig-rpc-protocol 0.11.0 is merged and tagged upstream; the other dig-*/chia-*
deps of dig-node-service were already at the latest permitted-by-caret version
in Cargo.lock. crates/dig-node-core/Cargo.toml is untouched (#3250's file set).

* chore(deps): revert dig-node-control-interface and dig-rpc-protocol bumps

Both create a duplicate-version split in this PR's scope and neither can be
closed without editing a sibling crate's manifest this lane does not own:

- dig-rpc-protocol 0.11.0 duplicates against dig-node-core/Cargo.toml:194
  ("0.10.2"), which is #3250's live file set (dig-node#593).
- dig-node-control-interface 0.35.0 duplicates against
  dig-wallet/Cargo.toml:81 ("0.33"), a sibling crate this lane does not own;
  the observed Clippy break (BalanceAsset/Asset type-identity mismatch,
  missing url_reconcile/url_current/urls fields) came from THIS duplicate,
  not from dig-rpc-protocol.

Both belong to their own sequenced dep-bump unit of work, not this ticket.

* fix(rewards-claim): fault laundering, permanent no-entry blacklist, fee ceiling magnitude

Three independent gates on dig-node#594 (51516e62) found four logic defects; this
addresses A, B and C per the corrected fix brief (D is documented only, not fixed
here per the brief's own instruction).

Defect A -- the anti-silence surface laundered every real fault into `Nominal`:
- A1: `fault_reported` had no fault-bearing ClaimLoopState to fall through to, so a
  chain adapter erroring every cycle read `Nominal` forever. Added
  `ClaimLoopState::Faulted { cycles }`, outranking Nominal/ClaimableButNotClaiming,
  under ChainSourceUnavailable.
- A2: inverted the test that asserted A1's bug as correct behaviour.
- A3: `ClaimableButNotClaiming` compared a per-cycle snapshot
  (`distributors_claimable`) against a lifetime-cumulative counter
  (`claims_submitted`), so it latched healthy forever after one lifetime success.
  Added `claims_submitted_this_cycle` (per-cycle) as the correct comparand; kept
  `claims_submitted` as a cumulative counter.
- A4: `last_discovery_at`/`last_cycle_at` were stamped even on a failed discovery
  or an all-faulted cycle, destroying the staleness signal a reader depends on.
  Now only stamped on success; added `last_attempt_at` to prove liveness
  separately. `fault_reported` and `distributors_faulted` now reset per cycle
  instead of latching for the process's lifetime.

Defect B -- "terminal, stop retrying" was implemented as a process-lifetime
blacklist (`terminal_no_entry: HashSet<Bytes32>`, never cleared). That blocked
SPEC 12.5 clause 2's re-entry path (evicted, re-challenged, re-admitted never
claims again) and permanently punished a peer that discovered a distributor
before the funder's AddEntry landed. Removed the blacklist entirely -- `own_entry`
is a cheap chain read, re-issued every cycle for every candidate, matching clause
3's "never cache across cycles". `NoEntrySlot` is now a per-cycle observation, not
a lifetime sentence.

Defect C -- the fee ceiling didn't bind anything and there was no aggregate cap:
- C1: default `CLAIM_FEE_CEILING_MOJOS_DEFAULT` lowered from 1_000_000_000
  (transplanted from `MIRROR_SPEND_FEE_CEILING_MOJOS`, sized for a mirror-coin
  spend) to 200_000 -- 2x the observed routine Chia fee range (5,000-100,000
  mojos), so it actually binds instead of leaving 4-5 orders of magnitude of
  slack.
- C2: added a per-cycle aggregate fee budget
  (`max_cycle_fee_budget_mojos`, default 10x the per-claim ceiling) checked
  across all claims in a cycle, closing the attacker-cost gap where funding K
  distributors could force a victim to spend K x the per-claim ceiling per cycle.
  New `ClaimOutcome::SkippedCycleBudgetExhausted`.

Tests: rewards_claim test count 26 -> 37 (11 new: repeated_discovery_faults_never_
read_as_nominal, failed_discovery_leaves_last_discovery_at_unchanged, a_reported_
fault_surfaces_as_faulted_not_nominal, a_lifetime_submission_does_not_mask_a_
later_cycle_that_submits_nothing, no_entry_slot_then_re_admitted_produces_a_claim_
on_the_later_cycle, distributors_each_under_ceiling_do_not_collectively_exceed_
the_cycle_budget, the_default_per_claim_ceiling_actually_binds_a_routine_fee, plus
renamed/rewritten no_entry_slot_is_non_terminal_and_re_checked_every_cycle).

Refs #3251

* fix(rewards-claim): refuse a claim entry for the wrong payout puzzle hash

CI fix: cadence.rs's RewardsClaimConfig literal was missing the
max_cycle_fee_budget_mojos field added in the previous commit (E0063,
caught by CI's Clippy/Test jobs -- the local cargo check for this
workspace is too slow to use as the compiler here).

Defect E (security-gate finding, folded in before this pass closes):
submit_initiate_payout was called with entry.payout_puzzle_hash -- whatever
the chain port handed back -- with no check against this node's own
own_payout_puzzle_hash. UnavailableClaimChainPort is the only production
adapter today so nothing can exploit this yet, but the whole point of the
ClaimChainPort seam is that #3249 swaps in a real adapter with nothing
above it changing, so deferring this would ship the landmine live with no
review pass watching for it. Added an equality guard before the spend:
a mismatch refuses to submit, counts
(ClaimStatus::claims_refused_payout_mismatch), surfaces its own named
outcome (ClaimOutcome::PayoutPuzzleHashMismatch), and is reported as a
fault (a divergent entry means the port is confused or hostile, not that
there is nothing to claim) -- never corrected by substituting our own
hash and proceeding.

Defect D: documented, not wired, per instruction -- added the "not yet
wired into node startup" paragraph to mod.rs's module doc (the PR body
carries the same paragraph) so the next reader arrives at the caveat in
the code, not only in a merged PR description.

Refs #3251

* fix(rewards-claim): B1 -- ClaimableButNotClaiming is a magnitude comparison, not a zero-test

submitted_this_cycle < claimable_this_cycle now fires the anti-silence state, carrying
the shortfall as ClaimableButNotClaiming { claimable, submitted }. The previous
submitted_this_cycle == 0 zero-test let one submission mask any number of same-cycle
skips (claimable=10, submitted=1 read Nominal).

Also folds in B3's precedence fix (ChainSourceUnavailable > Faulted >
ClaimableButNotClaiming > Idle > Nominal) and the per-distributor
payout_hash_mismatches_this_cycle counter so a per-distributor fault can no longer
pin the cycle-wide Faulted state, plus R2's rename of terminal_no_entry_slot to
no_entry_slot_this_cycle now that it is no longer terminal.

* fix(rewards-claim): B2/B3 -- value-ordered budget with rotation, per-distributor fault isolation

B2: run_cycle now splits into a pre-budget phase (asset/entry/hash/threshold checks,
producing the claimable set) and a budget phase, ordering the claimable set by accrued
value descending before applying the fee ceiling and cycle budget. Dust distributors
(low accrued value regardless of attacker-controlled fee) now sort last and are the
ones the budget drops, closing the claim-suppression attack where ten high-fee dust
distributors could consume the whole cycle budget ahead of a victim's real earnings.
A rotation_cursor tie-breaks only WITHIN equal-accrued-value tiers so a genuinely
tied honest tail that exceeds one cycle's budget every cycle still rotates through
and is eventually served, rather than dropping the same tail forever.

B3: the payout-hash mismatch check in evaluate_pre_budget now increments the
per-distributor payout_hash_mismatches_this_cycle counter instead of setting
fault_reported, so one hostile or buggy entry can no longer pin the cycle-wide
Faulted state and bury ClaimableButNotClaiming for every other healthy distributor.

R2: terminal_no_entry_slot -> no_entry_slot_this_cycle throughout.

* fix(rewards-claim): R5 -- document not-yet-wired loop; persist B2 rotation cursor

An operator reading their own rewards-claim.json and seeing enabled: true has no way
to know from that file alone that no startup path constructs a ClaimEngine yet
(#3268) -- mod.rs said so, but a config file reader does not arrive at a module doc.

Also gives RewardsClaimConfig a rotation_cursor: Option<Bytes32> field so B2's
tie-break cursor survives a save/load round-trip -- an in-memory-only cursor resets
on every restart, which would starve a legitimately tied honest tail forever on any
node that restarts daily.

* fix(rewards-claim): clippy collapsible-match + SPEC v0.1.3 wording refresh

Collapses the nested if into the outer match arm in run_cycle (clippy::collapsible_match).
Also refreshes NoEntrySlot / no_entry_slot_this_cycle doc comments now that
dig-rewards-coin v0.1.3's SPEC §12.5 amendment is merged and tagged: absence is
terminal for one claim attempt only, never for the distributor, must not be cached,
and must not accumulate into a permanent exclusion set -- confirming rather than
diverging from the re-read-every-cycle behaviour already implemented.

* fix(rewards-claim): add missing rotation_cursor field in cadence.rs test literal

Struct literal in the cadence test module was not updated when RewardsClaimConfig
gained rotation_cursor (R5 commit) -- CI's Clippy/Test jobs caught the missing field
(E0063) that a local cargo check could not (killed by memory pressure before this
workspace-wide build completed).

* fix(rewards-claim): F1 -- ChainSourceUnavailable is per-cycle, never a latch

compute_state() compared against self.state -- last cycle's OWN computed
output -- so once any cycle took an Unavailable port path, every later
cycle re-asserted ChainSourceUnavailable forever, even after the chain
came back and real claims were submitting. A node still syncing, or one
dropped connection, was enough to trip this permanently.

Add ClaimStatus::chain_unavailable_this_cycle, reset to false at the top
of every run_cycle and set true only on a cycle that actually took the
Unavailable path; compute_state now reads that flag instead of
self.state, so the reading is live again.

Test: a_transient_unavailable_cycle_does_not_latch_state_for_the_rest_of_the_process
(engine.rs) drives cycle 1 unavailable, cycle 2 healthy with a
submission, and asserts cycle 2 reads Nominal. Plus a compute_state-level
regression in types.rs.

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

* fix(rewards-claim): F3/F4/F5 -- fix stale counters, dedup candidates, correct version doc

F3: reset EVERY per-cycle counter (distributors_known/with_own_entry/
claimable/faulted, claims_submitted_this_cycle, no_entry_slot_this_cycle)
at the TOP of run_cycle, before any early return. The three
ChainUnavailable early-return paths skip the end-of-function assignment
block entirely, so a cycle that hit one used to leave the PRIOR cycle's
counts sitting on self.status while last_attempt_at stamped fresh for
THIS cycle -- a stale count under a fresh timestamp, exactly what SPEC
§2.4's staleness reasoning forbids. types.rs's doc sentence for
no_entry_slot_this_cycle now correctly says it is dated by
last_attempt_at (the field stamped unconditionally every cycle), not
last_cycle_at.

F4: dedup `candidates` by launcher id before phase 2. A real adapter
scanning §1.3 launch comments across every (store_id, root) this node
mirrors can plausibly return the same launcher id twice; without dedup
phase 2 would evaluate it twice and submit InitiatePayout twice against
one entry slot in one cycle -- the second spend is invalid but the fee
is paid anyway.

F5: dig-rewards-coin is v0.1.3, published on crates.io -- correct the
stale "v0.1.1" module-doc claim.

Tests: a_chain_unavailable_cycle_does_not_leave_prior_cycles_counters_stale
(F3), a_duplicated_launcher_id_submits_exactly_once (F4).

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

* fix(rewards_claim): fold payout-hash mismatches into the shortfall predicate (F2)

A payout-hash mismatch never enters the eligible set, so it was counted in
NEITHER distributors_claimable NOR claims_submitted_this_cycle -- the
shortfall lived in neither term of compute_state's magnitude comparison.
All-K-distributors mismatching therefore read Nominal (falsely healthy).

Fold payout_hash_mismatches_this_cycle into the comparison's denominator:
submitted < claimable + mismatches. The result is ClaimableButNotClaiming
(a shortfall), never the cycle-wide Faulted -- Defect B3 stays fixed.

Inverts the assertion at what was engine.rs:1305
(a_payout_mismatch_never_sets_the_cycle_wide_fault_or_masks_other_distributors):
it previously asserted ClaimLoopState::Nominal across three cycles of an
ongoing mismatch, which pinned the defect as intended behaviour (an
A2-class test). It now asserts ClaimableButNotClaiming { claimable: 1,
submitted: 1 }.

Adds all_distributors_mismatching_is_a_shortfall_not_nominal, covering the
brief's exact "what if every distributor refuses for the same reason" case.

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

* fix(rewards-claim): F1/F3 -- AtomicU32 in fake ports, not Mutex, keeps discovery Send

CI's Clippy job (the compiler for this crate, per brief) caught it: holding
a std::sync::MutexGuard across the .await in FlakyThenHealthyPort and
HealthyThenUnavailablePort's discover_distributors made the returned future
not Send, which #[async_trait]'s generated trait signature requires.

Neither fake needs a lock -- each holds one call counter, incremented once
per call, never read-modify-written across an await point. AtomicU32's
fetch_add removes the guard (and the Send bound violation) entirely.

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

* fix(rewards-claim): F7 -- persist the fee window and cadence gate across restart

The per-cycle aggregate fee budget and the 24h cadence clock both lived only
in memory: `spent_this_cycle_mojos` was a `run_cycle` local and nothing on
disk recorded a completed cycle. Every fresh process got a full
`max_cycle_fee_budget_mojos` and an empty cadence clock, so a node stuck in
a crash-restart loop could spend unbounded XCH on fees, one full budget per
restart.

Adds three `#[serde(default)]` fields to `RewardsClaimConfig`
(`fee_window_start_unix`, `fee_spent_in_window_mojos`,
`last_cycle_completed_at`) and a new opt-in `ClaimEngine::with_persisted_fee_
window(dir, cadence_seconds)` that:
- restores the window/cadence state from `dir` at construction,
- refuses to start a cycle until the cadence has elapsed since the last
  completed one,
- rolls a fresh budget window only once the cadence has elapsed since it
  opened, otherwise keeps enforcing the budget against the persisted spend,
- persists the spend BEFORE every chain submission (write-then-spend), never
  batched to cycle end, and persists the completed-cycle timestamp when a
  cycle finishes.

Engines that never call `with_persisted_fee_window` (every pre-F7 test) are
unaffected -- this is additive, opt-in state beside the existing rotation
cursor, not a change to B2's value-ordering or rotation mechanism.

`ClaimStatus`'s own counters stay in-memory on purpose (observability, meant
to reset on restart); only the spend bound and the cadence gate persist.

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

* fix(rewards-claim): F7 -- update cadence.rs test literal for new persisted fields

The three new persisted RewardsClaimConfig fields (fee_window_start_unix,
fee_spent_in_window_mojos, last_cycle_completed_at) broke this crate's only
remaining full struct literal outside config.rs/engine.rs's own test
modules -- E0063 missing fields, caught by CI's Clippy job. Switched to
..RewardsClaimConfig::default() so the next added field cannot break this
literal again, the same fix already applied once before for rotation_cursor.

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

* fix(rewards-claim): F8/F14 -- atomic state write, fail closed on a corrupt window

Salvaged from a lane killed by a weekly cap before it could commit. Uncompiled at
commit time; CI is the compile signal.

Covers the fourth gate pass findings on the F7 persisted spend bound:

- F8: RewardsClaimConfig::save_to now writes atomically (temp file + rename in the
  same directory), reusing the pattern already used by mirror/reconcile_state.rs
  for the same class of state. load_from distinguishes an ABSENT file (clean first
  run, defaults are correct) from a PRESENT but unparsable one, which fails CLOSED:
  the window is treated as fully spent and nothing is submitted. Never Default, and
  never a silent clamp downward, which would hand back the budget the corruption
  was hiding.
- F14: the budget comparison uses saturating arithmetic so a corrupt disk-seeded
  fee_spent_in_window_mojos cannot panic under the release profile's
  overflow-checks.
- F9/F10/F12/F13 in progress in the same files.

Refs #3251

* fix(rewards-claim): negate with ! rather than the unimported Not trait

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(rewards-claim): F13 -- correct stale test literals to the folded shortfall

compute_state (types.rs) already reported the folded shortfall
denominator (distributors_claimable + payout_hash_mismatches_this_cycle)
as `claimable` -- that part of F13 landed in f478516a. The two engine.rs
tests asserting this state were written against the pre-fold, un-folded
numbers and never updated, so CI showed the implementation producing the
correct folded value (`claimable: 2`, `claimable: 1`) while the test
literals still expected the stale un-folded one (`claimable: 1`,
`claimable: 0`).

Update both literals -- and the comments describing them -- to the
folded values the F13 fix actually produces. No production code change;
compute_state's predicate and payload were already correct.

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

* feat(rewards-claim): add ClaimOutcome::Faulted variant

Add the seventh ClaimOutcome variant: the type could only say a peer was
legitimately not paid, never that a chain call failed. Carries the launcher
id, a bounded (200 char) copy of the chain port's error text, and whether a
pre-committed fee was reversed, so a reader can tell no money moved.

Engine wiring at the two fault arms (engine.rs:332, :377) follows in the
next commit.

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

* fix(rewards-claim): both fault arms now push ClaimOutcome::Faulted

engine.rs:332 and :377 used to increment `faulted` and discard the
outcome, leaving a definitively-failed claim absent from the outcome
stream -- indistinguishable from a cycle that never touched that
distributor. Both PreBudgetResult::Fault and BudgetPhaseResult::Fault
now carry the chain port's (bounded) error text, and the
submit_initiate_payout failure path also carries the fee it reversed,
so a reader can tell no money moved. The counter stays; it is not a
substitute for the outcome.

7 call sites needed updating: 3 PreBudgetResult::Fault constructions
(reserve_asset_id, own_entry, payout_threshold), 2 BudgetPhaseResult::Fault
constructions (required_fee_mojos, submit_initiate_payout), and the 2
consuming match arms -- exactly the set that was silently discarding a
failure before this change.

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

* test(rewards-claim): a failed submission produces a Faulted outcome

Regression for the rework: reuses F12's fixture (a submission that
definitely never broadcast) to prove both facts from one cycle -- the
outcome exists and carries the reversed fee, and the persisted window
still reflects zero net spend. Also fixes a rustfmt diff on the
PreBudgetResult::Fault variant Clippy's Rustfmt job flagged.

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

* fix(rewards-claim): delete fee_window_poisoned, stop latching self-healing state

Finding 1 (dig-node#594 pass 6): a future-dated clock is self-healing by
construction (`t > now` goes false the moment real time passes it), but the
engine ORed it into `self.fee_window_poisoned` and set that field `true`
permanently -- an RTC glitch or VM resume froze the claim loop forever instead
of until the skew passed. This is the third instance of one mechanism (pass 3
latched ChainSourceUnavailable, pass 4 left a stale cadence-gate `state`), so
the fix removes the FIELD, not just the bug: with no `fee_window_poisoned` on
`ClaimEngine`, `self.fee_window_poisoned = true` is a compile error, not a
convention to remember.

Per-cycle conditions (corrupt + future-dated-clock) now live in a
`CycleConditions` value built fresh at the top of every `run_cycle` from `now`
plus a freshly reloaded `RewardsClaimConfig`, used, and dropped -- never
stored on the engine. `corrupt` is now re-read from disk every cycle too (it
previously latched at construction only), matching what
`ClaimLoopState::PersistedStateCorrupt`'s doc already claimed but the code
never did.

Rewrites the single-cycle f10 regression into a two-cycle test: cycle 1 with a
future-dated clock refuses; cycle 2, after the clock catches up and the
cadence elapses, MUST claim. The old one-cycle version was green whether the
latch bug was present or not.

Refs #594

* fix(rewards-claim): satisfy clippy doc-list indent and rustfmt

Clippy failed with 3x doc_lazy_continuation on the PersistedStateCorrupt
doc comment (types.rs:165-167): continuation lines of a `-` bullet must
be indented under the marker, not left flush. Indent them.

Rustfmt failed on the new fail_reserve_asset_for early-return in
FakeChainPort::reserve_asset_id (engine.rs:888): the Err(...) call
exceeded the line-length limit unwrapped. Let rustfmt wrap it.

Refs #594

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

* test(rewards-claim): red proof for corrupt-then-repaired stale read

Cycle 1 refuses a corrupt fee-window file; the file is then repaired to
valid values with a fully-spent window and a recent completed-cycle
time. Cycle 2 must neither grant a fresh budget nor skip the cadence
gate. Fails against current `with_persisted_fee_window`, which loads
the three fee-window fields once at construction and never refreshes
them from the per-cycle `cfg` -- see engine.rs:149-157, #594.

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

* fix(rewards-claim): resync fee-window fields from disk every cycle

`with_persisted_fee_window` only loaded fee_window_start_unix,
fee_spent_in_window_mojos and last_cycle_completed_at once, at
construction. Once the now-deleted fee_window_poisoned latch stopped
masking it, a file corrupt at construction and repaired later left
those three fields stuck on poisoned()'s None/0/None placeholders --
a fresh budget and a skipped cadence gate, and persist_fee_window then
overwrote the repaired disk values with them.

CycleConditions now carries the three fields from the SAME freshly
reloaded cfg it already used for the corrupt/future-dated check, and
run_cycle copies them onto self before the cadence gate or window-roll
logic runs, but only on a read that is neither corrupt nor future-
dated. This also fixes Finding 2b: future_dated_clock now reads cfg's
own clocks instead of self's stale ones. Corrects the doc claim at the
old lines 236-238 to describe what the code now does for both halves.

Closes #594.

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

* refactor(rewards-claim): make disk the sole store for the fee window

Delete `fee_window_start_unix`, `fee_spent_in_window_mojos` and
`last_cycle_completed_at` from `ClaimEngine`. `run_cycle` already re-reads
`RewardsClaimConfig` fresh every cycle for the corrupt/future-dated check,
so caching a copy on the engine bought nothing and cost exactly the
stale-read defect class F16 just fixed. A local `FeeWindowState`, scoped to
one `run_cycle` call, now threads the in-flight values through
`evaluate_budget_phase`/`uncommit_fee`/`persist_fee_window` instead. With
no field left to cache into, a future `self.fee_window_start_unix = ...`
outside this file is an E0609 compile error, the same enforcement
`fee_window_poisoned`'s removal already has.

No behaviour change: every early return, the corrupt/future-dated fail-
closed path, the cadence gate, the window roll, write-then-spend
pre-commit/uncommit and the per-claim ceiling are unchanged -- only where
the three values live changed.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* chore(release): v0.256.0

Bump dig-node-service to v0.256.0 for release.

This release includes:
- Reward distributor prover loop (#593)
- Peer reward claim loop (#594)
- Reward prover status RPC (#595)

* ci: scope commitlint to PR-introduced commits, fix title suffix check

A develop -> main release-cut PR was linting main..develop, the full
inherited commit range, instead of just the commits it introduces.
Every commit in that range was already linted at its own PR while it
was still mutable; re-linting it at cut time adds no information and
cannot be satisfied once merged (gitlinks and rev-pinned deps make
history immutable). Use commitDepth: 1 on a main-base PR; keep the
full-range lint unchanged for develop-base PRs, where authors can
still fix the commits.

Also fix the PR-title lint's blind spot: GitHub's squash merge lands
"$PR_TITLE (#$PR_NUMBER)" as the commit subject, about eight
characters longer than the title alone, so a title that passes
header-max-length can still produce an over-limit commit subject that
nothing checks. Lint the exact string that will land.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Sep 10, 2026
…unning (#607)

* feat(mirror): persist mirror-bond coin ids (#575)

* chore: open lane for #574

* feat(mirror): persist mirror-bond coin ids so a restart cannot double-create

Bond identity was reconstructed from a live chain scan on every read
(`mirror/observe.rs`), with no persistence of its own. A restart, a cold
replica, or a lagging/flaky chain source all rendered a real, unspent,
confirmed bond as "no bonds" -- and because the in-flight suppression is
keyed on pending/submitted audit entries, a bond whose create had already
CONFIRMED was not suppressed either, so the same short scan that emptied
the read surface also cleared the one thing that would have stopped a
second coin being paid for collateral that already exists (dig-node#574).

Persist the (store, root, epoch) -> coin_id mapping in the EXISTING spend
audit record (spend-audit.jsonl) rather than a new store: a mirror-coin
create already writes store_id + AuditedBond{root, epoch} + amount there,
and the coin id itself becomes durable the moment resolve_landed_spends
confirms it. This adds the one missing piece -- the advertised URL a
create carries -- and a read-side query, confirmed_mirror_bond, that
returns the newest CONFIRMED record naming a triple.

Chain stays authoritative. mirror::local_bond::recheck_missing_bonds
never trusts the record: for a held bond the live scan did not cover, it
asks the record for a candidate coin id, then re-verifies that SPECIFIC
coin against chain via the same independent check (chain_bond_verdict)
that verifies an untrusted peer's claimed bond. Only a fresh `Bonded`
verdict is folded back in, as covered; `Unbonded`/`Unverified` fall
through to an ordinary create, exactly as if no record existed.

Version: 0.254.86 (patch -- per #522 the MSI ProductVersion minor field
is exhausted and the counter lives in patch).

Co-Authored-By: Claude <noreply@anthropic.com>

* test(mirror): prove the recovery wiring end to end through PassRunner::run

Adds two integration-level tests over the REAL pass pipeline, not just the
isolated recheck_missing_bonds unit tests: a bond missing from the live
scan with a chain-reverified durable record is recovered (no double
create, correct Bonded state reported), and the control -- the same
record but chain disproves it -- correctly falls through to an ordinary
create. Together these are the concrete regression test for the
cold-start/lagging-chain-source double-create scenario the ticket asked
to have measured.

Also refactors in_flight_creates to take the already-folded SpendLedger
instead of re-reading the log itself, so PassRunner::run reads the audit
file once per pass and shares it with the new recovery step, and fixes a
doc comment on in_flight_creates that the recovery step would otherwise
have made stale on landing ("a Confirmed create has a coin the chain
observation already sees" is no longer unconditionally true).

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(fmt): wrap long test signatures to satisfy rustfmt

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(clippy): use slice::from_ref instead of cloning for a single-element slice

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(release): bump to v0.254.89

Base branch moved to develop after PR #576 merged there at v0.254.88
(main and develop are currently identical), leaving this branch's
carried-forward .88 as a zero-increment against the new base. Bumped
to the next free integer after fetching and verifying both origin/main
and origin/develop tip at .88.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(peer): count accepted relayed circuits in the connected pool (#579)

serve_accepted_relay_conn served every accepted relayed circuit (full mTLS
auth, full L7 peer RPC) while registering it nowhere, so connected_peers
under-reported every relayed inbound peer -- the relay-leg twin of the
direct-inbound defect #402/#523 already fixed.

adopt_inbound_peer_in_pool now dispatches by TraversalKind: Relayed routes to
dig-gossip's already-published adopt_relayed_inbound_handle (v0.32.0, the rev
this repo already pins), every other tier keeps the unchanged
adopt_direct_inbound_handle path. serve_accepted_relay_conn adopts before
serving and releases after, mirroring the direct listener exactly.

Refs: https://github.com/DIG-Network/dig_ecosystem/issues/3124

* fix(cli): guard the exit-code namespace shared with diga against collisions (#582)

* chore: open lane for #3189

* fix(cli): guard the exit-code namespace shared with diga against collisions

dign and diga deliberately share one process exit-code numbering (dig-app's
outcome.rs says so in its own doc comment), so a number is free only if it
is unoccupied ecosystem-wide. dig-node#407 assigned exit 7 to
NODE_UNREACHABLE by checking only this repo's own table, where 7 genuinely
was free -- and collided with diga's NOT_CONNECTED. A reviewer caught it by
hand; nothing failed automatically.

Adds scripts/check-exit-code-collisions.sh: parses both enums' code()/name()
match arms straight from their own source -- this repo's ExitCode, and a
live fetch of dig-app's outcome.rs at its default branch -- and fails if a
number carries two different names, or if either side draws a number from
the reserved shell signal range (126, 127, 128+N). Ships with an 18-case
hermetic test harness (scripts/tests/check-exit-code-collisions.test.sh)
covering the actual #407 collision shape, arm-order independence, arm-count
mismatch, the reserved-range boundary from both sides, the live-fetch path
itself, and fail-closed behaviour on an empty/missing/unreachable table.

Wires a real (unstubbed) invocation into ci.yml's existing "Release-script
tests" job so a collision introduced by a future PR, on either side, is a
red required check on that PR -- not a note a reviewer has to catch. The
fetch retries twice (2s backoff) since this becomes a required, network-
dependent check; a fetch failure still fails closed after retrying, never
silently passing as "diga has no codes".

Updates SPEC.md 8.4 to point at the mechanical guard instead of leaving
"re-check both tables" as unenforced prose, and records that the
extension's WALLET_WS_ERR.NOT_CONNECTED = -33001 is a separate JSON-RPC
error-code space, not a rival of this one. Adds a doc-comment to the
existing transcribed collision test pointing future readers at the live
script as the authoritative check; the transcription remains as a narrower,
hermetic regression pin for the #407 shape specifically.

No renumbering: every currently-assigned code is unchanged.

Refs #3189

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 corrupted strings (#3190) (#583)

* chore: open lane for #3190

* fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 corrupted strings

Replicates dig-node-service::continuation_guard (dig-node#526/#501) into dig-node-core,
dig-wallet, dig-runtime and dig-chat-protocol, line-for-line apart from crate-specific
constants -- ported rather than reinvented, per dig_ecosystem#3190.

Wiring the guard in surfaced 48 pre-existing lost-continuation defects the ticket's own
"no measured corruption in these four crates" note did not anticipate: 36 in dig-node-core,
12 in dig-wallet, mostly test-assertion prose where a multi-line message lost its `\`
continuation and shipped the source's own indentation as a mid-sentence space run (one
as the worse `\n`-plus-indentation variant). All 48 are collapsed to the single space the
sentence always meant, with surrounding indentation and wording otherwise untouched.

Two lines are real column-alignment, not defects, and get a targeted EXCLUDED_LINE_RANGES
entry on dig-node-core instead of a rewrite: download.rs's `claimed(...)` fixture-table
trailing comments, and net.rs's `label : value` debug-print alignment.

Refs https://github.com/DIG-Network/dig_ecosystem/issues/3190
Refs https://github.com/DIG-Network/dig_ecosystem/issues/3130

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(mirror): detect an IP change daily and reconcile mirror coins to the current advertise URL

Automatic half (D1-D4) of the daily mirror-URL reconcile: derived personal-day offset, two-observation hysteresis, nine ordered gates with K sized as a self-funding prefix before any reclaim, `submitted` never `completed`, audit lines gain `reclaim_reason` + `trigger`.

Gates at b4c09866: loop-reviewer PASS (review 5129272285), loop-security PASS @ 175304e1 (tree byte-identical, `git diff 175304e1 b4c09866` empty), adversarial loop-decider PASS (comment 5567083644; SHOULD-FIX findings ticketed separately).

Refs DIG-Network/dig-node#570
Refs DIG-Network/dig_ecosystem#3203

* feat(serve): content hosting + serve path batch, v0.255.0 (dig_ecosystem#3212)

Nine commits from the #3212 serve-path lane, gated at 899cc68f (reviewer review 5130425808,
security comment 5568662836), plus the single semver bump to 0.255.0 for the develop -> main batch.

- store_id/root case normalised at the CapsuleKey boundary; cache delete targets the matched entry
- tier-0 occupancy reads the eviction-aware ledger
- profile-sync outbound budget in bytes; announcer asked first
- melt confirmation depth on the terminal spend, fail-closed
- EngineWarming (-32002) while the peer tier attaches, never -32004
- window completeness derived from the bytes read
- deps: dig-stun 0.2, chia-query 0.24.3, dig-nat 0.21.2, dig-logging 0.2.2

Refs DIG-Network/dig_ecosystem#3212

* chore: untrack gitnexus-generated agent files (#590)

* chore: untrack gitnexus-generated agent files

These files were generated by `gitnexus analyze` as a side effect of
indexing this repository. They are development-loop private tooling
output, not product code, and carry no secrets. They are removed from
tracking going forward via .gitignore; history is deliberately NOT
rewritten.

Refs #3177

* chore: drop private-repo reference from gitignore comment

The ignore comment named a private repository and an internal issue
number in a public file, which is the same disclosure class this
change set exists to remove; the reference is dropped and the
guidance kept.

* feat(rewards): always-on prover loop engine -- honest liveness, type-enforced self-exclusion, bounded spend (#593)

The always-on reward-prover engine: ~2,000 lines under
`crates/dig-node-core/src/rewards/`, built against the merged `dig-rewards-coin`
SPEC. Library only -- nothing spawns it, and the sole production
`RewardsChainPort` refuses every call, so it cannot spend. Wiring the composed
system is #3265, which carries its own gate.

The epic's premise -- "anytime the process isn't running, rewards are not being
distributed" -- is half wrong, and the false half is the dangerous one. `Sync`,
`NewEpoch` and `InitiatePayout` need no manager authority, so funder downtime does
not stop rewards: it FREEZES THE ENTRY SET while accrual and payouts continue.
Peers that stopped mirroring keep earning; peers that started cannot begin. That
shaped the whole design.

Liveness honesty (SPEC 2.4). The status record carries no `healthy`/`ok`/`up`
boolean and no precomputed staleness, because a wedged loop cannot report its own
wedging -- whatever it last wrote stays there, so a writer-set flag reads true
forever after the failure it exists to reveal. The reader derives staleness from
`last_cycle_completed_at` against `observed_at` and its own clock. A recursive
JSON-key test enforces the absence at every nesting depth; asserting on keys and
never substrings, since `ProverState::Running` legitimately serializes the VALUE
"running". The one legal staleness signal is chain-derived (SPEC 12.4: 48 hours
AND a non-zero reserve, from the singleton's own spend history) and lives on the
distributor read, where a wedged prover cannot fake it.

Self-exclusion is a compile error, not a habit. dig-node#261's lesson is that an
invariant enforced on some paths is not an invariant. `admit` is the single
admission point, checks both SPEC 5.2 coordinates (own peer_id OR a payout puzzle
hash this wallet controls), and mints an `AdmittedPeer` with private fields and no
public constructor -- so `EntryAction::Add` cannot be built by a path that skipped
admission.

A prover's own fault can never strike a peer. `GateError` is a distinct type from
`GateIneligibleReason` and `record_prover_fault` takes `&self`, so SPEC 3.6 clause
4 is enforced by the borrow checker rather than by comment. Without that, a
misconfigured operator -- one missing mirror-collateral epoch ordinal -- would
strike every peer at once and evict its entire 250-entry set in three hours, each
eviction a fee it pays plus a settlement out of its own reserve.

The money bounds are stated where a human reads them (`rewards/mod.rs`): 24
bundles/day, 192 entry actions/day, a fee ceiling of 24x the configured standard
fee, 192 removals/day worst case with 96/day sustained churn. Recorded honestly:
SPEC 6.3's rate bound and fee ceiling are ONE control, not two.

Three gate rounds, every leg fresh-context. Round 3 at this head: reviewer PASS,
adversarial decider RATIFY (leg closed), security CHANGES-REQUIRED on a finding
the decider ratified deliberately -- adjudicated in
https://github.com/DIG-Network/dig-node/pull/593#issuecomment-5601784815 and
carried to #3265 with the remedy corrected, because the proposed fix would have
persisted a poison flag to the very store whose writes were failing.

Found and fixed under gate: a census ordinal off by one in both directions (SPEC
4.6 requires n-1 exactly); an unreachable grace window leaving a named constant
with no reader; a missing `NewEpoch` spend; an absent-ordinal path attributing a
prover fault to peers; and a daily fee ceiling 24x too high because a per-bundle
fee was consumed as a daily ceiling.

Refs DIG-Network/dig_ecosystem#3250

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

* feat: serve dig.getRewardProverStatus at Tier::Control (#595)

* chore: open lane for #3269

Bump dig-rpc-protocol to 0.11.0 (adds dig.getRewardProverStatus and
the other reward RPC methods to the wire).

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

* test(rewards): fail-closed Reward-tier guard + dig-rpc-protocol 0.11 line assertion

- dependency_tree.rs: assert the resolved dig-rpc-protocol line is 0.11 (was 0.10);
  documents the known-red two-version state pending the dig-peer 0.14.0 /
  dig-download 0.23.0 cascade (#3269).
- reward_methods_tier_guard.rs: fail-closed guard over the live Method::ALL
  catalogue -- every Reward-named method must be Tier::Control and not
  peer-reachable, so a fifth reward method added later is caught at the wrong
  tier automatically rather than inheriting a wrong default (binds #3261's rule
  node-side).
- peer.rs: sibling unit test exercising the real (pub(crate)) is_peer_reachable_method,
  since an external integration test cannot see it -- same guard, executed against
  this node's own allowlist rather than only the shared crate's.

Refs #3269

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

* style: remove trailing blank line in reward_methods_tier_guard.rs

* feat(rpc): serve dig.getRewardProverStatus at Tier::Control

Adds the missing handler for PR#595: a new reward_prover_statuses
registry + accessors on Node (empty until #3265 spawns a prover loop,
so the registry read is real, not a stub), a dispatch.rs arm inside
the Method enum match (never the string pre-match), and a
field-for-field mapping from dig-node-core's internal
rewards::state::RewardProverStatus (camelCase-tagged) onto
dig-rpc-protocol 0.11's wire RewardProverStatus (snake_case-tagged
struct, camelCase-tagged ProverState value), widening entry_count
u32 -> u64 explicitly and hex-encoding the three [u8; 32] identity
fields.

An all-zero launcher_id (what an uninitialised registry slot
hex-encodes to) is omitted at this boundary rather than rendered as
a real distributor with a plausible-looking id -- the money-hole
class the dig-rewards-coin driver's adversarial gates found three
times.

Tests (in dig-node-core::lib.rs's existing test module, where the
pub(crate) registry accessors are visible) drive the real dispatch
entry point (handle_rpc -> RpcDispatch::dispatch -> the Method arm)
and assert field-for-field on the serialized JSON body: populated
registry, empty registry (-> {"statuses": []}), zero-id omission,
tier/peer-reachability, enum-match-not-string-prematch, and
launcher_id filtering. The no-health-boolean / no-staleness
assertion is by key set, not substring.

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

* style: rustfmt the reward-prover-status registry + tests

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

* chore(deps): bump dig-download 0.23, dig-peer 0.14, dig-rpc-protocol 0.11

Closes the two-versions-of-dig-rpc-protocol split (#3269): dig-download 0.23.0
and dig-peer 0.14.0 both now resolve dig-rpc-protocol ^0.11, matching
dig-node-core's own dig-rpc-protocol = "0.11.0" line, and dig-node-service's
two 0.10 lines (main dep + dev-dependency restatement for
openrpc_drift_guard.rs) move to 0.11 to match.

Manifests only. cargo update / Cargo.lock intentionally NOT run yet: a fourth
capper, dig-peer-selector ("0.11" in dig-node-core/Cargo.toml), still requires
dig-peer = "^0.13" in every published version through 0.11.1, so the tree
cannot fully resolve until a dig-peer-selector release picks up dig-peer 0.14.
CI will stay red on this commit for that reason, which is expected.

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

* feat(rpc): close dig-rpc-protocol 0.11 cascade + attribute payout figures

Bump dig-peer-selector 0.11 -> 0.12 (the release that moves onto dig-peer
^0.14) and run cargo update, closing the two-versions-of-dig-rpc-protocol
split: Cargo.lock now resolves exactly one dig-rpc-protocol, at 0.11.0,
alongside dig-peer 0.14.0, dig-download 0.23.0, dig-peer-selector 0.12.0.

Add a subject-attribution test and doc comments to
reward_prover_status_to_wire: total_paid_out_base_units and
reserve_base_units are per-distributor totals (this distributor's payout to
ALL its mirrors, and this distributor's own reserve), never the querying
node's own earnings and never summed/cross-attributed across distributors.
This is the defect class a sibling adversarial gate found in dig-app#403's
rewards pane, which rendered a distributor total as one mirror operator's
personal earnings and overstated by up to 250x.

Checked: rewards/state.rs, rewards/port.rs and rewards/mod.rs contain no
Eligible/verdict/payout_hash symbol, so nothing in this mapping surfaces a
persisted EligiblePayoutHash verdict.

Refs #3269

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

* fix(rpc): silence dead_code on register_reward_prover_status pending #3265

Clippy's non-test lib target has no production caller for
register_reward_prover_status yet, because #3265 (the always-on prover loop
that would call it from bring-up) has not landed -- only tests call it today.
cfg_attr(not(test), allow(dead_code)) stands in for that missing caller
until #3265 wires a real one.

Refs #3269

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

* fix(rpc): make the all-zero identity guard non-silent and cover all three fields

Security (blocking) and the adversarial leg both found the same defect in the
zero-launcher_id filter: it checked only launcher_id, so a registration bug
that zeroed store_id or root beside a valid launcher_id would pass through as
a plausible record, and dropping the bad record silently destroyed the
evidence a registration bug happened at all -- SPEC Sec2.4 clause 1's exact
prohibition.

zeroed_identity_fields() now checks launcher_id, store_id AND root. The
dispatch filter still excludes a record with any zeroed field (never renders
an uninitialised slot as a real distributor), but first fires a
tracing::warn! naming which field(s) were zero, so a bad registration is
observable rather than swallowed. Kept isolated in dispatch.rs rather than
woven into the wire mapping, since this belongs at #3265's writer once that
lands.

Replaced get_reward_prover_status_omits_an_all_zero_launcher_id (which
proved the omission but not the observability, and never exercised a zeroed
store_id/root beside a valid launcher_id) with
get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field, covering
both a zeroed launcher_id and a zeroed store_id beside a valid launcher_id,
and asserting the tracing::warn! output via the crate's existing
capture_sync_logs test utility.

Fixed a now-false "Known-red" doc comment on
tests/dependency_tree.rs::the_workspace_carries_exactly_one_module_wire_crate:
the dig-peer 0.14.0 / dig-download 0.23.0 / dig-peer-selector 0.12.0 cascade
already landed in this PR's Cargo.toml/Cargo.lock, so the assertion is green,
not red. Assertion itself untouched -- still exact-version.

Refs #3269

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

* fix(rpc): correct a born-false "shipped dig-app 15.5.0" doc claim

dig-app's latest release is v15.4.0 -- there is no v15.5.0 tag -- and
dig-app#403 (the pane that would consume dig.getRewardProverStatus) is OPEN
and unmerged. Point the doc comment at the real, unmerged consumer instead
so a future reader doesn't take this as evidence a shipped consumer depends
on the guard, which would wrongly discourage relocating it to #3265's writer.

Refs #3269

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

* fix(rpc): correct doc placement, assert the zeroed field by name, treat root as an observation

Three findings from the correctness gate on PR#595 at 134864a9.

1. The zeroed-identity helper's doc block was spliced onto the end of
   reward_prover_status_to_wire's block with no separator, so the wire-mapping
   rationale documented a boolean predicate and the mapping function was left
   with no doc at all. Each doc block now sits above the item it describes.

2. The log assertion `logs.contains("launcher_id")` was a tautology: the warn
   emits launcher_id as a structured field on every fire, so the property the
   guard exists to add -- naming which field was zeroed -- was unasserted.
   Deleting `zeroed_fields = ?zeroed` from the warn left every assertion green.
   The test now asserts the zeroed_fields value itself, which the fixture makes
   exact and disjoint across cases.

3. `root` is an observation, not an identity. A registered prover that has not
   completed its first cycle plausibly has no root, and a writer that zero-inits
   it would have made a healthy prover invisible. A zeroed launcher_id or
   store_id still excludes the record; a zeroed root alone warns and returns.

Refs #3269

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

* fix(rpc): restore zeroed_fields structured field dropped from the pushed warn

The previous commit (080be3df) landed with `zeroed_fields = ?zeroed` missing
from the tracing::warn! call in the GetRewardProverStatus filter -- a
one-line regression introduced while proving the new log assertion goes red
without it, never restored before the commit was made. Without this field
the log line never names WHICH field was zero, so an operator sees only
that something was excluded, and the test asserting `zeroed_fields=[...]`
per case would fail. Restored; all 7 reward-prover-status tests green.

Refs #3269

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

* fix(rpc): split zeroed-field logging by level -- WARN for a missing
identity, DEBUG for a zeroed root

A zeroed launcher_id or store_id is a real registration bug: the record is
excluded and now logs at WARN, naming the exact field(s) via
`zeroed_fields=[...]`. A zeroed root alone is an ordinary pre-first-cycle
state, not a fault: the record is still returned, and now logs at DEBUG
instead of WARN, so an operator polling this endpoint sees warn-level
volume proportional to real registration bugs, not to every
not-yet-cycled prover on every poll.

Updated the doc comments on `zeroed_fields`, the dispatch filter and the
test to describe the level split, and extended the regression test to
assert on level (WARN vs DEBUG) as well as the `zeroed_fields` value.
Proved both directions: flipping the DEBUG branch back to WARN turns the
test red on the level assertion; flipping the field-name assertion back to
a bare `contains("launcher_id")` would have passed unconditionally (the
prior tautology) and is no longer possible since the assertions now pin
`zeroed_fields=[...]` plus the level string.

Refs #3269

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat(rewards): peer-side claim loop -- watch distributors, claim on cadence (#3251) (#594)

* feat(rewards): peer claim loop skeleton -- discovery, cadence, config, claim port

* test(rewards): write all twelve acceptance tests for the peer claim loop

* feat(rewards): wire the seven rewards_claim submodules into the crate

mod.rs declared no submodules, so types.rs/port.rs/config.rs/cadence.rs/
parser.rs/engine.rs/hints.rs (1,435 lines, 25 tests) were never part of the
crate and never compiled. Declare them and re-export the public surface.

* style(rewards): cargo fmt the rewards_claim submodules

* chore(deps): bump dig-node-control-interface 0.33->0.35, dig-rpc-protocol 0.10->0.11.0

dig-rpc-protocol 0.11.0 is merged and tagged upstream; the other dig-*/chia-*
deps of dig-node-service were already at the latest permitted-by-caret version
in Cargo.lock. crates/dig-node-core/Cargo.toml is untouched (#3250's file set).

* chore(deps): revert dig-node-control-interface and dig-rpc-protocol bumps

Both create a duplicate-version split in this PR's scope and neither can be
closed without editing a sibling crate's manifest this lane does not own:

- dig-rpc-protocol 0.11.0 duplicates against dig-node-core/Cargo.toml:194
  ("0.10.2"), which is #3250's live file set (dig-node#593).
- dig-node-control-interface 0.35.0 duplicates against
  dig-wallet/Cargo.toml:81 ("0.33"), a sibling crate this lane does not own;
  the observed Clippy break (BalanceAsset/Asset type-identity mismatch,
  missing url_reconcile/url_current/urls fields) came from THIS duplicate,
  not from dig-rpc-protocol.

Both belong to their own sequenced dep-bump unit of work, not this ticket.

* fix(rewards-claim): fault laundering, permanent no-entry blacklist, fee ceiling magnitude

Three independent gates on dig-node#594 (51516e62) found four logic defects; this
addresses A, B and C per the corrected fix brief (D is documented only, not fixed
here per the brief's own instruction).

Defect A -- the anti-silence surface laundered every real fault into `Nominal`:
- A1: `fault_reported` had no fault-bearing ClaimLoopState to fall through to, so a
  chain adapter erroring every cycle read `Nominal` forever. Added
  `ClaimLoopState::Faulted { cycles }`, outranking Nominal/ClaimableButNotClaiming,
  under ChainSourceUnavailable.
- A2: inverted the test that asserted A1's bug as correct behaviour.
- A3: `ClaimableButNotClaiming` compared a per-cycle snapshot
  (`distributors_claimable`) against a lifetime-cumulative counter
  (`claims_submitted`), so it latched healthy forever after one lifetime success.
  Added `claims_submitted_this_cycle` (per-cycle) as the correct comparand; kept
  `claims_submitted` as a cumulative counter.
- A4: `last_discovery_at`/`last_cycle_at` were stamped even on a failed discovery
  or an all-faulted cycle, destroying the staleness signal a reader depends on.
  Now only stamped on success; added `last_attempt_at` to prove liveness
  separately. `fault_reported` and `distributors_faulted` now reset per cycle
  instead of latching for the process's lifetime.

Defect B -- "terminal, stop retrying" was implemented as a process-lifetime
blacklist (`terminal_no_entry: HashSet<Bytes32>`, never cleared). That blocked
SPEC 12.5 clause 2's re-entry path (evicted, re-challenged, re-admitted never
claims again) and permanently punished a peer that discovered a distributor
before the funder's AddEntry landed. Removed the blacklist entirely -- `own_entry`
is a cheap chain read, re-issued every cycle for every candidate, matching clause
3's "never cache across cycles". `NoEntrySlot` is now a per-cycle observation, not
a lifetime sentence.

Defect C -- the fee ceiling didn't bind anything and there was no aggregate cap:
- C1: default `CLAIM_FEE_CEILING_MOJOS_DEFAULT` lowered from 1_000_000_000
  (transplanted from `MIRROR_SPEND_FEE_CEILING_MOJOS`, sized for a mirror-coin
  spend) to 200_000 -- 2x the observed routine Chia fee range (5,000-100,000
  mojos), so it actually binds instead of leaving 4-5 orders of magnitude of
  slack.
- C2: added a per-cycle aggregate fee budget
  (`max_cycle_fee_budget_mojos`, default 10x the per-claim ceiling) checked
  across all claims in a cycle, closing the attacker-cost gap where funding K
  distributors could force a victim to spend K x the per-claim ceiling per cycle.
  New `ClaimOutcome::SkippedCycleBudgetExhausted`.

Tests: rewards_claim test count 26 -> 37 (11 new: repeated_discovery_faults_never_
read_as_nominal, failed_discovery_leaves_last_discovery_at_unchanged, a_reported_
fault_surfaces_as_faulted_not_nominal, a_lifetime_submission_does_not_mask_a_
later_cycle_that_submits_nothing, no_entry_slot_then_re_admitted_produces_a_claim_
on_the_later_cycle, distributors_each_under_ceiling_do_not_collectively_exceed_
the_cycle_budget, the_default_per_claim_ceiling_actually_binds_a_routine_fee, plus
renamed/rewritten no_entry_slot_is_non_terminal_and_re_checked_every_cycle).

Refs #3251

* fix(rewards-claim): refuse a claim entry for the wrong payout puzzle hash

CI fix: cadence.rs's RewardsClaimConfig literal was missing the
max_cycle_fee_budget_mojos field added in the previous commit (E0063,
caught by CI's Clippy/Test jobs -- the local cargo check for this
workspace is too slow to use as the compiler here).

Defect E (security-gate finding, folded in before this pass closes):
submit_initiate_payout was called with entry.payout_puzzle_hash -- whatever
the chain port handed back -- with no check against this node's own
own_payout_puzzle_hash. UnavailableClaimChainPort is the only production
adapter today so nothing can exploit this yet, but the whole point of the
ClaimChainPort seam is that #3249 swaps in a real adapter with nothing
above it changing, so deferring this would ship the landmine live with no
review pass watching for it. Added an equality guard before the spend:
a mismatch refuses to submit, counts
(ClaimStatus::claims_refused_payout_mismatch), surfaces its own named
outcome (ClaimOutcome::PayoutPuzzleHashMismatch), and is reported as a
fault (a divergent entry means the port is confused or hostile, not that
there is nothing to claim) -- never corrected by substituting our own
hash and proceeding.

Defect D: documented, not wired, per instruction -- added the "not yet
wired into node startup" paragraph to mod.rs's module doc (the PR body
carries the same paragraph) so the next reader arrives at the caveat in
the code, not only in a merged PR description.

Refs #3251

* fix(rewards-claim): B1 -- ClaimableButNotClaiming is a magnitude comparison, not a zero-test

submitted_this_cycle < claimable_this_cycle now fires the anti-silence state, carrying
the shortfall as ClaimableButNotClaiming { claimable, submitted }. The previous
submitted_this_cycle == 0 zero-test let one submission mask any number of same-cycle
skips (claimable=10, submitted=1 read Nominal).

Also folds in B3's precedence fix (ChainSourceUnavailable > Faulted >
ClaimableButNotClaiming > Idle > Nominal) and the per-distributor
payout_hash_mismatches_this_cycle counter so a per-distributor fault can no longer
pin the cycle-wide Faulted state, plus R2's rename of terminal_no_entry_slot to
no_entry_slot_this_cycle now that it is no longer terminal.

* fix(rewards-claim): B2/B3 -- value-ordered budget with rotation, per-distributor fault isolation

B2: run_cycle now splits into a pre-budget phase (asset/entry/hash/threshold checks,
producing the claimable set) and a budget phase, ordering the claimable set by accrued
value descending before applying the fee ceiling and cycle budget. Dust distributors
(low accrued value regardless of attacker-controlled fee) now sort last and are the
ones the budget drops, closing the claim-suppression attack where ten high-fee dust
distributors could consume the whole cycle budget ahead of a victim's real earnings.
A rotation_cursor tie-breaks only WITHIN equal-accrued-value tiers so a genuinely
tied honest tail that exceeds one cycle's budget every cycle still rotates through
and is eventually served, rather than dropping the same tail forever.

B3: the payout-hash mismatch check in evaluate_pre_budget now increments the
per-distributor payout_hash_mismatches_this_cycle counter instead of setting
fault_reported, so one hostile or buggy entry can no longer pin the cycle-wide
Faulted state and bury ClaimableButNotClaiming for every other healthy distributor.

R2: terminal_no_entry_slot -> no_entry_slot_this_cycle throughout.

* fix(rewards-claim): R5 -- document not-yet-wired loop; persist B2 rotation cursor

An operator reading their own rewards-claim.json and seeing enabled: true has no way
to know from that file alone that no startup path constructs a ClaimEngine yet
(#3268) -- mod.rs said so, but a config file reader does not arrive at a module doc.

Also gives RewardsClaimConfig a rotation_cursor: Option<Bytes32> field so B2's
tie-break cursor survives a save/load round-trip -- an in-memory-only cursor resets
on every restart, which would starve a legitimately tied honest tail forever on any
node that restarts daily.

* fix(rewards-claim): clippy collapsible-match + SPEC v0.1.3 wording refresh

Collapses the nested if into the outer match arm in run_cycle (clippy::collapsible_match).
Also refreshes NoEntrySlot / no_entry_slot_this_cycle doc comments now that
dig-rewards-coin v0.1.3's SPEC §12.5 amendment is merged and tagged: absence is
terminal for one claim attempt only, never for the distributor, must not be cached,
and must not accumulate into a permanent exclusion set -- confirming rather than
diverging from the re-read-every-cycle behaviour already implemented.

* fix(rewards-claim): add missing rotation_cursor field in cadence.rs test literal

Struct literal in the cadence test module was not updated when RewardsClaimConfig
gained rotation_cursor (R5 commit) -- CI's Clippy/Test jobs caught the missing field
(E0063) that a local cargo check could not (killed by memory pressure before this
workspace-wide build completed).

* fix(rewards-claim): F1 -- ChainSourceUnavailable is per-cycle, never a latch

compute_state() compared against self.state -- last cycle's OWN computed
output -- so once any cycle took an Unavailable port path, every later
cycle re-asserted ChainSourceUnavailable forever, even after the chain
came back and real claims were submitting. A node still syncing, or one
dropped connection, was enough to trip this permanently.

Add ClaimStatus::chain_unavailable_this_cycle, reset to false at the top
of every run_cycle and set true only on a cycle that actually took the
Unavailable path; compute_state now reads that flag instead of
self.state, so the reading is live again.

Test: a_transient_unavailable_cycle_does_not_latch_state_for_the_rest_of_the_process
(engine.rs) drives cycle 1 unavailable, cycle 2 healthy with a
submission, and asserts cycle 2 reads Nominal. Plus a compute_state-level
regression in types.rs.

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

* fix(rewards-claim): F3/F4/F5 -- fix stale counters, dedup candidates, correct version doc

F3: reset EVERY per-cycle counter (distributors_known/with_own_entry/
claimable/faulted, claims_submitted_this_cycle, no_entry_slot_this_cycle)
at the TOP of run_cycle, before any early return. The three
ChainUnavailable early-return paths skip the end-of-function assignment
block entirely, so a cycle that hit one used to leave the PRIOR cycle's
counts sitting on self.status while last_attempt_at stamped fresh for
THIS cycle -- a stale count under a fresh timestamp, exactly what SPEC
§2.4's staleness reasoning forbids. types.rs's doc sentence for
no_entry_slot_this_cycle now correctly says it is dated by
last_attempt_at (the field stamped unconditionally every cycle), not
last_cycle_at.

F4: dedup `candidates` by launcher id before phase 2. A real adapter
scanning §1.3 launch comments across every (store_id, root) this node
mirrors can plausibly return the same launcher id twice; without dedup
phase 2 would evaluate it twice and submit InitiatePayout twice against
one entry slot in one cycle -- the second spend is invalid but the fee
is paid anyway.

F5: dig-rewards-coin is v0.1.3, published on crates.io -- correct the
stale "v0.1.1" module-doc claim.

Tests: a_chain_unavailable_cycle_does_not_leave_prior_cycles_counters_stale
(F3), a_duplicated_launcher_id_submits_exactly_once (F4).

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

* fix(rewards_claim): fold payout-hash mismatches into the shortfall predicate (F2)

A payout-hash mismatch never enters the eligible set, so it was counted in
NEITHER distributors_claimable NOR claims_submitted_this_cycle -- the
shortfall lived in neither term of compute_state's magnitude comparison.
All-K-distributors mismatching therefore read Nominal (falsely healthy).

Fold payout_hash_mismatches_this_cycle into the comparison's denominator:
submitted < claimable + mismatches. The result is ClaimableButNotClaiming
(a shortfall), never the cycle-wide Faulted -- Defect B3 stays fixed.

Inverts the assertion at what was engine.rs:1305
(a_payout_mismatch_never_sets_the_cycle_wide_fault_or_masks_other_distributors):
it previously asserted ClaimLoopState::Nominal across three cycles of an
ongoing mismatch, which pinned the defect as intended behaviour (an
A2-class test). It now asserts ClaimableButNotClaiming { claimable: 1,
submitted: 1 }.

Adds all_distributors_mismatching_is_a_shortfall_not_nominal, covering the
brief's exact "what if every distributor refuses for the same reason" case.

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

* fix(rewards-claim): F1/F3 -- AtomicU32 in fake ports, not Mutex, keeps discovery Send

CI's Clippy job (the compiler for this crate, per brief) caught it: holding
a std::sync::MutexGuard across the .await in FlakyThenHealthyPort and
HealthyThenUnavailablePort's discover_distributors made the returned future
not Send, which #[async_trait]'s generated trait signature requires.

Neither fake needs a lock -- each holds one call counter, incremented once
per call, never read-modify-written across an await point. AtomicU32's
fetch_add removes the guard (and the Send bound violation) entirely.

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

* fix(rewards-claim): F7 -- persist the fee window and cadence gate across restart

The per-cycle aggregate fee budget and the 24h cadence clock both lived only
in memory: `spent_this_cycle_mojos` was a `run_cycle` local and nothing on
disk recorded a completed cycle. Every fresh process got a full
`max_cycle_fee_budget_mojos` and an empty cadence clock, so a node stuck in
a crash-restart loop could spend unbounded XCH on fees, one full budget per
restart.

Adds three `#[serde(default)]` fields to `RewardsClaimConfig`
(`fee_window_start_unix`, `fee_spent_in_window_mojos`,
`last_cycle_completed_at`) and a new opt-in `ClaimEngine::with_persisted_fee_
window(dir, cadence_seconds)` that:
- restores the window/cadence state from `dir` at construction,
- refuses to start a cycle until the cadence has elapsed since the last
  completed one,
- rolls a fresh budget window only once the cadence has elapsed since it
  opened, otherwise keeps enforcing the budget against the persisted spend,
- persists the spend BEFORE every chain submission (write-then-spend), never
  batched to cycle end, and persists the completed-cycle timestamp when a
  cycle finishes.

Engines that never call `with_persisted_fee_window` (every pre-F7 test) are
unaffected -- this is additive, opt-in state beside the existing rotation
cursor, not a change to B2's value-ordering or rotation mechanism.

`ClaimStatus`'s own counters stay in-memory on purpose (observability, meant
to reset on restart); only the spend bound and the cadence gate persist.

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

* fix(rewards-claim): F7 -- update cadence.rs test literal for new persisted fields

The three new persisted RewardsClaimConfig fields (fee_window_start_unix,
fee_spent_in_window_mojos, last_cycle_completed_at) broke this crate's only
remaining full struct literal outside config.rs/engine.rs's own test
modules -- E0063 missing fields, caught by CI's Clippy job. Switched to
..RewardsClaimConfig::default() so the next added field cannot break this
literal again, the same fix already applied once before for rotation_cursor.

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

* fix(rewards-claim): F8/F14 -- atomic state write, fail closed on a corrupt window

Salvaged from a lane killed by a weekly cap before it could commit. Uncompiled at
commit time; CI is the compile signal.

Covers the fourth gate pass findings on the F7 persisted spend bound:

- F8: RewardsClaimConfig::save_to now writes atomically (temp file + rename in the
  same directory), reusing the pattern already used by mirror/reconcile_state.rs
  for the same class of state. load_from distinguishes an ABSENT file (clean first
  run, defaults are correct) from a PRESENT but unparsable one, which fails CLOSED:
  the window is treated as fully spent and nothing is submitted. Never Default, and
  never a silent clamp downward, which would hand back the budget the corruption
  was hiding.
- F14: the budget comparison uses saturating arithmetic so a corrupt disk-seeded
  fee_spent_in_window_mojos cannot panic under the release profile's
  overflow-checks.
- F9/F10/F12/F13 in progress in the same files.

Refs #3251

* fix(rewards-claim): negate with ! rather than the unimported Not trait

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(rewards-claim): F13 -- correct stale test literals to the folded shortfall

compute_state (types.rs) already reported the folded shortfall
denominator (distributors_claimable + payout_hash_mismatches_this_cycle)
as `claimable` -- that part of F13 landed in f478516a. The two engine.rs
tests asserting this state were written against the pre-fold, un-folded
numbers and never updated, so CI showed the implementation producing the
correct folded value (`claimable: 2`, `claimable: 1`) while the test
literals still expected the stale un-folded one (`claimable: 1`,
`claimable: 0`).

Update both literals -- and the comments describing them -- to the
folded values the F13 fix actually produces. No production code change;
compute_state's predicate and payload were already correct.

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

* feat(rewards-claim): add ClaimOutcome::Faulted variant

Add the seventh ClaimOutcome variant: the type could only say a peer was
legitimately not paid, never that a chain call failed. Carries the launcher
id, a bounded (200 char) copy of the chain port's error text, and whether a
pre-committed fee was reversed, so a reader can tell no money moved.

Engine wiring at the two fault arms (engine.rs:332, :377) follows in the
next commit.

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

* fix(rewards-claim): both fault arms now push ClaimOutcome::Faulted

engine.rs:332 and :377 used to increment `faulted` and discard the
outcome, leaving a definitively-failed claim absent from the outcome
stream -- indistinguishable from a cycle that never touched that
distributor. Both PreBudgetResult::Fault and BudgetPhaseResult::Fault
now carry the chain port's (bounded) error text, and the
submit_initiate_payout failure path also carries the fee it reversed,
so a reader can tell no money moved. The counter stays; it is not a
substitute for the outcome.

7 call sites needed updating: 3 PreBudgetResult::Fault constructions
(reserve_asset_id, own_entry, payout_threshold), 2 BudgetPhaseResult::Fault
constructions (required_fee_mojos, submit_initiate_payout), and the 2
consuming match arms -- exactly the set that was silently discarding a
failure before this change.

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

* test(rewards-claim): a failed submission produces a Faulted outcome

Regression for the rework: reuses F12's fixture (a submission that
definitely never broadcast) to prove both facts from one cycle -- the
outcome exists and carries the reversed fee, and the persisted window
still reflects zero net spend. Also fixes a rustfmt diff on the
PreBudgetResult::Fault variant Clippy's Rustfmt job flagged.

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

* fix(rewards-claim): delete fee_window_poisoned, stop latching self-healing state

Finding 1 (dig-node#594 pass 6): a future-dated clock is self-healing by
construction (`t > now` goes false the moment real time passes it), but the
engine ORed it into `self.fee_window_poisoned` and set that field `true`
permanently -- an RTC glitch or VM resume froze the claim loop forever instead
of until the skew passed. This is the third instance of one mechanism (pass 3
latched ChainSourceUnavailable, pass 4 left a stale cadence-gate `state`), so
the fix removes the FIELD, not just the bug: with no `fee_window_poisoned` on
`ClaimEngine`, `self.fee_window_poisoned = true` is a compile error, not a
convention to remember.

Per-cycle conditions (corrupt + future-dated-clock) now live in a
`CycleConditions` value built fresh at the top of every `run_cycle` from `now`
plus a freshly reloaded `RewardsClaimConfig`, used, and dropped -- never
stored on the engine. `corrupt` is now re-read from disk every cycle too (it
previously latched at construction only), matching what
`ClaimLoopState::PersistedStateCorrupt`'s doc already claimed but the code
never did.

Rewrites the single-cycle f10 regression into a two-cycle test: cycle 1 with a
future-dated clock refuses; cycle 2, after the clock catches up and the
cadence elapses, MUST claim. The old one-cycle version was green whether the
latch bug was present or not.

Refs #594

* fix(rewards-claim): satisfy clippy doc-list indent and rustfmt

Clippy failed with 3x doc_lazy_continuation on the PersistedStateCorrupt
doc comment (types.rs:165-167): continuation lines of a `-` bullet must
be indented under the marker, not left flush. Indent them.

Rustfmt failed on the new fail_reserve_asset_for early-return in
FakeChainPort::reserve_asset_id (engine.rs:888): the Err(...) call
exceeded the line-length limit unwrapped. Let rustfmt wrap it.

Refs #594

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

* test(rewards-claim): red proof for corrupt-then-repaired stale read

Cycle 1 refuses a corrupt fee-window file; the file is then repaired to
valid values with a fully-spent window and a recent completed-cycle
time. Cycle 2 must neither grant a fresh budget nor skip the cadence
gate. Fails against current `with_persisted_fee_window`, which loads
the three fee-window fields once at construction and never refreshes
them from the per-cycle `cfg` -- see engine.rs:149-157, #594.

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

* fix(rewards-claim): resync fee-window fields from disk every cycle

`with_persisted_fee_window` only loaded fee_window_start_unix,
fee_spent_in_window_mojos and last_cycle_completed_at once, at
construction. Once the now-deleted fee_window_poisoned latch stopped
masking it, a file corrupt at construction and repaired later left
those three fields stuck on poisoned()'s None/0/None placeholders --
a fresh budget and a skipped cadence gate, and persist_fee_window then
overwrote the repaired disk values with them.

CycleConditions now carries the three fields from the SAME freshly
reloaded cfg it already used for the corrupt/future-dated check, and
run_cycle copies them onto self before the cadence gate or window-roll
logic runs, but only on a read that is neither corrupt nor future-
dated. This also fixes Finding 2b: future_dated_clock now reads cfg's
own clocks instead of self's stale ones. Corrects the doc claim at the
old lines 236-238 to describe what the code now does for both halves.

Closes #594.

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

* refactor(rewards-claim): make disk the sole store for the fee window

Delete `fee_window_start_unix`, `fee_spent_in_window_mojos` and
`last_cycle_completed_at` from `ClaimEngine`. `run_cycle` already re-reads
`RewardsClaimConfig` fresh every cycle for the corrupt/future-dated check,
so caching a copy on the engine bought nothing and cost exactly the
stale-read defect class F16 just fixed. A local `FeeWindowState`, scoped to
one `run_cycle` call, now threads the in-flight values through
`evaluate_budget_phase`/`uncommit_fee`/`persist_fee_window` instead. With
no field left to cache into, a future `self.fee_window_start_unix = ...`
outside this file is an E0609 compile error, the same enforcement
`fee_window_poisoned`'s removal already has.

No behaviour change: every early return, the corrupt/future-dated fail-
closed path, the cadence gate, the window roll, write-then-spend
pre-commit/uncommit and the per-claim ceiling are unchanged -- only where
the three values live changed.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat(rewards): chain port + listRewardDistributors (unit 2) (#604)

* chore: open lane for #3269 (unit 2 -- rewards chain port + listRewardDistributors)

* chore(rewards): add dig-rewards-coin 0.2 dep; record blocked-reader finding

dig-rewards-coin 0.2.0 is published but ships no chain reader (its own
state.rs module doc: SPEC 12.1's read_distributor is withheld pending
DIG-Network/dig_ecosystem#3267). Separately, no registry in this codebase
records which distributors this node funds. A "real" RewardsChainPort
adapter over 0.2.0 therefore has no honest way to answer any of the four
trait methods with live data yet -- reimplementing read_distributor or
inventing a funded-distributor registry would be exactly the unreviewed
money-shape guess kernel invariant 6 says to escalate instead of build.
UnavailableChainPort remains the only production adapter; port.rs records
the finding for the next unit.

Refs #3269

* docs(rewards): revert dep add, name both blockers with evidence in port.rs

Per L1 direction: an unused dig-rewards-coin dep with no consumer is inert
weight and would want whichever version ships the reader (0.3.0+, PR#6 open
against DIG-Network/dig-rewards-coin), not 0.2 -- so it's reverted here and
belongs in the unit that actually consumes it.

Expanded the port.rs module doc to name both blockers explicitly with what
was read (state.rs:1-31, #3267, the open reader PR) and the negative grep
that found no funder-ownership registry anywhere in the tree, plus why
serving dig.listRewardDistributors through UnavailableChainPort was
considered and rejected (false capability signal; the exact "dispatch
surface with no function behind it" pattern dig-node#593 was the last PR
allowed to land on).

No RewardsChainPort adapter, no Node wiring, no dispatch arm -- all three
reward methods stay -32601 pending #3267 and a funder-ownership registry
(parallel tickets, both required).

Refs #3269

* feat(rewards): durable funder-ownership registry (identity only) (#606)

* feat(rewards): durable funder-ownership registry (identity only)

Records WHICH reward distributors this node funds -- launcher id plus the
store id when the funding act knew it -- and nothing else. No amount can
be recorded: every money figure here is chain-derived and goes stale, and
dig_ecosystem#3286's wrapping u64 share multiply means a figure crossing
this boundary can already be wrong. Durable storage would make it
permanent.

Persistence mirrors rewards_claim::engine::ClaimEngine: an optional state
directory (absent = inert, so tests and default builds need no disk),
atomic write, and a corrupt record is never overwritten. The set is never
cached on the registry -- every read re-reads the file -- so no transient
state lives on the struct across calls (the engine's F16/F18 discipline).

The read outcome is closed and distinguishes funds-nothing from every
unknown: NotConfigured (no state dir / dir missing / nothing written yet),
PersistedStateCorrupt and IoFailed. A corrupt record is quarantined by
COPY and left in place, so the next read is corrupt too rather than
decaying into an empty list -- SPEC 2.4 clause 1 in the place it costs
most, since an empty dig.listRewardDistributors tells an operator it funds
no distributors.

Node carries it in a OnceLock slot with pub(crate) accessors, mirroring
mirror_pointers and reward_prover_statuses. Nothing installs it in
production yet: no dig-node code funds a distributor, and the startup
wiring belongs to dig_ecosystem#3268, so the slot is marked the same way
register_reward_prover_status is.

Refs #3285

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

* fix(rewards): drop a duplicated funded_distributors initializer

Two test-only `Node` literals got the slot twice (E0062), because the
inserted line's own indentation made the wider-indented site match twice.

Refs #3285

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(rewards): wire the peer claim loop onto a cadence driver from real startup (#3268) (#605)

* feat(rewards): wire the peer claim loop onto a cadence driver from real startup (#3268)

The peer reward-claim engine shipped complete and tested in #594 but INERT:
nothing constructed it, so the 86400s cadence never fired while
`rewards_claim.enabled` defaulted to `true` -- a config asserting a subsystem
is on while nothing runs.

`rewards_claim/driver.rs` is a SCHEDULER, not a chain adapter: it derives this
node's own payout puzzle hash, loads `RewardsClaimConfig`, builds a
`ClaimEngine` against the only production port that exists
(`UnavailableClaimChainPort`, until #3249 lands a real one) and drives
`run_cycle` every `cadence_seconds + jitter`, jitter drawn from the OS CSPRNG.
`server.rs`'s `serve_with_shutdown` makes exactly one call into it, beside
`self_heal::spawn_driver_if_service()`.

`enabled = true` now means: a background task exists, drives a counted cycle
per interval, and its outcome is readable in-process as a NAMED state. With
`UnavailableClaimChainPort` every cycle honestly reports
`ChainSourceUnavailable` -- the gap is loud instead of silent.

Anti-silence: `ClaimLoopHandle` carries a monotonic `cycles_driven` counter
alongside the status, because `Idle` before the first cycle is correct and
honest, so status alone cannot tell "scheduler never fired" from "nothing was
claimable". The gate takes an INJECTED handle rather than reading the
process-wide singleton, so `ClaimDriverRefusal::{Disabled, ChainSyncDisabled,
NoOperatorWallet}` and "spawned but never ticked" are four pairwise-distinct
readings a test asserts in-process.

Nothing goes on the wire: no RPC method, dispatch row, handler or OpenRPC
entry. `ClaimStatus` stays off the wire until #3249's real adapter lets the
status surface be re-derived against it.

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

* fix(rewards): silence the deliberately-ignored fake-port argument (#3268)

`OneDistributorPort::own_entry` ignores the puzzle hash the engine passes in
on purpose -- the fake always returns the entry keyed to `entry_keyed_to` so
the ENGINE's own comparison is what decides claimable vs. refused. Named it
`_payout_puzzle_hash` (clippy `-D unused-variables`) and moved the rationale
onto the parameter, where the next reader meets it.

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

* test(rewards): close the untested joint between the claim gate and the drive loop (#3268)

`decide_claim_driver` was tested and `drive` was tested, but the production
body joining them -- load the config from the state dir, derive the engine,
reach `drive` -- was exercised by nothing. That is the exact shape of #594,
which shipped a complete, fully-tested and entirely inert claim engine: had
this body returned early, built the engine wrong, or never reached `drive`,
every test on this change would still have passed and a real node would still
never claim.

Split `run_claim_driver` on the same `load` / `load_from` pattern the config
itself uses: `run_claim_driver_in(state_dir, own_payout_puzzle_hash, port,
handle)` holds the whole body and is generic over the port, and
`run_claim_driver` is reduced to the wallet-derivation adapter that cannot be
reached from a test. Adds two tests through the real body: counted cycles from
a written config (zero before the interval, exactly one per interval after),
and `UnavailableClaimChainPort` reporting `ChainSourceUnavailable` by name on a
driven cycle -- proving the production adapter path is reached, not only a fake.

No behaviour change: same config, same engine construction, same port.

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

* fix(rewards): settle before advancing, and keep the wrapped assertion out of rustfmt's reach (#3268)

Two repairs to the new composition tests:

- The `ChainSourceUnavailable` test advanced the paused clock before the
  spawned body had reached its first `sleep`, so the timer was not yet
  registered and the advance bought no cycle at all -- it read zero cycles, not
  a driven one. A `settle()` first, mirroring the counted-cycles test.
- rustfmt rejoined a `\`-continued assertion message into one line, leaving 14
  literal spaces mid-sentence and tripping the repo's own
  `continuation_guard`. `concat!` states the wrap explicitly, so no formatter
  pass can reintroduce the run.

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

* feat(rewards): emit a per-cycle event so the claim loop has a reader (#3268)

The adversarial gate blocked #605 on this: the PR justified itself by making
an inert subsystem loud, but nothing in the shipped binary could hear it.
ClaimLoopHandle had no caller outside driver.rs tests, drive() emitted no
event, and all three tracing calls fired only on paths where the loop does
NOT run -- so on the default path (enabled=true, chain sync on) the
observable output was identical to before the PR: silence. Today that
silence covers a permanent ChainSourceUnavailable; after #3249 it would also
cover Faulted, PersistedStateCorrupt and ClaimableButNotClaiming.

log_cycle() now names the state and the cycle count after every cycle --
info for Nominal, warn for everything else, because "this peer is earning
nothing and here is why" is a warning, not routine chatter. Tested by
capturing the subscriber output rather than asserting the call site exists,
since this ticket exists because a guarantee that cannot be observed in a
running node is not a guarantee.

Refs DIG-Network/dig_ecosystem#3268

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

* fix(rewards): stop a u64::MAX jitter bound panicking the claim driver

`OsJitter::jitter_seconds` computed `bound + 1` for its modulus. `jitter_seconds`
comes from the node's persisted `rewards_claim` config and is not clamped, so a
config carrying `u64::MAX` overflow-panicked inside the detached claim-driver
task -- which has no restart and emits no further log output, so the claim loop
would die silently for the rest of the process lifetime.

`saturating_add(1)` keeps the draw within `0..=bound` for every input; the
composed `next_interval_seconds` range is unchanged.

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

* fix(rewards): sanitize the claim schedule so no config value silently disables the loop

`next_interval_seconds` saturates instead of panicking, so a persisted
`jitter_seconds = u64::MAX` no longer crashes the driver -- it schedules the
next cycle ~585 billion years out. The claim loop then never fires again: no
cycle, no `log_cycle` line, and a permanent, reassuring `0` cycle count. That is
#594's inert-but-green shape reopened one level up, in the config file.

`run_claim_driver_in` now sanitizes both schedule fields where it reads them,
before either reaches the engine's fee window or `drive`:

- `CLAIM_SCHEDULE_SECONDS_MAX = 31 * 24 * 60 * 60` (31 days) -- above every
  documented default (86,400s cadence, 3,600s jitter) and above "claim
  monthly", while excluding everything that means never.
- out of range (or a zero cadence, which would busy-loop) substitutes the
  published default and emits `tracing::warn!` naming the field, the rejected
  value and the substituted one. Nothing is accepted silently.

`config.rs` is untouched: it keeps reporting what is on disk.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(release): v0.257.0 -- the reward distributor lifecycle starts running

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Sep 11, 2026
* chore(release): v0.256.0 -- reward distributor prover, peer claim loop, prover-status RPC (#602)

* feat(mirror): persist mirror-bond coin ids (#575)

* chore: open lane for #574

* feat(mirror): persist mirror-bond coin ids so a restart cannot double-create

Bond identity was reconstructed from a live chain scan on every read
(`mirror/observe.rs`), with no persistence of its own. A restart, a cold
replica, or a lagging/flaky chain source all rendered a real, unspent,
confirmed bond as "no bonds" -- and because the in-flight suppression is
keyed on pending/submitted audit entries, a bond whose create had already
CONFIRMED was not suppressed either, so the same short scan that emptied
the read surface also cleared the one thing that would have stopped a
second coin being paid for collateral that already exists (dig-node#574).

Persist the (store, root, epoch) -> coin_id mapping in the EXISTING spend
audit record (spend-audit.jsonl) rather than a new store: a mirror-coin
create already writes store_id + AuditedBond{root, epoch} + amount there,
and the coin id itself becomes durable the moment resolve_landed_spends
confirms it. This adds the one missing piece -- the advertised URL a
create carries -- and a read-side query, confirmed_mirror_bond, that
returns the newest CONFIRMED record naming a triple.

Chain stays authoritative. mirror::local_bond::recheck_missing_bonds
never trusts the record: for a held bond the live scan did not cover, it
asks the record for a candidate coin id, then re-verifies that SPECIFIC
coin against chain via the same independent check (chain_bond_verdict)
that verifies an untrusted peer's claimed bond. Only a fresh `Bonded`
verdict is folded back in, as covered; `Unbonded`/`Unverified` fall
through to an ordinary create, exactly as if no record existed.

Version: 0.254.86 (patch -- per #522 the MSI ProductVersion minor field
is exhausted and the counter lives in patch).

Co-Authored-By: Claude <noreply@anthropic.com>

* test(mirror): prove the recovery wiring end to end through PassRunner::run

Adds two integration-level tests over the REAL pass pipeline, not just the
isolated recheck_missing_bonds unit tests: a bond missing from the live
scan with a chain-reverified durable record is recovered (no double
create, correct Bonded state reported), and the control -- the same
record but chain disproves it -- correctly falls through to an ordinary
create. Together these are the concrete regression test for the
cold-start/lagging-chain-source double-create scenario the ticket asked
to have measured.

Also refactors in_flight_creates to take the already-folded SpendLedger
instead of re-reading the log itself, so PassRunner::run reads the audit
file once per pass and shares it with the new recovery step, and fixes a
doc comment on in_flight_creates that the recovery step would otherwise
have made stale on landing ("a Confirmed create has a coin the chain
observation already sees" is no longer unconditionally true).

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(fmt): wrap long test signatures to satisfy rustfmt

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(clippy): use slice::from_ref instead of cloning for a single-element slice

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(release): bump to v0.254.89

Base branch moved to develop after PR #576 merged there at v0.254.88
(main and develop are currently identical), leaving this branch's
carried-forward .88 as a zero-increment against the new base. Bumped
to the next free integer after fetching and verifying both origin/main
and origin/develop tip at .88.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(peer): count accepted relayed circuits in the connected pool (#579)

serve_accepted_relay_conn served every accepted relayed circuit (full mTLS
auth, full L7 peer RPC) while registering it nowhere, so connected_peers
under-reported every relayed inbound peer -- the relay-leg twin of the
direct-inbound defect #402/#523 already fixed.

adopt_inbound_peer_in_pool now dispatches by TraversalKind: Relayed routes to
dig-gossip's already-published adopt_relayed_inbound_handle (v0.32.0, the rev
this repo already pins), every other tier keeps the unchanged
adopt_direct_inbound_handle path. serve_accepted_relay_conn adopts before
serving and releases after, mirroring the direct listener exactly.

Refs: https://github.com/DIG-Network/dig_ecosystem/issues/3124

* fix(cli): guard the exit-code namespace shared with diga against collisions (#582)

* chore: open lane for #3189

* fix(cli): guard the exit-code namespace shared with diga against collisions

dign and diga deliberately share one process exit-code numbering (dig-app's
outcome.rs says so in its own doc comment), so a number is free only if it
is unoccupied ecosystem-wide. dig-node#407 assigned exit 7 to
NODE_UNREACHABLE by checking only this repo's own table, where 7 genuinely
was free -- and collided with diga's NOT_CONNECTED. A reviewer caught it by
hand; nothing failed automatically.

Adds scripts/check-exit-code-collisions.sh: parses both enums' code()/name()
match arms straight from their own source -- this repo's ExitCode, and a
live fetch of dig-app's outcome.rs at its default branch -- and fails if a
number carries two different names, or if either side draws a number from
the reserved shell signal range (126, 127, 128+N). Ships with an 18-case
hermetic test harness (scripts/tests/check-exit-code-collisions.test.sh)
covering the actual #407 collision shape, arm-order independence, arm-count
mismatch, the reserved-range boundary from both sides, the live-fetch path
itself, and fail-closed behaviour on an empty/missing/unreachable table.

Wires a real (unstubbed) invocation into ci.yml's existing "Release-script
tests" job so a collision introduced by a future PR, on either side, is a
red required check on that PR -- not a note a reviewer has to catch. The
fetch retries twice (2s backoff) since this becomes a required, network-
dependent check; a fetch failure still fails closed after retrying, never
silently passing as "diga has no codes".

Updates SPEC.md 8.4 to point at the mechanical guard instead of leaving
"re-check both tables" as unenforced prose, and records that the
extension's WALLET_WS_ERR.NOT_CONNECTED = -33001 is a separate JSON-RPC
error-code space, not a rival of this one. Adds a doc-comment to the
existing transcribed collision test pointing future readers at the live
script as the authoritative check; the transcription remains as a narrower,
hermetic regression pin for the #407 shape specifically.

No renumbering: every currently-assigned code is unchanged.

Refs #3189

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 corrupted strings (#3190) (#583)

* chore: open lane for #3190

* fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 corrupted strings

Replicates dig-node-service::continuation_guard (dig-node#526/#501) into dig-node-core,
dig-wallet, dig-runtime and dig-chat-protocol, line-for-line apart from crate-specific
constants -- ported rather than reinvented, per dig_ecosystem#3190.

Wiring the guard in surfaced 48 pre-existing lost-continuation defects the ticket's own
"no measured corruption in these four crates" note did not anticipate: 36 in dig-node-core,
12 in dig-wallet, mostly test-assertion prose where a multi-line message lost its `\`
continuation and shipped the source's own indentation as a mid-sentence space run (one
as the worse `\n`-plus-indentation variant). All 48 are collapsed to the single space the
sentence always meant, with surrounding indentation and wording otherwise untouched.

Two lines are real column-alignment, not defects, and get a targeted EXCLUDED_LINE_RANGES
entry on dig-node-core instead of a rewrite: download.rs's `claimed(...)` fixture-table
trailing comments, and net.rs's `label : value` debug-print alignment.

Refs https://github.com/DIG-Network/dig_ecosystem/issues/3190
Refs https://github.com/DIG-Network/dig_ecosystem/issues/3130

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(mirror): detect an IP change daily and reconcile mirror coins to the current advertise URL

Automatic half (D1-D4) of the daily mirror-URL reconcile: derived personal-day offset, two-observation hysteresis, nine ordered gates with K sized as a self-funding prefix before any reclaim, `submitted` never `completed`, audit lines gain `reclaim_reason` + `trigger`.

Gates at b4c09866: loop-reviewer PASS (review 5129272285), loop-security PASS @ 175304e1 (tree byte-identical, `git diff 175304e1 b4c09866` empty), adversarial loop-decider PASS (comment 5567083644; SHOULD-FIX findings ticketed separately).

Refs DIG-Network/dig-node#570
Refs DIG-Network/dig_ecosystem#3203

* feat(serve): content hosting + serve path batch, v0.255.0 (dig_ecosystem#3212)

Nine commits from the #3212 serve-path lane, gated at 899cc68f (reviewer review 5130425808,
security comment 5568662836), plus the single semver bump to 0.255.0 for the develop -> main batch.

- store_id/root case normalised at the CapsuleKey boundary; cache delete targets the matched entry
- tier-0 occupancy reads the eviction-aware ledger
- profile-sync outbound budget in bytes; announcer asked first
- melt confirmation depth on the terminal spend, fail-closed
- EngineWarming (-32002) while the peer tier attaches, never -32004
- window completeness derived from the bytes read
- deps: dig-stun 0.2, chia-query 0.24.3, dig-nat 0.21.2, dig-logging 0.2.2

Refs DIG-Network/dig_ecosystem#3212

* chore: untrack gitnexus-generated agent files (#590)

* chore: untrack gitnexus-generated agent files

These files were generated by `gitnexus analyze` as a side effect of
indexing this repository. They are development-loop private tooling
output, not product code, and carry no secrets. They are removed from
tracking going forward via .gitignore; history is deliberately NOT
rewritten.

Refs #3177

* chore: drop private-repo reference from gitignore comment

The ignore comment named a private repository and an internal issue
number in a public file, which is the same disclosure class this
change set exists to remove; the reference is dropped and the
guidance kept.

* feat(rewards): always-on prover loop engine -- honest liveness, type-enforced self-exclusion, bounded spend (#593)

The always-on reward-prover engine: ~2,000 lines under
`crates/dig-node-core/src/rewards/`, built against the merged `dig-rewards-coin`
SPEC. Library only -- nothing spawns it, and the sole production
`RewardsChainPort` refuses every call, so it cannot spend. Wiring the composed
system is #3265, which carries its own gate.

The epic's premise -- "anytime the process isn't running, rewards are not being
distributed" -- is half wrong, and the false half is the dangerous one. `Sync`,
`NewEpoch` and `InitiatePayout` need no manager authority, so funder downtime does
not stop rewards: it FREEZES THE ENTRY SET while accrual and payouts continue.
Peers that stopped mirroring keep earning; peers that started cannot begin. That
shaped the whole design.

Liveness honesty (SPEC 2.4). The status record carries no `healthy`/`ok`/`up`
boolean and no precomputed staleness, because a wedged loop cannot report its own
wedging -- whatever it last wrote stays there, so a writer-set flag reads true
forever after the failure it exists to reveal. The reader derives staleness from
`last_cycle_completed_at` against `observed_at` and its own clock. A recursive
JSON-key test enforces the absence at every nesting depth; asserting on keys and
never substrings, since `ProverState::Running` legitimately serializes the VALUE
"running". The one legal staleness signal is chain-derived (SPEC 12.4: 48 hours
AND a non-zero reserve, from the singleton's own spend history) and lives on the
distributor read, where a wedged prover cannot fake it.

Self-exclusion is a compile error, not a habit. dig-node#261's lesson is that an
invariant enforced on some paths is not an invariant. `admit` is the single
admission point, checks both SPEC 5.2 coordinates (own peer_id OR a payout puzzle
hash this wallet controls), and mints an `AdmittedPeer` with private fields and no
public constructor -- so `EntryAction::Add` cannot be built by a path that skipped
admission.

A prover's own fault can never strike a peer. `GateError` is a distinct type from
`GateIneligibleReason` and `record_prover_fault` takes `&self`, so SPEC 3.6 clause
4 is enforced by the borrow checker rather than by comment. Without that, a
misconfigured operator -- one missing mirror-collateral epoch ordinal -- would
strike every peer at once and evict its entire 250-entry set in three hours, each
eviction a fee it pays plus a settlement out of its own reserve.

The money bounds are stated where a human reads them (`rewards/mod.rs`): 24
bundles/day, 192 entry actions/day, a fee ceiling of 24x the configured standard
fee, 192 removals/day worst case with 96/day sustained churn. Recorded honestly:
SPEC 6.3's rate bound and fee ceiling are ONE control, not two.

Three gate rounds, every leg fresh-context. Round 3 at this head: reviewer PASS,
adversarial decider RATIFY (leg closed), security CHANGES-REQUIRED on a finding
the decider ratified deliberately -- adjudicated in
https://github.com/DIG-Network/dig-node/pull/593#issuecomment-5601784815 and
carried to #3265 with the remedy corrected, because the proposed fix would have
persisted a poison flag to the very store whose writes were failing.

Found and fixed under gate: a census ordinal off by one in both directions (SPEC
4.6 requires n-1 exactly); an unreachable grace window leaving a named constant
with no reader; a missing `NewEpoch` spend; an absent-ordinal path attributing a
prover fault to peers; and a daily fee ceiling 24x too high because a per-bundle
fee was consumed as a daily ceiling.

Refs DIG-Network/dig_ecosystem#3250

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

* feat: serve dig.getRewardProverStatus at Tier::Control (#595)

* chore: open lane for #3269

Bump dig-rpc-protocol to 0.11.0 (adds dig.getRewardProverStatus and
the other reward RPC methods to the wire).

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

* test(rewards): fail-closed Reward-tier guard + dig-rpc-protocol 0.11 line assertion

- dependency_tree.rs: assert the resolved dig-rpc-protocol line is 0.11 (was 0.10);
  documents the known-red two-version state pending the dig-peer 0.14.0 /
  dig-download 0.23.0 cascade (#3269).
- reward_methods_tier_guard.rs: fail-closed guard over the live Method::ALL
  catalogue -- every Reward-named method must be Tier::Control and not
  peer-reachable, so a fifth reward method added later is caught at the wrong
  tier automatically rather than inheriting a wrong default (binds #3261's rule
  node-side).
- peer.rs: sibling unit test exercising the real (pub(crate)) is_peer_reachable_method,
  since an external integration test cannot see it -- same guard, executed against
  this node's own allowlist rather than only the shared crate's.

Refs #3269

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

* style: remove trailing blank line in reward_methods_tier_guard.rs

* feat(rpc): serve dig.getRewardProverStatus at Tier::Control

Adds the missing handler for PR#595: a new reward_prover_statuses
registry + accessors on Node (empty until #3265 spawns a prover loop,
so the registry read is real, not a stub), a dispatch.rs arm inside
the Method enum match (never the string pre-match), and a
field-for-field mapping from dig-node-core's internal
rewards::state::RewardProverStatus (camelCase-tagged) onto
dig-rpc-protocol 0.11's wire RewardProverStatus (snake_case-tagged
struct, camelCase-tagged ProverState value), widening entry_count
u32 -> u64 explicitly and hex-encoding the three [u8; 32] identity
fields.

An all-zero launcher_id (what an uninitialised registry slot
hex-encodes to) is omitted at this boundary rather than rendered as
a real distributor with a plausible-looking id -- the money-hole
class the dig-rewards-coin driver's adversarial gates found three
times.

Tests (in dig-node-core::lib.rs's existing test module, where the
pub(crate) registry accessors are visible) drive the real dispatch
entry point (handle_rpc -> RpcDispatch::dispatch -> the Method arm)
and assert field-for-field on the serialized JSON body: populated
registry, empty registry (-> {"statuses": []}), zero-id omission,
tier/peer-reachability, enum-match-not-string-prematch, and
launcher_id filtering. The no-health-boolean / no-staleness
assertion is by key set, not substring.

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

* style: rustfmt the reward-prover-status registry + tests

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

* chore(deps): bump dig-download 0.23, dig-peer 0.14, dig-rpc-protocol 0.11

Closes the two-versions-of-dig-rpc-protocol split (#3269): dig-download 0.23.0
and dig-peer 0.14.0 both now resolve dig-rpc-protocol ^0.11, matching
dig-node-core's own dig-rpc-protocol = "0.11.0" line, and dig-node-service's
two 0.10 lines (main dep + dev-dependency restatement for
openrpc_drift_guard.rs) move to 0.11 to match.

Manifests only. cargo update / Cargo.lock intentionally NOT run yet: a fourth
capper, dig-peer-selector ("0.11" in dig-node-core/Cargo.toml), still requires
dig-peer = "^0.13" in every published version through 0.11.1, so the tree
cannot fully resolve until a dig-peer-selector release picks up dig-peer 0.14.
CI will stay red on this commit for that reason, which is expected.

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

* feat(rpc): close dig-rpc-protocol 0.11 cascade + attribute payout figures

Bump dig-peer-selector 0.11 -> 0.12 (the release that moves onto dig-peer
^0.14) and run cargo update, closing the two-versions-of-dig-rpc-protocol
split: Cargo.lock now resolves exactly one dig-rpc-protocol, at 0.11.0,
alongside dig-peer 0.14.0, dig-download 0.23.0, dig-peer-selector 0.12.0.

Add a subject-attribution test and doc comments to
reward_prover_status_to_wire: total_paid_out_base_units and
reserve_base_units are per-distributor totals (this distributor's payout to
ALL its mirrors, and this distributor's own reserve), never the querying
node's own earnings and never summed/cross-attributed across distributors.
This is the defect class a sibling adversarial gate found in dig-app#403's
rewards pane, which rendered a distributor total as one mirror operator's
personal earnings and overstated by up to 250x.

Checked: rewards/state.rs, rewards/port.rs and rewards/mod.rs contain no
Eligible/verdict/payout_hash symbol, so nothing in this mapping surfaces a
persisted EligiblePayoutHash verdict.

Refs #3269

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

* fix(rpc): silence dead_code on register_reward_prover_status pending #3265

Clippy's non-test lib target has no production caller for
register_reward_prover_status yet, because #3265 (the always-on prover loop
that would call it from bring-up) has not landed -- only tests call it today.
cfg_attr(not(test), allow(dead_code)) stands in for that missing caller
until #3265 wires a real one.

Refs #3269

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

* fix(rpc): make the all-zero identity guard non-silent and cover all three fields

Security (blocking) and the adversarial leg both found the same defect in the
zero-launcher_id filter: it checked only launcher_id, so a registration bug
that zeroed store_id or root beside a valid launcher_id would pass through as
a plausible record, and dropping the bad record silently destroyed the
evidence a registration bug happened at all -- SPEC Sec2.4 clause 1's exact
prohibition.

zeroed_identity_fields() now checks launcher_id, store_id AND root. The
dispatch filter still excludes a record with any zeroed field (never renders
an uninitialised slot as a real distributor), but first fires a
tracing::warn! naming which field(s) were zero, so a bad registration is
observable rather than swallowed. Kept isolated in dispatch.rs rather than
woven into the wire mapping, since this belongs at #3265's writer once that
lands.

Replaced get_reward_prover_status_omits_an_all_zero_launcher_id (which
proved the omission but not the observability, and never exercised a zeroed
store_id/root beside a valid launcher_id) with
get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field, covering
both a zeroed launcher_id and a zeroed store_id beside a valid launcher_id,
and asserting the tracing::warn! output via the crate's existing
capture_sync_logs test utility.

Fixed a now-false "Known-red" doc comment on
tests/dependency_tree.rs::the_workspace_carries_exactly_one_module_wire_crate:
the dig-peer 0.14.0 / dig-download 0.23.0 / dig-peer-selector 0.12.0 cascade
already landed in this PR's Cargo.toml/Cargo.lock, so the assertion is green,
not red. Assertion itself untouched -- still exact-version.

Refs #3269

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

* fix(rpc): correct a born-false "shipped dig-app 15.5.0" doc claim

dig-app's latest release is v15.4.0 -- there is no v15.5.0 tag -- and
dig-app#403 (the pane that would consume dig.getRewardProverStatus) is OPEN
and unmerged. Point the doc comment at the real, unmerged consumer instead
so a future reader doesn't take this as evidence a shipped consumer depends
on the guard, which would wrongly discourage relocating it to #3265's writer.

Refs #3269

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

* fix(rpc): correct doc placement, assert the zeroed field by name, treat root as an observation

Three findings from the correctness gate on PR#595 at 134864a9.

1. The zeroed-identity helper's doc block was spliced onto the end of
   reward_prover_status_to_wire's block with no separator, so the wire-mapping
   rationale documented a boolean predicate and the mapping function was left
   with no doc at all. Each doc block now sits above the item it describes.

2. The log assertion `logs.contains("launcher_id")` was a tautology: the warn
   emits launcher_id as a structured field on every fire, so the property the
   guard exists to add -- naming which field was zeroed -- was unasserted.
   Deleting `zeroed_fields = ?zeroed` from the warn left every assertion green.
   The test now asserts the zeroed_fields value itself, which the fixture makes
   exact and disjoint across cases.

3. `root` is an observation, not an identity. A registered prover that has not
   completed its first cycle plausibly has no root, and a writer that zero-inits
   it would have made a healthy prover invisible. A zeroed launcher_id or
   store_id still excludes the record; a zeroed root alone warns and returns.

Refs #3269

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

* fix(rpc): restore zeroed_fields structured field dropped from the pushed warn

The previous commit (080be3df) landed with `zeroed_fields = ?zeroed` missing
from the tracing::warn! call in the GetRewardProverStatus filter -- a
one-line regression introduced while proving the new log assertion goes red
without it, never restored before the commit was made. Without this field
the log line never names WHICH field was zero, so an operator sees only
that something was excluded, and the test asserting `zeroed_fields=[...]`
per case would fail. Restored; all 7 reward-prover-status tests green.

Refs #3269

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

* fix(rpc): split zeroed-field logging by level -- WARN for a missing
identity, DEBUG for a zeroed root

A zeroed launcher_id or store_id is a real registration bug: the record is
excluded and now logs at WARN, naming the exact field(s) via
`zeroed_fields=[...]`. A zeroed root alone is an ordinary pre-first-cycle
state, not a fault: the record is still returned, and now logs at DEBUG
instead of WARN, so an operator polling this endpoint sees warn-level
volume proportional to real registration bugs, not to every
not-yet-cycled prover on every poll.

Updated the doc comments on `zeroed_fields`, the dispatch filter and the
test to describe the level split, and extended the regression test to
assert on level (WARN vs DEBUG) as well as the `zeroed_fields` value.
Proved both directions: flipping the DEBUG branch back to WARN turns the
test red on the level assertion; flipping the field-name assertion back to
a bare `contains("launcher_id")` would have passed unconditionally (the
prior tautology) and is no longer possible since the assertions now pin
`zeroed_fields=[...]` plus the level string.

Refs #3269

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat(rewards): peer-side claim loop -- watch distributors, claim on cadence (#3251) (#594)

* feat(rewards): peer claim loop skeleton -- discovery, cadence, config, claim port

* test(rewards): write all twelve acceptance tests for the peer claim loop

* feat(rewards): wire the seven rewards_claim submodules into the crate

mod.rs declared no submodules, so types.rs/port.rs/config.rs/cadence.rs/
parser.rs/engine.rs/hints.rs (1,435 lines, 25 tests) were never part of the
crate and never compiled. Declare them and re-export the public surface.

* style(rewards): cargo fmt the rewards_claim submodules

* chore(deps): bump dig-node-control-interface 0.33->0.35, dig-rpc-protocol 0.10->0.11.0

dig-rpc-protocol 0.11.0 is merged and tagged upstream; the other dig-*/chia-*
deps of dig-node-service were already at the latest permitted-by-caret version
in Cargo.lock. crates/dig-node-core/Cargo.toml is untouched (#3250's file set).

* chore(deps): revert dig-node-control-interface and dig-rpc-protocol bumps

Both create a duplicate-version split in this PR's scope and neither can be
closed without editing a sibling crate's manifest this lane does not own:

- dig-rpc-protocol 0.11.0 duplicates against dig-node-core/Cargo.toml:194
  ("0.10.2"), which is #3250's live file set (dig-node#593).
- dig-node-control-interface 0.35.0 duplicates against
  dig-wallet/Cargo.toml:81 ("0.33"), a sibling crate this lane does not own;
  the observed Clippy break (BalanceAsset/Asset type-identity mismatch,
  missing url_reconcile/url_current/urls fields) came from THIS duplicate,
  not from dig-rpc-protocol.

Both belong to their own sequenced dep-bump unit of work, not this ticket.

* fix(rewards-claim): fault laundering, permanent no-entry blacklist, fee ceiling magnitude

Three independent gates on dig-node#594 (51516e62) found four logic defects; this
addresses A, B and C per the corrected fix brief (D is documented only, not fixed
here per the brief's own instruction).

Defect A -- the anti-silence surface laundered every real fault into `Nominal`:
- A1: `fault_reported` had no fault-bearing ClaimLoopState to fall through to, so a
  chain adapter erroring every cycle read `Nominal` forever. Added
  `ClaimLoopState::Faulted { cycles }`, outranking Nominal/ClaimableButNotClaiming,
  under ChainSourceUnavailable.
- A2: inverted the test that asserted A1's bug as correct behaviour.
- A3: `ClaimableButNotClaiming` compared a per-cycle snapshot
  (`distributors_claimable`) against a lifetime-cumulative counter
  (`claims_submitted`), so it latched healthy forever after one lifetime success.
  Added `claims_submitted_this_cycle` (per-cycle) as the correct comparand; kept
  `claims_submitted` as a cumulative counter.
- A4: `last_discovery_at`/`last_cycle_at` were stamped even on a failed discovery
  or an all-faulted cycle, destroying the staleness signal a reader depends on.
  Now only stamped on success; added `last_attempt_at` to prove liveness
  separately. `fault_reported` and `distributors_faulted` now reset per cycle
  instead of latching for the process's lifetime.

Defect B -- "terminal, stop retrying" was implemented as a process-lifetime
blacklist (`terminal_no_entry: HashSet<Bytes32>`, never cleared). That blocked
SPEC 12.5 clause 2's re-entry path (evicted, re-challenged, re-admitted never
claims again) and permanently punished a peer that discovered a distributor
before the funder's AddEntry landed. Removed the blacklist entirely -- `own_entry`
is a cheap chain read, re-issued every cycle for every candidate, matching clause
3's "never cache across cycles". `NoEntrySlot` is now a per-cycle observation, not
a lifetime sentence.

Defect C -- the fee ceiling didn't bind anything and there was no aggregate cap:
- C1: default `CLAIM_FEE_CEILING_MOJOS_DEFAULT` lowered from 1_000_000_000
  (transplanted from `MIRROR_SPEND_FEE_CEILING_MOJOS`, sized for a mirror-coin
  spend) to 200_000 -- 2x the observed routine Chia fee range (5,000-100,000
  mojos), so it actually binds instead of leaving 4-5 orders of magnitude of
  slack.
- C2: added a per-cycle aggregate fee budget
  (`max_cycle_fee_budget_mojos`, default 10x the per-claim ceiling) checked
  across all claims in a cycle, closing the attacker-cost gap where funding K
  distributors could force a victim to spend K x the per-claim ceiling per cycle.
  New `ClaimOutcome::SkippedCycleBudgetExhausted`.

Tests: rewards_claim test count 26 -> 37 (11 new: repeated_discovery_faults_never_
read_as_nominal, failed_discovery_leaves_last_discovery_at_unchanged, a_reported_
fault_surfaces_as_faulted_not_nominal, a_lifetime_submission_does_not_mask_a_
later_cycle_that_submits_nothing, no_entry_slot_then_re_admitted_produces_a_claim_
on_the_later_cycle, distributors_each_under_ceiling_do_not_collectively_exceed_
the_cycle_budget, the_default_per_claim_ceiling_actually_binds_a_routine_fee, plus
renamed/rewritten no_entry_slot_is_non_terminal_and_re_checked_every_cycle).

Refs #3251

* fix(rewards-claim): refuse a claim entry for the wrong payout puzzle hash

CI fix: cadence.rs's RewardsClaimConfig literal was missing the
max_cycle_fee_budget_mojos field added in the previous commit (E0063,
caught by CI's Clippy/Test jobs -- the local cargo check for this
workspace is too slow to use as the compiler here).

Defect E (security-gate finding, folded in before this pass closes):
submit_initiate_payout was called with entry.payout_puzzle_hash -- whatever
the chain port handed back -- with no check against this node's own
own_payout_puzzle_hash. UnavailableClaimChainPort is the only production
adapter today so nothing can exploit this yet, but the whole point of the
ClaimChainPort seam is that #3249 swaps in a real adapter with nothing
above it changing, so deferring this would ship the landmine live with no
review pass watching for it. Added an equality guard before the spend:
a mismatch refuses to submit, counts
(ClaimStatus::claims_refused_payout_mismatch), surfaces its own named
outcome (ClaimOutcome::PayoutPuzzleHashMismatch), and is reported as a
fault (a divergent entry means the port is confused or hostile, not that
there is nothing to claim) -- never corrected by substituting our own
hash and proceeding.

Defect D: documented, not wired, per instruction -- added the "not yet
wired into node startup" paragraph to mod.rs's module doc (the PR body
carries the same paragraph) so the next reader arrives at the caveat in
the code, not only in a merged PR description.

Refs #3251

* fix(rewards-claim): B1 -- ClaimableButNotClaiming is a magnitude comparison, not a zero-test

submitted_this_cycle < claimable_this_cycle now fires the anti-silence state, carrying
the shortfall as ClaimableButNotClaiming { claimable, submitted }. The previous
submitted_this_cycle == 0 zero-test let one submission mask any number of same-cycle
skips (claimable=10, submitted=1 read Nominal).

Also folds in B3's precedence fix (ChainSourceUnavailable > Faulted >
ClaimableButNotClaiming > Idle > Nominal) and the per-distributor
payout_hash_mismatches_this_cycle counter so a per-distributor fault can no longer
pin the cycle-wide Faulted state, plus R2's rename of terminal_no_entry_slot to
no_entry_slot_this_cycle now that it is no longer terminal.

* fix(rewards-claim): B2/B3 -- value-ordered budget with rotation, per-distributor fault isolation

B2: run_cycle now splits into a pre-budget phase (asset/entry/hash/threshold checks,
producing the claimable set) and a budget phase, ordering the claimable set by accrued
value descending before applying the fee ceiling and cycle budget. Dust distributors
(low accrued value regardless of attacker-controlled fee) now sort last and are the
ones the budget drops, closing the claim-suppression attack where ten high-fee dust
distributors could consume the whole cycle budget ahead of a victim's real earnings.
A rotation_cursor tie-breaks only WITHIN equal-accrued-value tiers so a genuinely
tied honest tail that exceeds one cycle's budget every cycle still rotates through
and is eventually served, rather than dropping the same tail forever.

B3: the payout-hash mismatch check in evaluate_pre_budget now increments the
per-distributor payout_hash_mismatches_this_cycle counter instead of setting
fault_reported, so one hostile or buggy entry can no longer pin the cycle-wide
Faulted state and bury ClaimableButNotClaiming for every other healthy distributor.

R2: terminal_no_entry_slot -> no_entry_slot_this_cycle throughout.

* fix(rewards-claim): R5 -- document not-yet-wired loop; persist B2 rotation cursor

An operator reading their own rewards-claim.json and seeing enabled: true has no way
to know from that file alone that no startup path constructs a ClaimEngine yet
(#3268) -- mod.rs said so, but a config file reader does not arrive at a module doc.

Also gives RewardsClaimConfig a rotation_cursor: Option<Bytes32> field so B2's
tie-break cursor survives a save/load round-trip -- an in-memory-only cursor resets
on every restart, which would starve a legitimately tied honest tail forever on any
node that restarts daily.

* fix(rewards-claim): clippy collapsible-match + SPEC v0.1.3 wording refresh

Collapses the nested if into the outer match arm in run_cycle (clippy::collapsible_match).
Also refreshes NoEntrySlot / no_entry_slot_this_cycle doc comments now that
dig-rewards-coin v0.1.3's SPEC §12.5 amendment is merged and tagged: absence is
terminal for one claim attempt only, never for the distributor, must not be cached,
and must not accumulate into a permanent exclusion set -- confirming rather than
diverging from the re-read-every-cycle behaviour already implemented.

* fix(rewards-claim): add missing rotation_cursor field in cadence.rs test literal

Struct literal in the cadence test module was not updated when RewardsClaimConfig
gained rotation_cursor (R5 commit) -- CI's Clippy/Test jobs caught the missing field
(E0063) that a local cargo check could not (killed by memory pressure before this
workspace-wide build completed).

* fix(rewards-claim): F1 -- ChainSourceUnavailable is per-cycle, never a latch

compute_state() compared against self.state -- last cycle's OWN computed
output -- so once any cycle took an Unavailable port path, every later
cycle re-asserted ChainSourceUnavailable forever, even after the chain
came back and real claims were submitting. A node still syncing, or one
dropped connection, was enough to trip this permanently.

Add ClaimStatus::chain_unavailable_this_cycle, reset to false at the top
of every run_cycle and set true only on a cycle that actually took the
Unavailable path; compute_state now reads that flag instead of
self.state, so the reading is live again.

Test: a_transient_unavailable_cycle_does_not_latch_state_for_the_rest_of_the_process
(engine.rs) drives cycle 1 unavailable, cycle 2 healthy with a
submission, and asserts cycle 2 reads Nominal. Plus a compute_state-level
regression in types.rs.

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

* fix(rewards-claim): F3/F4/F5 -- fix stale counters, dedup candidates, correct version doc

F3: reset EVERY per-cycle counter (distributors_known/with_own_entry/
claimable/faulted, claims_submitted_this_cycle, no_entry_slot_this_cycle)
at the TOP of run_cycle, before any early return. The three
ChainUnavailable early-return paths skip the end-of-function assignment
block entirely, so a cycle that hit one used to leave the PRIOR cycle's
counts sitting on self.status while last_attempt_at stamped fresh for
THIS cycle -- a stale count under a fresh timestamp, exactly what SPEC
§2.4's staleness reasoning forbids. types.rs's doc sentence for
no_entry_slot_this_cycle now correctly says it is dated by
last_attempt_at (the field stamped unconditionally every cycle), not
last_cycle_at.

F4: dedup `candidates` by launcher id before phase 2. A real adapter
scanning §1.3 launch comments across every (store_id, root) this node
mirrors can plausibly return the same launcher id twice; without dedup
phase 2 would evaluate it twice and submit InitiatePayout twice against
one entry slot in one cycle -- the second spend is invalid but the fee
is paid anyway.

F5: dig-rewards-coin is v0.1.3, published on crates.io -- correct the
stale "v0.1.1" module-doc claim.

Tests: a_chain_unavailable_cycle_does_not_leave_prior_cycles_counters_stale
(F3), a_duplicated_launcher_id_submits_exactly_once (F4).

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

* fix(rewards_claim): fold payout-hash mismatches into the shortfall predicate (F2)

A payout-hash mismatch never enters the eligible set, so it was counted in
NEITHER distributors_claimable NOR claims_submitted_this_cycle -- the
shortfall lived in neither term of compute_state's magnitude comparison.
All-K-distributors mismatching therefore read Nominal (falsely healthy).

Fold payout_hash_mismatches_this_cycle into the comparison's denominator:
submitted < claimable + mismatches. The result is ClaimableButNotClaiming
(a shortfall), never the cycle-wide Faulted -- Defect B3 stays fixed.

Inverts the assertion at what was engine.rs:1305
(a_payout_mismatch_never_sets_the_cycle_wide_fault_or_masks_other_distributors):
it previously asserted ClaimLoopState::Nominal across three cycles of an
ongoing mismatch, which pinned the defect as intended behaviour (an
A2-class test). It now asserts ClaimableButNotClaiming { claimable: 1,
submitted: 1 }.

Adds all_distributors_mismatching_is_a_shortfall_not_nominal, covering the
brief's exact "what if every distributor refuses for the same reason" case.

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

* fix(rewards-claim): F1/F3 -- AtomicU32 in fake ports, not Mutex, keeps discovery Send

CI's Clippy job (the compiler for this crate, per brief) caught it: holding
a std::sync::MutexGuard across the .await in FlakyThenHealthyPort and
HealthyThenUnavailablePort's discover_distributors made the returned future
not Send, which #[async_trait]'s generated trait signature requires.

Neither fake needs a lock -- each holds one call counter, incremented once
per call, never read-modify-written across an await point. AtomicU32's
fetch_add removes the guard (and the Send bound violation) entirely.

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

* fix(rewards-claim): F7 -- persist the fee window and cadence gate across restart

The per-cycle aggregate fee budget and the 24h cadence clock both lived only
in memory: `spent_this_cycle_mojos` was a `run_cycle` local and nothing on
disk recorded a completed cycle. Every fresh process got a full
`max_cycle_fee_budget_mojos` and an empty cadence clock, so a node stuck in
a crash-restart loop could spend unbounded XCH on fees, one full budget per
restart.

Adds three `#[serde(default)]` fields to `RewardsClaimConfig`
(`fee_window_start_unix`, `fee_spent_in_window_mojos`,
`last_cycle_completed_at`) and a new opt-in `ClaimEngine::with_persisted_fee_
window(dir, cadence_seconds)` that:
- restores the window/cadence state from `dir` at construction,
- refuses to start a cycle until the cadence has elapsed since the last
  completed one,
- rolls a fresh budget window only once the cadence has elapsed since it
  opened, otherwise keeps enforcing the budget against the persisted spend,
- persists the spend BEFORE every chain submission (write-then-spend), never
  batched to cycle end, and persists the completed-cycle timestamp when a
  cycle finishes.

Engines that never call `with_persisted_fee_window` (every pre-F7 test) are
unaffected -- this is additive, opt-in state beside the existing rotation
cursor, not a change to B2's value-ordering or rotation mechanism.

`ClaimStatus`'s own counters stay in-memory on purpose (observability, meant
to reset on restart); only the spend bound and the cadence gate persist.

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

* fix(rewards-claim): F7 -- update cadence.rs test literal for new persisted fields

The three new persisted RewardsClaimConfig fields (fee_window_start_unix,
fee_spent_in_window_mojos, last_cycle_completed_at) broke this crate's only
remaining full struct literal outside config.rs/engine.rs's own test
modules -- E0063 missing fields, caught by CI's Clippy job. Switched to
..RewardsClaimConfig::default() so the next added field cannot break this
literal again, the same fix already applied once before for rotation_cursor.

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

* fix(rewards-claim): F8/F14 -- atomic state write, fail closed on a corrupt window

Salvaged from a lane killed by a weekly cap before it could commit. Uncompiled at
commit time; CI is the compile signal.

Covers the fourth gate pass findings on the F7 persisted spend bound:

- F8: RewardsClaimConfig::save_to now writes atomically (temp file + rename in the
  same directory), reusing the pattern already used by mirror/reconcile_state.rs
  for the same class of state. load_from distinguishes an ABSENT file (clean first
  run, defaults are correct) from a PRESENT but unparsable one, which fails CLOSED:
  the window is treated as fully spent and nothing is submitted. Never Default, and
  never a silent clamp downward, which would hand back the budget the corruption
  was hiding.
- F14: the budget comparison uses saturating arithmetic so a corrupt disk-seeded
  fee_spent_in_window_mojos cannot panic under the release profile's
  overflow-checks.
- F9/F10/F12/F13 in progress in the same files.

Refs #3251

* fix(rewards-claim): negate with ! rather than the unimported Not trait

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(rewards-claim): F13 -- correct stale test literals to the folded shortfall

compute_state (types.rs) already reported the folded shortfall
denominator (distributors_claimable + payout_hash_mismatches_this_cycle)
as `claimable` -- that part of F13 landed in f478516a. The two engine.rs
tests asserting this state were written against the pre-fold, un-folded
numbers and never updated, so CI showed the implementation producing the
correct folded value (`claimable: 2`, `claimable: 1`) while the test
literals still expected the stale un-folded one (`claimable: 1`,
`claimable: 0`).

Update both literals -- and the comments describing them -- to the
folded values the F13 fix actually produces. No production code change;
compute_state's predicate and payload were already correct.

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

* feat(rewards-claim): add ClaimOutcome::Faulted variant

Add the seventh ClaimOutcome variant: the type could only say a peer was
legitimately not paid, never that a chain call failed. Carries the launcher
id, a bounded (200 char) copy of the chain port's error text, and whether a
pre-committed fee was reversed, so a reader can tell no money moved.

Engine wiring at the two fault arms (engine.rs:332, :377) follows in the
next commit.

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

* fix(rewards-claim): both fault arms now push ClaimOutcome::Faulted

engine.rs:332 and :377 used to increment `faulted` and discard the
outcome, leaving a definitively-failed claim absent from the outcome
stream -- indistinguishable from a cycle that never touched that
distributor. Both PreBudgetResult::Fault and BudgetPhaseResult::Fault
now carry the chain port's (bounded) error text, and the
submit_initiate_payout failure path also carries the fee it reversed,
so a reader can tell no money moved. The counter stays; it is not a
substitute for the outcome.

7 call sites needed updating: 3 PreBudgetResult::Fault constructions
(reserve_asset_id, own_entry, payout_threshold), 2 BudgetPhaseResult::Fault
constructions (required_fee_mojos, submit_initiate_payout), and the 2
consuming match arms -- exactly the set that was silently discarding a
failure before this change.

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

* test(rewards-claim): a failed submission produces a Faulted outcome

Regression for the rework: reuses F12's fixture (a submission that
definitely never broadcast) to prove both facts from one cycle -- the
outcome exists and carries the reversed fee, and the persisted window
still reflects zero net spend. Also fixes a rustfmt diff on the
PreBudgetResult::Fault variant Clippy's Rustfmt job flagged.

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

* fix(rewards-claim): delete fee_window_poisoned, stop latching self-healing state

Finding 1 (dig-node#594 pass 6): a future-dated clock is self-healing by
construction (`t > now` goes false the moment real time passes it), but the
engine ORed it into `self.fee_window_poisoned` and set that field `true`
permanently -- an RTC glitch or VM resume froze the claim loop forever instead
of until the skew passed. This is the third instance of one mechanism (pass 3
latched ChainSourceUnavailable, pass 4 left a stale cadence-gate `state`), so
the fix removes the FIELD, not just the bug: with no `fee_window_poisoned` on
`ClaimEngine`, `self.fee_window_poisoned = true` is a compile error, not a
convention to remember.

Per-cycle conditions (corrupt + future-dated-clock) now live in a
`CycleConditions` value built fresh at the top of every `run_cycle` from `now`
plus a freshly reloaded `RewardsClaimConfig`, used, and dropped -- never
stored on the engine. `corrupt` is now re-read from disk every cycle too (it
previously latched at construction only), matching what
`ClaimLoopState::PersistedStateCorrupt`'s doc already claimed but the code
never did.

Rewrites the single-cycle f10 regression into a two-cycle test: cycle 1 with a
future-dated clock refuses; cycle 2, after the clock catches up and the
cadence elapses, MUST claim. The old one-cycle version was green whether the
latch bug was present or not.

Refs #594

* fix(rewards-claim): satisfy clippy doc-list indent and rustfmt

Clippy failed with 3x doc_lazy_continuation on the PersistedStateCorrupt
doc comment (types.rs:165-167): continuation lines of a `-` bullet must
be indented under the marker, not left flush. Indent them.

Rustfmt failed on the new fail_reserve_asset_for early-return in
FakeChainPort::reserve_asset_id (engine.rs:888): the Err(...) call
exceeded the line-length limit unwrapped. Let rustfmt wrap it.

Refs #594

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

* test(rewards-claim): red proof for corrupt-then-repaired stale read

Cycle 1 refuses a corrupt fee-window file; the file is then repaired to
valid values with a fully-spent window and a recent completed-cycle
time. Cycle 2 must neither grant a fresh budget nor skip the cadence
gate. Fails against current `with_persisted_fee_window`, which loads
the three fee-window fields once at construction and never refreshes
them from the per-cycle `cfg` -- see engine.rs:149-157, #594.

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

* fix(rewards-claim): resync fee-window fields from disk every cycle

`with_persisted_fee_window` only loaded fee_window_start_unix,
fee_spent_in_window_mojos and last_cycle_completed_at once, at
construction. Once the now-deleted fee_window_poisoned latch stopped
masking it, a file corrupt at construction and repaired later left
those three fields stuck on poisoned()'s None/0/None placeholders --
a fresh budget and a skipped cadence gate, and persist_fee_window then
overwrote the repaired disk values with them.

CycleConditions now carries the three fields from the SAME freshly
reloaded cfg it already used for the corrupt/future-dated check, and
run_cycle copies them onto self before the cadence gate or window-roll
logic runs, but only on a read that is neither corrupt nor future-
dated. This also fixes Finding 2b: future_dated_clock now reads cfg's
own clocks instead of self's stale ones. Corrects the doc claim at the
old lines 236-238 to describe what the code now does for both halves.

Closes #594.

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

* refactor(rewards-claim): make disk the sole store for the fee window

Delete `fee_window_start_unix`, `fee_spent_in_window_mojos` and
`last_cycle_completed_at` from `ClaimEngine`. `run_cycle` already re-reads
`RewardsClaimConfig` fresh every cycle for the corrupt/future-dated check,
so caching a copy on the engine bought nothing and cost exactly the
stale-read defect class F16 just fixed. A local `FeeWindowState`, scoped to
one `run_cycle` call, now threads the in-flight values through
`evaluate_budget_phase`/`uncommit_fee`/`persist_fee_window` instead. With
no field left to cache into, a future `self.fee_window_start_unix = ...`
outside this file is an E0609 compile error, the same enforcement
`fee_window_poisoned`'s removal already has.

No behaviour change: every early return, the corrupt/future-dated fail-
closed path, the cadence gate, the window roll, write-then-spend
pre-commit/uncommit and the per-claim ceiling are unchanged -- only where
the three values live changed.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* chore(release): v0.256.0

Bump dig-node-service to v0.256.0 for release.

This release includes:
- Reward distributor prover loop (#593)
- Peer reward claim loop (#594)
- Reward prover status RPC (#595)

* ci: scope commitlint to PR-introduced commits, fix title suffix check

A develop -> main release-cut PR was linting main..develop, the full
inherited commit range, instead of just the commits it introduces.
Every commit in that range was already linted at its own PR while it
was still mutable; re-linting it at cut time adds no information and
cannot be satisfied once merged (gitlinks and rev-pinned deps make
history immutable). Use commitDepth: 1 on a main-base PR; keep the
full-range lint unchanged for develop-base PRs, where authors can
still fix the commits.

Also fix the PR-title lint's blind spot: GitHub's squash merge lands
"$PR_TITLE (#$PR_NUMBER)" as the commit subject, about eight
characters longer than the title alone, so a title that passes
header-max-length can still produce an over-limit commit subject that
nothing checks. Lint the exact string that will land.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(release): v0.257.0 -- the reward distributor lifecycle starts running (#607)

* feat(mirror): persist mirror-bond coin ids (#575)

* chore: open lane for #574

* feat(mirror): persist mirror-bond coin ids so a restart cannot double-create

Bond identity was reconstructed from a live chain scan on every read
(`mirror/observe.rs`), with no persistence of its own. A restart, a cold
replica, or a lagging/flaky chain source all rendered a real, unspent,
confirmed bond as "no bonds" -- and because the in-flight suppression is
keyed on pending/submitted audit entries, a bond whose create had already
CONFIRMED was not suppressed either, so the same short scan that emptied
the read surface also cleared the one thing that would have stopped a
second coin being paid for collateral that already exists (dig-node#574).

Persist the (store, root, epoch) -> coin_id mapping in the EXISTING spend
audit record (spend-audit.jsonl) rather than a new store: a mirror-coin
create already writes store_id + AuditedBond{root, epoch} + amount there,
and the coin id itself becomes durable the moment resolve_landed_spends
confirms it. This adds the one missing piece -- the advertised URL a
create carries -- and a read-side query, confirmed_mirror_bond, that
returns the newest CONFIRMED record naming a triple.

Chain stays authoritative. mirror::local_bond::recheck_missing_bonds
never trusts the record: for a held bond the live scan did not cover, it
asks the record for a candidate coin id, then re-verifies that SPECIFIC
coin against chain via the same independent check (chain_bond_verdict)
that verifies an untrusted peer's claimed bond. Only a fresh `Bonded`
verdict is folded back in, as covered; `Unbonded`/`Unverified` fall
through to an ordinary create, exactly as if no record existed.

Version: 0.254.86 (patch -- per #522 the MSI ProductVersion minor field
is exhausted and the counter lives in patch).

Co-Authored-By: Claude <noreply@anthropic.com>

* test(mirror): prove the recovery wiring end to end through PassRunner::run

Adds two integration-level tests over the REAL pass pipeline, not just the
isolated recheck_missing_bonds unit tests: a bond missing from the live
scan with a chain-reverified durable record is recovered (no double
create, correct Bonded state reported), and the control -- the same
record but chain disproves it -- correctly falls through to an ordinary
create. Together these are the concrete regression test for the
cold-start/lagging-chain-source double-create scenario the ticket asked
to have measured.

Also refactors in_flight_creates to take the already-folded SpendLedger
instead of re-reading the log itself, so PassRunner::run reads the audit
file once per pass and shares it with the new recovery step, and fixes a
doc comment on in_flight_creates that the recovery step would otherwise
have made stale on landing ("a Confirmed create has a coin the chain
observation already sees" is no longer unconditionally true).

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(fmt): wrap long test signatures to satisfy rustfmt

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(clippy): use slice::from_ref instead of cloning for a single-element slice

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(release): bump to v0.254.89

Base branch moved to develop after PR #576 merged there at v0.254.88
(main and develop are currently identical), leaving this branch's
carried-forward .88 as a zero-increment against the new base. Bumped
to the next free integer after fetching and verifying both origin/main
and origin/develop tip at .88.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(peer): count accepted relayed circuits in the connected pool (#579)

serve_accepted_relay_conn served every accepted relayed circuit (full mTLS
auth, full L7 peer RPC) while registering it nowhere, so connected_peers
under-reported every relayed inbound peer -- the relay-leg twin of the
direct-inbound defect #402/#523 already fixed.

adopt_inbound_peer_in_pool now dispatches by TraversalKind: Relayed routes to
dig-gossip's already-published adopt_relayed_inbound_handle (v0.32.0, the rev
this repo already pins), every other tier keeps the unchanged
adopt_direct_inbound_handle path. serve_accepted_relay_conn adopts before
serving and releases after, mirroring the direct listener exactly.

Refs: https://github.com/DIG-Network/dig_ecosystem/issues/3124

* fix(cli): guard the exit-code namespace shared with diga against collisions (#582)

* chore: open lane for #3189

* fix(cli): guard the exit-code namespace shared with diga against collisions

dign and diga deliberately share one process exit-code numbering (dig-app's
outcome.rs says so in its own doc comment), so a number is free only if it
is unoccupied ecosystem-wide. dig-node#407 assigned exit 7 to
NODE_UNREACHABLE by checking only this repo's own table, where 7 genuinely
was free -- and collided with diga's NOT_CONNECTED. A reviewer caught it by
hand; nothing failed automatically.

Adds scripts/check-exit-code-collisions.sh: parses both enums' code()/name()
match arms straight from their own source -- this repo's ExitCode, and a
live fetch of dig-app's outcome.rs at its default branch -- and fails if a
number carries two different names, or if either side draws a number from
the reserved shell signal range (126, 127, 128+N). Ships with an 18-case
hermetic test harness (scripts/tests/check-exit-code-collisions.test.sh)
covering the actual #407 collision shape, arm-order independence, arm-count
mismatch, the reserved-range boundary from both sides, the live-fetch path
itself, and fail-closed behaviour on an empty/missing/unreachable table.

Wires a real (unstubbed) invocation into ci.yml's existing "Release-script
tests" job so a collision introduced by a future PR, on either side, is a
red required check on that PR -- not a note a reviewer has to catch. The
fetch retries twice (2s backoff) since this becomes a required, network-
dependent check; a fetch failure still fails closed after retrying, never
silently passing as "diga has no codes".

Updates SPEC.md 8.4 to point at the mechanical guard instead of leaving
"re-check both tables" as unenforced prose, and records that the
extension's WALLET_WS_ERR.NOT_CONNECTED = -33001 is a separate JSON-RPC
error-code space, not a rival of this one. Adds a doc-comment to the
existing transcribed collision test pointing future readers at the live
script as the authoritative check; the transcription remains as a narrower,
hermetic regression pin for the #407 shape specifically.

No renumbering: every currently-assigned code is unchanged.

Refs #3189

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 corrupted strings (#3190) (#583)

* chore: open lane for #3190

* fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 corrupted strings

Replicates dig-node-service::continuation_guard (dig-node#526/#501) into dig-node-core,
dig-wallet, dig-runtime and dig-chat-protocol, line-for-line apart from crate-specific
constants -- ported rather than reinvented, per dig_ecosystem#3190.

Wiring the guard in surfaced 48 pre-existing lost-continuation defects the ticket's own
"no measured corruption in these four crates" note did not anticipate: 36 in dig-node-core,
12 in dig-wallet, mostly test-assertion prose where a multi-line message lost its `\`
continuation and shipped the source's own indentation as a mid-sentence space run (one
as the worse `\n`-plus-indentation variant). All 48 are collapsed to the single space the
sentence always meant, with surrounding indentation and wording otherwise untouched.

Two lines are real column-alignment, not defects, and get a targeted EXCLUDED_LINE_RANGES
entry on dig-node-core instead of a rewrite: download.rs's `claimed(...)` fixture-table
trailing comments, and net.rs's `label : value` debug-print alignment.

Refs https://github.com/DIG-Network/dig_ecosystem/issues/3190
Refs https://github.com/DIG-Network/dig_ecosystem/issues/3130

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(mirror): detect an IP change daily and reconcile mirror coins to the current advertise URL

Automatic half (D1-D4) of the daily mirror-URL reconcile: derived personal-day offset, two-observation hysteresis, nine ordered gates with K sized as a self-funding prefix before any reclaim, `submitted` never `completed`, audit lines gain `reclaim_reason` + `trigger`.

Gates at b4c09866: loop-reviewer PASS (review 5129272285), loop-security PASS @ 175304e1 (tree byte-identical, `git diff 175304e1 b4c09866` empty), adversarial loop-decider PASS (comment 5567083644; SHOULD-FIX findings ticketed separately).

Refs DIG-Network/dig-node#570
Refs DIG-Network/dig_ecosystem#3203

* feat(serve): content hosting + serve path batch, v0.255.0 (dig_ecosystem#3212)

Nine commits from the #3212 serve-path lane, gated at 899cc68f (reviewer review 5130425808,
security comment 5568662836), plus the single semver bump to 0.255.0 for the develop -> main batch.

- store_id/root case normalised at the CapsuleKey boundary; cache delete targets the matched entry
- tier-0 occupancy reads the eviction-aware ledger
- profile-sync outbound budget in bytes; announcer asked first
- melt confirmation depth on the terminal spend, fail-closed
- EngineWarming (-32002) while the peer tier attaches, never -32004
- window completeness derived from the bytes read
- deps: dig-stun 0.2, chia-query 0.24.3, dig-nat 0.21.2, dig-logging 0.2.2

Refs DIG-Network/dig_ecosystem#3212

* chore: untrack gitnexus-generated agent files (#590)

* chore: untrack gitnexus-generated agent files

These files were generated by `gitnexus analyze` as a side effect of
indexing this repository. They are development-loop private tooling
output, not product code, and carry no secrets. They are removed from
tracking going forward via .gitignore; history is deliberately NOT
rewritten.

Refs #3177

* chore: drop private-repo reference from gitignore comment

The ignore comment named a private repository and an internal issue
number in a public file, which is the same disclosure class this
change set exists to remove; the reference is dropped and the
guidance kept.

* feat(rewards): always-on prover loop engine -- honest liveness, type-enforced self-exclusion, bounded spend (#593)

The always-on reward-prover engine: ~2,000 lines under
`crates/dig-node-core/src/rewards/`, built against the merged `dig-rewards-coin`
SPEC. Library only -- nothing spawns it, and the sole production
`RewardsChainPort` refuses every call, so it cannot spend. Wiring the composed
system is #3265, which carries its own gate.

The epic's premise -- "anytime the process isn't running, rewards are not being
distributed" -- is half wrong, and the false half is the dangerous one. `Sync`,
`NewEpoch` and `InitiatePayout` need no manager authority, so funder downtime does
not stop rewards: it FREEZES THE ENTRY SET while accrual and payouts continue.
Peers that stopped mirroring keep earning; peers that started cannot begin. That
shaped the whole design.

Liveness honesty (SPEC 2.4). The status record carries no `healthy`/`ok`/`up`
boolean and no precomputed staleness, because a wedged loop cannot report its own
wedging -- whatever it last wrote stays there, so a writer-set flag reads true
forever after the failure it exists to reveal. The reader derives staleness from
`last_cycle_completed_at` against `observed_at` and its own clock. A recursive
JSON-key test enforces the absence at every nesting depth; asserting on keys and
never substrings, since `ProverState::Running` legitimately serializes the VALUE
"running". The one legal staleness signal is chain-derived (SPEC 12.4: 48 hours
AND a non-zero reserve, from the singleton's own spend history) and lives on the
distributor read, where a wedged prover cannot fake it.

Self-exclusion is a compile error, not a habit. dig-node#261's lesson is that an
invariant enforced on some paths is not an invariant. `admit` is the single
admission point, checks both SPEC 5.2 coordinates (own peer_id OR a payout puzzle
hash this wallet controls), and mints an `AdmittedPeer` with private fields and no
public constructor -- so `EntryAction::Add` cannot be built by a path that skipped
admission.

A prover's own fault can never strike a peer. `GateError` is a distinct type from
`GateIneligibleReason` and `record_prover_fault` takes `&self`, so SPEC 3.6 clause
4 is enforced by the borrow checker rather than by comment. Without that, a
misconfigured operator -- one missing mirror-collateral epoch ordinal -- would
strike every peer at once and evict its entire 250-entry set in three hours, each
eviction a fee it pays plus a settlement out of its own reserve.

The money bounds are stated where a human reads them (`rewards/mod.rs`): 24
bundles/day, 192 entry actions/day, a fee ceiling of 24x the configured standard
fee, 192 removals/day worst case with 96/day sustained churn. Recorded honestly:
SPEC 6.3's rate bound and fee ceiling are ONE control, not two.

Three gate rounds, every leg fresh-context. Round 3 at this head: reviewer PASS,
adversarial decider RATIFY (leg closed), security CHANGES-REQUIRED on a finding
the decider ratified deliberately -- adjudicated in
https://github.com/DIG-Network/dig-node/pull/593#issuecomment-5601784815 and
carried to #3265 with the remedy corrected, because the proposed fix would have
persisted a poison flag to the very store whose writes were failing.

Found and fixed under gate: a census ordinal off by one in both directions (SPEC
4.6 requires n-1 exactly); an unreachable grace window leaving a named constant
with no reader; a missing `NewEpoch` spend; an absent-ordinal path attributing a
prover fault to peers; and a daily fee ceiling 24x too high because a per-bundle
fee was consumed as a daily ceiling.

Refs DIG-Network/dig_ecosystem#3250

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

* feat: serve dig.getRewardProverStatus at Tier::Control (#5…
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