Skip to content

fix(wallet): a synced phase must carry the height that bounds it - #500

Merged
MichaelTaylor3d merged 7 commits into
mainfrom
loop/495-syncstatus-honesty
Sep 2, 2026
Merged

fix(wallet): a synced phase must carry the height that bounds it#500
MichaelTaylor3d merged 7 commits into
mainfrom
loop/495-syncstatus-honesty

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — gate round in progress.

Closes #495

What was wrong

control.wallet.syncStatus composed its payload from two values chosen independently: phase,
from the arm ladder in SyncHandle::status, and peak_height, read straight off SyncState.
Nothing tied them together, so {phase: "synced", peak_height: null} — a positive claim that the
replica is current, beside a refusal to say what height it is current AT — was a state the type
could hold and the code could reach.

The predicate behind that arm answered true whenever EITHER height was missing:

pub(crate) fn is_following(replica: Option<u32>, peers: Option<u32>) -> bool {
    match (replica, peers) {
        (Some(replica), Some(peers)) => peers.saturating_sub(replica) <= FOLLOWING_TOLERANCE,
        _ => true,
    }
}

Both None arms are production-reachable:

peak_height: null is not on its own evidence of staleness — it can mean the answer simply carries
no height. What is wrong is the PAIRING with a positive currency claim.

The asymmetry that decided the fix, taken from is_following's own doc: its permissive arm was
justified entirely in terms of an unmeasured PEER tier — "a missing measurement must not be spent
as evidence against the replica"
— and offered no justification at all for the unmeasured-REPLICA
arm. Those are different things. An absent peer height is a missing second opinion; an absent
replica height is the subject of the claim having no measurement whatsoever. And a phase is not the
absence of an accusation: synced ASSERTS currency, and Syncing's own doc already covers both
cases — "the replica is otherwise not both caught up AND currently following the chain".

The fix — a shape change, not a value change

The claim now travels with its evidence, so the dishonest pairing has no constructor rather than
merely no fixture.

  • is_following is gone. In its place, FollowingEvidence::measure(replica, peers) -> Option<Self> (sync_supervisor.rs:570-620) refuses both unmeasured arms and the
    out-of-tolerance case. The tolerance itself is unchanged.
  • A private mod settled (sync_supervisor.rs:622-700) holds SettledPhase, whose fields the
    parent module cannot reach — module-scoped privacy is what actually buys the enforcement here, and
    the module doc says so. SettledPhase::synced demands a FollowingEvidence and takes the
    reported height FROM that evidence rather than from a separately read field. SyncPhase::Synced
    now appears exactly once in non-test construction, at sync_supervisor.rs:652, inside that
    constructor.
  • SyncHandle::status and status_without_supervisor build a SettledPhase. The arm ORDER is
    untouched — it is load-bearing and heavily documented; only the Synced predicate and the source
    of its height changed.
  • replica_answer_is_current (rpc.rs:1085) adopts the same predicate. Behaviour there is
    identical — its two early-return guards were exactly the None arms of measure — but the
    narrowing now lives in one place instead of two. Its rustdoc claimed the two endpoints could not
    disagree about the same moment; that claim was FALSE when written and is true now.
  • SyncPhase::NoWalletEnrolled's doc claimed "This says the chain replica is current". That
    arm checks no heights at all. The doc is corrected; the arm's behaviour is deliberately NOT
    changed, because requiring currency there would regress dig_ecosystem#2609 back to reporting a
    default install as forever catching up.

The peer-tier-unknown case reports syncing with chia_peer_peak_height: null beside it, which is
what distinguishes "no second opinion" from "measurably behind".

No wire change. The phase set, its spellings, ALL, as_wire, and the six fields
control.wallet.syncStatus emits are all untouched, so no dig-node-control-interface release is
required.

Why this is not cosmetic

server.rs:2843 feeds this phase into FundingObservation::classify (wallet_funded.rs:39). A
zero balance under synced: true classifies as ObservedEmpty — a positive assertion that the
wallet was observed empty — and under false as CannotSay. An unbounded synced therefore let
the node assert an observation it was not entitled to. The latch is monotonic and nothing ever
records "not funded", so withholding the claim is strictly the safe direction.

How it was verified

RED first, at the decision, against unmodified production code:

test result: FAILED. 762 passed; 4 failed; 1 ignored; 0 measured; 0 filtered out

Each of the four failed on its own assertion, and the grid failed at
replica_peak=None peer_peak=None latched=true session=Connected may_write=true watched=None wallet_enrolled=false. That RED run IS the revert-proof; no synthetic revert was needed.
0 filtered out is quoted deliberately — a cargo filter that matches nothing prints
0 passed; N filtered out and exits 0.

GREEN: test result: ok. 766 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out.
Clippy with -D warnings clean, cargo fmt -p dig-wallet --check clean, and cargo check -p dig-wallet clean again after merging main.

The acceptance asks for unreachability rather than an absent fixture, so
a_synced_phase_always_carries_the_heights_that_bound_it enumerates the FULL cross-product of the
ladder's inputs — replica peak, peer peak, latched flag, session state, write trust, watched set,
enrolment — and asserts that a Synced phase implies both heights are present, over every
combination. It also counts the combinations that DID reach Synced and asserts that count is
non-zero
, because an implication over an input space that never reaches Synced passes against an
implementation that never emits it.

Live control reading, 2026-09-02 03:56Z, installed node 0.206.0, read-only, service untouched:

$ dign wallet sync-status --json
{"phase":"synced","peak_height":9233876,"chia_peer_count":8,"subscription_peer_count":1,
 "chia_peer_peak_height":9233876,"watched_addresses":3}

Corroborated against an independent source at the same minute — api.coinset.org
get_blockchain_state reported peak 9233876, delta 0. The second source is cited rather
than the node's own word because a node reporting synced against a fabricated chain prints exactly
what a healthy one prints. This machine is genuinely at the tip, so it is the CONTROL: it does not
exhibit the defect, and it must keep reporting synced after the change. The probe is saved as
.claude/scripts/corroborate-wallet-peak.sh in the superproject.

Tests that encoded the defect, and one that would have gone vacuous

an_unmeasured_height_leaves_the_phase_unchanged asserted SyncPhase::Synced for BOTH unmeasured
arms — the reported pairing, asserted as correct behaviour. It is reversed rather than deleted, and
its doc now records why the old expectation was wrong instead of quietly dropping it.

Four sibling tests had chosen ChainPeerTier::UNOBSERVABLE as a deliberately inert don't-care axis.
One matters more than the rest: a_refused_writer_is_not_reported_as_synced (dig_ecosystem#2666)
asserts that the phase is not Synced, and its doc explicitly picked an unobservable tier so that
the old permissive predicate "cannot be the thing that fails the assertion".
After this change an
unobservable tier withholds Synced by itself, so that test would have stayed green against a node
that never learned about write-refusal at all — a pass with no remaining connection to its ticket.
All four now run on a measured tier level with the replica, so the axis each test varies stays the
only thing that can fail it.

The general lesson, worth more than the four names: after a change that makes a previously-permissive
input newly decisive, the grep to run is not "which tests fail" but "which tests chose this input
precisely because it was inert"
.

Blast radius

Established by grep and direct read, not by gitnexus — its index for this repo is ~301 commits
stale and returns a false-safe impactedCount: 0.

is_following: two production callers (SyncHandle::status, replica_answer_is_current), two test
callers in sync.rs, four doc references; zero references remain anywhere. SyncPhase::Synced: one
construction, one downstream comparison in dig-node-service, eight test assertions. StallWatch
does not use this predicate — session lifetime is untouched.

Boundary against #490

Checked before building. control.wallet.coins and its siblings (#490 / PR #492) compute synced
from replica_answer_is_current, which already narrowed both arms; this ticket's phase came from a
different producer, a different type and a different predicate that narrowed neither. Merging #492
would have left the pairing exactly as emittable. The only overlap is textual — both edit rustdoc in
rpc.rs — and this branch merges main, not the reverse.

Versions

Root 0.239.0 -> 0.242.0; dig-wallet 0.47.0 -> 0.48.0. Minor rather than patch: this changes
what an existing, unchanged consumer is told about states it can already be in. main moved to
f1170d0 (#489) mid-flight and took dig-wallet 0.47.0, the version this branch had reserved; the
merge commit resolves that, and both versions above were re-read from the files on disk afterwards
rather than from the commit log.

Salvage anchor for dig-node#495: `control.wallet.syncStatus` can still emit
`{phase: "synced", peak_height: null}` — a positive currency claim beside a
refusal to say what height it is a claim about. Version bump only; the fix
follows in this branch.

Refs #495

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d and others added 3 commits September 1, 2026 21:22
`control.wallet.syncStatus` can emit `{phase: "synced", peak_height: null}`:
a positive claim of currency beside a refusal to say what height it is
current at. Both unmeasured arms of `is_following` are production-reachable.

Adds three tests and REVERSES one that encoded the defect as correct
behaviour. All four fail for the right reason against today's code:
762 passed; 4 failed; 1 ignored; 0 measured; 0 filtered out.

Co-Authored-By: Claude <noreply@anthropic.com>
`control.wallet.syncStatus` could emit `{phase: "synced", peak_height:
null}` -- a positive claim of currency beside a refusal to say what height
it is current AT. Both unmeasured arms of the old `is_following` predicate
are production-reachable: the latch-over path sets `initial_sync_complete`
without ever writing a peak, and the peer tier reports no height until one
of the node's own peers speaks.

`is_following` is replaced by `FollowingEvidence`, which carries the two
heights whose gap establishes currency and cannot be constructed when
either is unmeasured. A private `settled` module then produces the phase
and the reported peak TOGETHER: `SettledPhase::synced` demands the
evidence and takes the height FROM it, and is the only route to
`SyncPhase::Synced` in the crate's non-test code. The pairing is
unrepresentable rather than unlikely.

`replica_answer_is_current` converges onto the same constructor, dropping
its two duplicated guards -- behaviour there is unchanged, and the
structural agreement its doc claimed between the two endpoints is now
true, having been false when written.

No wire change: the phase set, its spellings and the six emitted fields
are untouched.

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

# Conflicts:
#	Cargo.lock
#	Cargo.toml
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Independent correctness gate — IN PROGRESS, not the verdict. Head read: 5d3f25ee6fad4c8f7061b052c40f1100f604bc01.

Confirmed so far, by grep + direct read (gitnexus index for this repo is ~301 commits stale and returns a false-safe impactedCount: 0, so it was not used):

  1. Producer set is closed. WalletSyncStatus { .. } is constructed at exactly two sites, both in crates/dig-wallet/src/sage/sync_supervisor.rs (:500 in SyncHandle::status, :713 in status_without_supervisor), and both now take phase+peak from a settled::SettledPhase. SyncPhase::Synced appears in non-test code only inside mod settled (sync_supervisor.rs:652). crates/dig-wallet/src/sage/rpc.rs:1128 returns the type but delegates rather than constructing.

  2. replica_answer_is_current is behaviour-identical on both arms. Old: None replica -> false; None peer -> false; else gap <= FOLLOWING_TOLERANCE. New: FollowingEvidence::measure(replica, peer).is_some() -> None on either absent height, else the same gap test. Same truth table.

  3. Arm order in SyncHandle::status is preservedNotStarted, then the watched == Some(0) branch (WalletNotUnlocked / NoWalletEnrolled), then Synced, then Syncing. The Synced guard moved from an && chain into FollowingEvidence::measure(..).filter(|_| initial_sync_complete && peers >= 1 && session_may_write), which is the same conjunction.

Still running: the gate-rot revert-proofs on the four amended sibling tests, the sweep for unamended tests now vacuous, the wire-shape check, and the FundingObservation::classify consumer.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Auditing 5d3f25ee6fad4c8f7061b052c40f1100f604bc01 (resolved from gh pr view 500 --json headRefOid; matches the dispatch brief). Posting findings as they resolve so nothing is lost to a watchdog.

Resolved so far — no defect in these

1. Provenance of the untrusted input (tier.peak_height). Traced the whole chain rather than trusting the doc:

SyncHandle::statusChainPeerTier.peak_height (crates/dig-wallet/src/sage/fallback.rs:125) ← ChiaChainTransport::peer_tier (crates/dig-wallet/src/sage/chain.rs:431) ← chia_query::ChiaQuery::peer_peak_height (chia-query 0.20.0, src/lib.rs:401) ← pool.peak_height() (src/peer/pool.rs:421), which is an AtomicU32 written only by peak.fetch_max(new_peak.height, ...) in the per-session receiver handler (src/peer/pool.rs:884) on an inbound NewPeakWallet.

The security-relevant property, stated in that crate's own doc at pool.rs:170: it is a fetch_max across every session. That means:

  • A hostile peer can only ever RAISE the figure, never lower it. It is also monotonic for the pool's lifetime — the value outlives the session that wrote it.
  • observed_peak() maps the pool's internal 0 to None, so None genuinely means no peer has ever announced, and no peer action can produce None.

2. Question 3 — the money consumer is not reachable in the dangerous direction. FundingObservation::should_latch (crates/dig-node-service/src/wallet_funded.rs:74) is matches!(self, Self::Funded). ObservedEmpty and CannotSay both decline to latch, and Funded is decided by balance > 0 || pending > 0 before synced is consulted (wallet_funded.rs:39-46).

So this PR's effect on that path — making synced false in the peer-unmeasured case, i.e. ObservedEmpty becoming CannotSay — moves the classification between two variants that behave identically at the only decision site. It cannot cause a funded wallet to be described as disposable, because the latch does not consult synced on the non-zero branch at all. A peer that suppresses the phase moves ObservedEmpty -> CannotSay, which changes nothing. Clean.

3. Question 5, first half — is_following has no surviving callers. grep -rn "is_following" --include=*.rs . returns zero hits at this head. SyncPhase::Synced is constructed in exactly one non-test place: crates/dig-wallet/src/sage/sync_supervisor.rs:652, inside mod settled.

4. routing::route does NOT consume the phase. crates/dig-wallet/src/sage/routing.rs:49 takes db_synced: bool, not a SyncPhase. So the read-source decision is untouched by this change — there is no "more Syncing means every read goes to the fallback tier" egress amplification through that path.

Still open

replica_answer_is_current behavioural equivalence (incl. an argument-evaluation-order change I want to pin down), StallWatch, FOLLOWING_TOLERANCE, the #[doc(hidden)]/#[cfg(test)] seams, and the suppression/amplification analysis in both directions.

gitnexus is stale for this repo, so all of the above is grep + direct read, as briefed.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (2/2 before the verdict)

Head 5d3f25ee6fad4c8f7061b052c40f1100f604bc01. Both attack directions now resolved.

Q1 — can a hostile peer MANUFACTURE a synced claim? Not newly, and this PR strictly REMOVES one manufacture path.

To forge synced the attacker needs peer_peak - replica_peak <= FOLLOWING_TOLERANCE (= 4, sync_supervisor.rs:220) against a replica that is actually far behind — i.e. it must make the peer figure LOW.

It cannot. The figure is peak.fetch_max(...) over every session (chia-query-0.20.0/src/peer/pool.rs:885), so a hostile peer can only push it up, and any single honest peer announcing the true tip defeats the attempt. Lowering it requires an eclipse — control of every peer in the pool — and an eclipsed node's replica is attacker-fed anyway, so the phase is not the weakest link in that scenario. That is unchanged by this PR.

What this PR removes: before it, is_following answered true on _ => true whenever either height was None (sync_supervisor.rs:580 at origin/main). A peer that simply stayed SILENT — never sending NewPeakWallet — left peer_peak_height() == None and got SyncPhase::Synced for free, over a replica of any staleness. FollowingEvidence::measure (sync_supervisor.rs:611-618) returns None on peers?, so silence now yields Syncing. Strategic silence was the cheapest manufacture primitive available and this PR closes it. That is the security value of the change and it is real.

Q2 — can a hostile peer SUPPRESS a synced claim? Yes, permanently — but it is PRE-EXISTING and byte-for-byte unchanged.

NewPeakWallet { height: u32::MAX } from a single peer sets the pool's shared AtomicU32 via fetch_max and it never comes back down — it outlives the session that wrote it, for the pool's lifetime. evict_lagging_peers does not help: its median reference (pool.rs:622) protects the eviction decision, not the reported peak, and an inflating peer looks ahead rather than behind so it is never evicted for lag.

Effect at this head: measure(Some(r), Some(u32::MAX)) -> None -> Syncing forever, and replica_answer_is_current -> false forever on every money read.

This PR does not introduce it. At origin/main, is_following(Some(r), Some(u32::MAX)) returns false by the identical saturating_sub comparison, and replica_answer_is_current's two let..else guards both pass on Some/Some and hand off to the same expression. Identical before and after. Rated MEDIUM, pre-existing, NOT gating — recommend a follow-up ticket against chia-query, not this PR, since the missing bound is in that crate's pool.

No NEW attacker-controllable suppression. The one arm this PR flips (peers == None -> Syncing) is not attacker-reachable: observed_peak() maps the pool's internal 0 to None, fetch_max is monotonic, and there is no reset path, so no peer action can drive the figure back to None. It means only "no peer has ever spoken", which is the honest state the ticket is about.

No amplification, and this is the one I most wanted to be wrong about

I expected a wrongly-Syncing node to fall back to the coinset HTTP tier on every read — a peer-driven egress amplifier. It does not.

routing::route (crates/dig-wallet/src/sage/routing.rs:49) takes db_synced: bool, and its callers feed it db.is_synced() / replica_covers(...) (rpc.rs:1428, :1577, :2339, :2479, :3072) — never replica_answer_is_current. That method's only four call sites (rpc.rs:1479, :1622, :1849, :2065) each assign it to a synced: field on a result payload. It labels, it does not route. So suppression costs a truthful "I cannot say" and zero extra IO. No retry, re-dial or session teardown keys on the phase either — StallWatch::observe (sync_supervisor.rs:1326) has its own independent behind = p > r and is untouched by the diff.

Q6 partial — no new egress from the refactor

replica_answer_is_current did change argument-evaluation order: the old first let..else returned before awaiting the tier, whereas FollowingEvidence::measure(peak_height, self.chain_peer_tier().await.peak_height) (rpc.rs:1084-1089) evaluates the tier read unconditionally. Checked that it is harmless: chain_peer_tier (rpc.rs:1023) hits an override or fallback.peer_tier(), whose only two impls are the UNOBSERVABLE default (fallback.rs:147) and ChainTransport::peer_tier -> ChiaChainTransport::peer_tier (chain.rs:431), documented and verified as "This DIALS NOTHING" — it reads existing_client() and returns UNOBSERVABLE when none exists. No oracle call introduced, so the prohibition in that method's own doc still holds.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. Gate-rot revert-proofs, executed at 5d3f25e in an isolated worktree (C:/tmp/worktrees/gate495; the PR worktree was never mutated).

Baseline: 766 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out.

Method: three single-clause mutations of the Synced guard in SyncHandle::status, each deleting one conjunct from FollowingEvidence::measure(..).filter(|_| state.initial_sync_complete && observed.peers >= 1 && observed.session_may_write), then running each amended test by --exact name.

mutation test that SHOULD go red result
drop state.initial_sync_complete phase_ladder_not_started_syncing_synced FAILED (correct)
drop observed.session_may_write a_refused_writer_is_not_reported_as_synced FAILED (correct)
drop observed.peers >= 1 phase_is_syncing_when_caught_up_but_no_peer still passed

So two of the four amendments are proven to have restored real discrimination — in particular the dig_ecosystem#2666 refusal test does now fail against its own defect, which was the headline risk.

The third is a finding and is detailed in an inline comment. an_enrolled_wallet_mid_catch_up_still_reports_syncing (tests.rs:1596) was also confirmed vacuous under the drop-latch mutation; its property survives in the amended ladder test, so that one is non-gating.

Still running: whether ANY test in the 766 catches the dropped peer-count clause, and the RED-first proof of the new tests at 40bcabc.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security: PASS

Audited head: 5d3f25ee6fad4c8f7061b052c40f1100f604bc01 — resolved from gh pr view 500 --repo DIG-Network/dig-node --json headRefOid, not taken from the dispatch brief; it matches, and the worktree at C:/tmp/worktrees/dig-node-495 is at the same SHA with a clean tree. Read-only throughout: no checkout, no reset, no edit, no cargo fmt in that worktree. The one probe I compiled is a standalone file under C:/tmp/sec-495/, outside any checkout.

gitnexus's index for this repo is ~301 commits stale and returns a false-safe impactedCount: 0, so every claim below is grep + direct read, including reads of chia-query-0.20.0 from the vendored registry source.


Verdict by area

Area Finding
Manufacture a synced claim (Q1) Not newly possible, and this PR closes one existing primitive. Clean.
Suppress a synced claim (Q2) A permanent one-message denial primitive exists. Pre-existing, byte-for-byte unchanged, lives in chia-query. Not gating.
Money consumer (Q3) Structurally unreachable in the dangerous direction. Clean.
Unrepresentability (Q4) Holds end-to-end to the wire, within the scope its own doc claims. Clean.
is_following deletion (Q5) Behaviour-identical, proven by exhaustive probe + mutation. Clean.
Secrets / egress / logs / panics / 908 Nothing introduced. Clean.

1. This PR removes an attack primitive (the security value, stated plainly)

At origin/main, is_following's _ => true arm (sync_supervisor.rs:580) meant a Chia peer that simply never sent NewPeakWallet left peer_peak_height() == None and thereby handed the node SyncPhase::Synced over a replica of arbitrary staleness — with the other three clauses satisfied by an ordinary attached session. Strategic silence was the cheapest way to forge a currency claim, and it required no lie at all.

FollowingEvidence::measure (crates/dig-wallet/src/sage/sync_supervisor.rs:611) returns None on peers?, so silence now yields Syncing. That primitive is gone.

2. Q1 — a hostile peer cannot force the figure DOWN

Forging synced needs peer_peak - replica_peak <= FOLLOWING_TOLERANCE (= 4, sync_supervisor.rs:220) against a genuinely stale replica, i.e. the peer figure must be made low. Traced to source: it is peak.fetch_max(new_peak.height, ...) over every session (chia-query-0.20.0/src/peer/pool.rs:885, documented at :170). A hostile peer can only push it up; one honest peer announcing the true tip defeats the attempt. Lowering it requires a full eclipse, and an eclipsed node's replica is attacker-fed regardless — the phase is not the weakest link there. Unchanged by this PR.

3. Q2 — MEDIUM, pre-existing, NOT gating: an unbounded peer peak is a permanent denial primitive

A single NewPeakWallet { height: u32::MAX } sets the pool's shared AtomicU32 by fetch_max and it never comes back down — it outlives the session that wrote it, for the pool's lifetime. evict_lagging_peers does not help: its median reference (pool.rs:622) protects the eviction decision, not the reported figure, and an inflating peer looks ahead rather than behind so it is never evicted for lag.

Effect at this head: measure(Some(r), Some(u32::MAX)) yields None, so Syncing forever, and replica_answer_is_current is false forever on every money read (balance, coins, coinsByParent, coinById, coinSpend, arrivals).

This PR does not introduce it. At origin/main, is_following(Some(r), Some(u32::MAX)) returns false by the identical saturating_sub comparison, and the old replica_answer_is_current's two let..else guards both pass on Some/Some and reach the same expression. The exhaustive probe in section 6 covers u32::MAX explicitly and finds no divergence.

The shape of the gap is worth recording: dig-wallet already bounds its OWN replica peakdb.set_peak refuses an inflated claim, proven by the_guards_still_function_after_a_refused_inflation (crates/dig-wallet/src/sage/sync.rs:3353), whose doc says in as many words that an accepted u32::MAX "permanently DISABLES both" the phase and StallWatch. The peer-tier peak has no equivalent bound. Replica bounded, peer tier unbounded.

Recommended follow-up ticket against chia-query, not this PR: bound the announced peak, or make peer_peak_height() report a median/quorum rather than a maximum. Filing it here would be filing it in the wrong repo.

No NEW attacker-controllable suppression. The one arm this PR flips (peers == None becomes Syncing) is not attacker-reachable: observed_peak() maps the pool's internal 0 to None, fetch_max is monotonic, and there is no reset path, so no peer action can drive the figure back to None. It means only "no peer has ever spoken".

4. No amplification — the result I most expected to be wrong about

I expected a wrongly-Syncing node to fall back to the coinset HTTP tier on every read, giving a peer a cheap egress amplifier. It does not.

routing::route (crates/dig-wallet/src/sage/routing.rs:49) takes db_synced: bool, and its callers feed it db.is_synced() / replica_covers(...) (rpc.rs:1428, :1577, :2339, :2479, :3072) — never replica_answer_is_current. That method's only four call sites (rpc.rs:1479, :1622, :1849, :2065) each assign it to a synced: field on a result payload. It labels; it does not route. Suppression therefore costs a truthful "I cannot say" and zero extra IO.

Nothing retries, re-dials or tears down a session on the phase either: StallWatch::observe (sync_supervisor.rs:1326) carries its own independent behind = p > r and is untouched by the diff (the diff's last production hunk ends at :716).

5. Q3 — the money consumer cannot be driven either way

FundingObservation::should_latch (crates/dig-node-service/src/wallet_funded.rs:74) is matches!(self, Self::Funded). ObservedEmpty and CannotSay both decline to latch, and Funded is decided by balance > 0 || pending > 0 before synced is consulted (wallet_funded.rs:39-46).

So this PR's effect on that path — ObservedEmpty becoming CannotSay when the peer height is unmeasured — moves the classification between two variants that behave identically at the only decision site. It cannot cause a funded wallet to be described as disposable, because the latch never consults synced on the non-zero branch. A peer that suppresses the phase achieves nothing here.

6. Q5 — is_following deleted; the replacement is behaviour-identical, proven not asserted

grep -rn "is_following" --include=*.rs . returns zero hits at this head. SyncPhase::Synced is constructed in exactly one non-test place: sync_supervisor.rs:652, inside mod settled.

The worst outcome here would be a predicate that used to fail closed now failing open, so I did not settle for reading it. I extracted both bodies verbatim into a standalone rustc program and compared them exhaustively over every Option shape against a boundary-dense height space (0,1,2,3,4,5,6 around FOLLOWING_TOLERANCE; 999..1006; u32::MAX-1, u32::MAX; and the live node's 9233876 plus/minus the bound):

checked=324 disagreements=0 old_true_count=179
RESULT: BEHAVIOUR-IDENTICAL over 324 input pairs

old_true_count=179 of 324 is the non-vacuity check: the probe reached BOTH verdicts, so agreement is a measurement rather than two constant falses agreeing. And I mutated the probe to confirm it can go red — restoring the permissive arm as unwrap_or(0) yields assertion left == right failed: the refactor changed behaviour, left: 23, right: 0. The green is real.

Arm-ordering in SyncHandle::status is likewise equivalent: the .filter(...) reorders a conjunction over a pure, side-effect-free, non-await function, and the Synced arm's reported height is Some(evidence.replica_peak()) — the same state.peak_height value, unwrapped. The healthy live control in the ticket (peak 9233876, peers level) still reports synced.

7. Q4 — unrepresentable, and the doc's scope is honest

  • settled::SettledPhase's fields are module-private, and Rust privacy is module-scoped, so SyncHandle::status genuinely cannot build one by literal. synced() is the only route to SyncPhase::Synced and takes its height from the evidence.
  • Both producers are guarded: SyncHandle::status as above, and status_without_supervisor (:709) which is unconditionally NotStarted.
  • WalletBackend::wallet_sync_status (rpc.rs:1126) delegates to exactly those two and constructs nothing.
  • The wire assembly at control.rs:2562 reads all six fields off the same s value, so phase and peak_height cannot be sourced separately at the boundary.
  • Neither SyncPhase nor WalletSyncStatus derives Deserialize (sync_supervisor.rs:247, :329) — the node never parses remote input into these types.

Recorded as LOW / defense-in-depth, not gating: WalletSyncStatus has all-pub fields and SyncPhase::Synced is a public variant, so the pairing is unrepresentable within dig-wallet's producers rather than as a type-level invariant an external consumer inherits. No consumer constructs one — verified; the only non-dig-wallet hits on that identifier are the unrelated ControlAction::WalletSyncStatus enum variant and a test parse. There is no attacker path (an attacker does not get to call Rust constructors), and the doc at :636 scopes its claim correctly — "the only route ... in this crate's non-test code". Noted only so nobody later reads it as stronger than it is.

Test seams checked, none can bypass the invariant. detached_for_tests, set_trust, set_watched, with_chain_peer_tier_for_tests and AppState::with_chia_peer_count_for_tests are #[doc(hidden)] pub, so they do compile into production builds (only force_initial_sync_complete_for_test is #[cfg(test)]). The only caller of the AppState one is crates/dig-node-service/tests/server.rs:164, an integration-test binary. None of them can produce the forbidden pairing: they set observed/tier state, and SettledPhase::synced still demands the evidence. Pre-existing and unchanged by this PR.

8. Standard sweep on the delta

  • Secrets — no key, token or credential added, logged or committed. The diff touches seven files, all sync-status; no signing or spend path, so the 908 boundary is intact (the node signs nothing here).
  • New egress — none. The refactor did change argument-evaluation order (rpc.rs:1084): the old first let..else returned before awaiting the tier, whereas measure(peak_height, self.chain_peer_tier().await.peak_height) evaluates it unconditionally. Harmless — chain_peer_tier (rpc.rs:1023) hits an override or fallback.peer_tier(), whose only two impls are the UNOBSERVABLE default (fallback.rs:147) and ChiaChainTransport::peer_tier (chain.rs:431), documented and verified as "This DIALS NOTHING". The oracle prohibition in that method's own doc still holds.
  • Log injection — the diff adds no log or print site at all.
  • Panics on remote input — no unwrap/expect/indexing/unsafe on any added production line; every unwrap() in the diff is in #[cfg(test)] code. measure uses saturating_sub, identical to the predicate it replaces, and u32::MAX is covered by the probe.
  • Depsdig-wallet 0.47.0 to 0.48.0 and the workspace 0.239.0 to 0.242.0; no third-party dependency added, and no pin loosened.

9. Test quality, from a security standpoint

a_synced_phase_always_carries_the_heights_that_bound_it (sync_supervisor/tests.rs:4952) walks the full cross-product of every input SyncHandle::status reads and asserts that Synced implies both heights are present, with a non-vacuity counter (synced_reached > 0) that fails if no combination reaches the phase. That is the right shape: without it the implication would hold trivially against an implementation that simply never emits Synced — the strongest false green available here, and exactly the class of vacuous gate this contract keeps catching. The reversed test is renamed with its reasoning stated rather than quietly weakened.


Not gating, for the record

  1. MEDIUM, pre-existing, other repo — the unbounded NewPeakWallet height in chia-query's pool fetch_max is a permanent one-message denial primitive against the phase and against every money read's synced label. Recommend a follow-up ticket against chia-query (bound the announced peak, or report a median/quorum rather than a max).
  2. LOW, defense-in-depthWalletSyncStatus's public fields mean the invariant is enforced at the producers, not by the type. Nothing constructs one externally and the doc does not overclaim.

Neither is introduced by this diff. PASS. I have not merged, undrafted, pushed or edited anything.

@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 reviewed: 5d3f25ee6fad4c8f7061b052c40f1100f604bc01 (re-resolved from gh pr view 500 --json headRefOid, not from the dispatch brief). Independent correctness gate, fresh context, no code edited. gitnexus was NOT used — its index for this repo is ~301 commits stale and returns a false-safe impactedCount: 0; everything below is grep, direct read, or execution.

All work ran in a private worktree at C:/tmp/worktrees/gate495 cut from this SHA. The PR's own worktree was never mutated; the private one was restored to 5d3f25e with an empty git status --porcelain and has been removed.

Verified by EXECUTION

Baseline. cargo test -p dig-wallet --lib gave 766 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 16.80s.

The new tests are genuine revert-proofs, not decoration. Checked out 40bcabc (the tests-only RED commit, before a465d83 applied the fix) and ran each by exact name. All four FAILED there:

  • a_replica_with_no_peak_of_its_own_is_never_reported_as_synced gave 0 passed; 1 failed; 766 filtered out
  • a_replica_with_no_second_opinion_is_never_reported_as_synced gave 0 passed; 1 failed; 766 filtered out
  • a_synced_phase_always_carries_the_heights_that_bound_it gave 0 passed; 1 failed; 766 filtered out
  • an_unmeasured_height_on_either_side_withholds_the_synced_claim gave 0 passed; 1 failed; 766 filtered out

That settles point 2: the cross-product test discriminates against the pre-fix implementation, and its non-vacuity counter (synced_reached > 0) is asserted and does fire — a run in which no combination reached Synced would fail on that assertion, and the test passes at head.
Gate-rot proofs. Three single-clause mutations of the Synced guard in SyncHandle::status (sync_supervisor.rs:490-497), each deleting one conjunct from the filter state.initial_sync_complete && observed.peers >= 1 && observed.session_may_write:

mutation test expected to go red result
drop state.initial_sync_complete phase_ladder_not_started_syncing_synced FAILED — correct
drop observed.session_may_write a_refused_writer_is_not_reported_as_synced FAILED — correct
drop observed.peers >= 1 phase_is_syncing_when_caught_up_but_no_peer still passed (see finding 1)

The headline risk — dig_ecosystem#2666's refusal test going silently vacuous — is genuinely repaired: with session_may_write deleted the test fails, which it would not have done had the fixture kept ChainPeerTier::UNOBSERVABLE.

Whole-suite sensitivity to the un-caught clause. Running the FULL 766 under the drop-peer-count mutation gave 765 passed; 1 failedsage::sync_supervisor::tests::stall_evidence_survives_the_end_of_a_session catches it. So the clause is covered by the suite; it is only the test named for it that does not discriminate on it. That is why finding 1 is non-gating.

the_following_tolerance_holds_at_the_bound_and_fails_one_beyond_it is untouched by the diff and passes at head and under all three mutations.

Verified by READING

  • The synced-with-null-height pairing is unreachable on every emitting path. The struct literal is written at exactly two sites, both in sync_supervisor.rs (:500, :713), and both take phase and peak from a settled::SettledPhase. In non-test code SyncPhase::Synced is written in exactly one place, sync_supervisor.rs:652, inside SettledPhase::synced, which takes its height from FollowingEvidence::replica_peak() rather than from state.peak_height. Nothing in dig-node-service or elsewhere in the workspace constructs the struct. The module-privacy argument in the doc is correct: fields private to mod settled are unreachable from the parent, so a literal beside SyncHandle::status cannot bypass the constructors.
  • Arm order preserved (point 5). NotStarted, then the watched == Some(0) branch (WalletNotUnlocked / NoWalletEnrolled), then Synced, then Syncing — unchanged. The Synced guard moved from an and-chain into measure(..).filter(..), which is the same conjunction. StallWatch, SESSION_MAX_LIFETIME, STALL_AFTER and session teardown are untouched by the diff; the only sync.rs changes are two test call sites and a doc-link rename.
  • replica_answer_is_current is behaviour-identical on both arms (point 5). Old: absent replica height gives false; absent peer height gives false; otherwise the tolerance test. New: FollowingEvidence::measure(replica, peer).is_some(), which is None on either absent height and otherwise the same tolerance test. Same truth table.
  • No wire change (point 6). The only line the diff touches inside declare_sync_phases! is the rustdoc above NoWalletEnrolled => "no_wallet_enrolled"; the token, the variant set, ALL, as_wire and the serde attributes are unchanged. control.rs is not in the diff at all, so wallet_sync_status still emits the same six fields (control.rs:2566-2572).
  • The consumer moves conservatively (point 7). server.rs:2843 derives its synced flag from phase == Synced, which this change makes strictly harder to reach, so cases migrate from ObservedEmpty toward CannotSay in wallet_funded.rs:39. The Funded branch is decided by the balance before synced is consulted, so a funded wallet cannot become describable as disposable by this change; the latch is monotonic, so the only effect is that a genuinely empty wallet latches later rather than wrongly.
  • The NoWalletEnrolled doc edit is honest (point 8). The arm's behaviour is unchanged, and the new text explicitly withdraws the currency claim the old sentence made and says why requiring currency there would regress dig_ecosystem#2609. It now describes exactly what the arm checks. The rewritten rpc.rs:1072-1089 passage scopes its unrepresentability claim to that path, which is true as written.
  • Craft and coverage (point 9). FollowingEvidence and mod settled read cleanly, names carry intent, and the comments explain why rather than what. Coverage moves up: roughly 199 added test lines against roughly 60 added production lines, and every SettledPhase constructor is exercised by the cross-product grid.

Non-gating findings

None of these blocks the merge, and no review thread is opened for them, so required_conversation_resolution stays clear. Ranked.

1. phase_is_syncing_when_caught_up_but_no_peer does not discriminate on the axis it is named forcrates/dig-wallet/src/sage/sync_supervisor/tests.rs:1124-1141.

Deleting observed.peers >= 1 from the Synced guard leaves it green. The reason is that set_connected(0) also clears trust, so the second assertion is carried by observed.session_may_write rather than by the peer count. This is pre-existing rather than introduced here — the same held before the amendment — and the clause is caught elsewhere by stall_evidence_survives_the_end_of_a_session, which is why this is not gating. If it is ever tightened, the cheap fix is to re-assert trust after set_connected(0) so the peer count is the only failing conjunct. The fix must NOT be to relax the assertion.

2. an_enrolled_wallet_mid_catch_up_still_reports_syncing is now vacuous with respect to its own propertycrates/dig-wallet/src/sage/sync_supervisor/tests.rs:1596-1608.

Its fixture sets neither a replica peak nor a peer tier, so under the new rule it reaches Syncing for the unmeasured-height reason; deleting state.initial_sync_complete leaves it green (confirmed under the drop-latch mutation). Before this change it would have gone red. This is precisely the class the four amendments were made for, and it was missed by the sweep. It is non-gating only because the property it names is still guarded by the amended phase_ladder_not_started_syncing_synced, which does fail under that mutation. Fix, if taken: give it a measured peak and a level tier_at(..), matching what the four siblings received.

3. The struct's fields are pub and SyncPhase::Synced is a public variant, so the pairing is unrepresentable along the producing path but not in the TYPE — crates/dig-wallet/src/sage/sync_supervisor.rs:330-335. No consumer constructs one today (checked across the workspace), and the doc correctly scopes its claim to this crate's non-test code, so nothing is wrong as merged. Worth considering privatising the two fields behind accessors, or #[non_exhaustive], if the invariant is meant to survive a future consumer.

4. Minor: replica_answer_is_current lost a short-circuitcrates/dig-wallet/src/sage/rpc.rs:1088-1093. The old form returned early without awaiting chain_peer_tier() when the replica peak was absent; the new form always awaits it. peer_tier() is a local read (fallback.rs:147 defaults to UNOBSERVABLE), so this is a cost note, not a correctness one, and the returned value is unaffected.

`an_enrolled_wallet_mid_catch_up_still_reports_syncing` ran against an
UNOBSERVABLE peer tier and a db with no recorded peak. Before this PR the
`is_following(None, None)` answer was `true`, so an unfinished catch-up was the
only input that could produce `Syncing` and the test discriminated.

This PR made both of those inputs independently decisive, and the test became
vacuous with respect to #2609: it stayed green with `state.initial_sync_complete`
deleted from the `Synced` arm. Give it a MEASURED peer tier LEVEL with a recorded
replica peak, as its four siblings already have, so every other route to `Syncing`
is closed. Measured both ways: green with the clause removed under the old
fixture, red under the new one.

No production code changed.

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

Copy link
Copy Markdown
Contributor Author

Scoped re-gate of 5d3f25e..1e6c10c — IN PROGRESS, not the verdict

Head resolved from the remote: 1e6c10c. Prior PASS verdicts (loop-security, loop-reviewer, 2026-09-02 ~05:15Z) were posted at 5d3f25e; this pass audits only the delta.

Scope of the delta — one file, tests only.

crates/dig-wallet/src/sage/sync_supervisor/tests.rs | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)

No production code changed in this delta, so the earlier PASS verdicts still cover the production surface. The version bump is out of scope for this pass (a tweaker is concurrently merging main).

The repair, and why it is non-vacuous. an_enrolled_wallet_mid_catch_up_still_reports_syncing (tests.rs:1601-1618) previously called handle.status(&db, ChainPeerTier::UNOBSERVABLE) against a db with no recorded peak. This PR made an unmeasured chain height and an unmeasured replica height each independently sufficient to withhold Synced, so those two inputs — inert before, decisive now — kept the test green even with the property it names deleted. The repair sets db.set_peak(FROZEN_REPLICA_PEAK, "aa") and passes tier_at(FROZEN_REPLICA_PEAK) (five peers, level), matching the four sibling tests.

Under the new fixture the Synced arm at sync_supervisor.rs:490-492 has every conjunct satisfied except one:

FollowingEvidence::measure(state.peak_height, tier.peak_height).filter(|_| {
    state.initial_sync_complete && observed.peers >= 1 && observed.session_may_write
})

measure now returns Some (both heights measured and level), observed.peers >= 1 holds (set_connected(1)), session_may_write holds (set_trust(true)), and the watched set is non-empty (set_watched(3, true)). initial_sync_complete is never set in this test, so it is the sole remaining route to Syncing — which is exactly the property the test's name claims. Every other route is closed.

Independent mutation proof in progress in my own detached worktree (never the shared checkout): state.initial_sync_complete && removed from the Synced filter, then cargo test -p dig-wallet --lib an_enrolled_wallet_mid_catch_up_still_reports_syncing. Verdict follows, with the test count asserted non-zero (a filter matching nothing exits 0).

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

VERDICT: PASS — scoped re-gate of 5d3f25e..1e6c10c

Head audited: 1e6c10c, resolved from the remote. Prior PASS verdicts (loop-security, loop-reviewer, 2026-09-02 ~05:15Z) were posted at 5d3f25e; this pass covers only the delta and does not re-open the production surface.

1. Delta scope — one file, tests only

crates/dig-wallet/src/sage/sync_supervisor/tests.rs | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)

No production code changed, so the earlier PASS verdicts still stand over the production diff and the re-gate does not widen. The version bump is out of scope for this pass.

2. The repaired test is NON-VACUOUS — measured, not read

The assertion under audit is assert_eq!(status.phase, SyncPhase::Syncing) in an_enrolled_wallet_mid_catch_up_still_reports_syncing (crates/dig-wallet/src/sage/sync_supervisor/tests.rs:1617).

The property it names is that a completed-catch-up flag being ABSENT withholds Synced. The Synced arm is a four-way conjunction at crates/dig-wallet/src/sage/sync_supervisor.rs:490-492:

FollowingEvidence::measure(state.peak_height, tier.peak_height).filter(|_| {
    state.initial_sync_complete && observed.peers >= 1 && observed.session_may_write
})

The old fixture (ChainPeerTier::UNOBSERVABLE + a db with no peak) failed measure on TWO independent inputs that this PR newly made decisive, so the assertion was satisfied by an outcome rather than by the property — the classic freedom-of-input vacuity. The repair levels both heights (db.set_peak(FROZEN_REPLICA_PEAK, "aa"), tier_at(FROZEN_REPLICA_PEAK) = five peers at the same height), matching the four sibling tests, so measure returns Some, observed.peers >= 1 holds via set_connected(1), session_may_write holds via set_trust(true), and the watched set is non-empty via set_watched(3, true). initial_sync_complete is never set, leaving it the sole remaining route to Syncing.

Nearest-wrong-implementation check. The nearest wrong implementation is the Synced arm without its initial_sync_complete conjunct. I mutated exactly that in my own detached worktree (git worktree add --detach, never the shared checkout) — removed state.initial_sync_complete && from the filter — and ran one crate:

run cargo test -p dig-wallet --lib an_enrolled_wallet_mid_catch_up_still_reports_syncing
mutated (conjunct deleted) running 1 testtest result: FAILED. 0 passed; 1 failed; 766 filtered out
reverted (head as pushed) running 1 testtest result: ok. 1 passed; 0 failed; 766 filtered out

Test count is 1 in both directions, so this is not the exit-0/zero-tests-matched trap. The mutation is killed; the test now discriminates the property it names. The lane's claimed mutation proof is real and I reproduced it independently rather than taking it on trust.

3. Housekeeping

The added doc block (tests.rs:1596-1602) states WHY the fixture changed — which inputs were inert before the PR and decisive after — so the next reader cannot re-loosen it by accident. That is the right record for a fix that VACATED a test by relocating enforcement, and it reads cleanly (§2.5).

No findings. No threads opened by this pass. Nothing here bars merge; the orchestrator owns the merge, the version reconciliation and the required-check assertion by name.

…to 0.249.0

Brings origin/main (0.245.0, including #467, #489, #492, #497) onto the
branch and sets the workspace version to the pre-assigned 0.249.0.

Conflicts and how they were resolved:

- `Cargo.toml` — a pure version collision (branch 0.242.0 vs main
  0.245.0). Every other main-side hunk was already applied by the
  auto-merge; the only difference from `origin/main` in this file is the
  version line, now 0.249.0.
- `Cargo.lock` — taken wholesale from `origin/main`, then re-locked with
  `cargo update -w`, which re-points the two workspace members whose
  manifests moved (`dig-node-service` 0.245.0 -> 0.249.0, `dig-wallet`
  0.47.0 -> 0.48.0). Nothing in the tree still reads 0.242.0.
- `crates/dig-wallet/src/sage/rpc.rs` — reported as a conflict by an
  earlier attempt; on this merge git resolved it textually because the
  two sides touch disjoint regions of the file. The result was read
  against BOTH parents rather than accepted on git's word:

  * MAIN's hunks are intact. `is_definitive_rejection` keeps the #497
    narrowing — a refusal frees inputs only when its stated reason is
    bundle-intrinsic (`super::chain::refusal_is_bundle_intrinsic`), with
    a HOLD default — and the #492 doc block stating that `synced` is a
    CURRENCY test computed independently of the routing tier, so
    `{source: "db", synced: false}` is a reachable state.
  * THE BRANCH's hunk is intact. `replica_answer_is_current` still
    delegates to `sync_supervisor::FollowingEvidence::measure`, which
    withholds the evidence when EITHER the replica or the peer height is
    unmeasured, so a `synced` phase cannot be emitted without the peak
    height that bounds it (#495).
  * No rival implementation survives the merge. The pre-#495
    `is_following` predicate is gone from the tree; `FollowingEvidence`
    is the single producer consumed by both the money reads
    (`rpc.rs:1085`) and the status endpoint
    (`sync_supervisor.rs:490`), which is what makes the
    `{phase: "synced", peak_height: null}` pairing unrepresentable
    rather than merely unlikely.

No behaviour was chosen over the other side: both guards are load-bearing
on different questions — one on whether a refusal may free inputs, the
other on whether a currency claim may be made at all.

dig-wallet: 772 passed, 0 failed, 1 ignored.
dig-node-service: 774 passed, 0 failed.

Note: `cargo test` on this Windows host needs RUST_MIN_STACK raised
(default hits a rustc STATUS_STACK_BUFFER_OVERRUN ICE while encoding
dig-node-service metadata) — an environment limit, not a code fault.

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

Copy link
Copy Markdown
Contributor Author

Merged origin/main (0.245.0) and bumped to 0.249.0

New head: 59368425190ec21093a32b7230b17672ca8ac841 (chore(release): merge main into loop/495-syncstatus-honesty and bump to 0.249.0).

crates/dig-wallet/src/sage/rpc.rs merged cleanly because the two sides touch disjoint regions, and the result was read against BOTH parents rather than accepted on git's word: main's hunks are intact (is_definitive_rejection keeps the #497 narrowing — a refusal frees inputs only when its stated reason is bundle-intrinsic via super::chain::refusal_is_bundle_intrinsic, HOLD by default — plus the #492 doc block stating synced is a currency test independent of the routing tier), and this branch's hunk is intact (replica_answer_is_current still delegates to sync_supervisor::FollowingEvidence::measure, which withholds the evidence when either the replica or the peer height is unmeasured). No rival implementation survives: the pre-#495 is_following predicate is gone from the tree and FollowingEvidence is the single producer consumed by both the money reads (rpc.rs:1085) and the status endpoint (sync_supervisor.rs:490), which is what makes {phase: "synced", peak_height: null} unrepresentable rather than merely unlikely.

Cargo.toml was a pure version collision (0.242.0 vs 0.245.0), resolved to the pre-assigned 0.249.0; every other main-side hunk was already applied, so the only difference from origin/main in that file is the version line. Cargo.lock was taken wholesale from origin/main and re-locked with cargo update -w, which re-pointed dig-node-service 0.245.0 → 0.249.0 and dig-wallet 0.47.0 → 0.48.0. Nothing in the tree still reads 0.242.0.

Tests (post-merge, local): dig-wallet --lib 772 passed, 0 failed, 1 ignored; dig-node-service --lib 774 passed, 0 failed. Both crates own the syncStatus surface. Note for anyone re-running locally on Windows: the default stack makes rustc ICE with STATUS_STACK_BUFFER_OVERRUN while encoding dig-node-service metadata — RUST_MIN_STACK=67108864 clears it. That is an environment limit, not a code fault.

A loop-reviewer is concurrently auditing the delta 5d3f25e..1e6c10c. This merge commit is the only change after that range — it adds no new logic of its own, so the audit's subject is unchanged apart from the incoming origin/main content and the version bump.

…47.1)

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

Copy link
Copy Markdown
Contributor Author

Orchestrator bc2a88 — merging. Version assigned at merge time: 0.247.1. This PR is a fix, so per CLAUDE.md §2.4 it is a patch bump over main's 0.247.0; the 0.249.0 the lane carried was a collision-avoidance slot, not a SemVer judgement. Gate record: loop-reviewer PASS + loop-security PASS (~05:15Z), scoped re-gate PASS at 1e6c10c (10:42Z); every commit since is a mechanical merge of origin/main with the version line resolved by merge-main-keep-version.sh.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 2, 2026 17:58
@MichaelTaylor3d
MichaelTaylor3d merged commit bfaa79d into main Sep 2, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/495-syncstatus-honesty branch September 2, 2026 17:59
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.

control.wallet.syncStatus can still emit {phase: "synced", peak_height: null} — the pairing every money read now abolishes

1 participant