Skip to content

fix(wallet): distinguish unknown from zero across the wallet surface - #454

Merged
MichaelTaylor3d merged 10 commits into
mainfrom
loop/batch-wallet
Aug 31, 2026
Merged

fix(wallet): distinguish unknown from zero across the wallet surface#454
MichaelTaylor3d merged 10 commits into
mainfrom
loop/batch-wallet

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

DRAFT — DO NOT MERGE. The gate round has not returned.

Closes #306
Closes #286
Closes #256
Closes #384
Closes #390

#416 is NOT closed by this PR either. Only its RENDERING half is done here — the wire
gap plus the NOT CURRENT human line. Its other half, diagnosing why the replica is not
catching up, needs a real host and is untouched, so the ticket stays open.

#396 is NOT closed by this PR — see the verdict on that ticket. Its deliverable is a real
mainnet wallet holding a $DIG CAT and an NFT, which is not reachable from a worktree.


The through-line

Every ticket here is a surface that could not distinguish "I do not know" from "the answer is
zero / nothing / fine."
Each fix restores that distinction rather than papering over the symptom.

# The conflation Now
416 a stale-replica 0 rendered identically to an empty wallet the gap is on the wire and the human line says NOT CURRENT
306 "this tier declined to look" returned as "you hold no $DIG" the fallback tier is asset-scoped and answers
286 a funded wallet described as disposable, for ever funds latch the flag; an unknown defers, a measured zero does not
256 a clamped or zero-row write reported as success the derivation floors in force are reported; a zero-row write errors
384 no way to recover a coin whose parent read failed once a reset that clears the authoritative flag in the same transaction

Per ticket

#416 — the stale-replica zero (mvp)

Two halves; one is fixed here and one is not, stated plainly.

Fixed — making staleness legible where a person reads a balance. control.wallet.balance now
carries network_peak_height (the peak this node's own held Chia peers announced) and stale_by
(how far behind that peak the figure is). Both additive per §5.1, asserted against a consumer struct
that ignores them.

stale_by is None unless BOTH heights are known, because a zero and an absence are opposite
claims
: zero says this figure is level with the network, absence says nothing bounds this
figure
. The ticket's measured reading — balance 0, synced false, peak_height null — is the
second, and previously had no way to say so.

The human line was the actual defect surface. It read balance 0 · pending 0 · syncing, built from
result["balance"].as_u64().unwrap_or(0) — so a missing field printed a confident zero balance —
and syncing reads as reassuring progress rather than as a warning. It now renders unknown for an
unreadable field and prefixes every non-current figure with NOT CURRENT, naming the as-of height
and the gap.

NOT fixed — "establish why the replica is not catching up." That is a diagnosis on a specific real
machine's node, which this lane does not have and did not re-measure. Unverified; needs a lane
with the host.

#306 — CATs read as zero while unsynced

wallet_coins's fallback arm did return Ok(Vec::new()) for any CAT. Its stated blocker —
"CAT asset attribution while syncing needs puzzle uncurrying" — does not exist: a CAT coin is
identified by where it sits. Wired to asset_scoped_fallback_coins, the same helper
balance_for_address and coins_for_address already use, rather than re-derived — the balance and
the coin list behind it must not be able to scope to different assets.

The ticket's spend-selector warning does not apply as written, and this is worth recording. It
cites rpc.rs:2933,:3319 as spend-input selection reached through wallet_coins. On today's tree
wallet_coins has exactly two callers, get_coins and get_spendable_coin_count; those line
numbers are now unrelated code. So this widens two READS and does not change what a spend can select.

#286 — the latch nothing called

latch_ever_funded was written, persisted and tested, with no production caller. A new
wallet_funded module holds the decision as a pure function; the mirror pass is the observation
point because it already reads the operator wallet's balance on a timer.

Deviation from the ticket's wording, taken deliberately. #286 says "if it is unclear whether
funds were observed, latch."
Implemented literally, that latches on CannotSay — which is the state
every node is in on its first pass, so every auto wallet would latch immediately and
is_disposable would be vacuously false for ever. That is the exact vacuity the ticket's own body
cites as the pattern to avoid.

The instruction's purpose survives without that cost, because nothing ever records "not funded":
the latch is monotonic, so declining to latch on an unknown defers a decision rather than making the
wrong one, and the next observation that sees money latches. The direction that matters is covered
without a currency gate — a non-zero figure classifies as funded from either tier, so a stale or
fallback answer showing money latches at once. synced gates only the zero case. SPEC §16.4's
NOT YET SATISFIED block is deleted and replaced with the observation-point contract.

#256 — a no-op indistinguishable from success

Enumerated from the dispatch table: 19 sites, not the four the comment implied.

Only increase_derivation_index is changed. Its no-op is reachable and money-class: the write is
MAX(col, ?), so a request below the floor changes nothing by design, and WHERE id = 0 against an
absent settings row updates zero rows while execute returns Ok — the floor is never raised,
the operator is told it was, and funds at higher indices stay invisible with no error and no retry.

It now returns the floors in force, per tree, with None for a tree not asked about — never
Some(0), which would assert that tree scans nothing. Modelled on ChiaPeerRemovalOutcome: numbers
a consumer must read, no bool companion. A zero-row update is an error.

The other 18 are documented rather than changed, per the ticket's Sage-parity constraint: the
settings/theme/peer/metadata writes have no observable difference between "changed it" and "it
already said that", so there is nothing a richer response could truthfully report.
redownload_nft/update_nft on an unknown id are named as the real remaining candidates, gated on
establishing Sage's own response shape first.

#384 — reset the coin database (mvp, kind:business)

control.wallet.resetCoinDb plus dign wallet reset-coin-db --confirm.

  • The money hazard is closed structurally. initial_sync_complete and the recorded coverage are
    cleared in the same transaction that empties the coins, so a crash between them cannot leave an
    empty replica that is still authoritative — the balance 0, synced true state.
  • Refuses while a spend is in flight, counting inside the transaction so a reservation taken
    during a pre-check cannot slip the gap. Liveness is judged by expiry against the node's own
    clock
    , not by row presence: one lapsed unpruned hold must not permanently deny the only recovery
    this feature provides. The instant is not caller-supplied — that would be a lapse oracle.
  • A refusal is an error, not a success with a flag. A caller ignoring a refused: true field
    would read "your cache was reset" and act on it.
  • No key material is reachable. The table list is explicit and chain-derived only; a test proves a
    user theme survives while the coins do not.
  • confirm: true travels on the WIRE, not asserted in the CLI — a guard only the CLI applies is
    not a guard.

#390 — ALREADY SHIPPED, not rebuilt

The §2.0 already-shipped check found this ticket's design merged in 4523894 (PR #393) and wired
on both paths
— stage_from_states at sync.rs:772, promote_staged_cats at sync.rs:833 and
rpc.rs:3281, cat_admission_pending at db.rs:421, 18 tests. Full evidence on the ticket. The
Closes above is bookkeeping.

Blast radius

.gitnexus is registered against the primary checkout and is stale, so impact would return a
false-safe zero. Done by grep plus direct read instead, and stated as such:

  • balance_wire — 4 call sites, all in this file (1 production, 3 tests). All updated.
  • raise_derivation_floor — 2 production callers, both in actions.rs; 2 test callers in db.rs.
    Return type () to u32; all updated.
  • increase_derivation_index — 1 production caller (rpc.rs), reachable only via the Sage-parity
    dispatch. Response widened additively.
  • wallet_coins — exactly 2 callers, get_coins and get_spendable_coin_count. Both reads.
  • latch_ever_funded — had zero callers; now one.
  • New symbols (reset_chain_cache, reset_coin_db, wallet_reset_coin_db, stale_by,
    FundingObservation, amount, balance_freshness) have no prior callers.

A tier decision worth the gate's attention, and a CI finding that forced it.

control.wallet.resetCoinDb is destructive, so the master token looked right, and
requires_master_token fails CLOSED for an unpublished name — it landed there by default. CI
refused it
: the_contract_publishes_every_control_method_the_node_serves rejects a
served-but-unpublished method, and its sanctioned escape, KNOWN_UNPUBLISHED, is deliberately the
same constant the token gate reads
— so tolerating the publish drift and granting a paired token
access are ONE decision, by design.

That forced the question rather than allowing a default, and the default was wrong: #384 exists to
put a reset button in the DIG App, and the App holds a paired token.
Master-tiering it makes the
feature unreachable by the only consumer it was built for — a guard so tight it removes the
capability is a deletion, not a guard. It is therefore on the paired tier, recorded in
KNOWN_UNPUBLISHED_CONTROL_METHODS with the reasoning and a removal condition.

What bounds the damage is the combination the method does enforce: loopback-only + a token +
confirm: true on the wire + a refusal while any spend is in flight + a blast radius containing no
key material and nothing a re-sync cannot rebuild.
Not tier alone. Flagged explicitly for the
gate
— if the reviewer judges a destructive method must be master-tier regardless of reachability,
that is a coherent position and it means #384 cannot ship until the contract publishes.

An interim change is REVERTED and is not in the diff: the master-tier drift assertion was briefly
relaxed from equality to containment, and equality is restored.

Required cross-repo sibling (§1.3b / §4.1): dig-node-control-interface must publish
control.wallet.resetCoinDb. Until it does, the method sits in KNOWN_UNPUBLISHED, and
the_unpublished_list_still_describes_real_drift fails the moment it IS published unless the entry
is removed — so this cannot rot. Needs an orchestrator-dispatched lane.

Dependencies (§2.4b)

Checked against index.crates.io with the required User-Agent. Every dig-* and chia-*
declaration in dig-wallet is already at latest
— dig-node-control-interface 0.27.0,
dig-offers 0.3.0, dig-clvm 0.4.0, dig-keystore 0.13.0, chia-query 0.20.0. The chia-* set
moves together and sits uniformly at 0.36.1 / 0.36.0, the stated ceiling pending
chia-wallet-sdk publishing against 0.48. No bumps were owed; none were made.

SemVer

minor — dig-wallet 0.43.0 to 0.44.0, workspace 0.189.0 to 0.190.0. New capability, and
every wire change is additive (§5.1): new fields on the balance result, a new control method and CLI
verb, a widened increase_derivation_index response. raise_derivation_floor and
actions::increase_derivation_index changed return type, but both are internal to the crate.

SPEC

control.wallet.resetCoinDb row added with the same-transaction requirement, the
expiry-not-presence refusal rule and the master tier; the balance row extended with
network_peak_height/stale_by and the zero-is-not-absence rule; the CLI verb mapping added;
§16.4's NOT YET SATISFIED block replaced with the observation-point contract.


dig-node#454 — a reset mid-catch-up could still be overwritten (HIGH, money lie)

A security gate proved by execution that #384's atomicity claim, while correct, protected an
invariant that failed anyway: reset_chain_cache is one transaction, but the catch-up's own two
writes (apply_coin_states per batch, then complete_catch_up) are separate transactions that
nothing serialises against it. A reset landing mid-catch-up therefore emptied the coins and cleared
the flag, and the in-flight catch-up then set initial_sync_complete = 1 over the empty table —
balance 0, synced true on a funded wallet, with no attacker involved. The likelier and worse
variant is a partial coin set reported as synced: a plausible understated balance.

The shape: a reset_epoch counter in sync_state, incremented by the reset, observed by the
catch-up before its first batch and asserted in the terminal write's WHERE clause. A catch-up
that began before a reset cannot set the flag afterwards; it returns
SyncError::ResetDuringCatchUp and the supervisor runs a fresh one. The SPEC.md row now states
this as a normative MUST, so its "until a genuine catch-up re-establishes the flag" sentence is
backed by code rather than born false.

Regression tests (sage::sync::tests), both proven RED against the pre-fix code and asserting
the observable pair (balance, synced) rather than any internal counter:
a_reset_mid_catch_up_is_not_overwritten_into_an_empty_authoritative_replica and
..._into_a_partial_authoritative_replica.

Mangled string literals

Nine string literals across five files carried runs of ~18 spaces from \ continuations lost in
transit (four of them user-facing, including the destructive-reset warning and the NOT CURRENT
line). All are now concat! fragments — cargo fmt --check cannot see the mangling and cargo fmt
has reintroduced it on a sibling branch. Tracked ecosystem-wide as dig_ecosystem#3190.

For the interface sibling ticket

control.wallet.resetCoinDb MUST be declared with requires_master_token = false in
dig-node-control-interface. The drift guard forces removal of the KNOWN_UNPUBLISHED entry on
publish day, and without the explicit false the method defaults to master tier — which would put
it out of reach of the DIG App, its only consumer, exactly as the paired-tier rationale above says.

Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs Fixed
Comment thread crates/dig-node-service/src/control_cli.rs Fixed
Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Audited head: 405ef503060d02a616200f4c048d7223d0a80099 (resolved from gh pr view 454 --json headRefOid).
Merge base 3e480dd302ae0a8518ce535e5b1bc4a563b960ff = current origin/main.
Read from git objects in a private scratch; the lane's worktree was not touched.

Confirmed clean so far

  • No .loop/ residue. The 15-file diff contains no .loop/ path and no sibling-lane scratch file. .gitignore adds .lane/, which is this lane's own private scratch — correct.
  • Authorship on all 5 commits is Michael Taylor <michael@michaeltaylor.dev> — the one correct identity, no fabricated address.
  • Commit subjects are 68/72/74/77/78 chars, all under the 100-char commitlint ceiling.
  • The tier mechanism is as the lane describes it. requires_master_token_given (control.rs:381-386) is match ControlMethod::from_name(method) { Some(p) => p.requires_master_token(), None => !exempt.contains(&method) }. So an unpublished name defaults to master, and adding it to KNOWN_UNPUBLISHED_CONTROL_METHODS demotes it to paired. Tolerating publish drift and granting paired access really are one edit. The lane's account of the mechanism is accurate; the tier decision is still under review.
  • control.wallet.resetCoinDb is absent from is_open_control_read (control.rs:148-160) — it is token-gated, not an open read.

FINDING 1 — string-continuation damage IS present, in two user-facing messages (MEDIUM)

The hazard the brief warned about did not miss this diff. Two distinct corruptions, both in strings a user sees:

1a. crates/dig-node-service/src/control.rs:2334 — the confirm: true refusal. The source contains literal \n escapes followed by 13 spaces, so the emitted message is physically broken across lines with ragged indentation:

control.wallet.resetCoinDb is DESTRUCTIVE: it discards this node's cached coin database \n             and re-syncs from chain. Pass params.confirm = true to proceed. No key \n             material is affected.

This is the line-continuation \ + newline that a heredoc turned into a literal \n escape plus the indentation it was supposed to swallow. It compiles, and every gate is blind to it.

1b. crates/dig-wallet/src/sage/db.rs:2688ResetRefusal::Display, the other half of the same corruption: here the backslash was eaten outright, leaving runs of 18 literal spaces mid-sentence:

"refused: {reservations} coin reservation(s) are in flight. Resetting now would                  wipe the coins an unconfirmed spend was built on. Wait for them to confirm or                  expire, then retry."

This string is not decorative — wallet_reset_coin_db returns it verbatim as the control_error message (control.rs:2358), so it is what the DIG App renders when it refuses a reset.

Two test assertion messages carry the same 14-space runs (db.rs:5119, db.rs:5147) — cosmetic, but same root cause, so fix them in the same pass or cargo fmt will not save you.

Not a security defect — no money lie, no attacker leverage. Recorded as MEDIUM because 1a/1b are user-facing product text on a destructive operation's only two failure paths, and the brief asked specifically.

Continuing: #384 atomicity under concurrency, the open-read amplification question on wallet_balance, the 8 GHAS dismissals, and #286/#256/#306.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security gate — IN PROGRESS, not the verdict

Auditing head 405ef503060d02a616200f4c048d7223d0a80099 (resolved from gh pr view 454 --json headRefOid),
against merge-base 3e480dd302ae0a8518ce535e5b1bc4a563b960ff. Diff is 15 files / +1299 / -53.

Finding 1 — MANGLED LINE-CONTINUATION IN THE DESTRUCTIVE-ACTION WARNING (LOW, cosmetic, confirmed by byte dump)

crates/dig-node-service/src/control.rs:2332

The Rust string literal contains the two-character escape \n followed by 13 literal spaces, twice.
od -c on the blob at head confirms bytes \ n SP x13 — i.e. this is not a source-formatting
artifact, it is what the compiler sees. The intended construct was almost certainly a Rust
line-continuation (\ at end-of-line, which strips the newline and leading indentation).

What a user of control.wallet.resetCoinDb actually receives when they omit confirm:

control.wallet.resetCoinDb is DESTRUCTIVE: it discards this node's cached coin database 
             and re-syncs from chain. Pass params.confirm = true to proceed. No key 
             material is affected.

This is the confirmation warning for the only destructive method in the diff, and it is rendered
into a JSON-RPC error string that the DIG App shows verbatim. It compiles, cargo fmt will not
correct it, and no gate can see it — which is exactly why it is being called out rather than left.

Not a security defect and not a lie about money — the text is accurate, only badly wrapped. Rated
LOW / cosmetic, but recommend fixing in this PR since it is a one-character-per-site edit and the
message is a destructive-action gate.

It is the only instance in the diff — a full scan for \n\s{4,} over the 1759-line diff returns
this line and nothing else.

Hazard cleared — no .loop/ residue

git diff --name-only 3e480dd3..405ef503 returns 15 paths, zero under .loop/. No sibling-lane
scratch file is in this diff.

Continuing: #384 atomicity, the tier decision, the 8 GHAS dismissals, #286, #416's closing keyword.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. CORRECTION to Finding 1.

My previous comment said the mangled line-continuation was "the only instance in the diff." That was
wrong
— my scan pattern required a literal \n, and it missed every site where the backslash was
eaten entirely, leaving only the space run. An exhaustive scan (^\+.*\S {5,}\S) finds nine,
of which four are user-facing, and they are the two most load-bearing honesty strings in this PR.

Finding 1 (REVISED) — nine mangled string literals; four reach a user (LOW, cosmetic, not a lie)

site reaches rendered defect
crates/dig-node-service/src/control.rs:2332 JSON-RPC error, DIG App \n + 13 spaces, twice — the destructive-reset confirmation warning
crates/dig-wallet/src/sage/db.rs ResetRefusal::Display wire, via refusal.to_string() at control.rs:288 18 spaces — the spend-in-flight refusal
crates/dig-node-service/src/control_cli.rs:732 dign wallet reset-coin-db stdout 14 spaces — the reset success line
crates/dig-node-service/src/control_cli.rs:1432 dign wallet balance stdout 23 spaces — NOT CURRENT — this node cannot say what height this reflects, i.e. #416's entire deliverable for the exact reading the ticket filed

The other five are assert! messages in #[cfg(test)] code (db.rs, rpc.rs) and only degrade test
output.

Not a security defect and not a money lie — every sentence is true, merely wrapped with a gap
mid-sentence. So this does not gate on the §2.6 money/custody carve-out. But it is worth fixing in
this PR rather than filing: the four user-facing sites are precisely the honesty surfaces #416 and
#384 exist to build, cargo fmt will not repair them (it has been observed to reintroduce this
pattern on a sibling lane), and no gate downstream can see it because it compiles cleanly.

Severity LOW. Recommend fixing before merge; not gating on its own.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. Finding 2, and it is the significant one.

Finding 2 — #384: the reset is atomic, but an IN-FLIGHT CATCH-UP RE-ARMS synced OVER THE EMPTIED REPLICA (HIGH, money lie, live)

The atomicity claim itself checks out. WalletDb::reset_chain_cache (crates/dig-wallet/src/sage/db.rs:2729)
opens one self.pool.begin(), runs the reservation count, all eleven DELETEs and the
UPDATE sync_state SET initial_sync_complete = 0, covered_puzzle_hashes = '' on that same &mut *tx,
and commits once. A crash or driver error between the delete and the flag clear rolls back both. The
refusal path returns before any write and drops the transaction uncommitted. That half is correct,
and the prior gate's separate concern — that the reset might not rebuild — is also correctly refuted:
sync.rs:1097-1098 sets previous_height = None / header_hash = genesis_challenge, so a catch-up
replays from genesis.

But the invariant the method's own doc comment states is false, and that is where the defect is.
db.rs says:

a crash between them cannot leave an empty authoritative replica; reads fall back to the chain tier
until a genuine catch-up re-establishes the flag.

A genuine catch-up re-establishing the flag no longer implies a populated replica, because the reset
can delete that catch-up's own writes out from under it.

Where the two writes are split (this is the whole bug):

  • crates/dig-wallet/src/sage/sync.rs:1152apply_coin_states(db, ...) writes each batch's coins in
    its own transaction, once per batch.
  • crates/dig-wallet/src/sage/sync.rs:1160 — on respond.is_finished, a separate transaction
    db.complete_catch_up(...)crates/dig-wallet/src/sage/db.rs:1405, which is
    UPDATE sync_state SET peak_height = ?, initial_sync_complete = 1, covered_puzzle_hashes = ? WHERE id = 0.

Nothing serialises those against the reset. grep -n 'Mutex\|RwLock\|Semaphore' crates/dig-wallet/src/sage/db.rs
returns nothing, and WalletBackend::reset_coin_db (rpc.rs:1124) is a bare pass-through. The
supervisor runs a catch-up unconditionally per peer session (sync_supervisor.rs:1450) — it is not
gated on initial_sync_complete — so a catch-up is in flight routinely, not exceptionally.

The interleaving, with no attacker and no oracle misbehaviour:

t actor effect
T0 peer session's initial_sync_with_authority replaying from genesis; N batches of the user's real coins are now in coins
T1 user presses the #384 reset button (DIG App / dign wallet reset-coin-db --confirm) no spend in flight, so it proceeds: coins emptied, initial_sync_complete = 0, coverage ''. Commits. Prints "the replica is no longer authoritative and will re-sync from chain"
T2 the same session receives its final batch, is_finished = true complete_catch_up commits initial_sync_complete = 1, covered_puzzle_hashes = <full address set>, peak_height = <peak>

Resulting state: coins EMPTY, initial_sync_complete = 1, coverage FULL. Wallet-scoped reads
route to the local replica as authoritative and answer balance 0, synced true on a funded wallet
verbatim the state reset_chain_cache's own doc comment names as "the money-lie class this repo already
documents" and the state this entire PR exists to make unreachable.

Impact. A synced: true zero is not "I don't know", it is "the answer is nothing" — the exact
conflation this PR's through-line is built to remove. It lands at the worst moment: immediately after a
destructive action, while the user watches to see their funds return. stale_by does not rescue it —
the replica genuinely believes it is level with the network, so the #416 NOT CURRENT prefix does not
fire either. Both new honesty surfaces read as reassuring.

It self-heals, which caps this at HIGH rather than CRITICAL: the next peer session's catch-up
replays from genesis and repopulates, because catch-up is not gated on the flag. The window is one
peer-session cycle — minutes to hours, and longest on a node with few peers, which is the node most
likely to have prompted a reset in the first place.

Secondary effect on a wallet whose baseline was never armed: complete_catch_up arms
arrival_baseline_height as COALESCE(existing, MAX(?peak, COALESCE((SELECT MAX(created_height) FROM coins), 0))).
With coins emptied that inner MAX is NULL → 0, so the baseline arms at the replay peak, and every
coin re-synced afterwards at a lower height is classified backfill rather than arrival. Arming is
once-per-wallet, so that is permanent. Lower severity, same root cause.

Cheapest correct fix — a reset epoch. Add a monotonically-increasing reset_epoch to sync_state;
reset_chain_cache bumps it inside its existing transaction; initial_sync_with_authority captures it
before its first batch and passes it into CatchUpReplay; complete_catch_up becomes
... WHERE id = 0 AND reset_epoch = ?captured, and reports zero rows updated as "superseded by a reset,
replica stays non-authoritative". A reset then invalidates any catch-up that began before it, which is
the true intent. A lock is the wrong shape — the reset would block for up to CATCH_UP_DEADLINE.

Adjacent, PRE-EXISTING, not introduced here — recording it because #384 makes it newly reachable on a
funded wallet.
refresh_tracked_coins (rpc.rs:3301) is the second production writer of the flag
and its own comment states it "DOES latch over zero rows … declaring an empty table authoritative"
(dig_ecosystem#2514). Before #384 an empty coins table was mostly a fresh-install condition; #384 makes
it a user-triggerable state on a funded wallet, so #2514's window now opens on demand. That one is
defense-in-depth and a follow-up ticket, not a gate on this PR.

Still to finish: the three escalated decisions, the 8 GHAS dismissals, #390/#416 closing keywords.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. Rulings on the three escalated decisions.

Decision 1 — #384's tier: PAIRED IS CORRECT. Ship it. (ruling)

Not on the lane's reasoning, which is a product argument ("the App is the only consumer"), but on the
codebase's own stated master-tier criterion, which is stricter and independent of who calls it.
control.rs:335-337:

The master tier is not "pairing administration": it is every method whose effect OUTLIVES the token
that invoked it
. pairing.revoke is the designated remedy for a compromised paired app, so a method
that survives revocation has escaped that remedy.

Apply it. control.chiaPeers.add is master-tier because it installs a peer believed without
corroboration
, that authority persists after the token is gone, and revoke touches no peer row — the
remedy does not reach it. control.wallet.resetCoinDb grants no authority, installs no persistent state,
and creates nothing revoke cannot reach: revoke the paired token and the loop stops, the replica
re-syncs from genesis, and the node returns to exactly its prior state. Its effect does not survive the
remedy, so it is not a master-tier method.
Destructiveness is not the criterion here; escaping
revocation is. A guard chosen on the wrong axis would also have to master-tier every expensive read.

Blast radius supports it: no key material is reachable (the table list at db.rs:2810-2822 is explicit
and chain-derived; derivations, network_settings, user_themes, peers, offers deliberately
absent), no money moves, and what is destroyed is re-derivable by syncing.

So #384 does NOT need to block on dig-node-control-interface publishing the method. It can ship at
405ef503.

But the cross-repo sibling carries a requirement that is not yet written down, and it is a live
foot-gun.
requires_master_token_given (control.rs:381-386) is:

match ControlMethod::from_name(method) {
    Some(published) => published.requires_master_token(),
    None => !exempt.contains(&method),
}

The KNOWN_UNPUBLISHED exemption only applies on the None arm. The moment the contract publishes
control.wallet.resetCoinDb, the tier is read from the contract, and
the_unpublished_list_still_describes_real_drift (tests/control_contract_conformance.rs:106-111)
forces the entry's removal. If the contract publishes it with requires_master_token = true, the
DIG App's reset button breaks on publish day, and the failure is a permission denial in a released
client rather than a red test.

→ The sibling ticket must state: publish control.wallet.resetCoinDb with
requires_master_token = false.
Recommend adding that sentence to the ticket now; not a gate on this PR.

Amplification, recorded and NOT gating. There is no rate limit on this method — MissRateLimiter
(server.rs:121,598) keys the content miss path, and loopback shares the trusted RequestorId::Local
bucket, so it does not apply. A paired token holder can call the reset in a loop and keep the node
re-syncing from genesis indefinitely (denial of wallet reads + sustained peer bandwidth). The bound is
that the caller must already hold a paired token on loopback, and pairing.revoke ends it. LOW;
worth a follow-up ticket, not a gate.

Decision 2 — #286's deviation: THE LANE IS RIGHT, THE TICKET IS WRONG. Confirmed. (ruling)

You asked me to confirm or refute; I confirm, and on a stronger basis than the vacuity argument.

The vacuity argument is correct as far as it goes: classify returns CannotSay whenever a zero read is
not current, which is every node's state for the first seconds of its life, so latching on CannotSay
sets ever_funded on essentially every auto wallet and is_disposable becomes unconditionally false
the "passes because the thing it governs never occurs" vacuity #286's own body names as the anti-pattern.

The reason I can rule rather than weigh it is the safety direction, which is what "fail toward
latching" was actually protecting:

  • The danger of under-latching is that something ACTS on is_disposable == true during the window and
    discards a funded wallet. I grepped for that. autoseed::is_disposable (autoseed.rs:258) has no
    production consumer
    — the only references are its own definition and wallet_funded.rs's tests. So
    no action can be taken during the unlatched window, today, by anything.
  • The residual for a future consumer is narrower than it looks, because classify (wallet_funded.rs:40-46)
    returns Funded for a non-zero figure from either tier, synced gating only the zero case. So the
    only funded-but-unlatched state is "funded AND every read returned zero" — i.e. no observation ever saw
    the money, which no non-vacuous implementation could latch on either.
  • The latch is monotonic and nothing ever writes "not funded" (latch_ever_funded, autoseed.rs:279-289,
    returns early if already set). Declining on an unknown defers; it cannot settle into a wrong answer.

Both readings have a failure mode. The literal one destroys the feature's meaning permanently; the lane's
leaves a window that no consumer exists to observe and that resolves on the next usable read. Recommend
the ticket be corrected to match the code rather than the code to match the ticket.

One genuine under-latch, LOW, defense-in-depth, not gating. The observation point reads
observe_dig_balance (mirror/lifecycle.rs:774-782), which is dig_balance_base_units — the $DIG CAT
balance only
. A wallet holding real XCH and no $DIG classifies ObservedEmpty and never latches, so a
funded wallet would be described as disposable. Harmless today (no consumer), but it should be an
"any value" observation before anything acts on the flag. Follow-up ticket.

Decision 3 — the 8 GHAS dismissals: ALL CORRECT. Verified individually. (ruling)

Ten threads exist, all github-advanced-security, all "Cleartext logging of sensitive information", all
in crates/dig-node-service/src/control_cli.rs, zero unresolved.

  • All test-only, verified structurally, not by eye. #[cfg(test)] mod tests opens at line 1569 and
    the only column-0 closing brace in the file is at 2527 (the last line). Every flagged line —
    2319, 2356, 2360, 2371 (x2), 2374, 2394, 2397, 2406, plus one outdated — falls inside that range.
  • Every flagged expression is an assert! message, interpolating a string built by summarize(...)
    over inline json! literals: balances 0/6/12345, heights 42/9_220_177/9_211_798/8_380,
    booleans, and the literals "fallback"/"db". No key, seed, mnemonic, token, address or credential
    is present in any fixture.
    CodeQL's "trusted" source is the json! literal itself — a textbook false
    positive.
  • No production path in this diff logs anything sensitive. The diff adds exactly one logging call
    in non-test code (wallet_funded.rs:88-92, tracing::warn! with error = %e and ?observation);
    FundingObservation is a fieldless three-variant enum, so it carries no value at all.
  • The remedy CodeQL implies — stripping the interpolated string — would delete the diagnostic these tests
    exist to produce, and several are the only evidence distinguishing the stale-zero line from the
    empty-wallet line. Dismissal is the right call.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. Finding 2 is now PROVEN BY EXECUTION, not inferred.

I compiled and ran a probe against head 405ef503 in a private worktree. The money lie reproduces.

The probe is the sequential form of the interleaving — apply_coin_states and complete_catch_up are
separate transactions, so the in-flight catch-up's completion simply lands after the reset:

#[tokio::test]
async fn gate_probe_inflight_catch_up_rearms_synced_over_an_emptied_replica() {
    let db = WalletDb::open_in_memory().await.unwrap();

    // T0: the in-flight catch-up has replayed the user's real coins.
    db.upsert_coin(&coin("c1", 12_345, Some(10), None)).await.unwrap();

    // T1: the user presses reset. No spend in flight, so it proceeds.
    let report = db.reset_chain_cache(0).await.unwrap().expect("not refused");
    assert_eq!(report.coins_dropped, 1);
    assert!(!db.is_synced().await.unwrap(), "reset cleared the flag");

    // T2: the SAME catch-up receives its final batch and completes.
    db.complete_catch_up(&CatchUpReplay::finished_at(None, 100, "hh", &[]).unwrap())
        .await.unwrap();

    let coins_left = db.all_coins().await.unwrap().len();
    let synced = db.is_synced().await.unwrap();
    eprintln!("GATE PROBE RESULT: coins={coins_left} synced={synced}");
    assert_eq!(coins_left, 0, "the reset emptied the replica");
    assert!(!synced, "MONEY LIE REPRODUCED: coins=0 and synced=true");
}

Result — and note the test count is 1, so it genuinely executed; this is not a filter that matched
nothing:

running 1 test
test sage::db::tests::gate_probe_inflight_catch_up_rearms_synced_over_an_emptied_replica ... FAILED
GATE PROBE RESULT: coins=0 synced=true
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 744 filtered out; finished in 0.13s

The mid-test assertion assert!(!db.is_synced()...) passed — the reset does clear the flag, so the
atomicity half is confirmed working. The final assertion failed: after complete_catch_up,
coins = 0 and synced = true. That is balance 0, synced true on a funded wallet, produced with
no attacker, no oracle misbehaviour, and no crash — only ordinary ordering.

The existing test a_reset_clears_the_authoritative_flag_along_with_the_coins (db.rs:5111) stops one
step short of this: it asserts the flag is clear immediately after the reset and never asks what a
catch-up already in flight does next. That is why the gap survived to this gate.

Finding 2 stands at HIGH and is GATING. The fix I recommended (a reset_epoch in sync_state,
bumped inside reset_chain_cache's existing transaction, captured by initial_sync_with_authority before
its first batch and asserted in complete_catch_up's WHERE) would make this probe pass, and the probe
itself is worth keeping as the regression test.

Probe worktree removed after the run; nothing was written to any shared checkout.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security GATE: CHANGES-REQUIRED

Head audited: 405ef503060d02a616200f4c048d7223d0a80099 (resolved myself via
gh pr view 454 --json headRefOid; unchanged throughout the audit). Merge-base
3e480dd302ae0a8518ce535e5b1bc4a563b960ff. 15 files, +1299/-53.

One GATING finding, reproduced by execution rather than argued. Everything else passes, and the
three escalated decisions are ruled on below.


GATING

G1 — HIGH — #384: an in-flight catch-up re-arms synced over the emptied replica, recreating balance 0, synced true

crates/dig-wallet/src/sage/db.rs:2729 (reset_chain_cache) ·
crates/dig-wallet/src/sage/sync.rs:1152 (apply_coin_states) ·
crates/dig-wallet/src/sage/sync.rs:1160 and crates/dig-wallet/src/sage/db.rs:1405 (complete_catch_up) ·
crates/dig-wallet/src/sage/sync_supervisor.rs:1450

The atomicity claim you asked me to check hardest is CORRECT. One pool.begin(), all eleven
DELETEs and the UPDATE sync_state SET initial_sync_complete = 0, covered_puzzle_hashes = '' on the
same &mut *tx, one commit(). A crash or driver error rolls both back; the refusal path returns before
any write. The prior gate's separate concern is also correctly refuted — sync.rs:1097-1098 sets
previous_height = None and header_hash = genesis_challenge, so catch-up replays from genesis.

The defect is that the two writes protected by that transaction are not the only two that matter.
apply_coin_states (per batch) and complete_catch_up (the flag) are separate transactions, and
nothing serialises either against the reset — a grep for Mutex, RwLock and Semaphore in db.rs
returns nothing, WalletBackend::reset_coin_db (rpc.rs:1124) is a bare pass-through, and the
supervisor runs a catch-up unconditionally per peer session, not gated on the flag. So a catch-up is
in flight routinely, not exceptionally.

Attacker/state to action to impact. No attacker required.

  1. A funded wallet; a peer session's initial_sync_with_authority has replayed the user's coins.
  2. The user presses the Reset the cached coin database and re-sync from chain (control method + dign verb) #384 reset (DIG App button, or dign wallet reset-coin-db --confirm). No spend
    in flight, so it proceeds: coins emptied, flag 0, coverage empty.
  3. That same session receives its final batch and calls complete_catch_up, committing
    initial_sync_complete = 1 with full coverage and the replay's peak.
  4. Coins empty, synced = true, coverage fullrouting::route serves wallet-scoped reads from the
    local replica and answers balance 0, synced true on a funded wallet.

Neither new honesty surface fires: the replica believes it is level with the network, so stale_by is
0 and the #416 NOT CURRENT prefix does not appear. Both read as reassuring.

Proven, not inferred (cargo test -p dig-wallet --lib, private worktree at head — count is 1, so
it executed
; not a filter that matched nothing):

running 1 test
test sage::db::tests::gate_probe_inflight_catch_up_rearms_synced_over_an_emptied_replica ... FAILED
GATE PROBE RESULT: coins=0 synced=true
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 744 filtered out

The probe's mid-test assert!(!db.is_synced()) passed — the reset does clear the flag, so the
atomicity half genuinely works. The final assertion failed. Full probe source in
#454 (comment)

The partial case is worse than the zero case and is the more likely one. If the reset lands
mid-catch-up rather than at its end, later batches repopulate only part of the range, and the wallet
settles at synced = true with a plausible but understated balance — harder to notice than a zero,
and equally a claim about somebody's money.

Why the existing tests missed it. a_reset_clears_the_authoritative_flag_along_with_the_coins
(db.rs:5111) asserts the flag is clear immediately after the reset, and never asks what an already
in-flight catch-up does next.

It is also now a false NORMATIVE claim, not merely a stale comment. The new SPEC.md row states:

Reads then fall back to the chain tier until a genuine catch-up re-establishes the flag.

A genuine catch-up re-establishing the flag no longer implies a populated replica, because the reset can
delete that catch-up's own writes from under it. Per §4.2 the SPEC is the contract a reimplementation
would be built against, so this ships born-false. Fixing the code makes the sentence true; do not fix it
by softening the sentence.

Recommended fix — a reset epoch (small, and the probe becomes its regression test). Add a monotonic
reset_epoch to sync_state; bump it inside reset_chain_cache's existing transaction; capture it in
initial_sync_with_authority before the first batch and carry it in CatchUpReplay; make
complete_catch_up's statement end WHERE id = 0 AND reset_epoch = ?captured, and treat zero rows
affected as "superseded by a reset, replica stays non-authoritative". A lock is the wrong shape: the
reset would then block for up to CATCH_UP_DEADLINE.


Rulings on the three escalated decisions

1. #384's tier — PAIRED IS CORRECT. It does not need to wait on the contract publishing.
Ruled on the codebase's own master-tier criterion (control.rs:335-337): master is "every method whose
effect outlives the token that invoked it", because pairing.revoke is the designated remedy and a
method surviving it has escaped that remedy. chiaPeers.add qualifies — it installs uncorroborated
authority that revoke cannot reach. resetCoinDb grants no authority and installs no persistent state:
revoke the token and the node re-syncs to its prior condition. Destructiveness is not the axis here;
escaping revocation is. A guard chosen on the wrong axis would also have to master-tier every expensive
read. Not gating — ship it as paired.

Two riders, both non-gating:

  • No rate limit applies. MissRateLimiter (server.rs:121,598) keys the content miss path, and
    loopback shares the trusted RequestorId::Local bucket. A paired token holder can loop the reset and
    keep the node re-syncing from genesis. Bounded by needing a paired token on loopback, and revoke ends
    it. LOW, follow-up ticket.
  • The cross-repo sibling must specify requires_master_token = false.
    requires_master_token_given (control.rs:381-386) consults KNOWN_UNPUBLISHED only on the None
    arm
    ; once published the tier is read from the contract, and
    the_unpublished_list_still_describes_real_drift (tests/control_contract_conformance.rs:106-111)
    forces the entry's removal. A master-tier publication therefore breaks the DIG App on publish day,
    as a permission denial in a released client rather than a red test. That drift guard exists and is
    correct, so the drift cannot rot — but the tier requirement needs writing onto the sibling ticket.

2. #286 — THE LANE IS RIGHT, THE TICKET IS WRONG. Confirmed.
Beyond the vacuity argument, which is sound on its own: autoseed::is_disposable (autoseed.rs:258) has
no production consumer — only its own definition and wallet_funded.rs's tests — so nothing can act
during the unlatched window. The latch is monotonic and nothing ever writes "not funded"
(autoseed.rs:279-289), so declining on an unknown defers rather than deciding wrongly. And classify
(wallet_funded.rs:40-46) returns Funded for a non-zero figure from either tier, so the only
funded-but-unlatched state is "every read returned zero" — which no non-vacuous implementation could
latch on either. Recommend correcting the ticket to match the code.

One LOW rider: the observation reads observe_dig_balance (mirror/lifecycle.rs:774-782), which is the
$DIG CAT balance only, so a wallet funded purely in XCH never latches. Harmless today; fix before
anything acts on the flag.

3. The GHAS dismissals — ALL EIGHT CORRECT.
Ten threads exist, all github-advanced-security "cleartext logging of sensitive information", all in
control_cli.rs, zero unresolved. Verified structurally rather than by eye: #[cfg(test)] mod tests
opens at line 1569 and the only column-0 closing brace in the file is at 2527 (the last line), so
every flagged line — 2319, 2356, 2360, 2371 (twice), 2374, 2394, 2397, 2406, plus one outdated — is
test-only. Every flagged expression is an assert! message interpolating a string built by
summarize(...) over inline json! literals: balances, block heights, booleans, and the literals
"db" / "fallback". No key, seed, mnemonic, token, address or credential appears in any fixture.
The diff adds exactly one logging call on a production path (wallet_funded.rs:88-92, carrying
error = %e and ?observation), and FundingObservation is a fieldless three-variant enum that carries
no value at all. The remedy CodeQL implies would delete the diagnostics that distinguish the stale-zero
line from the empty-wallet line. Dismissal is right.


Non-gating findings

Passes

  • Secrets / credentials — no key, token, projectId or credential added, logged or committed.
  • Custody / privilege — no signing, no key handling, no elevation, no service/registry/scheduled-task
    write. The reset's table list (db.rs:2810-2822) is explicit and chain-derived only; derivations,
    network_settings, user_themes, peers and offers are deliberately excluded, with a test that a
    user theme survives while the coins do not. confirm: true is enforced on the wire, not in the CLI,
    so every client faces the gate.
  • AuthZ / exposure — I traced who can invoke rather than only whether it is correct. resetCoinDb is
    absent from is_open_control_read, requires a control-plane token, and binds loopback-only. No new open
    endpoint. network_peak_height / stale_by are additive fields on an existing open read and disclose
    only this node's own peers' announced peak.
  • dig-node: wallet_coins answers zero for every CAT while unsynced — the mirror image of #2879 #306 — the widening is safe, and this was the real question. wallet_coins has exactly two
    callers at head: get_coins (rpc.rs:2425) and get_spendable_coin_count (rpc.rs:2465), both reads.
    Spend selection uses a different method, spendable_coins (rpc.rs:3059, reached by send_xch,
    combine, split and mints via spend::select_coins), so nothing here widens what a spend can select.
    The PR body's claim holds. Hint forgery is defended correctly: asset_scoped_fallback_coins
    (rpc.rs:1352) keeps only coins whose actual puzzle hash equals cat_coin_puzzle_hash(owner_ph, asset_id), so a coin merely hinted at the wallet is filtered out — identified by where it sits, not
    by a claim anyone can make. Any normalisation mismatch would fail closed (under-report), and a
    positive-control test covers the matching case.
  • Sage-parity action methods all return an empty ActionResponse — a no-op is indistinguishable from success (incl. increase_derivation_index) #256raise_derivation_floor now returns the floor in force (u32), with None for a tree
    not asked about and never Some(0); a zero-row WHERE id = 0 update is Err(RowNotFound) instead of a
    silent Ok. Tested in both directions: a genuine raise as the control, a clamped raise as the case.
  • CAT discovery: stage derived-hash arrivals, promote only lineage-proven coins into coins #390 — genuinely already shipped, close justified: PR feat(wallet): stage derived-hash CAT arrivals, promote only lineage-proven coins #393 merged as 4523894 and is present in
    this PR's base; cat_admission_pending (db.rs:421) and promote_staged_cats
    (cat_discovery.rs:306) exist at head.
  • .loop/ residue — CLEAR. git diff --name-only returns 15 paths, zero under .loop/. No
    sibling lane's file is in this diff. The .gitignore addition is .lane/ (lane-local scratch), which
    is good hygiene.
  • Dependencies / SemVer — the Cargo.lock diff is two version lines and nothing else; no
    dependency added, removed, or loosened, and no pinned hash touched. dig-wallet 0.43.0 to 0.44.0 and
    workspace 0.189.0 to 0.190.0 agree, and minor is right for additive capability. The several
    chia-sdk-client lines in the build are pre-existing transitive resolution, not introduced here
    (§2.4b notes cargo tree -d is not the gate).
  • Store format §5.1network_peak_height and stale_by are additive, and asserted against a
    consumer struct that ignores them.

What I could not reach, stated plainly

  • Not run: the concurrent form of G1. My probe is the sequential form. It proves the end state is
    reachable and that nothing prevents it; it does not measure how wide the real-time window is on a live
    multi-peer node.
  • Not verified: The node's replica is ~8,380 blocks behind its own peers, and a stale-replica zero balance is indistinguishable from an empty wallet #416's first half — the actual ~8,380-block lag on a real host. That needs the
    machine, as the PR itself says.
  • .gitnexus not used. The index is stale, and impact on a stale index returns a false-safe zero.
    All blast radius here is grep plus direct reads of blobs at 405ef503, plus one compiled probe.
  • Shared-state disclosure. I read the PR through git cat-file / git show / git diff only. I
    created and then removed my own worktree at C:/tmp/gate454-sec/wt, and ran git worktree prune, which
    touches only administrative metadata for worktrees whose directories are already gone and cannot affect
    a live one. The primary checkout remains on pr409 with its pre-existing modifications untouched; I
    never wrote to it, and I did not touch any C:/tmp/worktrees/dn-*.

Verdict: CHANGES-REQUIRED on G1. Everything else in this diff is sound, and all three escalated
decisions were called correctly by the lane.

Adds the staleness gap to `control.wallet.balance` (`network_peak_height`,
`stale_by`, both additive) and makes the human line say what it knows: an
unreadable balance renders `unknown` rather than a confident `0`, and a
non-current figure is marked NOT CURRENT with its as-of height and its distance
from the network.

Also adds the missing caller for the funded latch, so a funded auto-created
wallet stops being described as disposable.

Refs #416 #286
#306: the Sage-parity coin read returned an empty set for ANY CAT while
unsynced, so a real $DIG holder read as holding none. Wired to the same
asset-scoped hint read the balance already uses.

#256: increase_derivation_index returned the shared empty ActionResponse, so a
clamped or zero-row write was indistinguishable from success -- and the
derivation floor decides which addresses this node scans. It now reports the
floors in force, and a zero-row update is an error.

#384: control.wallet.resetCoinDb + `dign wallet reset-coin-db --confirm`.
Clears the authoritative flag in the SAME transaction as the coins, refuses
while a spend is in flight, and touches no key material.

Refs #306 #256 #384
…ded latch

SPEC gains the control.wallet.resetCoinDb row (the same-transaction clearing of
the authoritative flag, the expiry-not-presence refusal, the master tier), the
balance row gains network_peak_height and stale_by with the rule that an absent
gap and a zero gap are opposite claims, and 16.4's NOT YET SATISFIED block is
replaced by the observation-point contract now that a caller exists.

Bumps dig-wallet 0.43.0 -> 0.44.0 and the workspace 0.189.0 -> 0.190.0: new
capability, every wire change additive.

Refs #416 #286 #384
…sumer is

The contract-conformance gate refused a served-but-unpublished method, and its
sanctioned escape -- KNOWN_UNPUBLISHED -- is deliberately the SAME constant the
token gate reads, so tolerating the publish drift and granting a paired token
access are one decision.

Weighed rather than inherited: #384 exists to put a reset button in the DIG App,
and the App holds a paired token, so reserving this to the master token would
make the feature unreachable by the only consumer it was built for. What bounds
a destructive method here is loopback-only + a token + confirm:true on the wire
+ a refusal while a spend is in flight + a blast radius holding no key material,
not tier alone.

This also restores the master-tier drift assertion to equality: the containment
relaxation it needed is no longer required.

Refs #384
…ica synced

`reset_chain_cache` is atomic, but the catch-up's own writes are separate
transactions that nothing serialises against it: `apply_coin_states` per batch,
then `complete_catch_up` for the flag. A reset landing mid-catch-up therefore
emptied the coins and cleared `initial_sync_complete`, and the in-flight
catch-up set the flag again one statement later -- `balance 0, synced true` on a
funded wallet, with no attacker involved. The likelier variant is worse: a
partial coin set reported as synced reads as a plausible understated balance.

`sync_state` now carries a `reset_epoch` the reset increments. A catch-up
observes it before its first write and presents it in the terminal statement,
which carries `WHERE reset_epoch = ?`; a catch-up that began before a reset
cannot complete afterwards and returns `SyncError::ResetDuringCatchUp` so the
supervisor runs a fresh one. `SPEC.md` states this as a MUST, so its "until a
genuine catch-up re-establishes the flag" sentence is now backed by code.

Also repairs nine string literals mangled by lost `\` continuations, four of
them user-facing -- the destructive-reset warning and the `NOT CURRENT` staleness
line among them. They are `concat!` fragments now, because `cargo fmt --check`
cannot see the mangling and `cargo fmt` has reintroduced it elsewhere.
…air more mangled strings

The point-read refresh (`refresh_tracked_coins`) is the other writer of
`initial_sync_complete`, and it races the coin-database reset exactly as the
catch-up did: it fetches rows, the user resets, and it latches the flag over
whatever survived. It now observes the same `reset_epoch` before its own first
write and latches coverage plus the flag in one guarded statement.

That is what makes the SPEC sentence true as written. Without it the clause
"reads fall back until a genuine catch-up re-establishes the flag" would still
over-claim, because a refresh could re-establish it a moment after a reset.

Seven further string literals across cat_discovery, fallback, quorum and
sync_supervisor carried the same lost-backslash mangling; three of those are
operator-facing sync log lines.
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate — IN PROGRESS, not the verdict

Head audited: 6dff779c506bcfa7b714071ec21f35ec507a84c3 (resolved from remote, not from the brief).

1. The delta is NOT 405ef503..6dff779c — the branch was REBASED

405ef503 is not an ancestor of the current head. git merge-base 405ef503 6dff779c = 3e480dd3; the
five commits the prior gate audited were replayed onto the new base 853a7eb8 (which now carries
d41cf6f and 853a7eb, two batches that merged to main in the meantime), and three new commits were
added on top.

Patch-id comparison across the rebase (git patch-id --stable):

commit patch-id status
chore(wallet): open wallet batch lane 8f0b8b76 identical to the audited version
fix(wallet): a stale-replica zero... d982b737 identical
feat(wallet): report what an action did... 13c994d6 identical
docs(spec): record the reset method... ffb8e218 -> 942778bd CHANGED (the SPEC reword)
fix(control): put the coin-db reset on the paired tier 7bae0f89 identical
fix(wallet): a reset mid-catch-up... 4c3f9567 new
fix(wallet): guard the oracle-tier latch... 17dd1cc0 new
style(wallet): single-fragment concat! 33f6135 new

So the previously-cleared content is byte-identical except the SPEC commit, which is exactly the reword
the brief flagged. My subject is therefore: the three new commits + the SPEC delta + the rebase's
interaction with 853a7eb.

2. closingIssuesReferences — VERIFIED

gh pr view 454 --json closingIssuesReferences returns exactly [256, 286, 306, 384, 390]. #416 is
absent. Matches the brief.

3. The epoch mechanism, read

db.rs:1332 reset_epoch(), db.rs:1443+ complete_catch_up_unless_reset with
WHERE id = 0 AND reset_epoch = ? and rows_affected() == 1 -> bool; db.rs:~2855 the reset does
reset_epoch = reset_epoch + 1 inside the same tx as the deletes and the flag clear. The migration
ALTER TABLE sync_state ADD COLUMN reset_epoch INTEGER NOT NULL DEFAULT 0 lands an existing DB at 0,
which is the correct reading.

Still open at this point: the third-writer grep, the mutation probe, the revert re-run, set_initial_sync_complete,
the SPEC sentences, versions/lock, and the concat! sweep. Findings follow as they resolve.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate — IN PROGRESS, not the verdict (2/n)

Head: 6dff779c506bcfa7b714071ec21f35ec507a84c3.

4. THIRD-WRITER GREP — complete, and the brief's claim about set_initial_sync_complete is WRONG in a way that does not gate

I enumerated every occurrence of initial_sync_complete in every .rs/.sql blob at head (not grep of a
worktree — git show <ref>:<path> per file). There are exactly four SQL statements that write it, plus
the table init:

site statement guarded?
db.rs:1399 set_initial_sync_complete UPDATE sync_state SET initial_sync_complete = ? no
db.rs:1441 latch_synced_over_unless_reset ... initial_sync_complete = 1 WHERE id = 0 AND reset_epoch = ? yes
db.rs:1494 complete_catch_up_unless_reset ... initial_sync_complete = 1, ... WHERE id = 0 AND reset_epoch = ? yes
db.rs:2893 reset_chain_cache SET initial_sync_complete = 0, ..., reset_epoch = reset_epoch + 1 n/a (it IS the reset)
db.rs:419 INSERT OR IGNORE ... VALUES (0, NULL, NULL, 0) n/a (init, value 0)

So there is no third writer of the = 1 direction. Both latching writers take the guard. That part of
the fix is complete.

But the brief's premise that set_initial_sync_complete has "no production caller" is false. It has
one: sync.rs:952, in the backwards-move / reorg path —

db.set_initial_sync_complete(false).await?;

I checked every call site against the #[cfg(test)] boundaries (rpc.rs:5026, sync.rs:1318,
db.rs:4579, transport.rs:289, and the whole of sync_supervisor/tests.rs). Every call passing true
is inside a test module. The single production call passes false.

Verdict on it: it does NOT gate. false is the fail-safe direction — it makes the replica
non-authoritative, so reads route to the chain/oracle fallback. It cannot produce the synced true over an empty table lie, and racing it against a reset is harmless in both orders (both leave the flag at 0).
I am recording it as LOW / defense-in-depth, with the correction that the reason it is safe is the
direction of its only caller, not the absence of one. Worth a follow-up ticket, not a gate: the honest
shapes are clear_initial_sync_complete() with no argument, or #[cfg(test)] on the true path.

5. record_coverage is now DEAD in production — noted, not a finding

db.rs:1414 record_coverage writes covered_puzzle_hashes unguarded. After this PR its only remaining
callers are in #[cfg(test)] blocks (rpc.rs:5340, 8900, 9977, 10163, 10469, 10551, 10663, 10704) — the
one production caller was refresh_tracked_coins, which the delta replaced with
latch_synced_over_unless_reset. It cannot lie on its own: routing gates on
initial_sync_complete first, and coverage beside a 0 flag is unreachable for money reads. Dead-code
observation, not a security finding.

6. refresh_tracked_coins guard placement is STRONGER than the catch-up's

rpc.rs:3271 reads epoch_at_start before the fallback fetch, not merely before the first write — so
the oracle path has no analogue of the deviation-1 question at all. The latch at rpc.rs:3394 presents it,
and a discarded latch is logged rather than swallowed.

Still open: the mutation probe, the revert re-run, the SPEC sentences, versions/lock, the concat! sweep,
and deviation 1's interleaving argument.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate — IN PROGRESS, not the verdict (3/n)

Head: 6dff779c506bcfa7b714071ec21f35ec507a84c3.

7. Deviation 1 (epoch read at first WRITE, not first REQUEST) — the lane's argument HOLDS

I checked the only thing that could falsify it: whether initial_sync_with_authority performs any
database write between entry and the epoch read. It does not. Between the function head and
sync.rs:1185 (epoch_at_first_write = Some(db.reset_epoch().await?)) the only work is building
subscribed and requested from the caller's own arguments — no db. call of any kind. previous_height
starts at None, so the replay is from genesis and takes nothing from local state.

The two interleavings, worked:

  • Reset lands between the first REQUEST and the epoch read. Read-at-request would refuse; read-at-write
    admits. The admission is SOUND: every write this catch-up makes happens after the reset, and batch 1's
    payload is chain state fetched from the peer, not local state the reset invalidated. The replica ends
    up holding this catch-up's complete replay. This is precisely the pass the SPEC calls "a pass that began
    wholly AFTER the reset".
  • Reset lands between the epoch read and apply_coin_states. Refused, even though the replay is in
    fact wholly post-reset. A false refusal — but it fails CLOSED (flag stays 0, reads fall back, the
    supervisor runs a fresh pass). Costs one catch-up, lies about nothing.

So there is no interleaving where reading later admits a completion that reading earlier would have
refused, unsoundly.
Deviation 1 is clear.

8. refresh_tracked_coins reaches a MONEY path — checked, and it is clean

The sole production caller is tipping.rs:1120, immediately before build_and_broadcast_dig_tip — a
point-read refresh so coin selection runs over current chain state. If a reset lands mid-refresh the latch
is discarded, initial_sync_complete stays 0, and selection therefore does not route to the emptied
replica. Rows the refresh wrote after the reset are real oracle-sourced chain coins, so a spend built on
them is valid. No false spend and no understated selection. The caller already treats a refresh failure as
non-fatal (if let Err(e) = ... eprintln! ... continue).

9. FINDING — SPEC over-claims on one clause (MEDIUM, does NOT gate)

Both sentences the brief named are now TRUE:

  • SPEC.md:4994-4996 (the reword) — "until a later sync pass re-establishes the flag — an
    address-history catch-up, or the oracle-tier point-read refresh, which is the other writer of it"
    .
    True: exactly those two writers exist (db.rs:1494 and db.rs:1441), both verified above.
  • The control.wallet.resetCoinDb row's new normative block — "The node MUST record a reset counter that
    the reset increments in that same transaction; every writer ... MUST observe that counter BEFORE its own
    first write and present it again in the statement that sets the flag, which MUST NOT take effect if the
    counter has moved."
    True on both writers, verified above and at db.rs:2893 (reset_epoch = reset_epoch + 1 inside the reset's own tx).

But one sentence in that same block claims more than the code delivers:

"A sync pass whose completion is refused this way MUST report an error rather than success, so a fresh
pass runs."

The preceding sentence explicitly binds "every writer ... the address-history catch-up and the
oracle-tier point-read refresh alike", so "a sync pass" reads as both. The catch-up complies
(sync.rs:1209 returns Err(SyncError::ResetDuringCatchUp)). The refresh does notrpc.rs:3392-3402
emits tracing::info! on a discarded latch and returns Ok(count).

Failure direction is SAFE (the flag stays 0, reads fall back, no money lie), and an independent
implementer reading the SPEC would build the STRICTER behaviour, so this introduces no hole. It is a §4.2
spec-truth defect, not a vulnerability. Not gating. Cheapest correct fix is one word in the SPEC —
narrow the sentence to the catch-up — since the refresh's only caller already ignores its errors.

10. Versions and lock — VERIFIED ON DISK

Read from the worktree files, not the diff:

main branch
root Cargo.toml 0.191.0 0.192.0
crates/dig-wallet/Cargo.toml 0.44.0 0.45.0

Cargo.lock at head agrees with every workspace member: dig-node-service 0.192.0 (via
version.workspace = true), dig-wallet 0.45.0, dig-node-core 0.65.0, and I cross-checked each member's
Cargo.toml against its lock entry. A --locked build will not fail on version drift. git status --porcelain in a clean checkout of the head is empty.

11. The concat! sweep — no reintroduction, and the residue is test-only

Scanning every crates/dig-wallet/**/*.rs blob at head for the mangling signature (>=4 interior spaces
inside a quoted literal) leaves six hits, none user- or operator-facing:
peer_reads.rs:523 is a // comment, and the other five are assert! messages in
sync_supervisor/tests.rs (2848, 3955, 4006, 4011, 4858). Nothing in a tracing::, write!, eprintln!
or Display path. cargo fmt has not reintroduced mangling into any of the repaired sites.

Probes still running: revert-RED and the unconditional-refuse mutation.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate — IN PROGRESS, not the verdict (4/n)

Head: 6dff779c506bcfa7b714071ec21f35ec507a84c3. Probes run in a lane-private worktree
(/c/tmp/secgate454/wt, detached at the head SHA) with its own CARGO_TARGET_DIR. The primary checkout
and every /c/tmp/worktrees/dn-* were untouched.

12. BASELINE — the regression tests really run, and they pass

running 2 tests
test sage::sync::tests::a_reset_mid_catch_up_is_not_overwritten_into_a_partial_authoritative_replica ... ok
test sage::sync::tests::a_reset_mid_catch_up_is_not_overwritten_into_an_empty_authoritative_replica ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 750 filtered out; finished in 0.02s

Count is 2, not 0 — this is not a filter that matched nothing.

13. PROBE A — REVERT REPRODUCED, and it fails on the right assertion

I reverted exactly the guard clause and nothing else, at db.rs:1500:

-             WHERE id = 0 AND reset_epoch = ?",
+             WHERE id = 0 AND ? IS NOT NULL",

(the bind is retained, so the statement shape and parameter count are unchanged — only the guard's
predicate is neutralised.) Result:

running 2 tests
test ...a_reset_mid_catch_up_is_not_overwritten_into_a_partial_authoritative_replica ... FAILED
test ...a_reset_mid_catch_up_is_not_overwritten_into_an_empty_authoritative_replica ... FAILED
test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 750 filtered out

Both panics land on the synced assertion, and in the partial case the panic is at sync.rs:3190,
which is after the balance == 300 control assertion — so the fixture really does hold a genuine
SUBSET rather than an empty table, exactly as the lane claimed:

panicked at crates\dig-wallet\src\sage\sync.rs:3190:9:
a partial coin set reported as synced is an understated balance presented as complete

panicked at crates\dig-wallet\src\sage\sync.rs:3160:9:
an emptied replica reported as synced answers `balance 0, synced true` on a funded wallet

The lane reported 0 passed; 2 failed; 744 filtered out; I measure 750 filtered out because
f73e161 added six db-seam tests after that run. The passed/failed pair is identical. The claimed RED
is real and I reproduced it independently.

Probe B (unconditional-refuse mutation, full -p dig-wallet --lib) is running; result to follow.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate — IN PROGRESS, not the verdict (5/n)

Head: 6dff779c506bcfa7b714071ec21f35ec507a84c3.

14. PROBE B — the unconditional-refuse mutation IS caught, by 18 tests

The brief's sharpest concern: a guard that refuses EVERY completion strands the replica permanently
non-authoritative, which is worse than the bug and silent. I mutated both guards to refuse
unconditionally —

db.rs:1448   Ok(result.rows_affected() == 1)          ->  Ok(result.rows_affected() == 1 && false)
db.rs:1509   let recorded = result.rows_affected() == 1;  ->  ... == 1 && false;

— and ran the whole -p dig-wallet --lib suite:

test result: FAILED. 733 passed; 18 failed; 1 ignored; 0 measured; 0 filtered out; finished in 124.84s

The 18:

  • both db-seam tests, on their CONTROL halfa_completion_from_before_a_reset_cannot_mark_the_replica_synced
    and an_oracle_latch_from_before_a_reset_cannot_mark_the_replica_synced. Each asserts the stale epoch is
    refused AND that a fresh one still lands (db.rs:5247-5259, db.rs:5279-5286). The mutation trips the
    second half.
  • 5 catch-up tests: fresh_install_catch_up_from_genesis_is_accepted, long_downtime_catch_up_is_accepted,
    initial_sync_completes_and_ignores_coins_outside_the_subscription,
    the_catch_up_never_admits_a_derived_hash_coin_as_xch, a_derived_hash_offered_as_an_address_is_refused_admission.
  • 11 supervisor tests that drive a real catch-up to completion, including
    a_replica_level_with_its_peers_is_never_declared_stalled, stall_evidence_survives_the_end_of_a_session,
    the_supervisor_promotes_a_staged_cat_admission_during_an_ordinary_sync.

And the decisive detail: the two #454 regression tests from probe A are NOT in that failure list. They
pass happily under a guard that refuses everything — which is exactly the blind spot the brief named, and
exactly why the 18 above matter. The "must still land" direction is pinned broadly and independently, not
by the same tests that pin the refusal.

Counts are real throughout: 0 filtered out, 733+18+1 = 752 = the 750-filtered + 2 from the earlier
targeted run.

15. reset_chain_cache — the increment IS in the reset's own transaction

db.rs:2837 let mut tx = self.pool.begin(), then the reservation count, the eleven DELETE FROMs, then
one statement SET initial_sync_complete = 0, covered_puzzle_hashes = '', reset_epoch = reset_epoch + 1 WHERE id = 0, then tx.commit(). Nothing between the deletes and the increment can commit separately, so
a concurrent writer cannot observe an emptied table at the OLD epoch. The refusal path returns before
begin-scoped writes take effect. Correct.

16. Migration safety — checked, and it fails CLOSED

ALTER TABLE sync_state ADD COLUMN reset_epoch INTEGER NOT NULL DEFAULT 0 runs in the
ADD_COLUMN_MIGRATIONS loop at db.rs:945, whose body is let _ = sqlx::query(stmt).execute(...) — errors
swallowed, deliberately, so the duplicate-column error on a fresh DB (whose CREATE TABLE already carries
the column, db.rs:417) is ignored. SQLite permits ADD COLUMN ... NOT NULL when a non-NULL default is
given, so the statement is legal on an existing DB. If it somehow did not apply, reset_epoch()'s
fetch_one would error and propagate through db.reset_epoch().await? — the catch-up fails, the flag is
never set, reads fall back. Fail-closed. (The swallow is a pre-existing pattern shared by every entry in
that list, not something this PR introduces.)

17. FINDING — a doc block was ORPHANED onto the wrong test (LOW, does NOT gate)

a57c75b inserted the two new #454 tests between an existing doc block and the function it documented.
At head, db.rs:5210-5218"The money hazard, asserted directly: a reset must never leave an EMPTY
replica claiming to be AUTHORITATIVE (dig-node#384).
"
... "The pre-state is asserted first, so this
cannot pass against a database that was never synced to begin with"
— now sits above
a_completion_from_before_a_reset_cannot_mark_the_replica_synced, which asserts no synced pre-state
(it opens an empty in-memory DB). So that last sentence is false of the test it now documents.

Meanwhile a_reset_clears_the_authoritative_flag_along_with_the_coins (db.rs:5288), which the block
was written for and where every sentence IS true, now carries no doc at all. Verified by reading the
same region at f903fe1 (the parent commit), where the block sits directly above that function.

Harmless to behaviour; it is a false doc claim in a money-adjacent test file, and the fix is to move the
block back down. Follow-up, not a gate.

Remaining: baseline full-suite green (running), then the verdict.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security scoped re-gate: PASS

Head audited: 6dff779c506bcfa7b714071ec21f35ec507a84c3 (resolved from gh pr view 454 --json headRefOid, re-checked at the end of the audit and unchanged; still DRAFT). Base 853a7eb8.

The HIGH from the prior round is FIXED, and I proved it by execution rather than by reading. No new
security defect in the delta. Two non-gating findings and one correction to the brief are below.


The scope was not what the brief said, and that matters

405ef503 is not an ancestor of the current head — the branch was REBASED onto 853a7eb8. I mapped
the real delta by patch-id: four of the five previously-cleared commits replayed byte-identically
(8f0b8b76, d982b737, 13c994d6, 7bae0f89), the SPEC commit changed as expected
(ffb8e218 -> 942778bd, the reword), and three commits are new. git diff 853a7eb..6dff779c is 20 files,
all wallet/control — the rebase dragged in nothing; the peer/net/network-info files that appear in a
405ef503.. diff are main's own #458/#453 commits, not this PR's.

The fix, verified from both directions by execution

check result
baseline, targeted 2 passed; 0 failed; 750 filtered out — count is 2, not a filter that matched nothing
revert probe (guard predicate neutralised, bind retained) 0 passed; 2 failed; 750 filtered out — both on the synced assertion; the partial case panics at sync.rs:3190, i.e. after its balance == 300 control, so the fixture holds a genuine SUBSET
mutation probe (both guards forced to refuse) 733 passed; 18 failed; 1 ignored; 0 filtered out
baseline, full suite 751 passed; 0 failed; 1 ignored; 0 filtered out — so all 18 are attributable to the mutation
cargo fmt -p dig-wallet zero changes — fmt does not re-mangle the repaired literals, and the tree is fmt-clean

The revert I applied was WHERE id = 0 AND reset_epoch = ? becoming WHERE id = 0 AND ? IS NOT NULL at
db.rs:1500 — the bind is retained so the statement shape and parameter count are unchanged, and only the
guard predicate is neutralised. The mutation was result.rows_affected() == 1 becoming
result.rows_affected() == 1 && false at both db.rs:1448 and db.rs:1509.

The mutation probe is the one that answers the brief's sharpest question. A guard that refuses every
completion would strand the replica permanently non-authoritative — silent, and worse than the bug. It is
caught by 18 tests: both db-seam tests on their control half (db.rs:5247-5259, db.rs:5279-5286),
five catch-up tests, and eleven supervisor tests that drive a real catch-up to completion. Decisively, the
two #454 regression tests pass under that mutation — so the "must still land" direction is pinned by
tests entirely independent of the ones pinning the refusal.

Areas checked, and why each is clear

  1. Third writer of initial_sync_complete — none exists. Enumerated every .rs/.sql blob at head.
    Four SQL writers: set_initial_sync_complete (db.rs:1399), latch_synced_over_unless_reset
    (db.rs:1441, guarded), complete_catch_up_unless_reset (db.rs:1494, guarded), and the reset
    (db.rs:2893). Nothing else sets it to 1.
  2. Deviation 1 (epoch at first WRITE) is sound. No db. call whatsoever occurs between the function
    head and sync.rs:1185; previous_height starts None, so nothing is taken from local state. The one
    interleaving where reading later ADMITS what reading earlier would refuse is the case where the whole
    replay runs after the reset — exactly the pass the SPEC entitles to re-establish the flag. The reverse
    case (reset between the read and the first write) refuses a valid replay: fail-closed, costs one
    catch-up, lies about nothing.
  3. Deviation 2 (refresh_tracked_coins) is stronger than required. rpc.rs:3271 reads the epoch
    before the fallback fetch, not merely before the first write.
  4. The reset's increment is in its own transaction. db.rs:2837 begin, then the reservation count,
    then 11 DELETE FROM, then one statement setting initial_sync_complete = 0,
    covered_puzzle_hashes = '' and reset_epoch = reset_epoch + 1, then commit. No concurrent writer can
    observe an emptied table at the old epoch.
  5. Migration fails closed. ALTER TABLE sync_state ADD COLUMN reset_epoch INTEGER NOT NULL DEFAULT 0
    runs under let _ = ... (db.rs:945), so the duplicate-column error on a fresh DB is ignored by design;
    an existing DB lands at 0, correct. Had it not applied, reset_epoch() fetch_one errors and propagates,
    so the flag is never set and reads fall back. A NULL or corrupted reset_epoch also never matches
    WHERE reset_epoch = ?: refusal, not a false latch.
  6. The money path through the refresh is clean. Its only production caller is tipping.rs:1120,
    immediately before build_and_broadcast_dig_tip. A discarded latch leaves initial_sync_complete = 0,
    so selection does not route to the emptied replica; rows written after the reset are real oracle-sourced
    chain coins. No false spend, no understated selection.
  7. No new attacker primitive. reset_epoch is written only by reset_chain_cache (paired-tier,
    loopback-only — already cleared). A peer driving a catch-up cannot influence it. The extra cost is one
    SELECT per catch-up and per refresh: no amplification. Repeated resets could keep the replica
    non-authoritative, but that caller can already empty the coin DB in a loop for the same denial, loudly.
    i64 overflow needs 2^63 resets.
  8. Versions and lock, read on disk. root 0.191.0 to 0.192.0, dig-wallet 0.44.0 to 0.45.0.
    Cargo.lock agrees with every member (dig-node-service 0.192.0 via version.workspace = true,
    dig-wallet 0.45.0, dig-node-core 0.65.0), so --locked will not fail.
  9. closingIssuesReferences = [256, 286, 306, 384, 390], read from the API. The node's replica is ~8,380 blocks behind its own peers, and a stale-replica zero balance is indistinguishable from an empty wallet #416 absent. Correct.
  10. concat! sweep. 22 multi-line concat! blocks, zero single-fragment (no useless_concat).
    Explicit named args added exactly where implicit capture would break (reservations, spend,
    settled, true_tip). Every repair preserves meaning — the only change is collapsing the mangled
    interior space runs. Residual mangling: six hits, all non-user-facing (one comment at
    peer_reads.rs:523, five assert messages in sync_supervisor/tests.rs).

Findings — neither gates

F1. SPEC claims more than the code delivers, on one sentence — MEDIUM, non-gating

Both sentences the brief named are now true. SPEC.md:4994-4996 ("until a later sync pass
re-establishes the flag — an address-history catch-up, or the oracle-tier point-read refresh, which is the
other writer of it") matches the two writers exactly. The control.wallet.resetCoinDb row's new counter
MUST is satisfied by both writers.

But the same block says:

"A sync pass whose completion is refused this way MUST report an error rather than success, so a fresh
pass runs."

The preceding sentence binds "every writer ... the address-history catch-up and the oracle-tier point-read
refresh alike", so "a sync pass" reads as both. The catch-up complies (sync.rs:1209, returning
Err(SyncError::ResetDuringCatchUp)); the refresh does notrpc.rs:3392-3402 logs tracing::info!
and returns Ok(count).

Money consequence: none. The failure direction is fail-closed (flag stays 0, reads fall back), and an
independent implementer reading this SPEC would build the stricter behaviour, so the over-claim opens no
hole. It is a section 4.2 spec-truth defect. Cheapest correct fix is one word — narrow that sentence to the
catch-up — since the refresh's only caller already discards its errors by design.

F2. A doc block was orphaned onto the wrong test — LOW, non-gating

a57c75b inserted the two new #454 tests between an existing doc block and the function it documented.
At head db.rs:5210-5218 — "The money hazard, asserted directly ... The pre-state is asserted first, so
this cannot pass against a database that was never synced to begin with" — sits above
a_completion_from_before_a_reset_cannot_mark_the_replica_synced, which asserts no synced pre-state
(it opens an empty in-memory DB), so that last sentence is false of the test it now documents. Meanwhile
a_reset_clears_the_authoritative_flag_along_with_the_coins (db.rs:5288), which the block was written
for, now carries no doc at all. Confirmed by reading the same region at the parent commit f903fe1, where
the block sits directly above that function. Fix: move the block back down.

Correction to the brief (not a finding)

set_initial_sync_complete does have a production callersync.rs:952,
db.set_initial_sync_complete(false) in the backwards-move/reorg path. Every call passing true is inside
a #[cfg(test)] module (boundaries checked at rpc.rs:5026, sync.rs:1318, db.rs:4579,
transport.rs:289, plus all of sync_supervisor/tests.rs). It does not gate: false is the fail-safe
direction and cannot produce the "synced true over an empty table" lie. But the reason it is safe is the
direction of its only caller, not the absence of one — worth a follow-up (clear_initial_sync_complete()
with no argument, or #[cfg(test)] on the true path). Separately, record_coverage (db.rs:1414) is now
dead in production — its last production caller was the code this PR replaced. Dead-code note only.


What I could not reach

Shared state

All probes ran in a lane-private worktree at C:/tmp/secgate454/wt (detached at the head SHA) with its own
CARGO_TARGET_DIR=C:/tmp/secgate454/target. Both removed, git worktree prune run. The primary checkout
(modules/apps/dig-node, parked on pr409) and every C:/tmp/worktrees/dn-* were never touched — no
checkout, reset, stash or edit in any shared tree. The only mutating commands anywhere were
git worktree add/remove and edits inside my own worktree, each reverted with git checkout -- there
and verified git status --porcelain empty at 6dff779c before removal.

MichaelTaylor3d and others added 2 commits August 31, 2026 12:27
…s, reunite a doc

Two non-gating findings from the re-gate.

The SPEC said "A sync pass whose completion is refused this way MUST report an
error rather than success". That binds BOTH writers by its preceding sentence, and
only the catch-up complies -- the oracle-tier refresh logs and returns Ok. The
behaviour is fine (it re-reads on its next call and has no pass to re-run), so the
sentence was the thing that was wrong. Narrowed to name each writer's obligation
rather than leaving a normative clause claiming more than the code delivers.

And a doc block was orphaned: the #454 tests were inserted between an existing
comment and its function, so the "money hazard, asserted directly" block came to
describe a different test while `a_reset_clears_the_authoritative_flag_along_with_the_coins`
lost its own. Reunited.

Refs #384

Co-Authored-By: Claude <noreply@anthropic.com>
Merged rather than rebased: main's #452 setUpstream master-tier entry and this
branch's move of resetCoinDb to the paired tier both edit the same list, and
replaying commit-by-commit produced sequential conflicts in one file with a
half-applied assert block in between. Merging resolves once against final state.

Both entries are correct together: setUpstream is master-tier (#255 -- it persists
a caller-chosen third party and survives pairing.revoke), resetCoinDb is paired
(its only consumer is the DIG App, which holds a paired token).
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 31, 2026 21:36
@MichaelTaylor3d
MichaelTaylor3d merged commit 6e2475a into main Aug 31, 2026
14 of 15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/batch-wallet branch August 31, 2026 21:36
MichaelTaylor3d added a commit that referenced this pull request Aug 31, 2026
Both branches had independently fixed the same mangled string literals in
control.rs -- this one by collapsing the runs inline, main's by wrapping them in
`concat!`. Took main's side: a backslash-continued literal is what `cargo fmt`
silently rejoins back into the defect, so `concat!` is the form that stays fixed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment