Skip to content

fix(mirror): a Bonded verdict must not rest on one uncorroborated chain read - #506

Merged
MichaelTaylor3d merged 5 commits into
mainfrom
loop/503-bond-quorum
Sep 2, 2026
Merged

fix(mirror): a Bonded verdict must not rest on one uncorroborated chain read#506
MichaelTaylor3d merged 5 commits into
mainfrom
loop/503-bond-quorum

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

DRAFT — DO NOT MERGE. Gate round in progress (correctness + security + adversarial refutation, three fresh contexts). This body will be final before any merge is proposed.

Closes #503

What was wrong

ChainBondVerifier::verify_against_chain took its ChainSource from ChainTransport::chain_source(). That provider is chia-query's router: it asks api.coinset.org FIRST and consults this node's dialled Chia peers only when that read fails. Its own ProviderInfo carries trustless: false, and its construction comment says outright that "the peers do not corroborate the answer".

So a Bonded verdict — which ranks a holder, and is worth rank 0 at zero collateral if forged — rested on one source's word.

The four steps in chain_bond_verdict_and_coin are all internal consistency, never chain membership. An attacker curries the real, public $DIG CAT puzzle around an invented parent coin, computes the child id, and publishes that id in a provider record. Every step passes, because nothing asks whether the coin exists on mainnet.

Why corroboration, and not verification

Verification would have been the cheaper answer and was checked first. It is not reachable:

  • ChainSource exposes no block header, no merkle path, no inclusion proof. Its only proof-shaped primitive is resolve_singleton_lineage, and a mirror coin is a CAT, not a singleton — it has no launcher to walk from. The alternative anchor, walking the CAT lineage to a real terminus, is unbounded and still needs a trusted anchor.
  • The self-verifying half already holds and needs no quorum. MirrorCoin::from_creating_spend(spend, coin_id) derives puzzle hash, amount, asset id and advertised terms from executed on-chain code bound to the claimed coin id. A lying source cannot change what coin C is — only whether it exists.

That is also why corroboration is cheap here: the whole verdict reduces to exactly two primitive reads, coin_record(C) and coin_spend(C.parent_coin_info).

The change is wiring, not a second quorum

dig-wallet's sage::peer_reads::PeerCorroboratedReads already corroborates exactly those two reads across the node's own concurrently-held peers, inheriting sage::quorum's CORROBORATION_FLOOR (= 2, never one source) and required_agreement, and already failing closed (Err is UNKNOWN, never absence). No new agreement mechanism was introduced; building one would be the rival-implementation defect.

  • crates/dig-wallet/src/sage/corroborated_source.rs (new) — CorroboratedChainSource, a ChainSource over PeerCorroboratedReads. The trait comes from chia_query::provider_registry::interface, which re-exports the whole dig-chainsource-interface crate, so no second declaration and no split-family risk. It recomputes the coin id from the returned (parent, puzzle_hash, amount) and rejects a mismatch, for records and spends. The methods it cannot serve return Err, never an empty Ok — an empty answer would be an absence lie. peak_height's None (peers did not agree) maps to Err, not to Ok(None) (source exposes no peak), because those are different claims.
  • ChainTransport::corroborated_chain_source — errs when no peer reads are attached and never falls back to the router. Falling back would let one endpoint overrule the peers exactly when they failed to agree.
  • ChainBondVerifier::verify_against_chain now reads through it; an error yields Unverified.

On independence: sources::independence_group_for already records that a coinset_fallback_enabled fabric is the oracle's group however many peers it holds. That is why N reads through the router are one read wearing a hat, and why this path must draw on DialedPeerSample.

How it is verified

crates/dig-node-service/tests/mirror_bond_corroboration.rs, 7 tests. The differential proof is a pair driving the same fabricated_bond() fixture through different sources:

same fixture, different source verdict
single source (a_single_source_bonds_the_fabricated_coin) Bonded
corroborated, 1 vouching + 3 honest (a_coin_only_one_peer_has_ever_seen_is_not_bonded) Unbonded

agreeing_peers_do_bond_a_genuine_coin is the anti-vacuity control: without it the suite could pass by never producing Bonded at all. Also covered: one answering peer cannot bond a genuine coin (Unverified), an even split does not bond (Unverified), a transport without peer reads refuses rather than using the router, and a source with no peers errs rather than reporting an absence.

Counts, checked as counts rather than exit status: mirror_bond_corroboration 7 passed / 0 failed / 0 filtered; mirror_bond_verify 10 passed / 0 failed (existing suite unbroken); dig-wallet --lib 769 passed / 0 failed / 1 ignored.

Bump rationale

0.247.0 → 0.251.0, minor: new compatible capability (CorroboratedChainSource, corroborated_chain_source) plus a behaviour change on the verdict path, no removed or renamed API. 0.248/0.249/0.250 are claimed by open PRs #499/#500/#498, so this takes the next free value rather than colliding.

Blast radius

gitnexus' registered dig-node index is 338 commits behind HEAD, and a stale index returns a false-safe impactedCount: 0, so it cannot answer here. Radius was established by ripgrep plus direct reads and is reported as such: verify_against_chain is private with exactly one caller; chain_source is unchanged and its two other callers (server.rs:2871, server.rs:3104, the collateral census) are untouched; the two new symbols had no prior callers. Additive plus one private call-site swap.

Disclosed limitations

  1. The call-site swap has no revert-failing test. verify_against_chain is private and reachable only through verdict_for, which short-circuits on declaration_source_is_readable() == false until feat(mirror): activate bond promotion on the coin's own peer declaration #501 lands. Stated rather than papered over; it becomes testable when feat(mirror): activate bond promotion on the coin's own peer declaration #501 merges.
  2. Corroboration raises an attacker's cost; it does not eliminate the attack. quorum.rs says so about itself, and §25.6a is worded to match rather than to overclaim.
  3. The collateral census still reads chain_source() and is deliberately out of scope per the ticket. chain.rs's peak_height doc already records that as a tracked sequencing constraint; corroborated_chain_source() makes fixing it a one-liner when selected.

Relation to #501

Orthogonal, and neither subsumes the other. #501 asks whose bond — does this coin declare this peer, answered by executed on-chain code and self-verifying given the coin. #503 asks whether the bond exists — chain membership, not self-verifying by any available means. They compose as two independent gates on one verdict.

Sequencing worth knowing: #503's defect only goes live once #501 merges, since until then the short-circuit means no chain read is paid and no Bonded is reachable. Both edit bond_verify.rs, so whichever lands second merges main in.

Salvage anchor. Version bump only; the corroboration work follows.

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

Copy link
Copy Markdown
Contributor Author

Progress — #503 corroborated bond verdict

Branch loop/503-bond-quorum @ 55d4c21f

Done (pushed):

  • crates/dig-wallet/src/sage/corroborated_source.rs — new CorroboratedChainSource, a ChainSource over PeerCorroboratedReads. Three-way map on both reads; Err (never Ok(None)/empty Vec) for every read it cannot serve; peak_height's "peers did not agree" maps to Err, not Ok(None).
  • ChainTransport::corroborated_chain_source — errs when no peer reads are attached, and deliberately does NOT fall back to the router.
  • bond_verify::verify_against_chain now reads through it.
  • SPEC.md §25.6a — a bonded verdict MUST rest on agreement, never one source.
  • tests/mirror_bond_corroboration.rs — 6 cases incl. the Bonded anti-vacuity control and a permanent single-source witness.

Blocked on: machine-wide ENOSPC (disk hit 0 bytes mid-run and truncated a source file, recovered from the pushed commit). Freed this lane's own target/ (7.3G). Rebuilding with CARGO_PROFILE_DEV_DEBUG=0.

Next action: cd C:/tmp/worktrees/dn-503 && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p dig-node-service --test mirror_bond_corroboration — check the test COUNT, not the exit status.

`ChainBondVerifier::verify_against_chain` took its `ChainSource` from
`ChainTransport::chain_source`, which is chia-query's router: with
`coinset_fallback_enabled` it asks api.coinset.org FIRST and consults this
node's dialled peers only when that read fails, and its own `ProviderInfo`
records `trustless: false`.

The four checks that promote a holder to `Bonded` are all internal
consistency of a coin and its creating spend; none of them establishes chain
MEMBERSHIP. A coin currying the real, public $DIG CAT puzzle around an
invented parent satisfies every one of them, so a single source's word was
enough to rank an attacker's peer at zero collateral cost.

Verification-by-proof is not available -- `ChainSource` exposes no header, no
merkle path, no inclusion proof, and a mirror coin is a CAT with no launcher.
Corroboration is, and it is cheap here: the whole verdict reduces to two
primitive reads, and `sage::peer_reads::PeerCorroboratedReads` already
corroborates exactly those two over the node's own peers, inheriting
`CORROBORATION_FLOOR` and `required_agreement`. This is a reuse and wiring
change, not a second quorum.

- new `sage::corroborated_source::CorroboratedChainSource`, a `ChainSource`
  over `PeerCorroboratedReads`. Three-way map on both reads; recomputes the
  coin id from the returned fields and refuses a mismatch; every read it
  cannot serve returns `Err`, never an empty `Vec` or `Ok(None)`, because an
  empty answer is an absence lie; `peak_height` maps "the peers did not
  agree" to `Err` rather than to `Ok(None)`, which means something else.
- `ChainTransport::corroborated_chain_source` errs when no peer reads are
  attached and deliberately does NOT fall back to the router: falling through
  to one endpoint exactly when the peers failed to agree would let it
  overrule them.
- `SPEC.md` §25.6a states the requirement normatively.

Tests drive the chain half through a scripted peer sample: the bug (one peer
vouching alone), the floor, an even split, a transport with no peer reads, a
peerless source, the `Bonded` anti-vacuity control, and a permanent witness
that the identical fabricated coin IS bonded by a single source.

Closes #503
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Ready for the gate round — all 15 CI checks green

Head 9609e6d5, one squashed commit. Deliberately still DRAFT (§2.4a): a ready PR signals the pre-merge gates are satisfied, and they have not run yet.

All six required contexts asserted BY NAME via check-merge-preconditions.sh, plus zero unresolved review threads:

check result
Lint commit messages pass
Check version increment pass
Rustfmt pass
Clippy pass
Test + coverage pass (19m17s — the coverage floor is inside this job)
Release-script tests pass

Non-required also green: Analyze rust/actions/js-ts, CodeQL, and all four package builds (deb amd64+arm64, msi, pkg).

Local, with counts checked rather than exit status: 7 passed / 0 filtered (mirror_bond_corroboration), 10 passed / 0 filtered (mirror_bond_verify), 769 passed / 1 ignored / 0 filtered (dig-wallet --lib).

Out of scope, stated rather than filed: the collateral census (server.rs:2871, server.rs:3104) still reads through chain_source(). chain.rs's peak_height doc already records that as a tracked sequencing constraint, so it is left untouched and unticketed here.

Not covered, stated rather than claimed: the verify_against_chain call-site swap has no test that fails on revert — it is private and reachable only through verdict_for, which returns Unverified before any chain read while declaration_source_is_readable() is false. Testable once #501 lands.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. Correctness gate, fresh context, head 9609e6d5.

Confirmed so far, by reading:

  1. Reuse claim TRUE. corroborated_source.rs contains no tally, no threshold, no agreement logic — it is a ChainSource face over PeerCorroboratedReads, which owns quorum::tally / CORROBORATION_FLOOR / required_agreement. No rival implementation.
  2. Three-way mapping holds. All four unserved methods (coin_records_by_puzzle_hash, coin_records_by_parent, resolve_singleton_lineage, block_timestamp) return ChainSourceError::Unsupported — never Ok(vec![]) / Ok(None).
  3. peak_height maps NoneErr, with the two-different-Nones distinction stated in the doc-comment. Correct.
  4. Hex key convention matches exactly. key_for = hex::encode (lowercase, no 0x); peer_reads::normalized (peer_reads.rs:441) strips 0x and lowercases. No silent miss.
  5. Coin-id recomputation present for BOTH pathscoin_bound_to (corroborated_source.rs:~120) is called from record_from AND spend_from, and rejects mismatch as Malformed.
  6. Scope clean. server.rs is not in the diff at all; the collateral census is untouched.

Still to check: no-fallback in ChainTransport::corroborated_chain_source, the differential test pair, the ChainView double's faithfulness, and SPEC §25.6a's normative voice.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Audited head: 9609e6d514f8fc412224853edb951262cf37d58c (resolved from gh pr view 506 --json headRefOid, not from the dispatch brief). Merge-base 476055fb. Read-only; no shared checkout mutated.

Diff is small and reviewable: 8 files, 806 insertions — corroborated_source.rs (new, 270 lines), a 15-line swap in bond_verify.rs, a 37-line constructor in chain.rs, a SPEC clause, and a 472-line integration test.

Axis 2 — independence: CLEAR

The new path cannot be satisfied by one endpoint answering repeatedly, and I checked this at the draw rather than taking the module docs' word for it.

  • corroborated_source.rs:113 / :130 call PeerCorroboratedReads::coin_record_by_id / coin_spend only. There is no chain_source fallback anywhere in the file, and chain.rs:479 returns Err rather than the router when peer_reads is absent.
  • The draw is assemble_distinct_sample (sync_supervisor.rs:2194). Two properties matter and both hold: distinctness is by IP host, not SocketAddr (:2209, :2221) so one machine on several ports is admitted once; and only PeerOrigin::Discovered is counted (:2227), which excludes the loopback/priority addresses a co-resident local attacker could supply.
  • sources::independence_group_for's coinset-fabric collapse does not apply — this path never enters the provider router.

So corroboration here is genuinely irreducible to one source. Not a finding.

Axis 1 — what an attacker must control to force Bonded

Stating the arithmetic precisely, since the brief asked for the fraction.

quorum::required_agreement(answered) = max(ceil(answered * 3 / 4), CORROBORATION_FLOOR=2) (quorum.rs:216). The denominator is peers that ANSWERED, not peers drawn (peer_reads.rs:244 skips Err responses silently).

answered attacker peers needed
5 4
4 3
3 3
2 2 (i.e. all of them)

The exposure is the bottom row: silence lowers the denominator for free, so an attacker who lands 2 sybils in a sample and can outlast/suppress the rest reaches a full quorum at 2-of-2. That is inherited from PeerCorroboratedReads, not introduced here, and it is bounded by host-distinct discovery plus the 300s SAMPLE_LIFETIME redraw. Recording it as the shape of the residual risk, not as a finding against this PR.

Axis 4 — fail-closed direction: CLEAR on the four unsupported methods

coin_records_by_puzzle_hash (:170), coin_records_by_parent (:182), resolve_singleton_lineage (:193) and block_timestamp (:206) all return ChainSourceError::Unsupported. None returns Ok(vec![]) or Ok(None) — no absence lie. peak_height (:213) correctly maps PeerCorroboratedReads' "peers did not settle" None onto Err rather than onto ChainSource's Ok(None), which would have meant "this source has no peak" — a settled fact a caller may act on. That distinction is made explicitly at :207-212 and is right.

bond_verify.rs:409-414 reads the constructor's Err as Unverified, and chain_bond_verdict_and_coin maps every source Err to Unverified (:230, :253) and MirrorError::ChainUnavailable likewise (:260). No new path yields Bonded.

One genuine strengthening worth crediting: coin_bound_to (corroborated_source.rs:95) re-derives SHA256(parent|puzzle_hash|amount) and refuses an answer that is not about the requested coin. The bond path never asserted that itself, so this closes a gap rather than merely re-stating the layer below.

Still working: axis 3 (the stacked cache window), axis 5 (availability/demotion), axis 6 (fan-out), axis 7 (induced false absence).

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

orchestrator bc2a88 / loop-reviewer started on 9609e6d at 2026-09-02T17:32Z (fresh context, correctness gate)

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

orchestrator bc2a88 / loop-security started on 9609e6d514f8fc412224853edb951262cf37d58c at 2026-09-02T17:32Z (fresh context; prior gate died at the session cap without a verdict). Read-only; own worktree under C:/tmp/worktrees/sec-506. Findings will be posted as they are established, marked IN PROGRESS; the verdict comment comes last.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-reviewer — correctness gate STARTED (IN PROGRESS — not the verdict)

Fresh-context correctness leg of the triple gate on PR #506.

Findings will be posted as inline threads as they are established; the verdict follows at the end.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — audit STARTED (IN PROGRESS — not the verdict)

Security leg of the triple gate for this PR. Auditing head 1b44d9bf998921e50e8e516c39cb266ab0f735ca against base bfaa79dec7ef955218d1dc90e44ad9b1531c08cd.

Scope of this leg (custody-adjacent: a Bonded verdict ranks a holder at rank 0 and is worth real placement if forged):

  1. Source independence — are the corroborating sources genuinely independent, or two handles on the same chia-query router/pool?
  2. Quorum integrity — can one attacker-controlled peer in the pool constitute the corroboration?
  3. Fail-closed discipline — verdict when only one source answers, when sources disagree, when a source times out.
  4. Corroboration key — is agreement checked on coin id AND parent/puzzle/amount, or only on existence?
  5. Amplification — outbound chain reads per inbound provider record (compare dig-node#501's HIGH finding: 16 reads per cheap token); is there a bound before the expensive step?
  6. Custody widening — any new trusted flag or provider-info field; does the ProviderInfo{trustless} claim still match behaviour?

Findings will be posted here as they are established, each marked IN PROGRESS; the verdict lands last as ## loop-security — VERDICT: …. Working in my own detached worktree; the primary checkout is read-only (it holds another lane's uncommitted work).

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial gate: STARTED

Third leg of the triple gate on 1b44d9b (diff bfaa79d...1b44d9b), fresh context, prompted to REFUTE. Correctness and security legs run in parallel; I have not read their verdicts.

Refutation targets:

  1. Is the second source actually a second source? Trace both corroborators to their producer. If PeerCorroboratedReads bottoms out in the same chia-query router / same HTTP endpoint, "corroborated" is one source read twice.
  2. Failure direction on: one reachable source, disagreement, timeout. Does any arm return Bonded?
  3. Vacuity -- a source kind with no producer, a config default that disables corroboration, a fixture that upserts the coin before the check.
  4. Does the discriminating test cross the promotion decision? I will revert the decision in my own worktree and re-run, checking the test COUNT rather than the exit status.
  5. Anything SPEC.md now promises that the code does not enforce.

Verdict posted here as a separate comment when the evidence is in. I write no production code and will not merge or undraft.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict (confirmed items, head 1b44d9b)

Recording four things now so they survive a stall.

1. Sources are genuinely INDEPENDENT, not two views of one router. Traced the whole production
chain rather than trusting the PR body:

  • crates/dig-node-service/src/server.rs:2179 installs the verifier with state.wallet_chain
  • server.rs:596wallet_chain: wallet_service.chain
  • crates/dig-wallet/src/sage/service.rs:183ChainTransport::new().with_peer_reads(db.clone())
  • crates/dig-wallet/src/sage/chain.rs:356 — that attaches DialedPeerSample::mainnet()
  • crates/dig-wallet/src/sage/peer_reads/dialed.rs:246redraw calls
    assemble_distinct_sample(QUORUM_SAMPLE, …) over connect_random_peer_excluding

So the reads go to up to QUORUM_SAMPLE distinct, separately dialled full nodes, and
api.coinset.org is not in that path at all. The router is not consulted, and there is no fallback
to it. This is the claim the whole ticket rests on and it holds.

2. One source available must not be Bonded — the mechanism is inherited, not re-invented.
still_usable (dialed.rs:236) requires live >= quorum::CORROBORATION_FLOOR and
live >= held.peers.len(), so any attrition forces a redraw rather than letting a sample ratchet
down to the two most eager repliers. Below the floor the tally is InsufficientErr → the
verifier's Unverified. No new agreement mechanism was added, which is the correct call under the
centralize-rival-implementations rule.

3. Fail-closed direction is right, and the three-state distinction is preserved where it matters.
corroborated_source.rs returns Err for every read it cannot serve rather than an empty Ok
(:216, :229, :240, :266), and maps PeerCorroboratedReads::peak_height's "peers did not
agree" None to Err rather than Ok(None) (:252-259). Those are different claims and
collapsing them would have failed open. Also coin_bound_to (:126) recomputes
SHA256(parent‖puzzle_hash‖amount) and refuses an answer about a different coin — arithmetic no
vote can outrank, and it closes a gap the bond path itself leaves open.

4. The merge commit added nothing of the lane's own. 1b44d9b is a merge of bfaa79d into
9609e6d; git diff bfaa79d..1b44d9b is exactly the 8 declared files, 806 insertions. The other
files in 9609e6d..1b44d9b are main's own sync_supervisor/rpc work arriving, not new work here.

Still to do: the revert-proof of the differential test pair, the SPEC drift sweep, and the §2.4b
dependency report. One version finding already established and posted separately.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — interim findings (IN PROGRESS — not the verdict)

Head 1b44d9b. Three items established so far. Posting as formed; the verdict follows.

The headline is positive: the direction of this change is right

chain_bond_verdict_and_coin (bond_verify.rs:225, :249) makes exactly two reads — the coin record and its creating spend — and every check between them is internal consistency of a coin. The PR's premise is correct: a coin currying the real $DIG CAT puzzle around an invented parent passes all of them, and only chain membership disproves it. Routing those two reads at bond_verify.rs:409-411 through PeerCorroboratedReads instead of the trustless: false router closes a real forgery surface, and corroborated_source.rs:120-139 (coin_bound_to) independently re-binds each answer to the requested coin id, so a quorum cannot answer question X with coin Y. The three-way Ok(Some)/Ok(None)/Err discipline is held consistently and the unsupported reads return Err rather than an empty Vec — a fabricated absence would be the dangerous direction on this path, and it is refused. No new trusted flag, no widened provider field.

1. The corroboration floor is 2, and the reason it is 2 does not apply on this path — DEFENSE-IN-DEPTH

quorum.rs:172 CORROBORATION_FLOOR = 2 with required_agreement(2) == 2 (quorum.rs:216-219), so a two-peer unanimous round corroborates. dialed.rs:294-312 draws at most QUORUM_SAMPLE = 4 held peers and dialed.rs:240-243 keeps a sample usable down to the floor; peer_reads.rs:241-250 tallies only the peers that ANSWERED, a peer that errors being simply absent.

Attack: an attacker holding 2 of the 4 drawn peers wins any round in which the other two do not answer — both his peers return the fabricated coin record and its creating spend, required_agreement(2) == 2 is met, and the verdict is Bonded at zero collateral. Thin rounds are not hypothetical here: quorum.rs:161-164 records a production round that reported answered: 2 out of four, and that measurement is the stated reason the floor is 2.

The point: that floor was chosen for the replica sync path, where refusing a thin round re-creates a frozen replica — a self-inflicted denial. On the bond path refusing is free, because the fallback verdict is Unverified, which shares a rank with Unbonded in credit_rank and leaves the slate unchanged. The bond path can afford a strictly higher floor than sync and should take one. Not gating (no live exploit — see item 3 — and the PR is a strict improvement over one endpoint), but it should be an explicit, separately-named constant rather than an inherited one.

2. The new SPEC clause is stronger than the code it describes — DOC ACCURACY

SPEC.md's added paragraph says a bonded verdict "MUST rest on AGREEMENT across independently drawn, concurrently-held untrusted peers" and names a "corroboration floor" without stating its value. A reader takes that as a plural quorum; the shipped meaning is two peers agreeing, out of four drawn. State the floor and the sample size in the clause, or a future reader audits against a bar the code never claimed.

3. The whole corroborated path is DORMANT today, which bounds every finding above

verdict_for (bond_verify.rs:306) short-circuits on declaration_source_is_readable(), which is false because peer_declaration (bond_verify.rs:145-150) is hard-wired to NotReadable until the dig-mirror-coin 0.8.0 typed accessor lands. So no chain read happens at all today and Bonded is unreachable for every input — which is also why the amplification cost below is currently zero.

This is why items 1 and 2 are not gating. It is also why they must not be lost: the short-circuit lifts itself the moment that accessor arrives, and bond_verify.rs:139-144 already carries a list of things that MUST land in that same change. Items 1 and 4 belong on that list.

Still to check: the amplification figure when the path goes live, and whether the nested block_in_place -> Handle::block_on bridge is exercised anywhere.

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

loop-reviewer — inline findings (IN PROGRESS, not the verdict)

Two findings anchored below. The verdict comment follows separately.

Comment thread Cargo.toml Outdated
Comment thread crates/dig-node-service/src/mirror/bond_verify.rs
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — interim findings, part 2 (IN PROGRESS — not the verdict)

Head 1b44d9b. Two more items, plus one hypothesis I raised and then falsified.

4. Amplification: 16 third-party HTTPS reads becomes up to 64 peer queries, and a Sybil can make them UNCACHEABLE — MEDIUM, dormant

The chain per inbound locate:

step bound where
records verified per locate 8 mirror_bond.rs:146 MAX_VERIFIED_PER_LOCATE
chain reads per verdict 2 (coin record + creating spend) bond_verify.rs:225, :249
peer queries per chain read up to 4, sequential, 15s timeout each dialed.rs:255 (QUORUM_SAMPLE), peer_reads.rs:241-250, dialed.rs:48

So up to 8 x 2 x 4 = 64 peer chain queries per locate, against 8 x 2 = 16 outbound api.coinset.org reads before this PR. The 4x rise in message count is defensible on its own — they ride already-held connections (dialed.rs:294-312, sample held for SAMPLE_LIFETIME 300s) rather than opening new HTTPS to a shared third party, so #501's specific harm (attacker-directed egress at coinset.org degrading the same transport the wallet reads through) is reduced in kind. Note it is transferred rather than removed: the load now lands on the same four held peers the wallet reads through.

The part that is a genuine regression is the memoisation, and it is cheap to trigger:

Unverified is deliberately never cached (bond_verify.rs:350-352 — correct on its own terms, an outage must not stay in force). required_agreement (quorum.rs:216-219) is ceil(answered * 3 / 4) floored at 2, so a 3-peer sample requires unanimity. One dissenting peer in a 3-peer sample therefore yields Verdict::Split -> Err (peer_reads.rs:252-255) -> Unverified -> not cached.

Attack: a stranger publishes 8 provider records carrying 8 distinct fabricated coin ids, and holds one peer in this node's drawn sample. Every locate for that content pays the full 64 sequential peer queries, and none of the verdicts is ever memoised, so the cost repeats on every locate indefinitely. Before this PR the same 8 records cost 16 coinset reads once, because coinset returned a definite Ok(None) -> Unbonded, which IS cached for VERDICT_TTL 600s. Two records ask 4 chain reads today; the same two ask 16 peer queries per locate, forever, afterwards.

The fix is not to cache Unverified — it is to memoise the inability to corroborate separately and briefly (a short negative-result TTL keyed the same way), so a stranger cannot convert one cheap DHT publish into unbounded repeated peer traffic. Belongs on the bond_verify.rs:139-144 must-land-together list.

5. The bridge drops chia-query's panic backstop that its own docs call load-bearing — LOW / defense-in-depth

corroborated_source.rs:79-92 states it uses "the same three-way shape chia-query's own facade uses". It reproduces the three-way match but not the fourth element: chia-query-0.20.0/src/provider_registry/bridge.rs:48-63 wraps both blocking arms in guard_panics, a catch_unwind whose stated purpose is that "no misuse can ever unwind out of the synchronous trait boundary". Without it, any panic inside a peer read unwinds out of ChainSource::coin_record, through verdict_for, through the outer block_in_place at bond_verify.rs:418, and into the task driving find_providers — instead of becoming a ChainSourceError::Transport that the call site already handles as Unverified. Six lines to restore; the VerdictCache std::sync::Mutex is not held across the read, so no poisoning follows today.

FALSIFIED — the nested block_in_place -> Handle::block_on bridge is SAFE

I suspected production nests differently from the tests: verify_against_chain wraps verdict_for in block_in_place (bond_verify.rs:418), and inside it block_on takes the Ok(_) arm and calls block_in_place again, while the PR's tests call the source un-nested (mirror_bond_corroboration.rs:169-178, 216-225). chia-query's own bridge tests do not cover the nested case either, and its module doc says Handle::block_on "panics if called while already inside a runtime".

Measured on tokio 1.53.1 (the resolved version), replicating the exact nesting: both arms return normally, neither panics.

CONTROL (un-nested, as the PR tests call it): Ok("... returned normally")
PRODUCTION (nested in block_in_place):        Ok("... returned normally")

So there is no panic risk from the nesting itself, and item 5 stands only as defense-in-depth against other panic sources. Recording the falsification so nobody re-derives it.

Test quality — the revert-proof is real

a_single_source_bonds_the_fabricated_coin (mirror_bond_corroboration.rs:396-472) drives the identical fabricated coin through a single-source ChainSource and asserts Bonded. That is a genuine defect witness: it proves the corroboration seam is load-bearing rather than decorative, and it will explain the flip if the seam is ever removed. evenly_split_peers_do_not_bond and one_answering_peer_cannot_bond_even_a_genuine_coin use the HONEST coin on purpose so the refusal provably comes from the count, not the coin. This is the shape a gate wants to see.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — VERDICT: PASS

Audited head: 1b44d9bf998921e50e8e516c39cb266ab0f735ca (resolved from the remote at 19:29Z and re-read at the end of the audit — unchanged; still draft). Base: bfaa79dec7ef955218d1dc90e44ad9b1531c08cd.

No gating security defect in this diff. It closes a real forgery surface and it fails closed in the direction that matters. Five non-gating findings, ranked, all recorded in detail in the two interim comments above; none blocks the merge.

Why PASS

The premise is correct and the fix is load-bearing. chain_bond_verdict_and_coin makes exactly two reads (bond_verify.rs:225, :249) and every check between them is internal consistency of a coin and its creating spend — so a coin currying the real, public DIG CAT puzzle around an invented parent passes all of them. a_single_source_bonds_the_fabricated_coin (mirror_bond_corroboration.rs:396-472) drives that exact coin through a single-source ChainSource and asserts Bonded, which is a genuine defect witness rather than a decorative test. Routing the two reads through PeerCorroboratedReads (bond_verify.rs:409-411) is the right remedy.

Fail-closed discipline holds at every branch I could find. Only-one-source answers, so Insufficient becomes Err becomes Unverified. Sources disagree, so Split becomes Err becomes Unverified. A source times out, so that peer is absent from the tally, and if the survivors cannot reach required_agreement the round errs. There is no fallback to the router (chain.rs:485-490 errs instead), which is the correct call: falling through to one endpoint exactly when the peers failed to agree would let that endpoint overrule them. The unsupported reads return Err(Unsupported) rather than an empty Vec or Ok(None) (corroborated_source.rs:215-245, 265-269) — a fabricated absence is the dangerous direction on this path and it is refused deliberately and consistently.

Corroboration is keyed on the coin's identity, not on existence. coin_bound_to (corroborated_source.rs:120-139) recomputes SHA256(parent | puzzle_hash | amount) and refuses any answer that does not hash to the id that was asked for. That is arithmetic no quorum can outrank, and it independently re-establishes locally what peer_reads.rs:271 already enforces below — so two colluding peers cannot answer a question about coin X with coin Y. The sample is drawn at distinct socket addresses (dialed.rs:254-274), so the same peer cannot be counted twice as its own corroboration.

No custody widening. No new trusted flag, no new or altered provider-info field. ProviderInfo{trustless: false} still describes the router accurately and the new source makes no trustless claim at all — it is a bare ChainSource. Cargo.lock moves by one line (the version), so no dependency was added, updated or loosened. No secret, token, credential or key appears anywhere in the diff.

The denial direction is closed by the lattice, which I checked rather than assumed. Unverified and Unbonded share a rank in credit_rank, and mirror_bond's lattice is credit-only — so a forged absence, however obtained, withholds promotion and cannot demote a legitimate holder below baseline. That is what makes every finding below a cost or hardening issue rather than a theft or censorship primitive.

Findings — ranked, none gating

  1. The corroboration floor is 2, and the reason it is 2 does not apply here (quorum.rs:172, :216-219) — DEFENSE-IN-DEPTH. Two peers agreeing out of four drawn corroborates. An attacker holding 2 of the 4 drawn peers wins any round the other two do not answer, and quorum.rs:161-164 records a production round that answered 2-of-4. The floor was chosen for the replica-sync path, where refusing a thin round re-creates a frozen replica; on the bond path refusing is free, because the fallback is Unverified at baseline rank. This path can afford a strictly higher, separately-named floor. Not gating because the path is dormant (item 3) and because two-of-four-untrusted is still an improvement on one endpoint's word.
  2. Amplification: 16 coinset reads becomes up to 64 peer queries, and one Sybil can make them uncacheable (mirror_bond.rs:146 times bond_verify.rs:225,:249 times dialed.rs:255) — MEDIUM, dormant. The 4x message rise is defensible (held connections, not new third-party HTTPS). The regression is memoisation: Unverified is never cached (bond_verify.rs:350-352) and required_agreement(3) == 3 demands unanimity, so one dissenting peer in a 3-peer sample turns every fabricated coin id into a permanently unmemoised verdict — 8 cheap DHT publishes then cost the full 64 sequential peer queries on every locate, indefinitely, where before they cost 16 coinset reads once. Remedy is a short negative-result TTL for the could-not-corroborate outcome, not caching Unverified.
  3. The whole path is dormant today, which is what bounds items 1 and 2. verdict_for short-circuits on declaration_source_is_readable() (bond_verify.rs:306), false because peer_declaration is hard-wired to NotReadable until the dig-mirror-coin 0.8.0 accessor lands. No chain read happens and Bonded is unreachable for every input. Items 1 and 2 must be added to the must-land-together list already at bond_verify.rs:139-144, because the short-circuit lifts itself the moment that accessor arrives.
  4. The new SPEC clause is stronger than the code it describes — DOC ACCURACY. It says a bonded verdict must rest on agreement across independently drawn peers and names a corroboration floor without its value; the shipped meaning is two-of-four. State the floor and the sample size, or a later audit measures against a bar the code never claimed.
  5. The bridge drops the chia-query catch_unwind backstop (corroborated_source.rs:79-92 vs chia-query-0.20.0/src/provider_registry/bridge.rs:48-63) — LOW. It reproduces the three-way match but not guard_panics, whose stated purpose is that no misuse unwinds out of the synchronous trait boundary. Six lines to restore.

One hypothesis raised and falsified, recorded so it is not re-derived: I suspected the production nesting (block_in_place at bond_verify.rs:418 wrapping a block_on that itself calls block_in_place) could panic, since neither this PR's tests nor the chia-query bridge tests cover the nested case. Measured on tokio 1.53.1 replicating the exact nesting: both the un-nested and nested arms return normally, neither panics. Item 5 therefore stands only as defense-in-depth against other panic sources.

The one substantive trade, stated plainly

This PR moves the bond verdict from one well-run centralized endpoint to two-of-four randomly drawn untrusted peers. That is contract-aligned — NC-12 holds every dialled peer untrusted and makes agreement the mechanism — and the router treated coinset as authoritative rather than corroborated, which is the hole. But the trade is only net-positive to the extent the floor is meaningful, which is exactly why finding 1 should not be lost behind the dormancy in finding 3.

Mechanics

Everything was read from git objects (git show, git diff, git cat-file) against the primary checkout, which holds another lane's uncommitted change to crates/dig-node-core/Cargo.toml and was never mutated — no checkout, reset, stash or edit; no worktree was needed or created. The one probe ran in an isolated scratch cargo project outside every checkout and has been removed. Blast radius was established by git grep over the head tree rather than gitnexus (the registered indexes point at primary checkouts and are stale by construction; a stale impact returns a false-safe zero): the new source has exactly one consumer, bond_verify.rs:411, and it calls only coin_record and coin_spend, so the Err(Unsupported) arms are unreachable from it.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial gate: HOLDS

Head 1b44d9b, diff bfaa79d...1b44d9b. Third leg, fresh context, prompted to refute. I found no refutation. Every target below was traced to its producer at source; the one I expected to break it did not.

1. "Corroboration makes a forged coin id unBondable" -- HOLDS

The second source is genuinely a second source. DialedPeerSample::mainnet() (crates/dig-wallet/src/sage/peer_reads/dialed.rs:1-25) dials real Chia full nodes through chia-query's discovery, counts only PeerOrigin::Discovered draws, excludes the loopback (a co-resident process is "a good peer to ask and not an independent voice"), keys the tally on distinct addresses, and redraws every SAMPLE_LIFETIME = 300s. Nothing here routes through chia_query's router or api.coinset.org. This is not the #510 shape -- there is no monotone accumulator; the tally is recomputed per read from live responses.

The strongest attack I had is false, and I checked it directly. PeerCorroboratedReads serves a shared wallet-DB read cache before the peer round (peer_reads.rs:235, :340), and db.rs:996 says that cache "replays whatever those three stored" -- including the single-source HTTP fallback. If the router path could write it, a forged id read once through chain_source would later be served as "corroborated". It cannot: the only production writers of put_chain_read / put_chain_spend are peer_reads.rs:276 and peer_reads.rs:367, both inside the post-corroborated() branch. The third apparent writer, chain.rs:1194, is inside mod tests. Repo-wide grep, no other call sites.

2. Failure direction -- HOLDS. No arm returns Bonded.

chain_bond_verdict_and_coin (crates/dig-node-service/src/mirror/bond_verify.rs:225-231, :248-254) takes only two ChainSource methods, coin_record and coin_spend. Every Err arm returns Unverified; Ok(None) -- which on this source means the peers agreed it does not exist -- returns Unbonded.

  • one reachable peer -> required_agreement(1) = max(1, CORROBORATION_FLOOR) = 2 (quorum.rs:216-218) -> Insufficient -> corroborated() is None (quorum.rs, Split | Insufficient => None) -> Err -> Unverified.
  • disagreement / even split -> Split -> same path.
  • timeout -> a non-answering peer is simply absent from the tally, lowering the count into the same Insufficient arm; an empty draw() (dialed.rs:294-312) does likewise.
  • no peer reads attached -> corroborated_chain_source errs and does not fall back to the router (chain.rs:485-492) -> Unverified.

3. Vacuity -- none found

  • Not disabled in production: the transport reaching the verifier is built ChainTransport::new().with_peer_reads(db) (crates/dig-wallet/src/sage/service.rs:183), carried as state.wallet_chain (server.rs:596) into spawn_bond_verifier_install (server.rs:2181). enable_chain_sync gates whether the verifier installs at all, which is pre-existing and unrelated.
  • No fixture upserts the coin before the check: source_over opens a fresh in-memory WalletDb per test (tests/mirror_bond_corroboration.rs:169-177), so the cache is empty and the peer round is genuinely exercised.
  • No source kind without a producer; CorroboratedChainSource's unserved methods return Err, never an empty Ok -- correct, since an empty list on this path would be a fabricated absence.

4. Does the discriminating test cross the decision? -- YES, and it revealed defence in depth

Baseline in my own worktree: 7 passed / 0 failed / 0 filtered out (counts, not exit status; a first run died in a transient futures-util build blowup unrelated to the diff and did not reproduce).

I then reverted the decision -- bypassed quorum::tally in coin_record_by_id so the first responder is believed:

test result: FAILED. 6 passed; 1 failed; 0 ignored; 0 filtered out
---- a_coin_only_one_peer_has_ever_seen_is_not_bonded ----
  left: Unverified   right: Unbonded

The test detects it. Worth recording why it failed as Unverified rather than Unbonded: with the record read's tally defeated, the attacker's forged record was believed -- and the second corroborated read, the creating spend, still could not be corroborated, so promotion was blocked anyway. Both reads are independently load-bearing; defeating one does not reach Bonded. Mutation reverted, worktree clean.

5. SPEC vs code -- no overclaim

SPEC.md:8891-8901 matches what is enforced. One nuance, not a defect: the SPEC says the two reads "MUST each be corroborated", while the code may serve either from the read cache without a fresh round. Every entry in that cache is corroboration-derived (see target 1), so the substance holds; staleness is bounded at UNSPENT_CACHE_TTL_SECS = 60s (peer_reads.rs:80) and is dominated by the pre-existing VERDICT_TTL = 600s (bond_verify.rs:52).

Disclosed limitation 1 -- verified true, and structural

verify_against_chain -> verdict_for returns Unverified at bond_verify.rs:305 on declaration_source_is_readable() (:167-171) before the source is used at all. So reverting the call-site swap is unobservable by any test that can exist today. Disclosing it was the right call rather than manufacturing a green.

Consequence the orchestrator should hold: Bonded is unreachable in production until #501 lands, so this change is inert on merge and becomes load-bearing only then. Both PRs edit bond_verify.rs; whichever lands second merges main in, and the second merge is the one that needs a re-read of the combined delta.

No blocking finding. I write no production code and did not merge or undraft.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — revert-proof MEASURED (not the verdict)

Two runs in an own detached worktree at 1b44d9b (C:/tmp/worktrees/e93-rev506; dn-503 and the
primary checkout untouched). Counts read as counts, not exit status.

Baselinecargo test -p dig-node-service --test mirror_bond_corroboration:
7 passed; 0 failed; 0 ignored; 0 filtered out. Non-zero, so the assertions genuinely executed.

Mutation A — the call site reverted to the pre-fix single-source read. In my worktree only,
bond_verify.rs:408 changed back to .chain_source(tokio::runtime::Handle::current()).await:

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

The suite is fully green with this PR's decision reverted. That confirms the inline finding on
bond_verify.rs:408 by measurement rather than by reading: the differential pair proves the seam
(same fixture, single source → Bonded, corroborated → Unbonded), but nothing observes which
source verify_against_chain actually asks. Restored afterwards.

This is not a claim that the pair is vacuous — it is not. a_coin_only_one_peer_has_ever_seen_is_not_bonded
and a_single_source_bonds_the_fabricated_coin drive the same fabricated_bond() fixture through
two different sources and disagree, which names a property rather than an outcome, and
agreeing_peers_do_bond_a_genuine_coin is a real anti-vacuity control. The gap is one line wide and
it is exactly the line the PR is about.

Also confirmed clean, so nobody re-derives them:

  • SPEC.md — the new paragraph sits inside §25.6a, is in MUST voice, and states the floor, the
    disagreement case and the no-fallback rule. Swept for superseded wording (single source,
    one read, chain_source, coinset) across all 8k lines: no stale mirror-bond phrasing survives,
    and the §25.6a verdict table's unverified row (the chain could not answer) already covers a
    corroboration failure without amendment.
  • §2.4b dependency freshness — nothing stale. Checked against the index with a User-Agent:
    dig-chainsource-interface 0.3.2 (declared 0.3), dig-mirror-coin 0.7.0, dig-mirror-collateral
    0.3.0, chia-query 0.20.0, dig-constants 0.13.0, dig-rpc-protocol 0.10.2,
    dig-node-control-interface 0.30.0 — all covered by the declared carets. The chia-* family is
    uniformly on the 0.36 line in both touched crates, so no internal split. No bumps were owed.
  • readable-code (§2.5)corroborated_source.rs clears the bar comfortably: every public item
    carries a doc comment, the comments state WHY rather than what, the helpers are small and
    intent-revealing (coin_bound_to, record_from, block_on's explicit three-way runtime handling),
    and the module doc names the failure direction it is allowed to be wrong in. No finding.

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

One further finding, measured rather than read. Non-gating.

Comment thread crates/dig-wallet/src/sage/corroborated_source.rs

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

loop-reviewer — VERDICT: CHANGES-REQUIRED

Head reviewed: 1b44d9bf998921e50e8e516c39cb266ab0f735ca (resolved from the remote, not from the
dispatch brief). Correctness leg of the triple gate, fresh context.

The change itself is correct and I would pass it. The one blocker is merge sequencing: the branch
version is now BELOW main, and the green version gate is measuring a stale base.

Ranked

  1. GATING — Cargo.toml:35, version 0.251.0 is below main's 0.252.0. #500 and #507
    landed after the 18:54Z merge. Check version increment reports pass only because it compares
    against the merge-base bfaa79d (0.247.1). Rebase onto current main, re-read the version from
    the file on disk, and take the next free value above 0.252.0. Watch the rebase output for
    dropping — an identical bump commit is discarded silently with RC=0. Thread left OPEN; it is
    the only one.
  2. Non-gating, resolved — bond_verify.rs:408 has no guard. Reverting this PR's decision to
    .chain_source(...).await leaves the suite at 7 passed / 0 failed / 0 filtered. The repo
    already has the idiom that would catch it (chain.rs:1404, an include_str! guard over the
    module's own source); it must be scoped to the function body, since the doc comment above names
    corroborated_chain_source three times and a file-wide contains stays green after a revert.
  3. Non-gating, resolved — corroborated_source.rs:133. Disabling the coin-id binding rejection
    also leaves 7/7 green; the module has no #[cfg(test)] and nothing names coin_bound_to.
    Benign-fixture shape — every fixture is self-consistent, so the check never fires.

What I verified rather than took on trust

  • Independence is real. server.rs:2179server.rs:596service.rs:183
    (with_peer_reads) → chain.rs:356 (DialedPeerSample::mainnet) → dialed.rs:246
    (assemble_distinct_sample over connect_random_peer_excluding). Distinct, separately dialled
    full nodes; api.coinset.org is not in the path and there is no fallback to the router.
  • One source is not Bonded, and disagreement fails closed. still_usable (dialed.rs:236)
    requires live >= CORROBORATION_FLOOR and live >= peers.len(), so attrition forces a redraw
    rather than letting a sample ratchet to the two most eager repliers. Below the floor or on
    disagreement → ErrUnverified, never Unbonded and never Bonded.
  • The three-state distinction is preserved where it decides something. Every unservable read
    returns Err rather than an empty Ok (:216, :229, :240, :266), and "peers did not agree"
    maps to Err, not Ok(None) (:252). Those are different claims.
  • The differential pair is discriminating, not an outcome assertion. The same fabricated_bond()
    fixture yields Bonded through one source and Unbonded through four, with
    agreeing_peers_do_bond_a_genuine_coin as a real anti-vacuity control. The doubles are expressive:
    distinct per-peer views and ids, and a peer that can refuse.
  • No new agreement mechanism was invented — the correct call under centralize-rival-implementations.
  • The merge added nothing of the lane's own; bfaa79d..1b44d9b is exactly the 8 declared files.
  • SPEC.md is normative, correctly placed in §25.6a, with no superseded wording surviving.
  • §2.4b clean — every dig-*/chia-* dep is at latest, chia-* uniformly on 0.36.
  • §2.5 clear pass on the new module.

Also honest and accurate: disclosed limitation 1 (the call site is unreachable through verdict_for
until #501 lands, because of the short-circuit at bond_verify.rs:305) checks out exactly as written.

Unresolved threads: 1 (the version). Merge is unblocked once that is rebased and re-bumped and my
thread is resolved; the other two legs of the gate report separately.

Mechanics: read via git objects plus an own detached worktree at C:/tmp/worktrees/e93-rev506, now
removed. dn-503, every sibling worktree and the primary checkout were never written to. gitnexus was
not used — its registered dig-node index is hundreds of commits behind and returns a false-safe
impactedCount: 0; radius was established by ripgrep plus direct reads and is reported as such.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

orchestrator e93b41 (19:14Z wake) -- this PR is in a background merge chain; do not re-push its branch or double-merge it. All gates on it have RETURNED (see the verdict comments above). Because main is strict-up-to-date and every PR bumps the same version line, the four gated dig-node PRs merge SEQUENTIALLY, each re-bumped above the moving main by merge-main-keep-version.sh (fresh detached worktree, push by SHA with read-back) and then merged by merge-on-green.sh (required checks asserted BY NAME, undraft, squash):

order PR version at merge why patch
1 #499 0.252.1 fix
2 #498 0.252.2 fix
3 #506 0.252.3 fix
4 #504 0.252.4 test/docs

Chain log: C:/tmp/orch-e93b41/chain.log on the loop machine. If a step goes red the chain STOPS at that PR and leaves the rest untouched; the next wake reads the log. Peer 6a1a2095: if you resume, this is why the branch heads and versions moved -- please do not run your own merge on these four.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 2, 2026 21:23
@MichaelTaylor3d
MichaelTaylor3d merged commit 1cf90cd into main Sep 2, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/503-bond-quorum branch September 2, 2026 21:23
MichaelTaylor3d added a commit that referenced this pull request Sep 2, 2026
… chain source keeps chia-query's panic backstop

dig-node#513 items 1 and 5, both of which need the file #506 created.

Item 1 -- `CORROBORATION_FLOOR` is two, so two agreeing peers were a full
quorum for a `Bonded` verdict. That constant is two for a LIVENESS reason
belonging to the sync path: it writes the wallet's replica, and demanding
more peers than a thin network offers is what froze a user's node for
hours. The bond path writes nothing -- a refused round yields `Unverified`,
the tier every record occupies with no verifier installed -- so refusing is
free there and the floor can be higher.

`BOND_CORROBORATION_FLOOR = 3` is therefore a SEPARATE constant, applied
through `tally_with_floor` and selected by the bond path via
`CorroboratedChainSource::requiring_corroboration`. It binds in BOTH
dimensions -- answers and agreement -- so a wide round in which two voices
agree does not buy its way past it. The cache is not consulted above the
default floor: a cached row records the answer a round settled on, never how
many peers settled it, and the sync path fills that cache at two.

Item 5 -- this adapter replaces `chia-query`'s bridge on the bond path and
had dropped its `guard_panics` backstop, keeping only the runtime-flavour
check. That catches the misuse we can name and nothing else; a panic
crossing a `ChainSource` method would unwind out of a `block_in_place`
inside a locate. Restored, asserted through `block_on` rather than on the
helper, so deleting it from the path turns the test red.

Item 2 (memoising `Unverified`) stays rejected by design: `VerdictCache::remember`
refuses `Unverified` and the path is bounded by `ReadAdmission` instead.

Also merges origin/main and bumps dig-mirror-coin 0.8 -> 0.9 (§2.4b).

Refs dig-node#513

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Sep 2, 2026
… chain source keeps chia-query's panic backstop

dig-node#513 items 1 and 5, both of which need the file #506 created.

Item 1 -- `CORROBORATION_FLOOR` is two, so two agreeing peers were a full
quorum for a `Bonded` verdict. That constant is two for a LIVENESS reason
belonging to the sync path: it writes the wallet's replica, and demanding
more peers than a thin network offers is what froze a user's node for
hours. The bond path writes nothing -- a refused round yields `Unverified`,
the tier every record occupies with no verifier installed -- so refusing is
free there and the floor can be higher.

`BOND_CORROBORATION_FLOOR = 3` is therefore a SEPARATE constant, applied
through `tally_with_floor` and selected by the bond path via
`CorroboratedChainSource::requiring_corroboration`. It binds in BOTH
dimensions -- answers and agreement -- so a wide round in which two voices
agree does not buy its way past it. The cache is not consulted above the
default floor: a cached row records the answer a round settled on, never how
many peers settled it, and the sync path fills that cache at two.

Item 5 -- this adapter replaces `chia-query`'s bridge on the bond path and
had dropped its `guard_panics` backstop, keeping only the runtime-flavour
check. That catches the misuse we can name and nothing else; a panic
crossing a `ChainSource` method would unwind out of a `block_in_place`
inside a locate. Restored, asserted through `block_on` rather than on the
helper, so deleting it from the path turns the test red.

Item 2 (memoising `Unverified`) stays rejected by design: `VerdictCache::remember`
refuses `Unverified` and the path is bounded by `ReadAdmission` instead.

Also merges origin/main and bumps dig-mirror-coin 0.8 -> 0.9 (§2.4b).

Refs dig-node#513

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
…s the panic backstop

dig-node#513 items 1 and 5, both of which need the file #506 created.

Item 1 -- `CORROBORATION_FLOOR` is two, so two agreeing peers were a full
quorum for a `Bonded` verdict. That constant is two for a LIVENESS reason
belonging to the sync path: it writes the wallet's replica, and demanding
more peers than a thin network offers is what froze a user's node for
hours. The bond path writes nothing -- a refused round yields `Unverified`,
the tier every record occupies with no verifier installed -- so refusing is
free there and the floor can be higher.

`BOND_CORROBORATION_FLOOR = 3` is therefore a SEPARATE constant, applied
through `tally_with_floor` and selected by the bond path via
`CorroboratedChainSource::requiring_corroboration`. It binds in BOTH
dimensions -- answers and agreement -- so a wide round in which two voices
agree does not buy its way past it. The cache is not consulted above the
default floor: a cached row records the answer a round settled on, never how
many peers settled it, and the sync path fills that cache at two.

Item 5 -- this adapter replaces `chia-query`'s bridge on the bond path and
had dropped its `guard_panics` backstop, keeping only the runtime-flavour
check. That catches the misuse we can name and nothing else; a panic
crossing a `ChainSource` method would unwind out of a `block_in_place`
inside a locate. Restored, asserted through `block_on` rather than on the
helper, so deleting it from the path turns the test red.

Item 2 (memoising `Unverified`) stays rejected by design: `VerdictCache::remember`
refuses `Unverified` and the path is bounded by `ReadAdmission` instead.

Also merges origin/main and bumps dig-mirror-coin 0.8 -> 0.9 (§2.4b).

Refs dig-node#513

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
…nputs (#505)

* chore(wallet): open the #502 lane -- bound total reservation hold

Salvage anchor for the dig-node#502 lane. Version assigned 0.251.0
(origin/main is 0.247.0; 0.248-0.250 are held by sibling lanes).

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

* test(wallet): pin the total reservation hold a repushed bundle may take

Four failing db-layer tests for dig-node#502, plus the constant they measure
against. `MAX_RESERVATION_HOLD_MS` is defined as a multiple of
`RESERVATION_TTL_MS` in one place so the two cannot drift; the TTL itself is
unchanged.

The acceptance test steps by less than a TTL past the cap and asserts its own
iteration count: a one- or two-push fixture is satisfied by the unfixed code,
because the first hold has not lapsed yet.

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

* fix(wallet): bound the total hold a repushed bundle may take on its inputs

`reserve_spend` re-armed `expires_at` to `now + RESERVATION_TTL_MS` on every
push of a given transaction id, so a caller re-pushing the same signed bundle
more often than the TTL renewed its hold forever and the inputs never returned.
That is the lockout failure the TTL's own doc names as the worse of the two,
reachable without a single dishonest answer.

Two composed bounds, neither of which shortens the TTL:

* A TOTAL cap anchored on the FIRST push. `MAX_RESERVATION_HOLD_MS` is defined
  as `6 * RESERVATION_TTL_MS` in one place, so lengthening the TTL scales the
  cap and the two cannot drift. `submitted_at` is not in the upsert's
  `DO UPDATE SET` list, so the stored value is a stable anchor; a test pins it.
* A reason-conditional re-arm. `chain::refusal_forecloses_a_later_push` names
  the four CLVM-execution / cost refusals that complain about the bundle's own
  contents and that no better-synced node can turn into an acceptance. Those
  still HOLD -- the verdict is height-dependent, so this crate declines to trust
  one node's view of it -- but they may not RENEW the hold. Everything else
  extends, including an unrecognised reason, an `Err`, and a bare verdict.

The two compose into the gate's "every observed refusal was foreclosing" case
without a per-attempt history, because a re-push may never move the deadline
EARLIER: if every attempt is non-extending the deadline never leaves the first
`submitted_at + TTL`, and an extending attempt's grant survives every later one.

The clamp lives in the SQL so it is atomic against the stored anchor; a
read-then-write above this layer would race two concurrent pushes.
`coin_reservations`' `ON CONFLICT(coin_id) DO NOTHING` first-claim-wins rule is
untouched and pinned by a test.

Closes #502

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

* chore: bump to 0.256.0, clear of #506's 0.251.0

* fix(wallet): drop the reason-conditional re-arm, keep the total-hold clamp

The adversarial gate on #505 refuted the reason-conditional half and it is
removed in full: VIEW_DEPENDENT_BUNDLE_CONTENT_REFUSALS,
refusal_forecloses_a_later_push, attempt_may_extend_the_hold, the
PendingTransactionRow::may_extend_expiry field and the CASE arm in
reserve_spend.

The four names it listed -- GENERATOR_RUNTIME_ERROR, BLOCK_COST_EXCEEDS_MAX,
INVALID_BLOCK_COST, INVALID_SPEND_BUNDLE -- do not identify a bundle no
destination is holding. push_tx relays to up to three destinations and only the
LAST answer returns, so such a refusal from the last says nothing about the
first, which may have admitted and gossiped the bundle. The removed code freed
inputs up to 550s earlier than main for a bundle that lands, with no attacker:
the double-spend direction #497 exists to close.

What ships is the clamp alone: expires_at is bounded by
submitted_at + 6 * RESERVATION_TTL_MS. The outer MAX is retained because a
non-monotonic clock is the one case that can still drive an incoming deadline
below a live one, and shortening a live hold is the dangerous direction.

SPEC.md 18.9a gains the total-hold bound as a normative clause: without it a
reimplementation built from the spec as written reproduces the unbounded re-arm
this change fixes.

Three limitations are now stated in MAX_RESERVATION_HOLD_MS' doc and two are
pinned by tests: the bound is on CONTINUOUS hold and a re-push after the prune
gets a fresh anchor; a bundle whose timelock matures past the cap has its inputs
freed while the network genuinely still holds it; and inside the last TTL before
the cap a re-push buys strictly less than a full TTL. The clamp also fails OPEN
under an absurd clock, since SQLite promotes integer overflow to REAL.

Refs #502

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

* chore: renumber to 0.252.8, under the MSI ProductVersion ceiling (#521)

* style(wallet): rustfmt the two reserve_spend test call sites

cargo fmt wanted the multi-line call form at both boundary tests. Formatted
those two files only; the workspace-wide check is now clean at zero diffs.

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

* docs(wallet): drop a comment left behind by the reverted re-arm field

The comment sat on `reserved_coin_ids`, which IS assembled from a stored
table and has no `true`. It described `may_extend_expiry`, the bool this
branch removed in fce2358, and pointed at a field doc that no longer exists.

Also tighten SPEC 18.9a: a re-push does not unconditionally 'update the
deadline' -- at or past the cap, and under a backwards clock, it correctly
leaves the deadline unchanged. State the re-arm as subject to the bound.

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

---------

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

* chore(mirror): open the peer-binding lane for #473

Stub anchor so a session cap cannot lose the lane. Activation of the
bond verifier follows.

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

* feat(mirror): activate bond promotion on the coin's own peer declaration

The bond verifier shipped in #467 was inert by construction:
`peer_declaration` returned `NotReadable` unconditionally, so `Bonded` was
unreachable, `verdict_for` short-circuited before any chain read, and every
holder got one verdict. That was the correct posture while nothing could bind
a coin to a claimant -- a coin proves that *a* bond exists and never that the
peer offering the record holds it, and promoting on the chain half alone
would rank a stranger republishing a public coin id first at zero collateral.

`dig-mirror-coin` 0.8.0 supplies the missing half. A coin's owner may declare
`dig-peer:<64-hex>` in the memo tail; only the owner's key can produce the
spend that writes it, so the term is an owner attestation carried by executed
on-chain code. `peer_declaration` now delegates to that crate's typed
accessor rather than parsing the tail here, because a second parser for a
security-critical format makes a divergence a silent authorization difference
instead of a compile error.

Promotion now requires BOTH bindings: coin -> content via
`MirrorCoin::advertises`, and coin -> peer id via the declaration.

`PeerDeclaration::NotReadable` is removed. It described a situation that no
longer exists, and a variant nothing constructs is a state the type claims to
model and does not.

The address-substitution residual, resolved
----------------------------------------------------------------
The declaration binds coin -> peer id, never peer id -> address, so a record
carrying an honest holder's peer id, that holder's real coin id and an
ATTACKER's addresses satisfies every check here and IS promoted.

SPEC 25.6a previously required closing that with an authoritative-record
restriction, on the stated grounds that "a dialler is not by itself a
backstop, because peer ids are derived from the presented certificate rather
than pinned against the dialled identity". That premise is false for every
path dig-node dials on: the download path makes the record's own
`provider_peer_id` the `PeerTarget` pin, dig-nat passes it to dig-tls, and
the verifier fails the handshake with `peer_id mismatch: expected .., got ..`.
dig-peer re-checks after connect, and fetched content is merkle-verified
against the caller's own requested root regardless. The attacker buys a
refused connection, not a redirected reader.

The restriction as written is also not implementable at this layer, and that
is worth recording rather than rediscovering: dig-dht really does keep
authoritative and hearsay records in two separate stores, but erases the
distinction in `merge_dedup_by_provider` before `find_providers` returns, and
a locator restricted to authoritative records would return almost nothing --
that store holds keys this node is k-closest to, not content it wants.

What this layer owes instead is a BOUND, and it is added here: at most one
record is promoted per claimed peer id, so one stolen identity cannot spend
the whole verified budget. A duplicate falls back to the baseline tier it
would have occupied with no verifier at all, never below it, so the lattice
stays credit-only.

Also corrects `peer.rs`'s note asserting no dial pins a peer id. The narrow
fact behind it -- dig-gossip's legacy rustls outbound does not pin, and every
`expected_peer_id` there is `#[cfg(test)]` -- is true; the generalisation to
every dial was not, and dig-node never dials on that path.

Closes #473
Closes #466

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

* docs(spec): 25.6a states the pin as the remedy, and two status bullets stop lying

Three normative corrections in the section a reimplementation of the bond
layer would be built from. No behaviour changes.

25.6a required an authoritative-record restriction and dismissed the
alternative because "a dialler is not by itself a backstop, since peer ids
are derived from the presented certificate rather than pinned against the
dialled identity". That was FALSE. It generalised one true narrow fact --
dig-gossip's legacy rustls outbound does not pin, and every
`expected_peer_id` there is test-only -- into a claim about every dial.
dig-node never dials on that path: the download path makes a record's own
`provider_peer_id` the pinned dial target, dig-nat passes it to dig-tls, and
the verifier refuses the handshake on a mismatch.

The restriction the clause preferred is also not available at the layer that
ranks, and requiring it as though it were is worse than not requiring it. A
DHT keeps attributed and hearsay records apart but flattens them into one
untagged list when answering a lookup, and a reader's records for content it
wants are overwhelmingly hearsay -- the attributed store covers the keys a
node is closest to, not what it fetches. Restricting the locator to
attributed records would return almost nothing.

What the ranking layer owes instead is now stated as a MUST: at most one
record promoted per claimed peer id per locate, so one stolen identity cannot
occupy every promoted slot on the strength of a single bond. Credit-only is
explicitly preserved.

The two status bullets were separately stale. Verification is no longer
inert. And the DHT pointer IS attached -- the `dig-dht ^0.13`/`0.15` semver
split that blocked it is resolved, the announce passes a coin id
(`dht.rs:493`), and `SnapshotMirrorPointers` is installed at `server.rs:2169`.
A bullet saying a mirror coin id never reaches the DHT would have made the
whole verification path read as unreachable.

Refs #473

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

* fix(mirror): bound promotion by peer IDENTITY, and write this node's own declaration

Three findings from the adversarial gate, one of them a live zero-cost attack
on the bound added earlier in this branch.

The bound was keyed on the raw wire string
----------------------------------------------------------------
`promoted_peers` used `record.provider_peer_id` verbatim, while every check
that GRANTS a promotion is case-insensitive: the coin's declaration compares
32 decoded bytes, and the TLS pin compares 32 bytes of certificate hash. A
peer id is fixed-length hex, so one identity has many spellings.

So a stranger answering one lookup could return eight records carrying an
honest holder's peer id in eight different hex cases, each with its own
addresses. Each passes `advertises`, each passes `declares_peer`, each is a
distinct `String` -- eight promotions and eight chain reads, consuming the
whole `MAX_VERIFIED_PER_LOCATE` budget on the strength of one bond the
attacker does not hold. Exactly the outcome the bound was written to prevent.

Both the bound and `VerdictKey`'s claiming-peer component are now keyed on the
ASCII-lowercased id. dig-dht applies this same normalisation to the
neighbouring `unverified_mirror_coin_id`, for the reason its own doc gives:
without it "dedup and equality would split on presentation". The wire-level
gap is filed as DIG-Network/dig-dht#27 -- it also affects self-exclusion and
the union address merge, both pre-existing.

The regression test was vacuous when first written, and that is worth
recording: with the honest holder LAST in the slate, a promoted respelling and
a baseline one land in the same position under a stable sort, so it passed
with the fix reverted. Moving the honest record between the two spellings
makes the behaviours differ. Now revert-proven -- reverting the fix fails
exactly one test, with the attacker's respelling ahead of the honest holder.

A coin this node creates now names this node
----------------------------------------------------------------
`MirrorAdvertisement` gained the field, and dig-node had to answer it. A coin
that declares nobody can never be promoted by any reader, so a node creating
one pays collateral for a claim nothing can credit to it -- the feature would
have been vacuous for every coin this node makes. `Node::own_peer_id` is
threaded to the create, re-read per pass rather than captured at spawn,
because the mirror task starts beside the peer network rather than after it.
`None` still creates the coin and warns; refusing would leave a node unable to
bond at all before its network is up.

Two stale claims
----------------------------------------------------------------
`verdict_for`'s own doc still said "nothing is promoted today" and "No chain
is read at all", in the commit that makes both false. And `peer.rs:2475` was
an uncorrected twin of the dialler claim fixed at `:863` -- same refuted
assertion, same file, in the code that configures the gossip pool. Fixing one
copy of a false normative claim and leaving the other is how it comes back.

Refs #473

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

* chore(mirror): salvage fix-lane WIP from the 15:11Z session cap -- uncompiled

Uncommitted work left in the dead lane's worktree (7 files, +753/-41). Never compiled or tested by the lane that wrote it; the resuming implementer verifies it first.

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

* fix(mirror): restore the spaces a lost continuation baked into six operator messages

Six operator-facing literals carried the source indentation of a `\`-line-continuation
that had been collapsed away, so an operator reading the log saw a 14-18 space gap in
the middle of a sentence. The text is what was always meant; only the run is removed.

Found by scanning every literal in the mirror modules rather than the one site the
review named -- the reviewer reported it in `lifecycle.rs`, and the defect was actually
in `advertise.rs` (3), `pass.rs` (2) and `runner.rs` (1). A defect class named at one
site is not a defect class swept.

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

* style(mirror): rustfmt the salvaged hunks

The salvage commit was written into the tree without ever being built or formatted,
so two lines it introduced were over width. Formatting only -- no behaviour change.

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

* docs(spec): 25.6a states the aggregate read bound, and stops promising the eviction we removed

Two coherence gaps between 25.6a and the code that now ships under it.

The per-locate read bound was specified; the AGGREGATE bound was not. A per-locate
ceiling bounds nothing on its own, because the gate admitting a locate is per-requestor
over self-minted identities -- an adversary multiplies the ceiling by as many identities
as it cares to mint. The clause now requires both limits the implementation holds (a
process-wide verification budget and a per-claimant distinct-unproven-coin ledger),
requires them to be consulted before the chain is touched, and states that exhaustion
degrades to `unverified` rather than refusing service.

The eviction clause said overflow "MUST evict rather than clear". The implementation
deliberately does less than that: an `unbonded` is refused admission to a cache full of
live entries rather than allowed to displace a `bonded`, because `unbonded` is the
verdict a stranger elicits for free and per-insert eviction is therefore paced by the
attacker. The clause said the code did something it no longer does.

Also documents a `clippy::too_many_arguments` allow on `admitted_verdict_for`: its
parameter list mirrors `verdict_for`'s exactly so that a transposition of one of the
four opaque 32-byte arguments stays visible at the call site.

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

* fix(mirror): the bond path takes its own corroboration floor and keeps the panic backstop

dig-node#513 items 1 and 5, both of which need the file #506 created.

Item 1 -- `CORROBORATION_FLOOR` is two, so two agreeing peers were a full
quorum for a `Bonded` verdict. That constant is two for a LIVENESS reason
belonging to the sync path: it writes the wallet's replica, and demanding
more peers than a thin network offers is what froze a user's node for
hours. The bond path writes nothing -- a refused round yields `Unverified`,
the tier every record occupies with no verifier installed -- so refusing is
free there and the floor can be higher.

`BOND_CORROBORATION_FLOOR = 3` is therefore a SEPARATE constant, applied
through `tally_with_floor` and selected by the bond path via
`CorroboratedChainSource::requiring_corroboration`. It binds in BOTH
dimensions -- answers and agreement -- so a wide round in which two voices
agree does not buy its way past it. The cache is not consulted above the
default floor: a cached row records the answer a round settled on, never how
many peers settled it, and the sync path fills that cache at two.

Item 5 -- this adapter replaces `chia-query`'s bridge on the bond path and
had dropped its `guard_panics` backstop, keeping only the runtime-flavour
check. That catches the misuse we can name and nothing else; a panic
crossing a `ChainSource` method would unwind out of a `block_in_place`
inside a locate. Restored, asserted through `block_on` rather than on the
helper, so deleting it from the path turns the test red.

Item 2 (memoising `Unverified`) stays rejected by design: `VerdictCache::remember`
refuses `Unverified` and the path is bounded by `ReadAdmission` instead.

Also merges origin/main and bumps dig-mirror-coin 0.8 -> 0.9 (§2.4b).

Refs dig-node#513

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

* fix(mirror): adopt dig-mirror-coin 0.9.0 in the lock and correct the WalletDb test import

The manifest already declared "0.9"; the lock still resolved 0.8.0, so the two
disagreed. Resolve the lock to 0.9.0 so the declared and resolved versions agree.

The corroborated_source test module imported WalletDb from a `crate::wallet_db`
path that does not exist; the type lives at `crate::sage::db::WalletDb`.

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

* build(deps): resolve dig-mirror-coin to 0.9.0 after the main merge

The manifest declares "0.9"; the post-merge lock carried 0.7.0 from main.
Re-resolve so declared and resolved agree on one line.

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

* style(wallet): order the corroborated_source test imports as rustfmt wants

The only `cargo fmt --all -- --check` diff on this branch: `db` must precede `peer_reads`. Fixed by hand rather than with `cargo fmt --all`, which has rewritten thousands of untouched lines on a sibling branch.

* fix(mirror): supply a peer id in the create fixtures the identity guard now refuses

Three tests went red on this branch for one reason: `create` now refuses before selecting any coin when the node has reported no peer id, because a coin naming no peer locks collateral for an epoch that no reader could ever credit. The fixtures predate that guard and passed `None`, whose comment ("what a node writes before its peer network is up") described a state that is no longer a supported input to a create.

Each of the three now passes a well-formed id built as `"a1".repeat(32)`, so its length is right by construction rather than by counting 64 characters in a literal. The guard itself is unchanged and correct: it fails closed on money, which is the direction a wrong answer should fail in.

The fourth call site keeps `None` deliberately. `an_all_rejected_value_refuses_and_spends_nothing` refuses at the advertisement guard, which returns before the identity guard is consulted, so its `None` is never reached. That test previously asserted only `is_err()`, which this PR's second early return makes ambiguous -- it would pass just as happily if the identity guard were reordered ahead of the URL one, leaving the URL guard it exists for unexercised. It now names the expected cause.

* fix(mirror): guard the advertise literals a test could not reach

The earlier pin for this defect class was real but covered the wrong module. `the_refusal_messages_read_as_sentences` drives `declaration_for_create`, so it asserts over `lifecycle.rs`'s own two refusal literals -- which were never the corrupted ones. The three genuinely broken runtime lines are inline `tracing` literals in this file with no test reachability at all, so they sat behind a green test that appeared to cover them. That is worse than the original defect, because it looks discharged.

The two rejection reasons move into `rejection_reason()` and the two info lines into `ADVERTISING_AT_CONFIGURED_URLS` and `nothing_publishable()`, so a test can reach the rendered text. `nothing_publishable` is a function rather than a const because it names the environment variable and `concat!` cannot take a const; spelling the variable a second time as a literal would be a second source of truth for the same name.

The walk is exhaustive BY CONSTRUCTION: a match maps each `Rejection` variant to the name a failure prints, so a new variant fails to compile until it is named in the walk. `rejection_reason`'s own match would force a new variant to be GIVEN a message, but nothing would force that message into the sweep meant to check it -- a gate over an enumeration can only check the enumeration it was handed.

* test(mirror): control row 2 so its Unverified is attributable to the declaration

The test claimed row 2 "differs only in the declared peer id", which was the reviewer's condition for it proving anything. On the fixture as built it does not: row 1 is minted by wallet(3) and row 2 by wallet(4), so they differ in owner puzzle hash AND declared peer id.

That matters because `verdict_for` reaches `Unverified` from two disjoint places -- the chain half producing no coin, and `PeerDeclaration::Silent` at the final match. A row-2 coin malformed anywhere in the chain half would satisfy the assertion while proving nothing about the declaration: the same vacuity already closed for row 3 by its fourth row, and left open for row 2.

The distinct owners are load-bearing and stay: `creating_spend` derives a coin's parent from (owner, asset, amount), so one wallet cannot publish two same-amount advertisements without the second overwriting the first. So rather than collapsing the rows, row 2's coin is asked the question it should answer positively -- same coin, same root, claimant `stranger` -- and must return `Bonded`. That proves the entire chain half passes, leaving the declaration as the only thing row 2 can be attributable to. The doc no longer states one-field difference as a property of the fixture.

* docs(mirror): keep configured_urls' doc attached to configured_urls

Lifting the message helpers put them BETWEEN `configured_urls`'s doc comment and its signature, so the doc block silently reattached to `rejection_reason` and the public function was left undocumented. Nothing catches this: it compiles, rustfmt and clippy are clean, and the rendered docs simply describe the wrong item. Moved the helpers above the doc block instead.

* test(mirror): derive the guard's count from Rejection::ALL and cover the warn wrapper

My own comment overclaimed, in the direction this guard exists to prevent. The exhaustive match is on the TYPE, so a new `Rejection` variant genuinely cannot compile without being named -- but naming is not walking. Add a variant, add the two match arms, and the array literal still compiles, `lines.len()` is still 4, and the hard-coded `assert_eq!(lines.len(), 4)` still passes while the new operator-facing message ships unguarded. A literal count does not merely fail to prevent that; it cements it.

The walk is now driven from `Rejection::ALL`, declared beside the enum, with the expected count DERIVED as `Rejection::ALL.len() + 2` so the walk and the list cannot drift. `ALL`'s doc states what is actually enforced -- the match forces a developer into this module and forces the variant to be given a message; nothing forces it into the array, and the derived length is what ties them together -- rather than repeating the stronger claim.

Also folds in a line that was outside the walk while the guard's name claimed `every`: the warn wrapper is operator-facing prose in its own right, so it becomes `not_advertised()` and the walk asserts the whole rendered sentence rather than the reason fragment it embeds.

* fix(mirror): drop the stale None declared_peer field the merge duplicated

The merge with origin/main spliced this branch's new `Some(declared_peer)`
field alongside main's unmodified `declared_peer: None` block for the same
struct literal -- a silent, non-conflicting 3-way merge that left the field
specified twice (E0062), because dig-node#473 (this branch) inserted its field
near the top of the literal while main's untouched block still carried the
pre-#473 field lower down. Removed the stale block; the kept comment and
`Some(declared_peer)` are this branch's real implementation.

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

* fix(mirror): close the continuation-guard exclusion #501 was granted

Empty EXCLUDED_DIRS now that this PR own its six lost-string-continuation
sites are already fixed elsewhere in this diff (mirror/advertise.rs,
mirror/pass.rs, mirror/runner.rs). Verified with the guard itself:
no_lost_string_continuation_leaves_a_multi_space_run_mid_sentence passes
scanning every directory under src, with files_scanned > 20 and zero
offenses.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mirror-bond promotion rests on a single uncorroborated chain read — bring the Bonded verdict under NC-12 quorum

1 participant