Uh oh!
There was an error while loading. Please reload this page.
fix(nc12): corroborate the anchored root, and widen the sole-owner sweep to dig-node-core - #369
Conversation
MichaelTaylor3d
commented
Aug 26, 2026
loop-security gate — STARTEDAuditing head Note: Scope this round (gated UP — this decides which bytes a user is served):
Findings posted here as formed. Verdict posted before my final summary. |
MichaelTaylor3d
commented
Aug 26, 2026
GATING FINDING 1 — the two serve-decision calls are NOT unanimous; they are a flat 2-of-N thresholdSeverity: HIGH. Measured against the compiled resolver at
for voice in&voices {matchask(voice.clone()).await{Ok(answer) => answers.push(answer),Err(e) => refusals.push(e),}}For For So the rule that actually ships for the two calls the read-path pin makes is: two MeasuredProbe appended to the PR's own The control in the same test asserts ExploitState: an operator follows §14.4b's stated remedy and configures
The number of honest dissenters is irrelevant. 2-of-3, 2-of-10, same outcome — so adding endpoints No attacker is even required. Three endpoints, two of them a generation behind: two say Why the PR's own tests do not see it
SPEC §14.4b claims the property the code does not have
That is precisely the false-satisfaction class dig_ecosystem#2790 exists to end, and it is on the Shape of the fix (not prescriptive)
Whatever the code fix, §14.4b must not claim unanimity on a call that cannot express dissent. |
MichaelTaylor3d
commented
Aug 26, 2026
VERIFIED CLEAR — independence-by-reach, the transitivity revert, and the boundariesAll measured in my own worktree ( 1. The transitivity revert now goes RED — and the OLD fixture confirms the false greenApplied the exact revert the test names (merge into the FIRST matching group only, via a It fires on the first I also re-added the old bridge-in-the-MIDDLE fixture and ran it under the same revert: Green under the revert. The lane's self-reported false green is confirmed as real, and the 2. The closure is correct, not merely correct on the fixtureThe two fixtures pin two orderings. I brute-forced the general property instead: 4000 deterministic The single-pass 3. Boundaries at exactly 0, 1, 2, 3 — measured, not read
Row 5 is the one that matters and the one the PR does not test: at N=2 both treatments refuse, so 4. Honesty note (NOT gating) — two endpoints is the most fragile count, and §14.4b recommends itRow 4 above: with exactly two configured endpoints, an outage or a DoS at either one stops the node |
MichaelTaylor3d
commented
Aug 26, 2026
#366 sweep widening — VERIFIED CLEAR, with one non-gating fragilityI re-implemented the classifier ( Every one of the 9 occurrences classified, and each verified by hand against its latch anchor:
All five latch anchors confirmed as genuine column-0 The three claimed measurements, re-measured
Crate-qualification and the zero-needle root — both correct
NON-GATING (defense-in-depth) — the terminator replay is prose, and prose rotsThe Assessment: acceptable to ship, worth a follow-up guard. Bounding the risk honestly:
So this is not a live defect. But the cheap fix is a ~15-line executable assertion — the replay I just SPEC coverage — two drifts, neither in the dangerous direction
Also verified clear
|
MichaelTaylor3d
commented
Aug 26, 2026
GATING FINDING 1a — the DISAGREE refusal is caught by an |
MichaelTaylor3d
commented
Aug 26, 2026
loop-security VERDICT: CHANGES-REQUIREDAudited head: All probes were run in my OWN worktree (
GATING (one finding, two halves — both posted in full above)Finding 1 — while the identical fixture on Finding 1a — SPEC.md §14.4b claims the property the code does not have — "One dissenting voice is a REFUSAL" Either clears the gate; the second is the floor:
Either way, add the missing fixture: a reached voice that rejects. Do not merge on this comment. Re-gate the corroboration leg only ( |
MichaelTaylor3d
commented
Aug 26, 2026
Verdict detail — verified clear, non-gating items, coordination(continues the CHANGES-REQUIRED verdict above; same head The three properties I was asked to check structurally1. Independence derived from REACH — CORRECT, and correct in general. The intersection is computed The single-pass 2. Unanimity, never majority — HOLDS for 3. Unreachable DROPPED not dissent, and the boundary — CORRECT, measured:
Row 5 is the only configuration that distinguishes drop from dissent, and the PR does not test it — at The lane's self-reported false green — genuinely fixedI re-ran the non-transitive revert (merge into the FIRST matching group only). The rewritten It fires on the first assertion, and #366 sweep — clearIndependent re-implementation of the classifier, replayed over both trees: All 9 occurrences hand-classified against a verified column-0 Also clear
Evidence reproduced in my worktree — unpiped, |
| gate | result |
|---|---|
cargo fmt --all --check | exit 0, 0 diffs |
cargo clippy -p dig-node-core -p dig-wallet --all-targets -- -D warnings | exit 0, 0 errors, 0 warnings |
| dig-node-core lib | test result: ok. 1000 passed; 0 failed |
| dig-wallet lib | test result: ok. 656 passed; 0 failed; 1 ignored |
all integration targets incl. banned_address_patterns | green, exit 0 |
| required contexts, asserted BY NAME | Lint commit messages / Check version increment / Rustfmt / Clippy / Test + coverage — all SUCCESS |
| unresolved review threads | 0 |
Every claimed number reproduces.
NON-GATING — follow-up tickets, do not hold the merge
- The terminator replay is prose, and prose rots.
lib.rs:195is harmless only because of where
it sits; a later column-0#[cfg(test)]above it would go silent and the doc comment would still
say "harmless". Bounded (dig-node-core has zero needles; the window ends at the next item), so ship
it — but make the replay a ~15-line executable assertion. PR#368's executed-fixture direction is
exactly this remedy. DIG_NODE_CHAIN_ENDPOINTSis absent from BOTH env-var tables (SPEC.md:218andcrates/dig-node-core/SPEC.md:1198). It is the one control that turns the feature on, and it exists
only in §14.4b prose.crates/dig-node-core/SPEC.md:397-400is stale — still says production usesCoinsetResolver
over a single endpoint. Under-claims, so not the dangerous direction, but §4.2 asks the SPECs to agree.- §14.4b's remedy recommends exactly two endpoints — the most fragile count. At N=2 an outage or
DoS at either one stops the node serving pinned content, an availability regression against
today's single-source behaviour. Recommend three or more, or state the cost where the remedy is given. corroborated_resolver.rs:78usesgroup.first()only.independent_voices' doc promises that
a caller can try each member of a group in turn and that any of its endpoints may speak for it — no
caller does. Fails closed, but the doc claims a failover that does not exist.voices()does N DNS lookups sequentially, uncached and un-timed-out, on the request path. The
no-cache choice is right (a stale independence verdict is worse than none), but one blackholed
endpoint stalls every content read for the resolver timeout, N times over, each holding aspawn_blockingthread. Resolve concurrently and bound it. Not a regression at the default N=1.- Privacy: every read now discloses the store id to N third parties instead of 1. Operator's
choice, but §14.4b does not say so. - Pin
dig-ipc-protocolexactly at 0.3.0. The caret admits 0.3.1, which moved that crate tochia-bls 0.36.1. The lock pins 0.3.0 so nothing bridges today; per CLAUDE.md §2.4b an exact pin
removes the latent second-line risk.
Coordination with PR#368
#368 rewrites the same doc block in sources.rs (~lines 502-560), replacing prose measurements
with executed JSON fixtures, while #369 adds new prose measurements to it. Merging #369 first means
#368 conflicts there and must resolve.
No security consequence:#368 removes a dead constructor and adds no ChiaQuery::new( production
site, so the sole-owner sweep still finds exactly one owner after both land. The only note is that the
conflict resolution should prefer #368's executed-fixture shape for the shared block, since that is
the remedy for non-gating item 1.
69b3f73 to
5ab2438CompareMichaelTaylor3d
commented
Aug 26, 2026
loop-security re-gate (scoped: the dissent-classification leg) — IN PROGRESSAuditing head Interim, so the work survives a stall. Not a verdict. Check 2 (real production producer per variant) — SATISFIED so far. All three variants are produced from a live chain read, not only from a fixture:
Check 1 (vacuity gone) — the fixed site is genuinely fixed. Open lines of inquiry (not yet findings):
Continuing with checks 3-6. |
MichaelTaylor3d
commented
Aug 26, 2026
loop-security re-gate: PASS (with three non-gating findings)Audited head: Scope: the dissent-classification leg only ( The remedy holds. Check-by-check1. The vacuity is gone, and there is no sibling - CLEARExactly one generic equality survives in the changed files: No call site instantiates 2. Every |
| Variant | Production producer |
|---|---|
Confirmed | coinset_resolver.rs:133 (pinned), coinset_resolver.rs:145 (lineage) |
Rejected | coinset_resolver.rs:134 (pinned), coinset_resolver.rs:147 (lineage) |
Unreachable | coinset_resolver.rs:135 (pinned), coinset_resolver.rs:153 (lineage) |
The lineage producer is the strong one: Rejected at :147 is reached structurally, from a walk that COMPLETED and whose history does not contain the root. No probe, no error-string inspection.
3. Can the veto be starved? - PARTLY. See Findings 1 and 2.
4. The three Ok(None) | Err(_) fallbacks - CLEAR, no bypass
All three take the fallback on the SAME anchored_root_resolver: Arc<dyn AnchoredRootResolver> field, which production sets to default_anchored_resolver() (lib.rs:4460, lib.rs:4731) = CorroboratedResolver:
lib.rs:3084-3095seams/content/content_serve.rs:429-441seams/dig_rpc/dispatch.rs:101-108
CorroboratedResolver OVERRIDES both verification methods, so none of the three can fall through to the permissive default in shared/chain_view.rs. The widened Err does land on the dissent rule in every case. Verified, not accepted.
The test at corroborated_resolver.rs:708-720 pins this composition directly - it asserts the tip resolution refuses on dissent AND that the bounded fallback refuses on the same fixture.
5. AnchoredRootResolver genuinely unchanged - CLEAR
git diff <base>..<head> -- crates/dig-node-core/src/shared/chain_view.rs is zero lines. So is the diff for crates/dig-node-core/src/lib.rs. The trait, both default impls and AnchoredStoreState are byte-identical to base. The only export change is seams/chia_peer/mod.rs adding two privatemod lines; pub use coinset_resolver::CoinsetResolver is untouched and default_anchored_resolver stays pub(crate). No added default, no widened surface.
6. The test is not vacuous, and the revert is measured - CLEAR
The control genuinely distinguishes. The suite contains real SERVING cases, so it is not "every case refuses": corroborated_resolver.rs:761-768 (9 confirm + 1 unreachable yields Ok(())) and :640-650 (both_up yields Ok(()) on both calls). An always-refuse implementation fails those; the flat-threshold implementation fails the dissent cases. The two axes are separated.
The revert I ran, verbatim - in my own throwaway worktree C:\tmp\worktrees\sec369 (detached at the head SHA), never the lane's tree:
--- a/crates/dig-node-core/src/seams/chia_peer/corroborated_resolver.rs+++ b/crates/dig-node-core/src/seams/chia_peer/corroborated_resolver.rs@@ -263,12 +263,7 @@ impl CorroboratedResolver {
Verdict::Confirmed => confirmations += 1,
// Decided the moment it is seen: a rejection is not weighed against the
// confirmations, it ends the resolution.
- Verdict::Rejected(why) => {- return Err(format!(- "{what}: an independent chain source REJECTED this root ({why}) - \- refusing rather than outvoting it (chain is the authority)"- ))- }+ Verdict::Rejected(why) => unreachable.push(why),
Verdict::Unreachable(why) => unreachable.push(why),
}
}(The removed string literal carries an em-dash in the source; rendered as - here so the comment body stays byte-safe.)
That restores the exact pre-fix semantics: a rejection and an unreachable chain become the same signal and both are discarded, leaving the flat 2-of-N confirmations bar.
Command: cargo test -p dig-node-core --lib corroborated_resolver::tests --no-fail-fast
- Baseline at head:
ok. 6 passed; 0 failed. - With the revert:
FAILED. 5 passed; 1 failed.
The single failing test:
seams::chia_peer::corroborated_resolver::tests::a_reached_voice_that_rejects_refuses_and_is_never_outvoted
panicked at corroborated_resolver.rs:680:14:
a reached rejection must refuse the pin: ()
It fails at the FIRST dissent assertion (2 confirm + 1 reject must refuse) - the .expect_err("a reached rejection must refuse the pin") at head line :685, shifted to :680 by the five removed lines. The : () payload is the very unit type that carried the original vacuity. The other five tests, including both serving controls, still pass - so the new test is the only thing pinning the fix, and it is anchored to the fix rather than to something incidental.
Worktree restored (git status --porcelain empty) and removed. No shared checkout was touched at any point.
Findings (ranked, none gating)
FINDING 1 - MEDIUM - the rationale at coinset_resolver.rs:117-121 claims a property the code does not have
The comment states:
The probe races the call it classifies, and that race is deliberately biased. ... The opposite misclassification, a rejection read as unreachability, is the defect this type exists to remove, and it fails OPEN. So the classification is arranged to be wrong only in the direction that refuses.
The code (coinset_resolver.rs:131-137) runs the verification FIRST and the probe SECOND:
Ok(()) => Verdict::Confirmed,Err(why)ifself.is_reachable(store_id).await => Verdict::Rejected(why),Err(why) => Verdict::Unreachable(why),That ordering admits exactly the misclassification the comment excludes: the chain is reachable at the verification (a GENUINE rejection) and unreachable at the probe, so the voice is classified Unreachable, dropped, and does not veto. Nothing arranges against it. The only arrangement that would is defaulting an inconclusive probe to Rejected, and the code defaults it to Unreachable.
Sharpening it: is_reachable (coinset_resolver.rs:98-103) is unspent_coins_by_hint(...).is_ok(), so an endpoint that is UP but answering 429/5xx classifies as unreachable rather than as reached - and the failure path is precisely where the extra probe request is spent, which is what makes a rate limit likely there.
Non-gating: it is a comment, not a control. Reported because it is the sentence a future auditor would rely on to skip this analysis. Recommend correcting it in this PR - one paragraph, zero risk.
FINDING 2 - MEDIUM - the veto is starvable, and SPEC.md 14.4b over-claims on that point
all_confirmed (corroborated_resolver.rs:259-283) drops an Unreachable voice and passes on confirmations >= 2. The veto is therefore only as available as the dissenting voice is reachable. Concrete scenario, built from the SPEC's own operational case:
- The operator follows 14.4b ("prefer three or more") and configures 3 endpoints.
- Two are a generation behind. 14.4b names one-behind as an ordinary state, so two-behind is ordinary too. A read pinned to the old root
R_oldgetsConfirmedfrom both, no attacker needed. - The current endpoint returns
Err(mismatch), its probe succeeds, soRejectedyields a veto and the read is refused. This is the fix working. - The attacker drives reads until the current endpoint rate-limits the node - every read already fans out to all endpoints, and the failure path adds the extra
is_reachablerequest. That endpoint's verification AND its probe now both returnErr, so it is classifiedUnreachableand dropped. confirmations = 2, zero rejections, soOk(())- the node serves the superseded generationR_old. NC-9 anti-rollback defeated.
The same outcome arises with no attacker at all if the current endpoint is merely down while two others lag.
Why this does not gate: on main today the dissent is discarded unconditionally, so this PR is strictly stronger, and the drop-unreachable rule is a disclosed, deliberately chosen trade (corroborated_resolver.rs:167-173, SPEC 14.4b) whose alternative is an availability collapse. It is also pinned as intended behaviour by the control at :761-768.
What should change: 14.4b currently asserts
The bar rises with
Nbecause every added source can veto, not because more must agree.
That holds only for REACHED sources. An attacker who can silence rather than outvote is unaffected by N. Recommend qualifying that one sentence in this PR, and tracking the starvation itself as a follow-up.
FINDING 3 - LOW/MEDIUM - NEW on the default install: an uncached DNS gate in front of every read
At base, default_anchored_resolver() returned Arc::new(CoinsetResolver) - no name resolution of its own (verified at 240732e:crates/dig-node-core/src/seams/chia_peer/coinset_resolver.rs:120-122). At head it returns CorroboratedResolver, whose voices() (corroborated_resolver.rs:158-165) is deliberately recomputed per resolution and calls DnsReach::addrs, which calls tokio::net::lookup_host. This sits ABOVE the chain client, so no client-side caching absorbs it, and content_serve.rs:401-404 calls it on every enforced read with no cache in between.
Two consequences, both landing on the DEFAULT single-endpoint install:
- Cost asymmetry on a caller-drivable path. One content read now costs one
getaddrinfoper endpoint, uncached (glibc without nscd does not cache), and a fallback read pays it TWICE becauseanchored_stateand the fallbackverify_pinned_rooteach callvoices(). The entry points are peer/client-drivable:content_serve.rs:429,dig_rpc/dispatch.rs:101,dig_peer/module_reshare.rs:932. - A new fail-closed denial. If
addrsfails,voices()returns empty and both rules returnErr("no configured chain endpoint could be reached"), so every read refuses. That now happens even where the HTTP client would have connected fine - a live pooled connection, or an install behind anHTTPS_PROXYthat resolves the origin itself. Before this PR the read had no such gate.
A related inconsistency worth naming: the module bounds the DNS lookup at 3s with an explicit comment that it "runs on the content-serve request path", but the CHAIN queries - far more expensive - are issued sequentially over all voices (corroborated_resolver.rs:261) with no per-voice bound at this layer. One slow configured endpoint stalls every read for its full client timeout, N times over. The author's own rationale for bounding DNS applies with more force here.
Follow-up, not a gate: cache the per-authority resolution for a short TTL, and bound or parallelise the per-voice chain query the way the lookups already are.
What I could NOT fully verify
Stated explicitly, because an unverified check reported as passed is the failure this gate exists to prevent.
- Check 3 was verified from code, not executed. I confirmed the classification ordering and that
is_reachableis a second network call, but I ran no live probe againstapi.coinset.org- so the rate-limit step of Finding 2 is derived, not measured. What IS measured is the ordering, and that a probe failure yieldsUnreachable. - Finding 3's per-read DNS cost is verified as a code path, not benchmarked. I confirmed no cache exists between
content_serveandvoices(). I did not measure real-world resolver caching on a target host, and OS-level caching may absorb much of it on Windows. - I did not re-audit
endpoints.rsURL parsing or thedig-walletsole-owner sweep in depth - out of the re-gate scope and cleared by earlier rounds. I read both; thedig-wallet/src/sage/sources.rschange is confined to#[cfg(test)] mod sole_owner_testsand contains no production code. - I ran only the
corroborated_resolver::testsfilter, per the disk constraint in the brief - not the full suite. NoStorageFullwas encountered (218G free); the rest of the suite is CI's word, and CI is reported green.
Read-only audit. Nothing edited, pushed, merged or undrafted. The revert probe ran in a private detached worktree at C:\tmp\worktrees\sec369, cut from the head SHA, restored and removed afterwards; no shared checkout and no other lane's worktree was touched.
MichaelTaylor3d
commented
Aug 26, 2026
Gate findings folded in — |
| fix reverted | failing test | failure |
|---|---|---|
| probe ordering | a_source_that_answered_then_rejected_still_vetoes_when_its_chain_drops | a source that answered and then rejected must refuse the resolution: () — i.e. the resolution returned Ok(()) and served |
| single-endpoint carve-out | one_endpoint_answers_without_a_name_lookup_but_two_still_need_one | left: Err("anchored state: no configured chain endpoint could be reached (chain is the authority)") / right: Ok(Some(Bytes32(aaaa…))) |
| cache read path | a_cached_reach_looks_up_once_per_ttl_and_survives_a_resolver_blip | left: 2 / right: 1 — the lookup was paid twice |
The distinguishing fixture is DegradingChain, which answers a bounded number of hint reads and
then stops. A chain that fails every read is Unreachable under both orderings and a chain that
answers every read is Rejected under both; only "reachable for the first read, gone by the second"
separates them. The veto is asserted through the agreement rule with two honest voices present,
not on the classifier's verdict, because the consequence — not the shape — is the property.
State
corroborated_resolverfilter: 7 passed; 0 failed (baseline 6 + 1 new).chia_peer: 15/15.banned_address_patterns: 3/3.cargo clippy --all-targets: clean.- Version 0.156.1 /
dig-node-core0.59.1 (branch was already 0.156.0 vs main's 0.155.0). - Blast radius:
EndpointResolver,DnsReach,CachedReach,voices,independent_voiceshave
no callers outsideseams/chia_peer/(measured by ripgrep acrosscrates/; gitnexus was not
indexed for this worktree, so this is the §2.0 documented fallback). The module's exported surface
—default_anchored_resolver,resolution_coinset,CoinsetResolver— is signature-unchanged. Closeslist untouched. Still DRAFT.
… and sweep dig-node-core
…bump chia-safe deps
…che the reach Three gate findings on PR#369, none gating, two of them the same hole. The classification in `EndpointResolver::verify_pinned_root` probed reachability AFTER the verification it was classifying, so a source that answered and then rejected a root was refiled as `Unreachable` whenever its follow-up probe happened to fail. `CorroboratedResolver` DISCARDS `Unreachable` and passes at two confirmations, so that is a mechanism for converting the dissent of a source an attacker can degrade into silence — two lagging endpoints then carry the serve. The doc comment already claimed the classification "is arranged to be wrong only in the direction that refuses"; it was a claim, not a control. The probe now runs FIRST. Reachability is a fact that, once established, cannot be withdrawn, and the residual misclassification runs the refusing way: a chain that drops between the probe and the verification is recorded as a REJECTION. `EndpointResolver` holds `Arc<dyn ChainReads>` so the one fixture that separates the two orderings — a chain reachable for the first read and gone by the second — can be built at all. `SPEC.md` §14.4b said "the bar rises with `N` because every added source can veto". That holds only for REACHED sources, and the difference is the whole property; it now says so, and states the no-downgrade rule as normative. Separately, `voices()` gained a per-read `lookup_host` above any chain-client cache. That is a new `getaddrinfo` on a caller-drivable path (twice on a read that falls back from the tip to the bounded pinned-root check) and a new fail-closed denial when only the NAME lookup failed. Resolved address sets are now cached for 60s and reused for 10 minutes across a resolver blip, and a SINGLE configured endpoint — the default install — is a voice without any lookup at all, since independence is a relation between endpoints and there is nothing for a lookup to decide. With two or more, an unresolvable endpoint still contributes no voice. Refs #365, #366. Co-Authored-By: Claude <noreply@anthropic.com>
PR#371 took dig-wallet to 0.37.1 on main, so the rebase resolved this branch's own 0.37.0 bump away as superseded -- leaving the crate at exactly main's version while still carrying 239 changed lines in sage/sources.rs. That is the rebase-drops-an-identical-bump trap: invisible to status, diff, fmt, clippy and tests, because nothing local compares against main. Bumped to 0.37.2 with the lock synced in the same commit, since a Cargo.toml bump that does not reach Cargo.lock fails CI under --locked. Co-Authored-By: Claude <noreply@anthropic.com>
99bcf13 to
d279c48CompareUh oh!
There was an error while loading. Please reload this page.
DO NOT MERGE — gate round in progress. Custody-adjacent; takes the full gate.
Closes#365
Closes#366
The two remaining NC-12 items on dig_ecosystem#2790. Measured against freshly-fetched
origin/main@4efa57fc(re-checked twice during the lane; unmoved).#365 — the anchored root: what is CORROBORATED, and what is STATED
Corroborated (built)
The anchored root, on all three calls the serve decision makes —
anchored_state,verify_pinned_root,verify_lineage_root. Newseams/chia_peer/{endpoints,corroborated_resolver}.rs;default_anchored_resolver()now returns the corroborating resolver.DIG_NODE_CHAIN_ENDPOINTS(comma list).DIG_NODE_COINSETkeeps its existing meaning when that is unset, so no current configuration changes meaning; the default remainshttps://api.coinset.org. An unparseable entry is dropped, never defaulted — a typo must not be able to pose as a second voice.Stated as accepted limitations —
SPEC.md§14.4b, each with its blast radiuscoin_records_by_puzzle_hashes/_by_hints/_by_parent, mempool submission, the melt confirmation, and the direct RPC-surface singleton walks. Enumerated in SPEC so no reader infers corroboration from the rule above. These live indig-wallet/src/sage/chain.rs, which belongs to the harden(dig-wallet): chain.rs:111 with_sources is dead code that builds a single-source peak_height path #360 lane (PR fix(node): dead transport ctor, temp-dir leak, version-gate reporting, executed sweep examples #368) — not touched here, by coordination.Why chia-query PR#39
independence_groups()is not consumed hereSame principle, unreachable layer, and measured rather than assumed:
dig-node-coredeclares nochia-querydependency at all — the dormant light client was removed and itschia-query = "=0.5.1"pin was precisely what held this crate off the chia 0.36 line. And chia-query is now 0.17.0, entirely on chia^0.36, while this crate pins chia^0.26. Consuming it would re-add the shed dependency and drag the stranded chia line. Reported, not shimmed (dig_ecosystem#3161/#3152).Its principle — derive independence from the routing mechanism, not a parallel label — is the one implemented here, one layer out: across separately-configured endpoints rather than within one router's tiers. The two compose; they do not compete. Note also this path does not run through the chia-query router at all — it is the
digstore_chainsingleton walk.#366 — the sole-owner sweep now covers
dig-node-coreThe heuristic is unchanged —
sweep,ends_a_column_0_item, theSwept { sites, ended_inside_a_test_item }fail-closed flag and the explicitly-open enumeration are all preserved. What changed is the haystack, plus the two things a wider haystack breaks:dig-wallet/sources.rs:NNN), andOWNERwith them. File names repeat across a workspace — both swept crates already contain achain.rs— so the unqualified"sources.rs"owner would have accepted a second fabric built in asources.rsin any swept crate. The guard would have widened its scope and lost its property in the same change.dig-node-coreholds zeroChiaQuery::new(, so a global needle total stays positive fromdig-walletalone — a typo in the second path would leave the new scope reading nothing while the guard still reported a real haystack. Per-root counting separates "clean" from "never opened".A deliberately-introduced second fabric in the new root is demonstrated to fail, through the same
production_call_sites_in, on a fixture namedsources.rsprecisely because that is the name the unqualified check would have waved through.The two known-silent shapes, re-run against the new haystack — one occurs
#[cfg(test)]as string content: ABSENT from both crates. Every occurrence of that text inside a literal is indented, and indentation is what the latch requires.dig-node-core/src/lib.rs:195(const DEFAULT_CACHE_CAP: ... ; // 1 GiB). The guard is not silent there today, and the reason is stated rather than trusted: the shape only bites when the latch is already SET, and no column-0#[cfg(test)]precedes it in that file. Verified by replaying the classifier over both crates — no file reaches such a terminator with the latch set, and no file ends inside a test item. That is a property of where the line sits; move it below a column-0#[cfg(test)]and it goes silent with nothing reporting it.Blast radius checked
gitnexus impact(per-worktree index, 11,798 nodes) ondefault_anchored_resolver,resolution_coinset,CoinsetResolver,anchored_root_resolver_arc,unique_suffix— all LOW — paired with grep, because gitnexus under-counted (default_anchored_resolverreported 0 direct callers; grep found the real two,lib.rs:4460and:4731, both Node construction).resolution_coinsethas 6 call sites, all retained.No public API removed or renamed:
CoinsetResolverkeeps its unit-struct shape and its single-endpoint behaviour, so itspub useand its five doc references are untouched. The only pre-existing bodies changed aredefault_anchored_resolver, theCoinsetResolverimpl (now delegating, same semantics), the test-onlyproduction_call_sites->production_call_sites_in, and a testtempdirhelper.detect_changesis MCP-only in this CLI build, so the diff-level equivalent was done by enumerating changed signatures; the result matches the prediction exactly.Two defects found in my own work, both by existing guards, both fixed here
banned_address_patternscaught me concatenating host and port. Real, not stylistic: this module strips IPv6 brackets when parsing, so re-joining produces::1:8555and would fail to resolve every v6 endpoint — on a §5.2 IPv6-first dial path. Nowlookup_host((host, port)). §5.2 working as designed.Verification
Revert-proofs, each run after committing (file-copy revert, never
git checkout):OWNER/sites unqualifiedGates, all run unpiped with counts asserted from the
test result:line:cargo fmt --check0 ·cargo clippy -D warnings0 · dig-node-core lib 1000/1000 · dig-wallet lib 656/656 ·banned_address_patterns3/3. CI green: all five required contexts asserted by name viacheck-merge-preconditions.sh(Lint commit messages, Check version increment, Rustfmt, Clippy, Test + coverage), 0 unresolved threads. The >=80% coverage gate passes insideTest + coverage.A pre-existing flake fixed on the way (it made the suite red, and would have made the midnight cron red): the
profile_synctesttempdir()was keyed(pid, counter-from-zero)and never cleaned up, so a recycled pid inherits an earlier run populated directory — measured against 223 leaked dirs. Failed in-suite, passed alone, passed on re-run. Now carries a monotonic component. The leak itself is untouched and reported as a follow-up.Versions / deps
dig-node-service0.154.0 -> 0.155.0,dig-node-core0.58.0 -> 0.59.0,dig-wallet0.35.0 -> 0.36.0 (all MINOR: new capability, no breaking surface).§2.4b:
dig-rpc-protocol0.10.1 -> 0.10.2 anddig-ipc-protocol0.2 -> 0.3 bumped (chia-free / same chia-0.26 line, verified from the index). Every otherdig-*dep is stranded behind the chia 0.36 uplift —dig-tls0.4.0,dig-identity0.7.1 anddig-nat0.21.0 all declare chia^0.36.1against this crate^0.26. Reported, not shimmed.Coordination — a real overlap the orchestrator should sequence
PR #368 (#360/#363) edits the same
sweepdoc-comment region ofsources.rsthat #366 required me to update — its doctest conversion of the silent-shapes paragraph versus my re-measurement of that same paragraph against the new haystack. My other four hunks in that file do not overlap theirs.Whichever merges second must rebase and keep BOTH: their worked examples as doctests, and the
dig-node-corere-measurement (which is the #366 deliverable). It is a doc-comment conflict, small and textually resolvable. I did not touch their PR.Gate round 2 — CHANGES-REQUIRED addressed: took option (a)
The finding was correct and I reproduced it before fixing it. With
T = ()the unanimity checkanswers.iter().any(|a| *a != candidate)is vacuously false —()always equals()— soverify_pinned_rootandverify_lineage_rootwere a flat 2-of-N threshold, and a reached voice'srejection landed in
refusalsand was discarded as unreachability.Why (a) rather than (b)
The gate said (a) is the better outcome and (b) the acceptable floor. (a) is what shipped, because the
classification already existed at the source and only needed a channel to travel in — so this was
a contained change, not a larger one:
verify_lineage_rootalready separated the cases structurally: a COMPLETED walk whose historylacks the root is a rejection; a FAILED walk is unreachable. No extra call.
verify_pinned_rootdelegates to a digstore function that collapses both into one error, soreachability is probed (
unspent_coins_by_hint, the read that function itself starts with) onlyon the failure path. A success costs nothing extra.
New
Verdict::{Confirmed, Rejected, Unreachable}and a narrowChainVoicetrait carry it.AnchoredRootResolver— the node-wide contract consumed across the crate — is unchanged, so thisadds no breaking surface.
The residual ambiguity fails in the refusing direction, deliberately. The probe races the call it
classifies; if the chain drops in between, a genuine unreachability is recorded as a REJECTION, which
refuses. The opposite misclassification is the defect being removed and fails open.
The rule now, for all three calls
weighed against the confirmations.
evidence rather than disagreement (the two demand opposite remedies, and the messages say which).
The bar now rises with
Nbecause every added source can VETO — not because more must agree.Half two — closed BY the (a) fix, and now proven rather than argued
content_serve.rs:430,dig_rpc/dispatch.rs:102andmodule_reshare.rs:931all fall back toverify_pinned_rooton a failed tip resolution. That fallback is exactly where a widenedErrwould have been laundered into a serve — and it is safe only because
verify_pinned_rootnowcarries the same dissent rule. Since that is a composition property rather than a local one, the new
test asserts both legs refuse on the SAME fixture, so a future change that makes the fallback lenient
again fails here. (
store_melted.rs:433also matches that arm and returnsMeltStatus::Unknown—already fail-closed.)
The missing fixture, added
Voice::Rejects— a REACHED voice that says no. Its absence is precisely why this shipped untested:every earlier verification fixture scripted only
Unreachable, which both semantics treatidentically.
a_reached_voice_that_rejects_refuses_and_is_never_outvotednow pins:Ok(())— this is the gate's own probe, inverted)NRevert-proof: restoring the shipped threshold (
Verdict::Rejectedpushed onto the unreachablelist) makes this test — and only this test — fail. Run after committing, restored by file copy.
SPEC corrected
§14.4b no longer claims a property the code lacks. It states the veto clause explicitly, says a flat
k-of-N threshold does NOT satisfy it and why, and records that the bar rises with
Nthrough theveto rather than through the count.
Non-gating items, folded in rather than filed
DIG_NODE_CHAIN_ENDPOINTSadded to both env tables.crates/dig-node-core/SPEC.md§4.1 rewritten — it still describedCoinsetResolveras production.two, any single outage drops below the floor and every read refuses.
voices()— the doc promised per-group failover the code did not perform. Resolved by stating thedesign: a group's members are the same machine, so a second member is a retry against the host
that just failed, not a fallback to another source.
fail-closed direction. Sequential un-timed-out lookups on the content-serve request path meant one
black-holed resolver stalled every read.
of not trusting one source.
dig-ipc-protocolpinned to=0.3.0.Rebase + coordination
Rebased onto
7ea14fe; the only delta was the CHANGELOG commit, so nothing audited moved. PR#368still overlaps one
sources.rsdoc block — prefer #368's executed-fixture (doctest) shape whenresolving, and keep the
dig-node-corere-measurement, which is #366's deliverable.Verification after the fix
cargo fmt --check0 ·cargo clippy -D warnings0 ·cargo test -p dig-node-core -p dig-wallet --all-targetsexit 0 — 1001 lib + every integration target + dig-wallet 656, zero failures. (Notethe earlier IPv6 defect was invisible to
--lib; all-targets is now the bar.)