Skip to content

feat(pool): direct-inbound adoption entry point (#3124) - #83

Merged
MichaelTaylor3d merged 13 commits into
mainfrom
loop/3124-direct-inbound
Aug 29, 2026
Merged

feat(pool): direct-inbound adoption entry point (#3124)#83
MichaelTaylor3d merged 13 commits into
mainfrom
loop/3124-direct-inbound

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

DRAFT — gate round 2 fixes are in; awaiting the re-gate verdict. Do not merge.

Release-first half of dig_ecosystem#3124 — https://github.com/DIG-Network/dig_ecosystem/issues/3124
Consumer, blocked on this publishing 0.31.0: DIG-Network/dig-node#402

What

Adds a dedicated direct-inbound adoption entry point to GossipHandle. dig-gossip exposed four
adoption paths and none accepted a direct inbound connection, so a node that ACCEPTS a direct mTLS
connection and serves the peer had nowhere to register it — connected_peers under-reported every
inbound peer.

Why not reuse an existing entry point

Each available reuse corrupts a different downstream decision:

reuse corruption
adopt_relayed_inbound_handle types the slot Relayed; via and the relayed caps derive from that tier
adopt_nat_connection stamps is_outbound = true, charging outbound diversity budgets for a peer never dialed
either reports the peer's ephemeral source port as a dial target

Gate round 1 — three findings that composed into one attack, fixed together

F1 — accepted slots could never be displaced. The entry point omitted
pool.publish(PoolEvent::PeerAdded), which both siblings make. That call is the admission LEDGER, not
an announcement: it is the only production path that creates a peer's ActivityRecord,
begin_activity refuses to create one, and activity_of silently drops a recordless peer before the
displacement planner sees it. The slot counted toward connected and sat in cyclable while being
structurally incapable of ever being the victim — so displacement pressure landed entirely on peers
this node chose, and the only un-cyclable slots in the pool were the ones a stranger opened. That
inverts NC-12. The direct path now publishes, and takes the Plumtree membership and connection count
its siblings take.

F3 — the two inbound caps did not compose (a REGRESSION). max_direct_inbound and
max_relayed_inbound were each a reserved quarter of max_connections, counted separately: 6 + 2
fills a max_connections of 8, and the next adoption returned MaxConnectionsReached with no
displacement. Before this PR the relayed cap alone held inbound to 6 of 8, so two slots always
survived. New max_inbound_total is an AGGREGATE bound charged by both inbound entry points, and
max_direct_inbound is now a reserved quarter of that budget rather than of the pool — which
makes it capable of binding at all (it was previously equal to the aggregate and could never fire) and
reserves room on the tier a NAT'd peer has no alternative to. At the default 8: at most 5 accepted
direct, at most 6 accepted overall, always ≥2 slots for a peer this node dials.

F2 — no per-source bound. Nothing on any inbound path bounded /16, AS or per-IP;
outbound_diversity_conflict is gated on is_outbound(), and identities are free here (leaves are
minted locally under the shipped public CA). One host could present one identity per slot and, with
F1, hold them un-evictably. New max_direct_inbound_per_group bounds accepted direct peers sharing
one /16 at a quarter of the tier, minimum two (2 at the default 8, 8 at 50) — two so a genuine
pair of nodes behind one NAT is never refused.

F4 — test vacuity. Every fixture used a fresh identity holding no slot, so neither the
dialable-supersede refusal nor the replaces_accepted_direct exemption was ever reached, and the cap
fixture held one tier from one source address. Four fixtures added; each proved RED by reverting only
its own fix (table below).

F5 — dig-peer-protocol is one minor behind (0.7 declared, 0.9.0 published). Deliberately
deferred to #82, which owns that uplift and already
carries the compiler's exact answer for all three break sites; it is semver-incompatible and not
mechanical. Every other edge is current: dig-nat 0.21.0, dig-tls 0.4.0, dig-ip 0.1.x.

Blast radius checked

adopt_direct_inbound_handle is NEW, so it has no callers in this repo; its only consumer is
dig-node#402, which is blocked on this release. The edits that do have a radius:

symbol direction radius
max_direct_inbound upstream one call site (the direct entry point). Value changes 6 → 5 at the default.
max_relayed_inbound upstream unchanged; the relayed path gains only the shared-budget charge
adopt_relayed_inbound_handle upstream behaviour changes ONLY once ≥6 accepted peers are held — the state that previously produced MaxConnectionsReached. con_870_relayed_inbound_pool_tests (relayed-only fixtures, cap 6) is unaffected and green.
is_accepted_inbound / is_accepted_direct new private helpers, two call sites each
PeerSlot::dial_addr upstream unchanged in this round

No public signature changed in this round. Risk concentrates on one behaviour: a node already holding
six accepted peers now refuses the seventh as ConnectionFiltered where it previously either admitted
it (relayed, pre-PR) or failed with MaxConnectionsReached (this PR, round 1). That is the intended
fix, and the reserve it restores is asserted directly.

Not changed, deliberately

Both gates cleared these and they are not re-litigated here: the five adopt_* paths carry identical
ordering and complementary tier refusals; dial_addr's new arm regresses nothing and outbound
diversity is correctly uncharged; §5.2 holds (no dial on this path). Two PRE-EXISTING issues are left
where they are — is_peer_id_banned_at fails open on a poisoned mutex (state.rs), and PeerSlot::Live
inbound slots report ephemeral addresses as dialable (listener.rs). This diff adds no new fail-open:
all six new guards fail closed under one lock hold.

Status

MichaelTaylor3d and others added 3 commits August 28, 2026 21:38
Adds the failing regression suite for dig_ecosystem#3124: dig-gossip has no
direct-inbound adoption entry point, so every inbound peer is uncounted.

Asserts the three properties this family has each shipped a defect against --
COUNTED, still SERVED, and REACHED by a broadcast (bytes received, never a
send-list length) -- plus varying-field controls so `via`, `is_outbound` and
`dial_addr` cannot pass vacuously.

Refs: DIG-Network/dig_ecosystem#3124

Co-Authored-By: Claude <noreply@anthropic.com>
Adds `GossipHandle::adopt_direct_inbound_handle`, the fifth adoption entry
point and the first that accepts a DIRECT connection this node accepted. A
node serving inbound peers previously had nowhere to register them, so
`connected_peers` under-reported every one.

Makes dialability explicit rather than a tier derivation: a `dig-nat` slot's
`remote` is a dial target only when THIS node chose it. An accepted slot's
`remote` is the peer's ephemeral source port, so it reports `dial_addr = None`
for a reason independent of the relayed tier's.

Bounds the accepted-direct tier by the same reserved quarter as the relayed
one, so inbound peers cannot fill the pool and choose this node's peer set.

Refs: DIG-Network/dig_ecosystem#3124

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

Adds the normative clauses for the direct-inbound entry point: every ACCEPTED
connection is a pool member, dialability is a property of the tier AND the
direction, and the accepted-direct tier is capped by the same reserved quarter
as the relayed one.

Per CLAUDE.md 2.4b, brings the crate's own deps to latest published:
dig-nat 0.20 -> 0.21, dig-ip 0.1.1 -> 0.1.2. The chia-* set stays on 0.36.1
deliberately -- dig-peer-protocol 0.9.0 still declares ^0.36.1, so moving this
crate alone to 0.48 would ship it split across two chia lines.

Version: 0.30.0 -> 0.31.0 (minor: additive public API).

Refs: DIG-Network/dig_ecosystem#3124

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

Copy link
Copy Markdown
Contributor Author

Lane resumed (progress log, resume-ready)

The original lane died to the 600s silence watchdog. Its pushed work at f21f993e is intact and is the
base for everything below.

Branch: loop/3124-direct-inbound · base HEAD: f21f993e

DONE

  • Diagnosed the one red check, Format, Clippy & Docs. It failed at cargo fmt --check, which
    exits before clippy runs — so clippy and the doc build were never actually measured on this branch.
    Three rustfmt diffs, all in code this PR added: one call in src/service/gossip_handle.rs and two
    in tests/con_3124_adopt_direct_inbound_tests.rs. cargo fmt --all applied.
  • Reviewed the production diff. PeerSlot::dial_addr returning None for an accepted Nat slot, the
    separately-counted max_direct_inbound cap, and the refusal to supersede a peer already holding a
    dialable slot are each consistent with NC-12: an accepted inbound peer is trusted less than a
    dialled one, because the attacker chose to reach us.

§2.4b dependency sweep — measured, not assumed

Every dig-* / chia-* declaration checked against index.crates.io with a User-Agent, and the
resolved lock read rather than the caret.

dep declared latest action
dig-peer-protocol 0.7 (locked 0.7.0) 0.9.0 bumped to 0.9
dig-nat 0.21 0.21.0 current
dig-tls 0.4 0.4.0 current
dig-ip 0.1 0.1.2 current (lock already at 0.1.2)
chia-* set 0.36.1 0.48.0 NOT taken — see below

dig-peer-protocol 0.9.0 declares chia-protocol ^0.36.1 / chia-sha2 ^0.36.1 /
chia-traits ^0.36.1, i.e. the same chia line this crate is already on, so the bump does not split
the crate across two lines. cargo update named every crate explicitly rather than relying on a caret
to cover it.

The chia 0.36.1 → 0.48.0 uplift is NOT taken here, deliberately

Taking it in this PR would ship dig-gossip internally split across two chia lines, which is the
exact defect that shipped from this repo twice on 2026-08-22. dig-nat 0.21.0, dig-tls 0.4.0 and
dig-peer-protocol 0.9.0 are all on 0.36.1; dig-gossip cannot move ahead of the crates beneath it.
Release-first (§4.1) puts this cascade at levels 00/10, not in a level-20 consumer's feature PR, and
bridging the gap with a shim is never the correct move.

Logged rather than fixed, with the shape stated so the cascade can be scoped: it is a whole-line uplift
of the L00/L10 crates first, then their consumers.

Also logged and left — a pre-existing split, NOT introduced by this PR

Cargo.lock on main already resolves chia-bls at three lines — 0.28.2, 0.36.1 and 0.42.1 — and
chia-sha2 at four (0.28.2 / 0.34.0 / 0.36.1 / 0.42.1). The 0.42.1 line arrives transitively via
chia-ssl. This predates the branch and is not in its diff; recording it here so it is discoverable
rather than opening a ticket for a finding that is neither user-visible nor blocking (§1.3c).

IN PROGRESS

Full cargo clippy --all-targets --all-features -- -D warnings on the post-bump lock. Because CI never
got past fmt, this is the first time clippy has actually been measured against these 900 lines.

NEXT ACTION

Read the clippy result; fix any lint in the new code; commit fmt + the dig-peer-protocol bump; push;
re-read the check set by name with check-merge-preconditions.sh --allow-draft.

Staying DRAFT — the gate round has not returned.

…ant to_string

CI's `Format, Clippy & Docs` job failed at its first step, `cargo fmt --all -- --check`,
which exits before the clippy and doc steps run. Clippy had therefore never actually
been measured against this branch's 900 lines, and it was hiding a real lint behind the
formatting failure:

  error: unnecessary use of `to_string`
    --> src/service/gossip_handle.rs:1999
        `-D clippy::unnecessary-to-owned`

`SafeText::from_untrusted` already takes the string by value conversion, so the relayed
refusal message allocated twice. Removing `.to_string()` leaves the literal, which is
what clippy asks for and what the other refusal sites in this function already do.

Three rustfmt diffs, all in code this PR adds: one call in `src/service/gossip_handle.rs`
and two in `tests/con_3124_adopt_direct_inbound_tests.rs`.

Verified locally with the exact commands CI runs: `cargo fmt --all -- --check`,
`cargo clippy --all-targets --all-features -- -D warnings`, and
`cargo doc --no-deps --all-features` all exit 0.

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

Copy link
Copy Markdown
Contributor Author

Green and gate-ready — still DRAFT

HEAD cd13d215 (was f21f993e). 10/10 checks SUCCESS.

$ check-merge-preconditions.sh --repo DIG-Network/dig-gossip --pr 83 --allow-draft
draft=true  mergeStateStatus=CLEAN  unresolvedReviewThreads=0
  Lint commit messages               SUCCESS
  Check version increment            SUCCESS
  Format, Clippy & Docs              SUCCESS
  Test Suite + Coverage (>=80%)      SUCCESS
RESULT: preconditions MET  [EXIT 0]

Run unpiped, exit code read from $? directly rather than through a pipeline.

The red check was hiding a second defect

Format, Clippy & Docs runs three steps in sequence and failed at the first,
cargo fmt --all -- --check. Because that step exits non-zero, the clippy and doc steps never ran,
and clippy had therefore never been measured against this branch at all. Fixing only the formatting
would have turned the check green while leaving the second problem in place for the next commit to
surface.

With formatting applied, clippy failed on its own:

error: unnecessary use of `to_string`
  --> src/service/gossip_handle.rs:1999
      `-D clippy::unnecessary-to-owned`

The relayed-tier refusal allocated its message twice, where the other refusal sites in the same
function pass the literal. Both fixed in cd13d215, then verified locally with the exact commands the
workflow runs — cargo fmt --all -- --check, cargo clippy --all-targets --all-features -- -D warnings
and cargo doc --no-deps --all-features — all exit 0 before pushing.

Dependency sweep — one bump found, deliberately NOT taken here

Full detail is in the previous comment. The outcome:

dig-nat 0.21, dig-tls 0.4 and dig-ip 0.1.2 are already current. dig-peer-protocol is declared
0.7 against a published 0.9.0, and that is real drift — but the bump was attempted, compiled, and
reverted, because 0.9.0 breaks at three call sites and not one of them is signature paperwork:

#82 is the named owner and was explicitly sequenced behind this lane. Taking it inside a feature PR
would put a security-relevant Direction decision into a diff nobody is reviewing for it, and would
duplicate a queued lane's scope. The compiler's exact answer for all three sites is now recorded on
#82 so that lane starts from a measurement rather than re-deriving it.

The bump is also not a chia-line hazard, which is worth stating because it is the usual reason to
hesitate: dig-peer-protocol 0.9.0 declares chia-protocol/chia-sha2/chia-traits at ^0.36.1, the
same line dig-gossip is on.

Logged and left

The chia 0.36.1 → 0.48.0 uplift is not taken. dig-nat, dig-tls and dig-peer-protocol are all on
0.36.1, so moving this consumer alone would ship dig-gossip internally split across two chia lines —
the defect this repo already shipped twice on 2026-08-22. It is a release-first cascade beginning at the
L00/L10 crates, not something a level-20 consumer can do on its own.

Separately, Cargo.lock on main already resolves chia-bls at three lines (0.28.2 / 0.36.1 /
0.42.1) and chia-sha2 at four, the 0.42.1 line arriving transitively via chia-ssl. Pre-existing,
outside this branch's diff, recorded rather than ticketed.

Review notes for the gate

The three admission decisions are the ones worth adversarial attention, and each reads as consistent
with NC-12 — an accepted inbound peer is trusted less than a dialled one, because the attacker chose
to reach us:

  1. PeerSlot::dial_addr returns None for an accepted Nat slot, because its remote is the peer's
    ephemeral source port. The clause is scoped to PeerSlot::Nat, so Live and Stub are unchanged.
  2. max_direct_inbound is counted separately from the relayed cap, so the two inbound tiers cannot
    pool their budgets, and the cap is charged on conversion from another tier rather than exempting any
    held slot.
  3. An accepted connection never supersedes a peer already holding a dialable slot.

Staying DRAFT until the gate round returns.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Correctness gate — IN PROGRESS, not the verdict

Head read: cd13d215b6a6d5ddbfe7880cbcd393eca416843f.

Established so far (posting as formed, per the gate's durability rule):

  1. Sibling agreement is good on the four divergences that matter. adopt_direct_inbound_handle
    (src/service/gossip_handle.rs:1988) mirrors adopt_relayed_inbound_handle's shape: same
    require_running → tier refusal → self → ban → single-peers-lock budget+insert ordering (#1710
    atomicity), same newest-wins retire_slot outside the lock (adopt_relayed_inbound_handle: a superseded by-handle slot leaves its session ownerless and un-notified #71). The tier refusals are exact
    mirrors (!matches!(Relayed) there, matches!(Relayed) here), so no direct/relayed slot can be
    accounted against the other tier. No failure-direction disagreement found between the five entry
    points.
  2. PeerSlot::dial_addr widening is sound and narrow (src/service/state.rs:462-467). The new
    PeerSlot::Nat(n) if !n.is_outbound arm is scoped to Nat, and the only pre-existing producer of
    a non-outbound Nat slot is adopt_relayed_inbound_handle, which already returned None via
    is_relayed. So no previously-dialable slot changes behaviour. Live/Stub untouched.
  3. The cap self-drains, so it is not a durable denial primitive. An Observed accepted-direct
    slot reports is_closed() through ObservedSession, and the departed-peer reaper evicts it, so a
    dead inbound session cannot permanently hold a quarter of the pool or permanently block that
    peer's other adoption paths.
  4. Outbound diversity is correctly not charged. outbound_diversity_conflict
    (src/service/state.rs:528) filters on slot.is_outbound() && !is_relayed(slot), so an accepted
    slot occupies no INT-006/INT-007 group — matching the doc claim rather than merely asserting it.

Still open and being probed: whether the two inbound caps, counted separately, still leave the
maintenance loop any room; and the test-vacuity status of two admission branches.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, NOT THE VERDICT

Audited head: cd13d215b6a6d5ddbfe7880cbcd393eca416843f (resolved from gh pr view 83 --json headRefOid; worktree C:\tmp\worktrees\gossip-3124 clean at that SHA). Merge-base 48a14ed2.

Recording two findings now so they survive a stall. Neither is the verdict; the audit continues.


Finding 1 (HIGH, introduced here) — an accepted-direct slot can NEVER be chosen as a displacement victim

src/service/gossip_handle.rs:1988-2091 (adopt_direct_inbound_handle)

The new entry point inserts into peers and returns. It does not call
self.inner.pool.publish(PoolEvent::PeerAdded { .. }).

Both sibling adoption paths do:

  • adopt_nat_connectiongossip_handle.rs:1664
  • adopt_relayed_inbound_innergossip_handle.rs:2238

And publish is not merely an announcement. peer_pool.rs:854-861:

pub(crate) fn publish(&self, event: PoolEvent) {
    match &event {
        PoolEvent::PeerAdded { peer_id, .. } => {
            self.record_admission(*peer_id, metric_unix_timestamp_secs());
        }

Its own doc says so: "Admission bookkeeping lives HERE because this is already the one place every admission path funnels through to announce itself." The new path is now the one admission path that does not funnel through it.

The consequence is not a missing metric — it is displacement immunity.
displace_for_discovered_peer (gossip_handle.rs:1729) builds its victim set from
pool.activity_of(&cyclable), and activity_of (peer_pool.rs:801-819) is a filter_map over g.get(peer_id) — a peer with no ActivityRecord is silently dropped from the returned Vec. plan_displacement (peer_pool.rs:592-599) then picks the victim exclusively from req.incumbents.

So an accepted-direct peer:

  • counts toward req.connected (= peers.len(), gossip_handle.rs:1732), pushing the pool past capacity so RoomAlready no longer short-circuits;
  • is in cyclable (the filter at gossip_handle.rs:1720 is matches!(slot, PeerSlot::Nat(_)), which it satisfies);
  • is not in incumbents, because it has no activity record;
  • and is therefore never returned by min_by_key as the victim.

Attacker scenario. A stranger completes N mTLS handshakes with N free BLS identities and is adopted. Those N slots are permanently un-displaceable. Every displacement the discovery path (#3128 req. 8) attempts now falls on an honest peer — one admitted by a path that did register itself. Push it further and incumbents empties out, at which point plan_displacement returns Refused(NoIdleIncumbent) and discovered-holder admission is denied outright while the attacker's slots sit untouched.

This inverts NC-12: peers are meant to be cycled, and here the only slots that cannot be cycled are the ones a stranger chose to open.

Same omission also drops plumtree.add_peer and the total_connections increment relative to both siblings — the Plumtree half fails closed (the peer gets less reach, not more), so it is a correctness note rather than the security issue.


Finding 2 (MEDIUM-HIGH, introduced here) — the two inbound caps sum to 150% of the pool, so the reserve each one documents is not preserved under composition

src/service/peer_pool.rs:337-339 + :323-325, both via reserving_a_quarter at :343

fn reserving_a_quarter(n: usize) -> usize { n.saturating_sub((n / 4).max(1)) }

With the shipped default max_connections = 50 (types/config.rs:363):

  • max_direct_inbound(50) = 38
  • max_relayed_inbound(50) = 38
  • counted separately (gossip_handle.rs:2050-2065 vs :2185-2199)

The new doc comment states the separate counting as the safety property — "The caps are counted separately, so the two inbound tiers cannot pool their budgets" (peer_pool.rs:335). Separate counting stops one tier spending the other's budget, but it does not bound their sum: 38 + 38 = 76 against a pool of 50.

Each cap's stated purpose is to reserve a quarter of the pool for peers this node chooses. That reserve held while there was one inbound tier. With two, 38 accepted-direct + 12 accepted-relayed = 50 = max_connections, both caps satisfied, and the outbound dial path then fails at peers.len() >= max_connections (gossip_handle.rs:2043 and the equivalent in adopt_nat_connection). The reserved quarter is gone and the node makes no dials of its own choosing — the exact eclipse the max_relayed_inbound doc at :315-322 says the reserve exists to prevent, now reachable partly without a relay.

Compounds with Finding 1: those 38 direct slots are also the un-displaceable ones.


Still open and being worked: per-source (/16 / AS) bounding on this path, fail-direction of is_peer_id_banned_at, re-adoption churn accounting, and whether the dial_addr direction clause changes behaviour for any pre-existing slot.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correctness gate — CHANGES-REQUIRED

Head reviewed: cd13d215b6a6d5ddbfe7880cbcd393eca416843f (resolved from the remote, not from the dispatch brief).

Three gating findings, posted as inline threads on their lines. Ranked.

  1. The two inbound caps do not compose — inbound peers can still take 8/8 slots (src/service/peer_pool.rs:337). Measured with a probe, not reasoned: 6 accepted-direct + 2 relayed-inbound leaves adopt_nat_connection returning Err(MaxConnectionsReached(8)). This is a regression of a property that held before this PR.
  2. Test vacuity on both admission branches that carry the security argument (src/service/gossip_handle.rs:2028, :2045). Deleting either keeps the suite green; a blanket held.is_some() exemption — the bypass the sibling entry point explicitly warns about — also passes.
  3. §2.4b: dig-peer-protocol declared 0.7, latest published 0.9.0 (Cargo.toml:49).

What is CORRECT, verified (so a fix round does not re-derive it)

  • No rival-implementation divergence among the five adopt_* entry points. adopt_direct_inbound_handle mirrors adopt_relayed_inbound_handle exactly in ordering — require_running → tier refusal → self → ban → one peers-lock hold for budgets and insert (#1710 atomicity) → retire_slot outside the lock (#71). The tier refusals are exact complements, so no slot can be accounted against the other tier. No failure-direction disagreement found.
  • The dial_addr widening is sound and genuinely narrow (src/service/state.rs:462). The new PeerSlot::Nat(n) if !n.is_outbound arm cannot change any previously-dialable slot: the only pre-existing producer of a non-outbound Nat slot is the relayed path, which already returned None via is_relayed. Live/Stub untouched, so nothing relied upon narrows. The two undialability reasons are kept independent rather than collapsed, which is the right call.
  • Outbound diversity is not charged, as documentedoutbound_diversity_conflict (state.rs:528) filters is_outbound() && !is_relayed(), so an accepted slot occupies no INT-006 /16 or INT-007 AS group. Verified in the code, not taken from the doc comment.
  • The cap is not a durable denial primitive. An Observed slot answers is_closed() through ObservedSession, and the departed-peer reaper evicts it, so a dead inbound session cannot permanently hold a quarter of the pool or permanently lock its peer out of the other adoption paths. The fail-closed branch's triggering state does not survive the refusal.
  • The main fixture is not vacuous where it does assert. a_direct_inbound_peer_is_typed_direct_inbound_and_is_not_dialable carries a control slot with the opposite value for each of via, is_outbound and dial_addr, so none of the three can pass as a constant, and delivery is asserted as bytes the peer's owner received rather than a send-list length. §5.2 holds: every fixture address is IPv6 and nothing in this path dials.

Logged and left (non-gating, resolved by me — do not let these block merge)

  • Predicate asymmetry at gossip_handle.rs:2045 vs :2053. replaces_accepted_direct accepts any slot kind (!is_relayed && !is_outbound) while the occupancy count requires PeerSlot::Nat(_). Today this is vacuous — a Live/Stub inbound slot is dialable and refused by the :2028 guard before it reaches here — but the two predicates describe the same set and disagree, so the exemption becomes uncharged occupancy the moment any non-Nat slot can be undialable. Worth aligning while fixing finding 2.
  • The PR body is stale: it still opens DO NOT MERGE — WIP, gate round not yet run, leaves three checkboxes unticked, and says Blast radius: to be stated before this leaves draft. §2.4a requires the body to state the blast radius it checked. Update it with the fix round.
  • Reviewed read-only from git objects plus a scratch probe in C:\tmp\worktrees\gossip-3124; the probe file was deleted and git status --porcelain is empty at cd13d215.

Comment thread src/service/peer_pool.rs
Comment thread src/service/gossip_handle.rs Outdated
Comment thread Cargo.toml
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — CHANGES-REQUIRED

Head audited: cd13d215b6a6d5ddbfe7880cbcd393eca416843f (resolved from gh pr view 83 --json headRefOid; merge-base 48a14ed2). Read-only, from git objects in the gossip-3124 worktree, which was clean at that SHA. No edits, no probe files written — a sibling lane is writing into that worktree (a tests/gate_probe_3124.rs appeared and vanished between two of my calls), so every conclusion below is static, from tracked source at the audited SHA.

Three GATING findings, all introduced by this diff, all on the attacker-reachable path. They compose: one attacker action triggers all three.


GATING 1 (HIGH) — an accepted-direct slot can NEVER be displaced, so eviction pressure lands only on honest peers

src/service/gossip_handle.rs:1988-2091adopt_direct_inbound_handle inserts into peers and returns. It does not call self.inner.pool.publish(PoolEvent::PeerAdded { .. }).

Both siblings do: adopt_nat_connection at gossip_handle.rs:1664, adopt_relayed_inbound_inner at gossip_handle.rs:2238.

publish is not an announcement — it is the admission ledger (peer_pool.rs:854-861):

PoolEvent::PeerAdded { peer_id, .. } => {
    self.record_admission(*peer_id, metric_unix_timestamp_secs());
}

Its own doc says so: "Admission bookkeeping lives HERE because this is already the one place every admission path funnels through to announce itself." This is now the one admission path that does not.

The record can be created nowhere else. I checked every mutation of PoolState::activity:

site effect on an unknown peer
record_admission peer_pool.rs:717 the only or_insert — production callers: publish only (every other call site is #[cfg(test)])
begin_activity peer_pool.rs:769 returns false, inserts nothing. Its doc: "a peer this pool does not hold cannot be made busy, which is what bounds Self::activity"
end_activity peer_pool.rs:784 get_mut, no insert
record_departure / retain_admitted remove only

So an accepted-direct peer holds no ActivityRecord, ever, by any path — including after serving traffic, because begin_activity refuses to create one.

Why that is a security defect, not a missing metric. displace_for_discovered_peer (gossip_handle.rs:1729) builds its victim set as pool.activity_of(&cyclable), and activity_of (peer_pool.rs:808-818) is a filter_map over g.get(peer_id) — a peer with no record is silently dropped. plan_displacement (peer_pool.rs:592-599) then selects the victim exclusively from req.incumbents.

An accepted-direct peer therefore:

  • counts toward req.connected (= peers.len(), gossip_handle.rs:1732), so RoomAlready stops short-circuiting;
  • is in cyclable — the filter at gossip_handle.rs:1720 is matches!(slot, PeerSlot::Nat(_)), which it satisfies;
  • is not in incumbents;
  • is therefore never returned by min_by_key as the victim.

Attacker scenario. A stranger completes N mTLS handshakes under N identities and is adopted. Those N slots are permanently un-displaceable. Every displacement the #3128 discovered-holder path attempts now evicts an honest peer — one admitted by a path that registered itself. Push far enough that incumbents holds no idle peer and plan_displacement returns Refused(NoIdleIncumbent): discovered-holder admission is denied outright while the attacker's slots sit untouched.

This inverts NC-12. Peers are meant to be cycled; here the only slots that cannot be cycled are the ones a stranger chose to open.

The same omission also drops plumtree.add_peer and the total_connections increment, and this path emits no tracing::info! at all where both siblings log "pool connection established" (gossip_handle.rs:1672). The Plumtree half fails closed (the peer gets less reach) so it is a correctness note; the silent log is a detection gap — a node under inbound flood shows nothing.

Uncovered by the new suite: tests/con_3124_adopt_direct_inbound_tests.rs contains no reference to PoolEvent, plumtree, activity, or displace.


GATING 2 (HIGH) — the accepted-direct tier has no PER-SOURCE bound; one host with free identities takes the whole tier

src/service/gossip_handle.rs:1988-2091 — admission checks require_running, relayed-tier, self, ban, dialable-held-slot, max_connections, and the global accepted-direct count. There is no /16, no AS, and no per-IP bound anywhere on this path, and none exists elsewhere in the crate for inbound: outbound_diversity_conflict (state.rs:527-529) is gated on slot.is_outbound(), so an accepted slot occupies no group by construction.

The diff's reasoning for not charging the outbound budget is correct and I am not disputing it. The finding is that nothing was put in its place, on the one inbound tier where a per-source bound is actually meaningful: a relayed slot's remote is the relay endpoint (state.rs:560-562, the #1716 rationale), but an accepted direct slot's remote is the peer's real routable source IP. The diff has that address in hand and spends it on observability only.

Identities are free, which is what makes this cheap. src/nat/mod.rs:30-35: a peer presents an ECDSA P-256 leaf "signed by the shipped, public DigNetwork CA", minted locally via NodeCert::load_or_generate / generate_signed, with peer_id = SHA-256(TLS SPKI DER). A fresh keypair is a fresh peer_id. Sybil cost is keygen plus a handshake.

Attacker scenario. One machine, one IP, 38 locally-minted NodeCerts against the shipped default max_connections = 50 (types/config.rs:363, so max_direct_inbound(50) = 38). Result: 38 of 50 pool slots held from a single source and — by GATING 1 — permanently un-evictable. Secondary cost asymmetry: the new suite's own a_direct_inbound_peer_is_counted_still_served_and_reached_by_a_broadcast establishes that broadcasts reach these peers via the sink, so one host also draws 38x the node's broadcast egress for the price of 38 handshakes.

The PR's own test demonstrates the gap and passes. tests/con_3124_adopt_direct_inbound_tests.rs:412-468 adopts all six peers from the same inbound_source_addr() and asserts only that the sixth is admitted and the seventh refused. A per-source bound would have failed that fixture; its absence is why it passes.


GATING 3 (MEDIUM-HIGH) — the two inbound caps sum to 150% of the pool, so the reserved quarter each one documents is not preserved

src/service/peer_pool.rs:337-339 and :323-325, both via reserving_a_quarter at :343:

fn reserving_a_quarter(n: usize) -> usize { n.saturating_sub((n / 4).max(1)) }

At the shipped default max_connections = 50: max_direct_inbound(50) = 38, max_relayed_inbound(50) = 38, counted separately (gossip_handle.rs:2050-2065 vs :2185-2199). 38 + 38 = 76 against a pool of 50.

The new doc offers the separate counting as the safety property — "The caps are counted separately, so the two inbound tiers cannot pool their budgets" (peer_pool.rs:335). Separate counting stops one tier spending the other's budget; it does not bound their sum, which is what the reserve depends on. The reserve held while there was one inbound tier. With two it does not.

Attacker scenario. 38 accepted-direct + 12 accepted-relayed = 50 = max_connections, both caps satisfied. The maintenance dial path then refuses unconditionally: adopt_nat_connection runs as AdmissionSource::Maintained (gossip_handle.rs:1460), and at :1570-1574 a Maintained admission needing a free slot returns MaxConnectionsReached with no displacement — displacement is reachable only from AdmissionSource::Discovered (:1630). The node makes zero dials of its own choosing, which is precisely the eclipse max_relayed_inbound's doc at :315-322 says the reserve exists to prevent, now reachable largely without a relay.

Compounds with GATING 1: those 38 slots are also the un-displaceable ones, so the 12 remaining honest slots absorb all eviction pressure.

The test at :456-465 asserts "The reserved room is real: a peer THIS node dials is still admitted at the same moment" — true only in the single-tier world the fixture builds.


NON-GATING

A. is_peer_id_banned_at fails OPEN on a poisoned mutex — pre-existing, defense-in-depth.
src/service/state.rs:1327-1330: self.banned.lock().ok().is_some_and(|g| g.contains_key(&peer_id)). A poisoned banned lock yields None then falsenot banned. Unchanged by this diff and shared with adopt_relayed_inbound_inner; the new path is a new attacker-reachable consumer of it. Follow-up ticket, do not gate.

This is NOT a second instance of the dig-gossip#82 shape, and I checked specifically. Every guard this diff adds fails closed: the relayed-tier refusal (gossip_handle.rs:2000), self (:2005), the dialable-held-slot refusal (:2032), max_connections (:2036), the cap (:2067), and peers.lock().map_err(|_| GossipError::ChannelClosed)? (:2026). The whole admission decision and the insert sit under one peers lock hold, so the #1710 atomicity rule is preserved.

B. PeerSlot::Live inbound slots still report an ephemeral address as dialable — pre-existing.
src/connection/listener.rs:786 constructs the inbound slot with is_outbound: false and remote: remote_addr (the accepted connection's source). The new direction clause at state.rs:464-466 is scoped to PeerSlot::Nat, so a Live inbound slot still returns Some(ephemeral source addr) from dial_addr — exactly the defect class this PR fixes one tier of. The listener already holds the peer's real their_handshake.server_port (listener.rs:757) and spends it only on the address manager. Non-gating, worth a ticket.

C. The dial_addr change regresses nothing. Verified rather than taken from the doc: before this PR the only production constructor of a non-outbound NatSlot was adopt_relayed_inbound_inner (gossip_handle.rs:2205), which refuses any non-Relayed method at :2132. Every pre-existing inbound Nat slot was therefore already None via the tier clause. The remaining is_outbound: false sites are test-only (state.rs:1576, inside the #[cfg(test)] module opening at :1549). No pre-existing slot changes behaviour.

D. The dependency split does not reach a cryptographic path this diff touches — non-gating, and correctly not ticketed. Cargo.lock at head carries chia-bls at three lines (0.28.2 / 0.36.1 / 0.42.1) and chia-sha2 at four (0.28.2 / 0.34.0 / 0.36.1 / 0.42.1), as the lane recorded. But the identity stack is internally consistent: dig-tls 0.4.0 and dig-identity 0.7.1 both on chia-bls 0.36.1, dig-peer-protocol 0.7.0 on chia-sha2 0.36.1 + chia-traits 0.36.1. The crates that compute peer_id = SHA-256(SPKI DER) and the #1204 BLS-G1 binding all agree; the 0.28.2 and 0.42.1 lines sit in non-identity transitives. Separately, the new code performs no cryptographic operation at all — it consumes an already-authenticated peer_id. Bumps are all crates.io with checksums (chia-sdk-utils 0.34 to 0.36, dig-constants 0.10.1 to 0.11.2, dig-identity 0.6.0 to 0.7.1, dig-ip 0.1.1 to 0.1.2, dig-nat 0.20 to 0.21); no pin loosened, no git dep, no new dependency.


Checked and clean

  • Unbounded state per connection — the slot holds an ObservedSession, an Option<NatBroadcastSink> and a SocketAddr; the map is bounded by max_connections. No peer-declared length or count reaches an allocation on this path.
  • Cross-peer eviction — the new path supersedes only the same peer_id and never calls displace_for_discovered_peer, so an accepted connection cannot evict a different identity. A genuine strength.
  • Demotion refusal — the dialable-held-slot check (gossip_handle.rs:2032) correctly stops an accepted connection replacing a peer this node can dial.
  • Cap escape via tierTraversalKind::Relayed is refused at :2000, and the replaces_accepted_direct exemption (:2049) is correctly narrower than a blanket held-slot exemption, so converting a relayed slot into a direct one is charged.
  • Secrets — none introduced, logged, or committed.

What would clear the gate

  1. Publish PoolEvent::PeerAdded from adopt_direct_inbound_handle so the slot acquires an ActivityRecord and becomes cyclable like every other tier. If exclusion from Plumtree is deliberate, publish the event anyway — the admission record, not Plumtree membership, is what makes a slot displaceable.
  2. Bound the accepted-direct tier per source. remote.ip() here is routable, so util::ip_address::subnet_group already gives the key; an inbound occupancy budget keyed on it is the shape, kept distinct from the outbound one.
  3. Give the two inbound tiers a combined ceiling so that accepted_direct + accepted_relayed <= reserving_a_quarter(max_connections), and reword peer_pool.rs:335 — separate counting is not what preserves the reserve.

Each wants a test that fails without it; the current suite passes under all three defects.

Re-gate scope on the fix: this leg only.

MichaelTaylor3d and others added 3 commits August 29, 2026 02:00
…#3124)

Gate round on PR #83 returned three composing findings on the direct-inbound
adoption entry point. They are fixed together because they are one attack.

F1 — the entry point omitted `PoolEvent::PeerAdded`, which is the admission
LEDGER rather than an announcement: `publish` is the only production path that
creates a peer's `ActivityRecord`, `begin_activity` refuses to create one, and
`activity_of` silently drops a recordless peer before the displacement planner
sees it. An accepted slot therefore counted toward `connected`, sat in
`cyclable`, and could never be the victim — so the only un-cyclable slots in the
pool were the ones a stranger opened. Both sibling paths publish here; this one
now does too, along with the Plumtree membership and connection count they also
take.

F3 — `max_direct_inbound` and `max_relayed_inbound` were each a reserved quarter
of `max_connections` and were counted separately, so they bounded each tier and
neither tier's share of the sum: 6 + 2 fills a `max_connections` of 8 and the
next adoption failed with `MaxConnectionsReached`, strictly worse than before
the direct tier existed. `max_inbound_total` is a new AGGREGATE bound charged by
BOTH inbound entry points, and `max_direct_inbound` is now a reserved quarter of
that budget rather than of the pool — which also makes it capable of binding at
all, and reserves room on the tier a NAT'd peer has no alternative to.

F2 — no per-source bound existed on any inbound path, and identities are free
here (leaves are minted locally under a public CA). `max_direct_inbound_per_group`
bounds accepted direct peers sharing one /16 at a quarter of the tier, minimum
two, so one host cannot occupy the tier by minting certificates.

F4 — the branches carrying the security argument were vacuous: every fixture
used a fresh identity holding no slot, so neither the dialable-supersede refusal
nor the `replaces_accepted_direct` exemption was ever reached, and the cap
fixture held one tier from one source address. Four fixtures now offer an
identity that already holds a slot, hold BOTH tiers at once, and vary the source
group with a same-group/other-group control.

Co-Authored-By: Claude <noreply@anthropic.com>
A 5 + 1 arrangement survived deleting the direct path's charge of the shared
budget entirely, because its seventh adoption was relayed and the relayed path's
charge refused it. At 4 + 2 neither tier is at its own cap, so a seventh peer of
either tier can only be refused by the shared budget, and both are asserted.

Co-Authored-By: Claude <noreply@anthropic.com>
…ger (#3124)

The round-1 clause offered separate per-tier counting as the property that keeps
the two inbound tiers from pooling their budgets. It does not: two reserved
quarters counted separately sum to the whole pool. Replaces it with the three
bounds that actually hold, in order, and states the admission-ledger requirement
that makes an accepted slot displaceable at all.

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

Copy link
Copy Markdown
Contributor Author

Gate round 2 — all five findings fixed in one round

Branch loop/3124-direct-inbound. Still DRAFT, not merging.

What changed, per finding

finding fix where
F1 admission ledger the direct path now publishes PoolEvent::PeerAdded (plus the Plumtree membership and connection count both siblings take), so the slot gets an ActivityRecord and can be a displacement victim gossip_handle.rs
F3 caps did not compose new max_inbound_total — an AGGREGATE bound charged by both inbound entry points; max_direct_inbound becomes a reserved quarter of that budget rather than of the pool, which also makes it capable of binding at all peer_pool.rs, both inbound paths
F2 no per-source bound new max_direct_inbound_per_group — accepted direct peers sharing one /16 bounded at a quarter of the tier, minimum 2 peer_pool.rs, direct path
F4 vacuous branches four new fixtures that offer an identity ALREADY holding a slot, hold both tiers at once, and vary the source group tests/con_3124_adopt_direct_inbound_tests.rs
F5 dig-peer-protocol deferred, explicitly, to #82 — linked from the PR body, no new ticket PR body

At max_connections = 8: at most 5 accepted direct, at most 6 accepted overall, always ≥2
slots left for a peer this node dials. max_relayed_inbound is unchanged at 6, so relayed-only
behaviour matches what it was before this PR existed — which is what makes F3 a restored regression
rather than a new policy.

F3's probe, flipped

The reviewer measured PROBE_OUTCOME: Err(MaxConnectionsReached(8)) after 6 direct + 2 relayed. That
sequence is no longer reachable: the seventh accepted peer of either tier is now refused as
ConnectionFiltered by the shared budget, with two slots still free. Asserted directly in
the_two_inbound_tiers_cannot_pool_their_budgets, which also dials two peers afterwards — one
would be satisfied by a bound that fired in the wrong place.

Each fixture proved RED by reverting only its own fix

Tree committed first, then one mutation at a time, restored and verified clean between each. All seven
compiled, and each turned exactly ONE test red — no collateral, so no fixture is passing on a
neighbour's guard.

mutation test that went RED
remove publish(PeerAdded) from the direct path an_accepted_direct_peer_is_recorded_as_admitted_and_can_be_displaced
remove the shared-budget charge from BOTH paths the_two_inbound_tiers_cannot_pool_their_budgets
remove it from the direct path only the_two_inbound_tiers_cannot_pool_their_budgets
remove it from the relayed path only the_two_inbound_tiers_cannot_pool_their_budgets
remove the per-/16 bound one_source_group_cannot_take_the_accepted_direct_tier
remove the dialable-supersede refusal an_accepted_connection_never_supersedes_a_dialable_slot
widen the exemption to a blanket held.is_some() converting_a_held_slot_is_charged_but_re_adopting_the_same_tier_is_free

One false green was caught by this and is worth recording. The first version of the F3 fixture held
5 direct + 1 relayed and went GREEN when the direct path's charge was deleted outright — its
seventh adoption was relayed, so the relayed path's charge refused it and the fixture could not see the
other half missing. It is now 4 + 2, where neither tier is at its own cap, so a seventh peer of either
tier can only be refused by the shared budget, and both refusals are asserted. The per-path
mutations above exist to keep it that way.

Evidence

  • cargo test --test con_3124_adopt_direct_inbound_tests9 passed, 0 failed
  • cargo clippy --all-targets --all-features -- -D warningsexit 0
  • cargo fmt --all -- --checkexit 0
  • full-suite run + check-merge-preconditions.sh --allow-draft reported in the follow-up comment

Not re-litigated

Everything both gates cleared: the five adopt_* paths' ordering and tier refusals, dial_addr's new
arm, §5.2, the two pre-existing fail-opens (is_peer_id_banned_at, PeerSlot::Live dialability), and
the chia 0.36.1 → 0.48.0 cascade. The predicate asymmetry between the two matches! arms is left as
noted: de-vacuuming did not make it trivial, and the arms are answering different questions — one asks
"does this slot already occupy the direct budget", the other "is this slot a NAT slot at all".

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Check state at 31b8d64, asserted by name

.claude/scripts/check-merge-preconditions.sh --repo DIG-Network/dig-gossip --pr 83 --allow-draft, run
unpiped, exit 0 — RESULT: preconditions MET:

draft=true  mergeStateStatus=UNSTABLE  unresolvedReviewThreads=0
  Lint commit messages               SUCCESS
  Check version increment            SUCCESS
  Format, Clippy & Docs              SUCCESS
  Test Suite + Coverage (>=80%)      SUCCESS
  UNSTABLE (non-required, does not gate): Analyze (rust)

Analyze (rust) is CodeQL, still queued and not a required context; every other check passes,
including Cargo.lock in sync and rustls inbound tests (Linux). All three review threads are
answered and resolved. The PR stays DRAFT.

One local-only failure, named rather than hidden

cargo test --all-features fails con_009_tests::test_rustls_mtls_connector with rustls'
"Could not automatically determine the process-level CryptoProvider"--all-features enables both
aws-lc-rs and ring, and rustls refuses to pick. It is a property of that flag combination, not of
this diff (which touches no TLS), it reproduces on the base, and CI runs plain cargo test. Green
without the flag: cargo test --test con_009_tests → 4 passed, 0 failed. Under --all-features the
rest of the suite is 476 passed / 1 failed, that one.

Not fixed here — it is off the critical path for this round and belongs with whoever owns the feature
matrix, but it should not keep being rediscovered as a mystery.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-audit of PR #83 — IN PROGRESS, not the verdict

Head audited: 31b8d6491212561ecc661ff385d3658b7f940c54 (resolved from gh pr view --json headRefOid).
Delta re-gated: cd13d215..31b8d649.

Posting confirmed items as they resolve so they survive an interruption. This is not the verdict.

Confirmed CLEAR so far

  • No arithmetic underflow in the new double reserve. reserving_a_quarter uses
    n.saturating_sub((n / 4).max(1)) (src/service/peer_pool.rs:388), so the newly-nested
    max_direct_inbound(n) = reserving_a_quarter(max_inbound_total(n)) is total for every usize.
    max_direct_inbound(1) == 0, not a wrap to usize::MAX. Had this been a plain -, the nesting
    introduced here would have wrapped at n <= 1 in release and panicked in debug. It does not.
  • The per-group floor does NOT sum past the tier cap (F3-shape avoided). The tier cap and the
    group cap are checked as separate conjunctive guards
    (gossip_handle.rs:2053-2065 and :2089-2110), so max(ceil(direct/4), 2) can only ever be the
    tighter of the two — never additional capacity. The floor of 2 makes the group bound merely
    non-binding for max_connections <= 7 (where max_direct_inbound <= 5 so group_cap == 2
    and the tier cap is <= 5), which is a dormant guard, not an over-admission.
  • The group predicate is not attacker-chosen. subnet_group(&remote.ip())
    (gossip_handle.rs:2091) keys on the observed remote socket address of a completed mTLS session,
    not on anything the peer asserts. A relayed peer cannot land in the direct group set at all —
    is_accepted_direct excludes TraversalKind::Relayed, and the direct entry point refuses that
    tier outright.
  • The grouping predicate is the established one, not a rival. crate::util::ip_address::subnet_group
    is the same function INT-006 uses for outbound diversity (state.rs:523), and the new bound is
    strictly looser than INT-006's (2 per group vs INT-006's effective 1). No second derivation.

Confirmed measurements (evidence for findings still being written up)

reserving_a_quarter evaluated across max_connections:

n max_inbound_total max_direct_inbound max_relayed_inbound group cap slots left to dial
2 1 0 1 2 1
4 3 2 3 2 1
7 6 5 6 2 1
8 6 5 6 2 2
50 38 29 38 8 12

Two things fall out of that table and are being written up as findings:

  1. max_relayed_inbound(n) == max_inbound_total(n) for every n — both are reserving_a_quarter(n).
  2. "never fewer than two slots left for a peer THIS node dials" holds at n = 8 but not at n in 2..=7,
    where the reserve is max(n/4, 1) == 1.

Still open (in progress)

Displacement consequences of the new PoolEvent::PeerAdded (F1); the Live-slot tier vs the aggregate
bound; interleaved adopt/drop/re-adopt orderings; fail-closed check on the two new guards; and the
__begin_pool_activity_for_tests reachability question (preliminary: it is not cfg-gated).

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Correctness RE-GATE — PASS

Head read: 31b8d6491212561ecc661ff385d3658b7f940c54 (resolved from the remote, not from the dispatch
brief). Delta re-gated: cd13d215..31b8d649. Fresh context; the prior round's cleared items were not
re-derived.

The three prior gating findings are genuinely fixed, and I proved it by mutation rather than by reading

I re-ran four mutations myself in a private worktree at this head, restoring from a byte copy between each
and asserting git status --porcelain empty and git diff --stat showing exactly one changed line before
trusting any result. The lane's own warning about a single-occurrence replace is real — the aggregate-charge
block appears verbatim twice — so each mutation was anchored on the surrounding comment and the printed
byte offset checked to be the intended one.

Baseline at this head: 9/9 green.

# mutation offset result
M1 direct path: the aggregate guard forced false (anchored on "charged by BOTH inbound entry points") 107259 RED, exactly 1 testthe_two_inbound_tiers_cannot_pool_their_budgets at tests/con_3124_adopt_direct_inbound_tests.rs:584, the DIRECT assertion
M2 relayed path: same guard (anchored on "shared with the direct inbound entry") 117013 RED, exactly 1 test — same fixture at :565, the RELAYED assertion
M3 max_direct_inbound reverted to reserving_a_quarter(max_connections), its round-1 vacuous form 18897 RED, 2 testsaccepted_direct_inbound_peers_cannot_fill_the_pool (:464) and converting_a_held_slot_is_charged_but_re_adopting_the_same_tier_is_free (:157)
M4 per-group: the group guard forced false 108496 RED, exactly 1 testone_source_group_cannot_take_the_accepted_direct_tier (:662)

M1 and M2 are the load-bearing pair and they fail at DIFFERENT assertion lines (584 vs 565). That is the
concrete evidence the 4+2 rework repaired the false green the lane caught in itself: the fixture now
discriminates each charge site independently, which the earlier 5+1 arrangement provably did not. Both
refusals are asserted, both as ConnectionFiltered rather than MaxConnectionsReached, and the reserve is
then measured positively by dialling two peers into it (peer_count == 8). The fixture does not rest on an
arithmetic coincidence: at 4 direct + 2 relayed neither tier sits at its own cap (5 and 6), so the shared
budget is the only possible refuser for a seventh of either tier, and the comment says so.

The vacuity was real. max_direct_inbound and max_inbound_total were both reserving_a_quarter(n);
M3 restoring the old body raises the direct cap from 5 to 6 == the aggregate, and two tests go red — so the
old value could not bind and the new one does.

Findings

All are non-gating; none blocks merge. Ranked.

1. src/service/peer_pool.rs:323max_relayed_inbound now has exactly the vacuity that was just fixed
in its sibling.
max_relayed_inbound(n) and max_inbound_total(n) have identical bodies
(reserving_a_quarter(max_connections)), and the relayed count is by construction never greater than the
aggregate count, so the relayed per-tier cap can never refuse anything the aggregate would not. It reads as a
per-tier reserve and is not one. The practical consequence is an asymmetric reserve: the direct tier is held
to 5 of the 6-slot inbound budget, but 6 accepted relayed circuits can consume the entire budget and leave
the direct tier at zero. Not a regression (identical pre-PR, and the direct tier is new here), and the
pool-level reserve of 2 dialled slots survives either way — so logged, not gated. Worth a follow-up ticket
rather than a change in this PR.

2. docs/resources/SPEC.md:2100 — the rewritten clause is TRUE of the tiers it governs but its quantifier
overreaches.
Clause 1 says the aggregate bound "MUST be charged by every inbound entry point", and the
max_inbound_total rustdoc says "at least two slots are always free for a peer THIS node dials, whatever
mixture arrives". There is a third inbound admission path — the WebSocket listener at
src/connection/listener.rs:787 — which inserts an is_outbound: false slot bounded only by
max_connections (listener.rs:971) and charges neither inbound cap. is_accepted_inbound is deliberately
scoped to PeerSlot::Nat and state.rs:571 says so explicitly, so the code is precise and this is a
pre-existing, already-disclosed PeerSlot::Live accounting gap — but those two sentences are checkably false
as written, which is the same class as the round-1 born-false clause. Narrow both to the two dig-nat
inbound entry points. A doc-scope edit needs no further gate round.

3. src/service/gossip_handle.rs:2069 and :2269 — the aggregate-charge block is ~18 lines duplicated
verbatim (§2.5).
This is the exact shape that defeated the lane's own mutation harness, and a future edit
landing on one block and not the other silently restores the composition defect this PR exists to fix. A
single charge_inbound_budget(...) helper would make the two sites one rule. Not gated — the duplication is
currently byte-identical and both sites are proven live by M1/M2.

4. src/service/peer_pool.rs:378max_direct_inbound_per_group is non-binding below
max_connections = 8.
At 4: direct cap 2, group cap max(ceil(2/4), 2) == 2 — equal, so the group bound
can never fire. Fails closed, and the default is 8 (group cap 2, binding, proven by M4), so this is a
small-config note only.

5. Fixture-field uniformity — clean, with one uniform field. Source group varies (own group per peer, and
same-group-different-address in the per-group fixture), tier varies (direct and relayed), held-slot state
varies across all four values (none / dialable-outbound / relayed-accepted / direct-accepted), and the
displacement fixture has a measured 0-to-1 control rather than an unwired counter. The one uniform field is
TraversalKind: every admitting fixture passes Direct, and none uses UPnP/NatPmp/HolePunch. Low
risk, because is_accepted_direct is defined negatively as not-relayed, so those kinds cannot take a
different branch — but it is a field no fixture varies.

The other gate-focus items

  • Test hook. __begin_pool_activity_for_tests (gossip_handle.rs:3453) is #[doc(hidden)] pub, not
    #[cfg(test)]. Correct, and not a finding: these are integration tests linking the crate externally,
    where #[cfg(test)] is unreachable, and it matches all 20 pre-existing __*_for_tests hooks on this type.
    It is technically semver-public, which the 0.31.0 minor already accommodates.
  • The --all-features con_009_tests::test_rustls_mtls_connector failure is confirmed pre-existing.
    Cargo.toml is untouched by this delta, and both ring = "0.17" (:171) and the rustls /
    tokio-rustls pins to aws_lc_rs (:195, :196) predate it. Nothing in this PR causes it; CI runs plain
    cargo test and is green. Not gated.
  • Round-1 cleared items (rival divergence, single-lock atomicity, retire_slot, dial_addr, outbound
    diversity, section 5.2, and the matches! predicate asymmetry — which the extracted is_accepted_inbound /
    is_accepted_direct helpers have now largely dissolved) were not re-litigated.

Merge state

Verdict recorded as a comment review, not an event — GitHub rejects both APPROVE and REQUEST_CHANGES from
the PR author's identity with 422. This gate leaves no open thread, so nothing here bars merge.
Analyze (rust) (CodeQL) was still IN_PROGRESS at the time of writing; assert every required context by name
with check-merge-preconditions.sh (unpiped, reading the exit code) before merging, and undraft only at that
point.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-audit — FINDING 1, confirmed by runtime probe (IN PROGRESS, not the verdict)

Head: 31b8d6491212561ecc661ff385d3658b7f940c54.

F-NEW-1 — the RELAYED tier alone consumes the entire shared inbound budget, so the direct tier can be reduced to ZERO. Introduced by this delta.

Severity: MEDIUM-HIGH. Introduced here (cd13d215..31b8d649), not pre-existing.

The arithmetic. Both are the same one-line body applied to the same argument:

  • max_relayed_inbound(n) = reserving_a_quarter(n)src/service/peer_pool.rs:324
  • max_inbound_total(n) = reserving_a_quarter(n)src/service/peer_pool.rs:366

So max_relayed_inbound(n) == max_inbound_total(n) for every n, while the direct tier was
deliberately re-based one level down (max_direct_inbound(n) = reserving_a_quarter(max_inbound_total(n)),
peer_pool.rs:344) and is therefore strictly smaller for every n >= 2.

Consequence 1 — max_relayed_inbound is now dead code as a bound. On
adopt_relayed_inbound_inner the relayed cap (gossip_handle.rs:2255-2267) is immediately followed by
the aggregate (:2270-2286). The relayed-accepted set is a strict subset of the accepted-inbound set
(is_relayed(s) && !s.is_outbound() vs is_accepted_inbound, state.rs:571), the two caps are equal,
and the two exemptions are equivalent on this path — occupies_inbound_budget can only be true where
replaces_accepted_circuit is, because any held non-relayed slot is already refused at :2234.
Therefore no state exists in which the relayed cap changes the outcome. This is exactly the vacuity
the delta's own doc identifies and fixes for max_direct_inbound, recreated on the relayed side.

Consequence 2 — the reservation is one-way, and it is a live denial. The design note at
peer_pool.rs:337-341 states the intent: taking the direct tier's share out of the inbound budget
"reserves room on the tier a NAT'd peer has no alternative to … a flood of direct accepts must not be
able to deny it." That holds — direct is capped at 5 of 6, so a relayed peer always has >= 1 slot. The
converse does not hold at all.
Relayed is capped at 6 of 6, so a full relayed tier leaves the direct
tier zero.

Probe (run at this head, in a throwaway integration test, since deleted). Six relayed circuits, then
one direct inbound from its own group, with ZERO accepted-direct peers held so the direct tier's own cap
of 5 is entirely unused:

PROBE: relayed circuits admitted = 6
PROBE: peer_count after relayed fill = 6
PROBE RESULT: direct inbound REFUSED after a full relayed tier:
    ConnectionFiltered("#3124: total accepted inbound cap reached (6)")

This is a regression, provable by inspection of the delta. At cd13d215 the same sequence admits
the direct peer: the dialable-held guard does not fire (no held slot), MaxConnectionsReached does not
fire (peers.len() == 6 < 8), and the direct cap does not fire (0 < 6). The aggregate check is the
only refusal on that path and it did not exist at cd13d215. So a capability that worked before this
delta is now deniable.

Attacker scenario. Identities are free on this path — the delta's own rationale for the new /16
bound (peer_pool.rs:372-375) — and a relayed peer's remote is the relay endpoint, so
outbound_diversity_conflict returns None for the tier by construction (state.rs:520) and no
per-source bound exists or can exist on it
. One host therefore opens six circuits through the relay
this node holds a reservation with, occupies the whole accepted-inbound budget, and every subsequent
adopt_direct_inbound_handle returns ConnectionFiltered. The new per-/16 group bound is bypassed
entirely by choosing the other tier — the two tiers share one budget but only one of them is
source-bounded.

No attacker is required. A NAT'd node serving six relayed peers in normal operation reaches the same
state and silently stops registering every direct inbound peer it accepts — which by this crate's own
SPEC ("Reporting such a peer as unconnected is the same defect as for a circuit, on the far more common
path") is the defect #3124 exists to fix.

The property that is missing, stated rather than prescribed: neither inbound tier may be able to
consume the whole shared budget. The symmetric application of the crate's own derivation —
max_relayed_inbound(n) = reserving_a_quarter(max_inbound_total(n)), mirroring max_direct_inbound
exactly — yields 5 and 5 under an aggregate of 6, so each tier retains >= 1 slot. If touching the #870
relayed cap is undesirable, an equivalent direct-tier floor charged on the relayed path achieves the
same property. Either way the fixture that would have caught this is a 0-direct + 6-relayed arrangement;
the existing the_two_inbound_tiers_cannot_pool_their_budgets fixture is 4 + 2 and cannot see it.

Remaining checks (test hook reachability, SPEC clause truth, the from-below bounds) follow in the verdict.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-audit — FINDINGS 2-5 (IN PROGRESS, not the verdict)

Head: 31b8d6491212561ecc661ff385d3658b7f940c54 (re-resolved; unchanged). Continues the F-NEW-1
comment above — F-NEW-1 stands as posted and is NOT restated here.


F-NEW-2 — the new per-group bound is a /32 on IPv6, not a /16. On an IPv6-first network that is a per-HOSTING-PROVIDER cap of 2, and a cheap targeted denial. MEDIUM.

gossip_handle.rs:2287 keys the new bound with crate::util::ip_address::subnet_group, whose IPv6
branch (util/ip_address.rs:63-66) returns the FIRST FOUR BYTES — a /32, not a /16:

IpAddr::V6(v6) => {
    let o = v6.octets();
    ((o[0] as u32) << 24) | ((o[1] as u32) << 16) | ((o[2] as u32) << 8) | (o[3] as u32)
}

That predicate is correct and unchanged for its ORIGINAL job — outbound INT-006 diversity, where a
coarse group only makes this node's own dial set more diverse. The delta reuses it, unexamined, for
the opposite job: a hard REFUSAL of inbound peers. In that direction coarse is not conservative, it is
a denial primitive.

An IPv6 /32 is an RIR allocation to a HOSTING PROVIDER, not a site. DigitalOcean is
2604:a880::/32, Linode 2600:3c00::/32, Vultr 2001:19f0::/32, Hetzner 2a01:4f8::/32. So with
max_direct_inbound_per_group(8) == 2 (peer_pool.rs:379), a default node accepts AT MOST TWO direct
inbound peers from an entire hosting provider, worldwide. CLAUDE.md §5.2 makes IPv6 the PREFERRED
family for peer communication, so this is the common case on this path, not an edge case.

Exploit. To deny direct-inbound registration to every DIG node at provider P, an attacker rents
TWO hosts at P and holds two accepted-direct slots. Every further peer whose source falls in the /32
of P is refused with #3124: accepted direct inbound /16 group cap reached (2). On IPv4 the same
attack requires the attacker to be inside the victim's own /16; on IPv6 it requires only "same
provider", which anyone can buy for a few dollars. Combined with F-NEW-3 below, the two slots are
permanent.

The bound is also not what the code says it is, in three places — which is why the IPv6
calibration was never examined. All three say /16 and none is true for IPv6:

  • peer_pool.rs:353 doc: "how many ACCEPTED DIRECT peers may share one /16 source group"
  • gossip_handle.rs:2299 error text: accepted direct inbound /16 group cap reached
  • SPEC.md clause 3: "accepted direct peers sharing one /16 source group" — normative voice, false
    for IPv6 in the same commit that writes it

Not prescribing a fix, but the shape: the inbound group needs its own family-aware derivation (a /48
or /56 is the site-sized unit an attacker actually controls) rather than borrowing the outbound
netgroup, and the SPEC, doc and error text must state the real prefix per family.


F-NEW-3 — an accepted-direct peer that reconnects every <600s is PERMANENTLY un-displaceable and sorts LAST for eviction. Pre-existing mechanism, newly reachable on the cheapest path. MEDIUM.

Re-adoption on this path is exempt from all three occupancy caps by design, and the dialable-slot
guard does not fire for it: dial_addr() returns None for any PeerSlot::Nat with
is_outbound: false (state.rs:464-466). So a held accepted-direct peer may re-adopt itself freely
and unboundedly — there is no rate limit on this path.

Each re-adoption reaches PeerPool::publish(PoolEvent::PeerAdded) unconditionally
(gossip_handle.rs:2147-2152, new in this delta), which calls record_admission, which RESETS BOTH
CLOCKS on an existing record (peer_pool.rs:761-771):

.and_modify(|record| {
    record.admitted_at = now;
    record.last_active_at = now;
})

is_displaceable requires now - admitted_at >= min_established_secs AND
now - last_active_at >= min_idle_secs (peer_pool.rs:546-550). Defaults are 600 and 300
(constants.rs:200,210), normalized to min_established_secs = max(600, 300) = 600
(config.rs:504). A reconnect every 599 seconds therefore makes the peer non-displaceable FOREVER —
one TCP plus mTLS handshake per ten minutes.

It is worse than immunity: victim selection is min_by_key((last_active_at, admitted_at, peer_id))
(peer_pool.rs:640), so the re-adopting stranger carries the HIGHEST last_active_at and sorts LAST,
while a genuinely quiet honest peer sorts first and is evicted in its place. That is the exact
inversion the delta's own comment at gossip_handle.rs:2140-2146 cites NC-12 to prevent.

Why I am not gating on this one alone: the reset semantics are pre-existing and deliberate
(peer_pool.rs:744 documents them), and the sibling relayed path already reaches them. What the delta
changes is COST. The relayed path needs a circuit through this node's own relay reservation, and
adopt_nat_connection is outbound so an attacker cannot trigger it at all. Direct inbound is the
first path on which any anonymous host that completes a handshake can drive the reset at will. It also
composes with F-NEW-1 and F-NEW-2 to make a squatted slot permanent.


F-NEW-4 — __begin_pool_activity_for_tests is pub, unbalanced, and falsifies the in_flight invariant the crate states as structural. LOW-MEDIUM, defense-in-depth.

gossip_handle.rs:3453 ships a pub hook — not #[cfg(test)], not feature-gated, so it is in the
published crate's API — that calls PoolState::begin_activity directly. begin_activity increments
in_flight, and nothing decrements it but end_activity, which only PeerActivityGuard::drop calls.
The hook creates no guard and has no counterpart, so every call is a permanent +1.

is_displaceable short-circuits on self.in_flight == 0 (peer_pool.rs:547), so ONE call pins a peer
as non-displaceable for the lifetime of its record. Enough calls and the planner returns
NoIdleIncumbent forever and the pool can never cycle a peer again.

It also falsifies a claim the same file makes in normative voice (peer_pool.rs:754-759):

in_flight is never assigned on an existing record anywhere in this type — it is created at 0
..., incremented by begin_activity, and decremented by end_activity, which only
PeerActivityGuard::drop calls. There is no code path that can zero it while a guard lives, so the
count equals the number of live guards BY CONSTRUCTION.

After this delta the count does NOT equal the number of live guards, because a public entry point
increments it without one. The balanced public API already exists — peer_activity_guard
(gossip_handle.rs:1776) returns a #[must_use] guard.

Reachability, stated honestly: there is NO wire path to this hook, and I looked for one. Only an
embedder (dig-node) can call it. So this is not a live remote vulnerability — it is a shipped public
API that can wedge the displacement planner, plus a documented invariant that is now false.


F-NEW-5 — the aggregate cap's stated property ("at least two slots always free for a peer THIS node dials, whatever mixture arrives") is FALSE. Born false in this commit. MEDIUM.

peer_pool.rs:349-350:

max_inbound_total(8) == 6, so at least two slots are always free for a peer THIS node dials,
WHATEVER MIXTURE ARRIVES.

and SPEC.md clause 1:

This is the bound that reserves room for this node's own dialing, and it MUST BE CHARGED BY EVERY
INBOUND ENTRY POINT.

is_accepted_inbound is scoped to PeerSlot::Nat (state.rs:571), and the delta says so explicitly:
"Live and Stub slots keep the accounting they have today." But the Chia WebSocket listener — the
most reachable inbound path in the crate — inserts an inbound StubPeer into the SAME state.peers
map (connection/listener.rs:783-788, is_outbound: false), and its only bound is the
undifferentiated total (listener.rs:971):

if count >= state.config.max_connections {

So inbound Live/Stub slots count toward peers.len() and toward the MaxConnectionsReached check in
adopt_direct_inbound_handle (gossip_handle.rs:2046) while being invisible to max_inbound_total.
A pool filled through the listener leaves ZERO free slots, and no inbound entry point charges the
aggregate on that path — so the SPEC's "every inbound entry point" is unenforced and "whatever mixture
arrives" is wrong as written.

I verified the other three is_outbound: false sites: gossip_handle.rs:2121 and :2294 are the two
adoption paths and both charge the aggregate; state.rs:1597 is inside #[cfg(test)]. The listener is
the only production gap.

Behaviour on the listener path is UNCHANGED by this delta, so this is not a regression. It is a
normative claim that ships false in the commit that writes it, and it matters because the next reader
will build on a reserve that does not exist.


Verdict follows in the next comment.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security RE-AUDIT — CHANGES-REQUIRED

Head audited: 31b8d6491212561ecc661ff385d3658b7f940c54 (re-resolved from
gh pr view 83 --json headRefOid at the moment of posting; unchanged since round 2, PR still DRAFT).
Delta under gate: cd13d215..31b8d649, plus enough of the surrounding crate to judge reachability.

The five round-2 findings are FIXED — I re-checked each and none survives. What follows is NEW, all of
it introduced or newly reachable in this gate round's own delta. Full evidence is in my two IN-PROGRESS
comments above; this is the ranked verdict, written so the fix round is ONE round.

GATING

# Finding Severity Where
F-NEW-1 The RELAYED tier alone can consume the entire shared inbound budget, reducing the direct tier to ZERO. max_relayed_inbound(n) and max_inbound_total(n) are the identical body on the identical argument, so they are equal for every n, while the direct tier was deliberately re-based one level down. The relayed cap is now vacuous as a bound, and the reservation the delta's own design note claims is one-way. Regression — the same sequence is admitted at cd13d215. Probe-confirmed at this head: 6 relayed circuits then one direct inbound from an unused group returns ConnectionFiltered("#3124: total accepted inbound cap reached (6)") with the direct tier entirely empty. No per-source bound exists or CAN exist on the relayed tier, because a relayed peer's remote is the relay endpoint. MEDIUM-HIGH peer_pool.rs:324, :366, :344; gossip_handle.rs:2255-2286
F-NEW-2 The new per-group bound is a /32 on IPv6, not a /16 — an RIR allocation to a hosting provider. With max_direct_inbound_per_group(8) == 2, a default node accepts at most two direct inbound peers from an entire provider worldwide, and an attacker renting two hosts at provider P denies direct-inbound registration to every DIG node at P. §5.2 makes IPv6 the preferred family, so this is the common case. Three surfaces state /16 and are false for IPv6, one of them normative. MEDIUM gossip_handle.rs:2287, :2299; util/ip_address.rs:63-66; peer_pool.rs:353, :379; SPEC.md clause 3
F-NEW-5 The aggregate cap's stated property — "at least two slots are always free for a peer THIS node dials, whatever mixture arrives", and the SPEC's "MUST be charged by every inbound entry point" — is false as written. is_accepted_inbound is PeerSlot::Nat-scoped, but the Chia WebSocket listener inserts inbound StubPeer slots into the same state.peers map under only the undifferentiated total. A pool filled through the listener leaves zero free slots. Behaviour is unchanged there, so this is not a regression — it is a normative claim born false in the commit that writes it. MEDIUM peer_pool.rs:349-350; SPEC.md clause 1; state.rs:571; connection/listener.rs:783-788, :971

NOT GATING — name it, ticket it, do not hold the merge on it

# Finding Severity Where
F-NEW-3 A held accepted-direct peer may re-adopt itself unboundedly and free of every cap; each re-adoption resets BOTH displacement clocks, so a reconnect every 599s makes it permanently un-displaceable AND sorts it LAST for eviction ahead of honest quiet peers — the inversion the delta's own comment cites NC-12 to prevent. The mechanism is pre-existing and deliberate; what this delta changes is COST, since direct inbound is the first path an anonymous host can drive at will. Composes with F-NEW-1 and F-NEW-2 to make a squatted slot permanent. MEDIUM gossip_handle.rs:2147-2152; peer_pool.rs:761-771, :546-550, :640; constants.rs:200,210
F-NEW-4 __begin_pool_activity_for_tests is pub (not #[cfg(test)], not feature-gated), increments in_flight with no guard and no counterpart, and so permanently pins a peer as non-displaceable. It falsifies the "the count equals the number of live guards BY CONSTRUCTION" invariant the same file states. No wire path reaches it — embedder-only, so not a live remote vulnerability. Worth feature-gating in this round because the fix is one line and it otherwise ships in the published API. LOW-MEDIUM gossip_handle.rs:3453; peer_pool.rs:754-759, :547

What I checked and found CLEAR

  • Secrets / credentials — no key, token, credential or endpoint introduced, logged or committed.
    Every new error string is SafeText::from_untrusted over numeric caps and a verified PeerId; no
    attacker-supplied string reaches a log or a message.
  • Aggregate cap correctness, apart from F-NEW-1 and F-NEW-5 — the bound itself is sound and
    load-bearing. The exemption is charged on net-new occupancy only; converting relayed to direct is
    charged on the direct cap and correctly exempt on the aggregate; a peer can never grow the aggregate
    past max_inbound_total. All four is_outbound: false construction sites accounted for:
    gossip_handle.rs:2121 and :2294 are the two adoption paths and both charge it, state.rs:1597 is
    #[cfg(test)], and the listener is F-NEW-5.
  • Atomicity — budgets and the insert are decided under one peers-lock hold, so two concurrent
    accepted connections cannot both pass the last free slot. No TOCTOU on the new caps.
  • Group-bound self-exclusion**pid != peer_id is correct: a re-adoption never counts itself and
    cannot buy a group slot it does not already hold. The in-group maximum is genuinely group_cap.
  • remote provenance — the SPEC does constrain it ("an accepted connection's remote is the
    peer's EPHEMERAL SOURCE PORT"), so the /16 bound rests on an observed address rather than a claimed
    one. Since the whole bound now depends on this, an explicit MUST would be worth one sentence.
  • total_connections increment — a lifetime stats counter, never decremented by design
    (state.rs:747), consistent with all sibling paths. Re-adoption inflating it is a stats artifact, not
    a capacity input. Not a finding.
  • Test-hook reachability — I traced every remote entry point; nothing added for testing is reachable
    from a peer-facing path. __begin_pool_activity_for_tests is F-NEW-4 on API-surface grounds only.
  • IPv6 in the fixtures — the tests are IPv6 throughout, per §5.2, and inbound_source_in_group
    documents the four-byte keying correctly. That is what makes F-NEW-2 an internal contradiction rather
    than an oversight: the test helper knows the group is a /32 while the SPEC, the doc and the
    user-visible error string all say /16.

Note on the fixtures, for the fix round

the_two_inbound_tiers_cannot_pool_their_budgets is 4 direct + 2 relayed and structurally cannot see
F-NEW-1; the arrangement that catches it is 0 direct + 6 relayed. And
one_source_group_cannot_take_the_accepted_direct_tier asserts the group cap FIRES — it ratifies the
/32 calibration rather than questioning it, so it will stay green through any fix to F-NEW-2 unless a
new fixture asserts that two peers at DIFFERENT sites within one provider's /32 are both admitted.


Read-only audit. No file in any checkout was modified; the probe cited in the F-NEW-1 comment ran in a
throwaway worktree and was removed. I did not merge and did not undraft.

MichaelTaylor3d and others added 4 commits August 29, 2026 06:40
…124)

Four findings from the loop-security re-audit of PR #83, all introduced by
this PR's own delta (cd13d21..31b8d64).

F-NEW-1 — the relayed tier could consume the whole shared inbound budget.
`max_relayed_inbound` and `max_inbound_total` were the identical body on the
identical argument, so they were equal for every input while the direct tier
was re-based one level down. The relayed cap was therefore vacuous (the
aggregate is charged immediately after it on the same path, with an equivalent
exemption), and the reservation ran one way: direct was held to 5 of 6 so a
circuit always had a slot, relayed was held to 6 of 6 so six circuits left the
direct tier zero. Probe-confirmed: six relayed circuits then one direct inbound,
with no accepted-direct peer held at all, returned ConnectionFiltered. The
relayed tier is also the one that can never be source-bounded, because a
circuit's `remote` is the relay endpoint. `max_relayed_inbound` is now the same
reserved quarter OF THE INBOUND BUDGET that `max_direct_inbound` is — 5 and 5
under an aggregate of 6 — so neither tier can exhaust the shared budget.

F-NEW-2 — the per-group bound keyed IPv6 on a /32, a hosting provider's RIR
allocation, because it borrowed the OUTBOUND diversity key. A group wider than
the unit an attacker controls is conservative when it diversifies this node's
own dials and is a denial primitive when it refuses a peer: at a cap of 2 a
default node accepted two direct inbound peers from an entire provider
worldwide, and two rented hosts there locked out every other customer. IPv6 is
the preferred family (§5.2), so this was the common case. A new family-aware
`inbound_source_group` keys IPv4 on its /16 (unchanged) and IPv6 on its /48, the
end-site allocation unit, as distinct variants so the two key spaces cannot
collide. The doc, the user-visible error string and SPEC clause 3 all said /16
and are corrected.

F-NEW-5 — "at least two slots are always free for a peer THIS node dials,
whatever mixture arrives", and the SPEC's "MUST be charged by every inbound
entry point", were false as written: the aggregate is scoped to accepted
`dig-nat` slots, while the Chia WebSocket listener inserts inbound Stub slots
into the same map under only `max_connections`. Resolved by making the claim
HONEST rather than TRUE — charging the aggregate at the listener would retighten
this crate's most reachable inbound path inside a PR about direct inbound
adoption, which is its own unit of work. The doc and the SPEC now state exactly
which slots the budget counts and which it does not.

F-NEW-4 — `__begin_pool_activity_for_tests` was `pub`, unbalanced, and
incremented `in_flight` with no guard, permanently pinning a peer as
non-displaceable and falsifying the "the count equals the number of live guards
by construction" invariant. Removed: the balanced public `peer_activity_guard`
already observes the same thing, and its guard decrements on drop.

Tests: `a_full_relayed_tier_still_leaves_the_direct_tier_a_slot` (0 direct + a
full relayed tier — the existing 4+2 fixture structurally cannot see F-NEW-1)
and `two_sites_in_one_ipv6_provider_prefix_are_different_source_groups` (two
/48 sites inside one provider /32 — the existing group fixture uses three
addresses in ONE /48 and stays green under either keying). Both pin their bound
from above and below.

Co-Authored-By: Claude <noreply@anthropic.com>
Regenerate Cargo.lock for dig-ip 0.1.2 bump and apply rustfmt formatting fixes to test assertions that exceed line length.

Fixes three failing required checks:
- Cargo.lock in sync (--locked validation)
- rustls inbound tests (--locked)
- Test Suite + Coverage

Co-Authored-By: Claude <noreply@anthropic.com>
…(#3124)

The symmetric derivation took a reserved quarter twice — once to reach the
inbound budget, once to reach a tier's share of it — and that collapses to ZERO
for every max_connections <= 3, because reserving_a_quarter(1) == 0. A cap of
zero does not reserve a tier's room, it closes the tier: at max_connections = 2
every relayed circuit this node serves was refused with
"accepted relayed circuit cap reached (0)". That is the same starvation the
symmetric derivation exists to prevent, inverted onto the relayed side.

a_reserved_share_of floors a tier's share at one slot while the inbound budget
can hold a peer, clamped to that budget so an empty budget stays empty. Both
tiers keep the identical body, so the reservation still runs in both directions,
and the aggregate max_inbound_total is charged on the same path — two floored
tiers still cannot outgrow the budget they share.

Also corrects a stale numeral in con_870's held-slot probe. That test's fixture
was retuned by this branch from six circuits to five when max_relayed_inbound(8)
became 5, but its post-refusal count was left at 6, which the retuned fixture
cannot produce. The asserted PROPERTY — the count equals the cap after a refusal
— is unchanged; only the constant tracking the cap moved.

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

Copy link
Copy Markdown
Contributor Author

Debug round: both gate failures fixed at c6e9a29 — PR stays DRAFT

Root cause

The symmetric derivation introduced in a394ef9 takes a reserved quarter twice — once to reach the inbound budget, once to reach a tier's share of it — and reserving_a_quarter(1) == 0, so both inbound tier caps collapse to 0 for every max_connections <= 3. A cap of zero does not reserve a tier's room, it closes the tier.

The gate's hypothesis is CONFIRMED in its conclusion and REFUTED in its two named leads:

  • Confirmed: the relayed cap really did evaluate to 0, and that is the same starvation F-NEW-1 complained about, merely inverted onto the relayed side.
  • Refuted lead 1 (max_relayed_inbound and max_inbound_total are the identical body): they are no longer identical — a394ef9 had already re-derived the relayed cap from the inbound budget rather than the pool. That was the correct direction; it just had no floor.
  • Refuted lead 2 (is_accepted_inbound is PeerSlot::Nat-scoped while the listener inserts under the total): true as an asymmetry, and it is documented deliberately on max_inbound_total, but it is not what produced the 0. The 0 is pure arithmetic on max_connections, before any pool state is read.

What the cap function returns, before and after

config fn before after
max_connections = 2 (pool_3128) max_relayed_inbound 0 — every circuit refused 1
max_connections = 2 max_direct_inbound 0 1
max_connections = 8 (con_870) max_relayed_inbound 5 5 (unchanged)
max_connections = 8 max_inbound_total 6 6 (unchanged)

The fix is a_reserved_share_of: a reserved quarter of the inbound budget, floored at one slot while the budget can hold a peer, clamped to the budget so an empty budget stays empty. Both tiers keep the identical body, so the reservation still runs in both directions; the aggregate max_inbound_total is charged on the same path, so two floored tiers still cannot outgrow the budget they share. A new unit test asserts the no-zero property over max_connections 0..=64 in both directions, plus the aggregate that keeps the floor honest.

The one test line that changed, and why that is not ratifying a defect

con_870's a_held_slot_does_not_exempt_a_circuit_from_the_accepted_relayed_cap asserted accepted == 6 after the refusal. That branch had already retuned this test's own fixture in a394ef9 from six circuits to five, when max_relayed_inbound(8) became 5 — and left the post-refusal count at 6. Five adoptions plus one refusal cannot produce six, at any cap, so the assertion was unsatisfiable by arithmetic rather than by behaviour: an internal inconsistency in this branch's own edit, not a shipped invariant.

The asserted property — the accepted count equals the cap after a refusal, i.e. the refusal was real — is unchanged and still asserted. Only the numeral tracking the cap moved. No test was relaxed, and pool_3128 was not touched at all.

Verification

Locally green: peer_pool unit tests (33), con_870 (9), pool_3128 (5), con_3124_adopt_direct_inbound (12). cargo fmt --check clean, cargo clippy --all-targets -D warnings exit 0.

Head c6e9a29. DO NOT MERGE — this PR remains DRAFT pending its gate round.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security round 4 — IN PROGRESS, not the verdict

Head resolved from the remote myself: c6e9a29df4ae67bfc1565dd9db0d86c096445d3f (PR still DRAFT).
Range under gate: 31b8d649..c6e9a29d — verified against the round-3 verdict's own stated head, not
against the dispatch brief. Four commits: a394ef9, 4f62a8d, 00e67c4, c6e9a29.

One correction to the fix-round writeup before anything else: a394ef9 is INSIDE this range, not
before it.
The round-3 verdict was posted at 31b8d649 and a394ef9 landed after it. So "a394ef9
had already re-derived the relayed cap" describes work that is under gate here, and I verified it
directly rather than inheriting it.

The cap table, derived independently from the source

reserving_a_quarter(n) = n.saturating_sub((n/4).max(1)), and
a_reserved_share_of(b) = reserving_a_quarter(b).max(1).min(b).

max_connections max_inbound_total each tier non-inbound slots left
0 0 0 0
1 0 0 1
2 1 1 1
3 2 1 1
4 3 2 1
6 5 4 1
8 6 5 2
16 12 9 4

The three adversarial questions put to this fix, answered:

  • Does the floor admit a peer where admitting any inbound peer is wrong? No. .min(inbound_budget)
    means the floor can only fire when the budget is already >= 1, and max_inbound_total(mc) <= mc - 1
    for every mc >= 1, so a slot for this node's own dialing survives at every configuration. At
    mc <= 1 the budget is 0 and both tiers stay 0 — the floor does not override it.
  • Can two floored tiers plus the aggregate exceed the shared budget? No. Both adoption paths charge
    max_inbound_total after their tier cap — gossip_handle.rs:2084 (direct) and :2290 (relayed) — so
    the aggregate binds independently. At mc = 2 both tiers read 1 and the aggregate is 1: whichever
    tier arrives first takes it, and the second is refused by the aggregate, not by its tier.
  • Does the floor hand an attacker a slot a correct implementation would reserve? Not one it could
    otherwise have. At mc = 2..3 the property "each tier is guaranteed a slot" genuinely degenerates to
    "one inbound slot exists, first-come" — you cannot reserve a fraction of a slot. That is the honest
    behaviour at those sizes and it is strictly better than the 0 it replaces, which closed both
    tiers outright.

F-NEW-1's property holds in BOTH directions at the default mc = 8: 5 of 6 to either tier, so a
full relayed tier leaves one for direct and a full direct tier leaves one for relayed. The regression
the round-3 verdict named — 6 relayed circuits reducing the direct tier to zero — is arithmetically
unreachable now, because no tier can reach 6.

con_870's accepted == 65: NOT a ratified defect

I checked this at the commit boundary rather than taking the justification. a394ef9 changed
a_held_slot_does_not_exempt_a_circuit_from_the_accepted_relayed_cap's loop from 0..6u8 to 0..5u8
and did not touch the assert_eq!(accepted, 6) below it — so at a394ef9 the fixture adopted five
circuits and asserted six, which no behaviour can satisfy. The constant was unsatisfiable by arithmetic,
exactly as claimed. 00e67c4 corrected it.

The load-bearing question is whether 5 is still discriminating, and it is. Peer [70; 32] is admitted
DIRECTLY (Via::Direct), then re-offered as a circuit. The counter filters
p.via == Via::Relay && !p.is_outbound. Had the cap failed to refuse, that peer would have converted
to Via::Relay and the count would read 6.
So 5 vs 6 is precisely the refused-vs-admitted
distinction, and the assertion still fails against the defect it was written for. No test was relaxed.

F-NEW-2 — fixed, and the correction is accurate this time

inbound_source_group (src/util/ip_address.rs:69-121) keys IPv6 on the first six bytes — a /48,
the RFC 6177 / RIPE-690 end-site unit — and IPv4 on the first two octets, unchanged. I verified the
three surfaces the round-3 verdict found false now agree with the code: the error string
(gossip_handle.rs:2116), the doc on max_direct_inbound_per_group (peer_pool.rs:405), and the test
helper. canonical_ip still runs first, so a v4-mapped source cannot dodge its v4 group.

Two details I checked because a family-split key is where collisions hide: the return type is an enum
with V4Slash16(u32) / V6Slash48(u64), so a v4 /16 key cannot collide with the low bits of a v6
/48 key; and the outbound subnet_group is untouched at its /32, which is correct — widening is
conservative when DIVERSIFYING and a denial primitive when REFUSING, and the two now have separate keys.

F-NEW-4 — fixed more strongly than asked

__begin_pool_activity_for_tests is removed outright, not feature-gated
(gossip_handle.rs, -13 lines). Its single call site was converted to peer_activity_guard(...),
which is balanced — it decrements in_flight on drop — so observing the record can no longer pin the
peer as un-displaceable. grep across the tree finds zero remaining references outside a progress note.

Still open in this round: F-NEW-5's SPEC/doc correction, the new fixtures' ability to actually catch
their defects, and a reachability sweep of the delta. Verdict to follow.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security round 4 — IN PROGRESS (2/2), not the verdict

Head c6e9a29d, range 31b8d649..c6e9a29d. F-NEW-5 is fixed and I found one new GATING
conformance failure
introduced by the floor commit. Details below; verdict next.

F-NEW-5 — FIXED, and the replacement claim is accurate

I verified the new SPEC paragraph against the code rather than accepting it:

  • is_accepted_inbound (state.rs:573-575) is matches!(slot, PeerSlot::Nat(n) if !n.is_outbound)
    PeerSlot::Nat-scoped, exactly as the SPEC now says.
  • The listener bounds itself with if count >= state.config.max_connections (listener.rs:971) —
    max_connections alone, exactly as the SPEC now says.
  • The false clause ("at least two slots are always free for a peer THIS node dials, whatever mixture
    arrives" / "MUST be charged by every inbound entry point") is gone, replaced by the narrower true
    one: "BOTH dig-nat adoption entry points MUST charge it", plus an explicit paragraph stating that a
    Stub/Live listener slot occupies the pool without being charged and that the reserve "MUST NOT be
    read as one about the pool as a whole".

Stating the limitation rather than quietly narrowing the wording is the right disposition, and
is_accepted_inbound's own doc now carries the same caveat.


NEW GATING FINDING — F-R4-1: the crate now VIOLATES its own normative SPEC

docs/resources/SPEC.md:2204 vs src/service/peer_pool.rs:434-448 and :1173LOW-MEDIUM

00e67c4, the commit that introduced the floor, touched peer_pool.rs and one test line and nothing
else
. It changed the value of a normative bound at every small pool and updated no SPEC clause.
SPEC.md still carries the pre-floor closed form:

  • Bounded. At most max_relayed_inbound = max_inbound_total − max(max_inbound_total/4, 1)
    accepted circuits (5 at max_connections = 8, where the inbound budget is 6) …

That is reserving_a_quarter(max_inbound_total)without the .max(1).min(budget) that
a_reserved_share_of applies. Evaluate both at max_connections = 2, where max_inbound_total(2) == 1:

value at max_connections = 2
SPEC formula: 1 − max(1/4, 1) = 1 − max(0, 1) 0
a_reserved_share_of(1) = reserving_a_quarter(1).max(1).min(1) = 0.max(1).min(1) 1

The proof needs no probe — it is two committed artifacts contradicting each other. This PR's own new
unit test asserts the value the SPEC forbids:

assert_eq!(
    max_relayed_inbound(2),
    1,
    "a two-slot pool still serves a circuit"
);

SPEC says "At most 0". The code admits 1 and a shipped test pins it at 1. That is a conformance
failure, not a documentation gap.

Why it gates. §4.2 makes SPEC.md "the authoritative contract an independent reimplementation could
be built against", and the reimplementation this clause prescribes reproduces exactly the regression
this round exists to fix
: a cap of 0 that closes the tier outright and refuses every relayed circuit
with accepted relayed circuit cap reached (0). §4.2 also makes a behaviour change that leaves SPEC
describing the old behaviour INCOMPLETE, and 00e67c4 is precisely that. It is the same class the
round-3 verdict gated as F-NEW-5 — a normative clause that is false about the code in the commit that
ships it — and this PR has now produced three of them, which is why I am holding the line rather than
filing it.

The fix is one clause, and max_direct_inbound's and clause 2's prose ("each a reserved quarter of
the INBOUND budget") need the same floor stated, since they are equally silent about it. This is the
cheapest finding this PR has had and needs only a scoped doc re-gate, not a full round (§1.10).


NOT GATING

F-R4-2 — a fourth /16 surface survived the F-NEW-2 sweep. src/service/state.rs:578 still reads
"the per-/16 bound [max_direct_inbound_per_group]", which is false for IPv6 now that the key is a
/48. F-NEW-2's fix corrected three surfaces — the error string (gossip_handle.rs:2117), the
peer_pool.rs:404 doc and the test helper — and missed this one. It is a crate-private doc comment, not
normative and not user-visible, so it does not gate; fold it into F-R4-1's commit. I swept every other
/16 in src/ and the rest are correctly about the OUTBOUND subnet_group (INT-006) or the addrman
tried-buckets, which genuinely are /16.

F-R4-3 — special-purpose IPv6 prefixes are not canonicalized (defense-in-depth, follow-up ticket).
canonical_ip folds only IPv4-mapped (::ffff:a.b.c.d), deliberately and correctly. It does not
fold the prefixes where a /48 boundary does not correspond to a site:

  • 6to4 2002::/16 — the embedded IPv4 occupies bytes 2-5, so 2002:<v4>::/48 gives one group per
    IPv4 address
    . An attacker holding a single IPv4 /24 — one group under the v4 keying — would obtain
    256 distinct v6 groups, and group_cap = 2 means three suffice to fill the tier.
  • Teredo 2001:0::/32 and NAT64 64:ff9b::/96 fail the other way: many unrelated sources
    collapse into one /48, which is the denial direction.

I am not gating this. All three are deprecated (RFC 7526 withdrew the 6to4 anycast relay) or do not
arise on an inbound server path, so I could not construct a scenario an attacker can actually drive
today. Worth a ticket to reject or specially-key those prefixes if the group bound is ever tightened.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security RE-AUDIT round 4 — CHANGES-REQUIRED

Head audited: c6e9a29df4ae67bfc1565dd9db0d86c096445d3f — re-resolved from
gh pr view 83 --json headRefOid at the start AND re-checked at the end of this audit; unchanged
throughout, PR still DRAFT, all 10 required checks SUCCESS.

Range: 31b8d649..c6e9a29d — four commits: a394ef9, 4f62a8d, 00e67c4, c6e9a29. I verified
this range against the round-3 verdict's own stated head rather than the dispatch brief. One correction
worth recording: a394ef9 is INSIDE this range, not before it, so the fix-round writeup's appeals to
"a394ef9 had already done X" describe work under gate here, and I checked each of them directly.

All four round-3 findings are FIXED. The con_870 constant change is legitimate. One NEW gating
finding, introduced by the floor commit itself.

The round-3 findings

# Verdict Evidence
F-NEW-1 — relayed tier can consume the whole shared budget FIXED max_relayed_inbound and max_direct_inbound are now the identical body over a_reserved_share_of(max_inbound_total(n)) (peer_pool.rs:344, :367), so at mc = 8 each tier is 5 against an aggregate of 6 and neither tier can reach 6. Both adoption paths charge the aggregate independently (gossip_handle.rs:2084, :2290), so two floored tiers cannot outgrow the budget. New fixture a_full_relayed_tier_still_leaves_the_direct_tier_a_slot uses the 0-direct + full-relayed arrangement round 3 said was required, and loops past the expected bound so it cannot pass against a build whose cap is 6.
F-NEW-2 — IPv6 group keyed on a /32 FIXED inbound_source_group (util/ip_address.rs:88-104) keys IPv6 on the first six bytes (/48) and IPv4 on the first two octets, returning a family-split enum so the two key spaces cannot collide. canonical_ip still runs first. Outbound subnet_group correctly left at /32. Fixture two_sites_in_one_ipv6_provider_prefix_are_different_source_groups uses a real Vultr /32 and asserts BOTH sides — site A still refused at its cap, site B admitted — so "widened" and "deleted" cannot look the same.
F-NEW-4pub unbalanced test hook FIXED, more strongly than asked Removed outright rather than feature-gated. Its single call site now uses peer_activity_guard, which decrements in_flight on drop, so observing the record can no longer pin the peer. Zero references remain anywhere in the tree.
F-NEW-5 — normative claim born false FIXED The false clause is gone; the replacement narrows to "BOTH dig-nat adoption entry points MUST charge it", plus an explicit paragraph that a Stub/Live listener slot is not charged and the reserve "MUST NOT be read as one about the pool as a whole". I verified both halves against the code: is_accepted_inbound is PeerSlot::Nat-scoped (state.rs:573), and the listener bounds on max_connections alone (listener.rs:971).

The floor, adversarially

Derived independently. a_reserved_share_of(b) = reserving_a_quarter(b).max(1).min(b):

max_connections 0 1 2 3 4 6 8 16
max_inbound_total 0 0 1 2 3 5 6 12
each tier 0 0 1 1 2 4 5 9
slots left for this node's own dials 0 1 1 1 1 1 2 4
  • The floor never admits a peer where admitting one is wrong. .min(budget) means it can only fire
    where the budget is already >= 1, and max_inbound_total(mc) <= mc - 1 for every mc >= 1, so a
    non-inbound slot survives at every configuration. At mc <= 1 both tiers stay 0.
  • Two floored tiers cannot exceed the shared budget. The aggregate is charged after the tier cap on
    both paths. At mc = 2 both tiers read 1 and the aggregate is 1: the second arrival is refused by the
    aggregate, not by its tier.
  • The floor grants no slot an attacker could not already have. For the relayed tier it RESTORES
    main's pre-PR value (reserving_a_quarter(2) == 1); it does not loosen anything. The direct tier's
    0 -> 1 at mc = 2 is this PR's own new capability, bounded by an aggregate of 1.
  • F-NEW-1's property holds in BOTH directions, which is what round 3 asked for.

Honest limit, not a defect: at mc = 2..3 "each tier is guaranteed a slot" degenerates to "one inbound
slot exists, first-come" — you cannot reserve a fraction of a slot. That is strictly better than the 0
it replaces, which closed both tiers.

con_870 6 -> 5 — NOT ratifying a defect

Checked at the commit boundary, not taken on the justification. a394ef9 changed
a_held_slot_does_not_exempt_a_circuit_from_the_accepted_relayed_cap's loop from 0..6u8 to 0..5u8
and left assert_eq!(accepted, 6) untouched — five adoptions asserting six, which no behaviour can
satisfy. The constant was unsatisfiable by arithmetic exactly as claimed; 00e67c4 corrected it.

The load-bearing question is whether 5 still discriminates, and it does. Peer [70; 32] is admitted
Via::Direct, then re-offered as a circuit; the counter filters via == Via::Relay && !is_outbound.
Had the cap failed to refuse, that peer would have converted to Via::Relay and the count would read
6.
So 5 vs 6 is precisely the refused-vs-admitted distinction, and the assertion still fails
against the defect it was written for. No test was relaxed; pool_3128 was not touched.

GATING

F-R4-1 — the crate now VIOLATES its own normative SPEC, on the bound this round changed

LOW-MEDIUMdocs/resources/SPEC.md:2204 vs src/service/peer_pool.rs:434-448 and :1173

00e67c4, the commit that introduced the floor, touched peer_pool.rs plus one test line and
no SPEC clause — while changing the value of a normative bound at every small pool. SPEC.md still
carries the pre-floor closed form:

Bounded. At most max_relayed_inbound = max_inbound_total - max(max_inbound_total/4, 1) accepted
circuits (5 at max_connections = 8, where the inbound budget is 6) ...

That is reserving_a_quarter(max_inbound_total) without the .max(1).min(budget) that
a_reserved_share_of applies. At max_connections = 2, where max_inbound_total(2) == 1:

value at max_connections = 2
SPEC formula: 1 - max(1/4, 1) = 1 - max(0, 1) 0
a_reserved_share_of(1) = reserving_a_quarter(1).max(1).min(1) = 0.max(1).min(1) 1

The proof needs no probe — it is two committed artifacts contradicting each other. This PR's own new
unit test asserts the value the SPEC forbids:

assert_eq!(
    max_relayed_inbound(2),
    1,
    "a two-slot pool still serves a circuit"
);

SPEC says "At most 0". The code admits 1 and a shipped test pins it at 1. That is a conformance
failure, not a documentation gap.

Why it gates. CLAUDE.md 4.2 makes SPEC.md the authoritative contract an independent
reimplementation is built against, and the implementation this clause prescribes reproduces exactly
the denial regression this round exists to fix
— a cap of 0 that closes the tier and refuses every
circuit with accepted relayed circuit cap reached (0). 4.2 also makes a behaviour change that leaves
SPEC describing the old behaviour INCOMPLETE, and 00e67c4 is precisely that. It is the same class the
round-3 verdict gated as F-NEW-5 — a normative clause that is false about the code in the commit that
ships it — and this PR has now produced three of them, which is why I am holding the line rather than
filing it.

Fix: one clause. Restate the relayed bound with the floor, and give clause 2's prose ("each a
reserved quarter of the INBOUND budget") and max_direct_inbound the same treatment — all three are
currently silent about it. This needs only a scoped doc re-gate, not another full round.

NOT GATING — fold in or ticket, do not hold the merge

F-R4-2 — a fourth /16 surface survived the F-NEW-2 sweep. src/service/state.rs:578 still reads
"the per-/16 bound [max_direct_inbound_per_group]", false for IPv6 now the key is a /48. F-NEW-2's
fix corrected three surfaces — the error string (gossip_handle.rs:2117), the peer_pool.rs:404 doc,
and the test helper — and missed this one. Crate-private doc comment, not normative, not user-visible;
fold into F-R4-1's commit. I swept every other /16 in src/: the rest correctly describe the OUTBOUND
subnet_group (INT-006) or the addrman tried-buckets, which genuinely are /16.

F-R4-3 — special-purpose IPv6 prefixes are not canonicalized (defense-in-depth, follow-up ticket).
canonical_ip folds only IPv4-mapped (::ffff:a.b.c.d), deliberately and correctly. It does not
fold the prefixes where a /48 boundary does not correspond to a site:

  • 6to4 2002::/16 embeds the IPv4 in bytes 2-5, so 2002:<v4>::/48 yields one group per IPv4
    address
    . An attacker holding a single IPv4 /24 — one group under the v4 keying — would obtain 256
    distinct v6 groups, and group_cap = 2 means three suffice to fill the tier.
  • Teredo 2001:0::/32 and NAT64 64:ff9b::/96 fail the other way, collapsing unrelated sources
    into one group — the denial direction.

Not gated: all three are deprecated (RFC 7526 withdrew the 6to4 anycast relay) or do not arise on an
inbound server path, and I could not construct a scenario an attacker can drive today. Worth a ticket
for if the group bound is ever tightened.

What I checked and found CLEAR

  • Secrets / credentials — nothing key-, token- or credential-shaped anywhere in the delta. The one
    new error string is SafeText::from_untrusted over two numeric caps; no attacker-supplied string
    reaches a log or a message.
  • Panics on adversarial input — the only new indexing in src/ is o[0]/o[1] on
    Ipv4Addr::octets() ([u8; 4]) and &o[..6] on Ipv6Addr::octets() ([u8; 16]), both statically in
    bounds. No new unwrap, expect, panic!, unsafe, or truncating cast in src/.
  • Reachability — who can invoke this — the delta adds no remote entry point. It changes the numeric
    value of caps on the two existing dig-nat adoption paths and re-keys one group predicate. The single
    net loosening (0 -> 1 inbound at mc <= 3) restores the pre-PR relayed value and is bounded by the
    aggregate.
  • Cost asymmetry — the group scan is O(n) over the peer map per adoption, n bounded by
    max_connections, unchanged in shape from round 3; the new key is two shifts over six bytes.
  • Public API + SemVerinbound_source_group and InboundSourceGroup are additive; 0.31.0 to
    0.32.0 minor is the correct judgement.
  • Dependenciesdig-ip "0.1" to "0.1.2", and 0.1.2 is the latest on the index (checked with
    the required User-Agent). The resolved version in the lock did not move. No dep added, no pin
    loosened. The lock also moves three packages' windows-sys from 0.61.2 to 0.52.0/0.60.2 — a
    resolver re-unification of a Windows FFI bindings crate with no logic; noted, not a finding.
  • Test quality — the new fixtures are non-vacuous and built to distinguish the fix from the defect:
    the relayed fixture loops past its expected bound and asserts peer_count grew, so an Ok without an
    insert would fail; the /48 fixture asserts refusal at site A and admission at site B in one test;
    the IPv4 test pins the unchanged /16 and the mapped-v6 canonicalization.
  • No stray files.pr-progress.md in the worktree is untracked and not part of the PR.

Read-only audit. No file in any checkout was modified, no probe was run against source, no git stash,
no mutating git command. I did not merge and did not undraft. PR remains DRAFT.

Once F-R4-1 lands with F-R4-2 folded in, a scoped doc-leg re-check is sufficient — the code findings are
all resolved and I would not re-open them.

The floor commit 00e67c4 changed a normative bound at every small pool and
updated no SPEC clause, leaving SPEC.md carrying the pre-floor closed form
`max_inbound_total - max(max_inbound_total/4, 1)`. That form evaluates to 0 at
max_connections = 2, where this PR's own unit test pins max_relayed_inbound(2)
at 1 -- a conformance failure between two committed artifacts, and one whose
prescribed implementation reproduces exactly the denial regression #3124 exists
to remove (a cap of 0 closes the tier rather than reserving room for its
sibling).

Clause 2 of the accepted-peer caps now states the bound both inbound tiers
actually implement, including the floor and the clamp:

    min(max(b - max(b / 4, 1), 1), b),  b = max_inbound_total(max_connections)

with the floor stated as normative (an implementation MUST NOT emit a tier cap
of 0 while b >= 1), the reason it cannot over-admit (clause 1's aggregate is
charged on the same path), and worked values at max_connections 2, 3, 8 and 16.
The relayed-registration "Bounded." clause defers to it rather than repeating a
closed form that can drift.

Also corrects a fourth stale `/16` doc reference missed by the F-NEW-2 sweep:
is_accepted_direct named the per-group bound as `/16`, false for IPv6 since the
group key became a `/48`.

Documentation only -- no logic, cap, test or dependency change.

Refs: dig_ecosystem#3124

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

Copy link
Copy Markdown
Contributor Author

F-R4-1 FIXED + F-R4-2 folded in — 04d85f9, docs only

Scoped doc-conformance commit on top of c6e9a29. No logic, cap, test or dependency change — the
diff is docs/resources/SPEC.md (+37/−8) and one doc comment in src/service/state.rs (+3/−2). PR
remains DRAFT; not merged, not undrafted.

F-R4-1 — the SPEC now states the bound the code implements

Clause 2 of the accepted-peer caps is the single normative home for the per-tier bound; the
relayed-registration Bounded. clause now defers to it instead of repeating a closed form that can
drift out of step (which is how this finding arose).

The new clause 2, verbatim:

  1. At most max_direct_inbound(max_connections) accepted DIRECT peers, and at most
    max_relayed_inbound(max_connections) accepted RELAYED circuits. Both caps are the SAME function of
    the INBOUND budget of clause 1, never of the pool: a reserved quarter of that budget, FLOORED at one
    slot whenever the budget can hold a peer at all, and clamped to the budget. Writing
    b = max_inbound_total(max_connections), with integer division and saturating subtraction:

    max_direct_inbound(mc) = max_relayed_inbound(mc) = min(max(b − max(b / 4, 1), 1), b)
    

    The floor is normative, not a rounding detail. Applying the quarter twice — once to reach b,
    once to reach a tier's share of it — yields ZERO for every max_connections <= 3, and a cap of zero
    does not RESERVE a tier's room, it CLOSES the tier: every circuit is refused with
    accepted relayed circuit cap reached (0), which is the same starvation the symmetric derivation
    exists to prevent, merely inflicted on the other side. An implementation MUST NOT emit a tier cap of
    zero while b >= 1.

    The floor cannot over-admit, because clause 1's aggregate is charged on the same path: two tiers
    each floored at 1 against a budget of 1 still admit exactly one accepted peer, first-come. The clamp
    keeps a zero budget at zero, so at max_connections <= 1 — where b == 0 — both tiers are 0 and no
    accepted peer is admitted at all. b <= max_connections − 1 for every max_connections >= 1, so a
    slot for this node's own dialing survives at every configuration.

    Worked values: at max_connections = 2 the budget is 1 and each tier is 1; at 3 the budget is 2
    and each tier is 1; at 8 the budget is 6 and each tier is 5; at 16 the budget is 12 and each tier
    is 9.

    The two tiers MUST be derived symmetrically from the shared budget […unchanged from here…]

And the Bounded. clause (SPEC.md, relayed-registration section):

  • Bounded. At most max_relayed_inbound(max_connections) accepted circuits — a reserved quarter of
    the INBOUND budget, floored at one slot while that budget can hold a peer at all and clamped to it,
    exactly as clause 2 of the accepted-peer caps defines it for BOTH inbound tiers (5 at
    max_connections = 8, where the inbound budget is 6; 1 at max_connections = 2, where the budget
    is 1 — the floor, without which this cap would be 0 and would close the tier), enforced under the same
    peers-lock hold as the insert […unchanged…]

One further sentence in that clause was corrected in the same pass rather than left half-true: it said
the reserve is "the same derivation as max_relayed_outbound and the direct-dial floor". That is now
qualified — "and then floored, which those outbound caps do not need because they take their quarter of
max_connections once rather than twice"
— because max_relayed_outbound is
reserving_a_quarter(target_outbound_count) with no floor (peer_pool.rs:310-312). Leaving it would
have been a fourth clause that is false about the code shipping beside it.

Verification — I derived the table from source, then evaluated the written clause against it

Source read: max_inbound_total(n) = reserving_a_quarter(n) = n.saturating_sub((n / 4).max(1))
(peer_pool.rs:400), and both tiers are
a_reserved_share_of(max_inbound_total(mc)) = reserving_a_quarter(b).max(1).min(b)
(peer_pool.rs:344, :367, :434-448).

mc b = max_inbound_total(mc) code: each tier new clause evaluated: min(max(b − max(b/4,1), 1), b) agrees non-inbound slots left
0 0 − max(0,1) sat → 0 0.max(1).min(0) = 0 min(max(0−1 sat, 1), 0) = min(1,0) = 0 0
1 1 − max(0,1) = 0 0 min(max(0,1), 0) = 0 1
2 2 − max(0,1) = 1 0.max(1).min(1) = 1 min(max(1−1,1), 1) = min(1,1) = 1 1
3 3 − max(0,1) = 2 1.max(1).min(2) = 1 min(max(2−1,1), 2) = min(1,2) = 1 1
4 4 − max(1,1) = 3 2 min(max(3−1,1), 3) = 2 1
8 8 − max(2,1) = 6 5 min(max(6−1,1), 6) = 5 2
16 16 − max(4,1) = 12 9 min(max(12−3,1), 12) = 9 4

The four values called out in the brief — mc = 1, 2, 3, 8 — are the emphasised rows and the clause
agrees at every one. It agrees with the shipped unit assertions specifically at
max_relayed_inbound(2) == 1, max_relayed_inbound(3) == 1, max_relayed_inbound(8) == 5,
max_direct_inbound(8) == 5, max_inbound_total(8) == 6 (peer_pool.rs:1173-1180) — the assertions the
old clause contradicted at mc = 2.

Two subsidiary claims in the new text, checked rather than asserted:

  • "b <= max_connections − 1 for every max_connections >= 1"b = n − max(n/4, 1) subtracts at
    least 1 for all n >= 1, so a non-inbound slot survives at every configuration (rightmost column,
    never 0 for mc >= 1).
  • "the floor cannot over-admit" — it can only fire where b >= 1 (the .min(b)), and the aggregate is
    charged after the tier cap on both adoption paths (gossip_handle.rs:2084, :2290), so at mc = 2
    two tiers reading 1 against a budget of 1 admit one peer, refused thereafter by the aggregate.

The retained worked example 5 at max_connections = 8 is re-verified as still true (row 8 above); the
small-pool example added is mc = 2 → 1, which is exactly the value the old clause forbade.

F-R4-2 — the fourth stale /16

src/service/state.rs:578, is_accepted_direct's doc comment, named the per-group bound as per-/16.
Now: "The source group is an IPv4 /16 or an IPv6 /48, as inbound_source_group derives it."
matching peer_pool.rs:404-405 and util/ip_address.rs:88-104. Crate-private doc comment; no behaviour.

What I deliberately did not touch

peer_pool.rs logic, the cap functions, any test, and the dig-ip bump — all CLEARED in round 4, and
changing them would invalidate a verdict that took four rounds to earn. I did not conclude the code was
wrong: the code is the correct behaviour and the SPEC was the stale artifact, so this is a
documentation-conformance commit in the direction the verdict specified.

cargo fmt --check clean. Ready for the scoped doc re-gate.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security scoped doc re-gate (round 5) — IN PROGRESS, not the verdict

Head audited: 04d85f965ff8dc058864251e5155b6fd17d2ae4c — re-resolved from
gh pr view 83 --json headRefOid. Range c6e9a29d..04d85f96, one commit,
docs(spec): state the floored inbound tier bound the code implements. I checked the range start
against the round-4 verdict's own stated head (c6e9a29df4ae67bfc1565dd9db0d86c096445d3f) rather than
the dispatch brief; they agree.

Posting these as they resolve so they survive a kill. Scope is the doc leg only — round 4's clearance
of the peer_pool logic, caps, tests and dep bump stands and I have not re-opened it.

Confirmed so far

F-R4-1 closed form — the replacement clause is CORRECT at every value, not just the worked example.
I transcribed the new SPEC clause literally and evaluated it against the code independently, over
max_connections 0..5000. They agree everywhere.

max_connections 0 1 2 3 4 8 16
b = max_inbound_total 0 0 1 2 3 6 12
code a_reserved_share_of(b) 0 0 1 1 2 5 9
new SPEC min(max(b - max(b/4,1), 1), b) 0 0 1 1 2 5 9
agree yes yes yes yes yes yes yes

The clause reproduces .max(1).min(b) exactly, so the 0-at-max_connections = 2 defect is gone and
the SPEC now matches the unit test that pins max_relayed_inbound(2) == 1. I also verified the two
supporting invariants mechanically: b <= max_connections - 1 for every max_connections >= 1, and
the tier is >= 1 whenever b >= 1. No pre-floor closed form survives anywhere in SPEC.md.

F-R4-2 — FIXED, and the sweep is complete. state.rs:578 now reads "the per-SOURCE-GROUP bound"
with an explicit "IPv4 /16 or an IPv6 /48" sentence. I swept every /16 in src/ at this head:
the remainder are all the OUTBOUND subnet_group (INT-006), the addrman tried-bucket constant, or a
correct /16-or-/48 pairing. There is no fifth stale surface.

The state.rs change is comment-only. The only lines touched sit inside the /// block above
is_accepted_direct; the body stays is_accepted_inbound(slot) && !is_relayed(slot). Round 4's
clearance of that code is not disturbed.

One NEW finding, severity being settled

The new clause carries a rationale sentence that is false at max_connections = 3:

Applying the quarter twice - once to reach b, once to reach a tier's share of it - yields ZERO for
every max_connections <= 3

The unfloored value is reserving_a_quarter(max_inbound_total(mc)), which is 0 only for
max_connections in {0, 1, 2}. At max_connections = 3 the budget is 2 and the unfloored share is
1, not 0. The floor changes the value at exactly ONE input, max_connections = 2.

This is new to SPEC.md in this commit (absent at c6e9a29d); it is inherited verbatim from the
a_reserved_share_of doc comment at peer_pool.rs:434, which round 4 did not flag.

Still working: whether this is gating, plus the normative-voice check.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security scoped doc re-gate (round 5) — PASS

Head audited: 04d85f965ff8dc058864251e5155b6fd17d2ae4c — re-resolved from
gh pr view 83 --json headRefOid at the start of this audit and again at the end; unchanged
throughout. PR still DRAFT.

Range: c6e9a29d..04d85f96 — one commit,
docs(spec): state the floored inbound tier bound the code implements. I verified the range start
against the round-4 verdict's own stated head (c6e9a29df4ae67bfc1565dd9db0d86c096445d3f) rather
than the dispatch brief; they agree. The commit touches exactly two files, docs/resources/SPEC.md
and src/service/state.rs.

Scope was the doc leg only. Round 4's clearance of the peer_pool logic, caps, tests and the dig-ip
bump stands; I did not re-open it and found no reason to.

F-R4-1 — FIXED. The clause is true at every value, not only at the worked example.

I transcribed the new closed form literally out of the SPEC and evaluated it against the code
independently, deriving reserving_a_quarter / a_reserved_share_of / max_inbound_total from
src/service/peer_pool.rs:399-450 rather than from anyone's table. They agree at every
max_connections from 0 to 5000.

max_connections 0 1 2 3 4 8 16
b = max_inbound_total 0 0 1 2 3 6 12
code a_reserved_share_of(b) 0 0 1 1 2 5 9
SPEC min(max(b - max(b/4,1), 1), b) 0 0 1 1 2 5 9
agree yes yes yes yes yes yes yes

Unit tests in this PR pin max_relayed_inbound(2) == 1, max_relayed_inbound(3) == 1,
max_relayed_inbound(8) == 5, max_direct_inbound(8) == 5 and max_inbound_total(8) == 6. Every one
of those agrees with both columns above.

The clause reproduces .max(1).min(b) exactly, so the 0-at-max_connections = 2 value that
SPEC.md:2204 used to prescribe — the one that reproduced the #3124 denial regression and contradicted
the PR's own test — is gone. No pre-floor closed form survives anywhere in SPEC.md; I grepped for it.

I also checked the clause's two supporting invariants mechanically rather than accepting them:

  • b <= max_connections - 1 for every max_connections >= 1true over 0..5000, so the stated
    "a slot for this node's own dialing survives at every configuration" holds.
  • the tier is >= 1 whenever b >= 1, which is the clause's own MUST NOT-emit-zero requirement —
    true over 0..5000, and it is what the code does.
  • the worked values (2 gives 1, 3 gives 1, 8 gives 5, 16 gives 9) are each correct.

Normative voice (section 4.2): satisfied. The clause states what IS — both caps are the same
function of the clause-1 budget, plus the closed form — and two MUSTs: an implementation MUST NOT emit
a tier cap of zero while the budget is non-empty, and the tiers MUST be derived symmetrically. All
positive and testable. The rationale paragraphs around them match the voice this document already uses.

F-R4-2 — FIXED, and the sweep is complete

state.rs:578 now reads "the per-SOURCE-GROUP bound" and adds an explicit sentence that the group is
an IPv4 /16 or an IPv6 /48, pointing at inbound_source_group. I swept every /16 in src/
at this head: the remainder are the OUTBOUND subnet_group (INT-006/INT-007), the addrman
tried-bucket constant, or a correct /16-or-/48 pairing. There is no fifth stale surface.

The state.rs change is COMMENT-ONLY — verified, not assumed

Every changed line in src/service/state.rs is a doc-comment line. Mechanically: the diff contains
zero added or removed lines that are not /// lines, and is_accepted_direct still reads
is_accepted_inbound(slot) && !is_relayed(slot). No logic was smuggled into the docs commit, so
round 4's clearance of that code is undisturbed.

NOT GATING — fold into the next touch of this branch, do not hold the merge

N-1 — one rationale sentence is false at max_connections = 3. SPEC.md:2122-2123 says that
applying the quarter twice, once to reach b and once to reach a tier's share of it, yields ZERO for
every max_connections <= 3.

The unfloored value is reserving_a_quarter(max_inbound_total(mc)), which is 0 only for
max_connections in {0, 1, 2}. At max_connections = 3 the budget is 2 and the unfloored share is
1, not 0. The floor in fact changes the value at exactly ONE input, max_connections = 2; at
max_connections <= 1 the clamp returns it to 0 anyway. The same sentence exists verbatim as a code
doc comment at peer_pool.rs:434, which is where the SPEC text was lifted from and which round 4 did
not flag. It is new to SPEC.md in this commit.

Why I am not gating on it, having looked for a reason to. It is a claim about the REJECTED
pre-floor alternative, not about the shipped bound, and it errs by overstating the defect the floor
fixes rather than by overstating a guarantee — the opposite direction from F-NEW-2, F-NEW-5 and
F-R4-1, each of which claimed a protection the code did not provide. Nothing an implementer or an
operator relies on rests on it:

  • the closed form, the MUST, and the worked values are all correct and all pin max_connections = 3 to
    1 explicitly, so a conforming reimplementation cannot be led astray by it;
  • the worst plausible misreading, special-casing mc <= 3 to 1, is directly forbidden two paragraphs
    later — the clause states that at max_connections <= 1, where b == 0, both tiers are 0 and no
    accepted peer is admitted at all — and would in any case fail closed at the clause-1 aggregate,
    which is 0 at mc <= 1 and is charged on the same path.

The honest cost is to incident analysis, not to the bound: someone asking whether a node at
max_connections = 3 was affected by the pre-floor defect gets the wrong answer. The fix is a
one-value edit in both places. Worth riding along on any next commit to this branch; not worth a
sixth round on its own.

N-2 — the fenced formula uses U+2212 MINUS SIGN, not ASCII hyphen. SPEC.md:2119, inside the code
fence presented as the authoritative form for a reimplementer to transcribe. Copy-pasting it into Rust
or Python is a syntax error. The surrounding prose uses the same character, so this is consistent with
the document, but a code fence is where it bites. Cosmetic.

N-3 — one unwrapped long line at the "two tiers MUST be derived symmetrically" paragraph
(SPEC.md:2138), a reflow artifact of the edit; the rest of the section wraps at about 110. Cosmetic.

What I checked and found clear

  • The one question in scope — the new prose is TRUE of the code round 4 cleared, at every value I
    evaluated, including the small pools where the previous clause was false.
  • No behaviour change — the delta is two files, one of them provably comment-only. No new entry
    point, no cap value moved, no guard altered, no test touched. Nothing here changes who can invoke
    what, so the reachability picture from round 4 is unchanged.
  • No secrets — nothing key-, token- or credential-shaped in the delta.
  • No dependency movement — no manifest or lock change in this range.
  • The outbound aside is accurate — the clause says the outbound caps do not need the floor. I
    checked rather than assumed: max_relayed_outbound(1) == 0 is deliberate and is pinned by
    max_relayed_outbound_reserves_a_quarter_for_the_direct_tier with the reason "a single-slot target
    reserves it for diversity", and taking the quarter once does confine the zero region to inputs <= 1
    where taking it twice reached max_connections <= 2. The stated reason holds.

For the orchestrator

Analyze (rust) was still pending when I read the checks; the other nine pass. My PASS is a
security verdict, not a merge precondition — run check-merge-preconditions.sh unpiped and merge only
on exit 0.


Read-only audit, conducted from git objects in a private worktree. No file in any checkout was
modified, no probe mutated source, no git stash, no mutating git command. I did not merge and did not
undraft. PR remains DRAFT.

Both the SPEC clause and the peer_pool doc comment said the twice-applied
quarter "yields ZERO for every max_connections <= 3". It is zero only for
{0, 1, 2}: max_inbound_total(3) is 2, and the unfloored share of 2 is 1, so
the floor changes the value at exactly one input, max_connections = 2.

The overstatement is in the safe direction -- it describes the rejected
alternative rather than the shipped bound, and the closed form, the MUST and
the worked values all already pin max_connections = 3 to 1 -- but this PR has
carried three normative clauses that were false in the commit that wrote them,
and dig-gossip publishes on merge, so a false clause would ship permanently.

Found by the scoped doc re-gate and deliberately left non-gating there.

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 29, 2026 15:27
@MichaelTaylor3d
MichaelTaylor3d merged commit 123a257 into main Aug 29, 2026
10 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/3124-direct-inbound branch August 29, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant