Skip to content

fix(read): merge same-peer address hints so a reachable pool address is not shadowed (#1590, #836) - #97

Merged
MichaelTaylor3d merged 2 commits into
mainfrom
fix/836-resource-provider-lookup
Jul 25, 2026
Merged

fix(read): merge same-peer address hints so a reachable pool address is not shadowed (#1590, #836)#97
MichaelTaylor3d merged 2 commits into
mainfrom
fix/836-resource-provider-lookup

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

Root cause (#1590/#836 read-leg DATA blocker)

Provider-source ADDRESS shadowing in the download locator — NOT the resource→capsule granularity gap the ticket hypothesised.

The download locator is SelfExcludingLocator(UnionLocator([raw_dht_discovery, PoolProviderLocator])). Both sources can name the SAME authenticated peer_id with DIFFERENT addresses:

  • the DHT carries the holder's advertised provider record — an untrusted, often stale/relayed-net reach hint (172.31.44.121:<ephemeral> in the e2e, refused);
  • the PoolProviderLocator carries the holder's live connection address — connection-verified and reachable (172.31.29.67:9444).

UnionLocator (crates/dig-node-core/src/seams/dig_peer/union_locator.rs) deduped by peer_id keeping the FIRST-SEEN record whole and discarding every later same-peer record. So the DHT's unreachable hint (seen first) SHADOWED the pool's reachable address for the same holder. The confirm step (dig.getAvailability) then dialed only the unreachable address, every dial refused, dig-download's locate_and_confirm returned empty → NotFoundno providers located for ContentId::Resource {…}, and the read fell through to §21 upstream rpc.dig.net → 400 → 404. Holder inbound fetchRange = 0.

Evidence (arbiter e2e c095436): fetch_resource: located providers before download … located=1 connected_pool=1, the pool locator logs it is offering the holder at :9444, yet the fetch dials 172.31.44.121:<ephemeral> (the DHT hint, refused) and never 172.31.29.67:9444.

Fix

UnionLocator now MERGES the (untrusted, MAX_ADDRS_PER_PROVIDER-capped) address hints of same-peer_id records across sources instead of dropping the later record. peer_id stays the authenticated identity (SPKI-pinned at connect); addresses are only reach hints, so unioning them is safe, and the reachable pool address survives so a fetchRange reaches the holder.

Why the DISCOVERY locator stays unpolluted: the merge is a no-op on the discovery union [dht, EmptyLocator, EmptyLocator] (no same-peer cross-source dups), and NodeContent::find_providers / redirect-on-miss use self.locator (the raw discovery locator) unchanged — redirect hints stay capsule/announced-holder granularity. #96 self-exclusion (both defenses) and #1584 discovery leg are untouched; verify-then-decrypt fail-closed is unchanged.

Tests (TDD red → green)

  • download.rs::resource_fetch_uses_the_reachable_pool_address_when_the_dht_hint_is_unreachable — drives the REAL locator composition (NodeContent::new) with an address-aware transport that fails a dial unless the record carries the reachable address, and asserts a fetchRange reaches the holder over its reachable pool address + merkle-verified bytes. Pre-fix it reproduces the exact content not found: no providers located for ContentId::Resource {…} error.
  • union_locator.rs::same_peer_across_sources_merges_address_hints — pins the cross-source address merge.

Verification

Blast radius

UnionLocator::find_providers — callers: the download locator (NodeContent::new) and the discovery union (NodeContent::for_dht). The discovery union has no same-peer cross-source dups → behaviour unchanged there. Only the download/fetch path changes (reachable address now survives). Version bumped 0.58.2 → 0.58.3 (patch), dig-node-core 0.18.1 → 0.18.2.

Resume step

Re-run runlegs-read-arbiter.sh on cached c095436 + this fix — it should now issue a fetchRange to the holder at :9444DATA_MERKLE_VERIFIED.

Do NOT merge. Does not close #836/#1586/#1572/#1062/#1590.

…is not shadowed (#1590, #836)
The download locator unions the raw DHT discovery source with a
PoolProviderLocator over the live connected pool. Both can name the SAME
authenticated peer_id with DIFFERENT addresses: the DHT carries an untrusted,
often stale/relayed-net advertised hint, while the pool carries the
connection-verified reachable address. UnionLocator deduped by peer_id keeping
the first-seen record whole and discarding later same-peer records, so the DHT's
UNREACHABLE hint (seen first) shadowed the pool's REACHABLE address for the same
holder. The confirm/fetch then dialed only the unreachable address, every dial
was refused, dig-download reported `no providers located for the resource`, and
the read fell through to §21 upstream -> DATA 404 despite a connected, dialable
holder (arbiter e2e c095436, run e2e-836-arb-20260725-094734).
UnionLocator now MERGES the (untrusted, MAX_ADDRS_PER_PROVIDER-capped) address
hints of same-peer records across sources instead of dropping the later record.
peer_id remains the authenticated identity (SPKI-pinned at connect); addresses
are only reach hints, so unioning them is safe, and the reachable pool address
survives so a fetchRange reaches the holder. First-seen record ORDER is
preserved (dig-dht stays authoritative), and this is a no-op on the DISCOVERY
union ([dht, empty, empty]) so redirect hints stay announced-holder granularity.
Regression tests:
- download.rs: resource_fetch_uses_the_reachable_pool_address_when_the_dht_hint_is_unreachable
drives the real locator composition with an address-aware transport (fails a
dial unless the record carries the reachable address); pre-fix it reproduces
the exact `no providers located for ContentId::Resource` error.
- union_locator.rs: same_peer_across_sources_merges_address_hints pins the merge.
Co-Authored-By: Claude <noreply@anthropic.com>

@MichaelTaylor3dMichaelTaylor3d left a comment

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness review — PASS (fresh independent context)

Verified the #836 read-leg DATA fix (address-shadowing in UnionLocator). All confirm points hold:

  1. Merge is correct (union_locator.rs:76-100): same-peer_id records now UNION their address hints (existing.addresses.extend(record.addresses)) into the first-seen record instead of drop-later. peer_id stays the sole authenticated identity (SPKI-pinned at connect); only untrusted reach hints merge. The reachable pool address survives alongside the DHT hint.
  2. Cap respected (sanitize_address_hints re-run after every extend, MAX_ADDRS_PER_PROVIDER = 8): merging can never grow a record past the #1490/#94 dial-amplification bound, even across many sources. No unbounded growth.
  3. Discovery/redirect untouched: download.rs:549-556 composes a SEPARATE outer UnionLocator([self.locator, PoolProviderLocator]) for the download path only; the discovery/redirect leg keeps the raw self.locator = SelfExcludingLocator(CapsuleFallbackLocator(UnionLocator([dht, empty, empty]))). The inner discovery union has no cross-source dups, so the merge is a genuine no-op there — redirect hints stay announced-holder granularity, unpolluted. #96 self-exclusion (both defenses), #1584, #1580 CapsuleFallback, verify-then-decrypt all unchanged.
  4. Tests non-vacuous + RED/GREEN: AddressAwareTransport (download.rs:1541) fails a dial unless the record carries the reachable addr — an address-only mock, so pre-fix the pool record is dropped → is_reachable false → fetch_resource Err (the exact e2e no providers located for ContentId::Resource path); post-fix both addrs present → fetchRange served + merkle-verified. same_peer_across_sources_merges_address_hints asserts one record, both hints survive. Both drive the real composition; helpers (anchored_mock_content, on_pool_event, mock_peer_hex) are real pre-existing testkit.
  5. Meta: patch bump correct (0.58.2→0.58.3 / core 0.18.1→0.18.2, behaviour-fix); DEVELOPMENT_LOG records the address-shadowing gotcha + the merge-not-drop lesson; readable-code clean (intent-revealing match, WHY-comments citing #1590/#836/#1490).

dig-constants: no shared constant introduced or shadowed — MAX_ADDRS_PER_PROVIDER is a local dial-amplification bound (not a cross-repo value); addresses are runtime reach hints, not canon. Nothing belongs in / should consume dig-constants here.

Non-blocking note (resolving myself): the fix's real-world efficacy depends on the NatRangeTransport dialing BEYOND the first address in a record (DHT hint is ordered first, reachable pool addr second) — that behaviour lives in dig-nat, out of this diff, and is validated by the arbiter e2e (c095436). No action needed.

Blast radius: only union_locator.rsfind_providers (merge logic) + additive tests + version/log. detect_changes scope matches. Gates: Rustfmt/Clippy/CodeQL/version/commitlint green; Test+coverage + build jobs pending — merge gated on those going green (coverage ≥80%).

…older (#836, #1590)
The download union merged same-peer address hints but kept the pool source
SECOND, so the reachable :9444 was appended AFTER the stale/unreachable DHT
hint. The real transport dials a SINGLE address — provider.best_address(), the
first dialable candidate in list order (dig-dht record.rs) — so it still dialed
the leading unreachable DHT address and the read 404'd despite a connected,
dialable holder.
Put PoolProviderLocator FIRST in the DOWNLOAD union (NodeContent::new): a live
connection-verified pool address is strictly better than an untrusted advertised
DHT hint, so it now leads the list and best_address() selects the :9444 that
actually connects. Orders ONLY the download union; the discovery leg
(self.locator, find_providers/redirect), #1584 self-exclusion, #1580
capsule-fallback, and verify-then-decrypt fail-closed are untouched.
Rewrite AddressAwareTransport::is_reachable to model best_address() (the first
dialable candidate) instead of .any() — the .any() model was the false-green
trap: it passed whenever the reachable address appeared anywhere in the list,
even when best_address() was the unreachable one. The test is now RED under the
old append-order and GREEN once the pool leads. Strengthen the union merge test
to assert first-seen order leads.
Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review July 25, 2026 11:04
@MichaelTaylor3d
MichaelTaylor3d merged commit a51980f into mainJul 25, 2026
13 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the fix/836-resource-provider-lookup branch July 25, 2026 11:11
MichaelTaylor3d added a commit that referenced this pull request Jul 25, 2026
…(#836) (#100)
* fix(read): bypass getAvailability confirm for connected-pool holders (#836)
The #836 read-leg DATA miss survives the #97 address fix at the next layer:
dig-download's `locate_and_confirm` keeps ONLY providers whose
`dig.getAvailability` answer is `available`, dropping every other provider
BEFORE any `dig.fetchRange`. On a relayed/isolated net a holder the reader is
already CONNECTED to (offered by `PoolProviderLocator`) can answer
availability=not-available as a FALSE NEGATIVE (cache-inventory lag, a
resource-vs-capsule granularity quirk, or a transient probe failure), so the
confirm drops it -> `providers.is_empty()` -> `DownloadError::NotFound`
("no providers located for ContentId::Resource {...}") -> ZERO fetchRange ->
§21 upstream backfill -> 404. This is the exact e2e symptom: the availability
probe dials :9444, then no fetchRange.
Ground truth (ends the "key mismatch" misdiagnosis): the logged `ea12da62` IS
the RESOURCE key (`ContentId::to_key()` hashes store+root+rk), and `befbbaf9`
is that resource's raw retrieval_key -- same resource, no key mismatch. The
locate chain (CapsuleFallback bridge + pool union + self-exclusion) resolves
the holder in-process; the drop is downstream in the CONFIRM.
Fix: `PoolConfirmTransport` wraps the range transport and short-circuits
`query_availability` to available=true for any provider whose peer_id is in the
connected pool -- a live, connection-verified holder is confirmed by the
connection itself, and the whole-resource merkle verify (not the self-reported
availability flag) is the real integrity gate. A DHT-only provider still goes
through the real confirm; a connected non-holder fails its ranges and is
dropped there (bounded, safe). Preserves #96/#97/#1584/#1580 +
verify-then-decrypt fail-closed; the discovery/redirect leg is untouched.
Faithful regression test `connected_pool_holder_is_fetched_even_when_it_answers_availability_false`:
a connected, reachable holder that answers availability=false but WOULD serve
bytes -- RED pre-fix (no fetchRange, "no providers located"), GREEN post-fix.
Every prior read-leg mock answered availability=true, so none could reproduce
the confirm-gate drop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaxTyBhLj2Ry1eVQBLLotN
* style(read): cargo fmt PoolConfirmTransport construction (#836)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
MichaelTaylor3d pushed a commit that referenced this pull request Aug 2, 2026
…rce fills the cap
union_locator merges the address hints of the same peer_id named by multiple sources
(#836/#97), then caps the combined set at MAX_ADDRS_PER_PROVIDER. But the cap ran
AFTER the merge and kept the FIRST MAX, so an earlier source that had already filled
the cap with unreachable hints (a stale DHT record) silently dropped a later source's
novel REACHABLE hint (the live pool connection) for the same peer (#1620). The node
then dialed only stale hints and missed a dialable holder. Masked in production only
by wiring the reachable pool source first — any reorder reintroduced it.
merge_address_hints now reserves the cap's slots for the later source's NOVEL hints
first, then leads with as many of the earlier source's hints as still fit — so the
earlier HEAD keeps its lead position (#836: best_address breaks ties by list order)
while only the earlier SURPLUS TAIL is displaced, never a later reachable hint. When
everything fits, the order is unchanged (earlier-then-later). The shared order-
preserving dedup is factored into dedup_hints; sanitize_address_hints (first-seen
ingest) dedups-then-caps through it.
Regression test: an earlier source filling the cap + a later source's one reachable
hint -> the reachable hint survives and the earlier head still leads. The existing
#836 merge/order tests stay green. union_locator suite 6/6; fmt + clippy -D warnings clean.
Closes #1620
Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d pushed a commit that referenced this pull request Aug 2, 2026
…rce fills the cap
union_locator merges the address hints of the same peer_id named by multiple sources
(#836/#97), then caps the combined set at MAX_ADDRS_PER_PROVIDER. But the cap ran
AFTER the merge and kept the FIRST MAX, so an earlier source that had already filled
the cap with unreachable hints (a stale DHT record) silently dropped a later source's
novel REACHABLE hint (the live pool connection) for the same peer (#1620). The node
then dialed only stale hints and missed a dialable holder. Masked in production only
by wiring the reachable pool source first — any reorder reintroduced it.
merge_address_hints now reserves the cap's slots for the later source's NOVEL hints
first, then leads with as many of the earlier source's hints as still fit — so the
earlier HEAD keeps its lead position (#836: best_address breaks ties by list order)
while only the earlier SURPLUS TAIL is displaced, never a later reachable hint. When
everything fits, the order is unchanged (earlier-then-later). The shared order-
preserving dedup is factored into dedup_hints; sanitize_address_hints (first-seen
ingest) dedups-then-caps through it.
Regression test: an earlier source filling the cap + a later source's one reachable
hint -> the reachable hint survives and the earlier head still leads. The existing
#836 merge/order tests stay green. union_locator suite 6/6; fmt + clippy -D warnings clean.
Closes #1620
Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Aug 2, 2026
…rce fills the cap (#130)
union_locator merges the address hints of the same peer_id named by multiple sources
(#836/#97), then caps the combined set at MAX_ADDRS_PER_PROVIDER. But the cap ran
AFTER the merge and kept the FIRST MAX, so an earlier source that had already filled
the cap with unreachable hints (a stale DHT record) silently dropped a later source's
novel REACHABLE hint (the live pool connection) for the same peer (#1620). The node
then dialed only stale hints and missed a dialable holder. Masked in production only
by wiring the reachable pool source first — any reorder reintroduced it.
merge_address_hints now reserves the cap's slots for the later source's NOVEL hints
first, then leads with as many of the earlier source's hints as still fit — so the
earlier HEAD keeps its lead position (#836: best_address breaks ties by list order)
while only the earlier SURPLUS TAIL is displaced, never a later reachable hint. When
everything fits, the order is unchanged (earlier-then-later). The shared order-
preserving dedup is factored into dedup_hints; sanitize_address_hints (first-seen
ingest) dedups-then-caps through it.
Regression test: an earlier source filling the cap + a later source's one reachable
hint -> the reachable hint survives and the earlier head still leads. The existing
#836 merge/order tests stay green. union_locator suite 6/6; fmt + clippy -D warnings clean.
Closes #1620
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@MichaelTaylor3d