feat(mirror): activate bond promotion on the coin's own peer declaration - #501
feat(mirror): activate bond promotion on the coin's own peer declaration#501MichaelTaylor3d wants to merge 23 commits into
Conversation
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>
…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>
loop-security — IN PROGRESS, not the verdictAudited head: Tooling note: the gitnexus and socraticode MCP servers both timed out on connect this session, so Scope being threat-modelled (12 files, +394/-132):
Findings will be posted as they are established, then a VERDICT comment. Nothing here is a merge |
loop-reviewer correctness gate — IN PROGRESS (not the verdict)head read: Established so far, each durable:
Still to check: the load-bearing pin claim ( |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
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)
crates/dig-node-core/src/mirror_bond.rs:226- the stated acceptance of BOTH closed tickets is unmet. An absentunverified_mirror_coin_idstill yieldsUnverifiedwith no hint-scan fallback, which is verbatim #473's Task and Acceptance and #466's second control. The declaration binds coin -> peer and supplies nopeer_id -> ownerresolution, so the fallback stays unreachable. Either implement it, or dropCloses #473and state the residue - do not rewrite the tickets' acceptance.crates/dig-node-service/src/mirror/lifecycle.rs:186(and:196) - corrupted log literals: an 18-space and a 22-space run inside twotracing::warn!messages, an eaten\continuation. Nothing tests the text, so nothing else catches it.crates/dig-node-service/src/mirror/bond_verify.rs:322- the activated promotion decision is untested throughverdict_for. EveryBondedin 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". Theadvertisesstep 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,f1170d08are allfix(wallet); this diff ismirror/*+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'sPeerTarget::peer_idis a required non-Optionfield (src/peer.rs:34),fast_connect.rs:349takesexpected_peer_id: PeerIdby value, anddig-tls 0.4.0/src/verify.rs:115fails the handshake withpeer_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 thepeer.rsdoc corrections at:870and:2482are not born-false. - Both new normative claims about the 25.6 DHT pointer are TRUE at this head:
dig-node-core/Cargo.toml:404takesdig-dht = "0.15",seams/dig_peer/dht.rs:493callsannounce_provider_with_collateral(id, coin_id), andSnapshotMirrorPointersis installed atserver.rs:2168. - The two bound tests are NOT vacuous. Remove the dedup and
one_stolen_identity_cannot_occupy_more_than_one_promoted_slotyields[attacker-1, attacker-2, attacker-3, honest]rather than the asserted interleave; inthe_promotion_bound_is_not_defeated_by_respelling_one_peer_idthe 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:898dedups by the RAWprovider_peer_idstring, so byte-identical copies never reach this locator and the case-varied respelling is exactly the case that does. peer_declarationis not a rival parser - it delegates todig_mirror_coin::declared_peerand compares only through the typednames. RemovingPeerDeclaration::NotReadableis consistent with every arm in the diff, anddeclaration_source_is_readablestill gatesverdict_foratbond_verify.rs:305in the fail-closed direction, so a future format change withholds credit rather than granting it.- "the binding is checked first" is accurate:
advertisesis evaluated atbond_verify.rs:329before therequired_collateralpricing at:333, so the no-census degradation stated in the PR body and in SPEC.md 25.6a is real. - All five
NodeMirrorEffects::newcall sites are updated;own_peer_idis re-read per pass rather than captured at spawn, and theNonepath 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-coin0.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.
loop-security — FINDINGS (IN PROGRESS, not the verdict)head What is CLEAN, with whyThe authorization primitive is sound. I read
Every failure direction in The promotion moves and locks no funds. It is a rank in a locator's output. The per-identity bound is not vacuous, and I checked the upstream that could have made it so. The limiter is on the correct side of the expensive step.
Findings, ranked1. HIGH — GATING. Activating the verifier loads a 16x third-party-egress multiplier onto a State, action, impact:
Why this gates rather than being filed: the module argued this hazard itself, as the justification 1b, same finding. The cache's stated eviction protection does not hold at an attacker-chosen 2. MEDIUM — not gating. The promotion is decided on a single uncorroborated chain read, and That method's own doc is explicit about what it hands back: with So when the oracle read fails, one dialled full-node peer's word decides a promotion. Nothing checks Not gating, for three reasons: it is the ecosystem's inherited posture for every chain read rather What I do ask for is one sentence in 3. MEDIUM — not gating; recommend fixing here, it is three lines. The create-side guard fails OPEN
Reachability, which is what keeps this at MEDIUM: Two things hold the severity down and are why I am not gating: the lifecycle only spends when 4. LOW — not gating. Two log literals in this diff are corrupted by a lost line continuation. 5. LOW — not gating, pre-existing. Unbounded peer-supplied text reaches a log field. What I did not do
|
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
loop-security pre-merge audit — one GATING finding. Full analysis in the findings comment on this PR.
VERDICT: CHANGES-REQUIREDhead audited: One GATING finding. The authorization design itself is sound and I want to say that plainly — the GATING1. HIGH — the activated verifier turns one cheap inbound RPC into up to 16 outbound HTTPS reads on An anonymous peer's This module argued this hazard itself as its reason for shipping inert. The PR lifts the mitigation NOT gating — named, with a recommendation2. MEDIUM — the promotion rests on an uncorroborated single-source chain read and 3. MEDIUM — the create-side guard fails open on money, and two rustdocs in this diff disagree about 4. LOW — two corrupted log literals, 5. LOW, pre-existing — unbounded peer text into a log field, Areas checked and clear
Read entirely from git objects ( |
|
Fix lane started — head Both gate rounds read. Working the eight findings in this order (TDD, failing test first for A/C/D/E):
Then Local build note: 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>
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 ( (a) Promotion decision at (b) Coin-id-binding rejection at 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 |
Gate verification notePR #501's promotion soundness rests on the This PR must verify the pin in its own gate round, confirming dig-tls carries the promised validation before #501 lands. |
Lane resumed (orchestrator e93b41)Resuming this DRAFT PR after the 15:11Z session cap. Branch Done so far:
Working queue, in order:
Re-merging |
…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>
Progress — head
|
Lane report — head
|
| 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
- The salvage commit was never compiled when it was written, and it turned out to be correct —
cargo checkpassed 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. - The cache no longer evicts on overflow the way SPEC said it did. The change is deliberate and I believe correct — an
unbondedis 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.
|
Lane resumed (orchestrator e93b41, 2026-09-02). Owner: implementer lane on Plan for this pass:
PR stays DRAFT until the full triple gate returns. |
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>
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>
4f8ef12 to
324080d
Compare
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 1. #473's residue is BOUNDED OUT — you were right to drop
|
…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.
324080d to
274b335
Compare
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.
CORRECTION — I propagated a false quantity into four places, and a gate caught itEarlier in this family I wrote that "roughly half of all provider records are pointer-less by What is actually true. So a node holding N capsules across S distinct stores publishes S pointer-less records and N What this does and does not change.
How it happened, since that is the reusable part. An adversarial pass supplied both the mechanism |
…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.
…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>
# Conflicts: # Cargo.lock # Cargo.toml
FYI from the #527/#513/#481 audit-residue lane - one cheap fix worth folding in here, and two ticket closes gated on this PRNot 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 2. One finding is cheap and safe enough that it is much better landed HERE than after you#527 item 2:
Two doc claims in your file are wrong in the permissive direction about a security property:
The file already contradicts itself at The fix is a reordering (move the call inside 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
That last one is #527 item 4 and it is the strongest of the set. Related: Nothing here blocks this PR. |
|
Heads-up from the #526 lane (dig-node#526 — the lost- A control-tested scan of
Three of those are operator-visible #526 is deliberately NOT fixing them — this PR owns those files, so repairing them from One thing worth knowing either way: #526's ticket body states that this PR's two guards #526's guard is a source scan (skips comment lines structurally, refuses any run of 2+ |
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-coin0.9.0 lets a coin's owner declaredig-peer:<64-hex>in the memo tail. Only theowner'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 topeer id (the declaration).
PeerDeclaration::NotReadableis removed — it described a situationthat 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 dialleris 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_idthePeerTargetpin, dig-nat passes it todig-tls, and the verifier fails the handshake with
peer_id mismatch; dig-peer re-checks afterconnect; 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_idthere 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 almostnothing 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
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.owner recreates it at an epoch boundary. Until then that holder is
unverifiedand sits atbaseline — a stated degradation, and no honest holder ranks below where no verifier at all would
put it.
createhas no integration test of its own; theformat round-trip is covered in
dig-mirror-coin(create→ chain →discover→declares_peer).Also corrected
Two stale normative claims that would mislead a later reader:
verdict_for's doc still said nothingwas promoted and no chain was read, and
peer.rs:2475was an uncorrected twin of the dialler claimfixed 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
gitnexuswas not usable for this lane (the registereddig-nodeindex is ~300 commits behind the primary checkout and a stale index answersimpactedCount: 0, risk: UNKNOWNrather 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 areadmitted_verdict_forandChainBondVerifier::verifyinside this module, plustests/mirror_bond_verify.rs. No consumer outsidedig-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.Findings discharged
ReadAdmissionis 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.Unverifiedis never memoisedVerdictCache::rememberrefusesUnverifiedon 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.c3a0522a). The review namedlifecycle.rs; the defect was inadvertise.rs(3),pass.rs(2) andrunner.rs(1) and not inlifecycle.rs. A lost\-continuation had baked 14-18 spaces of source indentation into six sentences an operator reads.verdict_formirror_bond.rsearly-continueMAX_VERIFIED_PER_LOCATE, keeps its place at baseline with no chain read.dig-tlspeer_idpincrates/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_itdrives the realverdict_foragainst coins built from genuine CAT spends, not a hand-writtenCoinRecord. The pre-existing unit test sits below the decision atpeer_declarationand would pass under averdict_forthat 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 red —only_a_coin_that_declares_the_claimant_promotes_itanda_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-tlspin — promotion soundness rests on it, so it was checked at sourceThe pin is an
Option, so the real question was whether dig-node's dial ever passesNone:dig-tls 0.4.0src/verify.rs:112-117—pin_and_bindrejects withpeer_id mismatch: expected {expected}, got {derived}.dig-nat 0.21.0src/dialer.rs:197—dig_tls::client_config_spki_pinned(&self.node, Some(peer.peer_id), ...);src/dialer.rs:194records that the dialer ALWAYS pins.dig-nat 0.21.0src/peer.rs:34—PeerTarget::peer_id: PeerId, not anOption, so a caller cannot omit it. dig-node's dial sites construct throughPeerTarget::with_addrs(capsule_resolver.rs:264,neighbourhood_probe.rs:249).The claim in
bond_verify.rsis 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 againstindex.crates.iowith the requiredUser-Agent), includingdig-mirror-coin0.9.0,dig-nat0.21.0 anddig-tls0.4.0. Two things worth stating rather than silently doing:chia-*set is ceilinged, not stale.chia-protocol/chia-bls/chia-sha2/chia-traitspublish 0.48.0, butchia-sdk-driver,chia-sdk-typesandchia-sdk-utilstop 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-protocolis pinned=0.3.0and 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 mirror— 168 passed, 0 failed (619 filtered out).cargo test -p dig-node-service --test mirror_bond_verify— 11 passed, 0 failed, 0 filtered out.cargo fmt --all— the salvage commit had never been formatted; two over-width lines corrected inef2cf4c7. 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 #473has been REMOVED, and the residue is stated rather than hiddenTaking the reviewer's option (b) on GATING finding 1. #473's Acceptance asks, verbatim, that a provider
record with no
unverified_mirror_coin_idwhose publisher does hold a valid mirror coin is positivelyverified 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 nopeer_id -> owner_puzzle_hashresolution, which is exactly whatdig_mirror_coin::discoverneeds in orderto find a coin starting from a record that names none. So
mirror_bond.rs:226still returnsUnverifiedfor 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 -> ownerresolution it needs — is recorded as the open remainderon #473 so a later reader finds it.
Honest limits (added to the list above)
unverified_mirror_coin_idis never promoted. It keeps its place atbaseline with no chain read. Closing that needs a
peer_id -> owner_puzzle_hashresolution that noon-chain artifact currently provides; it is the open remainder on Nothing binds a mirror coin's owner to a DHT peer id — verification cannot name a claimant #473.
What changed in this pass
coherent and are committed: the
Cargo.lockdig-mirror-coin0.9.0 resolution, and a test importcorrected from a
crate::wallet_dbpath that does not exist tocrate::sage::db::WalletDb. Thatsingle import was the entire build / clippy / test red on the prior head.
origin/mainmerged at04079d57(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 wereCargo.toml(version)and
Cargo.lockonly. Version re-read from the file after the merge: 0.253.0, unchanged.dig-mirror-coin0.9.0 now resolves single-line. The manifest already declared"0.9"while thepost-merge lock still carried 0.7.0 from main;
cargo update -p dig-mirror-coinbrought them intoagreement.
greping the lock confirms exactly onedig-mirror-coinentry.Merge remote-tracking branch 'origin/main' into loop/473-peer-bindingheader. Each was re-created withgit commit-tree(same tree, remapped parents) aschore(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 branchonly —
mainwas 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.rsnow exists on this branch and both landed.CORROBORATION_FLOOR= 2BOND_CORROBORATION_FLOORis 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 isUnverifiedat baseline rank.tests/mirror_bond_corroboration.rs:486pins it, and:516pins the sync floor at two from the other side — so neither can be silently moved onto the other's value.UnverifieduncacheableVerdictCache::rememberstill refusesUnverifiedon 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 byMAX_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.guard_panicscorroborated_source.rs:111-112, with the link to the original at:120and the reason at:353.Build and test evidence for this head (324080d)
cargo check --workspace --all-targets— exit 0.cargo clippy --workspace --all-targets -- -D warnings— exit 0.cargo test -p dig-node-service— run on a clean tree withCARGO_INCREMENTAL=0; counts reported in thehand-back comment.
Two local-only traps hit while measuring, recorded because each produced a reassuring wrong answer:
a
cargo test … | tailpipeline reported exit 0 while the target had failed to link (the pipelinereturns
tail's status), andcargo check's metadata-only artifacts then shadowed the test build(
crate ... required to be available in rlib format), followed by a rustc ICE injoin_codegenfrom apoisoned 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.
gitnexusremains unusable for this lane — the registereddig-nodeindex is ~300 commits behind the primary checkout, and a stale index returns
impactedCount: 0, risk: UNKNOWNrather than erroring, which is a false-safe zero. The radius wastherefore established by grep + direct read and is stated as such:
Cargo.lock—dig-mirror-coinonly; no otherdig-*orchia-*line moved. The multi-linechia-blsand
chia-protocolsets are pre-existing onmain(chia-wallet-sdkpulls several throughclvmrand
chialisp) and are not this diff's delta.corroborated_source.rs— the changed line is inside#[cfg(test)]; no production path reads it.Bump rationale
0.252.14 — patch, in the 0.252.x merge chain.
mainis at 0.252.6, and the other eight open PRshold 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.tomlon disk after the merge, not from the commit log — a rebasecan 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
324080d7was the most blocked PR in the repo: four failing checks, four unresolved threads, and aCONFLICTINGmerge state. Head is now274b3350.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.
Lint commit messages(×2)Rustfmtdbmust sort beforepeer_readsin a#[cfg(test)]import blockTest + coverageThe two
Lint commit messagesfailures were the same commit seen by two runs, which is the only place theone-cause hypothesis held.
The test failures were this PR's guard doing its job
createnow refuses before selecting any coin when the node has reported no peer id, because a coin namingno peer locks collateral for an epoch that no reader could credit. Three fixtures passed
None, under acomment 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 withrepeatso the length isright 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_nothingrefuses at the advertisement guard, which returns before
declaration_for_createis consulted, so itsNoneis never reached. That test asserted onlyis_err()— which this PR's second early return makesambiguous, 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-filterpass, 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/mainwas merged in. Both conflicts were purely the version line. A rebase was not used because arebase whose patch already merged prints
dropping <sha> ... patch contents already upstream, exits 0 withzero conflicts, and silently discards the version bump.
Scope is unchanged: this PR closes #466 ONLY
Closes #473stays removed and was not re-added. Confirmed through the GraphQLclosingIssuesReferencesparser rather than by reading the body, because a keyword inside a code spanparses 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; theshort 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_sentencesdrivesdeclaration_for_create, so it asserts overlifecycle.rs's own two refusal literals — which werenever the corrupted ones. The three genuinely broken runtime lines are inline
tracingliterals inadvertise.rswith no test reachability at all. So three corrupted operator-facing lines sat behinda 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_URLSandnothing_publishable(), andadvertise.rs::every_operator_facing_line_reads_as_a_sentencewalks allfour with a non-vacuity control, driven from a
Rejection::ALLconst with the expected countderived 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
matchis on the type, so a newRejectionvariant cannotcompile 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
4still passes while the new message ships unguarded. Thatis 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 secondoperator-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)androw 2 by
wallet(4), so they differ in owner puzzle hash and declared peer id. That matters becauseverdict_forreachesUnverifiedfrom two disjoint places, so a row-2 coin malformed anywhere in thechain half would satisfy the assertion while proving nothing about the declaration. The distinct
owners are load-bearing and stay (
creating_spendderives the parent from(owner, asset, amount)),so a control was added instead: the same coin, same root, claimant
stranger, must returnBonded.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
\string continuations still bake source indentation into seven shipped messages #526 — the lost-continuation defect class is notswept crate-wide: seven more survive, including a client-facing JSON-RPC error body at
pairing.rs:146and the assertion message of the test that guards this class atlogging.rs:261.All pre-date this PR's merge base.
advertise.rsare operator-facing runtime lines. Thepass.rsandrunner.rsthree are testassertion 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_reasonand the public function was leftundocumented. It compiles, rustfmt and clippy are clean, and only the rendered docs are wrong.
Fixed by moving the helpers above the doc block.