Skip to content

feat(exchange): adopt dig-sex for selection residency, acquisition and recursive discovery - #289

Merged
MichaelTaylor3d merged 9 commits into
mainfrom
loop/dig-sex-integration
Aug 21, 2026
Merged

feat(exchange): adopt dig-sex for selection residency, acquisition and recursive discovery#289
MichaelTaylor3d merged 9 commits into
mainfrom
loop/dig-sex-integration

Conversation

@MichaelTaylor3d

@MichaelTaylor3dMichaelTaylor3d commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What this adds

Consolidates the recursive-discovery rival onto dig_sex::discovery (#272), which resolves the
unbounded-reach defect on the way (#281), and carries the self-exclusion half of #266.

Continues the dig-sex integration batch already on this branch (dig-sex 0.4, tier-0 residency #287,
acquisition #270/#282).

The split: dig-sex owns the DECISION, dig-node keeps the WIRE

forwarded_ask.rs stays as transport — opcode, framing, dial. Its decision half is gone, replaced by
decide_forward / parse_enabled called from NodeContent::forwarded_holders. dig-node's in-tree
fan-out constant and its hand-rolled peer selection are deleted.

Two gates stay in dig-node because they are properties of this node's resources rather than of the
protocol: the per-requestor relay bucket and the node-wide concurrency semaphore. The relay bucket is
passed IN as relay_budget_available rather than checked around the decision, so the crate's
RelayBudgetSpent arm stays reachable instead of being shadowed by a local guard.

BEHAVIOUR CHANGE — stated plainly, two of them

1. Reach drops ~28x. dig-node fanned out to 4 peers over a hop cap of 4 — about 1,360 dials and
DHT walks per admitted frame against a full relay burst. Under the canonical bounds (fan_out 3,
hop_cap 2) one admitted frame recruits 12 nodes3 + 3^2, the SUM over hops — or 48
against the same relay burst. That 12 is also the disclosure radius.

Corrected after the gate caught it. An earlier revision of this PR quoted
worst_case_nodes_recruited() = 9 as both the recruitment and the disclosure radius. That function
returns fan_out ^ hop_cap, which is the leaf count of the last hop only and omits every
intermediate node — nodes that do the same work and learn the same triple. So the reduction was
overstated (~150x for ~28x) and the disclosure radius was 25% low.

The part worth recording: the FORWARDED_ASK_FANOUT doc-comment this PR deleted warned about
precisely this reading — "the leaf count of ONE question, not the cost of a frame... understates by
about 5x."
The consolidation removed the warning and then made the mistake it warned about. Deleting
a rival's documentation deserves the same care as deleting its code; the warning is now restored
next to the figure, in download.rs, forwarded_ask.rs and SPEC §10.4.4.

The root cause is upstream — dig-sexdiscovery.rs:77-83 calls a leaf count "the number of nodes
one admitted request can recruit"
and "the disclosure radius". This PR cites the real numbers and
pins the difference explicitly; the crate's own naming is filed separately against dig-sex.

Enforcement was never wrong — only the claim was. No bounds changed.

Is 3x2 too small for real use? Possibly — on a sparse pool a holder three hops out becomes
unreachable. But that is a tuning question, and RecursionConfig is a value this node passes in at
the composition root, so config can carry it whenever measurement says so. Fail-closed and
off-by-default are not negotiable and are not being traded for reach
— this path spends other
nodes' bandwidth.

2. An unreadable hop budget now REFUSES.redirect_depth was parsed with
.and_then(Value::as_u64).unwrap_or(0), so a present-but-unparseable value — an attacker-supplied
field — became 0, the most permissive value the field has, at every hop, forever. A request whose
hop budget cannot be read is a request whose reach is unbounded.

The new HopBudget type keeps the three states distinct, and deliberately gives the same field two
readings
:

stateforwardingredirect
absentfull budget (an originating request honestly has one)depth 0
readablethat budgetthat depth
present, unparseablerefuse (UnreadableHopBudget)depth 0, unchanged

The redirect keeps its tolerant reading because it spends only this node's own lookup; forwarding is
gated more tightly because it spends other nodes'. That asymmetry is deliberate, documented on the
type, tested (the_redirect_leg_keeps_its_tolerant_reading_of_the_same_field), and confines the
behaviour change to the forwarding decision.

3. A hop-counter-less request shape is fully spent — a MUST restored.main carried a clause
requiring the dig-nat mux AvailabilityRequest, which has no field able to hold a hop count, to be
treated as fully spent. An earlier revision of this PR deleted the clause while peer.rs:1391 still
implemented it via HopBudget::spent(). The gate measured the consequence: replacing spent() with
fresh() there left 864 passed / 0 failed — nothing guarded the one shape that cannot count
hops, and that flip is the same failure direction as the .unwrap_or(0) defect this PR exists to fix.

Restored in SPEC §10.4.4 over the class rather than one message type: any inbound shape with no
field able to hold a hop count MUST declare its budget spent, so a second such shape inherits it
without the clause being rewritten. Guarded by a new test on the mux responder (below).

RecursionConfig is now installed with the leg at the composition root rather than read from env at
decision time, so the amplification posture is fixed at start-up and the decision is exercisable
without process-global state.

What I carried from #266, and what I dropped

I carried the self-exclusion fix. The brief said it was redundant under the crate; measured against
the code, it is not, and dropping it would have reintroduced #261.

decide_forward excludes requestor and self from the peers this node asks. #266 fixes self
appearing in the answer — a peer is free to answer with a record naming us, and merge_answers
does not filter one out. The two rules travel in opposite directions; adopting the crate discharges
only one. Carried: retain_excluding_self (one shared filter at the merge, covering every source
including any added later) plus both of #266's tests — the forwarded-leg assertion and its DHT-leg
control.

There was no A→B→C round-trip test on #266 to carry. Its 84 added test lines are the
self-exclusion pair; nothing in that branch or on main composes two hops. So I wrote one:
a_holder_two_hops_away_is_reached_through_the_middle_node chains a real second NodeContent as the
middle hop, so B runs the same consolidated decision against its own peers and budget. Its control —
the same topology with B's onward leg removed — is the load-bearing half: without it the test cannot
tell "the middle hop recursed" from "the middle hop knew the holder".

Nothing from #266 was dropped. #266 can be closed as subsumed.

Blast radius checked

locate_holders / forwarded_holders / miss_outcome / availability_batch and their callers
(range_miss_envelope, content_miss_envelope, availability_answer, the peer stream leg,
dig_rpc::dispatch). Threading HopBudget in place of a bare u64 is what surfaced the full caller
set at compile time rather than by grep. forwarded_request and the wire parser are untouched, so
the on-wire shape is byte-identical.

Risk assessment: MEDIUM, not high. The change is confined to the miss-enrichment path; both
behaviour changes move in the fail-closed direction; the leg is off by default in production, so a
node that has not opted in sees no change at all.

Verification

  • cargo test -p dig-node-core --lib865 passed, 0 failed (371s).
  • cargo clippy --workspace --all-targets --all-features — clean. cargo fmt --all applied.

One intermittent failure seen and chased down, reported rather than buried. An earlier run of the
same suite failed tests::cache_lock_is_exclusive_then_released (864/1). It passes in isolation and
passed on the clean re-run above. It is a test-isolation hazard, not a regression from this work:
several tests drive evict_modules_if_needed, which takes the cross-process acquire_cache_lock(),
while cache_lock_is_exclusive_then_released asserts exclusivity on a lockfile path derived from
DIG_NODE_CACHE under ENV_GUARD — the eviction tests do not hold that guard, so under parallel
execution the two can resolve to the same lockfile. Nothing in my commits touches either. Flagged as
a follow-up rather than fixed here; it will flake in CI eventually and should be pinned properly.
Revert-proofs, with the assertion that fired:

  1. Restore .unwrap_or(0) on the unreadable budget →
    an_unreadable_hop_budget_forwards_nothing_while_a_readable_one_forwards fails at
    forwarded_ask_tests.rs:362, the asked().is_empty() refusal assertion — the right one, not the
    answer-shape assertion beside it.

  2. Let only an originating request forward (a relayed hop never does) →
    a_holder_two_hops_away_is_reached_through_the_middle_node fails at line 715, the
    holder-reached-A assertion, and the_hop_budget_is_pinned_from_both_sides fails alongside it.

  3. Flip HopBudget::spent() to fresh() at the mux leg (peer.rs:1391) — the flip the gate showed
    was invisible against the whole suite → the_hop_counterless_mux_shape_forwards_nothing fails at
    peer.rs:4505, the asked().is_empty() refusal assertion, with a diagnostic naming the ask that
    leaked. Its control drives the same content through availability_batch with a readable budget
    and observes an ask go out, so the test distinguishes "the mux leg refuses" from "this fixture
    never forwards".

fan_out and hop_cap are now pinned separately rather than only through their product, since
9/1 satisfies any assertion on the product while being a pool-wide broadcast. Recruitment is
asserted as the computed sum over hops (12), with worst_case_nodes_recruited() == 9 pinned beside
it and labelled as the leaf count, so the difference between the two stays visible in the test rather
than only in prose.

Docs — one sibling NOT done, deliberately

docs.dig.net/docs/protocol/peer-network.md:818 still says "on the order of 1,300+ outbound dials",
now wrong by ~150x, and the same line exists in 13 i18n copies. That is a different repo and I was
scoped as the single writer in dig-node only; editing a shared checkout mid-flight risks colliding
with another lane. Flagging rather than silently dropping it — it needs its own PR.

Closes#272
Closes#281
Closes#261

MichaelTaylor3dand others added 7 commits August 20, 2026 15:24
…act (#287)
Co-Authored-By: Claude <noreply@anthropic.com>
…#282)
Full subject: feat(acquisition): decide read-triggered backfill with dig-sex, failing closed on an unreadable switch (#270, #282)
Co-Authored-By: Claude <noreply@anthropic.com>
Full subject: feat(discovery): consolidate the recursive ask onto dig-sex, refusing an unreadable hop budget (#272, #281)
WIP: tests still to be retargeted at the consolidated path.
Co-Authored-By: Claude <noreply@anthropic.com>
… path (#272, #261)
Co-Authored-By: Claude <noreply@anthropic.com>
… bounds (#272, #281)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3dMichaelTaylor3d changed the title feat(exchange): finish the dig-sex integration — selection residency, conduct, admission, acquisition, reward ledgerfeat(exchange): adopt dig-sex for selection residency, acquisition and recursive discoveryAug 21, 2026

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

CHANGES-REQUIRED (recorded as a comment review: self-approval/request-changes is a 422 on an own-authored PR). Gate round 1 on head 5cb07523ac0a80334b2dc313e1ead9325440c708. Four findings inline; two gating, both on the amplification + hop-budget claims rather than on the enforcement itself. Suite + revert-proofs still running; verdict comment follows.

Comment threadSPEC.md Outdated
Comment threadSPEC.md
Comment threadcrates/dig-node-core/src/forwarded_ask_tests.rs
Comment threadcrates/dig-node-core/src/forwarded_ask_tests.rs
@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

CHANGES-REQUIRED

Head reviewed: 5cb07523ac0a80334b2dc313e1ead9325440c708 (resolved from the remote; unchanged during the review).

Two gating findings, both posted inline. Neither is a defect in the enforcement — the code moves fail-closed in every branch I could reach. Both are about a claim the PR makes being wrong or unguarded, which on an amplification change is the thing a reimplementer and an operator act on.

Gating

  1. SPEC.md 10.4.5 / 10.4.4 / download.rs:242 — the amplification figure is a LEAF COUNT, not the recruitment.worst_case_nodes_recruited() is fan_out ^ hop_cap = the last hop only; one admitted frame recruits 3 + 9 = 12, and against the relay burst the old 1,360 included, 4 x 12 = 48. So the reduction is ~28x, not the ~150x in the body, and the stated disclosure radius is 25% low. The doc comment this PR deleted warned about exactly this arithmetic ("the leaf count of ONE question, not the cost of a frame... understates by about 5x"). Upstream dig-sex names the leaf count "nodes recruited" and "the disclosure radius" — file that against the crate too.
  2. peer.rs:1391 / SPEC.md 10.4.4 — a deleted MUST plus a demonstrated vacuity.main's clause requiring a hop-counter-less request shape to be treated as fully spent is gone from this revision, while the code still implements it via HopBudget::spent(). Measured: replacing spent() with fresh() at that call site leaves 864 passed / 0 failed. Nothing in the suite guards the one shape that cannot count hops, and it is the same failure direction as the .unwrap_or(0) defect this PR exists to fix.

Non-gating (inline)

  1. The fan-out pair does not pin fan_out and hop_cap separately — 9/1 satisfies both assertions.
  2. A whitespace run inside an assertion message.

Verification I ran myself, not carried from the body

  • cargo test -p dig-node-core --lib in an isolated worktree at this head: 864 passed, 0 failed (456.7s).
  • Revert-proof 1 (restore .unwrap_or(0) in HopBudget::from_params): exactly one failure, an_unreadable_hop_budget_forwards_nothing_while_a_readable_one_forwards at forwarded_ask_tests.rs:376 — which is assert!(unreadable.asked().is_empty(), ...), the refusal assertion, not the answer-shape one beside it. As claimed.
  • Revert-proof 2 (refuse to forward from any relayed hop): two failures — the_hop_budget_is_pinned_from_both_sides at :342 and a_holder_two_hops_away_is_reached_through_the_middle_node at :753, the holder-reached-A assertion. As claimed.
  • Residency key space, derived independently: NodeHeldCapsules builds ContentId::capsule(store, root).to_key() from cache_list_cached() — the same list refresh_dht_inventory announces from, and the same derivation capsule_resolver.rs:106 recomputes to verify a sampled key. So residency is measured in the sampler's own space, threaded as an argument, and re-read per round. an_already_held_candidate_is_not_refetched_while_an_unheld_one_still_is and the displacement-margin test both carry real controls; the margin test would pass on neither resident: false nor a skip-before-selection.
  • The two-reading split is confined. Every surviving production caller of the tolerant redirect_depth() (peer.rs:1454,1665, dispatch.rs:462,866) feeds bandwidth_redirect, which calls find_providers only and never locate_holders. There is no path on which the tolerant reading reaches the forwarding decision. REDIRECT_HOP_CAP (4) and hop_cap (2) are consumed by different methods (used() vs remaining()), and the wire depth the forwarding leg emits is monotone under both (decide_forward decrements by exactly one).
  • Acquisition consolidation: the three hand-rolled guards in spawn_capsule_backfill are gone, replaced by one dig_sex::acquisition::decide call; the fail-closed parser direction won, and every_acquisition_refusal_is_the_crate_decision_and_the_control_still_acquires carries the Acquire control on a node that could actually pull.
  • dig-sex 0.4.0 is live on crates.io. Wire shape byte-identical: forwarded_request and the parser are untouched, and HopBudget never reaches the encoder.

The two corrections to the brief — both were right

  1. Self-exclusion is NOT redundant.decide_forward (dig-sex 0.4 discovery.rs:145) excludes self from the peers ASKED; merge_answers does no filtering of the ANSWER, and a peer may answer with a record naming us. Dropping it would have reintroduced The forwarded ask does not self-exclude — SPEC states no source can ever offer self, and one of two paths honours it #261. retain_excluding_self at the merge, covering both legs, is the right shape.
  2. There was no A-B-C test to carry, and the one written is genuine. A and B are both real NodeContent instances; B runs the same consolidated decision against its own peers and budget, and the control removing B's onward leg is what makes it evidence rather than a restatement.

MichaelTaylor3dand others added 2 commits August 20, 2026 18:31
Full subject: fix(spec): correct the recruitment figure to the sum over hops, and restore the hop-counterless MUST (#272)
The amplification claim quoted `worst_case_nodes_recruited()` = fan_out ^ hop_cap = 9, which is the
LEAF COUNT of the last hop only. One admitted frame recruits 3 + 3^2 = 12 nodes, or 48 against a full
relay burst — so the reduction from dig-node's rival is ~28x, not ~150x, and the stated disclosure
radius was 25% low.
The deleted FORWARDED_ASK_FANOUT doc warned about exactly this reading. Restoring the warning next to
the figure, and pinning fan_out and hop_cap separately since their product does not identify them.
Also restores the MUST requiring a hop-counter-less request shape to be treated as fully spent —
removed while peer.rs still implemented it, and unguarded by any test.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3dforce-pushed the loop/dig-sex-integration branch from b79ea0a to eff1a01CompareAugust 21, 2026 01:56
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 21, 2026 03:47
@MichaelTaylor3d
MichaelTaylor3d merged commit b77b5f7 into mainAug 21, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/dig-sex-integration branch August 21, 2026 03:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

@MichaelTaylor3d