Skip to content

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

Draft
MichaelTaylor3d wants to merge 23 commits into
mainfrom
loop/473-peer-binding
Draft

feat(mirror): activate bond promotion on the coin's own peer declaration#501
MichaelTaylor3d wants to merge 23 commits into
mainfrom
loop/473-peer-binding

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — triple gate pending. DRAFT until the reviewer + security + adversarial round returns.

Activates the mirror-bond verifier that #467 shipped deliberately inert, and supplies the binding
that makes promotion meaningful.

The binding

dig-mirror-coin 0.9.0 lets a coin's owner declare dig-peer:<64-hex> in the memo tail. Only the
owner's key can produce the spend that writes a memo, so the term is an owner attestation carried by
executed on-chain code — no new authority, no new key, no wire change. The accessor is TYPED and
lives in that crate; a second parser here would make a divergence a silent authorization difference
rather than a compile error.

Promotion now requires both bindings: coin to content (MirrorCoin::advertises) and coin to
peer id
(the declaration). PeerDeclaration::NotReadable is removed — it described a situation
that no longer exists.

The address-substitution question, resolved by measurement

The declaration binds coin to peer id, never peer id to address, so a record carrying an honest
holder's peer id, that holder's real coin id and an attacker's addresses is promoted here.

SPEC.md §25.6a required closing that with an authoritative-record restriction, because "a dialler
is not by itself a backstop"
. That premise was 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; dig-peer re-checks after
connect; and fetched content is merkle-verified against the caller's own requested root regardless.
The narrow fact behind the old claim — dig-gossip's legacy rustls outbound does not pin, and every
expected_peer_id there is #[cfg(test)] — is true, but dig-node never dials on it.

The restriction as written is also not implementable at the ranking layer: dig-dht keeps
attributed and hearsay records in two stores deliberately, then flattens them into one untagged list
in merge_dedup_by_provider, and a locator restricted to attributed records would return almost
nothing because that store covers keys this node is k-closest to rather than content it fetches.

What this layer owes instead is a bound, and it is here: at most one record promoted per claimed
peer id. Keyed on the peer's IDENTITY, not the text — the adversarial gate showed a string key is
defeated at zero cost by respelling one honest peer id in eight hex cases.

Honest limits

  • A node with no censused requirement for the epoch promotes nobody, because it cannot price a
    bond. Detection of a false claim still works there (the binding is checked first, deliberately);
    certification does not. Stated in SPEC.md §25.6a's table.
  • No coin on chain carries a declaration yet. The format is new; a coin acquires one when its
    owner recreates it at an epoch boundary. Until then that holder is unverified and sits at
    baseline — a stated degradation, and no honest holder ranks below where no verifier at all would
    put it.
  • dig-node's threading of its own peer id into create has no integration test of its own; the
    format round-trip is covered in dig-mirror-coin (create → chain → discoverdeclares_peer).

Also corrected

Two stale normative claims that would mislead a later reader: verdict_for's doc still said nothing
was promoted and no chain was read, and peer.rs:2475 was an uncorrected twin of the dialler claim
fixed at :863. Pre-existing wire-level gap filed as DIG-Network/dig-dht#27.

Closes #466


Round-3 lane (orchestrator e93b41) — what changed and what was measured

Blast radius checked

gitnexus was not usable for this lane (the registered dig-node index is ~300 commits behind the primary checkout and a stale index answers impactedCount: 0, risk: UNKNOWN rather than erroring), so the radius below was established by grep + direct read across the worktree and the vendored crate sources, and is stated as such.

  • verdict_for / chain_bond_verdict_and_coin / peer_declaration — callers are admitted_verdict_for and ChainBondVerifier::verify inside this module, plus tests/mirror_bond_verify.rs. No consumer outside dig-node-service.
  • BondRankingLocator::find_providers (dig-node-core/src/mirror_bond.rs) — the single wrapping point for every production consumer of a provider record (multi-source fetch, redirect-on-miss hint, capsule warm), which is why the verdict is applied at the locator rather than the download executor.
  • The six corrected string literals are operator-facing log text with no programmatic reader; grepped for any test or tool matching on their content — none.

Findings discharged

finding outcome
loop-security HIGH — a cheap inbound token becomes unbounded outbound HTTPS Bounded. ReadAdmission is consulted before the source is touched, so a refused claim reads nothing. A process-wide token bucket bounds total egress across self-minted identities; a per-claimant distinct-unproven-coin ledger bounds fabricated coin ids. Both decided under one lock so a token is never spent on a claim the ledger was about to refuse.
dig-node#513 finding 2 — Unverified is never memoised Bounded by the ledger instead, deliberately. VerdictCache::remember refuses Unverified on purpose: it records this node's own momentary inability to look, so caching it would hold an outage in force after it ended. The amplification a memo would have absorbed is bounded for every claimant by the ledger, not only for repeats of one coin id.
loop-reviewer finding 2 — corrupted operator log text Fixed, and wider than reported (c3a0522a). The review named lifecycle.rs; the defect was in advertise.rs (3), pass.rs (2) and runner.rs (1) and not in lifecycle.rs. A lost \-continuation had baked 14-18 spaces of source indentation into six sentences an operator reads.
loop-reviewer finding 3 — no test through verdict_for Covered, and proved load-bearing — see below.
mirror_bond.rs early-continue Documented in place: a holder that claims nothing, and every record past MAX_VERIFIED_PER_LOCATE, keeps its place at baseline with no chain read.
the dig-tls peer_id pin Verified at source; it holds — see below.
dig-node#513 items 1 and 5 Cannot land here. Both live in crates/dig-wallet/src/sage/corroborated_source.rs, a file #506 creates that does not exist on this branch. Referenced, not closed.

The promotion test is load-bearing (revert-proof, run)

tests/mirror_bond_verify.rs::only_a_coin_that_declares_the_claimant_promotes_it drives the real verdict_for against coins built from genuine CAT spends, not a hand-written CoinRecord. The pre-existing unit test sits below the decision at peer_declaration and would pass under a verdict_for that ignored it entirely.

Proved by mutation rather than asserted: replacing the final match with an unconditional BondVerdict::Bonded (the exact "some coin bonds this content" weakening the finding names) turns 2 tests redonly_a_coin_that_declares_the_claimant_promotes_it and a_coin_that_passes_every_chain_check_is_still_not_promoted_to_a_claimant. Committed before mutating and restored from a file copy.

Because a behavioural test fails on that exact mutation, the requested include_str! source-text guard on the promotion decision is not added: it would be a strictly weaker duplicate, and this file's own docs already record the "needle so loose it matches anything" hazard.

The dig-tls pin — promotion soundness rests on it, so it was checked at source

The pin is an Option, so the real question was whether dig-node's dial ever passes None:

  • dig-tls 0.4.0 src/verify.rs:112-117pin_and_bind rejects with peer_id mismatch: expected {expected}, got {derived}.
  • dig-nat 0.21.0 src/dialer.rs:197dig_tls::client_config_spki_pinned(&self.node, Some(peer.peer_id), ...); src/dialer.rs:194 records that the dialer ALWAYS pins.
  • dig-nat 0.21.0 src/peer.rs:34PeerTarget::peer_id: PeerId, not an Option, so a caller cannot omit it. dig-node's dial sites construct through PeerTarget::with_addrs (capsule_resolver.rs:264, neighbourhood_probe.rs:249).

The claim in bond_verify.rs is sound and promotion may stay enabled.

§2.4b dependency sweep

Every dig-* dependency of both touched crates is already at its latest published version (checked against index.crates.io with the required User-Agent), including dig-mirror-coin 0.9.0, dig-nat 0.21.0 and dig-tls 0.4.0. Two things worth stating rather than silently doing:

  • The chia-* set is ceilinged, not stale. chia-protocol/chia-bls/chia-sha2/chia-traits publish 0.48.0, but chia-sdk-driver, chia-sdk-types and chia-sdk-utils top out at 0.36.0. Moving the loose crates alone is exactly the split-across-two-chia-lines defect this repo has shipped twice, so the set stays on 0.36.x and moves when the sdk crates do.
  • dig-ipc-protocol is pinned =0.3.0 and 0.3.1 is published. An exact pin on a protocol crate is a contract decision, not drift; changing it inside a custody-adjacent PR is the wrong place for it. Reported, not touched.

Build and test evidence

  • cargo check --workspace --all-targets — exit 0 on the salvage commit.
  • cargo test -p dig-node-service --lib mirror168 passed, 0 failed (619 filtered out).
  • cargo test -p dig-node-service --test mirror_bond_verify11 passed, 0 failed, 0 filtered out.
  • cargo fmt --all — the salvage commit had never been formatted; two over-width lines corrected in ef2cf4c7. CI's fmt gate would have been red without it.

Version 0.253.0 (feat), set above the in-flight 0.252.x merge chain.


Round-4 lane (orchestrator dfc93a) — salvage recovery, commitlint, and the #473 scope correction

Closes #473 has been REMOVED, and the residue is stated rather than hidden

Taking the reviewer's option (b) on GATING finding 1. #473's Acceptance asks, verbatim, that a provider
record with no unverified_mirror_coin_id whose publisher does hold a valid mirror coin is positively
verified as Bonded
. This PR does not do that, and the mechanism it ships cannot.

The owner-written dig-peer:<64-hex> declaration binds coin -> peer id. It supplies no
peer_id -> owner_puzzle_hash resolution, which is exactly what dig_mirror_coin::discover needs in order
to find a coin starting from a record that names none. So mirror_bond.rs:226 still returns Unverified
for an absent pointer, with no chain read and no hint scan.

What this PR does close is #466's primary direction: a claim with a pointer is checked against chain
and bound to a claimant. Neither ticket's Acceptance section has been rewritten; the residue — the
absent-pointer fallback plus the peer_id -> owner resolution it needs — is recorded as the open remainder
on #473 so a later reader finds it.

Honest limits (added to the list above)

What changed in this pass

  • The 15:11Z/23:18Z salvage is recovered. Two files were left uncommitted by the cap. Both were
    coherent and are committed: the Cargo.lock dig-mirror-coin 0.9.0 resolution, and a test import
    corrected from a crate::wallet_db path that does not exist to crate::sage::db::WalletDb. That
    single import was the entire build / clippy / test red on the prior head.
  • origin/main merged at 04079d57 (post-test(nc-12): measure the NC-12 doc claims in seams/dig_peer, guard 2 #504 and post-fix(mirror): a Bonded verdict must not rest on one uncorroborated chain read #506). Conflicts were Cargo.toml (version)
    and Cargo.lock only. Version re-read from the file after the merge: 0.253.0, unchanged.
  • dig-mirror-coin 0.9.0 now resolves single-line. The manifest already declared "0.9" while the
    post-merge lock still carried 0.7.0 from main; cargo update -p dig-mirror-coin brought them into
    agreement. greping the lock confirms exactly one dig-mirror-coin entry.
  • Commitlint is fixed at source. Three merge commits carried the raw
    Merge remote-tracking branch 'origin/main' into loop/473-peer-binding header. Each was re-created with
    git commit-tree (same tree, remapped parents) as chore(merge): bring origin/main into loop/473-peer-binding, and the descendants re-parented. git diff <old-head> <new-head> is empty,
    so the rewrite is provably content-identical. Force-pushed with --force-with-lease, to this lane branch
    only — main was never touched.

dig-node#513 — all three items are now addressed on this branch

The earlier note that items 1 and 5 "cannot land here" described the tree before #506 merged;
corroborated_source.rs now exists on this branch and both landed.

#513 item outcome
1 — bond path wants a floor above CORROBORATION_FLOOR = 2 Landed. BOND_CORROBORATION_FLOOR is a separately-named constant applied at the bond call site (bond_verify.rs:657,666), with the rationale in code: refusing is free here because the fallback is Unverified at baseline rank. tests/mirror_bond_corroboration.rs:486 pins it, and :516 pins the sync floor at two from the other side — so neither can be silently moved onto the other's value.
2 — one dissenting peer makes Unverified uncacheable Answered by the ledger, deliberately not by a TTL. VerdictCache::remember still refuses Unverified on purpose: it records this node's own momentary inability to look, and caching it would hold an outage in force after it ended. The amplification a negative TTL would have absorbed is instead bounded for every claimant by MAX_UNPROVEN_COINS_PER_CLAIMANT, which is strictly stronger — it bounds fabricated coin ids that a per-coin memo structurally cannot absorb, because the attacker picks a fresh coin id each time.
3 — the bridge loses guard_panics Landed. Restored at corroborated_source.rs:111-112, with the link to the original at :120 and the reason at :353.

Build and test evidence for this head (324080d)

  • cargo check --workspace --all-targetsexit 0.
  • cargo clippy --workspace --all-targets -- -D warningsexit 0.
  • cargo test -p dig-node-service — run on a clean tree with CARGO_INCREMENTAL=0; counts reported in the
    hand-back comment.

Two local-only traps hit while measuring, recorded because each produced a reassuring wrong answer:
a cargo test … | tail pipeline reported exit 0 while the target had failed to link (the pipeline
returns tail's status), and cargo check's metadata-only artifacts then shadowed the test build
(crate ... required to be available in rlib format), followed by a rustc ICE in join_codegen from a
poisoned incremental cache. All three are local artifact-state, none is a code defect, and CI builds from
a cold cache.

Blast radius for this pass

Confined to the salvage recovery. gitnexus remains unusable for this lane — the registered dig-node
index is ~300 commits behind the primary checkout, and a stale index returns
impactedCount: 0, risk: UNKNOWN rather than erroring, which is a false-safe zero. The radius was
therefore established by grep + direct read and is stated as such:

  • Cargo.lockdig-mirror-coin only; no other dig-* or chia-* line moved. The multi-line chia-bls
    and chia-protocol sets are pre-existing on main (chia-wallet-sdk pulls several through clvmr
    and chialisp) and are not this diff's delta.
  • corroborated_source.rs — the changed line is inside #[cfg(test)]; no production path reads it.
  • The merge-commit reword changes no tree, so its radius is empty by construction.

Bump rationale

0.252.14 — patch, in the 0.252.x merge chain. main is at 0.252.6, and the other eight open PRs
hold 0.252.5 through 0.252.12, so 0.252.14 is free and leaves headroom. This supersedes the earlier
0.253.0 on this branch: after the #521 renumbering the whole in-flight set moved into 0.252.x patch space,
and a lone 0.253.0 would have forced every sibling to jump the minor to stay ordered.

The version is read back from Cargo.toml on disk after the merge, not from the commit log — a rebase
can silently drop a bump commit as "already upstream", and this branch is merged rather than rebased for
exactly that reason.

Round-5 lane — the four red checks, the conflict, and the version

324080d7 was the most blocked PR in the repo: four failing checks, four unresolved threads, and a
CONFLICTING merge state. Head is now 274b3350.

The four red checks had THREE different causes, not one

Worth stating because the working hypothesis was a single shared cause, and acting on that would have
produced one fix and three still-red checks.

check cause fix
Lint commit messages (×2) one commit header was 116 chars, limit 100 message-only history rewrite
Rustfmt db must sort before peer_reads in a #[cfg(test)] import block reordered by hand
Test + coverage three fixtures predate this PR's own identity guard each now supplies a peer id

The two Lint commit messages failures were the same commit seen by two runs, which is the only place the
one-cause hypothesis held.

The test failures were this PR's guard doing its job

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 credit. Three fixtures passed None, under a
comment calling it "what a node writes before its peer network is up" — a state that is no longer a
supported input to a create. Each now passes Some("a1".repeat(32)), built with repeat so the 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.

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

The commit rewrite changed no tree

The over-long header was rewritten with a --msg-filter pass, which rewrites messages and nothing else.
Verified rather than assumed: the tree hash before and after is the same object, 7a955bd3.

Merge, not rebase

origin/main was merged in. Both conflicts were purely the version line. A rebase was not used because a
rebase whose patch already merged prints dropping <sha> ... patch contents already upstream, exits 0 with
zero conflicts, and silently discards the version bump.

Scope is unchanged: this PR closes #466 ONLY

Closes #473 stays removed and was not re-added. Confirmed through the GraphQL
closingIssuesReferences parser rather than by reading the body, because a keyword inside a code span
parses as zero: the parser reports exactly #466.

Round-5 gate outcomes

Two independent fresh-context gates ran against 274b3350. Both are recorded on their threads; the
short version, including where they found me wrong.

Correctness gate — CHANGES-REQUIRED, both findings fixed

The finding-2 pin 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
advertise.rs with no test reachability at all. So three corrupted operator-facing lines sat behind
a green test that appeared to cover them, which is worse than the original defect because it looks
discharged. They are now lifted into rejection_reason(), ADVERTISING_AT_CONFIGURED_URLS and
nothing_publishable(), and advertise.rs::every_operator_facing_line_reads_as_a_sentence walks all
four with a non-vacuity control, driven from a Rejection::ALL const with the expected count
derived from it rather than written as a literal. Proved by mutation, twice: reintroducing an
18-space run in the const turns the guard red naming the offending constant, and corrupting a
rejection reason turns it red through the wrapper that embeds it; both reverted.

A correction worth stating, because I first wrote the stronger claim here and on the thread: the walk
is not exhaustive by construction. The match is on the type, so a new Rejection variant cannot
compile without being named — but naming is not walking. Add a variant, add the match arms, and an
array literal with a hard-coded count of 4 still passes while the new message ships unguarded. That
is the same shape as the defect this guard exists to catch, one level up, so the count is now derived
from Rejection::ALL.len() and the doc states exactly what is enforced and what is not. A second
operator-facing line found outside the walk by the re-gate — the warn wrapper, while the guard's name
claimed every — is now inside it as not_advertised().

Row 2 of the promotion test differed in two fields, not one. 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, so a row-2 coin malformed anywhere in the
chain half would satisfy the assertion while proving nothing about the declaration. The distinct
owners are load-bearing and stay (creating_spend derives the parent from (owner, asset, amount)),
so a control was added instead: the same coin, same root, claimant stranger, must return Bonded.

Security gate — PASS

No live vulnerability. Every failure mode resolves in the withhold-credit direction: no wrong
promotion and no blocked read could be constructed, and exhaustion degrades to exactly the pre-PR
baseline where promotion did not happen at all.

It also ruled against this PR's own stated divergence, which had been flagged for contest rather
than assumed. The argument that the per-claimant ledger subsumes a negative TTL is true of the
distinct-coin-id case and false of the repeat case, which is exempt from the ledger by design and
bounded only by the global bucket. Non-gating for the reason above, and carried with three further
defense-in-depth gaps as #527.

Filed rather than smuggled in

  • Lost \ string continuations still bake source indentation into seven shipped messages #526 — the lost-continuation defect class is not
    swept crate-wide: seven more survive, including a client-facing JSON-RPC error body at
    pairing.rs:146 and the assertion message of the test that guards this class at logging.rs:261.
    All pre-date this PR's merge base.
  • One correction to this body's earlier claim: of the six literals repaired, only the three in
    advertise.rs are operator-facing runtime lines. The pass.rs and runner.rs three are test
    assertion messages, visible only on failure.

One defect introduced and fixed inside this round

Lifting the message helpers placed 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. It compiles, rustfmt and clippy are clean, and only the rendered docs are wrong.
Fixed by moving the helpers above the doc block.

MichaelTaylor3d and others added 2 commits September 1, 2026 20:55
Stub anchor so a session cap cannot lose the lane. Activation of the
bond verifier follows.

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>
MichaelTaylor3d and others added 2 commits September 1, 2026 21:53
…s 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>
…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>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Audited head: 59a033149257cb75af42711aef0667d07187d072 (resolved from gh pr view 501 --json headRefOid).
Merge base: ec87de4f25f1c5a9c7c4d4777ac1ab5deca1d601. Diff read at that pair, from git objects only —
no shared checkout was mutated.

Tooling note: the gitnexus and socraticode MCP servers both timed out on connect this session, so
blast-radius is being done with ripgrep + direct reads rather than impact. Stated because a §2.0
fallback must be declared.

Scope being threat-modelled (12 files, +394/-132):

  1. bond_verify.rs — the promotion decision itself: who can drive it, what a parse failure does,
    whether the declaration is checked against chain or one peer's word.
  2. mirror_bond.rs + peer.rs — the PeerDeclaration type change and the removal of NotReadable.
  3. lifecycle.rs / spends.rs / server.rs — whether the declaration path moves or locks funds.
  4. The per-peer-identity promotion bound, and whether it is a censorship primitive as well as a
    protection.
  5. Non-vacuity of each new guard test.

Findings will be posted as they are established, then a VERDICT comment. Nothing here is a merge
signal yet.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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

head read: 59a033149257cb75af42711aef0667d07187d072 (resolved from gh pr view --json headRefOid).
Base for the diff: merge-base ec87de4f. Reading via git show/git diff only — the shared checkout
was not mutated. gitnexus and socraticode MCP both failed to connect (CONNECT_TIMEOUT), so blast radius
is by ripgrep + direct read; stated for the record.

Established so far, each durable:

  1. Main since the branch point is wallet-only (33ab0826, adf03d81, f1170d08 — all
    fix(wallet)), and this diff touches mirror/*, mirror_bond.rs, peer.rs. No semantic conflict
    with what merged. Version is not being reviewed, per the brief.
  2. The two promotion-bound tests are NOT vacuous. one_stolen_identity_cannot_occupy_more_than_one_promoted_slot
    expects [attacker-1, honest-no-pointer, attacker-2, attacker-3]; with the bound removed a stable
    sort gives [attacker-1, attacker-2, attacker-3, honest], so the assertion discriminates. Likewise
    the_promotion_bound_is_not_defeated_by_respelling_one_peer_id places the honest record BETWEEN the
    two spellings, which is what makes a text-keyed set observably different from an identity-keyed one —
    and the test says so in its own comment.
  3. peer_declaration is not a second parser — it delegates to dig_mirror_coin::declared_peer and
    only compares via the typed names. PeerDeclaration::NotReadable removal is consistent with
    every match arm in the diff.
  4. Two gating-class findings are already established and will be stated with file:line in the
    verdict: an acceptance shortfall against one of the two tickets this PR closes, and corrupted
    whitespace inside two tracing::warn! literals in mirror/lifecycle.rs.

Still to check: the load-bearing pin claim (dig-download -> dig-nat -> dig-tls) that the PR uses to
retire SPEC.md §25.6a's authoritative-record restriction, and the new normative claim that the §25.6
DHT pointer is attached at seams/dig_peer/dht.rs:493.

@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 correctness gate - VERDICT: CHANGES-REQUIRED

head reviewed: 59a033149257cb75af42711aef0667d07187d072 (resolved from gh pr view --json headRefOid; it did not move during the review). Base: merge-base ec87de4f. Read via git show / git diff only - the shared checkout was not mutated. gitnexus and socraticode MCP both failed to connect (CONNECT_TIMEOUT), so blast radius is ripgrep + direct read.

GitHub refuses a verdict-bearing review event from the PR author identity, so this is recorded as a comment review. The three inline threads are what bar the merge.

Gating (each an inline thread)

  1. crates/dig-node-core/src/mirror_bond.rs:226 - the stated acceptance of BOTH closed tickets is unmet. An absent unverified_mirror_coin_id still yields Unverified with no hint-scan fallback, which is verbatim #473's Task and Acceptance and #466's second control. The declaration binds coin -> peer and supplies no peer_id -> owner resolution, so the fallback stays unreachable. Either implement it, or drop Closes #473 and state the residue - do not rewrite the tickets' acceptance.
  2. crates/dig-node-service/src/mirror/lifecycle.rs:186 (and :196) - corrupted log literals: an 18-space and a 22-space run inside two tracing::warn! messages, an eaten \ continuation. Nothing tests the text, so nothing else catches it.
  3. crates/dig-node-service/src/mirror/bond_verify.rs:322 - the activated promotion decision is untested through verdict_for. Every Bonded in the suite comes from a double that derives the declaration from the claiming peer id and so cannot express "the coin declares a different peer". The advertises step is unexercised too.

What was verified and holds, so it is not re-derived

  • No semantic conflict with main since the branch point. 33ab0826, adf03d81, f1170d08 are all fix(wallet); this diff is mirror/* + mirror_bond.rs + peer.rs. Version deliberately not reviewed, per the gate brief.
  • The pin claim that retires SPEC.md 25.6a's authoritative-record restriction is TRUE, and I checked it rather than accepting the lane's word: dig-nat 0.21.0's PeerTarget::peer_id is a required non-Option field (src/peer.rs:34), fast_connect.rs:349 takes expected_peer_id: PeerId by value, and dig-tls 0.4.0/src/verify.rs:115 fails the handshake with peer_id mismatch: expected {expected}, got {derived}. The narrow dig-gossip fact the old wording over-generalised is also as described. So the SPEC rewrite and the peer.rs doc corrections at :870 and :2482 are not born-false.
  • Both new normative claims about the 25.6 DHT pointer are TRUE at this head: dig-node-core/Cargo.toml:404 takes dig-dht = "0.15", seams/dig_peer/dht.rs:493 calls announce_provider_with_collateral(id, coin_id), and SnapshotMirrorPointers is installed at server.rs:2168.
  • The two bound tests are NOT vacuous. Remove the dedup and one_stolen_identity_cannot_occupy_more_than_one_promoted_slot yields [attacker-1, attacker-2, attacker-3, honest] rather than the asserted interleave; in the_promotion_bound_is_not_defeated_by_respelling_one_peer_id the honest record sits BETWEEN the two spellings, which is what makes a text-keyed set observably different from an identity-keyed one. The test states that reasoning itself.
  • The lowercase key is load-bearing, not merely defensive. dig-dht 0.15.0/src/service.rs:898 dedups by the RAW provider_peer_id string, so byte-identical copies never reach this locator and the case-varied respelling is exactly the case that does.
  • peer_declaration is not a rival parser - it delegates to dig_mirror_coin::declared_peer and compares only through the typed names. Removing PeerDeclaration::NotReadable is consistent with every arm in the diff, and declaration_source_is_readable still gates verdict_for at bond_verify.rs:305 in the fail-closed direction, so a future format change withholds credit rather than granting it.
  • "the binding is checked first" is accurate: advertises is evaluated at bond_verify.rs:329 before the required_collateral pricing at :333, so the no-census degradation stated in the PR body and in SPEC.md 25.6a is real.
  • All five NodeMirrorEffects::new call sites are updated; own_peer_id is re-read per pass rather than captured at spawn, and the None path creates the coin rather than refusing - both the right failure directions, and both explained at the call site.
  • Readable-code bar: met, and above the usual level here. Every guard states its failure direction and several tests state what a wrong implementation would return.

Not findings, recorded so they are not raised again

  • dig-mirror-coin 0.8.0 is not on crates.io yet (the index shows 0.7.0 as latest), so this cannot compile or go green and I could not run the suite. That is the sequencing the PR body already declares, not a review finding - but it does mean findings 1-3 come from reading, and nobody has yet observed the suite passing at this head.
  • The version sitting below main is out of scope per the gate brief.

Comment thread crates/dig-node-core/src/mirror_bond.rs
Comment thread crates/dig-node-service/src/mirror/lifecycle.rs Outdated
Comment thread crates/dig-node-service/src/mirror/bond_verify.rs
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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

head 59a033149257cb75af42711aef0667d07187d072, base ec87de4. Posted as formed; verdict follows.

What is CLEAN, with why

The authorization primitive is sound. I read dig-mirror-coin PR#10 at f5ea101 — the crate is
NOT on crates.io (index tops out at 0.7.0), so I read the dependency PR rather than a published
artifact. In declaration.rs:

  • strip_prefix(PEER_DECLARATION_PREFIX) is an exact match, so xdig-peer: / dig-peers: are
    ordinary advertised strings — no prefix-lookalike promotion.
  • from_hex indexes bytes, not &str slices, after a length check at 64. A 64-byte string of
    multi-byte characters therefore cannot panic the parse. That matters: claiming_peer_id reaches it
    straight off an unsigned provider record, and provider_peer_id is a bare pub String in
    dig_dht::ProviderRecord with no deserialize_with normalisation — unlike its neighbour
    unverified_mirror_coin_id, which has one — so it really is arbitrary attacker bytes.
  • Two prefixed terms yield None, counted by prefix rather than by successful parse, so an owner
    cannot resolve its own ambiguity by malforming one half.
  • names() compares 32 decoded bytes, so hex case is not an authorization axis.
  • create now REJECTS a urls entry beginning with the prefix (create.rs:116), closing the second
    writer path by which declared_peer: None plus a crafted advertised URL produced a coin declaring
    somebody. It errors rather than silently declaring.

Every failure direction in chain_bond_verdict_and_coin is the safe one. Source Err to
Unverified; missing creating spend to Unverified; memo decode failure to Unbonded; spent coin
to Unbonded; wrong puzzle hash to Unbonded; advertises false to Unbonded; and
required_collateral: None to Unverified, never Bonded. Nothing falls through to promoted. The
asset id, amount and owner all come from the executed parent puzzle rather than a memo, so the one
owner-written value is used only for what a memo can prove.

The promotion moves and locks no funds. It is a rank in a locator's output. credit_rank is
credit-only, the sort is stable, nothing is dropped, and a disproven pointer is logged and not
demoted — so a hostile record cannot push an honest holder below where no verifier at all would put
it.

The per-identity bound is not vacuous, and I checked the upstream that could have made it so.
dig_dht::lookup.rs:176 dedups by the RAW provider_peer_id string, and capsule_fallback.rs:70
does the same. Identical spellings are gone before the slate reaches this layer — but a RESPELLED
peer id survives both, which is exactly what the lowercased promoted_peers set at
mirror_bond.rs:221,246 catches. The guard covers the case its upstream does not.

The limiter is on the correct side of the expensive step. lib.rs:4120 and the miss_outcome
leg admit through allow_miss_lookup BEFORE find_providers runs, so the chain reads this PR
activates sit inside an existing gate rather than in front of one. That is what keeps finding 1 a
multiplier rather than unbounded.

Node::own_peer_id() / PeerStatus::peer_id() are the only new public surface. A peer id is a
public value — SHA-256(TLS SPKI DER), already presented on every dial — so exposing it leaks
nothing; both are read-only and neither is reachable from a wire handler.

Findings, ranked

1. HIGH — GATING. Activating the verifier loads a 16x third-party-egress multiplier onto a
per-requestor budget sized for cheap DHT lookups, and the module's own stated reason for shipping
inert was this exact hazard.
bond_verify.rs:322, mirror_bond.rs:230.

State, action, impact:

  • An anonymous remote peer sends dig.getContent / dig.fetchRange for a (store, root, rk) this
    node does not hold (seams/dig_rpc/dispatch.rs:479,953). land_origin gates only the
    landing/reshare side effects — the comment there says reads are NEVER altered — so a peer-origin
    miss still locates.
  • One admitted token buys one locate. One locate verifies up to MAX_VERIFIED_PER_LOCATE = 8
    records, and each verification is TWO blocking chain reads (coin_record plus coin_spend). So
    one token now costs up to 16 outbound HTTPS reads, where before this PR it cost zero:
    declaration_source_is_readable() was false, so verdict_for returned before any read.
  • The budget is DEFAULT_MISS_LOOKUP_BURST = 16 and DEFAULT_MISS_LOOKUP_REFILL_PER_SEC = 4
    (rate_limit.rs:165,169), per requestor with no aggregate cap, across up to
    MAX_TRACKED_REQUESTORS = 4096 identities. One identity sustains roughly 64 coinset reads/sec, and
    a peer id is self-minted, so identities scale that linearly.
  • The slate's size and contents are chosen by whoever answered the lookup (the module docs say so),
    and the requester picks the content key — so it can grind a key it is closest to and serve itself
    eight records carrying eight distinct fabricated coin ids. Every one misses the cache, because
    VerdictKey includes the attacker-chosen coin_id.
  • Those reads go to api.coinset.org through the same shared ChiaQuery client the wallet, the
    collateral census and the mirror spends read through
    chain.rs:307 returns a view of the one
    client, deliberately. I found no client-side throttle in chia-query's coinset transport to absorb
    it. So the impact is not abstract egress: getting this node's own chain access rate-limited
    degrades wallet sync, the census that prices bonds, and the spend path.

Why this gates rather than being filed: the module argued this hazard itself, as the justification
for shipping inert — that paying these reads "converts one cheap-lookup token into attacker-directed
egress at api.coinset.org, which degrades the same transport this node's wallet reads through" — and
this PR removes the mitigation without replacing it. Any one of these closes it: charge
allow_miss_lookup more than one token when a slate carries pointers, put a dedicated chain-read
bucket in front of verify, or cut MAX_VERIFIED_PER_LOCATE to a value whose doubling fits the
existing budget. The module already says promotion only has to reach the holders a download tries
first.

1b, same finding. The cache's stated eviction protection does not hold at an attacker-chosen
insert rate.
bond_verify.rs:355 evicts ONE arbitrary entry per insert past
MAX_CACHED_VERDICTS = 1024, and its comment says clearing instead would hand "a stranger a cheap
way to discard every honest verdict this node has earned simply by rotating coin ids, which converts
a memoisation into an amplifier." Rotating coin ids inserts a definite Unbonded per read, so at the
rate above the whole 1,024-entry map turns over in well under a minute — the threat the comment
describes is realised linearly rather than instantly, so the honest hit rate under attack is near
zero and finding 1's read volume is what remains. Cheapest fix: do not cache the Ok(None)
no-such-coin negative at all — it costs one read to re-establish and is the one an attacker mints for
free — or hold negatives in a bounded partition separate from earned positives.

2. MEDIUM — not gating. The promotion is decided on a single uncorroborated chain read, and
SPEC.md §25.6a does not say so.
bond_verify.rs verify_against_chain to
dig-wallet/src/sage/chain.rs:307.

That method's own doc is explicit about what it hands back: with coinset_fallback_enabled, "the
default every production fabric is built from", it asks api.coinset.org FIRST and consults dialled
peers only when that read fails, and "it is not a race, and the peers do not corroborate the answer",
with trustless: false and answers "believed because the tier that produced them was believed".

So when the oracle read fails, one dialled full-node peer's word decides a promotion. Nothing checks
inclusion: MirrorCoin::from_creating_spend runs the parent puzzle locally, which proves the spend
is self-consistent, never that it was ever on chain. A peer in this node's chia peer set can hand
back a fabricated coin record plus a self-consistent creating spend declaring its own peer id at
a full collateral amount, and take rank 0 at zero collateral — the exact economic guarantee #466
exists to create. TLS pinning does not help, because the attacker promotes an identity it genuinely
holds.

Not gating, for three reasons: it is the ecosystem's inherited posture for every chain read rather
than something this PR introduces; it needs the oracle read to fail first; and the payoff is a
ranking position that an attacker answering the lookup can partly take by ordering the slate anyway.
Content is still merkle-verified against the caller's own root, so nothing forged is served.

What I do ask for is one sentence in SPEC.md §25.6a. It now asserts a promoted holder "has proven
both halves" and that the declaration is an owner attestation "carried by executed on-chain code" —
true of the memo, not of the read that produced it. A normative claim about an economic guarantee
should name the trust assumption underneath it. NC-12 corroboration on this path is worth a follow-up
ticket, not a round here.

3. MEDIUM — not gating; recommend fixing here, it is three lines. The create-side guard fails OPEN
on money, and two rustdocs in this diff disagree about whether it should.
lifecycle.rs:182 and
spends.rs:181-186.

declared_peer()'s own doc says a coin created without a declaration "locks real collateral for an
epoch and can never be promoted by any reader, which is a worse outcome than the create being
retried next pass" — then returns None, and build_create creates the coin anyway, its doc
arguing the opposite. The code does the thing the first doc calls worse.

Reachability, which is what keeps this at MEDIUM: spawn_mirror_passes is spawned beside
spawn_peer_network, its first pass runs before the loop's trailing sleep(MIRROR_PASS_INTERVAL)
(10 minutes, dig_constants::MIRROR_ROUND_LENGTH_MS), and spawn_bond_verifier_install budgets
120 s waiting for the engine to exist — so peer-network bring-up demonstrably lags. Worse, a host
with outbound HTTPS but blocked P2P ports — an ordinary firewalled machine — has
own_peer_id() == None PERMANENTLY while its chain reads work fine, so it would create an
uncreditable bond every epoch, indefinitely. An epoch is seven days and the coin is not recreated
mid-epoch.

Two things hold the severity down and are why I am not gating: the lifecycle only spends when
DIG_WALLET_ENABLE_LIVE_BROADCAST is on, which is not the default
(SpendCapability::BroadcastDisabled is "the money-safe default"), and the collateral is reclaimable
per §25.4.4 rather than lost. The fix its own doc prescribes is to skip the create this pass and let
the next one take it.

4. LOW — not gating. Two log literals in this diff are corrupted by a lost line continuation.
lifecycle.rs:186 and :196 both read "the coin will name no peer". Cosmetic in
effect, but operator-facing text on the money path, and it is in this diff. Flagged for the
correctness gate rather than owned here.

5. LOW — not gating, pre-existing. Unbounded peer-supplied text reaches a log field.
mirror_bond.rs:253 logs peer = %record.provider_peer_id at debug level, and that field carries no
wire-boundary normalisation, so it can hold arbitrary length and arbitrary bytes including newlines.
Unchanged by this PR; noted because this PR is what makes the surrounding branch reachable. The
allocation paths are fine — promoted_peers only ever holds 64-char strings, since names() cannot
match anything else.

What I did not do

  • Nothing was executed. dig-mirror-coin 0.8.0 is unpublished, so this head cannot build and CI
    cannot be green on it. My non-vacuity assessment of the new guards is therefore by reading, not by
    a red run. By construction they do bite: dropping .to_ascii_lowercase() at mirror_bond.rs:247
    reorders the expected slate in the_promotion_bound_is_not_defeated_by_respelling_one_peer_id, and
    only_the_coins_own_declaration_of_this_claimant_promotes_it drives the real accessor over ten
    rows including the two-declaration and prefix-lookalike cases. A revert-proof run is owed once
    0.8.0 publishes.
  • No shared checkout was mutated. Everything here was read with git show / git diff /
    git grep <rev> against 59a0331, f5ea101 and origin/main. No worktree was cut, nothing was
    checked out, no git identity was set.
  • gitnexus and socraticode MCP both timed out on connect, so the blast radius above is ripgrep
    plus direct reads rather than impact.

@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-security pre-merge audit — one GATING finding. Full analysis in the findings comment on this PR.

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

Copy link
Copy Markdown
Contributor Author

VERDICT: CHANGES-REQUIRED

head audited: 59a033149257cb75af42711aef0667d07187d072 (re-resolved from
gh pr view 501 --json headRefOid at the end of the audit as well as the start; unchanged).
Base ec87de4f25f1c5a9c7c4d4777ac1ab5deca1d601. Twelve files, +394/-132.

One GATING finding. The authorization design itself is sound and I want to say that plainly — the
two-binding requirement, the byte-wise comparison, the credit-only lattice and every fail-closed
direction in the chain half all hold, and the per-identity bound is real rather than decorative. The
gate is not on whether promotion is forgeable; it is on what activating the chain read costs and who
can spend it.

GATING

1. HIGH — the activated verifier turns one cheap inbound RPC into up to 16 outbound HTTPS reads on
the shared chain client the node's wallet, census and spends use, behind a per-requestor budget with
no aggregate cap.
crates/dig-node-service/src/mirror/bond_verify.rs:322,
crates/dig-node-core/src/mirror_bond.rs:230.

An anonymous peer's dig.getContent for content this node lacks reaches the locate
(seams/dig_rpc/dispatch.rs:479,953land_origin gates only the landing legs, reads are
unaltered). Each locate verifies up to 8 records at 2 chain reads each; before this PR the count was
zero, because declaration_source_is_readable() short-circuited. The budget is burst 16 / 4-per-sec
per requestor, no aggregate ceiling, up to 4,096 tracked identities, and peer ids are self-minted
— so ~64 coinset reads/sec/identity, scaling linearly. The slate is chosen by whoever answers the
lookup and the content key by the requester, so eight fabricated coin ids miss the cache every time
(VerdictKey includes the attacker-chosen coin id), and bond_verify.rs:355's one-at-a-time
eviction is turned over in under a minute at that rate. Egress leaves through the one shared
ChiaQuery client (dig-wallet/src/sage/chain.rs:307), with no client-side throttle in
chia-query's coinset transport that I could find.

This module argued this hazard itself as its reason for shipping inert. The PR lifts the mitigation
and adds none. Remedy is small — charge more than one token when a slate carries pointers, add a
chain-read bucket in front of verify, or cut MAX_VERIFIED_PER_LOCATE so its doubling fits the
existing budget. Details and the cache half in the inline thread.

NOT gating — named, with a recommendation

2. MEDIUM — the promotion rests on an uncorroborated single-source chain read and SPEC.md §25.6a
does not say so.
chain_source() asks api.coinset.org first and, on failure, one dialled peer
with — its own words — no race and no corroboration, trustless: false. Nothing checks inclusion, so
a peer in the chia peer set can hand back a self-consistent fabricated coin declaring its OWN peer id
and take rank 0 at zero collateral. Inherited posture, needs the oracle read to fail first, and the
payoff is a ranking position a slate-orderer can partly take anyway — so a follow-up ticket, not a
round. Ask: one sentence in §25.6a naming the trust assumption, because that section now asserts
what a promoted holder "has proven".

3. MEDIUM — the create-side guard fails open on money, and two rustdocs in this diff disagree about
whether it should.
mirror/lifecycle.rs:182 vs mirror/spends.rs:181-186. declared_peer()'s doc
calls creating a coin with no declaration "a worse outcome than the create being retried next pass";
the code then does it. A host with outbound HTTPS but blocked P2P ports has own_peer_id() == None
permanently, so it locks reclaimable-but-idle $DIG in an uncreditable bond every 7-day epoch. Held
below gating because the lifecycle only spends when DIG_WALLET_ENABLE_LIVE_BROADCAST is on (not the
default) and the collateral is reclaimable. Recommend fixing here — it is the three lines its own
doc prescribes.

4. LOW — two corrupted log literals, lifecycle.rs:186 and :196 ("the coin will
name no peer", a lost line continuation). Correctness gate's call.

5. LOW, pre-existing — unbounded peer text into a log field, mirror_bond.rs:253.
provider_peer_id has no wire-boundary normalisation. Debug level, unchanged by this PR.

Areas checked and clear

  • Forgery / replay of the declaration — clear. Exact prefix match, byte-indexed hex parse that
    cannot panic on peer-supplied input, two declarations name nobody, names() compares 32 decoded
    bytes. create now rejects a dig-peer:-prefixed urls entry, closing the second writer path.
  • Can a hostile peer promote a bond it does not own — no, not through this layer. Promotion needs
    the coin's owner-written declaration of that exact peer id, and the memo is written by a spend only
    the owner's key can produce. A republished public coin id yields Unverified.
  • Parse failure / missing field direction — fail-closed throughout; no path falls through to
    promoted, including required_collateral: None.
  • Stale / reorged declaration — bounded, credit-only. VERDICT_TTL is 600 s against a 7-day
    epoch, is_spent() is checked, and there is no confirmation-depth requirement — so a reorg could
    hold a promotion for up to 10 minutes. Ranking only; worth a note, not a finding.
  • Does promotion move or lock funds — no. It is a rank; the lattice is credit-only and nothing is
    dropped. Funds only move on the create side, which is finding 3.
  • Persisted state — none added. The verdict cache is in-memory and bounded (see finding 1b); no
    new on-disk state, no new file paths, no path traversal surface.
  • New public APINode::own_peer_id() and PeerStatus::peer_id() only. A peer id is public by
    construction and both are read-only; no new RPC or control method is exposed.
  • Unbounded allocationpromoted_peers holds at most 8 entries and only 64-char strings, since
    names() cannot match anything else. to_ascii_lowercase on a peer id is transient.
  • Test non-vacuity — sound by construction, but nothing was executed: dig-mirror-coin 0.8.0
    is not published (index tops out at 0.7.0), so this head cannot build. A revert-proof run is owed
    once it publishes, and CI cannot be green before then in any case.

Read entirely from git objects (git show / git diff / git grep <rev>) against 59a0331,
dig-mirror-coin f5ea101 and origin/main. No shared checkout was mutated, no worktree cut, no
git identity set.
gitnexus and socraticode MCP both timed out on connect, so blast radius is
ripgrep plus direct reads rather than impact.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Fix lane started — head 59a0331, still DRAFT, do not merge.

Both gate rounds read. Working the eight findings in this order (TDD, failing test first for A/C/D/E):

# finding plan
A HIGH — locate-triggered chain reads have no aggregate cap; VerdictKey includes the attacker-chosen coin id so nothing hits; bond_verify.rs:355 evicts one entry per insert process-wide read admission (token bucket + concurrency ceiling + per-claimant distinct-coin cap), fail-closed to Unverified with zero reads; eviction stops being attacker-paced
B reviewer 1 — absent-pointer path is #473's acceptance and is not buildable from this crate's API not implemented here (orchestrator's decision): Closes #473Refs, residue stated on #473 and in Honest limits
C reviewer 2 — corrupted log literals lifecycle.rs:186/:196 restore the continuations + a literal-hygiene test
D reviewer 3 — every Bonded comes via a double that derives the declaration FROM the claimant a real creating-spend ChainSource double driven through verdict_for; three rows differing only in the declared peer; advertises exercised on an existing coin
E security MEDIUM 3 — declared_peer() doc says a declaration-less coin is worse than a retry, then creates it the create refuses when own_peer_id is None
F security MEDIUM 2 — single-source uncorroborated chain read unstated one sentence in SPEC.md §25.6a
G LOW — unbounded unnormalised provider_peer_id at debug truncate + normalise

Then git merge origin/main, version 0.251.0, and a reply + resolve on every thread from both rounds.

Local build note: dig-mirror-coin 0.8.0 is still unpublished (dig-mirror-coin#10, head f5ea101), so every local run is made through cargo --config 'patch.crates-io.dig-mirror-coin.path=…' — no file in the tree carries a [patch] section and Cargo.toml/Cargo.lock stay as main's until 0.8.0 publishes.

Next action: run the two cheap fixes (C, G) red-first, then A.

…compiled

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

Copy link
Copy Markdown
Contributor Author

Guard follow-ups from the loop-security audit (items 1 and 2 in #513)

Two findings on the bond-verification gate that touches this function (bond_verify.rs:408 promotion decision and corroborated_source.rs:133 coin-id-binding rejection):

(a) Promotion decision at bond_verify.rs:408 has no guard. Use the repo's include_str! guard idiom (see chain.rs:1404 for the pattern) to catch failures.

(b) Coin-id-binding rejection at corroborated_source.rs:133 has no test anywhere — benign-fixture shape. Add a test proving an answer with a mismatched coin id is rejected.

Both are non-gating and should land with this PR since they are in the call-site flow.

Also: the three non-gating findings filed on #513 (CORROBORATION_FLOOR, Unverified memoisation, guard_panics) MUST be added to the must-land-together list at bond_verify.rs:139-144 and addressed together with this PR + #503, because they are dormant today (short-circuit at declaration_source_is_readable()) but become load-bearing the moment this accessor arrives.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Gate verification note

PR #501's promotion soundness rests on the dig-tls peer_id mismatch pin (the TLS identity is validated against the peer's advertised identity). That pin was reported UNCHECKED by the dig-mirror-coin#10 adversarial gate (dig-tls absent from modules/crates/30-network/ in that gate's checkout).

This PR must verify the pin in its own gate round, confirming dig-tls carries the promised validation before #501 lands.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Lane resumed (orchestrator e93b41)

Resuming this DRAFT PR after the 15:11Z session cap. Branch loop/473-peer-binding, head 4a585f2 (merge of origin/main, which was at 0.252.1).

Done so far:

  • Merged origin/main; conflicts were Cargo.toml (version) and Cargo.lock only. Workspace version set to 0.253.0 (feat, above the in-flight 0.252.x merge chain). Lock regenerated from main's.
  • dig-mirror-coin 0.8.0 is live on crates.io and already declared as "0.8" at crates/dig-node-service/Cargo.toml:110 — the blocker on this lane is cleared.
  • Compiling the salvage commit 3d91097b (7 files / +753, committed without ever being built) now. Expect breakage; that is the first work item.

Working queue, in order:

  1. Compile the salvage commit green.
  2. loop-security HIGH (bond_verify.rs:377) + dig-node#513 finding 2 — bound the outbound read budget per locate and memoise Unverified with a TTL.
  3. loop-reviewer GATING — corrupted log literal in lifecycle.rs, missing verdict_for promotion test, mirror_bond.rs:226 early-continue.
  4. dig-node#513 items 1 and 5 — bond-path corroboration floor above CORROBORATION_FLOOR=2, and restore chia-query's guard_panics backstop in the bridge.
  5. Verify the dig-tls peer_id mismatch pin at source and cite it; if it does not hold, promotion stays disabled.
  6. §2.4b dep sweep on every crate touched.

Re-merging origin/main once #506 lands. Staying DRAFT until the gate round returns.

MichaelTaylor3d added a commit that referenced this pull request Sep 2, 2026
…andshake (#498)

* chore(403): open lane — client-side pairing for a user-run Agent

Anchor commit for dig-node#403. Bumps the workspace version to 0.243.0
and opens the branch so the lane's state survives a session cap.

Refs #403

* feat(pair): give an unprivileged client the pairing handshake + a token ladder (#403)

An ordinary OS user could not drive control.* against a dig-node running as a
root system service: control_client::call_control had exactly one token source,
the 0600 root:root master token (#501). The server side of the #280 handshake
was complete; nothing implemented the client half.

- paired_client: the token LADDER as a pure function over the two read outcomes
  (master when readable -> per-user paired token -> the master read's own remedy,
  verbatim), plus the per-user 0600 store, the refusal bound on client_name, and
  the poll bound taken from the server's expires_ms.
- pair connect [--client-name NAME]: request over the OPEN method, print the
  compare-codes value, poll to a terminal state, persist on approval.

No file mode is widened anywhere. The paired token cannot administer pairings
and carries no chain authority.

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

* docs(spec): specify the client pairing verb + the CLI token ladder (#403)

Also drops the ticket number from the `pair connect` help text -- the
no_help_text_exposes_an_internal_ticket_number guard caught it.

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

* fix(pair): resolve the client token store from the per-user base and create it exclusively

Four gate findings from dig-node#498.

B1: `paired_token_path` resolved through `state::legacy_state_dir`, whose chain runs
`resolve_cache_dir` -> `private_fallback_dir` = `temp_dir()/DigNode-<PID>/cache` when the
canonical dir is unwritable. On such a host the bearer token was written into a 1777 directory
under a /proc-enumerable name. It now resolves from `dig_node_core::platform_user_base()`
directly, so the temp fallback is structurally unreachable, and REFUSES when no per-user base
exists rather than degrading to the cwd.

B2: the store was `fs::write` + a later chmod, so it existed at the process umask (0644) for a
window, and `write` follows symlinks -- a planted link disclosed the token or clobbered an
arbitrary file as the victim, with no race. It is now created with `create_new` at mode 0600.

B3: three user-facing strings in `pair connect` carried a ~26-space run from a lost line
continuation, including the verb's SUCCESS message and both terminal failure paths. A fourth
(the waiting banner) leaked its source indentation. All four are lifted into named functions so
a guard test can assert the signature is gone.

B4: the rung-3 remedy named only sudo verbs the unprivileged reader who sees it cannot run. It
now names `dign pair connect` first, keeping the operator half and the platform-correctness
that landed in #458.

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

* test(pair): fail on an indented continuation, not only a mid-line space run

The guard against a lost `\` line continuation trimmed the start of every
line before checking, so it could only see the corruption in ONE of its two
forms. A multi-line literal without the trailing backslash emits the source's
indentation as a NEWLINE plus nine spaces, not as an interior run, and
`"a\n         b"` prints just as raggedly as `"a          b"`.

Proved by reverting only `paired_message`'s continuation: the old assertion
PASSED on the reverted string, so it was not load-bearing against that shape.
The tightened test fails on it ("prose must not be indented"), and passes once
the continuation is restored.

The banner keeps its one legitimate indent -- exactly four spaces on the
copy-pasteable `sudo dign pair approve <id>` line -- and is now the only string
allowed any, checked against that exact width rather than waved through.

No production code changed.

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

* style(pair): apply rustfmt to the token-store test

The previous fix commit left `the_store_resolves_from_the_per_user_base_and_
never_the_temp_fallback` unformatted, which the fmt gate fails on. Whitespace
only; no assertion changed.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Progress — head c3a0522a

The salvage commit compiles clean (cargo check --workspace --all-targets, exit 0) and turns out to carry most of round 2's answers already. Measured, not assumed:

loop-security HIGH (unbounded outbound reads) — ANSWERED by the salvage, by a different mechanism than the brief proposed. ReadAdmission (bond_verify.rs:402-500) is consulted BEFORE the source is touched, so a refused claim reads nothing. Two limits under one lock: a process-wide token bucket (VERIFICATION_BURST / VERIFICATION_REFILL_PER_SEC) bounding total egress however many self-minted identities the traffic is spread across, and a per-claimant distinct-unproven-coin ledger (MAX_UNPROVEN_COINS_PER_CLAIMANT) for the fabricated-coin-id case.

dig-node#513 finding 2 (Unverified never memoised) — deliberately NOT fixed by memoising, and I agree with that. VerdictCache::remember refuses Unverified on purpose (bond_verify.rs:568-573): it records this node's own momentary inability to look, so caching it would hold an outage in force after it ended. The fabricated-coin-id amplification the memo would have absorbed is instead bounded by the ledger above, which bounds it for every claimant rather than only for repeats of one coin id. Exhaustion degrades to Unverified with the slate returned unchanged — never a refusal of service.

Reviewer finding 2 (corrupted operator log text) — FIXED, and it was wider than reported. The review named lifecycle.rs; scanning every string literal in the mirror modules found the defect in advertise.rs (3), pass.rs (2) and runner.rs (1), and not in lifecycle.rs at all. A lost \-continuation had baked 14-18 spaces of source indentation into six sentences an operator reads. Commit c3a0522a.

Worth recording: my automated fixer's first pass also "fixed" lifecycle.rs:1176,1180, where an 8-space run is a load-bearing test fixture emulating source indentation. Caught by reading the diff and reverted. A sweep needs its diff read.

Reviewer finding 3 (no test through verdict_for) — ANSWERED by the salvage. tests/mirror_bond_verify.rs:489 only_a_coin_that_declares_the_claimant_promotes_it drives the real verdict_for against coins built from genuine CAT spends (tests/support/mod.rs::declaring_bonds), so a verdict_for that promoted on the weaker "some coin bonds this content" fails it. The unit test only_the_coins_own_declaration_of_this_claimant_promotes_it sits below the decision at peer_declaration and could not have caught that.

The dig-tls pin — VERIFIED AT SOURCE, and it HOLDS. Promotion soundness rests on a dial that pins the expected peer id, and the pin is an Option, so the real question was whether dig-node's dial ever passes None:

  • dig-tls 0.4.0 src/verify.rs:112-117pin_and_bind rejects with peer_id mismatch: expected {expected}, got {derived} when a pin is present.
  • dig-nat 0.21.0 src/dialer.rs:197dig_tls::client_config_spki_pinned(&self.node, Some(peer.peer_id), ...), and src/dialer.rs:194 states the dialer ALWAYS pins.
  • dig-nat 0.21.0 src/peer.rs:34PeerTarget::peer_id: PeerId, not an Option, so the pin cannot be omitted by a caller. dig-node's dial sites construct through PeerTarget::with_addrs (capsule_resolver.rs:264, neighbourhood_probe.rs:249).

So the claim in bond_verify.rs:940-945 is sound and promotion may stay enabled.

Blocked on #506, which is still OPEN. dig-node#513 items 1 and 5 both live in crates/dig-wallet/src/sage/corroborated_source.rs, a file #506 creates and that does not exist on this branch. Re-merging and taking both the moment it lands.

Next: test counts, then the §2.4b dep sweep, then the #506 re-merge. Version is 0.253.0; DRAFT until the gate round returns.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Lane report — head 8716c07e, version 0.253.0, staying DRAFT

origin/main merged twice (through 0.252.2); the workspace version is 0.253.0, re-read from Cargo.toml on disk after each merge rather than from the commit log.

Evidence

check result
cargo check --workspace --all-targets exit 0
cargo test -p dig-node-service --lib 800 passed, 0 failed (787 before the final main merge), 0 filtered out
cargo test -p dig-node-service --test mirror_bond_verify 11 passed, 0 failed, 0 filtered out
cargo clippy -p dig-node-service -p dig-node-core --all-targets -- -D warnings clean
cargo fmt --all clean (two over-width lines corrected in ef2cf4c7)

Test counts are stated because a filtered-to-nothing run exits 0 while measuring nothing.

Commits added this round

  • c3a0522a — six operator-facing log literals repaired.
  • ef2cf4c7 — rustfmt the salvage, which had never been built or formatted. CI's fmt gate would have been red without this.
  • 2caf706b — SPEC 25.6a: the aggregate read bound stated, and the eviction clause corrected where it promised behaviour the code deliberately no longer has.

Two things a gate should look at specifically

  1. The salvage commit was never compiled when it was written, and it turned out to be correctcargo check passed first time. It is nonetheless the largest untested-provenance part of this diff (7 files, +753), and the two defects I did find in it were both invisible to a compiler: unformatted source, and a SPEC clause that had drifted from it.
  2. The cache no longer evicts on overflow the way SPEC said it did. The change is deliberate and I believe correct — an unbonded is free for a stranger to elicit, so per-insert eviction is paced by the attacker — but it is a normative change to a security-relevant policy and belongs in the security leg's scope, not just the correctness leg.

Outstanding — one item, and it is not mine to close

dig-node#513 items 1 and 5 cannot land on this branch. Both concern crates/dig-wallet/src/sage/corroborated_source.rs, a file #506 creates and that does not exist here; the must-land list they were to be added to likewise lives in #506's diff. #506 was still OPEN at 21:10Z after ~50 minutes of polling. Referenced, not closed — Closes names only #473 and #466.

When #506 lands, this branch needs: a re-merge, then the bond path given its own corroboration floor above CORROBORATION_FLOOR (2), and chia-query's guard_panics backstop restored in the bridge. Conflicts in bond_verify.rs resolve in favour of BOTH behaviours — corroboration and promotion are independent halves.

Ready for the full triple gate (custody-adjacent: this activates a promotion decision that ranks who a reader fetches from). Not undrafting — the gate round has not returned.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Lane resumed (orchestrator e93b41, 2026-09-02).

Owner: implementer lane on loop/473-peer-binding, worktree C:/tmp/worktrees/dn-473, head at pickup 8716c07e (v0.253.0, DRAFT).

Plan for this pass:

  1. Merge origin/main (1cf90cd5, which lands fix(mirror): a Bonded verdict must not rest on one uncorroborated chain read #506's corroborated ChainBondVerifier::verify_against_chain plus crates/dig-wallet/src/sage/corroborated_source.rs). Resolve bond_verify.rs in favour of BOTH behaviours: corroboration AND bond promotion on the coin's own peer declaration.
  2. dig-node#513 item 1 - give the BOND path its own corroboration floor, higher than CORROBORATION_FLOOR = 2, with a discriminating test (a 2-of-4 round must yield Unverified, not Bonded). The sync path keeps 2 so the replica cannot freeze; on the bond path refusing is free.
  3. dig-node#513 item 5 - restore chia-query's guard_panics backstop, dropped by the bridge at corroborated_source.rs:79-92.
  4. Item 2 (memoising Unverified) stays REJECTED by design - VerdictCache::remember refuses Unverified and the path is bounded by ReadAdmission instead. Stated on three non-gating hardening findings from #506 loop-security audit — dormant until #501 #513 for the gate to judge.

PR stays DRAFT until the full triple gate returns.

MichaelTaylor3d and others added 5 commits September 2, 2026 16:30
Co-Authored-By: Claude <noreply@anthropic.com>
…erator 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>
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>
…g 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>
Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d and others added 5 commits September 2, 2026 16:30
Co-Authored-By: Claude <noreply@anthropic.com>
…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>
…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>
Co-Authored-By: Claude <noreply@anthropic.com>
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>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

From the #473 lane — three things this PR should know. Informational, not a gate.

Posting as a plain comment rather than a review thread: I do not want to block your lane on something
I could not measure from main, and two of these are FYI.

1. #473's residue is BOUNDED OUT — you were right to drop Closes #473, and it stays dropped

Two fresh contexts settled it: the absent-pointer hint-scan fallback will never be built. discover
requires an owner_puzzle_hash that no peer_id resolves to, it would be unsound before this PR
(peer_declaration is NotReadable on main, so nothing would check which peer a scanned coin
declares), and an owner-less scan's "not found" is not a sound negative — it truncates at
MAX_CANDIDATES = 10_000 on a puzzle hash anyone may extend with dust.

#473 now closes on two doc/SPEC children (DIG-Network/dig-dht#28, #520), not on
this PR. Nothing is being asked of this branch. Full reasoning:
#473 (comment)

2. A structural fact that may affect your SPEC §25.6a wording

crates/dig-node-core/SPEC.md:659-662 mandates announcing at store AND capsule granularity — two
records per held capsule
, and mirror/pointers.rs:113 returns None for anything that is not a
capsule id. ContentId::Store names no root; a coin bonds (store, root, owner, epoch).

So about half of all provider records are pointer-less by construction, on current software — not
old versions, not transient. If any sentence in §25.6a or in this PR's honest-limits section implies
that a pointer-less record means an un-collateralised or lagging publisher, it will read as false the
first time somebody measures coverage. Being tracked in dig-node#520.

3. One thing I could NOT measure, and it is the place I would look for a forge

Your round-3 adversarial pass found the case-respelling attack — eight spellings of one honest peer id
becoming eight promotions — and keyed the bound and VerdictKey on the canonical id.

Is that canonicalisation applied at the PARSE BOUNDARY, or only at the two sites that round found?
Neither ReadAdmission nor declares_peer exists on origin/main, so this lane could not reach the
code to check. If a third consumer of provider_peer_id on this branch compares the raw wire string,
the same attack survives at that site. dig-dht's own ProviderRecord doc is explicit that the
field is pub and that the normalization guarantee holds only for ADMITTED or STORED records — a
record from any other source "can hold arbitrary attacker-shaped bytes of arbitrary length."

Worth one grep for every read of provider_peer_id on this branch before the gate closes.

4. Minor, and genuinely just a note: verdict-cache displacement

MAX_CACHED_VERDICTS = 1024 with single-entry eviction. Sustained coin-id rotation by one claimant
fully displaces honest verdicts over ~1024 inserts. The surrounding comment reasons about
clear-vs-evict but not about sustained churn. Bounded and low-severity — a comment, not a ticket, per
the inflow rule.

MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
…rong for a fix

Two defects in the version this branch claimed.

It was UNBUILDABLE. All four package jobs failed identically at "Resolve +
validate the package version":

  package-version: minor version 258 exceeds the MSI ProductVersion limit of 255

scripts/package-version.sh caps MAJOR and MINOR at 255 and PATCH at 65535,
because Windows Installer's ProductVersion either rejects an out-of-range field
or silently truncates it -- which would make two versions compare EQUAL. The
check is deliberate and its own comment anticipates this exact case: "a stable
0.256.0 is just as unbuildable as a nightly one."

It was also the WRONG BUMP. This branch is a `fix`, and CLAUDE.md 2.4 maps
fix->patch, feat->minor. A minor bump was never owed here.

0.252.5 is a patch off main's 0.252.4: legal under the ceiling, correct for a
fix, and it does not consume one of the three remaining legal minor slots
(0.253/0.254/0.255), which open PRs #501/#509/#514 already hold.

Cargo.lock line 3034 (dig-node-service) moves with it. The other 0.258.0 entries
in the lock are upstream wasm-encoder and wasmparser and are deliberately left
alone.

Refs #508

Co-Authored-By: Claude <noreply@anthropic.com>
…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.
…rd 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.
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.
…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.
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.
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

CORRECTION — I propagated a false quantity into four places, and a gate caught it

Earlier in this family I wrote that "roughly half of all provider records are pointer-less by
construction."
That is wrong, and it is the same copy-propagated-false-claim defect this whole
family exists to fix. Correcting it everywhere I said it.

What is actually true. inventory_content_ids
(crates/dig-node-core/src/seams/dig_peer/dht.rs:376-392) deduplicates the store id through a
BTreeSetif seen_stores.insert(store) at :387 — so the store-granularity ContentId is
emitted once per store, while a capsule ContentId is emitted per capsule.

So a node holding N capsules across S distinct stores publishes S pointer-less records and N
that may carry a pointer
. The pointer-less share is S/(S+N), which approaches half only in the
degenerate case of one capsule per store, and is small for a node holding many capsules per store.

What this does and does not change.

  • It does not change the verdict. The hint-scan fallback is still bounded out on three
    independent grounds, none of which depends on population size: discover cannot be called at all,
    a fallback would be unsound before dig-node#501 lands, and an owner-less scan's "not found" is not
    a negative a verifier may act on.
  • It does not restore the vacuity argument I originally used and then withdrew. The population is
    smaller than I said but is still non-empty and non-transient — every distinct store held yields
    one pointer-less record, forever, on current software. The corrected rationale stands: withholding
    credit from that population is already the specified, harmless behaviour under credit-only ranking.
  • It does mean any measurement of collateral coverage should expect S uncollateralisable
    records, not N.

How it happened, since that is the reusable part. An adversarial pass supplied both the mechanism
(the two-granularity announce) and the quantity ("about half"). I verified the mechanism at source and
accepted the quantity without checking inventory_content_ids. A verified mechanism does not
verify the number attached to it.
The dedup is four lines away from the announce I did read.

…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.
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
…his node's absence claim (#516)

* chore(release): claim 0.258.0 for the forwarded-ask absence fix

Salvage anchor for dig-node#508. Version claimed early so a concurrent lane
does not compute the same slot; the fix follows on this branch.

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

* test(download): red tests for dig-node#508 -- a peer's absence claim moves this node's verdict

Two failing tests, both at the decision layer (`LocatedHolders::establishes_absence`):

* a hop answering `absence_established: true` turns this node's inconclusive
  search into a proven absence, which `Node::availability_answer` then re-emits
  downstream at full strength.
* an `Answered` whose records are ALL removed by the self-filter also arrives as
  a proven absence -- a second route into the same defect that does not touch
  `absence_established` at all.

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

* fix(download): a forwarded ask never establishes an absence (#508)

A peer answering `absence_established: true` for a subtree search that never
completed moved this node from inconclusive to proven-absent, and
`Node::availability_answer` then re-emitted `absence_established: true` to the
next hop -- so an honest node laundered the lie and it travelled at full
strength. The lie was free: an empty `Answered` and an empty
`AnsweredInconclusive` are scored identically by both `ask_routing` and
`conduct`.

`ForwardedAnswers::asked()` now starts at `conclusive: false`, permanently. The
reason is that ABSENCE HAS NO WITNESS: content from a stranger is safe to accept
because the merkle root verifies it, and there is no verifier for "nobody has
it", so a node may establish an absence only from its own completed search.
Corroboration was considered and rejected -- `decide_forward` selects over the
connected pool, which discovery and PEX can shape, and an eclipsed pool collapses
any k-of-n to 1.

`recursion_disabled()` is UNCHANGED at `conclusive: true`, which is what keeps
every miss on every stock node a plain, provable not-found.

This finishes #273 rather than extending it: that ticket closed
silence-becomes-assertion (`unwrap_or(true)`); this closes
stranger's-assertion-becomes-ours, the identical class with one door left open.

Also closes a second route into the same defect, reachable today WITHOUT any
`absence_established` claim: an `Answered` naming only this node is emptied by
the self-filter after the flag was decided, so a peer manufactured a proven
absence simply by answering "you hold it".

SPEC: the three-state table's MEANING column is unchanged (it is the emitter's
claim about its own search and stays true); only dig-node's READING column moves,
and the resulting vacuity of the three states is stated explicitly.

Closes #508

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

* chore(release): re-lock dig-node-service at 0.258.0

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

* fix(release): take 0.252.5 -- 0.258.0 is unbuildable and a minor is wrong for a fix

Two defects in the version this branch claimed.

It was UNBUILDABLE. All four package jobs failed identically at "Resolve +
validate the package version":

  package-version: minor version 258 exceeds the MSI ProductVersion limit of 255

scripts/package-version.sh caps MAJOR and MINOR at 255 and PATCH at 65535,
because Windows Installer's ProductVersion either rejects an out-of-range field
or silently truncates it -- which would make two versions compare EQUAL. The
check is deliberate and its own comment anticipates this exact case: "a stable
0.256.0 is just as unbuildable as a nightly one."

It was also the WRONG BUMP. This branch is a `fix`, and CLAUDE.md 2.4 maps
fix->patch, feat->minor. A minor bump was never owed here.

0.252.5 is a patch off main's 0.252.4: legal under the ceiling, correct for a
fix, and it does not consume one of the three remaining legal minor slots
(0.253/0.254/0.255), which open PRs #501/#509/#514 already hold.

Cargo.lock line 3034 (dig-node-service) moves with it. The other 0.258.0 entries
in the lock are upstream wasm-encoder and wasmparser and are deliberately left
alone.

Refs #508

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

* docs(download): correct a born-false test doc and pin the conduct inequality (#508)

Two gating review findings on PR #516, both the same class: a claim asserted in a
test that the code does not support.

F1 - the doc on `a_holder_answer_whose_records_are_all_dropped_is_not_an_absence`
claimed a "second route into the same defect [that] does not go through
`absence_established` at all". False in the commit that wrote it.
`parse_forwarded_answer` reaches `AskOutcome::Answered` by exactly two arms:
`held && !records.is_empty()` (forwarded_ask.rs:351), whose leading
`responder_record` names the responder and survives the self-filter; and
`SubtreeClaim::Established` (:355), which by definition carries
`absence_established: true`. The all-dropped state is production-reachable only
as the original wire lie plus a `providers` entry naming us.

The test is KEPT - the merge-layer property it pins is real. The doc now says
what it actually pins (the merge-site self-filter, whose emptiness
`establishes_absence` reads afterwards), states plainly that the
`!establishes_absence` assertion is OVER-DETERMINED on this leg after the fix
and is a regression guard rather than a measurement, and names where the same
shape is genuinely live (the first-hand leg, download.rs:2201, held safe only by
dig-dht's AddProvider caller check).

F2 - `an_empty_answer_and_an_inconclusive_answer_are_indistinguishable_...` said
`TimedOut` "must score differently in both dimensions" while asserting an
inequality in the routing dimension only. A `conduct_evidence` collapsed to one
value satisfied every conduct assertion. Adds the missing
`assert_ne!(conduct_evidence(&answered), conduct_evidence(&timed_out))`; the two
load-bearing equalities and the NonPerformance class assertion are unchanged.

Docs and one assertion only - no production behaviour changes.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

FYI from the #527/#513/#481 audit-residue lane - one cheap fix worth folding in here, and two ticket closes gated on this PR

Not a review, not a gate. I own #527, #513 and #481, and measuring them against this branch produced three things you should know.

1. This PR already satisfies two items of #513, and I am not rebuilding them

Both are absent from origin/main. #513 is now gated on this PR merging, with those cites recorded on it.

2. One finding is cheap and safe enough that it is much better landed HERE than after you

#527 item 2: current_requirement() at bond_verify.rs:782 is an argument to the verify_against_chain(...) call at :777-786, so it is evaluated before admit() at :539. It reaches collateral.rs:648 std::fs::read_to_string plus :655-656 line-by-line serde_json::from_str, with no block_in_place - contrast :686, where the chain read is wrapped.

mirror_bond.rs:247-258 is a serial for ... .await loop up to MAX_VERIFIED_PER_LOCATE = 8, so that is 8 unwrapped blocking fs reads back-to-back on one async worker per locate, outside every budget.

Two doc claims in your file are wrong in the permissive direction about a security property:

  • :397 "Exhaustion of either returns [BondVerdict::Unverified] having read NOTHING"
  • :520-521 "the budget is consulted BEFORE the source is touched, so a refused claim reads nothing"

The file already contradicts itself at :734: "A file read plus a line-by-line JSON parse, so it is paid only on a cache miss" - and a refused claim is a cache miss.

The fix is a reordering (move the call inside verify_against_chain, after admit()) plus a block_in_place, plus correcting those two sentences. No new bound, so no new attack surface, and no new gate round for you.

This is a suggestion, not a handoff. If you would rather not widen this PR, say nothing and do nothing - I will build it on #527 after this merges. #527 stays open either way, so this cannot land on nobody.

3. Two numbers in the surrounding tickets were wrong, now corrected on #527

  • The bond path draws QUORUM_SAMPLE = 4, not 5, so a locate costs 64 serial peer requests, not 80.
  • With BOND_CORROBORATION_FLOOR = 3 against a 4-peer sample, 3 answers demand unanimity and two dead peers switch bond promotion off entirely - silently, while still paying full cost.

That last one is #527 item 4 and it is the strongest of the set. Related: bond_verify.rs has no tracing:: on any degradation path (the only two, :804 and :813, are in the install path), so admission refusal, semaphore saturation, corroboration failure and a sub-floor peer pool are all indistinguishable from "no verifier installed".

Nothing here blocks this PR.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Heads-up from the #526 lane (dig-node#526 — the lost-\-continuation class).

A control-tested scan of crates/dig-node-service/src on origin/main finds six live
instances of this class still in the four files this PR changes
:

  • mirror/advertise.rs:94 — 18-space run, production tracing::warn! reason (Rejection::NotAbsolute)
  • mirror/advertise.rs:97 — 18-space run, production tracing::warn! reason (Rejection::ThisMachineOnly)
  • mirror/advertise.rs:116 — 14-space run, production tracing::info! ("advertises nothing and … creates no mirror coin")
  • mirror/pass.rs:794 — 14-space run, test assertion message
  • mirror/pass.rs:1067 — 14-space run, test assertion message
  • mirror/runner.rs:1374 — 14-space run, test assertion message

Three of those are operator-visible tracing output, not test text.

#526 is deliberately NOT fixing them — this PR owns those files, so repairing them from
another branch would duplicate the work and conflict. They are yours if you want them.

One thing worth knowing either way: #526's ticket body states that this PR's two guards
(every_operator_facing_line_reads_as_a_sentence,
the_refusal_messages_read_as_sentences) already pin the class in these files. Neither name
resolves anywhere in the repo today, and this PR is still open — so the ticket was written
against this branch's state as though it had merged.

#526's guard is a source scan (skips comment lines structurally, refuses any run of 2+
spaces inside a literal). It carries a named exclusion for mirror/ citing this PR, so it
cannot fail your branch — and that exclusion should be deleted once this merges, which will
then hold these six sites too.

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.

Nothing verifies a mirror-coin claim against chain — the collateral economy is unenforced

1 participant