Skip to content

feat(mirror): the pass runner -- disk and chain observed, reclaims before creates - #414

Merged
MichaelTaylor3d merged 8 commits into
mainfrom
loop/412-mirror-pass-runner
Aug 30, 2026
Merged

feat(mirror): the pass runner -- disk and chain observed, reclaims before creates#414
MichaelTaylor3d merged 8 commits into
mainfrom
loop/412-mirror-pass-runner

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — gate round in progress. Custody-adjacent; this takes the full triple gate.

Step 4's impure half of #412 — the mirror-coin pass
runner (SPEC.md §25.4). The pure planner, presence tracker, spend builders and scoped signer landed
in #379 (fba53ec); this adds the half that observes, orders and executes.

Closes #412


What the runner is, and what it deliberately is not

crates/dig-node-service/src/mirror/runner.rs. It owns steps 1, 2, 4, 5 and 6: it observes disk and
chain, derives the in-flight set from the audit record, asks pass::decide what to do, then does it —
every reclaim first, creates second, stopping cleanly.

Everything that can fail sits behind one trait, MirrorEffects. What that trait deliberately does
not hold is the ORDER, the funds gating, or the in-flight suppression — those are ordinary code
here, over a trait a test implements in twenty lines. Each of the three rules is a property of the
SEQUENCE rather than of any single effect, so a test that could only observe effects individually
could not see any of them.

Not in this diff, and stated plainly rather than implied: nothing constructs a pass and nothing
schedules one. MirrorEffects has no production implementation, so dig_mirror_coin::list,
MirrorSigner::new, build_create and build_reclaim still have zero callers, and no coin is
created, reclaimed or broadcast at this head. Constructing a MirrorSigner at bring-up is the act
that starts unattended spending, and doing it in the same diff that first tests the runner would ship
the money path before anyone has watched a single pass run. That is the next child, and #412's step 8
is its acceptance.

Blast radius checked

gitnexus fell back to grep + direct read (§2.0 bound 2, stated as required): this worktree had no
index and a fresh analyze was not worth the ~10-minute stop for a radius this small.

symbol radius risk
BondState (renamed + 2 new variants) zero references outside mirror/pass.rs — the enum has no consumer yet LOW
SpendIntent / SpendRecord (+ bond) 9 construction sites, 8 of them test fixtures; the one production site is MirrorSpends::intent LOW
MirrorSigner::sign one production caller: none. The tests in signer.rs are the only callers LOW
pass::decide / PassInputs (+ relayed) one caller, PassRunner::run LOW

spends_list_wire/spend_row map SpendRecord into the contract's AutomatedSpend field by field,
so the added field does not reach the control.spends.list wire. #[serde(default)] means an
existing audit line still parses.

The five items #412 carried

1. F6 — the signing-failure status. signer.rs used a bare ? on WalletSigner::sign, so
recorded dropped inside the frame and Drop wrote Unresolved"the node signed; money may well
have moved"
. No bundle exists when signing fails. Now Failed { stage: Signing }, the only status
whose money_may_have_moved() is false. Regression test first, and it needed a new
unsignable_for_tests seam (a truncated CLVM cons) because an empty spend set always signs.

2. BondState::WithheldDisabled, plus new Withheld and Reclaiming — matching
dig-node-control-interface 0.26.0. Three states differing in scope and decisively in remedy: an
operator told "withheld" about a disabled node goes looking at content when they should be looking
for a switch. Reclaiming outranks the chain in bond_states, because the coin is still there — that
is what a reclaim is for — and Bonded would say the collateral is advertising a capsule whose money
is on its way home.

3. Relayed filtered AT THE SOURCE. split_by_provenance splits the observation by
CapsuleProvenance at the point it is made, and PassInputs::held / ::relayed are separate fields
thereafter — so the create path is structurally unable to see a relayed capsule and no caller can
substitute a pre-filtered set. This is the one place an attacker influences what the node spends its
own money on. It also gives withheld a real producer: a Held-keyed derivation could never emit
it, which was the vacuously-satisfied clause #412 flagged.

4. Two SPEC.md doc corrections. §25.2's banner said "two structural halves" and listed three, one
being a parameter the same commit removed — now three, correctly named. §25's allowlist tail reached
clauses the subsection banners call pending — now read narrowly, entry by entry.

5. MIRROR_SPEND_FEE_CEILING_MOJOS — already exists. #379 shipped it at signer.rs:70 with
both-sided coverage. The brief listed it as owed; it is not. Nothing to do, recorded so nobody re-does it.

From the coordinator's 0.26.0 note

  • Served-set enumeration, not provenance_unknown. The relayed set travels through to
    PassInputs::relayed and bond_states emits a Withheld row for each, so a page can be complete.
  • PassReport::locked_dig_base_units is the WHOLE-SET total, including reclaiming coins
    summed here, where the entire chain observation is in hand, precisely so §25.8 cannot compute it by
    summing a page. A page sum under-reports locked money, showing unspendable funds as available.
  • Keys are canonicalised BEFORE anything sorts them (canonical), so this producer's order agrees
    with a client's MirrorBondKey derive rather than paging out of step with it.

0.26.0 is NOT adopted in this PR. Adopting it reddens control_contract_conformance.rs until the
method is served, which is step 6 — a different unit of work. This PR leaves that test green.

Tests — and the proof they are load-bearing

61 mirror tests, 12 of them new in runner.rs. What each new one pins:

test property, and the wrong implementation it is red against
reclaims_are_not_gated_on_funds balance zero, two coins to reclaim — a funds-gated path does nothing at all
the_reclaim_precedes_the_create_in_the_same_pass one recording list, not two counters; two counters cannot see an interleaving
a_failed_reclaim_does_not_stop_the_next_one failure is first, honest coin behind it
a_shortfall_creates_a_deterministic_prefix_and_is_not_an_error disk order shuffled, so it needs the sort; stopped_at stays None
a_failed_create_stops_the_pass_... affordable bond behind the failure is not attempted
an_in_flight_create_is_suppressed_across_a_restart runner built fresh over the log — an in-memory ledger passes a reused-instance test and fails this
a_resolved_record_no_longer_suppresses_the_create suppression must LAPSE, or the bond is uncollateralised forever
an_open_record_for_a_previous_epoch / ..._another_root_of_the_same_store why the key is three terms; a store-keyed impl passes one and fails the other
a_requirement_unknown_defers_creates_but_not_reclaims both a create and a reclaim present, so an early return is red
the_switch_off_reclaims_everything_and_creates_nothing OFF releases; it must not freeze
a_relayed_capsule_never_reaches_the_create_path a Held bond alongside, so silent-drop reports one state instead of two
bond_ids_are_canonicalised_before_anything_orders_them case and 0x varied independently, ordered so raw strings sort differently
the_locked_total_includes_coins_that_are_being_reclaimed three coins at 300/900/1500, so no partial sum coincides with the right answer
an_unreadable_chain_makes_the_pass_spend_nothing unknown ≠ empty; the double panics if anything spends

Revert-proof, committed first. Reverting only the F6 fix, and separately reversing only the
reclaim/create order, fails exactly two tests and only those two — 59 passed, 2 failed. Notably
reclaims_are_not_gated_on_funds stayed green under the reversed order, which is correct: it pins
funds-independence, not placement. The two properties have separate proofs.

Gates

  • cargo test -p dig-node-service — 587 lib tests green, mirror suite 61/61
  • cargo clippy -p dig-node-service --all-targets -- -D warningsclean
  • cargo fmt --all — clean; it touched only the two files in this diff
  • Version 0.169.00.170.0 (minor: new capability, additive field, no wire change)

Six failures seen in an earlier accidental workspace-wide run are in dig-app-core
(wallet::node::* cache tests and a copy space-run check) — a different crate with no dependency on
dig-node-service, untouched by this diff, and pre-existing.

What remains for #412

Constructing and scheduling a pass (a MirrorEffects over the node's chain transport, operator
wallet and capsule cache; the start-up run, the MIRROR_ROUND_LENGTH_MS tick, the debounced presence
trigger), then step 6 (serve control.mirror.bondStates, adopt 0.26.0, add the dign verb),
step 7 (the DHT pointer) and step 8 (the real-machine proof, which is the epic's acceptance).

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

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Local evidence, terminal

cargo test -p dig-node-service (lib + every integration target) — exit 0, 24 result blocks, 0 FAILED.
Mirror suite 61/61, including the 12 new runner.rs tests and the F6 regression test.

cargo clippy -p dig-node-service --all-targets -- -D warningsexit 0.

cargo fmt --all — clean, and it touched only the two files already in this diff.

Revert-proof, run against committed state (file copies, not git checkout): reverting only the F6
fix in signer.rs, and separately reversing only the reclaim/create order in runner.rs::execute,
fails exactly two tests and only those two — 59 passed, 2 failed:

failures:
    mirror::runner::tests::the_reclaim_precedes_the_create_in_the_same_pass
    mirror::signer::tests::a_signing_failure_is_recorded_as_failed_at_signing_not_unresolved

reclaims_are_not_gated_on_funds stayed GREEN under the reversed order. That is correct rather than a
gap: it pins funds-independence, not placement, and the two properties have separate proofs. A single
test going red for both would have meant one of them was untested.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Correctness gate on ab17b159968aa579025a1c65d86d052731cf0f96 — IN PROGRESS, not the verdict.

Confirmed so far, with evidence, so it survives an interruption:

1. The money path is genuinely NOT live — the PR's central claim holds. Grepped the whole crate tree, not the PR body:

  • MirrorEffects appears in no file outside mirror/runner.rs — zero production impls.
  • dig_mirror_coin::list / mirror_coin::listzero occurrences anywhere under crates/.
  • MirrorSigner::newzero callers.
  • PassRunner::new — called only from runner.rs:545 (fn runner(..), #[cfg(test)]).

So the decision not to construct or schedule a pass in this unit is correct and honestly declared: no coin can be created, reclaimed or broadcast at this head.

2. control.spends.list wire is unchanged. spend_row (control.rs:3236-3263) maps SpendRecord field by field into AutomatedSpend; bond is not among them. #409's contract-type response is not regressed.

3. Relayed is excluded structurally, not by a promise. split_by_provenance (runner.rs:324) splits at the point of observation; PassInputs::held / ::relayed are separate fields; run (runner.rs:218-222) is the only producer, and it splits its own observe_disk result — a caller cannot substitute a pre-filtered set. The match on CapsuleProvenance is exhaustive over a non-#[non_exhaustive] enum (dig-node-core/src/lib.rs:4117), so a third provenance would be a compile error rather than a silent fall-through into held.

4. F6 is fixed. signer.rs:176-191 resolves the record with journal.failed(.., FailureStage::Signing, ..) before returning, so the Drop-written Unresolved ("money may well have moved") is no longer reachable on a signing failure.

5. The §25 promotions are narrow and nothing over-promoted. SPEC.md §25.4's banner still marks the three triggers and the confirmation record pending; §25.1's banner still marks the biconditional and the observation pending; §25.8's own banner is still PENDING — not yet implemented, which is right — only the vocabulary was promoted, and the section list now says explicitly it is to be read narrowly.

6. The in-flight defect was real. SpendRecord carried only store_id; plan.rs:116-117 keys reclaim/create on (store, root, epoch), so store-keyed suppression would have withheld a legitimate create for a different root and — at rollover — for the next epoch. AuditedBond makes all three terms available and in_flight_creates (runner.rs:374-400) uses all three.

One gating finding and two non-gating ones are being posted inline now. Verdict follows.

@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/412-mirror-pass-runner branch from 5ba189c to 80d2d2a Compare August 30, 2026 04:25
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Auditing ab17b159968aa579025a1c65d86d052731cf0f96 (resolved from gh pr view 414 --json headRefOid, not from the dispatch brief). Diff taken against merge-base fba53ecbba10afeab90efbc7c74cca1302d9760b. Read-only: no checkout, no worktree, nothing mutated in the shared tree.

Posting findings as they resolve so nothing is lost to a stall. Four items are settled.

1. The restraint HOLDS — nothing here starts unattended spending (VERIFIED)

The load-bearing claim in the brief. Measured, not taken on trust:

  • Every impl MirrorEffects is inside #[cfg(test)]runner.rs:463 (FakeEffects) and runner.rs:1010 (BlindChain). There is no other impl in the workspace.
  • Every PassRunner::new call is inside #[cfg(test)]runner.rs:546, runner.rs:1033.
  • Nothing outside runner.rs references runner:: at all. mirror/mod.rs:83 adds pub mod runner; and that is the whole wiring.
  • Every MirrorSigner::new call is in a test — signer.rs:207 (in mod tests) and tests/mirror_fee_ceiling.rs:46. No bring-up path constructs one.
  • dig-wallet is untouched by this diff, so WalletBackend::current_signer() is unchanged and still answers None for the general surface. Default-on auto-tipping does not go live as a side effect of this PR.

So every finding below is LATENT — it becomes live only when a production MirrorEffects lands. That is a severity input, not an excuse: the whole point of gating now is that the first caller inherits whatever this file decides.

2. bond did NOT join the control.spends.list wire (VERIFIED — no contract regression)

spend_row (control.rs:3236) maps into dig-node-control-interface's AutomatedSpend field by field, and bond is not among the fields mapped. The new SpendRecord::bond is on-disk-only. #409's compiler-checked-response property is intact — a field could not silently join the wire here, because the response type is the published contract's own and an unmapped field is simply not sent.

AuditedBond is #[serde(default)] on both SpendIntent (spend_audit.rs:358) and SpendRecord (spend_audit.rs:391), so the on-disk record stays additively readable. That is the §5.1-shaped direction and it is correct.

3. F6 is genuinely fixed, and Drop cannot undo it (VERIFIED)

signer.rs:176-190 replaces the bare ? with an explicit journal.failed(&recorded, FailureStage::Signing, cause) before returning Err. I checked the thing the fix depends on rather than assuming it: impl Drop for RecordedSpend (spend_audit.rs:706-718) early-returns on self.settled.get(), so the record settled by failed() does not also get an Unresolved entry written behind it. One attempt, one terminal entry, money_may_have_moved() == false.

I also walked the other early returns in sign(): both refusals (NotThisWallet at signer.rs:163, FeeAboveCeiling at signer.rs:168) happen before journal.begin(...) at signer.rs:174, so there is no RecordedSpend in scope to drop. No other early-return path in that function.

4. The amount is still derived, never a constant (VERIFIED)

pass.rs:169 prices from apply_safety_margin(*required_per_store_dig_base_units, inputs.margin_bp) and only from the Known arm; Unknown yields None. runner.rs:executes the create loop under if let Some(per_coin), so a pass with no requirement cannot run the loop at all and no default is substituted (runner.rs:349-351). The amount is passed to MirrorEffects::create as one value derived once per pass, so two coins of one pass cannot be priced differently.

Still open: items 1 and 2 of the brief (double-spend reachability, relayed-into-create), the zero-funds reclaim path, and the Reclaiming precedence question. Verdict to follow on this PR.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

CHANGES-REQUIRED

Head reviewed: 80d2d2aeb9548176531b13a931802cb7a1ce7f50 (resolved from the remote, not from the dispatch brief). Correctness leg, fresh context.

One gating finding. Everything else verified clean, and the central judgement call -- not constructing or scheduling a pass in this unit -- is right and honestly declared.

Ranked findings

# severity where what
1 GATING mirror/pass.rs:223-225 the Reclaiming precedence ignores the epoch, so an ordinary rollover reports a correctly-bonded capsule as money leaving; no fixture varies the epoch term against a bond holding both coins
2 non-gating mirror/runner.rs:374-386 an unreadable ledger is unknown but resolves toward spending, asymmetrically with the chain's unknown; the third state is not expressible on PassReport
3 non-gating SPEC.md 25.8 banner still cites dig-node-control-interface 0.25.0 while this PR's vocabulary matches 0.26.0. Outside the diff and pre-existing; sweep it when step 6 adopts 0.26.0. Resolved by me.

What I verified, and how

The deliberate omission -- verified by grep over crates/, not from the PR body. MirrorEffects has no occurrence outside mirror/runner.rs; dig_mirror_coin::list has zero occurrences anywhere; MirrorSigner::new has zero callers; PassRunner::new is reached only from runner.rs:545, which is #[cfg(test)]. The money path is not live and the PR is not under-declaring what it did. The reasoning is correct on its own terms: constructing a MirrorSigner is the act that starts unattended spending, and 2.6 puts that behind a watched pass, not beside the first test of the runner.

The three section-25 promotions, each checked independently.

  • 25.4 steps 4/5/6 -- PassRunner::run at runner.rs:218, in_flight_creates called at runner.rs:230. The order is execute's: every reclaim attempted first with failures collected, then creates behind if let Some(per_coin), breaking at the first error. Real.
  • 25.1 Relayed exclusion -- split_by_provenance (runner.rs:324) has one production caller, run (runner.rs:220), which splits its own observe_disk result; PassInputs::held and ::relayed are separate fields thereafter. No caller can substitute a pre-filtered set. The CapsuleProvenance match is exhaustive over a non-#[non_exhaustive] enum (dig-node-core/src/lib.rs:4117), so a future third provenance is a compile error rather than a silent promotion into held. Real, and structural rather than a promise.
  • 25.8 vocabulary -- Disabled, Withheld and Reclaiming are emitted from production at pass.rs:216, :233, :241. Withheld has a genuine producer, the relayed set, which is what discharges the vacuity 412 flagged. Real -- but see finding 1, which is about the precedence, not the vocabulary.

Nothing else was promoted. 25.4's banner still marks the three triggers and the confirmation record pending; 25.1's still marks the biconditional and the observation pending; 25.8's own banner is still PENDING -- not yet implemented. The section list's new read-NARROWLY clause is what makes those consistent rather than contradictory, and it is the right instrument. No born-false claim in this diff.

The head moved mid-review, and I re-ran the affected checks. The brief named ab17b15; the lane rebased and pushed 80d2d2a while I was reading. The delta is CHANGELOG.md plus twelve lines of SPEC.md, and every finding below was re-resolved against 80d2d2a -- pass.rs:223-225 is byte-identical there, and runner.rs is untouched by the rebase.

The self-caught banner correction is accurate, not merely different. It now reads "the deciding and the ORDERING halves exist, and nothing RUNS them -- no pass is constructed, so no observation is ever made and no spend is ever attempted", which is exactly what the grep above shows, and it does not overclaim in the opposite direction: it asserts nothing about observations existing. The added "0.26.0 is NOT adopted at this head and the method is NOT served" is also true of the diff -- crates/dig-node-service/Cargo.toml:92 still declares dig-node-control-interface = "0.24", and control_contract_conformance.rs is not in the diff and cannot have gone red.

The in-flight ledger defect, all four parts.

  1. Real. SpendRecord carried only store_id; root and epoch existed solely inside the free-prose purpose. plan.rs:116-117 keys reclaim/create on (store, root, epoch), so store-keyed suppression would withhold a legitimate create for a different root of the same store, and at rollover last epoch's open entry would suppress this epoch's create -- the node stops rolling over. Unimplementable as specified; correctly diagnosed.
  2. The fix supplies the key. AuditedBond { root, epoch } on both SpendIntent and SpendRecord, carried through SpendJournal::begin (spend_audit.rs:787), populated at the one production site MirrorSpends::intent (spends.rs:136-150), and consumed with all three terms at runner.rs:390-397.
  3. serde(default) does not reintroduce an absent-means-something collapse. Absent yields None, which suppresses nothing, so the pass falls back to the chain observation -- which already excludes any bond holding a current-epoch coin. It fails toward one possible duplicate, never toward a false suppression, and the epoch-overflow path (spends.rs:145) fails the same way. Finding 2 is the one place that direction is argued too broadly.
  4. The wire is untouched. spend_row (control.rs:3236-3263) maps field by field and does not carry bond; 409's contract-type response is not regressed.

F6. signer.rs:176-191 replaces the bare ? with an explicit arm calling journal.failed(&recorded, FailureStage::Signing, cause) before returning, so Drop's Unresolved is unreachable on a signing failure. The regression test asserts money_may_have_moved() directly and not only the discriminant -- correct, since a future third status with the wrong answer would pass a status-only assertion. The unsignable_for_tests seam (a truncated CLVM cons, cfg(test)) fails inside signature computation, so the test exercises the signing failure and not the ownership refusal in front of it.

BondState blast radius re-derived. Withheld, Disabled and Reclaiming appear only in mirror/pass.rs and mirror/runner.rs across crates/; the enum has no consumer outside the mirror module and reaches no served method. Zero-reference claim confirmed. Reclaiming-outranks-the-chain is the right idea; finding 1 is that it is applied one term too wide.

Ordering and funds-independence. Spot-checked the revert-proof's logic rather than re-running it. reclaims_are_not_gated_on_funds builds its fixture at balance zero with two coins to reclaim, so it is red against a funds-gated implementation and -- correctly -- green under a reversed order, because it pins funds-independence rather than placement. the_reclaim_precedes_the_create_in_the_same_pass uses one recording list, the only shape that can see an interleaving. The two properties do have separate proofs, as claimed.

Fixtures against the nearest wrong implementation. Confirmed as described: the failing reclaim is placed first with an honest coin behind it, so a stop-on-first-failure implementation is red; an_in_flight_create_is_suppressed_across_a_restart builds the runner fresh over the log, which an in-memory ledger would fail while passing a reused-instance test; the three coins at 300/900/1500 admit no partial sum equal to the total; and the 0x prefix and the case are varied independently in the canonicalisation test.

Housekeeping. No unrelated formatting of another lane's files despite cargo fmt --all's workspace reach. dig-app-core is untouched by this diff and does not depend on dig-node-service, so its six failures are pre-existing and not caused here. MIRROR_SPEND_FEE_CEILING_MOJOS did already exist at signer.rs:70 -- not a finding, as the PR says.

Required checks, asserted by name

check-merge-preconditions.sh --repo DIG-Network/dig-node --pr 414 --allow-draft, run unpiped: exit 1, because Test + coverage was IN_PROGRESS. The other four required contexts -- Lint commit messages, Check version increment, Rustfmt, Clippy -- were present and SUCCESS, and unresolvedReviewThreads was 0 before this review. The rebase to 80d2d2a restarts them, so re-run it before any merge; the exit code is the verdict, never the rollup.

Not merging, not undrafting, not editing. Finding 1 goes back to the lane. Findings 2 and 3 are non-gating and I am resolving them myself.

Comment thread crates/dig-node-service/src/mirror/pass.rs Outdated
Comment thread crates/dig-node-service/src/mirror/runner.rs
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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

Re-resolved to 80d2d2aeb9548176531b13a931802cb7a1ce7f50. My first comment audited ab17b15; the lane rebased while I was reading. I diffed the two heads rather than re-reading from scratch:

git diff ab17b15..80d2d2a --name-only -- '*.rs' 'Cargo.toml' 'Cargo.lock'   ->  (empty)

The Rust is byte-identical between the two heads. Only CHANGELOG.md and SPEC.md moved (15+/5-). So every finding in my first comment holds unchanged at 80d2d2a, and the line numbers below are 80d2d2a line numbers.

The SPEC delta is a correction in the honest direction and I checked it rather than waving it through: the banner now says the deciding and ordering halves exist with nothing running them, and the 0.26.0 sentence now reads "agreeing with dig-node-control-interface 0.26.0's tokens. That crate is NOT adopted at this head and the method is NOT served — only the vocabulary lines up." That is accurate. The lock pins 0.24.0 (Cargo.lock), 0.26.0 is published (I checked the index: 0.22/0.23/0.24/0.25/0.26), and the earlier wording "matching 0.26.0" would have over-claimed. Corrected before I raised it.


LEAD QUESTION 1 — can the loop double-spend a bond? No permanently. The None choice is deliberate, correct, and does NOT reinstate the old defect.

The absent-bond case, answered from the code

in_flight_creates, runner.rs:395-399:

.filter_map(|r| {
    let bond = r.bond.as_ref()?;
    let store_id = r.store_id.as_ref()?;
    (bond.epoch == current_epoch).then(|| Bond::new(store_id.clone(), bond.root.clone()))
})

The ? on r.bond.as_ref() drops the whole record out of the in-flight set. So:

  • A None suppresses NOTHING. The code picked, deliberately, and documented it at runner.rs:44-49.
  • A None does NOT silently reinstate the store-keyed behaviour — this was your sharpest sub-question and it is the one that could have been ugly. There is no fallback arm. A record with no bond contributes zero entries to the suppression set, so it cannot suppress anything, let alone the wrong thing. The pre-existing defect you quoted — "last epoch's open entry suppresses this epoch's create and rollover stops entirely" — is structurally unreachable through this door, because reaching it would require the record to contribute a store-only key and it contributes none. The new key fixes the defect rather than moving it, and the two tests at runner.rs:824 (an_open_record_for_a_previous_epoch_does_not_suppress) and runner.rs:846 (an_open_record_for_another_root_of_the_same_store_does_not_suppress) pin exactly the two axes the old single-term key collapsed.

Why fail-open is the RIGHT pick here — traced, not deferred to

The lane's justification is that the duplicate "is reclaimed at rollover as EpochEnded". I did not take that on trust; I traced plan() (plan.rs:156-172) for the two-coins-one-bond state:

  • During epoch n: both coins hit the Equal arm and both insert the same Bond into the covered BTreeSet. Neither is reclaimed. This is the cost — one epoch of doubled collateral.
  • At rollover (epoch n+1): both are Lessboth pushed as EpochEnded reclaims. The duplicate collateral comes home automatically.
  • If the capsule is deleted mid-epoch: both hit Equal + !held_set.containsboth reclaimed as NoLongerHeld.

There is no path that leaves duplicated collateral permanently locked. Worst case is one epoch of double collateral, self-healing, versus the failing-closed alternative's permanently uncollateralised bond and an undiscoverable node. The pick is correct.

Reachability of bond: None on a mirror record — effectively nil

MirrorSpends::intent() (spends.rs:120) is the only non-test producer of a MIRROR_COIN intent; I enumerated every MIRROR_COIN site and the rest are mod tests / CLI tests. It always sets bond, except when i64::try_from(BigInt) overflows, and for a create the epoch is the node's own — not attacker-set. And no MIRROR_COIN record exists in the field at all, because nothing has ever reached the signer.

What I did find on this question (both fail-OPEN, both bounded by the same self-healing property — reported at defense-in-depth rank, not gating)

Two other doors empty the suppression set, and neither is the bond field:

  1. runner.rs:376-386 — an unreadable ledger returns Vec::new(), i.e. no suppression at all, with only a tracing::warn!. Deliberate and documented. But note the asymmetry: an unreadable ledger is a transient node fault an operator can fix, whereas a None bond is a permanent property of an old record — the code gives the two the same answer, and only one of them argues for it.
  2. A torn last lineSpendLog appends with f.flush() and no sync_all (spend_audit.rs:540), fold drops unparseable lines into unreadable_lines, and in_flight_creates never consults that count. A crash mid-append can therefore lose the one open record that was doing the suppressing.

Both are bounded by the rollover recovery above, which is why they are notes rather than gates.


LEAD QUESTION 2 — can a Relayed capsule reach the create path? No. It is a shape, and I tried all four doors.

  • Mislabelled provenance at the source. Provenance is read per-artifact from the durable <root>.relay sidecar (capsule_store.rs:68), and the fallback is _ => Held — fail-open toward spending, so the write order is what matters. I checked it: module_reshare.rs:320-323 calls persist_holder_claim(cached, claim)? before std::fs::rename(&tmp, cached). The marker lands before the .dig becomes visible, and a marker that cannot be written is a hard WarmFailure::CacheWriteFailed that abandons the promotion. There is no window in which a relayed .dig is on disk without its marker.
  • A race between observation and use. observe_disk() is called once at runner.rs:219 and split immediately at :220. One snapshot, no re-read, no TOCTOU inside a pass.
  • The presence debounce retaining a flipped capsule. This was the one I expected to bite and it does not. PresenceTracker::observe (presence.rs:105-126) marks a bond present: false in the same call it goes missing from on_disk, and settled filters on o.present. A Held -> Relayed flip removes the bond from held in that very pass — no grace window.
  • A caller substituting a pre-filtered set. Structurally unavailable: MirrorEffects::observe_disk returns the unfiltered set with provenance required, split_by_provenance (runner.rs:325-337) is the single application point, and PassInputs::held / ::relayed are separate fields. plan() only ever receives held. The residual trust is that an impl does not lie about provenance — inherent to being the observer, and the trait is shaped to make that the only remaining way to get it wrong.

The exclusion holds.


One finding is heading for the verdict as GATING-adjacent, and it is not either of the above: runner.rs:231 reads the wallet balance with ? before the plan is taken, which lets a degraded wallet abort the pass and take the reclaims down with it. Writing that up now with the exploit trace.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security VERDICT: CHANGES-REQUIRED (one blocking item)

Audited head: 80d2d2aeb9548176531b13a931802cb7a1ce7f50 — re-resolved from the remote after the lane rebased mid-audit. Merge-base 113c422962fe74066a37080a685c75c82e704187. My first two comments audited ab17b15; git diff ab17b15..80d2d2a -- '*.rs' 'Cargo.toml' 'Cargo.lock' is empty, so all code findings transfer unchanged and every line number below is a 80d2d2a line number. Read-only throughout: no checkout, no worktree, no mutation of the shared tree. PR left DRAFT.

Nothing in this PR is a LIVE vulnerability — the restraint holds (see "What is clean"), so no unattended spending exists at this head. I am gating on exactly one item anyway, and I want to be explicit about why, because the brief's rule is "gate on live, ticket the rest".

F1 is a defect in this artifact's own headline invariant. runner.rs:9-26 states three rules the file exists to hold, ordered, with rule 1 being "Reclaims run before creates, and are never gated on funds." The file's whole design argument is that these live in the runner so that an effects implementation cannot get them wrong by being written differently. Rule 1 does not hold as written. Merging it means the next PR — the one that adds the caller — inherits a pass that silently skips every reclaim on a degraded wallet, at the point when it is hardest to see and competing with a live spender. Locking this shape correctly before a caller exists is the stated purpose of this PR, so this is in scope for it rather than for a follow-up.


F1 — BLOCKING. runner.rs:231 — the balance read gates the reclaims, inverting Rule 1

229:        let on_chain = self.effects.observe_chain()?;
230:        let in_flight = in_flight_creates(&self.log, ctx.current_epoch);
231:        let dig_balance_base_units = self.effects.dig_balance_base_units()?;   // aborts the pass

State to action to impact. An operator wallet that can answer observe_chain but cannot report its spendable $DIG — a CAT balance query against a degraded chain source, an RPC timeout, a locked or partially-available wallet — returns Err(PassError::Wallet(..)). The ? at :231 propagates out of run() before pass::decide is ever called, so execute never runs and not one reclaim is attempted. The node is then simultaneously unable to advertise and unable to recover collateral it has already locked. That is legacy bug 6 reached through a different door: not "funds gate the reclaim", but "the funds READ gates the reclaim".

This is not a contrived state — it is the state 25.4.4 was written for. That clause has reclaims attempted at fee = 0 precisely when no XCH is selectable, i.e. exactly when balance and coin-selection queries are the things failing. The pass gives up in the one situation the spec singles out for pressing on.

The reclaim list needs nothing from the balance. I checked the three consumers: plan() (plan.rs:145-190) does not take a balance; split_by_funds applies it only to create; bond_states uses the resulting split only for Unfunded reporting. The balance is a creates-only input, read two statements too early.

Aborting is strictly worse than attempting, on the module's own logic. Even if every reclaim then failed, PassReport.reclaim_failures would record it per coin — a surface an operator can act on. The ? instead yields one opaque PassError::Wallet and no per-coin record at all.

Why no test caught it — the doubles are wrong in one direction. Neither MirrorEffects double can ever fail this call: runner.rs:472-474 returns Ok(self.balance) and runner.rs:1020-1022 returns Ok(u64::MAX). The only PassError::Wallet values any fixture constructs are at :481 (a failing reclaim) and :498 (a failing create). The trait declares the error arm at :112 and no test exercises it, so reclaims_are_not_gated_on_funds proves only the balance == 0 case — the honest zero — and never the unreadable-balance case. The failure mode is unreachable from the fixtures, which is why a genuinely careful test file did not see it.

Fix. Move the read below plan, and do not let its failure reach the reclaims. Note the minimal version (unwrap_or(0)) is safe for the money — reclaims_are_not_gated_on_funds already proves reclaims run at balance 0 — but it would report every bond as Unfunded, i.e. "you are short of $DIG", when the truth is "the wallet could not be read". That is the unfunded-vs-everything-else conflation this whole BondState vocabulary exists to remove (dig-app#300), so please carry the distinction rather than collapsing it. A regression test wants a double whose balance read returns Err while its reclaims succeed.


Non-blocking findings (recommend fixing F2 here, since it is small and in-diff; F3-F6 are fine as follow-ups)

F2 — pass.rs:223-225: Reclaiming ignores the epoch and discards the coin

let reclaiming = reclaim.iter()
    .any(|(c, _)| c.store_id == bond.store_id && c.root == bond.root);

Two consequences from one line, and this is the inverse error the brief asked me to judge.

(a) It can hide a genuinely bonded, advertising bond. No epoch term, so any reclaim entry for that (store, root) wins, and it is checked before the chain. State: bond held; chain carries coin_old (epoch n-1) and coin_new (epoch n). plan() reclaims coin_old as EpochEnded and covered marks the bond from coin_new. bond_states then reports Reclaiming — "the bond is going away and the money is not back yet" — while the bond is in fact live, current-epoch and advertising. This is the ordinary rollover state, not a corner: the reclaim and the create are broadcast in the same pass and confirm independently, and 25.4.4 has the reclaim going out at fee = 0 while the create pays a fee, so the reclaim confirming slower is the expected case under fee pressure. The dig-node-control-interface 0.26.0 method doc says this method exists because conflated states "produce hourly out-of-funds alarms about a healthy node" — reporting a healthy advertising bond as reclaiming is that same family.

(b) any() throws away the coin the eventual surface needs. dig-node-control-interface 0.26.0 declares MirrorBondState::Reclaiming { coin_id, epoch, amount_dig_base_units } (results.rs:2305-2312, "read from the coin"), but this PR's BondState::Reclaiming is a unit variant. So the 25.8 producer must re-derive the payload by re-searching on_chain — a second derivation of "which coin", with no defined answer when several match, which is exactly the pattern this PR avoids so carefully for locked_dig_base_units ("computed here, from the one place that has seen the entire chain observation, precisely so that 25.8's surface cannot compute it by summing a page"). The SPEC is not over-claiming — 80d2d2a correctly says only the tokens line up and the crate is not adopted — so this is a shape gap to close before the surface is served, not a false claim.

Fix. find() instead of any(), and carry { coin_id, epoch, amount_dig_base_units } on the variant. That closes (b) and makes (a) visible at the same time, since the reported epoch would no longer be able to disagree silently with the live coin.

F3 — the two epochs in PassContext are never cross-checked

pass.rs:169 destructures CollateralRequirementResult::Known { required_per_store_dig_base_units, .. } — the .. discards the requirement's own epoch, and nothing in pass.rs or runner.rs ever reads it. So ctx.current_epoch and ctx.requirement's epoch may disagree with nothing noticing, and the node would create a coin bonding epoch X priced at epoch Y's requirement. This compounds with plan.rs:169-170, where a future-epoch coin (Greater) is deliberately left alone and never reclaimed — so a coin created at an epoch ahead of the node's later view stays locked until the wall clock reaches it. PassContext.current_epoch is an unvalidated i64 on the public API of the component that is supposed to own exactly this kind of invariant. Suggest: when the requirement is Known and its epoch disagrees with current_epoch, treat creates as deferred (reclaims unaffected).

F4 — the suppression set empties silently through two doors that are not the bond field

runner.rs:376-386 returns Vec::new() on an unreadable ledger (deliberate and documented), and a torn last line is dropped into unreadable_lines by foldSpendLog appends with flush() and no sync_all (spend_audit.rs:540) — a count in_flight_creates never consults. Both are fail-open. Both are bounded by the rollover recovery I traced in my previous comment (duplicate coins are reclaimed as EpochEnded, so no duplicate collateral is ever permanently locked), which is why neither gates. Worth noting the asymmetry in the reasoning though: an unreadable ledger is a transient node fault an operator can fix, whereas a None bond is a permanent property of an old record. The code gives them the same answer and only the second one argues for it.

F5 — note: the F6 fix introduces a new on-disk sink for a wallet error string

signer.rs:186 now writes e.to_string() from WalletSigner::sign into the audit record. That is the right trade (the alternative is a false Unresolved claiming money may have moved), and the file is permission-restricted at spend_audit.rs:541. Naming it only so it is a known sink.

F6 — note: 2.4b dependency freshness

Cargo.lock pins dig-node-control-interface 0.24.0; 0.26.0 is published (index shows 0.22, 0.23, 0.24, 0.25, 0.26) and is the version whose vocabulary this PR adopts internally. The SPEC now states plainly that it is not adopted and the method is not served, so nothing is misrepresented — flagging only against the standing "touch a crate, bump its dig-* deps" rule, as a process item rather than a security one.


What is clean, and how I checked it

  • The restraint HOLDS — no unattended spending is started. Every impl MirrorEffects is #[cfg(test)] (runner.rs:463, runner.rs:1010); every PassRunner::new is a test (runner.rs:546, :1033); nothing outside runner.rs references runner::; every MirrorSigner::new is a test (signer.rs:207, tests/mirror_fee_ceiling.rs:46). dig-wallet is untouched, so current_signer() still answers None and default-on auto-tipping does not go live as a side effect.
  • No double-spend of a bond is permanently possible. Traced in full in my previous comment: bond: None suppresses nothing, does not fall back to a store-only key (runner.rs:396 drops the record entirely), so the pre-existing store-keyed defect is fixed rather than moved; and plan.rs:156-172 reclaims duplicates at rollover as EpochEnded, or as NoLongerHeld if the capsule goes away. Worst case is one epoch of doubled collateral, self-healing.
  • A Relayed capsule cannot reach the create path. Four doors checked: the sidecar is written before the .dig rename (module_reshare.rs:320-323, hard-fail if unwritable, so no marker-less window); one snapshot per pass with no re-read (runner.rs:219-220); the presence tracker drops a vanished bond in the same call (presence.rs:105-126), so a Held-to-Relayed flip leaves held immediately; and the split is a shape, not a filter — observe_disk returns the unfiltered set with provenance required, split_by_provenance is the single application point, held and relayed are separate fields.
  • bond did NOT join the control.spends.list wire. spend_row (control.rs:3236) maps field-by-field into the contract's AutomatedSpend; bond is absent. The fix(spends): control.spends.list emits its own contract's shape #409 compiler-checked-response property is intact, and #[serde(default)] on both SpendIntent and SpendRecord keeps the on-disk record additively readable.
  • F6 (the Unresolved bug) is genuinely fixed and Drop cannot undo it. signer.rs:176-190 settles the record as Failed { stage: Signing } before returning; impl Drop for RecordedSpend (spend_audit.rs:706-718) early-returns on settled, so no second entry lands. Both other early returns in sign() (:163, :168) precede journal.begin at :174, so no other path can drop a RecordedSpend into Drop.
  • The amount is derived, never substituted. pass.rs:169 prices only from Known via apply_safety_margin(required_per_store, margin_bp); the create loop is inside if let Some(per_coin) (runner.rs:291), so it cannot run without an amount and no default is ever substituted.
  • Reclaims are not gated on the $DIG balance (runner.rs:271-278, proven at balance: 0) and every reclaim is attempted independently — a failure does not stop the next. The create loop stops cleanly: no partial spend, no retry, no second attempt, deterministic prefix. Both directly tested. (The unreadable-balance case is F1.)
  • No key material. Nothing added logs, prints or serialises a seed or key; runner.rs contains no seed/secret/mnemonic reference at all. The abandon ... art mnemonic in signer.rs is pre-existing (context, not added by this diff) and is the public BIP-39 zero-entropy test vector, not a real seed.
  • Also checked and clear: in_flight_creates uses the unlimited ledger(), not the paged query(), so the suppression is never computed from a truncated page (spend_audit.rs:547-562 vs :576-595); and canonical() (runner.rs:342-352) and MirrorSpends::intent() (spends.rs:120-150, via hex::encode) agree on lowercase-unprefixed form, so the disk-derived and ledger-derived bond keys cannot disagree by spelling.

To reach PASS I need F1 addressed. F2 is small and in-diff and I would rather see it here than in the PR that adds the caller, but I am not gating on it. F3-F6 are legitimate follow-up tickets. Re-gate scope on return: F1 and F2 are both inside mirror/, so a scoped fix re-runs this leg only.

MichaelTaylor3d and others added 2 commits August 29, 2026 21:37
…ming

The predicate matched on (store_id, root) and ignored the epoch, while the
Bonded lookup two lines below also required epoch == current_epoch. plan()
reclaims every epoch < current coin unconditionally, so on any pass between the
new epoch's coin confirming and the old epoch's reclaim confirming, a genuinely
collateralised, advertising capsule reported Reclaiming -- the inverse of the
false money statement the variant was added to prevent.

The precedence is kept, not deleted: it is still correct for the switch-off and
NoLongerHeld paths, where the coin that would read Bonded is itself going home.
It is now matched by coin_id, so it can only outrank the coin it is about.

Also corrects SPEC.md 25.8's banner, which still cited contract 0.25.0; 0.26.0
is published.

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

The wallet balance was read with `?` before the plan, so a wallet that could
be talked to but could not report its $DIG aborted `run()` and made NO reclaim
-- a node unable to advertise AND unable to recover what it had already locked.
That is legacy bug 6 reached through the funds READ rather than the funds gate,
and it inverts the file's own rule 1.

The balance is now `Option<u64>` and reaches only the pricing of creates. `None`
is not zero: an uncovered bond reports the new `FundsUnknown` state rather than
`Unfunded`, which would assert the wallet is short on no evidence and send a
person hunting for $DIG when what is broken is the wallet the node asks.

`BondState::Reclaiming` now carries {coin_id, epoch, amount}, matching `Bonded`,
so the state names the coin it is about instead of asserting a bare fact.

The test double is widened to fail the balance read: every FakeEffects returned
Ok, so `PassError::Wallet` on that call was unreachable from any fixture and
the arm read as covered while never having run.

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

Copy link
Copy Markdown
Contributor Author

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

Audited head: 741d4d45c8557614fed71b4e5824596530eec394 (resolved from gh pr view 414 --json headRefOid).
Scope: the delta 80d2d2a..741d4d4 (5749a01, 741d4d4). Read-only, from git objects — no shared checkout touched.

F1 — CLEARED. The invariant moved, not just the code.

1. No remaining path where an unreadable balance prevents a reclaim attempt. The independence is
now structural, not incidental, and holds at three separate layers:

  • runner.rs:236-246 — the balance read is a match, not ?. Failure yields None plus a warn!.
    The only two remaining ? before a reclaim can be decided are observe_disk (:219) and
    observe_chain (:229), both of which are genuine preconditions — a pass that cannot see disk or
    chain has no reclaim list to compute.
  • pass.rs:187-192reclaim comes out of plan(desired, on_chain, current_epoch, in_flight),
    which takes no balance argument at all, and it is computed before per_coin (:196) and
    before the balance is consulted (:210). The reclaim list cannot be reached by a funds value.
  • runner.rs:290-295 — the reclaim loop in execute is unconditional and runs to completion before
    the create loop, which is separately gated on per_coin_dig_base_units (:306).

2. None is never silently zero. All 12 occurrences of dig_balance_base_units at this head
were checked: there is no unwrap_or, unwrap_or_default, unwrap_or(0) or as coercion anywhere.
The Option reaches exactly two consumers:

  • pass.rs:210(Some(per_coin), Some(balance)) prices the split; every other combination yields
    (Vec::new(), None), so split is None whenever the balance is.
  • pass.rs:294-317 — because split is None, the (Some, Some) arm at :295 cannot match,
    and BondState::Unfunded is only constructible inside that arm (:301). So an unreadable balance
    is structurally incapable of producing an out-of-funds assertion. It falls to the guard at :313
    and reports FundsUnknown.

That is the money-lie removal being enforced by the type, not by a convention.

3. The PassError::Wallet arm is genuinely reachable, and the test that reaches it is not
vacuous.
runner.rs:491-493 fails the read under the new balance_fails flag, and
an_unreadable_balance_defers_creates_and_still_reclaims (runner.rs:~600) sets it.

The assertion discriminates: FakeEffects derives Default (runner.rs:466), so balance is 0
when unset, and ctx() carries a Known requirement. Had the error arm not fired, the pass
would have priced against a zero balance and the bond would read
Unfunded { short_dig_base_units: REQUIRED }. The test asserts states == [FundsUnknown], so it
fails if the arm is skipped. This is the class of coverage my last round said was absent, and it is
now present.

F2 — precedence preserved, and the money figure has ONE source

pass.rs:263-288. Among current-epoch coins for the bond, the first non-reclaiming coin wins
Bonded; only when every current-epoch coin is in reclaim (matched by coin_id) does the bond
read Reclaiming.

  • Switch-off precedence is intact: with creates_enabled == false, decide sets desired = &[]
    (pass.rs:181-185), so plan reclaims every live coin — the current-epoch coin is therefore in
    reclaim, no non-reclaiming candidate exists, and Reclaiming still outranks Disabled (:289).
  • It now outranks only the coin it is about. A prior-epoch coin going home no longer masks a live
    current-epoch coin.

On the two-amount-sources question — confirmed, there is only one source. Every per-coin amount
is read from HeldMirror::collateral_dig_base_units on records from the single observe_chain() of
that pass: Bonded at pass.rs:278, Reclaiming at pass.rs:287, and the report aggregate at
runner.rs:250-253 folds the same field over the same observation. Nothing re-derives an amount from
the epoch requirement; per_coin_dig_base_units is a distinct field describing what a create would
lock, never what a coin does. No drift class here.

One consequence worth carrying to step 6, stated so nobody re-derives it: the row payloads are
current-epoch-only while locked_dig_base_units is the whole owned set, so summing rows
under-reports locked money by design. runner.rs:169-183 already forbids that; dig-app#289 must
render the aggregate and never a row sum.

Still to check: the FundsUnknown / contract-enum divergence, the F3/F4/F6 dispositions on #412, and
the restraint set. Verdict to follow on this PR.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-audit — PASS

Audited head: 741d4d45c8557614fed71b4e5824596530eec394 — resolved from
gh pr view 414 --json headRefOid, and re-confirmed as the tip at the end of the audit
(check-merge-preconditions.sh reported tip 741d4d45…). The head did not move while I worked.

Scope: the delta 80d2d2a..741d4d4 (5749a01, 741d4d4) — three files: SPEC.md,
mirror/pass.rs, mirror/runner.rs. Everything cleared last round is unchanged by this delta.
Read entirely from git objects (git show <ref>:<path>, git diff); no shared checkout was
touched, no worktree cut, nothing edited
.

Both of my round-1 findings are resolved. No gating security defect in this delta.


F1 — RESOLVED. The invariant moved, not just the code.

1. No remaining path where an unreadable balance prevents a reclaim attempt

The independence is now structural at three layers, not incidental at one:

  • runner.rs:236-246 — the balance read is a match, not ?; failure yields None and a
    warn!. The only ? still standing before a reclaim can be decided are observe_disk
    (runner.rs:219) and observe_chain (runner.rs:229), and those are genuine preconditions — a
    pass that cannot see disk or chain has no reclaim list to compute at all.
  • pass.rs:187-192reclaim comes out of plan(desired, on_chain, current_epoch, in_flight),
    which takes no balance argument, and it is computed before per_coin (:196) and before the
    balance is consulted (:210). No funds value can reach the reclaim list even by mistake.
  • runner.rs:290-295 — the reclaim loop in execute is unconditional and completes before the
    create loop, which is separately gated on per_coin_dig_base_units (:306).

Legacy bug 6 is now closed through the funds read as well as through the funds gate.

2. None is never silently zero — and Unfunded is structurally unreachable from it

All 12 occurrences of dig_balance_base_units at this head were checked. There is no unwrap_or,
unwrap_or_default, unwrap_or(0), or numeric coercion anywhere.
The Option reaches exactly two
consumers:

  • pass.rs:210 — only (Some(per_coin), Some(balance)) prices a split; every other combination
    yields (Vec::new(), None). So split is None whenever the balance is.
  • pass.rs:294-317 — because split is None, the (Some, Some) arm at :295 cannot match,
    and BondState::Unfunded is only constructible inside that arm (:301). Control falls to the
    guard at :313 and reports FundsUnknown.

So an unreadable balance is incapable of producing an out-of-funds assertion, and that is enforced
by the type rather than by a convention a later edit could forget. This is the strongest available
form of the fix.

per_coin_dig_base_units still reports Some(x) in that state, and that is correct rather than a
leak: the price is known, only affordability is not. execute then iterates an empty create, so
nothing is bought.

3. The PassError::Wallet arm is genuinely reachable, and the test that reaches it is not vacuous

runner.rs:491-493 fails the read under the new balance_fails flag;
an_unreadable_balance_defers_creates_and_still_reclaims (runner.rs:~600) sets it.

The assertion discriminates, which is the part that matters. FakeEffects derives Default
(runner.rs:466), so balance is 0 when unset, and ctx() carries a Known requirement. Had
the error arm not fired, the pass would have priced against a zero balance and the bond would read
Unfunded { short_dig_base_units: REQUIRED }. The test asserts states == [FundsUnknown], so it
fails if the arm is skipped. That is exactly the coverage I said was absent last round, and it is
now present and non-vacuous.

Compilation is confirmed rather than assumed: Clippy is SUCCESS at this head and the workflow runs
cargo clippy --workspace --all-targets --locked -- -D warnings (ci.yml:90), so the new test code
builds. Test + coverage was still IN_PROGRESS when I finished — the execution green is the
orchestrator's precondition to confirm, not mine. My claim above is about what the assertion can and
cannot distinguish, which is a property of the source.


F2 — RESOLVED, and it converges on the contract rather than diverging from it

pass.rs:263-288. Among current-epoch coins for the bond, the first non-reclaiming coin wins
Bonded; only when every current-epoch coin is in reclaim (matched by coin_id) does the bond read
Reclaiming.

Precedence preserved where it was correct. With creates_enabled == false, decide sets
desired = &[] (pass.rs:181-185), so plan reclaims every live coin — the current-epoch coin is
therefore in reclaim, no non-reclaiming candidate exists, and Reclaiming still outranks Disabled
(:289) and the plan. Pinned by switching_creates_off_reclaims_every_live_coin_and_creates_none.

And it now outranks only the coin it is about. The loop does not break on a reclaiming candidate
(reclaiming = reclaiming.or(Some(candidate))), so the Bonded-vs-Reclaiming decision is independent
of the order observe_chain returns coins in
— a live coin is found whether it precedes or follows
the outgoing one. That property is worth naming, because it is the difference between a fix and a fix
that happens to pass its fixture.

The two new fixtures carry different amounts (700 vs 1_500), so they prove which coin was read,
not merely that some coin was.

The two-amount-sources question — independently confirmed: there is ONE source

Every per-coin amount is read from HeldMirror::collateral_dig_base_units, on records from the single
observe_chain() of that pass:

  • Bonded at pass.rs:278 (coin.collateral_dig_base_units)
  • Reclaiming at pass.rs:287 (going.collateral_dig_base_units)
  • the report aggregate at runner.rs:250-253, folding the same field over the same observation

Nothing re-derives an amount from the epoch requirement. per_coin_dig_base_units is a distinct field
describing what a create would lock, never what a coin does. No drift class here — the payload
is a projection of the aggregate's own inputs, not a second measurement of them.

One consequence to carry to step 6: row payloads are current-epoch-only while locked_dig_base_units
is the whole owned set, so summing rows under-reports locked money by design. runner.rs:169-183
already forbids the row sum, and the 0.26.0 contract puts locked_dig_base_units at the call level
on MirrorBondStatesResult::Known — so the wire shape agrees. dig-app#289 must render the call-level
figure, never a page sum.


The FundsUnknown divergence — correctly deferred, NOT gating. But option 2's premise is false.

Not gating, and not close. BondState and FundsUnknown appear nowhere outside
crates/dig-node-service/src/mirror/ at this head (grep across crates/). The state is internal,
unserialized, and unreachable from any wire; dig-node still pins 0.24.0; nothing serves §25.8. An
internal state that cannot yet be served is a step-6 design input, not a defect in this diff.

The divergence is also smaller than the variant count suggests, and moving in the right direction.
I extracted dig-node-control-interface-0.26.0 from crates.io independently and read the enum: the
contract's Reclaiming already carries { coin_id, epoch, amount_dig_base_units }. So F2's fix
this round moved the node's internal Reclaiming into structural alignment with the published wire.
Seven of the node's eight variants now match the contract shape; the sole divergence is the extra
FundsUnknown.

On your three options

Concur without reservation that mapping FundsUnknown to Unfunded is unacceptable. It asserts a
shortfall on no evidence, on the surface dig-app#300 reads to decide whether to alarm, and it sends a
person hunting for $DIG when the broken thing is the wallet the node asks. It reinstates precisely the
lie F1 removed.

Option 2's stated advantage does not hold, and this is the one thing I would change in your
write-up.
You flagged it as needing verification; the answer is no. On 0.26.0,
CollateralUnknownReason has exactly four variants, unchanged since 0.23.0: NotCensused,
BehindFinalityDepth, RecordUnreadable, NoChainSource. Every one names the census, the record,
or the chain source. None names the wallet.
So collapsing into Deferred without a contract change
forces one of those four, which tells a person their census or chain source is broken while it is
working — the same remedy-misdirection class as the Unfunded mapping, milder only because it asserts
no shortfall. Option 2 therefore needs a contract minor too, and its advertised "needs no contract
change" should be struck. With a new BalanceUnreadable reason it is safe and is the cheapest
honest option; I would rank it first.

Option 1 is safe but is a strict downgrade. MirrorBondStatesUnknownReason likewise has four
variants — ServedSetUnknown, ChainUnreadable, InFlightUnknown, ProvenanceUnknown — none about
the wallet, so it needs a fifth reason as you said. Beyond that, answering at the call level blanks
every row, including the Bonded ones the node knows perfectly well
, converting partial knowledge
into total ignorance and hiding confirmed locked collateral. Not a lie, but it discards true
information and makes an unreadable balance indistinguishable from a total observation failure.

Option 3 is safe and most faithful; the cost is real and it is the fallback.

A modelling observation worth folding in: the unreadable balance is one fact about one wallet,
and encoding it as N identical per-row states is itself a shape error. The rows still need a definite
state, so option 2-with-a-new-reason remains the right per-row answer — but a node-level flag on
MirrorBondStatesResult::Known is where the fact actually belongs, and it preserves the contract's
"every per-row state is a definite statement" property rather than straining it.

One conversion hazard for step 6, cheap to get wrong: the node's epoch is i64 (pass.rs:128)
and the contract's is u64. Use a checked conversion at the mapper — an as u64 on a negative value
yields ~1.8e19 and would render an absurd epoch beside a real money figure. Not reachable today (no
mapper exists); noting it so it is not discovered by a screenshot.


Your non-blocking dispositions — checked, and honest

  • F4 (ledger asymmetry) — the deferral IS recorded on Mirror-coin lifecycle: the pass runner, the state surface, the DHT pointer, and the real-machine proof #412 (comment 2026-08-30T04:44:50Z). It
    cites runner.rs:376-386, states the fix shape (sync_all on append; consult unreadable_lines so
    a torn tail is distinguishable from an empty one), and states plainly that the asymmetry favours
    spending
    . The reasoning holds on the merits: fail-open costs one epoch of doubled collateral that
    reclaims at rollover — bounded and self-healing — whereas fail-closed leaves a bond permanently
    uncollateralised and therefore penalisable. Deferring the bounded direction is the right call, and
    the record does not soften it.
  • F3 and F6 are recorded in the same comment, with fix shapes and the parent epic linked.
  • F5 was a note only; nothing owed.
  • SPEC.md:8239 now cites 0.26.0 (confirmed in the diff).

Restraint — still holds, with one correction to how it is phrased

Substance confirmed: there is no production wiring on this path.

  • impl MirrorEffects exists at exactly two sites, both inside #[cfg(test)] mod tests
    FakeEffects (runner.rs:482) and BlindChain (runner.rs:1075). No production implementation
    exists
    , so nothing touches a real wallet or a real chain.
  • The only callers of PassRunner::new are runner.rs:568 (test helper) and runner.rs:1098 (a test).
  • The only caller of MirrorSigner::new is signer.rs:207 (test helper).
  • pub mod mirror is declared at lib.rs:80 and nothing outside src/mirror/ references it — the
    sole external reference in the whole crate tree is the integration test tests/mirror_fee_ceiling.rs.
    No signer is installed on any WalletBackend.
  • bond remains off the control.spends.list wire; it lives on the internal SpendRecord
    (spend_audit.rs:392, #[serde(default)] — correctly additive). The delta touches neither
    control.rs nor spend_audit.rs.

Correction, so it does not become a false claim later: PassRunner::new (runner.rs:197) and
MirrorSigner::new (signer.rs:133) are ordinary pub fn, not #[cfg(test)]. The accurate
statement is unreferenced from production, not cfg(test)-gated. The security property is the same
today; the phrasing is not, and a future reader relying on a cfg gate that does not exist would be
relying on nothing.

I found no current_signer() in dig-node-service at all — the only match in the tree is
dig-wallet/src/sage/rpc.rs:2812, a different subsystem, untouched by this delta.


Other areas checked in this delta, and why each is clear

  • Secrets / credentials — no key, token, credential, projectId or endpoint introduced. The only new
    string literal in the delta is "the wallet is locked" in a test double.
  • Custody / privilege — unchanged. No signing path, no elevation, no service/registry write, no
    file-permission change. §908's boundary is untouched: nothing here holds a user key.
  • Input / boundary — no new parsing, no deserialization, no network or peer input. Inputs are the
    node's own disk observation and its own wallet-owned chain observation.
  • Panics on adversarial input — no new unwrap/expect/index outside #[cfg(test)]; the added
    control flow is total (a match on a two-Option tuple with a _ arm, plus a guard arm at :313).
  • Integer / bounds — the aggregate at runner.rs:253 still uses saturating_add; the new payload
    fields are copies, not arithmetic.
  • AuthZ / exposure — no endpoint, RPC, method or permission added. Nothing a stranger can reach:
    the mirror pass has no remote entry point at all, because it has no production caller.
  • Amplification / cost asymmetry — no request-shaped path exists, so there is no bytes-in /
    work-out ratio to compare. One complexity note below.
  • Interleaved / half-applied edit from the concurrent-lane window — I looked for this specifically
    and found none. Every dig_balance_base_units test assignment was updated to Some(...), the
    Reclaiming payload was propagated to all three assertion sites, and clippy --all-targets -D warnings is SUCCESS at this head, which a half-applied type change could not survive.

Two non-gating notes for the hardening pass — do NOT hold this PR for them

  1. pass.rs:265-272 is now O(held x on_chain x reclaim) where it was O(held x (on_chain +
    reclaim)): the reclaim scan moved inside the per-coin loop. Not exploitable — all three sets derive
    from the node's own disk and its own dig_mirror_coin::list(source, owner_puzzle_hash), and
    inflating on_chain requires an attacker to lock real $DIG to a puzzle hash the node controls,
    which the node then reclaims. The cost asymmetry points the wrong way for an attacker. Worth a
    HashSet<&str> of reclaiming coin ids when this path first runs against a real chain observation,
    purely so it scales.
  2. runner.rs:239-243 logs error = %e from a wallet error. Inert today — there is no production
    MirrorEffects, so no real wallet string reaches it. When step 6 writes one, keep §7 redaction in
    view so an RPC error body cannot carry anything sensitive into the log.

Verdict

PASS on the delta 80d2d2a..741d4d4 at head 741d4d45c8557614fed71b4e5824596530eec394.

F1's invariant is genuinely held and enforced by the type — an unreadable balance cannot reach the
reclaim list, and None cannot become an out-of-funds assertion. F2's narrowing is correct,
order-independent, and converges on the published contract. Each coin's amount has exactly one source.
The deferrals are recorded with their reasoning intact.

Merge preconditions remain the orchestrator's to assert: at the time of writing the PR is a DRAFT with
Test + coverage IN_PROGRESS (RESULT: BLOCKED [EXIT 1]), so this PASS is a security verdict, not a
merge authorisation.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 30, 2026 05:13
@MichaelTaylor3d
MichaelTaylor3d merged commit b547c70 into main Aug 30, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/412-mirror-pass-runner branch August 30, 2026 05:14
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.

Mirror-coin lifecycle: the pass runner, the state surface, the DHT pointer, and the real-machine proof

1 participant