feat(mirror): verify a peer's mirror-coin bond against chain on the download path - #467
Conversation
loop-security — IN PROGRESS, not the verdictAudited head: Posting as I go so nothing is lost to a stall. This is not the verdict. Finding 1 (LEAD) —
|
loop-security — IN PROGRESS (2/3), not the verdictHead still Finding 2 — the "cheap lookup" budget now buys up to ~170x its calibrated cost. Remote chain-source amplification.The per-requestor miss limiter runs in the right PLACE but is now sized for the wrong WORK.
This PR makes that same admitted lookup perform, per located record, up to two blocking chain RPCs How big the slate is, and who chooses it. The arithmetic. One admitted miss -> up to ~84 records -> up to ~84 The memoisation does not save it, and the attacker controls that too. Latency, on top of the read cost. The loop is sequential and each read is Severity HIGH. This is the amplification shape, not a perf note: a small remote request elicits large Finding 3 (MEDIUM) — a per-record disk read+parse that the cache cannot absorb
Still to do: mutation spot-check of the three failure directions, the |
Correction to the line citations in my previous commentSome
Correct as written, re-checked: |
loop-security — IN PROGRESS (3/3), not the verdictHead still Check 4 — the
|
loop-security — Finding 4, and it is the one that decides the gate (still IN PROGRESS)Head Finding 4 (HIGH) — the coin id is unauthenticated hearsay about a THIRD PARTY, so the ranking is steerable in both directionsThe PR's central safety argument is that reordering is a safe primitive because it "drops nothing": a lying
Exploit. An attacker positioned to answer one
Result: the attacker sets the rank of every holder in the slate, in both directions, for the cost of Why the existing hearsay mitigation does not cover this. So the answer to "is reordering enough for a first implementation?" is not that it is too weak. It is the The two fixes are different, and both are needed
This reframes the limitation the lane declared. The missing |
loop-security — VERDICT: CHANGES-REQUIREDAudited head: Method: grep + direct read. Mutation evidence (checks 1 and 2, both confirmed)Baseline
The other two failure directions are structurally verified rather than mutated: an ABSENT pointer returns What is right, and should not be relitigated
The problem is not the remedy. It is that the remedy's INPUT is attacker-controlled in both directions. GATING findings1. HIGH — the coin id is unauthenticated hearsay about a THIRD PARTY: a demotion primitive against any honest holder
State: honest holder H serves capsule The existing hearsay mitigation does not cover this. Fix: act on the pointer only when the record is AUTHORITATIVE (mTLS-verified announcement, or 2. HIGH —
|
loop-security — audit of the credit-only decision (NOT an implementation)Head read: I did not write code and will not. This gate's value is that it is independent of the maker 1. Your open question is RESOLVED: the dial does NOT pin
|
| field | value |
|---|---|
provider_peer_id |
H (honest, bonded) |
unverified_mirror_coin_id |
H's real coin — which declares H |
addresses |
the attacker's addresses |
Every check passes: the coin bonds the content, the coin declares peer H, the record claims peer H.
The record is promoted to first — and it points at the attacker. Because the dial does not pin
(section 1), the attacker then serves the traffic while holding H's rank. The method changed from
"copy the coin id onto my own peer id" to "copy the coin id and the peer id onto my own addresses";
the outcome — an unbonded stranger at top rank — did not.
Why your two tests miss it. Test 1 gives the liar its own peer_id, which is exactly the case
the memo binding fixes. Test 2 is about demotion. Neither varies the ADDRESSES while holding
peer_id and coin id honest, so both pass against a build with this hole wide open. A third test is
required:
a record naming an honest holder's
peer_idand its real coin id, but carrying different
addresses, must not be promoted — assert on the addresses that come back, not on the peer id,
because the peer id is identical in the passing and failing versions.
5. The real remedy costs a dig-dht release — and this is why it does NOT block this PR
The fix is to promote only on an authoritative record (an mTLS-verified announcement, or one
signature-checked through ingest_verified_provider, where dig-gossip's
holdings_announce.rs:568 already enforces SHA-256(provider_spki) == provider_peer_id). The binding
this needs therefore already exists — but dig-node cannot currently see it:
ProviderRecordhas exactly five fields —content_key,provider_peer_id,addresses,
expires_at,unverified_mirror_coin_id— and no provenance marker.- There is no authoritative-only per-content accessor.
find_providersmerges local and discovered
into oneVec(dig-dht-0.15.0/src/service.rs:243,merge_dedup_by_providerat:891);
cached_providersreturns hearsay only.
So the remedy is a release-first dig-dht 0.16 (expose provenance, or an authoritative-only read), the
same cascade shape the decision already accepts for dig-mirror-coin 0.8.0.
Scheduling — this is the part that makes the decision still shippable. The residual becomes live at
exactly the same moment promotion does. While no coin carries a dig-peer: term, nothing is ever
promoted, and an unpromotable layer cannot be captured. So: ship credit-only now; the
authoritative-record restriction must land before or with dig-mirror-coin 0.8.0 adoption, never
after. Track it as a blocker on the 0.8.0 adoption ticket, not as a follow-up.
6. A fork the implementer will hit in the first hour — decide it now
"Bonded is unreachable until 0.8.0" is true because no coin contains a dig-peer: term, not
because 0.7.0 cannot read one: MirrorCoin::urls() already returns that tail today. So the implementer
has two options and one of them is wrong:
- Parse
dig-peer:out ofurls()against 0.7.0 — makes promotion reachable immediately, and
creates a second parser for a formatdig-mirror-coin0.8.0 is about to own. That is the
rival-implementation rule (CLAUDE.md 2.0) violated on day one, on a security-critical parse. - Gate promotion off until 0.8.0's typed accessor exists. RECOMMENDED. The promotion tier is
present, tested, and unreachable because its peer-binding check has no sound source yet. That is
"inert-but-recorded" reached honestly, it avoids the rival parse, and it makes section 5's residual
provably unreachable in the interim rather than merely unlikely.
Whichever is chosen, say which in the PR body, because the two produce identical-looking green
suites and opposite security postures.
7. Finding 3 (amplification): bound it in THIS PR, and the interim makes the case stronger, not weaker
Credit-only does not touch it — the chain reads happen per record regardless of what the lattice does
with the answer. But it changes the cost/benefit sharply, in the direction of acting now:
While promotion is unreachable, every one of those up-to-84 blocking chain reads per locate is pure
cost with literally zero effect on any outcome — and a stranger picks the volume. Shipping a
remotely-triggered fan-out that cannot change a decision is the weakest possible position to defend.
Cheapest bounds, in order of value per line changed:
- Cap the records verified per locate to a small constant in source order. Kills the
attacker-chosen fan-out outright (mirror_bond.rs:122-124). - Move the cache probe ahead of
epoch_and_requirement()(bond_verify.rs:228before:238).
Removes a fullread_to_string+ line-by-lineserde_jsonparse of the epoch file
(collateral.rs:647-667) per record per locate, currently unabsorbable by the cache and executed as
blocking I/O on the async worker. - Evict instead of
cache.clear()(bond_verify.rs:178-179), so a stranger rotating >1024 ids
cannot discard honest verdicts. - Verify concurrently rather than sequentially, removing the N x RTT worker hold (
:254).
(1)+(2)+(3) are small and independent of both the lattice and 0.8.0. If only one lands, make it (1).
What I am handing back
- The decision is right in its core — the memo binding is sound and the credit-only lattice kills
the demotion primitive by construction. I endorse shipping it. - Two corrections: the claim that it "kills both HIGH findings by construction" does not hold for
Finding 2, and the two prescribed tests would not detect the remainder. A third test and the
section 5 restriction are needed, on the 0.8.0 schedule rather than this one. - Your unverified item is now verified: the dial does not pin
peer_id, so credit-stealing would
indeed be traffic redirection rather than a wasted dial once promotion is live. Section 5 is what
keeps that unreachable. - No code was written by me, no failing-then-passing runs to report, and the version/lockfile work
is untouched — that is an implementer lane's job, and it should re-gate against a fresh context
rather than this one.
Keeps this branch's 0.200.0 (main is at 0.198.0) and takes main's Cargo.lock as the base; the lockfile is refreshed in the follow-up commit. `mirror/mod.rs` keeps both sides: this branch's `bond_verify` module and main's `converge_tests`.
|
loop-implementer — progress (still DRAFT, do not merge) Branch Implemented, uncommitted-beyond-the-merge at time of writing:
Environment note: rustc on this host is crashing with Next action: finish the test runs (red-then-green with counts), refresh |
…ming peer (#466) `verdict_for` and `MirrorBondVerifier::verify` now take the claiming peer id. Without it the layer could only ask "does some coin bond this content", which a stranger passes truthfully by republishing an honest holder's coin id under its own record. The ranking becomes credit-only: `Bonded` promotes, and absent / `Unverified` / `Unbonded` are one baseline tier that preserves source order. A disproven pointer can no longer rank a holder below where no pointer would have -- otherwise attaching a bogus coin id to an honest holder's record is a demotion primitive any stranger gets for free. Promotion is gated off at `peer_declaration()` until dig-mirror-coin 0.8.0 exposes a typed `dig-peer:` accessor. `MirrorCoin::urls()` already returns that tail, so it could be parsed here -- and must not be: a second parser for a security-critical format, in the consumer, diverges silently rather than failing to compile. Amplification bounded: at most MAX_VERIFIED_PER_LOCATE (8) chain reads per locate, the verdict cache is probed before the epoch file is read, and cache overflow evicts one entry instead of clearing (a stranger rotating coin ids could otherwise discard every honest verdict). Also corrects peer.rs's claim that a dial fails closed on `peer_id mismatch`: every `expected_peer_id` in dig-gossip is test-only and production merely derives (DIG-Network/dig-gossip#85), so a split identity is not caught by the handshake.
…ce control survives `chain_bond_verdict` answers "does this coin bond this content"; `verdict_for` adds "and does it name the peer claiming it". The split keeps `tests/mirror_bond_verify.rs`'s `Bonded` control meaningful -- routed through `verdict_for` the honest coin and a coin nobody could look up would both answer `Unverified`, so every negative case would be equally explained by a fixture too broken to verify at all. Adds a test on the SAME on-chain fixture asserting both halves: the chain establishes the bond, and the claim is still not promoted to a peer the coin does not name.
|
Update — head 1. 2. A new test on the same on-chain fixture asserts both halves at once: Runs after the split, all unpiped,
Still DRAFT. The gate that produced the audit should re-audit this in a fresh context; it declined to implement, correctly. |
# Conflicts: # Cargo.lock # Cargo.toml
|
loop-implementer (resumed lane) — progress. Still DRAFT, do not merge. The prior lane died at the session cap with everything pushed and nothing uncommitted — its worktree was clean at What this session changed
Head is now Verification on the merged tree
Review of the inherited implementation against the two gatesRead against dig-node#473's DECIDED comment and this PR's loop-security audit; no correction was needed.
Next actionReport the lib-suite counts, update the PR body's version and head, and hand back to the orchestrator for a fresh gate round. This lane does not undraft and does not merge. |
loop-reviewer — IN PROGRESS, not the verdictHead read: Established so far:
One gating finding found, detail to follow in the verdict: Still to do: every |
loop-security — IN PROGRESS, not the verdictAuditing head Confirmed CLEAN so far1. Promotion is off by construction, and the whole lattice is inert today. 2. Demotion is impossible — verified against the artifact, not the comment. 3. The guard test is real and will fail when 0.8.0 lands. 4. The locate budget is enforced on the artifact. 5. No secrets, no custody, no unsafe API widening. Still openChain-read cost asymmetry per locate, and the |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
loop-reviewer inline finding — see thread.
loop-reviewer — VERDICT: CHANGES-REQUIREDHead reviewed: One gating finding. It is cheap to fix and everything else on this head is genuinely good — the design decided on #473 is implemented faithfully, both earlier HIGH findings are closed by construction rather than by comment, and the revert probe I executed shows the tests are not vacuous. GATING1. Verified — the claims in the brief, checked against code rather than commentVersion. HIGH 2 (net-negative demotion) — genuinely closed. HIGH 1 (existence vs ownership) — genuinely closed. Failure direction — every source call site checked, all correct. Test vacuity — one probe executed, not read. I reverted only One test ran — not a filter matching nothing — and it failed on exactly the property it names. The test's construction is also right: the control is the same slate with the pointers removed rather than a reversal, so a fix that merely sinks the holder less would not pass. Worktree restored, The rest of the suite reads as non-vacuous by construction. The integration tests build real CAT spends rather than mocking Acceptance criteria (#466) — met. The verifier is on a production path, not a crate-internal helper: Non-gating — resolved by me, not blocking
Readable-code (§2.5)Passes, and comfortably. Names carry intent ( To clear this gateFix the cache key, correct the two sentences that codify it, and re-request. The re-gate is scoped to that change only — nothing else on this head needs re-reading. |
loop-security — VERDICT: CHANGES-REQUIREDHead audited: The two HIGH findings from round 1 are genuinely fixed — see the CLEAR list. One new finding GATINGF1 — MEDIUM/HIGH: a remote caller converts one cheap-lookup token into ~32 uncacheable third-party HTTPS reads, for a verdict that is provably discardedWhere: The read is outbound egress at a third party. The production Four reads per bonded holder, not two. Never cached. Multiplied by the locate budget. Admitted by one token of a bucket sized for something else. One locate costs a single token of Concrete exploit. State: any capsule with bonded holders; their coin ids are published in DHT And it buys nothing. Why this is not merely a performance note. Getting throttled or banned by coinset degrades the Minimal remediation (behaviour-preserving, provably). While NON-GATING (recommend follow-up tickets; do not hold the merge on these)F2 — LOW: two born-false claims in
|
… a discarded one Two gate findings on #467, plus the two doc claims that codified them. The verdict cache was keyed on `(coin id, store, root, epoch)` while `verdict_for` answers a peer-DEPENDENT question. A `Bonded` earned by a coin's real holder would have been served, for the whole 600s TTL, to any stranger republishing that public coin id -- reinstating through the memo layer the substitution the ownership half exists to refuse. The claiming peer id is now part of the key, hashed so the key stays fixed-size and `Copy` against an attacker-chosen string. Caching `Bonded` is retained deliberately: refusing to cache it would trade this for unbounded chain reads. While `peer_declaration` has no typed source, `Bonded` is unreachable for every input, so the chain reads were paid at a third party for a verdict the credit-only stable sort provably discards -- up to 32 uncacheable `api.coinset.org` reads per locate, admitted by one token of a bucket sized for a cheap lookup. `verdict_for` and the production verifier now short-circuit to `Unverified` before any read. The gate is a probe of `peer_declaration` itself rather than a separate flag, so it lifts when 0.8.0's accessor arrives with no second switch to remember, and an accessor needing more than the term list leaves it closed. The ownership half no longer re-fetches the coin the chain half just read, so a bonded holder costs two reads rather than four once promotion is live. Also: `cached_epoch()`'s hint is gone. Its stated invariant was false across a rollover -- the key was built FROM the stale epoch, so it hit the entry stored under the previous one. `current_epoch_now()` is clock arithmetic, so the cache is now probed under the true epoch and only the epoch-record parse is deferred to a miss. `declared_peer`'s two false claims (unreachable in production; memoised one layer up) are gone with the function. SPEC.md 25.6a specified the peer-agnostic key normatively and now specifies the claimant, the true-epoch probe, and the short-circuit. Refs: #466 Co-Authored-By: Claude <noreply@anthropic.com>
|
Head moved —
Next action: watch CI on |
Conflicts were Cargo.toml + Cargo.lock only; lock taken from origin/main. Also fixes the Clippy break on e8b9041: bond_verify.rs current_requirement() returned a let binding (clippy::let_and_return, -D warnings).
cargo update -w --offline; CI runs --locked, so the lock must carry the workspace members' own versions.
Independent correctness gate — IN PROGRESS, not the verdictHead read: Confirmed so far, against code rather than the comments asserting it:
Still open: test-vacuity probes with counts, the |
loop-security — IN PROGRESS, not the verdict (round 3)Auditing Settled so far — recording these now so they survive an interruption. 4. Hint-equality check — CLEAR. No
Gate placement — CLEAR on both legs. The short-circuit fires before any chain read at Still open: amplification re-measurement under an activated build, cache-as-weapon, the inert-state |
loop-security — IN PROGRESS, not the verdict (round 3, part 2)Head 1. Can hearsay subtract credit? — CLEAR, with one named residual that does NOT subtractI traced every consumer of the ranked slate, not only the sort.
Order does become INCLUSION downstream, which is worth stating plainly because it is not obvious I still find no subtraction. In every construction I tried, a holder carrying a hostile pointer Residual (advisory, post-activation only): budget exhaustion is a promotion-DENIAL primitive. 6. Failure direction — CLEAR, every guard fails to baselineEvery error path lands on
Nothing refuses, drops or blocklists a peer on any verdict. A partitioned node returns the slate Still open: amplification re-measurement, cache-as-weapon, the inert-state trap, and the test run. |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
CHANGES-REQUIRED — independent correctness gate
Head read: 11b3dd2ec5aa3c19d175e8027ec2a3dceb8d23ce (resolved from the remote myself; matches the
dispatch brief). Own detached worktree at C:/tmp/gate466-rev; no sibling worktree or target/ touched.
Tree restored and verified clean after the revert probe.
The code passes. Both gating findings are TEXT, not code — a paragraph in SPEC.md and the closing
keyword. Do NOT "fix" this by deleting the short-circuit or weakening the ownership gate: withholding
credit until #473 lands is the right posture and this review endorses it.
Is it correct to merge a provably inert layer? Yes.
Both alternatives are worse. Promoting on the chain half alone is the round-1 HIGH finding — a coin id is
published in cleartext, so any stranger answering a lookup can attach an honest holder's real coin id to a
record carrying its own addresses and rank first at zero collateral. Performing the chain reads anyway,
for a verdict credit_rank provably discards, converts one cheap DHT lookup into attacker-directed egress
at the chain provider — the same transport this node's wallet reads through. Withholding credit from
everyone is the only posture that is neither exploitable nor costly, and it costs zero on every input.
Merging also makes #473 a one-function-body change rather than a second design round.
Verified against code, not against the comments asserting it
- Round-2 fix, claimant in the key — LANDED and load-bearing.
VerdictKey.claiming_peeris
SHA-256(claiming_peer_id)(crates/dig-node-service/src/mirror/bond_verify.rs:82,:88-103).
Revert probe on this head: replacing the hasher update with a discard gives
test result: FAILED. 3 passed; 1 failed; 747 filtered out—
a_verdict_earned_by_one_peer_is_not_served_to_anotherpanics atbond_verify.rs:751with
left: Some(Bonded) / right: None. Four tests ran, so it is a real red, not a filter matching nothing.
Restored. - Round-2 fix, SPEC no longer codifies a peer-agnostic key — LANDED. Section 25.6a states
(coin id, store, root, epoch, claiming peer id)and why each component is load-bearing. - Round-2 fix,
verdict_forshort-circuits before the chain read — LANDED.bond_verify.rs:306,
ahead ofchain_bond_verdict_and_coin; the asyncverifyrepeats it at:484before the cache probe,
epoch read and requirement parse. - The gate lifts itself — no flag, no second edit.
declaration_source_is_readable()
(bond_verify.rs:167) probes through the realpeer_declaration. No env var anywhere in the diff.
no_visible_term_promotes_a_claim_before_the_typed_accessor_exists(bond_verify.rs:804) asserts
assert_ne!(..., DeclaresThisPeer)over four term sets INCLUDING the matchingdig-peer:{peer}, so any
accessor that starts answering that term genuinely FAILS it. A tripwire, not a tautology. - Credit-only ranking — a discriminating test, not an assertion.
a_bogus_pointer_leaves_an_honest_holder_exactly_where_no_pointer_would
(crates/dig-node-core/src/mirror_bond.rs:400) runs the same slate twice, with and without the bogus
pointers, and compares orders. A three-tier lattice fails it.BondVerdictnot derivingOrdmakes a
future sort on the verdict a compile error. MAX_VERIFIED_PER_LOCATE = 8bounds the ARTIFACT.a_locate_reads_at_most_the_budget_off_the_chain
(mirror_bond.rs:438) drives the realBondRankingLocatorwith a 40-record slate and reads the
verifier's own call counter: 8 reads, all 40 records still returned.- Step order —
an_uncensused_node_still_catches_the_lie_but_will_not_certify_the_truthcovers it:
the binding is checked before collateral magnitude. - Wiring is real.
BondRankingLocatoris installed insideNodeContent::new(download.rs:1295)
outside every other locator, and the engine test drives the real constructor. - Suite:
cargo test -p dig-node-service --test mirror_bond_verifygives
10 passed; 0 failed; 0 filtered out. - 2.4b: holding
chia-*at the 0.36 line is right here —dig-mirror-coin 0.7andchia-query
compile against it, and moving chia in this crate alone would ship it split across two chia lines, the
exact defect 2.4b exists to prevent.dig-*are at latest published.
GATING
G1 — SPEC.md:8284 reports a capability the running node does not have. The status bullet says
"section 25.10's verification of OTHER peers' claims is implemented", and 25.6a is written in
normative present tense ("A node that LOCATES a holder verifies that holder's claimed bond and promotes a
proven one"). The vacuity appears only as a CONDITIONAL later ("While the node has no sound source for the
coin-to-peer binding..."), and nothing in SPEC.md says that condition IS the current state. A reader
cannot distinguish satisfied from vacuously satisfied — precisely what CLAUDE.md 2.0 forbids: a spec that
cannot tell the two apart reports a capability the system does not have. The PR body states it superbly;
the PR body is not the durable record.
Fix: one sentence in the 25.10 status bullet — the coin-to-peer binding has no source on a shipped node
today, bonded is therefore unreachable, no chain is read, and dig-node#473 owns the activation. Do not
weaken 25.6a's normative clauses; they are correct as the contract.
G2 — Closes #466 closes a ticket whose stated acceptance is not true of a running node. #466 asks
that a peer advertising a bond it does not hold is "detected on a production path". On this head it is
not: verdict_for returns Unverified before any chain read on every input. Your reasoning for testing
at chain_bond_verdict is sound — through verdict_for every acceptance test would return Unverified
and be indistinguishable from a broken verifier — but the consequence is that the acceptance is proven of
the mechanism and its wiring, one level below where production stops. Merging as-is records collateral
enforcement as delivered when it is not, and that mis-statement outlives the PR.
Fix, either is fine: (a) drop Closes #466 and leave it open with #473 as its blocker, or (b) keep it and
in the SAME unit of work comment on #466 re-scoping its acceptance explicitly to mechanism + wiring, with
#473 named as owner of the runtime half. The closure is immediate on merge, so not "later".
ADVISORY — not blocking
crates/dig-node-core/src/mirror_bond.rs:1-45— the module a reimplementer opens first never mentions
thatBondedis unreachable on a shipped node.bond_verify.rssays it plainly; this is the public
crate and does not.crates/dig-node-core/src/download.rs— the engine test is named..._ranks_a_disproven_bond_last_...
and its final assertion message says a disproven claim is "demoted", both contradicting the module's
central property that nothing is ever demoted. The record is last because it was located last. That test
also does not discriminate credit-only from a three-tier lattice (both yield 9,7,8);mirror_bond.rs:400
is what carries that property. Rename, so a later reader does not "fix" the lattice toward the name.chain_bond_verdictispubwith no production caller. Justified in its doc and correct given G2 —
noted so a dead-code sweep does not delete a deliberately-exposed seam.crates/dig-node-core/src/peer.rs:858— the dig-gossip pinning note is correct and useful but outside
#466's blast radius. No objection; recording that I read it as deliberate.
Not merged, not undrafted.
loop-security — IN PROGRESS, not the verdict (round 3, part 3)Head 2. Amplification, re-measured — TODAY it is ZERO; post-activation it is 16 reads + 8 CLVM runsToday, per admitted request token: zero outbound reads, on every input. Two independent gates:
Asserted, not inferred:
Post-activation worst case per locate, which the activation lane must own:
The reads go to One cost the current analysis does not name. Bounding upstream is real and worth recording: 3. Cache as a weapon — CLEAR on correctness, one bounded thrash
Minor (advisory, post-activation). Still open: the inert-state trap judgement and the revert probes. Verdict next. |
loop-decider (adversarial) — VERDICT: MERGE WITHOUT
|
| run | result |
|---|---|
cargo test -p dig-node-service --test mirror_bond_verify |
10 tests, 10 passed, 0 filtered out |
cargo test -p dig-node-service --lib mirror::bond_verify |
4 tests, 4 passed, 747 filtered |
cargo test -p dig-node-core --lib mirror_bond |
8 tests, 8 passed, 1051 filtered |
cargo test -p dig-node-core --lib the_engine_ranks_a_disproven_bond_last |
1 test, 1 passed, 1058 filtered |
Claim 1 — "shipping an inert layer is the right call" — STANDS as an engineering call, REFUTED as a reason to close #466
The premise is measured and true. index.crates.io/di/g-/dig-mirror-coin lists 0.3.1 / 0.4.0 /
0.5.0 / 0.7.0 — no 0.8.0. peer_declaration (bond_verify.rs:143) returns NotReadable
unconditionally, declaration_source_is_readable() (:174) probes through it, and both
verdict_for (:206) and ChainBondVerifier::verify (:482) short-circuit on it before any cache
probe, epoch read, disk read or chain read.
What I attacked and could not break:
- "Dead code that will rot." Not dormant in the dormant-code sense — the layer IS installed in
production (NodeContent::new,download.rs:1299-1301, reached fromfor_dhtat:1523), and
the ranking is exercised by 8 core tests through the realBondRankingLocator. Only the chain
decision is short-circuited. Rot needs unexercised code; this is exercised. - "A trap for the maintainer who flips one function body." The case I most expected to win, and it
fails.no_visible_term_promotes_a_claim_before_the_typed_accessor_exists(bond_verify.rs:804)
puts a well-formeddig-peer:<64hex>term in its fixture, so it FAILS the moment the body is
replaced. And the gate is the condition itself:declaration_source_is_readablecalls the
production function on the most favourable input, so there is no second switch to forget. - "The SPEC will be born false." It is not. §25.6a states the posture normatively and
permanently — "a node that cannot read such a declaration MUST NOT promote" — which stays true
after activation. No born-false claim found.
Where it is refuted: the closing keyword.
#466's acceptance is "A peer advertising a bond it does not hold is detected on a production
path", and its scope says outright "the acceptance is a call site, not a function." On a
production path today verdict_for returns Unverified for every input, including a peer
advertising a bond it does not hold. The PR body concedes it in its own table ("it detects a false
claim on a real node — no") and states chain_bond_verdict "is pub for exactly this reason and
has no production caller."
Closes #466 would therefore close a ticket on an acceptance criterion that is measurably unmet, by
that ticket's own standard — the vacuous-conformance class: a closed ticket asserting a capability
the node does not have.
The opposite failure is real and I weighed it. The branch is correct, inert, specced and tested;
holding it open through more main merges costs rebase churn, a re-bump (a --onto rebase silently
DROPS a colliding version bump) and further gate rounds on a diff that would not change. That is why
the answer is merge, not hold — the fix is one keyword, not a branch-lifetime decision.
One overclaim to correct, non-gating. "it costs anything today — no, zero, on every input" is
not literally true: per record carrying a pointer, declaration_source_is_readable runs a
"00".repeat(32) and a format! (two String allocations), and BondRankingLocator builds a Vec
and runs a stable sort on every locate. Zero chain reads, zero disk reads, no reordering in effect —
but not zero work. Say "no chain read, no disk read, no reordering" and it is true.
Claim 2 — "credit-only ranking makes hearsay safe to act on" — STANDS
I attacked this hardest and could not produce a path where attacker-supplied input leaves an honest
holder worse off than the no-pointer baseline.
Structural argument, verified in code. credit_rank (mirror_bond.rs:130) is a function of one
record's own verdict; the sort is sort_by_key, STABLE (:203). Unverified and Unbonded share
rank 1. The only cross-record coupling is the MAX_VERIFIED_PER_LOCATE = 8 budget
(:172-179), and skipping a record can only move it from rank 0 to rank 1 — it can only withhold
promotion, never impose a rank below baseline. An honest holder can be denied credit; it can never be
pushed below a non-promoted peer.
The budget attack I built, and why it is not a refutation. Post-activation, an attacker answering
one lookup (stop_on_providers = true makes one answer the whole slate) places 8 pointer-carrying
records ahead of an honest bonded holder; the budget is exhausted and the honest holder is never
verified, so never promoted. That is a free, remote credit-denial primitive, and it means the
mechanism offers no guarantee against precisely the adversary it was built for. But it sits inside
the claimed model, and the honest holder's absolute standing is unchanged. A capability gap, not a
harm. Worth stating on #473 so activation does not inherit a defence that is trivially deniable.
Cache eviction. remember (:249) evicts ONE arbitrary entry via entries.keys().next(), not
clear() — the earlier round's amplifier is gone. The victim is not attacker-selectable: HashMap's
RandomState is seeded per map. Unverified is never cached (:250), so an outage cannot be
latched.
Mutation probe — the property is load-bearing, not decorative. I replaced credit_rank's
collapsed arm with a three-tier lattice (Unbonded => 2):
running 8 tests
test mirror_bond::tests::an_unreachable_chain_is_unverified_not_unbonded ... FAILED
test mirror_bond::tests::a_bogus_pointer_leaves_an_honest_holder_exactly_where_no_pointer_would ... FAILED
test result: FAILED. 6 passed; 2 failed; 0 ignored; 1051 filtered out
Mutation reverted; the worktree is clean.
Claim 3 — "the algorithm is SYSTEM.md's, not invented" — STANDS
I traced every value reaching a comparison. No claimant-supplied value reaches one.
store_launcher_id/root_hashcome frombondable_tuple(content)(bond_verify.rs:419) — the
ContentIdthe caller asked about, not the record.epochcomes fromsettled_epoch()(:436), this node's own clock.ownerisself.inner.proof.parent_inner_puzzle_hash(dig-mirror-coin-0.7.0/src/coin.rs:93) —
the lineage proof, never a memo.- The only claimant-supplied value is
claimed_coin_id, used solely as a lookup key and then bound
bycandidate(coin_id), which matches the child by a coin id hashing parent_id, puzzle hash and
amount.read_parent_outputsadditionally rejects a substituted puzzle reveal by tree-hashing it
againstcreating_spend.coin.puzzle_hash. advertises(coin.rs:150) isself.declared == asked && self.namespace_hint == mirror_hint(...)
— exact equality on both halves, with no arithmetic recompute substituted for the binding.
The freely-chosen-epoch attack, worked through.
a_freely_chosen_epoch_solves_onto_any_other_advertisements_hint (namespace.rs:198) proves the
hint alone is forgeable: the epoch term absorbs the difference between two advertisements. But check
1 pins declared.epoch == asked epoch, and the asked epoch is this node's local settled epoch (a
small integer) while the solved epoch is a ~256-bit-scale value. The collision cannot survive check
- Both halves are genuinely non-redundant, and the prior round's mutation dropping the hint half
already faileda_declaration_that_disagrees_with_its_own_hint_is_unbonded, still present and green
at this head.
One thing that looks like a hole and is not. An attacker CAN mint a real, fully collateralised
coin advertising someone else's (store, root) at the current epoch under its own owner, and it
passes all four steps. That is not an attack — mirroring is permissionless and the attacker paid real
$DIG. The residual is coin-is-not-bearer, which is exactly what peer_declaration / #473 closes and
what the short-circuit currently refuses to guess at.
Claim 4 — "the tests are load-bearing" — STANDS, with one stale test that must not be relied on
The concern is real but lands on the wrong file. The split:
- The chain tests (
tests/mirror_bond_verify.rs, 10) do sit belowverdict_for's short-circuit,
callingchain_bond_verdict. That is stated in the PR rather than hidden, and it is the only level
at which the four steps are observable today. Not vacuous — the prior round mutated two of them red. - The ranking tests (
dig-node-core/src/mirror_bond.rs, 8) drive the real
BondRankingLocatorwith a mock verifier, so they run at the production level and are NOT
short-circuited. My mutation broke two of them, which settles the question. a_coin_that_passes_every_chain_check_is_still_not_promoted_to_a_claimant
(tests/mirror_bond_verify.rs:384) asserts BOTH halves on ONE fixture —chain_bond_verdictsays
Bonded,verdict_forsaysUnverified— so the second assertion cannot be satisfied by a broken
chain. Good construction.
Finding (non-gating): the_engine_ranks_a_disproven_bond_last_on_its_own_discovery_path
(download.rs:4452) is insensitive to the security property its name asserts. Under my three-tier
mutation it still PASSED (running 1 test ... 1 passed), because with input order 7,8,9 and
expected 9,7,8 a three-tier lattice yields the identical answer. Its name says "ranks a disproven
bond last" and its second assertion message says "a disproven claim is demoted on the redirect
path" — both stale wording from the pre-credit-only design, and both contradict the FIRST assertion
message on the same test. The job it really does (proving the layer is wired into the production
constructor) it does do; I confirmed for_dht (download.rs:1523) calls Self::new, which installs
BondRankingLocator outermost. Rename it and fix the second assertion message, or a future
reader will believe credit-only is covered there when it is covered only in mirror_bond.rs.
Carried forward, unchanged and non-gating: spawn_bond_verifier_install (bond_verify.rs:517) gives
up after 120 s with only a tracing::debug! and has no test. Harmless while the layer is inert; it
becomes a silent no-verifier failure the moment #473 activates it, so it belongs on #473's checklist.
Recommendation: MERGE WITHOUT Closes #466
The code is correct, genuinely inert, normatively specced, and its two security properties
(credit-only ranking; both halves of advertises) are proven load-bearing by mutation. Holding the
branch open buys nothing and risks a silently dropped version bump on the next rebase.
But #466's acceptance — detection on a production path — is not met, and the PR body says so
itself. Before merge:
- Change
Closes #466toRefs #466. This is the gating item. - Put the activation obligations on Nothing binds a mirror coin's owner to a DHT peer id — verification cannot name a claimant #473 so they cannot land separately: replace
peer_declaration's body, keep the claiming peer inVerdictKey, add the authoritative-record
restriction, re-size and re-verify the miss-lookup budget against the now-live chain reads, note
the budget-exhaustion credit-denial above, and testspawn_bond_verifier_install. Nothing binds a mirror coin's owner to a DHT peer id — verification cannot name a claimant #473 closes
both tickets. - Optional and cheap, in this PR: fix the two stale strings on
the_engine_ranks_a_disproven_bond_last_on_its_own_discovery_path, and soften the body's "costs
zero on every input" to "no chain read, no disk read, no reordering".
Items 2 and 3 are hygiene. Item 1 is the one that decides the gate.
loop-security — IN PROGRESS, not the verdict (round 3, part 4)Head 5. The inert state — genuinely zero-risk today, and the guard rails are SUFFICIENTConfirmed inert on two independent axes:
The trap question is the right one to ask, and I judge the rails adequate. Four mechanisms, each
The three obligations are recorded in two places, which is what makes them survive the squash: The one gap I do want to name — SPEC.md, and it is cheap to close in this PR
The "therefore" does not follow, and the requirement it states does not close the attack it just
This is advisory, not gating — nothing is promoted today, so nothing is exploitable — but the fix Revert probes running now; verdict follows. |
loop-security: PASSAudited head: No gating security defect. Round 1's two HIGH findings and round 2's F1 are all closed in code, The three remedies are revert-proven, not merely presentEach mutation applied to exactly one occurrence (the helper refuses on any other count), tested, then
Baselines, with counts read rather than exit statuses: Area by areaHearsay cannot subtract credit. Amplification is zero today, on every input. Two independent gates — Cache. Peer-scoped by Hint equality. Failure direction. Every one of eleven error and unknown paths lands on Code hygiene. No Inert state. Genuinely zero-risk, and the guard rails are sufficient: a self-lifting probe that Advisory — none of these gate this mergeA1. A2. Budget exhaustion is a promotion-denial primitive (post-activation). A3. The cost analysis omits the CPU term (post-activation). A4. Post-activation worst case, for the activation lane's record: per locate, 8 records times A5. The verdict cache never purges expired entries (minor). A6. What I did NOT cover
Read-only throughout: no edit to any tracked file, no merge, no undraft. The PR remains a draft, which |
Gate round 3 findings, all text or test-quality; no behaviour changes. - SPEC.md status bullet said verification of other peers' claims 'is implemented' with no way for a reader to tell satisfied from vacuously satisfied. It now states that no claim is verified on a running node, and names dig-node#473 as what makes promotion reachable. - SPEC.md 25.6a described the address-substitution attack and then gave a remedy that does not close it: the coin's declaration binds coin to peer_id, but the attacker's record already carries the HONEST peer id, so the check passes and attacker addresses are promoted on somebody else's bond. Adds the authoritative-record MUST that does close it, and names the residual an unrestricted implementation carries. - mirror_bond's module doc now leads with the inertness rather than leaving a reader to infer it, and no longer implies a locate is free of all work while inert -- it is free of chain and disk I/O and of any reordering, not of allocation or the sort. - the engine acceptance test could not tell credit-only from a three-tier lattice: with the slate ordered 7,8,9 both answer 9,7,8. The disproven record now precedes the merely-unverified one, so credit-only answers 9,8,7 and a three-tier lattice answers 9,7,8. Its name and one message asserted the holder was 'demoted', which is the opposite of the property the module guarantees. Refs #466
51c3c7d to
32864a2
Compare
# Conflicts: # Cargo.lock # Cargo.toml
Heads-up: this branch's version now COLLIDES with
|
Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
loop-reviewer — VERDICT: PASS
Audited head: 90311cf35e128b69cb7cb2e3a658f53379357a76 (resolved from the remote; PR is draft,
all checks SUCCESS, 0 unresolved threads). Scoped re-gate of the 20:54Z CHANGES-REQUIRED review from
11b3dd2e. Read-only in C:/tmp/worktrees/dn-verify; no shared checkout mutated, no commits.
gitnexus is ~300 commits stale for this repo (returns a false-safe impactedCount: 0), so blast
radius was done with grep + direct read.
1. Prior findings — fixed in code/text, not merely resolved
- G1 (SPEC reported a capability the node lacks) — FIXED.
SPEC.md:8335-8343now reads
"§25.10's verification of OTHER peers' claims is BUILT BUT INERT — no claim is verified on a
running node today", statesbondedis unreachable, that the chain read is short-circuited
before it is paid, that the ranking is a no-op on every slate, warns "A reader must not take this
bullet as saying collateral is enforced; it is not", and names dig-node#473. §25.6a's normative
clauses are untouched, as directed. This distinguishes satisfied from vacuously satisfied. - G2 (
Closes #466) — NOT fixed at this head. See the blocking precondition below. - Round-2 fixes still present and load-bearing:
VerdictKey.claiming_peer(bond_verify.rs:83,
hashed at:95-101); the self-lifting short-circuitdeclaration_source_is_readable()at
bond_verify.rs:167, applied at:306(sync) and:484(async) ahead of the chain read.
2. Delta 11b3dd2e..90311cf3 — behaviour-preserving
Three lane commits plus two merges of origin/main. The only lane-authored content commit is
32864a2e, and it touches exactly SPEC.md, crates/dig-node-core/src/mirror_bond.rs (module doc
only) and crates/dig-node-core/src/download.rs (test-only: renames
the_engine_ranks_a_disproven_bond_last... → ..._promotes_a_proven_bond_without_sinking_a_disproven_one
and reorders the fixture slate so credit-only answers 9,8,7 while a three-tier lattice answers 9,7,8 —
the test now discriminates where it previously did not). No production behaviour change, so the
loop-security PASS from 11b3dd2e carries over. Everything else in the diffstat
(module_stream.rs, profile_sync.rs, capsule_serve_peak_memory.rs, control.rs, …) arrived via
the merges of #487/#488 from main.
3. Version — OK
Cargo.toml on disk is 0.236.0; origin/main is 0.235.0. Strictly greater.
4. Tests — real counts, not a filter matching nothing
cargo test -p dig-node-service --test mirror_bond_verify→10 passed; 0 failed; 0 filtered out.cargo test -p dig-node-core --lib mirror_bond→8 passed; 0 failed; 1065 filtered out.
Both ran the expected number of tests.
BLOCKING MERGE PRECONDITION — the PR body still closes #466
The body line 1 says Refs #466 -- deliberately NOT a closing keyword, but the last line of the
body is Closes #466, and GraphQL closingIssuesReferences.totalCount is 1. Merging as-is
closes #466 and records collateral enforcement as delivered when verdict_for returns Unverified
on every input — exactly the mis-statement G2 was raised about, and exactly what the loop-decider
verdict ("merge WITHOUT Closes #466") forbids.
This is a body edit, which this gate does not make and the orchestrator owns. Delete the trailing
Closes #466 and re-assert totalCount == 0 before merging. The code verdict is PASS; this
precondition is not optional.
loop-reviewer — VERDICT: PASSAudited head: 1. Prior findings — fixed in code/text, not merely resolved
2. Delta
|
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>
…to 0.249.0 Brings origin/main (0.245.0, including #467, #489, #492, #497) onto the branch and sets the workspace version to the pre-assigned 0.249.0. Conflicts and how they were resolved: - `Cargo.toml` — a pure version collision (branch 0.242.0 vs main 0.245.0). Every other main-side hunk was already applied by the auto-merge; the only difference from `origin/main` in this file is the version line, now 0.249.0. - `Cargo.lock` — taken wholesale from `origin/main`, then re-locked with `cargo update -w`, which re-points the two workspace members whose manifests moved (`dig-node-service` 0.245.0 -> 0.249.0, `dig-wallet` 0.47.0 -> 0.48.0). Nothing in the tree still reads 0.242.0. - `crates/dig-wallet/src/sage/rpc.rs` — reported as a conflict by an earlier attempt; on this merge git resolved it textually because the two sides touch disjoint regions of the file. The result was read against BOTH parents rather than accepted on git's word: * MAIN's hunks are intact. `is_definitive_rejection` keeps the #497 narrowing — a refusal frees inputs only when its stated reason is bundle-intrinsic (`super::chain::refusal_is_bundle_intrinsic`), with a HOLD default — and the #492 doc block stating that `synced` is a CURRENCY test computed independently of the routing tier, so `{source: "db", synced: false}` is a reachable state. * THE BRANCH's hunk is intact. `replica_answer_is_current` still delegates to `sync_supervisor::FollowingEvidence::measure`, which withholds the evidence when EITHER the replica or the peer height is unmeasured, so a `synced` phase cannot be emitted without the peak height that bounds it (#495). * No rival implementation survives the merge. The pre-#495 `is_following` predicate is gone from the tree; `FollowingEvidence` is the single producer consumed by both the money reads (`rpc.rs:1085`) and the status endpoint (`sync_supervisor.rs:490`), which is what makes the `{phase: "synced", peak_height: null}` pairing unrepresentable rather than merely unlikely. No behaviour was chosen over the other side: both guards are load-bearing on different questions — one on whether a refusal may free inputs, the other on whether a currency claim may be made at all. dig-wallet: 772 passed, 0 failed, 1 ignored. dig-node-service: 774 passed, 0 failed. Note: `cargo test` on this Windows host needs RUST_MIN_STACK raised (default hits a rustc STATUS_STACK_BUFFER_OVERRUN ICE while encoding dig-node-service metadata) — an environment limit, not a code fault. Co-Authored-By: Claude <noreply@anthropic.com>
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>
Refs #466 -- deliberately NOT a closing keyword. See "Why #466 stays open" below.
Verifies a peer's claimed mirror-coin bond against chain, and acts on the verdict by RANKING the
holder set — the first consumer of
MirrorCoin::advertisesoutsidedig-mirror-coinitself.Read this first: the layer is INERT today, by construction, and that is deliberate
Bondedis unreachable on a real node right now, so this PR changes no observable behaviour.It is not a flag, a TODO, or an oversight — it is the only safe posture until
#473 lands, and the code is built so that resolving
473 turns it on with no second change here.
NodeContent::new)verdict_forreturnsUnverifiedbefore any chain readWhy: a coin proves that a bond exists, never that the claimant holds it. Promoting on the chain
half alone is the HIGH finding from the first security round — a stranger reads an honest holder's
coin id from the DHT (it is published in cleartext by design) and republishes it to rank first at
zero collateral. Nothing in this repo can currently bind a mirror coin's owner to a DHT peer id, so
the layer withholds credit from everyone rather than granting it on a check it cannot make.
declaration_source_is_readable()probes through the realpeer_declaration, so the momentdig-mirror-coin0.8.0 exposes the typeddig-peer:accessor and that body is replaced, bothshort-circuits lift automatically. No flag to remember, and
no_visible_term_promotes_a_claim_before_the_typed_accessor_existsFAILS when it happens, so theobligations that must ship in that same change cannot be forgotten.
What it does
crates/dig-node-service/src/mirror/bond_verify.rs) —SYSTEM.md's algorithm,not an invented one: coin record at
mirror_coin_puzzle_hash(), re-derived from its creatingspend so asset id / amount / owner all come from executed on-chain code, then
MirrorCoin::advertisesfor exact equality on the memo-declared triple with the hintrecomputed from the coin's own lineage proof. Never an arithmetic recompute of the morph alone —
mirror_hintsums four terms including an unbounded freely-chosenepoch, so an author cansolve onto another's hint.
crates/dig-node-core/src/mirror_bond.rs) —BondVerdictandBondRankingLocator, wrapped outside every other locator layer inNodeContent::new, so themulti-source download, the redirect-on-miss hint and the capsule warm inherit one ranking.
Credit-only ranking — a stranger can never demote an honest peer
Two tiers, not three.
Bondedpromotes; absent /Unverified/Unbondedcollapse to ONE baselinetier with source order preserved (
sort_by_keyis stable).BondVerdictdeliberately does notderive
Ord, socredit_rankis the sole ordering and a future.sort()on the verdict cannotcompile.
This is what makes hearsay safe to act on. A provider record is attributed by nobody —
dig-dhtsays so itself — so a bogus coin id can be attached to an honest holder's peer id by any peer that
answers a lookup. Under a three-tier lattice that sinks the honest node to last on every read.
Credit-only means an unverified or disproven pointer can never rank a peer below where no pointer
would have put it. Hearsay can withhold credit; it can never subtract it.
Bounded work
MAX_VERIFIED_PER_LOCATE = 8, enforced in the production loop and asserted on the artifact by atest that drives the real
BondRankingLocatorwith a 40-record slate and reads the counter.(coin id, store, root, epoch, claiming peer id)— the claimant is part ofthe key because the verdict depends on it; a peer-agnostic key would serve one holder's earned
Bondedto a stranger republishing the same public coin id.clear()— clearing hands a stranger a cheap way to discardevery honest verdict this node has earned by rotating coin ids.
Unverifiedrecords a momentary inability to look and holdingit would keep an outage in force after it ended.
Where the acceptance tests sit, and why it matters
The acceptance tests call
chain_bond_verdict, one level belowverdict_for's short-circuit.Through
verdict_fortoday every one of them would returnUnverifiedand be indistinguishablefrom a broken verifier, so the control would be meaningless. This is stated rather than hidden: it
means the ticket's "detected on a production path" is proven of the mechanism and of its wiring, and
is not yet true of a running node. That gap is #473 and nothing else.
chain_bond_verdictispubfor exactly this reason and has no production caller.Relationship to #473 — distinct defects, strictly sequenced
Measured, not assumed:
dig-mirror-coinlatest published is 0.7.0; there is no 0.8.0 and no PRin flight for the accessor.
They are not the same defect and this is not duplicated work, but #466 delivers nothing observable
until #473 lands. Sequence #473 next or this ships as a correct, free, silent no-op.
Why #466 stays open
#466's acceptance is that a peer advertising a bond it does not hold is detected on a production
path, and its scope says "the acceptance is a call site, not a function." On a production path
today
verdict_forreturnsUnverifiedfor every input, so no such peer is detected. The mechanismand its wiring are delivered; the detection is not.
Re-scoping the ticket to match what was built was the alternative, and it is the wrong one — that
acceptance was worded specifically to forbid shipping an unreachable verifier, so rewriting it after
the fact would defeat its own guard. #466 therefore stays OPEN with one exact, checkable resume
condition: #473 lands,
peer_declarationgains a body, and the existing tests go green atverdict_forlevel rather than atchain_bond_verdictlevel. #473 closes both.The adversarial gate reached this independently and gated on it; the correctness gate offered it as
one of two remedies. This is the one taken.
Evidence
Test counts are stated because a filter matching nothing exits 0 printing
running 0 tests.See the comment thread for the per-run counts and the revert probes on this head.
§2.4b dependencies
dig-*at latest published (dig-sex 0.5,dig-mirror-coin 0.7,dig-nat 0.21,dig-identity 0.7.1,dig-constants 0.13.0).chia-*deliberately held at the0.36.1/ sdk0.36.0line:dig-mirror-coin 0.7andchia-querycompile against it, and moving chia here alonewould ship this crate split across two chia lines — the exact defect 2.4b exists to prevent. The
0.48 uplift is a release-first cascade owned upstream.
2026-09-02T01:45Z orchestrator: removed the trailing
Closes #466per the loop-decider verdict (acceptance "detected on a production path" is unmet while the layer is inert); #466 stays open and tracks activation.