Skip to content

feat(digsex): wire the dig-sex economic layer into the node - #456

Merged
MichaelTaylor3d merged 22 commits into
mainfrom
loop/batch-digsex
Sep 2, 2026
Merged

feat(digsex): wire the dig-sex economic layer into the node#456
MichaelTaylor3d merged 22 commits into
mainfrom
loop/batch-digsex

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — DRAFT. The gate round has not run.

Wires two of the dig-sex economic-layer policies into the node. Both were implemented, tested and consuming nothing from dig-node.

Scope: three of the five batch tickets were already satisfied or are deferred

The batch named five. Measured against origin/main @ 3e480dd before building:

ticket outcome
#287 adopt dig-sex 0.4 + residency already shipped — CLOSED with evidence. The body is stale: it says Cargo.toml:348 pins dig-sex = "0.2"; the file declares dig-sex = "0.5" at crates/dig-node-core/Cargo.toml:509. run_round already takes held, populates resident from it, and is fed a real cache-derived set every round by NodeHeldCapsules::held_content_ids (tier0_live.rs:423). The acceptance bar is asserted as an outcome by an_already_held_candidate_is_not_refetched_while_an_unheld_one_still_is.
#270 acquisition already shipped. dig_sex::acquisition::decide is called at seams/capsule/capsule_store.rs:587, with a control-plus-three-refusals test at lib.rs:14217. Left open for the gate to confirm; not rebuilt.
#271 reward ledger not started, deliberately. Blocker measured and posted on the ticket: the required sealed-at-rest persistence (NC-2/NC-3) has no existing node-state helper to reuse, and a plaintext JSON ledger would have compiled, passed, and violated the ticket's own constraint on a money-adjacent record.

dig-sex latest published is 0.5.0, not the 0.4 the tickets name (index.crates.io/di/g-/dig-sex, with the required User-Agent).

Blast radius checked

.gitnexus is stale — the registered dig-node index is ~301 commits behind and would return a false-safe impactedCount: 0. This radius was therefore established by grep + direct read, and that is stated rather than implied.

  • NodeResponder::handle_json_rpc / handle_availability (peer.rs) — the two entry points gaining an admission gate. 38 call sites of the four responder methods across the crate; exactly one passes an empty conn_key.
  • NodeContent (download.rs) — gains conduct + conduct_epoch fields and a filter between routable and decide. The single ask_routing.record call site is the only writer; conduct is fed from the same place.
  • RoutedPeer (ask_routing.rs) — gains Hash so it can key the conduct map. Additive; no existing behaviour reads it.
  • Node (lib.rs) — gains peer_admission; all constructors updated.

No public API was removed or renamed, and no wire format changed.

VACUITY — which wired clauses actually fire

This is stated explicitly because a conformance list that cannot separate satisfied from vacuously satisfied reports a capability the system does not have.

#269 admission — genuinely fires. Every inbound JSON-RPC and availability request on the peer surface passes the meter, and a refusal is reachable today.

#268 conduct — wired, observed, and changing NO dialling decision today. An earlier draft of this section said "half fires", which implies a live effect on the dial set. It does not have one, and the gate measured why:

  • HonestAnswer and NonPerformance are produced on every forwarded ask, so the conduct record is genuinely maintained.
  • ProvenLie and SelfContradiction are produced nowhere.
  • ConductState::dial_share's only non-test consumer is dialable()'s > 0.0 filter, and dig_sex::dial_share floors a non-performing peer at MIN_NON_PERFORMANCE_DIAL_SHARE = 0.1 — deliberately, so induced distress cannot become an eviction primitive.

So neither producible class can reach 0.0, and today dialable(pool) ≡ routable(pool), element for element. The exclusion path is correct and tested; it is also unreachable until a producer of a verifiable fault exists. Stating that precisely is the whole reason the dormancy was disclosed: a conformance claim that cannot separate satisfied from vacuously satisfied reports a capability the system does not have. The code stays — the record it maintains is what a future dig-download attribution surface will read.

The reason is a real architectural boundary, not an oversight: per-chunk verification attribution lives inside dig-download's engine against chunk_hashes (module_transport.rs:1587 says so in as many words), and the node's own DownloadError surface offers only Transport/Sink/NotDownloadable. A whole-capsule mismatch (module_reshare.rs:1858) is assembled from several holders and names no single peer. Synthesising a ProvenLie from a transport error would brand an honest peer on unverifiable evidence — precisely the conflation SPEC 8.2A exists to prevent — so it was not done. Surfacing the per-peer verdict needs a dig-download change, release-first.

Three further observations from the gate, folded in rather than fixed

  • Refused → NonPerformance means this PR's own load-shedding penalises honest nodes. A peer refused at admission (Wire dig-sex admission (SPEC 8.5) into the inbound path, metered by authenticated identity #269) is recorded as non-performing by the ask loop (Wire dig-sex conduct (SPEC 8.2A) so peer behaviour actually feeds the dial budget #268), so a node shedding load lowers the conduct of nodes that did nothing wrong. Bounded by the same 0.1 floor above — and, per the paragraph above, currently changing no decision at all.
  • MeterFull is unreachable under the shipped defaults, but NOT for the reason first stated here (gate S5). It fires only when the tracked-peer table is full; the table is max_tracked_peers = 1024 entries and AdmissionMeter::release REMOVES a peer entry at zero, so the table is proportional to ACTIVE peers, not to every peer ever seen. The earlier claim that it is never pruned under load was false. Unreachability comes from the pool ceilings being reached first, not from an unpruned table.
  • GlobalCeiling at 64 is Sybil-reachable but transient, and writes no durable record. Enough concurrent identities can hold the node-wide ceiling and shed other peers' work, but the allowance returns on Drop and nothing about the episode is persisted — so it is a momentary denial, not a lasting one.

Rival check

The batch brief flagged a rival: dig-node coercing an unreadable hop budget to the most permissive value while dig-sex refuses. That disagreement is already reconciled and is not a live finding. download.rs:2746 maps an unreadable forwarding budget to remaining → None, which dig_sex::discovery::decide_forward (discovery.rs:170) turns into ForwardRefusal::UnreadableHopBudget — both sides now refuse (dig-node#281). The one asymmetry that remains is deliberate and documented: the redirect leg keeps the tolerant reading because it spends only this node's own DHT lookup, while the forwarded ask spends other nodes' bandwidth. Different cost, different tolerance — stated at download.rs:2740-2751.

Tests

13 new, all passing with real counts (running 8 / running 5 — not a filter that matched nothing).

Conduct (8): a_proven_lie_costs_dial_share_while_an_honest_peer_keeps_its_own, a_proven_lie_does_not_decay_with_elapsed_time, non_performance_decays_on_elapsed_time_without_the_peer_being_talked_to, sustained_non_performance_never_silences_a_peer_completely, an_unobserved_peer_starts_neutral_rather_than_penalised, retain_drops_peers_that_left_the_pool, a_proven_liar_leaves_the_dial_set_while_a_merely_slow_peer_stays_in_it, a_self_contradiction_is_durable_like_a_lie_not_transient_like_a_timeout.

Admission (5): listed on #269.

Both proven load-bearing by injecting the real defect, committed first:

  • Meter key → a constant (the shared-bucket mistake): only exhausting_one_peers_share_does_not_refuse_a_different_peer fails (4 passed, 1 failed). The single-peer half alone would have passed it, because a constant-keyed meter also refuses at the limit — it just refuses everybody.
  • Dial threshold > 0.0> 0.5 (above the non-performance floor): only a_proven_liar_leaves_the_dial_set_while_a_merely_slow_peer_stays_in_it fails (7 passed, 1 failed). The merely-slow control is the assertion that catches it, and it is the one that stops induced distress becoming an eviction primitive.

§2.4b dependencies

Every dig-* and chia-* declaration in dig-node-core was checked against the index and is already at the latest published version — dig-sex 0.5.0, dig-download 0.22.0, dig-dht 0.15.0, dig-nat 0.21.0, dig-peer 0.13.0, dig-peer-selector 0.11.0, dig-tls 0.4.0, dig-identity 0.7.1, dig-constants 0.13.0, dig-rpc-protocol 0.10.2, dig-store-cache 0.1.1, dig-message 0.7.0, dig-keystore 0.13.0, dig-ip 0.1.2, dig-pex 0.1.1; chia-* on the single 0.36 line. No bump was owed, so none was made.

SemVer

0.189.0 → 0.190.0 (dig-node-core 0.64.0 → 0.65.0). MINOR — two new capabilities, no removed or renamed API and no wire change. Version re-read from disk after the last fetch, not from the commit log.

One pub-API BEHAVIOUR change, stated explicitly because the first draft of this body omitted it. serve_peer_session (pub, peer.rs) threads no caller identity. Before this PR a caller-less session served the JSON-RPC, availability and range paths; after it, JSON-RPC and availability are refused -32000 (unauthenticated), because those two now meter against the mTLS-verified peer_id and admitting an absent one would make "present no identity" the cheapest way out of the meter. Range and module-range are unaffected. The function's own doc previously asserted the old behaviour and has been corrected — an earlier revision of this body cited that sentence as evidence, which this commit falsified.

Minor still covers it, and the reason is the fallback direction. The signature is unchanged and nothing is removed, so it compiles for every consumer; the behaviour change is a REFUSAL of unauthenticated work on a surface that is mTLS-only in production, and it fails closed. There is no in-crate caller and no known external one — both real listeners use serve_peer_session_from with the verified caller. A major bump would be right if this removed a capability a working consumer depends on; what it removes is an unmetered path that only an unauthenticated caller could take.

Gate evidence (head 825f329)

  • cargo build -p dig-node-coreRC=0
  • cargo clippy -p dig-node-core --all-targets -- -D warningsRC=0, zero errors
  • cargo fmt --checkRC=0
  • cargo test -p dig-node-core --lib1037 passed; 0 failed; 0 ignored; 0 filtered out, 632s, CARGO_RC=0. Run unpiped to a file and the count read from the file: a filter matching nothing exits 0 and prints running 0 tests, and a piped $? is the last command's. 0 filtered out is what makes this the whole suite.

One regression found and fixed, and why the fix is the right one

Admission runs ahead of the peer allowlist, so node_responder_returns_method_not_found_for_management_methods — which called handle_json_rpc with an empty conn_key — started getting -32000 instead of -32601.

Before choosing, I checked whether any production path reaches the responder caller-less, because refusing would otherwise have broken live peer traffic. It does not: both real listeners supply a verified caller (peer.rs:3306 derives it from the client leaf via caller_from_tls; peer.rs:2373 builds a relayed caller). The public serve_peer_session does pass None, but has no in-crate caller — only doc references — and its own doc concedes the caller is supplied by the listener.

So the refusal is correct, and the test was updated to pass a 64-hex key: the property it guards (an authenticated peer is still merely "some peer_id", never an authorized admin — audit #179) is unchanged, and it now exercises the allowlist rather than stopping at admission.

Gate round 1 — CHANGES-REQUIRED, both findings fixed

G1 — the admission clamp refused batches this node advertises it serves. The clamp used dig-sex's default max_request_units = 256, while MAX_AVAILABILITY_ITEMS = 512, so a 257–512 item dig.getAvailability batch was answered -32000 "request too large" by a node whose own batch stood ready to answer all 512.

The clamp is not deleted — it is the admission metering #269 exists to provide. The two numbers are now ONE number: admission::node_limits() derives max_request_units from MAX_AVAILABILITY_ITEMS rather than restating it, so they cannot drift. 512 is the right number because it is the limit the node already advertises and already implements; the clamp's job is to refuse work beyond the contract, not below it.

Consequence, and it is deliberate: a batch PAST 512 is now refused whole at the boundary rather than answered as a truncated prefix. availability_batch's truncation remains as the in-process last line of defence, and both doc claims that said otherwise (peer.rs responder trait, lib.rs:3928) now say what the code does.

The old 513-item test could not see any of thislib.rs:10273 calls availability_batch BELOW the responder that decides, so it passes identically whether the clamp admits, refuses, or is unwired. It is kept, relabelled to say what it actually pins, and pointed at the new pair.

New, at the RESPONDER level (peer.rs), the_responder_serves_a_batch_at_the_advertised_limit_and_refuses_one_past_it: a 512-item batch must be ANSWERED in full, and a 513-item batch must be REFUSED with reason request too large. Both sides pinned — a bound tested only from below can only confirm itself, and the at-bound half is the one that fails under the defect. Verified red on the pre-fix tree for the right reason (left: None, right: Some(512), 1 failed), not a filter that matched nothing.

G2 — serve_peer_session's own doc. Corrected to what the code does, and the pub-API behaviour change is now stated in the SemVer section above.

A gap in my own evidence, stated rather than left implicit

The conduct→ask-loop wiring is proven by compilation and by conduct.rs's unit tests, but there is no test driving a forwarded ask end-to-end and asserting a peer's conduct changed the peers it dialled. The admission half has that test (the_responder_refuses_an_unauthenticated_session_before_consulting_the_allowlist); the conduct half does not, because the forwarded-ask harness is substantially heavier. Given the exclusion path is dormant anyway (nothing produces a verifiable fault yet), I judged the end-to-end test worth more once dig-download surfaces attribution. Flagging it so the gate can disagree.


Gate response (loop-security CHANGES-REQUIRED at 6ed8d51)

G1 — HIGH, LIVE — fixed by changing the SHAPE, not the numbers. AdmissionMeter::admit checks the
node-wide ceiling before the per-peer share against one shared counter, so the identities needed to deny
every other peer was global_ceiling / per_peer_share = 8, each one staying inside its own share so
the per-peer limiter never fired. Raising the ceiling only raises that price. Instead, a peer's first
concurrent unit is now charged to a separate reserve pool whose per_peer_share is exactly 1 and
whose size is RESERVED_FIRST_SLOTS = MAX_INFLIGHT_PEER_CONNECTIONS (derived, not restated). Denying an
honest peer now costs one identity and one held connection per slot — linear, and bounded by the
connection cap the node already enforces rather than by a number 8x below it. This also answers the
gate's other two reasons: the guaranteed surface is no longer narrower than one connection's old
MAX_INFLIGHT_STREAMS_PER_CONNECTION allowance, and 32 honest 65s forwarded asks now consume 32 of 512
reserved slots rather than half of 64.

The reserve grants no extra concurrency: a peer's total share is still per_peer_share, with the
first unit charged to the reserve and the remainder to the shared pool. Relayed work goes straight to the
shared pool — work done on another node's behalf is exactly what a loaded node should shed. The gate's
"credit where due" property survives: the guard is still dropped before write_framed.

G2 — HIGH — the shipped configuration is now pinned and the exploit is now a test. Six new tests:

  • eight_sybil_identities_cannot_deny_an_honest_peer_under_the_shipped_limits — the gate's own exploit,
    run against PeerAdmission::default() (the real shipped config, not hand-picked limits), with the
    honest ninth peer as the load-bearing control. This is the test that fails without the fix.
  • the_shipped_admission_configuration_is_pinned — all five dimensions of node_limits(), not one.
    The gate's global_ceiling: 1 mutation now fails here.
  • the_reserve_is_never_scarcer_than_the_connections_it_serves — asserted as the relation to
    MAX_INFLIGHT_PEER_CONNECTIONS, not as the literal 512, so a drifted reserve cannot pass.
  • a_spent_shared_pool_sheds_a_busy_peer_while_a_quiet_one_is_still_served — the placement property:
    with one pool both peers get the identical refusal, so this cannot pass on a mere ceiling increase.
  • denying_a_newcomer_costs_at_least_one_identity_per_reserved_slot — walks distinct identities until a
    newcomer is refused and asserts the count exceeds global_ceiling / per_peer_share.
  • the_reserve_does_not_widen_the_share_of_any_single_peer — the split must not become a share increase.

S4 — the relay budget is VACUOUS on this node, and now says so. Nothing in the crate constructs
WorkKind::Relayed; every production call site passes Own. node_limits() and the relayed test both
state that the budget is satisfied because the case never occurs, not because it is enforced. The ceiling
is kept so the first producer inherits a budget rather than an omission.

S5 — corrected above. AdmissionMeter::release removes an entry at zero, so the tracked-peer table
IS pruned; the original bullet was false.

S3 — the refusal log now names the peer (16-hex prefix), so an operator can tell one caller taking
the allowance from a genuinely loaded node. in_flight_total()'s doc no longer claims an operator status
surface that nothing renders.

Blast radius (grep + direct read — the registered dig-node gitnexus index is stale by construction and
returns a false-safe impactedCount: 0).
PeerAdmission is constructed in 9 places, all
::default() in lib.rs; admit has exactly 2 production call sites (peer.rs handle_json_rpc,
handle_availability); node_limits, AdmissionGuard and in_flight_total have no callers outside
this module and its tests. admission_refused gained a conn_key parameter at both call sites. No
public API of the crate changed shape for any external consumer.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. Gate on head 825f329.

Confirmed so far:

  1. dig-sex latest IS 0.5.0index.crates.io/di/g-/dig-sex (with the required User-Agent) yields 0.3.0 / 0.4.0 / 0.5.0. crates/dig-node-core/Cargo.toml declares dig-sex = "0.5". Adopt dig-sex 0.4 and give tier-0 selection the residency it needs -- run_round knows nothing about what is held #287's ticket body was stale, the PR's correction is right, and closing it on that basis was justified on this point.
  2. Commit subjects — five commits, max length 87 chars. Commitlint's 100-char subject rule is satisfied.
  3. Wire dig-sex admission (SPEC 8.5) into the inbound path, metered by authenticated identity #269 meter key is NOT caller-influenceable. conn_key reaches PeerAdmission::admit from exactly one place per request kind (peer.rs:1139/1151/1161/1172), each caller.as_ref().map(|c| c.peer_id.clone()). caller is built by caller_from_tls (peer.rs:3324-3332) from conn.peer_certificates()dig_nat::peer_id_from_leaf_cert_der, i.e. SHA-256 of the verified client leaf SPKI — never a wire field. authenticated_peer (admission.rs) then admits only a well-formed 64-hex value and refuses otherwise, so a malformed value cannot be coerced into a bucket. This is the correct shape.
  4. Refusal is reachable, not theoreticaladmit is called at the top of handle_json_rpc (peer.rs:1351) and handle_availability (peer.rs:1425), ahead of dispatch, and AdmissionGuard: Drop releases on every exit path including panic (poison recovered rather than propagated).

Still open in this review: the max_tracked_peers / MeterFull exclusion path, the conduct half and its stated dormancy, a revert-proof spot-check, and the serve_peer_session public-API behaviour change.

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

CHANGES-REQUIRED

Head reviewed: 825f329ab263b98497a0cc8d3271729f155ed9a4 (resolved from the remote, not from the dispatch brief). PR is DRAFT and was left DRAFT; nothing merged, nothing edited.

Two gating findings, both posted as inline threads. Everything else the lane claimed, I checked and it held — this is a genuinely careful PR and the vacuity disclosure is the right instinct. .gitnexus was not used: the registered dig-node index is stale and impact returns a false-safe zero, so the radius below came from grep + direct read of git objects, stated rather than implied.

Gating

  1. peer.rs:1423 — max_request_units (256, dig-sex default) refuses availability batches of 257-512 items that this crate's own MAX_AVAILABILITY_ITEMS = 512 documents and tests as served, truncated. Wire-visible narrowing; the existing 513-item test calls availability_batch below the responder so the suite cannot see it; two doc claims become false. Do not fix by deleting the clamp.
  2. peer.rs:1351 — the admission gate makes serve_peer_session's own doc (peer.rs:999-1001) false for the JSON-RPC and availability paths on a pub API, and the PR body cites that same comment as evidence. Behaviour change for an external consumer, not stated in the SemVer section.

Both are cheap. Neither is a design error.

Verified — the lane's claims held

  • dig-sex latest is 0.5.0. index.crates.io/di/g-/dig-sex yields 0.3.0 / 0.4.0 / 0.5.0; Cargo.toml declares "0.5". #287's ticket body was stale and the close was justified on that point; dig_sex::acquisition::decide for #270 is genuinely live at capsule_store.rs:587.
  • #269's meter key cannot be influenced by an unauthenticated caller. Every path reaches admit with caller.map(|c| c.peer_id) (peer.rs:1139/1151/1161/1172); caller comes only from caller_from_tls (peer.rs:3324-3332, SHA-256 of the verified client leaf SPKI) or relayed_caller_contact off the mTLS-verified PeerConnection.peer_id (peer.rs:2371). Never a wire field. Refusal is reachable, not theoretical, and AdmissionGuard: Drop releases on every path including panic.
  • No lie is synthesised from a transport error. Grepping every .rs in the crate at this head: ConductEvidence:: appears in production at exactly two lines, download.rs:1975 and :1978, both HonestAnswer/NonPerformance. ProvenLie and SelfContradiction appear only in conduct.rs tests. The conflation SPEC 8.2A exists to prevent was not committed.
  • Both revert-proofs hold, checked analytically rather than by rebuild (disk at 99%): MIN_NON_PERFORMANCE_DIAL_SHARE = 0.1 (dig-sex conduct.rs:116/194), so a > 0.5 threshold drops the slow control at 0.1 while quiet (1.0) and the liar exclusion (0.0) are unchanged — exactly one of the eight fails, and it is the control half that catches it. Likewise a constant meter key fails only the two-peer test; the other four never key a second identity.
  • The rival is reconciled, as claimed — download.rs:2788-2803 documents the three-state hop budget, remaining -> None -> ForwardRefusal::UnreadableHopBudget (dig-node#281), with the redirect/forward asymmetry stated. My brief was stale on this. (The body cites 2740-2751; the text is at ~2788-2803.)
  • Commit subjects: five commits, longest 87 chars. Commitlint's 100-char rule is satisfied.

Non-gating (posted for the record, not blocking)

  • The vacuity disclosure is honest but understates itself. "Conduct half fires" is not quite the shape. dial_share has exactly one consumer — the > 0.0 filter in dialable (conduct.rs:104-110) — and neither HonestAnswer nor NonPerformance can ever produce 0.0, because dig_sex::dial_share floors non-performance at 0.1. So in production dialable is identically routable and conduct changes no dialling decision at all today; the observation half records, and the effect half is entirely inert. Worth saying that way in the body and on #268, because "half fires" reads as half the behaviour rather than none of it.
  • The two halves of this PR interact. download.rs:1978 scores AskOutcome::Refused as NonPerformance, and this same PR makes refusals more common by shedding load at admission. A node correctly shedding is penalised by its peers. Bounded — floored and decaying, never an exclusion — so not gating, but it is a feedback loop that did not exist before this commit.
  • Refusal::MeterFull is unreachable under the node's defaults, and that is good news. release removes a peer's entry at zero (dig-sex admission.rs:175-181), so in_flight.len() is bounded by global (64) which is far below max_tracked_peers (1024). I went looking for a Sybil eviction primitive — fill the tracked map, deny honest peers — and there isn't one. What Sybils can reach is GlobalCeiling (8 identities x per_peer_share 8 = 64), which refuses everybody transiently. That is SPEC 8.5's design and writes no durable exclusionary record, so it is not a finding against this PR, but it is the state an attacker aims for and is worth a ticket against the limits rather than the wiring.

The e2e gap: I do not gate on it

The lane flagged that no test drives a forwarded ask end-to-end and asserts conduct changed the peers dialled, and invited disagreement. I agree with the lane. Given the exclusion path is provably inert in production, such a test could today only assert dialable == routable — it would be a vacuous green, and a vacuous green defending a dormant path is worse than a stated gap. It becomes worth writing the moment dig-download surfaces per-peer attribution, and that is the right sequencing.

What I could not reach

  • I did not rebuild or run the suite (disk ~28 GB at 99%; a full -p dig-node-core --lib run is 632s per the body). The revert-proofs and the test-count claim are therefore checked by reading, not by execution. The body's 1037 passed; 0 filtered out is plausible and correctly guards against the matched-nothing trap, but I did not independently reproduce it.
  • I did not audit the dig-sex 0.5.0 crate beyond admission.rs and conduct.rs.

Comment thread crates/dig-node-core/src/peer.rs
Comment thread crates/dig-node-core/src/peer.rs
MichaelTaylor3d and others added 7 commits August 31, 2026 11:17
Stub commit so the branch, PR and issue log exist before implementation.

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

Wires `dig_sex::admission` (SPEC 8.5) into the mTLS peer surface. The meter was
implemented, tested and gating nothing: the node did the work first and had no
admission step at all.

`PeerAdmission` holds one node-wide `AdmissionMeter` and is consulted at the top
of `NodeResponder::handle_json_rpc` and `handle_availability`, ahead of the method
allowlist and every dispatch, so a refused request costs a hex decode rather than a
read, a decode or a DHT lookup.

Two properties the shape enforces rather than documents:

- The meter key is the mTLS-verified `peer_id`, decoded via the existing `hex64`.
  A session with no verified identity is REFUSED, never admitted unmetered and never
  metered under a placeholder -- a constant key collapses every requestor into one
  bucket, so one peer would exhaust the allowance for everybody.
- `AdmissionGuard` releases on `Drop`, so allowance returns on every exit path
  including the error paths a hand-written release is forgotten on.

Co-Authored-By: Claude <noreply@anthropic.com>
Wires `dig_sex::conduct` (SPEC 8.2A), which was implemented, tested and received
not one observation from dig-node.

`ConductState` keys `ConductRecord`s on `RoutedPeer` -- the mTLS-verified peer_id
the ask router already ranks on -- so conduct can neither be attributed to nor
escaped by a self-chosen identity, and is bounded by pool membership rather than a
TTL. Reputation stays node-local and is never gossiped.

The forwarded-ask loop now feeds it the outcome it already classifies, and
`dialable()` filters the pool BEFORE `decide_forward` ranks it: ranking a peer this
node has proven dishonest would still spend dials on it whenever the ranking
favoured it.

The threshold is "share > 0.0", which is not arbitrary -- `dial_share` returns
exactly 0.0 for a proven fault and floors non-performance above zero. So the filter
excludes precisely the verifiably faulty and can never evict a merely-slow peer,
which is what stops induced distress being an eviction primitive.

VACUITY, stated rather than implied: only `HonestAnswer` and `NonPerformance` are
produced today. `ProvenLie` needs a per-peer verification verdict that
`dig-download` owns and does not surface (module_transport.rs:1587), and
`SelfContradiction` needs an announce/answer correlation the node does not keep.
Both are follow-ups; neither is faked from a transport error, because branding an
honest peer on unverifiable evidence is the conflation SPEC 8.2A exists to prevent.
The exclusion path is therefore correct, tested, and dormant in production.

Co-Authored-By: Claude <noreply@anthropic.com>
MINOR: two new capabilities, both backwards compatible. Inbound peer work is now
admitted per authenticated identity before it is performed (#269), and peer conduct
gates which peers a forwarded ask will dial (#268). No public API was removed or
renamed and no wire format changed; an existing caller sees the same surface.

Also drops the WIP marker the lane opened with.

Co-Authored-By: Claude <noreply@anthropic.com>
…y the gates (#269)

Adds the WIRING assertion the unit tests cannot make: `admission.rs`'s tests prove
the meter behaves, not that anything calls it, and a meter nothing calls is the
defect #269 describes. The new test drives `NodeResponder::handle_json_rpc` and
asserts an unauthenticated session is refused with -32000 rather than the
allowlist's -32601 -- which is how we know admission ran FIRST -- with an
authenticated control proving the responder is not simply refusing everybody.

`node_responder_returns_method_not_found_for_management_methods` now passes a
64-hex conn_key. That is the shape every production session supplies, since both
listeners derive it from the verified client leaf and no production path reaches
the responder caller-less. Without it the test would answer -32000 and stop
exercising the allowlist at all; with it, the property under test is unchanged.

Also drops an unused accessor and two redundant u32 conversions for clippy
-D warnings.

Co-Authored-By: Claude <noreply@anthropic.com>
… three false docs

The admission clamp used dig-sex's default `max_request_units` = 256 while this crate's own
`MAX_AVAILABILITY_ITEMS` = 512, so a 257-512 item `dig.getAvailability` batch was refused
`-32000 "request too large"` by a node whose own `availability_batch` stood ready to answer all
512. `admission::node_limits()` now DERIVES the clamp from `MAX_AVAILABILITY_ITEMS` rather than
restating it, so the two numbers cannot drift apart again.

The clamp is kept -- it is the admission metering #269 exists to provide. A batch past 512 is now
refused whole at the boundary instead of answered as a truncated prefix; `availability_batch`'s
truncation remains as the in-process last line of defence, and both doc claims that said otherwise
now say what the code does.

The existing 513-item test calls `availability_batch` BELOW the responder that decides, so it
passes identically whether the clamp admits, refuses, or is unwired. It is relabelled to say what
it actually pins. The new pair sits at the RESPONDER level and asserts both sides: a batch AT the
advertised limit is answered in full, one past it is refused with reason `request too large`.

Also corrects `serve_peer_session`'s doc, which claimed a caller-less session still serves the
JSON-RPC/availability paths. Since #269 it serves neither -- both meter against the mTLS-verified
peer_id and refuse an absent one -- and the doc now states that on the `pub` item.

Co-Authored-By: Claude <noreply@anthropic.com>
Clean rebase, no conflict, no `dropping` -- the bump commit survives and simply
stops meaning anything against the new main. Read from the file, not the log.
0.191-0.193 are claimed by siblings still in flight.
CI runs `cargo nextest --locked`, which will not update the lock itself. A bump
touching only Cargo.toml fails the whole test job with `cannot update the lock
file ... because --locked was passed` -- an error that names the lock, so it reads
as a dependency problem rather than a stale version.
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Lane update — conflicts resolved, rebased onto main via merge

Outcome 1: still needed and distinct. §2.0 already-shipped check against origin/main (12a3f49): crates/dig-node-core/src/seams/dig_peer/ on main contains 30 modules, and neither admission.rs nor conduct.rs is among them. dig-sex = "0.5" is already a declared dependency of dig-node-core (Cargo.toml:509) for the cache/tier/relevance decisions, but the inbound-work meter (#269) and the peer-conduct dial gate (#268) this PR adds are absent from main.

State

  • branch: loop/batch-digsex
  • head: 7aea5c4
  • version: 0.220.0; dig-node-core 0.67.0
  • git merge origin/main, never rebase
  • this branch was the furthest behind (base 0.194.0 vs main 0.216.0), yet conflicts were still only Cargo.toml + Cargo.lock; lib.rs and peer.rs auto-merged
  • lock taken from origin/main, then cargo update -w --offline in the same commit

One thing worth flagging to the merge order

This PR changes dig-node-core, but the branch's dig-node-core version was 0.65.0 — identical to main's, so the version-increment gate had nothing to increment. I set it to 0.67.0, skipping 0.66.0 because #467 (loop/mc-verify) has taken that number. Whichever of #456 / #467 merges second will still need its dig-node-core version re-read from disk and re-bumped after rebasing, since two open PRs both computed their number against the same base.

§2.4b dependency freshness

dig-* are at latest published: dig-sex 0.5, dig-nat 0.21, dig-identity 0.7.1, dig-constants 0.13.0.

chia-* deliberately NOT bumped. Pinned at 0.36.1; latest is 0.48.0. dig-sex 0.5 / dig-mirror-coin 0.7 / chia-query all compile against the 0.36 line and the manifest comments require the set to unify on it — bumping chia here alone ships this crate split across two chia lines, which is the defect §2.4b exists to prevent. That uplift is a release-first cascade owned by the dependency crates. Reporting the shape rather than shimming.

Next action: wait for CI on 7aea5c4, then gate. Not undrafted, not merged.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Local verification on the merged head 7aea5c4:

cargo test -p dig-node-core --lib admission
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 1043 filtered out

6 admission tests executed; count checked rather than exit status alone. The full suite is CI's job -- this only confirms the merge compiles and the lane's own probes still hold.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Head moved — origin/main advanced to a375f46 (#477) mid-pass, which put this back to DIRTY. Re-merged (git merge, never rebase); this time zero conflicts, and the version was re-read from Cargo.toml on disk afterwards rather than taken from the commit log.

  • head: 6ed8d51
  • version: 0.220.0 (still above main's 0.217.0)
  • mergeStateStatus: BLOCKED (draft + checks pending), no longer DIRTY

Next action: watch CI on 6ed8d51, then gate. Still draft, not merged.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (PR #456, head 6ed8d51)

Audited against head 6ed8d51c6df6d9f650cb6be8c003f8122f30eae9, resolved from the remote, in a private
worktree. Merge base a375f46. gitnexus was not used for blast radius: the registered dig-node index
is stale by construction and returns a false-safe impactedCount: 0, so this radius is grep + direct read
of dig-sex 0.5.0's own source from the registry cache.

Two items established so far. Neither is a money finding — see the §908 note at the end.


S1 (HIGH, live) — the node-wide admission ceiling is a new denial primitive that 8 free identities saturate

dig_sex::AdmissionLimits::default() is taken wholesale except for max_request_units
(admission.rs:130-135). The defaults, read from
~/.cargo/registry/.../dig-sex-0.5.0/src/admission.rs:97-107, are:

global_ceiling: 64      per_peer_share: 8      relay_ceiling: 16      max_tracked_peers: 1024

AdmissionMeter::admit (dig-sex admission.rs:150) checks self.global >= global_ceiling before the
per-peer share, and self.global is one counter shared by every peer. So:

Exploit. A stranger mints 8 self-signed TLS identities — free; peer_id is just SHA-256 of the SPKI
DER and the verifier accepts any well-formed self-signed leaf (peer.rs:1268 says so itself). It opens 8
mTLS peer sessions (cap is MAX_INFLIGHT_PEER_CONNECTIONS = 512, peer.rs:155) and on each opens 8 yamux
streams (cap is MAX_INFLIGHT_STREAMS_PER_CONNECTION = 64, peer.rs:162), each carrying a peer-reachable
JSON-RPC method whose handler awaits network IO. Each request holds one admission unit for the whole
handler, because the guard is held across the entire method (peer.rs:1429-1436). 8 x 8 = 64 = the
global ceiling
, and from that moment every other peer's handle_json_rpc and handle_availability
returns -32000 / "node at capacity" (peer.rs:1243-1256, admission.rs:66).

Why this is a regression and not merely a limiter doing its job. Before this PR these two paths had no
node-wide counter at all; concurrency was bounded per-connection at 64 and per-node at 512 connections.
The new ceiling is 64 node-wide for both paths combined — lower than what a SINGLE connection was
previously allowed on its own.
So the change does two things at once: it cuts the node's concurrent
capacity on its busiest read paths by roughly two orders of magnitude, and it makes the remaining capacity
a shared resource that an attacker can hold. A per-peer limiter is a defence; a global one reachable by
8 free identities is a denial primitive, and this is the shape §2.0/"a rate limiter keyed so a stranger can
fill it is a DoS primitive" warns about.

The hold time is the multiplier, and it is attacker-selectable. The guard is not released until the
handler returns, and a peer-reachable method can await a DEFAULT_DHT_RPC_TIMEOUT of 5s
(seams/dig_peer/dht.rs:67) or a forwarded recursive ask. At 64 slots x ~5s the node's whole JSON-RPC +
availability surface tops out near ~13 req/s under miss traffic — which honest load alone can reach on a
busy node, before any attacker is involved.

One thing the design got right and worth recording so nobody "fixes" it: the guard drops when the
handler returns, before write_framed (peer.rs:1212-1213, 1223-1224), so a peer that stops reading
its socket cannot hold a slot via backpressure.

Recommended shape (not prescribing): keep the per-peer share as the enforcing dimension, and either raise
global_ceiling well above per_peer_share x plausible-peer-count or reserve headroom so a peer with no
work in flight is never refused by the global counter alone.


S2 (informational, but it falsifies two claims in the PR body) — three of the five admission dimensions are vacuous

The PR body deserves credit for raising vacuity itself. Two of its statements are wrong in the reason,
and a third case is not listed at all.

  1. Refusal::MeterFull is not merely unreached, it is arithmetically unreachable. The body says it
    "fires only when the tracked-peer table is full, and no Sybil-eviction primitive exists to fill it
    (max_tracked_peers = 1024, never pruned under load)". The table is pruned — dig-sex
    admission.rs:175-186 removes a peer's entry the moment its count reaches zero, so in_flight.len()
    is bounded by global = 64. 64 < 1024 always, so the MeterFull branch (dig-sex admission.rs:161)
    can never be taken under these limits. The never pruned premise is false.

  2. Refusal::RelayBudget is fully vacuous — no production call site passes WorkKind::Relayed.
    grep -rn "WorkKind::Relayed" crates/ returns only admission.rs:355,358 and 363, all inside
    #[cfg(test)]. Both production admits pass WorkKind::Own (peer.rs:1432, peer.rs:1507), including
    the relay-accepted session path, which reaches the same responder via serve_peer_session_from
    (peer.rs:2453). So relayed_work_exhausts_the_relay_ceiling_while_own_work_still_admits
    (admission.rs:344) passes while SPEC 6.1.8's separate relay budget does nothing, and relayed work
    is billed to the same Own bucket the clause exists to keep it out of. This is the exact
    "CacheTier::Tier2Bribed" shape and it is not disclosed in the body.

  3. RequestTooLarge fires on one path only. handle_json_rpc passes a constant 1
    (peer.rs:1432), so max_request_units = 512 is consulted only by handle_availability
    (peer.rs:1503-1509). That is correct as written; recording it so the 512 is not later read as a
    node-wide request-size bound.

The conduct half's dormancy, by contrast, is disclosed accurately and I confirmed it against the
crate: dial_share floors non-performance at MIN_NON_PERFORMANCE_DIAL_SHARE = 0.1 (dig-sex
conduct.rs:116), only proven_faults reaches 0.0, and neither ProvenLie nor SelfContradiction has a
producer in this diff (download.rs:1970-1981 emits only HonestAnswer / NonPerformance). So
dialable(pool) === routable(pool) today, as the body states.


§908 / money: I traced every path this PR introduces. It moves no $DIG and no XCH, builds no spend,
touches no key material, and reaches no wallet or signing surface. dig-sex here is the economic policy
crate used only for concurrency accounting and a local reputation scalar — no value transfer, no price, no
balance. The node signs nothing on the user's behalf on any path in this diff. Nothing renders an
attacker-plantable figure to an operator either (the #469 shape is absent — in_flight_total() is the only
new operator-visible number and it counts this node's own admitted work).

Still to come: the handle_availability refusal envelope shape, coverage proportionality, a revert probe,
and the dig-node-core 0.66.0/0.67.0 collision with #467.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (2/3) — S1 is now PROVEN BY EXECUTION

Head 6ed8d51c6df6d9f650cb6be8c003f8122f30eae9, unchanged since my first comment (re-resolved).

The S1 exploit, executed

I did not take the claim on a read. I built a standalone crate in lane-private scratch that depends
only on dig-sex = "0.5.0" and replicates node_limits() byte for byte (dig-sex defaults, with
max_request_units = 512 = MAX_AVAILABILITY_ITEMS). No dig-node source was touched for this.

running 5 tests
PROBE limits: AdmissionLimits { global_ceiling: 64, per_peer_share: 8, relay_ceiling: 16,
                                max_tracked_peers: 1024, max_request_units: 512 }
PROBE exploit: honest peer refused with GlobalCeiling after 64 sybil units from 8 identities
PROBE vacuity: MeterFull unreachable; 64 distinct peers then GlobalCeiling
PROBE cost:    7 identities leave 8 slots; the attack needs exactly 8
PROBE:         entries are removed at zero, so the table tracks ACTIVE peers only
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Real counts — running 5 tests, 0 filtered out, not a filter that matched nothing.

What the five assertions establish, and note that they are one-sided in the way that matters
(an honest control is present in each):

  • eight_sybil_identities_deny_service_to_an_honest_peer — 8 identities x 8 units each = exactly 64,
    every one of them within its own per-peer share, and then a ninth peer holding zero work in flight
    is refused Refusal::GlobalCeiling. The per-peer limiter never fires; the node-wide one does all the
    denying.
  • seven_identities_are_not_enough... — with 7 identities the honest peer is still served. So the
    attacker cost is pinned at exactly 8 identities, not "some number of them". A peer id is
    SHA-256 of a self-signed TLS SPKI, so eight of them cost eight keypairs.
  • meter_full_cannot_be_reached_under_the_shipped_limits — walking distinct peers stops at 64,
    never at max_tracked_peers = 1024, and the refusal is GlobalCeiling rather than MeterFull.
  • the_tracked_peer_table_is_pruned_at_zero — release removes the entry, directly falsifying the PR
    body's "max_tracked_peers = 1024, never pruned under load".

So S1 is not a modelling argument. Under the limits this PR ships, eight free identities take the entire
node-wide allowance for dig.getJsonRpc and dig.getAvailability, and every honest peer on the network
is answered -32000 / "node at capacity".
Before this PR those two paths had no node-wide counter at
all.

Test counts at head — real, and adequate in number

cargo test -p dig-node-core --lib -- seams::dig_peer::admission seams::dig_peer::conduct
running 14 tests ... test result: ok. 14 passed; 0 failed; 0 ignored; 1047 filtered out
CARGO_RC=0

14 in the two new modules + 2 new responder-level tests in peer.rs = 16 new tests for 937 added
lines
. That count is proportionate. The gap is not in the count, it is in what is never asserted:

node_limits() is tested for exactly one of its five dimensions. admission.rs:329-339 asserts
max_request_units == MAX_AVAILABILITY_ITEMS. Every other meter test — admission.rs:206-214,
:345-351 — supplies hand-picked limits (per_peer_share: 2, global_ceiling: 64, relay_ceiling: 1).
So global_ceiling, per_peer_share, relay_ceiling and max_tracked_peers arrive from
AdmissionLimits::default() and no test in this diff asserts anything about their values or their
ratio
. The shipped denial surface is the one part of the configuration nothing measures — which is
precisely why S1 could land green.

Related: exhausting_one_peers_share_does_not_refuse_a_different_peer uses global_ceiling: 64, per_peer_share: 2, a ratio of 32:1. Production ships 8:1. The test therefore demonstrates per-peer
isolation in a regime where the global ceiling is 32 identities away, and cannot see the regime the node
actually runs in, where it is 8.

Corrections to the record

The mux availability leg's refusal shape is safe, and I checked it rather than assuming. Success is
{"items":[...]} (lib.rs:4105); a refusal is a JSON-RPC envelope (peer.rs:1510). dig_nat::mux:: AvailabilityResponse.items carries no #[serde(default)] (dig-nat 0.21.0 mux.rs:102-107), so the
envelope fails to decode and surfaces as Err, never as Ok(empty). A refusal therefore cannot become a
manufactured absence. For a connected-pool holder PoolConfirmTransport then fabricates available()
(download.rs:1157-1170) — pre-existing #836 behaviour, safe because the merkle bind is the real gate;
the cost is a wasted dial, not an admitted byte.

The forwarded-ask leg is likewise correct: parse_forwarded_answer maps any error frame to
AskOutcome::Refused (seams/dig_peer/forwarded_ask.rs:316-318), never to an empty answer. The
-32000 this PR introduces is classified as a refusal, which is the fail-closed direction and matches
dig-node#273.

Next

Mutation probe: I am about to set global_ceiling: 1 in node_limits() (admission.rs:130) in my
lane-private worktree only, to measure whether ANY test in the 1061-test suite catches a pathological
shipped ceiling. crates/dig-node-core/src/seams/dig_peer/admission.rs is backed up to
admission.rs.secgate-bak in that worktree and will be restored by file copy, never by git.
If I am
killed mid-probe, restoring is cp admission.rs.secgate-bak admission.rs. No other file is touched and
nothing is committed or pushed.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security VERDICT: CHANGES-REQUIRED

Audited head: 6ed8d51c6df6d9f650cb6be8c003f8122f30eae9 — resolved from the remote myself at the
start and re-resolved after every probe; it did not move. Merge base a375f46. 937 additions, 10 files.

Blast radius was established by grep + direct read, not gitnexus: the registered dig-node index is
stale by construction and returns a false-safe impactedCount: 0 rather than erroring. Stated rather
than implied.

No money finding. I traced every path this PR introduces: it moves no $DIG and no XCH, builds no
spend, touches no key material, and reaches no wallet or signing surface. §908 holds — the node signs
nothing on the user's behalf anywhere in this diff. dig-sex is used here purely as a concurrency
accountant and a local reputation scalar. Nothing renders an attacker-plantable figure to an operator
(the #469 shape is absent). Secrets: none introduced, none logged. Dependencies: the Cargo.lock change
is the two workspace version bumps and nothing else. Crypto: none touched.

Two GATING findings. Both are about the same thing: the shipped limits are wrong, and nothing
measures them.


G1 — HIGH, LIVE — 8 free identities deny the node's entire peer read surface

crates/dig-node-core/src/seams/dig_peer/admission.rs:130-135 takes dig-sex's defaults wholesale except
for max_request_units. Those defaults (dig-sex 0.5.0 admission.rs:97-107) are global_ceiling: 64,
per_peer_share: 8. AdmissionMeter::admit (dig-sex admission.rs:150) tests
self.global >= global_ceiling before the per-peer share, and self.global is one counter shared by
every peer on the node.

Exploit — state, attacker action, impact.

  1. State: the node is running normally, serving peers.
  2. Action: a stranger mints 8 self-signed TLS identities. A peer_id is SHA-256 of the SPKI DER
    and the verifier accepts any well-formed self-signed leaf — peer.rs:1268 says so in its own words
    ("authenticated means only derived some peer_id, never authorized"). Eight keypairs; no cost, no
    permission, no prior relationship with the node. It opens 8 mTLS sessions (cap
    MAX_INFLIGHT_PEER_CONNECTIONS = 512, peer.rs:155) and issues 8 concurrent requests on each — every
    one inside its own per-peer share, so the per-peer limiter never fires.
  3. Impact: global reaches 64. From that instant every other peer's handle_json_rpc and
    handle_availability is answered -32000 / "request refused" / reason "node at capacity"
    (peer.rs:1243-1256, admission.rs:66). That is the node's discovery, availability and content-read
    surface — getAvailability, getPeers, getNetworkInfo, resolveCapsule, getProviderSnapshot, announce —
    off, for the whole network, for as long as the attacker holds it.

Proven by execution, not by reading. I built a standalone crate depending only on dig-sex = "0.5.0"
that replicates node_limits() exactly. No dig-node source was involved.

PROBE limits: AdmissionLimits { global_ceiling: 64, per_peer_share: 8, relay_ceiling: 16,
                                max_tracked_peers: 1024, max_request_units: 512 }
PROBE exploit: honest peer refused with GlobalCeiling after 64 sybil units from 8 identities
PROBE cost:    7 identities leave 8 slots; the attack needs exactly 8
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

The seven-identity case is the control: with 7 the honest peer is still served. The cost is pinned at
exactly 8 identities, not "enough of them".

Three reasons this is a regression rather than a limiter doing its job.

  • The ceiling is lower than what ONE connection was previously allowed. Before this PR these two
    paths had no node-wide counter at all — concurrency was bounded per connection at
    MAX_INFLIGHT_STREAMS_PER_CONNECTION = 64 (peer.rs:162) and per node at 512 connections. The new
    node-wide ceiling for both paths combined is 64. A single honest connection could previously hold
    what the entire node is now allowed.
  • A slot can be parked for 65 seconds, and this codebase already knows that.
    MAX_FORWARDED_ASK_BUDGET = 65s (seams/dig_peer/forwarded_ask.rs:84), and ask_budget reaches it at
    the dig-sex defaults (fan_out 3, hop_cap 2). An inbound JSON-RPC that triggers a forwarded ask holds
    its admission guard for that whole time, because the guard is held across the entire method
    (peer.rs:1429-1436). forwarded_ask.rs:78-82 already reasons about exactly this hazard — "one hop
    naming a ten-minute budget holds this node's inbound request, and one of its
    MAX_CONCURRENT_FORWARDED_ASKS slots, open for ten minutes, an amplification achieved with a single
    integer."
    This PR adds a second, scarcer resource to that same hold and does not extend the
    reasoning to it.
    With MAX_CONCURRENT_FORWARDED_ASKS = 32 an attacker parks 32 of the 64 slots a
    minute at a time and cycles the other 32, holding the ceiling continuously.
  • Honest load alone reaches it. 32 concurrent legitimate forwarded asks — well within normal
    operation — consume half the node's entire inbound JSON-RPC and availability allowance for up to
    65s each. At 64 slots against 5s DHT RPCs (seams/dig_peer/dht.rs:67) the surface tops out near
    ~13 req/s under miss traffic. No attacker required.

What must change (the property, not a number). A peer holding zero work in flight must not be
refusable by the node-wide counter at a Sybil cost this low. Either raise global_ceiling well above
per_peer_share x plausible-peer-count, or reserve headroom so the global ceiling sheds only from peers
already holding work. I am deliberately not prescribing a constant — but whichever is chosen, G2 says it
has to be asserted.

Credit where due, so nobody "fixes" it away: the guard is dropped when the handler returns, before
write_framed (peer.rs:1212-1213, 1223-1224), so a peer that stops reading its socket cannot hold a
slot through backpressure. That is the right shape and should survive whatever change G1 gets.


G2 — HIGH — the shipped configuration is completely unguarded, which is how G1 landed green

Mutation probe, run by me at this head. I set global_ceiling: 1 in node_limits() — a
configuration in which the node refuses every peer after a single concurrent request, i.e. a node that
serves nobody — and ran the full library suite:

test result: ok. 1061 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 495.52s
CARGO_RC=0

Nothing failed. Real counts, 0 filtered out, run unpiped to a file with the exit status read from
the file.

The cause is precise: node_limits() is asserted for exactly one of its five dimensions.
admission.rs:329-339 pins max_request_units == MAX_AVAILABILITY_ITEMS. Every other meter test
supplies hand-picked limits (admission.rs:206-214 uses per_peer_share: 2; :345-351 uses
relay_ceiling: 1), so global_ceiling, per_peer_share, relay_ceiling and max_tracked_peers
arrive from AdmissionLimits::default() and no test in this diff asserts anything about their values or
their ratio.

Worse, the ratio the tests do exercise is not the one that ships:
exhausting_one_peers_share_does_not_refuse_a_different_peer (admission.rs:224) runs at
global_ceiling: 64, per_peer_share: 232:1, where the global ceiling is 32 identities away.
Production ships 8:1. The test demonstrates per-peer isolation in a regime the node never runs in and
is structurally incapable of seeing G1.

Required: a test asserting the shipped node_limits() relationship — concretely, that a peer with no
work in flight is still admitted after the maximum number of other peers have each taken their full
per-peer share. That assertion fails today, and it is what makes G1's fix load-bearing rather than a
number someone changed.


Non-gating findings

S3 — MEDIUM (defense-in-depth) — the denial is invisible and unattributable to the operator

in_flight_total() (admission.rs:185-190) documents itself as "For tests and the operator status
surface". It has no production consumer — a grep across crates/ returns only its own definition and
three assertions inside #[cfg(test)]. There is no status field, no metric, no counter exposed anywhere.
The only signal a refusal produces is tracing::debug! at peer.rs:1245-1248, which carries reason
and not the peer id.

So the module's own stated design goal — admission.rs:57-61, "shed load must be distinguishable from
an outage by an operator reading a log, and from a ban by the peer reading the response"
— is met for
the peer and not for the operator: at info level nothing is emitted at all, and even at debug the
operator cannot tell which peers hold the 64 slots. When G1 fires in the field it is undiagnosable. Not
gating on its own; it is why G1 would go unnoticed. Recommend a follow-up ticket, and note the doc claim
about an operator status surface is false as written.

S4 — MEDIUM (vacuity) — the relay budget does nothing, and this is not in the PR body

The body's vacuity section is genuinely good and I want to be clear it caught the conduct half correctly.
I verified it independently: dial_share floors non-performance at MIN_NON_PERFORMANCE_DIAL_SHARE =
0.1 (dig-sex conduct.rs:116), only proven_faults reaches 0.0, and neither ProvenLie nor
SelfContradiction has a producer — download.rs:1970-1981 emits only HonestAnswer and
NonPerformance. So dialable(pool) is identical to routable(pool), exactly as stated.

But it misses one. A grep for WorkKind::Relayed across crates/ returns only admission.rs:355,
:358 and :363, all inside #[cfg(test)]. Both production admits pass WorkKind::Own
(peer.rs:1432, peer.rs:1507) — including the relay-accepted session path, which reaches the same
responder through serve_peer_session_from (peer.rs:2453). Consequences:

  • relayed_work_exhausts_the_relay_ceiling_while_own_work_still_admits (admission.rs:344) passes while
    SPEC 6.1.8's separate relay budget governs nothing;
  • relayed work is billed to the same Own bucket the clause exists to keep it out of — the concrete harm
    the SPEC names is that a hop's fan-out spends a victim's budget at its peers.

This is the CacheTier::Tier2Bribed shape. State it in the body, or wire WorkKind::Relayed on the
relayed path.

S5 — LOW — two statements in the PR body are false in a way that would mislead the next reader

The body says MeterFull is unreached because max_tracked_peers = 1024 is "never pruned under
load
". The table is pruned: dig-sex admission.rs:175-186 removes a peer's entry the moment its
count reaches zero. The real reason is stronger and arithmetic: in_flight.len() is bounded by global,
so it can never exceed 64, and 64 is always less than 1024 — MeterFull (dig-sex admission.rs:161) is
unreachable by construction, not merely unreached. Both facts confirmed by probe
(meter_full_cannot_be_reached_under_the_shipped_limits, the_tracked_peer_table_is_pruned_at_zero).

Also worth recording: RequestTooLarge fires on one path. handle_json_rpc passes a constant 1
(peer.rs:1432), so max_request_units = 512 is consulted only by handle_availability. Correct as
written; noted so the 512 is not later read as a node-wide request-size bound.

S6 — LOW (future, currently dormant) — the durable exclusion resets on reconnect

ConductState::retain (conduct.rs:113-115) drops the record of any peer not in the current pool, and
dialable calls it on every read. A peer carrying proven_faults therefore clears its record simply by
leaving the pool and reconnecting — the exclusion is durable against time but not against a
reconnect. The diff knows this and documents it as the bound's deliberate cost (conduct.rs:260-265),
and it is harmless today because nothing produces a verifiable fault. It stops being harmless the day a
ProvenLie producer is added
, which is the stated plan once dig-download surfaces per-peer
attribution. Worth carrying on #268 so the exclusion is not later shipped believing it is durable.

S7 — informational, merge-ordering hazard — FLAGGING ONLY, per the brief

The version collision with #467 is inverted, which is worse than a re-bump:

root Cargo.toml dig-node-core
main 0.217.0 0.65.0
#456 (this) 0.220.0 0.67.0
#467 0.221.0 0.66.0

This PR takes the lower workspace version and the higher core version. And
.github/workflows/ensure-version-increment.yml:57-60 reads only the repo-root Cargo.toml — it
never reads crates/dig-node-core/Cargo.toml. So if #456 merges first, #467 rebases with
dig-node-core going 0.67.0 to 0.66.0, a version DECREASE, and the gate reports green
because the root
version increased. Merging #467 first is the safe order: #456's root 0.220.0 would then sit below main's
0.221.0 and the gate would catch it. Not fixing this, as instructed.


Areas checked and clear

  • Money / §908 / custody — no value transfer, no spend, no key, no signing on any new path. Clear.
  • Secrets — nothing introduced or logged; the refusal log carries a fixed reason string only.
  • Dependencies — the Cargo.lock diff is the two workspace versions and nothing else; no dependency
    added, updated or loosened. dig-sex = "0.5" (crates/dig-node-core/Cargo.toml:509) pre-dated this PR.
  • Meter key non-choosability — the key is caller.peer_id, derived by the node from the verified TLS
    leaf (peer.rs:1208-1212, 1218-1222), never a wire field. hex64 (seams/dig_peer/dht.rs:913)
    decodes to bytes, so hex case cannot split one peer into two buckets. The unauthenticated case refuses
    rather than substituting a placeholder — the correct direction, and tested.
  • Fail-closed on refusal, both legs — the forwarded-ask parser maps any error frame to
    AskOutcome::Refused (forwarded_ask.rs:316-318), never to an empty answer, so the new -32000
    cannot become a manufactured absence (the dig-node#273 defect). On the mux leg
    dig_nat::AvailabilityResponse.items carries no #[serde(default)] (dig-nat 0.21.0 mux.rs:102-107),
    so the refusal envelope fails to decode and surfaces as Err, not Ok(empty); PoolConfirmTransport
    then fabricates available() for a connected holder (download.rs:1157-1170), which is pre-existing
    #836 behaviour and safe because the merkle bind is the real gate — the cost is a wasted dial.
  • Peer-supplied input and bounds — frames are capped at 64 KiB before allocation (peer.rs:781-789),
    which pre-dates this PR; a non-array items yields requested_units 0 and an empty answer rather than
    a panic; u32::try_from(...).unwrap_or(u32::MAX) cannot wrap. No new unwrap on adversarial input,
    and both Mutex locks recover from poisoning rather than propagating — the right direction for a
    release path (admission.rs:99-106).
  • Rival implementations — the disagreement named in the brief is genuinely reconciled at this head,
    and this PR neither reopens it nor adds a third implementation: an unreadable redirect_depth maps to
    HopBudget::remaining() returning None (download.rs:2969-2972), which
    dig_sex::discovery::decide_forward refuses as UnreadableHopBudget (dig-sex discovery.rs:169-171),
    pinned by download.rs:3511-3515; the redirect leg's deliberately tolerant reading is pinned
    separately at download.rs:3526-3531. One duplication worth naming without gating: the node now
    carries a seventh peer-surface limiter, and PeerAdmission is keyed on the same conn_key as the
    pre-existing rate_limit::MissRateLimiter (RequestorId::Peer, MAX_TRACKED_REQUESTORS = 4096) with
    a different bound and a different failure direction — deny the whole request, versus skip the
    enrichment. Neither references the other, so an operator tuning one cannot know the other binds first.
  • Public APIserve_peer_session's behaviour change is real and correctly disclosed in the body. I
    confirmed it has no caller anywhere in the workspace (doc references only), and both production
    listeners use serve_peer_session_from with the verified caller. Minor is the right bump for it.
  • Conduct state bounds — keyed by the mTLS-verified RoutedPeer and bounded by pool membership;
    conduct_ticks() is monotonic off an Instant with no wall clock (download.rs:2019-2027). The
    retain scan is O(pool squared) per forwarded ask (conduct.rs:114), immaterial at 512 peers.
  • Coverage proportionality — 16 new tests for 937 added lines (14 in the two new modules, measured:
    running 14 tests ... 14 passed; 0 failed; 1047 filtered out, plus 2 responder-level tests in
    peer.rs). The count is proportionate; the gap is G2 — what is never asserted, not how much.

What I could not reach

  • No live multi-node run. G1 is proven at the meter's own arithmetic under the shipped limits, and
    the reachability is traced through peer.rs by direct read — but I did not stand up a fleet and watch
    an honest peer be refused on the wire. That is a loop-e2e-attacker job if you want it before acting;
    I do not think it changes the verdict, because the refusing branch is unconditional once global hits
    64.
  • I did not audit dig-sex 0.5.0 itself beyond the modules this PR consumes (admission, conduct)
    plus discovery::decide_forward for the rival check.
  • cargo clippy and cargo fmt were not re-run at this head. The body reports them green at
    825f329, three commits earlier. That belongs to the correctness gate, not to me.

Shared-state disclosure

I worked in C:\tmp\worktrees\dn-digsex, the worktree the brief assigned me. For the G2 mutation probe I
edited crates/dig-node-core/src/seams/dig_peer/admission.rs in place after copying it to
admission.rs.secgate-bak, and restored it by file copy — never git checkout, reset or stash.
Post-restore verification: git status --porcelain empty; HEAD still
6ed8d51c6df6d9f650cb6be8c003f8122f30eae9; node_limits() byte-identical to the committed text; backup
and both scratch logs removed; no stash created (the three stashes present are pre-existing and
belong to other branches); no local commits; nothing pushed. The standalone dig-sex probe ran entirely
outside any git repo, in lane-private scratch.


Verdict: CHANGES-REQUIRED on G1 and G2. G2 is the cheaper of the two and is what stops this
recurring: today a global_ceiling of 1 — a node that serves nobody — passes 1061 green tests.

MichaelTaylor3d added a commit that referenced this pull request Sep 1, 2026
…ntities cannot deny the node

Gate G1 on #456. dig-sex AdmissionMeter::admit tests the node-wide ceiling BEFORE the
per-peer share against one shared counter, so the identities needed to deny every peer
was global_ceiling / per_peer_share -- eight, at the shipped defaults. A peer_id is
SHA-256 of a self-signed TLS SPKI, so eight identities cost eight keypairs, each staying
inside its own share so the per-peer limiter never fired. The node-wide ceiling (64 for
both read paths combined) was also lower than the 64 streams a SINGLE connection was
previously allowed, and 32 honest 65s forwarded asks alone consumed half of it.

Raising the ceiling only raises the price. This changes the shape: a peer's FIRST
concurrent unit is charged to a reserve pool whose per-peer share is exactly one and
whose size is MAX_INFLIGHT_PEER_CONNECTIONS, derived rather than restated. Denial now
costs one identity AND one held connection per slot -- linear, and bounded by the
connection cap the node already enforces rather than by a number 8x below it. A peer's
total concurrent share is unchanged; only the pool its first unit comes from.

Relayed work goes straight to the shared pool, keeping the SPEC 6.1.8 relay budget in one
place: work done on another node's behalf is what a loaded node should shed first.

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

MichaelTaylor3d commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

G1 + G2 addressed at cc2378f — PR stays DRAFT

Thank you for executing the exploit rather than arguing it. The 7-identity control is what made the
cost actionable: it pinned the answer at "the shape is wrong", not "the number is low".

G1 — fixed by changing the SHAPE, not the numbers

I deliberately did not raise global_ceiling. With a single shared counter the Sybil cost is
global_ceiling / per_peer_share by construction — raising the ceiling changes the price and
leaves the property untrue at every value. Your own framing said the property, not a constant, so:

A peer's FIRST concurrent unit is now charged to a separate reserve pool whose per_peer_share is
exactly 1
, sized RESERVED_FIRST_SLOTS = MAX_INFLIGHT_PEER_CONNECTIONS (derived from the constant,
not restated). Everything beyond a peer's first unit — and all relayed work — draws on the shared pool
with its existing limits.

Consequences, against your three reasons:

  1. The guaranteed surface is no longer narrower than one old connection. 512 reserved slots
    against the 64 streams a single connection used to be allowed.
  2. The 65s forwarded-ask hold. You were right that forwarded_ask.rs:78-82 already contained the
    answer and it simply had not been extended. 32 concurrent asks now occupy 32 of 512 reserved slots
    instead of half of the node's entire allowance.
  3. Denial is now linear. One identity takes one reserved slot, and it must hold a connection to do
    it — so admission can no longer be exhausted more cheaply than the connection cap the node already
    enforces. Eight keypairs buy eight slots, not the whole surface.

Two things I checked so the fix does not buy the property by giving something away:

  • The reserve grants NO extra concurrency. A peer's total is still per_peer_share, with the first
    unit charged to the reserve and the rest to the shared pool — the_reserve_does_not_widen_the_share_of_any_single_peer
    pins that, and it fails under the reverted code.
  • The drop-before-write_framed property you credited survives, untouched.

Relayed work goes straight to the shared pool, so SPEC 6.1.8's separate budget stays in exactly one
place. Work done on another node's behalf is precisely what a loaded node should shed first, so it is
deliberately not given a guaranteed slot.

G2 — the shipped configuration is now pinned, and the exploit is a test

Six new tests. Test counts are real: 12 passed; 0 failed; 1055 filtered out for the module, and
1067 passed; 0 failed; 0 filtered out for the full lib (was 1061).

  • eight_sybil_identities_cannot_deny_an_honest_peer_under_the_shipped_limits — your exploit, run
    against PeerAdmission::default(), i.e. the real shipped config, with the honest ninth peer
    holding zero as the control. The sybils hold until refused rather than counting to a literal.
  • the_shipped_admission_configuration_is_pinnedall five dimensions. Your global_ceiling: 1
    mutation now fails here.
  • the_reserve_is_never_scarcer_than_the_connections_it_serves — asserted as the relation to
    MAX_INFLIGHT_PEER_CONNECTIONS, never as the literal 512, plus RESERVED_FIRST_SLOTS > global_ceiling.
  • a_spent_shared_pool_sheds_a_busy_peer_while_a_quiet_one_is_still_served — the placement
    property. With one pool both peers receive the identical GlobalCeiling, so this cannot pass on a
    mere ceiling increase, which is the failure mode a "the set is empty" assertion would have had.
  • denying_a_newcomer_costs_at_least_one_identity_per_reserved_slot — walks distinct identities until
    a newcomer is refused, asserting the count exceeds global_ceiling / per_peer_share.
  • the_reserve_does_not_widen_the_share_of_any_single_peer.

Revert-proof — committed FIRST, reverted by file copy, never git checkout. I restored the
single-pool meter (reserved.global_ceiling = 0, burst per_peer_share un-decremented — exactly the
pre-fix behaviour) in the worktree only:

test result: FAILED. 8 passed; 4 failed; 0 ignored; 0 measured; 1055 filtered out
panicked at admission.rs:538:
a peer holding ZERO work was refused while 8 free identities held theirs — the node-wide
pool is deniable at a Sybil cost of eight keypairs

Four of the six are load-bearing. The two that still pass under the revert are the config-pinning
ones, correctly: the limits themselves did not change, only which pool spends them. Restored by
cp; tree clean, zero markers left, git diff empty.

S3, S4, S5

  • S5 — corrected. The PR body claimed max_tracked_peers is "never pruned under load". That was
    false: release removes an entry at zero. The body now says so and states the real reason
    MeterFull is unreachable (the pool ceilings bind first).
  • S4 — stated, not left to read as a rule. Nothing in the crate constructs WorkKind::Relayed;
    every production call site passes Own. node_limits() and the relayed test now say the relay
    budget is satisfied because the case never occurs, not because it is enforced. The ceiling is
    kept so the first producer inherits a budget rather than an omission. The SPEC 6.1.8 clause itself lives in
    dig-sex; under the inflow rules this is a finding, not a ticket, so it is recorded here and routed
    to the orchestrator rather than filed.
  • S3 — the refusal log now names the peer (16-hex prefix). A count without identities cannot tell
    an operator whether one caller is taking the allowance or the node is simply loaded.
    in_flight_total()'s doc no longer claims an operator status surface nothing renders; a
    reserved_in_flight() companion was added for the tests.

Sequencing — S7, flagged not fixed, and it is worse than a collision

Handed to the orchestrator. Two facts that compose badly: the #467 version collision is inverted,
and ensure-version-increment.yml reads only the root manifest. This branch is now merged with
main at ff62f55b, root 0.223.0 (above main's 0.222.0), dig-node-core 0.67.0 (above
main's 0.65.0) — so core 0.67.0 still stands on this branch. But a sibling merging first with a lower
core version would pass the gate green, because nothing reads the member manifest.

Not merging, not undrafting

Head 3494ddf, pushed (cc2378f + a rustfmt-only commit). Re-gate scope is the admission module plus the two admission_refused call
sites; the blast radius is in the PR body.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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

Head 3494ddf23fe218e34938e44e8d38280a57d1bfad, resolved from the remote myself. Scoped delta
6ed8d51..3494ddf. The mirror/* churn in that range came in from main via the merge (#469) and is
not this lane's — the lane's own delta is admission.rs + peer.rs.

FIRST: the branch cannot merge as it stands, and one of the two red checks is the round-1 S7 hazard landing

Read from disk at head, not from the rollup:

root Cargo.toml dig-node-core
origin/main (45ecbc8) 0.223.0 0.65.0
head 3494ddf 0.223.0 0.67.0

The root versions are EQUAL, not incremented. git merge-base --is-ancestor origin/main HEAD
returns false — head is behind main, whose tip 45ecbc8 (#470) landed after this branch merged main at
ff62f55 (0.222.0). The lane took 0.223.0 correctly against the base it had; #470 then took the same
number. This is exactly the S7 shape I flagged in round 1, now realised.

It is already red, so the gate caught it — Check version increment fail
(run 33531694143). Root must go to 0.224.0 after the next merge of main. dig-node-core 0.67.0 over
main's 0.65.0 is fine and needs no change.

Second red check, unrelated to security: Lint commit messages fail — commit dcbf40e is
wip: G1 two-pool admission + G2 shipped-config tests, and wip is not in the type-enum. That commit is
the G2 revert-proof, committed first as required; it just needs rewording to a valid type.

Neither is a security finding and neither is my gate — flagging because a PASS from me would otherwise
read as "ready", and it is not.

The "by construction" argument — I checked it, and it HOLDS

The claim is that with one shared counter the Sybil cost is global_ceiling / per_peer_share
by construction, so raising the ceiling reprices the attack rather than removing it. That is correct,
and it is arithmetic rather than judgement:

  • To refuse an honest peer you must fill global_ceiling, because AdmissionMeter::admit tests
    self.global >= global_ceiling before the per-peer share (dig-sex 0.5.0 admission.rs:150-160).
  • One identity contributes at most per_peer_share.
  • So identities needed = ceil(global_ceiling / per_peer_share), for every value of the ceiling.

Since an identity is one keypair, the count is not a defence at any value. And the ceiling would have to
exceed the node's entire stream capacity — MAX_INFLIGHT_PEER_CONNECTIONS x
MAX_INFLIGHT_STREAMS_PER_CONNECTION = 512 x 64 = 32,768 — before the connection cap became the
binding constraint instead, at which point the ceiling is no longer shedding anything. So raising the
number could not have fixed it, and changing the shape was the right call.
I want that on the record
because it is the reasoning I would have had to disagree with to reject this fix.

The reserve does what it claims — verified by reading, mutations still to run

admission.rs:106RESERVED_FIRST_SLOTS = MAX_INFLIGHT_PEER_CONNECTIONS, derived not restated.
with_reserved_first_slots (admission.rs:213-230) builds reserved at per_peer_share: 1 and
burst at per_peer_share: limits.per_peer_share.saturating_sub(1).

  • No extra concurrency. 1 (reserve) + 7 (burst) = 8 = the shipped per_peer_share. Confirmed in the
    code and pinned by the_reserve_does_not_widen_the_share_of_any_single_peer.
  • The denial cost is now linear and bounded by a cap that already exists. Filling the reserve needs
    512 distinct identities, since its per_peer_share is 1 — and each identity needs its own mTLS
    session, because conn_key is derived from that connection's client leaf. 512 identities therefore
    means 512 connections, which is MAX_INFLIGHT_PEER_CONNECTIONS exactly. Filling the reserve is
    exhausting the connection cap, so admission can no longer be the scarcer resource. That is the claim,
    and it is true.
  • No double-charge and no leak. dig-sex's admit mutates nothing on any error path (all five checks
    return before in_flight.insert), so a failed reserve attempt costs nothing before the burst attempt.
    AdmissionGuard carries a Tier and Drop returns the slot to the pool it came from
    (admission.rs:130-145) — crediting the wrong pool would be a permanent two-sided leak, and it does
    not happen.
  • The two locks are taken sequentially rather than atomically, which I checked for an overcommit
    race: there is none. Each pool enforces its own limits under its own mutex, so a race can only change
    which pool a unit lands in, never let a peer exceed 1+7 or a pool exceed its ceiling.
  • RequestTooLarge from the reserve returns immediately instead of retrying burst
    (admission.rs:258-262). Correct — both pools clamp on the same max_request_units, so the retry
    could only reach the identical refusal, and returning early keeps the answer to an oversized request
    independent of node load.
  • Drop-before-write_framed is untouched. The only peer.rs change is threading conn_key into
    admission_refused for the S3 log; the guard binding and its scope are byte-identical, so the guard
    still releases when the handler returns and a peer that stops reading its socket still cannot hold a
    slot through backpressure.
  • The new log line cannot panic. conn_key.get(..16).unwrap_or(conn_key) uses str::get, which
    returns Option rather than panicking, and falls back whole on a short or empty key.

The discrimination claim is correct, and I worked the counterfactual

a_spent_shared_pool_sheds_a_busy_peer_while_a_quiet_one_is_still_served uses global_ceiling: 8,
per_peer_share: 5, reserve 8. Two busy peers take 5 each: 2 reserved units and 8 burst units = the
whole burst ceiling
. Then busy is refused (reserve PeerShare, then burst GlobalCeiling) while the
quiet newcomer is served from the reserve.

Under a single pool at those same limits the two peers hold 5 and 3, global is 8, and both the busy
peer and the quiet one receive the identical GlobalCeiling — so the second assertion fails. The test
genuinely discriminates placement, not price.

And it is the necessary complement to the Sybil test, which on its own is not immune: a single-pool
meter with global_ceiling raised to 1024 would let 8 identities hold 64 units and still admit the ninth
peer, so eight_sybil_identities_cannot_deny_an_honest_peer_under_the_shipped_limits would pass on a
mere price change. The two together are what make the fix load-bearing. The lane's reasoning here is
right.

Next: my own mutation probes, run independently rather than taking the lane's revert-proof on trust.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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

My first mutation run was a FALSE GREEN, and I am recording it because it nearly became evidence

I ran three mutations against head and all three reported 12 passed; 0 failed. Taken at face value that
would have said the new tests catch nothing — including my own round-1 global_ceiling: 1 mutation,
which the_shipped_admission_configuration_is_pinned asserts against directly. That conclusion would
have been completely wrong.

The mutations never applied. Every one died with:

FileNotFoundError: [Errno 2] No such file or directory:
'/c/tmp/worktrees/dn-digsex/crates/dig-node-core/src/seams/dig_peer/admission.rs'

The Windows python cannot open an MSYS /c/... path; it needs C:/.... So all three passes compiled
and ran the unmutated tree, and the reassuring 12 passed was a true statement about a measurement
that never happened.

I caught it only because the script was required to print a MUTATED_OK confirmation and I checked for
that line before reading the test result. The test result alone was indistinguishable from a real
negative. Re-running with a Windows path and a hard abort when the edit does not confirm; the verdict
will carry the corrected numbers.

The baseline from that run is still valid and confirms the lane's module count independently:

running 12 tests ... test result: ok. 12 passed; 0 failed; 0 ignored; 1055 filtered out
CARGO_RC=0

Worktree restored and verified between passes: git status --porcelain empty, HEAD still 3494ddf,
backup removed.


The finding you routed to me: the dig-sex ordering is NOT a defect, and changing it would buy zero

You asked whether working around dig-sex's global-before-per-peer ordering from outside is acceptable, or
whether the crate must change. My answer has three parts, and the first is a correction.

1. The ordering is not the cause of G1 and reordering cannot fix anything. AdmissionMeter::admit
returns Ok only if all five predicates pass. Reordering two refusal predicates cannot change the
admitted set — it changes only which Refusal variant is reported first. Checking per_peer_share
before global_ceiling would relabel an over-quota peer's refusal from GlobalCeiling to PeerShare
during saturation and admit not one additional request. So "the ordering is still wrong inside the
crate" is not a finding I can support, and a release-first cascade to change it would spend a cascade on
a cosmetic relabel.

What actually caused G1 was the policy: a single shared pool with no per-peer floor, whose Sybil cost
is global_ceiling / per_peer_share at every value. That is what this PR changed, and it changed it in
the right place.

One ordering in that function is load-bearing and should not be disturbed: MeterFull is checked
after PeerShare, so an over-quota peer cannot grow the table it is already exceeding. The crate says
so itself and it is correct.

2. relay_ceiling is vacuous in dig-NODE, not in dig-sex. The crate implements the SPEC 6.1.8 budget
correctly; nothing in dig-node ever constructs WorkKind::Relayed, so the budget is satisfied because the
case never occurs. Aiming a fix at the crate would be aiming it at the wrong repo. The gap is a dig-node
one — the relayed path admits as Own — and the code now says so at node_limits() and on the relayed
test, which is the honest disposition for this PR.

3. Working around it from outside is acceptable here, and is arguably the better layering — but the
mechanism belongs in the crate eventually.
The reserve's size is MAX_INFLIGHT_PEER_CONNECTIONS,
which is node knowledge dig-sex cannot have, so the sizing must stay node-local regardless. The
mechanism — a guaranteed per-peer floor — is a general admission policy, and dig-sex is the crate that
owns admission policy, so a second consumer would otherwise re-derive it (the rival-implementation rule).
Within this repo dig_sex::admission is consumed only by seams/dig_peer/admission.rs, and I found no
other declaring manifest at the ecosystem paths I checked — so there is no rival today and no urgency.

Recommendation: do not gate this PR on a dig-sex change. File a follow-up on dig-sex to offer the
floor as a parameterised policy (shaped like with_reserved_first_slots, taking the reserve size from the
caller), and explicitly close out the "ordering" half as not-a-defect so nobody spends a cascade on it
later. If you want that follow-up to carry my reasoning, points 1 and 2 above are the load-bearing parts.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security round 2 — IN PROGRESS (3/3) — four independent mutations, all caught

Re-run with a Windows path and a hard abort when the edit does not confirm. Every pass printed
MUTATED_OK before its run, so each of these measured a genuinely mutated tree. Exit 101 in all four
cases is a failed test, not a compile error — read from the output, which shows real panics and real
test result: FAILED lines with counts.

# mutation result caught by
baseline, unmutated head 3494ddf 12 passed; 0 failed; 1055 filtered out, RC=0
A node_limits().global_ceiling = 1 — my round-1 mutation 11 passed; 1 failed, RC=101 the_shipped_admission_configuration_is_pinned (admission.rs:643)
B burst per_peer_share un-decremented — the reserve becomes extra concurrency 9 passed; 3 failed, RC=101 the_reserve_does_not_widen_the_share_of_any_single_peer (:685), exhausting_one_peers_share_does_not_refuse_a_different_peer (:366), a_dropped_guard_returns_its_allowance (:391)
C RESERVED_FIRST_SLOTS = 64 — reserve no larger than the burst ceiling 11 passed; 1 failed, RC=101 the_reserve_is_never_scarcer_than_the_connections_it_serves (:662)
D reserved pool disabled (global_ceiling: 0) 8 passed; 4 failed, RC=101 + a_spent_shared_pool_sheds_a_busy_peer_while_a_quiet_one_is_still_served (:626)

A closes G2. The exact mutation that passed 1061 green tests in round 1 now fails. The shipped
configuration is pinned on all five dimensions.

B closes the "no extra concurrency" question you asked me to confirm — and it is guarded three times
over, not once. Turning the split into a share increase breaks the dedicated assertion and the two
round-1 tests, so a future reader cannot quietly widen a peer's share without three failures.

C confirms the derivation is load-bearing, not decorative. Sizing the reserve to the burst ceiling
instead of the connection cap fails immediately.

D independently reproduces the lane's revert-proof shape — 8 passed; 4 failed — from a different
revert than the lane used
, which is worth more than reproducing theirs: two different ways of removing
the reserve both land on 4 failures including the placement test.

And D confirms the lane's argument for why BOTH G1 tests are needed. Under D the Sybil test still
passes — 8 sybils can hold only 56 of the 64 burst units, so the ninth peer is admitted anyway. Only
a_spent_shared_pool_sheds_a_busy_peer_while_a_quiet_one_is_still_served catches it. That is the
empirical version of the claim that the Sybil count alone is satisfiable without the shape change, and it
holds.

Worktree restored and verified after every pass: STATUS_LINES=0, HEAD still 3494ddf, backup removed.

Full-suite confirmation of the 1067 count is running now; the verdict follows it.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security ROUND 2 VERDICT: PASS (security) — but the branch cannot merge yet

Audited head: 3494ddf23fe218e34938e44e8d38280a57d1bfad, resolved from the remote myself at the
start and re-confirmed after the last probe; it did not move. Scoped delta 6ed8d51..3494ddf. The
mirror/* churn in that range arrived from main via the merge of ff62f55 (#469) and is not this
lane's work; the lane's own delta is admission.rs + peer.rs.

Both round-1 GATING findings are genuinely fixed. I did not take the lane's revert-proof on trust —
I ran four independent mutations of my own, and one of my first three runs was a false green that I had
to catch and redo (below). No new security defect in the delta.


G1 — CLEARED. The fix is right in kind, and I checked the reasoning before the code

The lane declined to raise global_ceiling and changed the shape instead. That was the correct call and
it is the part I would have had to disagree with to reject this, so I verified it first.

With one shared counter, refusing an honest peer requires filling global_ceiling, and one identity
contributes at most per_peer_share — so the Sybil cost is ceil(global_ceiling / per_peer_share) at
every value of the ceiling. Since an identity is one keypair, that count is never a defence. The
ceiling would have to exceed the node's whole stream capacity — MAX_INFLIGHT_PEER_CONNECTIONS x
MAX_INFLIGHT_STREAMS_PER_CONNECTION = 512 x 64 = 32,768 — before the connection cap became binding
instead, by which point the ceiling sheds nothing. Raising the number could not have fixed it. The
claim holds.

The three things you asked me to confirm specifically:

  • The reserve grants no extra concurrency. with_reserved_first_slots (admission.rs:213-230) sets
    the reserve to per_peer_share: 1 and the burst pool to limits.per_peer_share.saturating_sub(1), so
    a peer's total is 1 + 7 = 8, unchanged. Verified by mutation B, which is guarded three times over.
  • Drop-before-write_framed is untouched. The only peer.rs change is threading conn_key into
    admission_refused for the S3 log. The guard binding and its scope are byte-identical, so the slot is
    still released when the handler returns and a peer that stops reading its socket still cannot hold one
    through backpressure.
  • a_spent_shared_pool_sheds_a_busy_peer_while_a_quiet_one_is_still_served genuinely discriminates.
    I worked the counterfactual: at its own limits (global_ceiling: 8, per_peer_share: 5) a single pool
    gives the busy peer and the quiet newcomer the identical GlobalCeiling, so the second assertion
    fails. It tests placement, not price. Confirmed empirically by mutation D.

The denial cost is now what the lane claims. Filling the reserve needs 512 distinct identities, because
its per-peer share is 1 — and each identity needs its own mTLS session, since conn_key derives from
that connection's client leaf. 512 identities means 512 connections, which is
MAX_INFLIGHT_PEER_CONNECTIONS exactly, so filling the reserve is the same act as exhausting the
connection cap
. Admission can no longer be the scarcer resource. RESERVED_FIRST_SLOTS is derived from
that constant rather than restated (admission.rs:106), and mutation C proves the derivation is
load-bearing.

Mechanically sound on the points that would bite later: dig-sex's admit mutates nothing on any error
path, so a failed reserve attempt costs nothing before the burst attempt (no double-charge);
AdmissionGuard carries a Tier and Drop returns the slot to the pool it came from
(admission.rs:130-145), so there is no two-sided leak; the two locks are never held simultaneously, so
there is no deadlock and no overcommit race — a race can only change which pool a unit lands in;
RequestTooLarge returns from the reserve without retrying burst (admission.rs:258-262), which keeps
the answer to an oversized request independent of node load; and the new log line uses str::get, which
returns Option rather than panicking on a short or empty key.

One direction worth stating so nobody "re-tightens" it later. The node's concurrent capacity on these
two paths is now up to RESERVED_FIRST_SLOTS + global_ceiling = 576, against 64 after round 1 and
effectively unbounded before this PR. So the PR still reduces concurrency versus main — it just no
longer reduces it below the point a Sybil can weaponise. That is the right trade, and a future change
that shrinks the reserve toward the burst ceiling would reintroduce G1; mutation C is what stops it.

G2 — CLEARED. My round-1 mutation now fails

the_shipped_admission_configuration_is_pinned (admission.rs:643) pins all five dimensions, and
the_reserve_is_never_scarcer_than_the_connections_it_serves (:662) pins the reserve relation.

My four independent mutations — all caught

Each pass printed a MUTATED_OK confirmation before running, with a hard abort if the edit did not
apply. Exit 101 is a failed test in all four cases, not a compile error — read from the output, which
shows real panics and real test result: FAILED counts.

# mutation result caught by
baseline at 3494ddf 12 passed; 0 failed; 1055 filtered out, RC=0
A node_limits().global_ceiling = 1 (my round-1 mutation) 11 passed; **1 failed**, RC=101 the_shipped_admission_configuration_is_pinned
B burst per_peer_share un-decremented 9 passed; **3 failed**, RC=101 the_reserve_does_not_widen_the_share_of_any_single_peer, exhausting_one_peers_share_does_not_refuse_a_different_peer, a_dropped_guard_returns_its_allowance
C RESERVED_FIRST_SLOTS = 64 11 passed; **1 failed**, RC=101 the_reserve_is_never_scarcer_than_the_connections_it_serves
D reserved pool disabled (global_ceiling: 0) 8 passed; **4 failed**, RC=101 + a_spent_shared_pool_sheds_a_busy_peer_while_a_quiet_one_is_still_served

D independently reproduces the lane's 8 passed; 4 failed from a different revert than the lane
used
, which is worth more than reproducing theirs — two different ways of removing the reserve both
land on four failures including the placement test.

And D empirically confirms why BOTH G1 tests are needed. Under D the Sybil test still passes: 8
sybils can hold only 56 of the 64 burst units, so the ninth peer is admitted anyway. Only the placement
test catches it. That is the lane's argument, and it is correct.

Counts confirmed independently at head: module 12 passed; 0 failed; 1055 filtered out; full lib
1067 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out, 553.65s, CARGO_RC=0, run unpiped
with the status read from the file.

S3 / S4 / S5 — all three addressed, verified

  • S3 — the refusal log now names the peer, truncated to a 16-hex prefix (peer.rs:1250-1258), and
    in_flight_total()'s doc no longer claims an operator surface that nothing renders
    (admission.rs:288-294). reserved_in_flight() is still test-only, but its doc does not overclaim.
  • S4node_limits() now states that relay_ceiling is satisfied because the case never occurs,
    and the relayed test says it is the only producer of WorkKind::Relayed in the crate. That is the
    honest disposition.
  • S5 — the body correction is right, including the reason: the table is pruned at zero, and
    unreachability comes from the pool ceilings being hit first.

NOT security, but the branch cannot merge in this state — two required checks are RED

I am not gating on these; they block independently and the orchestrator merges only on all-checks-green.
Naming them so a security PASS is not misread as "ready".

1. Check version increment — FAIL (run 33531694143). Read from disk at head, not from the rollup:

root Cargo.toml dig-node-core
origin/main (45ecbc8) 0.223.0 0.65.0
head 3494ddf 0.223.0 0.67.0

The root versions are equal, not incremented. git merge-base --is-ancestor origin/main HEAD is
false — head is behind main, whose tip 45ecbc8 (#470) landed after this branch merged main at ff62f55
(0.222.0) and took the same 0.223.0. This is exactly the S7 hazard I flagged in round 1, now realised.
Root needs 0.224.0 after the next merge of main; dig-node-core 0.67.0 over main's 0.65.0 is fine.

Re-bump after the merge and re-read the version from the file on disk, not from the commit log — a
rebase can silently drop a bump commit whose patch is already upstream.

2. Lint commit messages — FAIL (run 33531694229). Commit dcbf40e is
wip: G1 two-pool admission + G2 shipped-config tests; wip is not in the type-enum. That is the G2
revert-proof, correctly committed first — it just needs rewording to a valid type.


The finding you routed to me: the dig-sex ordering is NOT a defect

1. Reordering cannot fix anything. AdmissionMeter::admit returns Ok only if all five
predicates pass, so reordering two refusal predicates cannot change the admitted set — only which
Refusal is reported first. Checking per_peer_share before global_ceiling would relabel an
over-quota peer's refusal during saturation and admit not one additional request. I cannot support
"the ordering is wrong inside the crate", and a release-first cascade to change it would spend a cascade
on a cosmetic relabel. The real cause of G1 was the policy — one shared pool with no per-peer floor —
and this PR fixed it in the right place.

One ordering there is load-bearing and must not be disturbed: MeterFull is checked after
PeerShare, so an over-quota peer cannot grow the table it is already exceeding.

2. relay_ceiling is vacuous in dig-NODE, not in dig-sex. The crate implements SPEC 6.1.8 correctly;
dig-node simply never constructs WorkKind::Relayed. A fix aimed at the crate would be aimed at the
wrong repo.

3. Working around it from outside is acceptable, and is arguably the better layering. The reserve's
size is MAX_INFLIGHT_PEER_CONNECTIONS — node knowledge dig-sex cannot have — so the sizing stays
node-local regardless. The mechanism (a guaranteed per-peer floor) is general admission policy and
belongs in dig-sex eventually, parameterised exactly as with_reserved_first_slots is. Within this repo
dig_sex::admission is consumed only by seams/dig_peer/admission.rs, and I found no other declaring
manifest at the ecosystem paths I checked — so there is no rival today and no urgency.

Recommendation: do not gate this PR on a dig-sex change. File a follow-up offering the floor as a
parameterised policy, and explicitly close the "ordering" half as not-a-defect so nobody spends a cascade
on it later.

A false green I had to catch in my own work

My first mutation run reported 12 passed; 0 failed for all three mutations — which would have said the
new tests catch nothing, including the config pin that demonstrably fails. The mutations never applied:
Windows python cannot open an MSYS /c/... path and every edit died with FileNotFoundError, so all
three passes compiled and ran the unmutated tree. The reassuring count was a true statement about a
measurement that never happened. I caught it only because the script had to print MUTATED_OK and I
checked for that line before reading the test result. Recording it because the failure mode is invisible
from the test output alone, and the same trap will hit the next agent that scripts a probe here.

Residual, non-blocking

per_peer_share: 0 would be the one config where the reserve grants more than the nominal share (1 vs
0), since saturating_sub(1) floors the burst share at zero. Unreachable in production — the shipped
value is 8 and is now pinned — so this is a note, not a finding.

Shared-state disclosure

Worked in C:\tmp\worktrees\dn-digsex. For each mutation I edited admission.rs in place after copying
it to a backup outside the repo, and restored by file copy — never git checkout, reset or
stash. Verified after every pass and at the end: git status --porcelain empty, HEAD still
3494ddf23fe218e34938e44e8d38280a57d1bfad, backups removed, no stash created (the three present are
pre-existing and belong to other branches), no local commits, nothing pushed.

What I did not re-check

cargo clippy and cargo fmt at this head — the lane reports both clean and that belongs to the
correctness gate. I also did not re-audit the mirror/* code that arrived from main; it is #469's and
was gated there.


Verdict: PASS on security. G1 and G2 are genuinely fixed, the fix is right in kind rather than a
price change, and I proved the guards load-bearing with four mutations of my own. Do not merge yet
Check version increment and Lint commit messages are red, and the version collision is the round-1 S7
hazard landing exactly as predicted.

MichaelTaylor3d and others added 6 commits September 1, 2026 10:29
Co-Authored-By: Claude <noreply@anthropic.com>
…ntities cannot deny the node

Gate G1 on #456. dig-sex AdmissionMeter::admit tests the node-wide ceiling BEFORE the
per-peer share against one shared counter, so the identities needed to deny every peer
was global_ceiling / per_peer_share -- eight, at the shipped defaults. A peer_id is
SHA-256 of a self-signed TLS SPKI, so eight identities cost eight keypairs, each staying
inside its own share so the per-peer limiter never fired. The node-wide ceiling (64 for
both read paths combined) was also lower than the 64 streams a SINGLE connection was
previously allowed, and 32 honest 65s forwarded asks alone consumed half of it.

Raising the ceiling only raises the price. This changes the shape: a peer's FIRST
concurrent unit is charged to a reserve pool whose per-peer share is exactly one and
whose size is MAX_INFLIGHT_PEER_CONNECTIONS, derived rather than restated. Denial now
costs one identity AND one held connection per slot -- linear, and bounded by the
connection cap the node already enforces rather than by a number 8x below it. A peer's
total concurrent share is unchanged; only the pool its first unit comes from.

Relayed work goes straight to the shared pool, keeping the SPEC 6.1.8 relay budget in one
place: work done on another node's behalf is what a loaded node should shed first.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
# Conflicts:
#	Cargo.lock
#	Cargo.toml
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-reviewer (correctness) — IN PROGRESS, not the verdict

Audited head 9a503ec701996026dca999e0c0792281321a5d55, resolved from the remote myself. PR left DRAFT; nothing edited, nothing merged. gitnexus is unavailable this session (MCP connect timeout) and the registered dig-node index is ~300 commits stale anyway, so the blast radius below is grep + direct read of git objects, stated rather than implied.

1. Security re-gate: NOT needed — the post-PASS delta is provably empty of lane code

loop-security round 2 PASSed at 3494ddf. The commits that appear after it in the log (2746100 G1 two-pool admission, 0bf6ada reserved slot, 66ff794 rustfmt) are rewritten copies of the same content, not new work:

git diff --stat 3494ddf 66ff794   -> (empty)

The two trees are byte-identical. The only content in 3494ddf..9a503ec that is not a rewrite is the merge of origin/main (#455, #470, #480, #484, #487, #488 — each gated on its own PR) and the version bump. No security re-gate trigger.

2. Net diff against main is small and confined

merge-base = aa9ec72; 10 files, +1272/-13: admission.rs (new, 696), conduct.rs (+320), peer.rs (+168), download.rs, lib.rs, two mod/manifest lines, Cargo.lock.

3. Version

Cargo.toml = 0.238.0, main = 0.235.0. Increments, and leaves room for the planned #467 (0.236.0) and #494 (0.237.0) ahead of it.

4. §2.4b — dig-sex is at latest

index.crates.io/di/g-/dig-sex (with the required User-Agent) ends at 0.5.0; crates/dig-node-core/Cargo.toml:509 declares "0.5". Current.

Still open: the two 08-31 gating findings verified against code at this head, #268/#269 acceptance, and a revert-proof of the reserved-admission-slot test.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-reviewer — IN PROGRESS (2/3), not the verdict — head 9a503ec

5. Both 08-31 gating findings are genuinely FIXED, not merely resolved

  • F1 (the 2x limit disagreement). node_limits() now DERIVES the clamp instead of restating it — admission.rs:193 max_request_units: crate::MAX_AVAILABILITY_ITEMS as u32, with lib.rs:316 holding the single 512. Two tests pin it from both ends: admission.rs:456-466 asserts the derivation AND that it is strictly greater than the dig-sex default (so a silent revert to 256 fails), and peer.rs:5571-5600 exercises it at the RESPONDER level — 512 answered in full, 513 refused. The old lib.rs:11010 in-process truncation test is kept and relabelled. This is the fix the finding asked for, not a deletion of the clamp.
  • F2 (the falsified pub doc). peer.rs:1036-1048 rustdoc and peer.rs:1053-1058 inline comment now both state the post-Wire dig-sex admission (SPEC 8.5) into the inbound path, metered by authenticated identity #269 rule — a caller-less session serves range/module-range only; JSON-RPC and availability refuse -32000 unauthenticated. The superseded phrasing does not survive anywhere I could find it.

6. The reserved-slot property tests name the PROPERTY, not an outcome

admission.rs:528 asserts the HONEST ninth peer holding zero work is admitted while 8 sybils hold their full share — the load-bearing half, and the module's own doc says why asserting only "the sybils were refused" would pass on the defective meter. admission.rs:553 bounds denial cost BELOW by the reserve. admission.rs:602 states the shape separately (a busy peer sheds before a quiet one). RESERVED_FIRST_SLOTS is derived from MAX_INFLIGHT_PEER_CONNECTIONS (admission.rs:108) with a drift test at :661. A revert-proof run is in flight; result in the verdict.

7. #268 is wired but its dial-budget half is VACUOUS — and the PR says so

ConductState::observe fires at download.rs:2010 (HonestAnswer / NonPerformance only) and dialable() gates the pool at download.rs:1900. ProvenLie and SelfContradiction have no producer, so — as the PR body states at line 40-45 — dialable(pool) ≡ routable(pool) element for element today, and the ranking is still ask_routing.decide, not dial_share. The disclosure is exactly right and the architectural reason (per-peer attribution lives inside dig-download) is real. The problem is only the CLOSING keyword: #268 asks for dial_share to shape the dial budget, and it does not yet.

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

loop-reviewer (correctness) — CHANGES-REQUIRED

Audited head: 9a503ec701996026dca999e0c0792281321a5d55, resolved from the remote myself. PR left DRAFT; nothing merged, nothing edited. gitnexus MCP was unavailable this session (connect timeout) and the registered dig-node index is ~300 commits stale, so the blast radius came from grep + direct read of git objects, stated rather than implied.

ONE gating finding, posted as an inline thread: SPEC.md was not updated for the new wire-visible admission bound on the peer surface (§4.2).

Everything else I checked held — evidence in the two interim comments above: both 08-31 findings are genuinely FIXED rather than merely resolved (a derivation instead of a restatement, a responder-level test pinning 512-answered / 513-refused, and the superseded doc phrasing swept off the pub item), the admission tests name the PROPERTY rather than an outcome, the post-security-PASS delta is a byte-identical tree, and dig-sex is at its latest published version.

Comment thread crates/dig-node-core/src/seams/dig_peer/admission.rs
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-reviewer — VERDICT: CHANGES-REQUIRED

Audited head: 9a503ec701996026dca999e0c0792281321a5d55 (resolved from the remote at 01:31Z and again at the end; it did not move). PR left DRAFT. Nothing merged, nothing edited, no git identity set. gitnexus MCP was unavailable this session and its dig-node index is ~300 commits stale, so the radius is grep + direct read of git objects.

Gating (1)

§4.2 — SPEC.md still describes the peer surface as it was before this PR. Inline thread on admission.rs:193. SPEC.md:3231 enumerates "Three bounds govern the path" for dig.getAvailability; there are now four and the new one runs first. The two new wire-visible refusals (-32000 unauthenticated, -32000 request too large) and the reserved-slot guarantee are unrecorded, and the 513-item answer changed from a truncated prefix to a whole-batch refusal without the SPEC saying so. Peer-observable refusals are contract, not implementation detail.

Non-gating (1) — resolved by me, does not block

Closes #268 overstates what shipped, and the PR body already says why. observe fires at download.rs:2010 and dialable() gates the pool at download.rs:1900, but only HonestAnswer/NonPerformance have producers, so dialable(pool) ≡ routable(pool) element for element and ranking is still ask_routing.decidedial_share does not yet shape the dial budget, which is #268's stated ask. The disclosure (PR body lines 40-45) and the architectural reason (per-peer verification attribution lives inside dig-download, release-first) are both correct and honest. Suggest narrowing #268's closing keyword to a bounded child ("dial_share ranks the dial budget once dig-download surfaces per-peer attribution") rather than closing it as met. Not a blocker under §2.6.

Verified — held at this head

  • 08-31 F1 fixed, not merely resolved. admission.rs:193 derives max_request_units from crate::MAX_AVAILABILITY_ITEMS (lib.rs:316 = 512) instead of restating it; admission.rs:456-466 asserts the derivation AND that it exceeds the dig-sex default, so a silent revert to 256 fails; peer.rs:5571-5600 exercises it at the RESPONDER level (512 answered in full, 513 refused), which is the level my earlier finding said the suite could not see.
  • 08-31 F2 fixed. peer.rs:1036-1048 (rustdoc on the pub item) and :1053-1058 now state the post-Wire dig-sex admission (SPEC 8.5) into the inbound path, metered by authenticated identity #269 rule; the superseded phrasing survives nowhere I could find it.
  • Test vacuity on the reserved slot — the tests discriminate. admission.rs:528 asserts the honest ninth peer holding ZERO work is admitted while 8 sybils hold their full share, and the module doc states why asserting only "the sybils were refused" would pass on the defective meter. Arithmetic on the shipped numbers (dig-sex 0.5.0 AdmissionLimits::default = global_ceiling: 64, per_peer_share: 8): the single-pool revert gives 8x8 = 64 = ceiling, so the ninth peer is refused GlobalCeiling and the assertion fails. :553 bounds denial cost below by the reserve; :602 states the shed-order property separately; :661 pins RESERVED_FIRST_SLOTS == MAX_INFLIGHT_PEER_CONNECTIONS (512) against drift.
    Disclosure: I could NOT execute the mutation. cargo test -p dig-node-core --lib admission in my worktree dies in aws-lc-sys's cmake build script (a local native-toolchain failure, unrelated to this diff; CI is green). The revert-proof above is analytic, and I say so rather than implying a run. My first mutation attempt (with_reserved_first_slots(limits, 0)) would ALSO have been a false negative even had it built — it drops per_peer_share to 7 in the burst pool, so 8x7 = 56 < 64 and the honest peer is still admitted. The correct revert is the single-pool meter, not a zero reserve. Worktree file restored; git status --porcelain clean.
  • relay_ceiling vacuity is disclosed in the code (admission.rs:181-189), which is the right instinct — nothing constructs WorkKind::Relayed.
  • §2.5. admission.rs reads cleanly: intent-revealing names, WHY-comments that state the attack rather than the mechanism, doc-comments on every public item, and tests whose rustdoc names the property each one proves. No findings.
  • §2.4b. dig-sex declared "0.5" (crates/dig-node-core/Cargo.toml:509); index.crates.io/di/g-/dig-sex ends at 0.5.0. Current. No other dig-*/chia-* declaration is touched by this PR.
  • Version. Cargo.toml = 0.238.0 vs main 0.235.0; increments and leaves room for feat(mirror): verify a peer's mirror-coin bond against chain on the download path #467 (0.236.0) and fix(rpc): source the shell error catalogue from dig-rpc-protocol so -32004 carries one name #494 (0.237.0) ahead of it. Minor is right — the signature is unchanged and nothing is removed; the behaviour change (an unauthenticated caller-less session loses two paths) is stated in the PR body's SemVer section.

Security re-gate: NOT needed

loop-security round 2 PASSed at 3494ddf. The three commits that appear after it (2746100, 0bf6ada, 66ff794) are rewritten copies of the audited content, not new work: git diff --stat 3494ddf 66ff794 is EMPTY — the two trees are byte-identical. The remainder of 3494ddf..9a503ec is the merge of origin/main (#455, #470, #480, #484, #487, #488, each gated on its own PR) plus the version bump. No lane-authored code entered the diff after the security PASS.

MichaelTaylor3d and others added 2 commits September 1, 2026 20:49
The peer surface gained an inbound admission meter (dig-sex SPEC 8.5,
dig-node#269) that runs in FRONT of the per-requestor miss budget, and
SPEC 10.4 still enumerated three bounds. An enumeration is exactly the
shape that goes false silently: a reimplementer reading the section could
not see the new bound, nor the two wire-visible refusals it can answer.

States the INVARIANT rather than transcribing the implementation: a
bounded number of free identities must not be able to deny the peer
surface to everyone else, so denial now costs one identity AND one held
connection per slot. Records the two refusal reasons (unauthenticated,
request too large), the change from a truncated 512-item prefix to a
whole-batch refusal past MAX_AVAILABILITY_ITEMS, and marks the relay
ceiling VACUOUS rather than listing it as an active rule.

Co-Authored-By: Claude <noreply@anthropic.com>
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/dig-node-core/Cargo.toml
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

closingIssuesReferences now resolves to 269 only — that is deliberate and it stays

Checked because the value previously read 268,269. The change was made by this lane's own body edit, not lost to a parser: the body says so in as many words — "Refs #268 — conduct (SPEC 8.2A) — deliberately NOT a closing keyword". Refs is not a closing keyword, so GitHub resolves 269 alone. Nothing to restore.

And it should not be restored, because #268's acceptance bar is not met. #268 asks that conduct observations "let dial_share shape which peers the node spends its dial budget on". Measured on 724cfed, not taken from the body:

  • ConductState::dial_share's only non-test consumer is the > 0.0 filter in conduct.rs:108.
  • dig_sex::dial_share floors a non-performing peer at MIN_NON_PERFORMANCE_DIAL_SHARE = 0.1 — deliberately, so induced distress cannot become an eviction primitive.
  • The two classes that can reach 0.0, ProvenLie and SelfContradiction, are constructed nowhere outside tests: every hit is in conduct.rs's own test module, and download.rs:2029 states in the production path why neither is synthesised there (per-chunk verification attribution lives inside dig-download, and a whole-capsule mismatch names no single peer, so manufacturing a ProvenLie from a transport error would brand an honest peer on unverifiable evidence).

So dialable(pool) is element-for-element equal to routable(pool) today. The record is genuinely maintained and the exclusion path is correct and tested — it is vacuously satisfied, and a closing keyword would report a capability the node does not have. #268 stays OPEN for the bounded residue: a dig-download change, release-first, that surfaces the per-peer verification verdict so a verifiable-fault producer exists.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-reviewer — SCOPED re-gate, IN PROGRESS (not the verdict)

Auditing head ca28faf9910317843aee76f35211acc6160f8212, resolved from the remote myself (gh pr view 456 --json headRefOid). PR left DRAFT; nothing edited, nothing merged, no git identity set. gitnexus AND socraticode MCP both failed to connect this session (CONNECT_TIMEOUT), so the radius below is ripgrep + direct read of git objects — stated rather than implied.

Delta 9a503ec..ca28faf, lane content isolated

Nine commits, of which one is lane-authored and the rest are merge-from-main content already gated on their own PRs:

Confirmed so far

Still open in this pass: the SPEC 10.4.0 text verified against the shipped code, and proof the SPEC.md conflict resolution lost no main-side hunk. Verdict follows.

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

loop-reviewer — VERDICT: PASS (scoped re-gate)

Audited head: ca28faf9910317843aee76f35211acc6160f8212, resolved from the remote myself. PR left DRAFT; nothing merged, nothing edited, no git identity set. gitnexus AND socraticode MCP both failed to connect this session (CONNECT_TIMEOUT), so the radius is ripgrep + direct read of git objects.

1. The delta is ONE lane commit; all previously-verified code is byte-identical

9a503ec..ca28faf is nine commits, eight of which are merge-from-main content already gated on their own PRs (ec87de4f v0.236.0, 2098dbc6 #467, f1170d08 #489, adf03d81 #490/#492, 33ab0826 #497, plus three merges). The single lane commit is 6146ab77 docs(spec): record the two-pool peer admission bound in SPEC 10.4.0.

Measured, not assumed: git diff 9a503ec:<f> ca28faf:<f> is EMPTY for both admission.rs and conduct.rs. The only peer.rs change in the delta is a main-side doc correction about dig-gossip peer-id pinning (DIG-Network/dig-gossip#85), unrelated to this lane. So the 02:17Z "Verified" block — F1, F2, the reserved-slot discrimination, §2.5, §2.4b — carries forward unchanged, and no security re-gate is triggered.

2. The gating finding is FIXED

§4.2 SPEC backfill — fixed at SPEC.md:3231-3276. Verified against code, not against the thread:

  • The enumeration is corrected: SPEC.md:3231 now reads "FOUR bounds govern the path, and the first of them (10.4.0) runs in FRONT of the per-requestor budget of 10.4.1", replacing the "Three bounds" line the finding named.
  • All six wire-visible refusal reasons match the code exactly. SPEC lists unauthenticated, request too large, node at capacity, peer at capacity, relay budget exhausted, meter full; admission.rs:65-70 produces those six literals and no others. The envelope matches too — SPEC says -32000 / message: "request refused" / data.reason, and peer.rs:1269 emits precisely that shape.
  • The reserve numbers are derived, not transcribed. SPEC states RESERVED_FIRST_SLOTS = MAX_INFLIGHT_PEER_CONNECTIONS (= 512) and a reserve per_peer_share of exactly 1; admission.rs:108 derives the constant from crate::peer::MAX_INFLIGHT_PEER_CONNECTIONS (peer.rs:155 = 512) and admission.rs:221 sets per_peer_share: 1, with :229 subtracting that one unit from the shared pool so total per-peer concurrency is unchanged — exactly as the SPEC says.
  • The whole-batch refusal is recorded and the clamp is stated as an equality. SPEC requires the clamp equal the advertised batch size and a batch AT 512 be answered in full; admission.rs:193 sets max_request_units: crate::MAX_AVAILABILITY_ITEMS (lib.rs:317 = 512).
  • The normative property is stated as a PROPERTY, not an outcome"a bounded number of free identities MUST NOT be able to deny the peer surface to everyone else", with the cost floor left as a MUST-NOT (denial no cheaper than one held connection per denied slot) and the numbers explicitly MAY. That is the right shape for a reimplementer.
  • The relay ceiling is marked VACUOUS rather than listed as an active rule, matching admission.rs:181-189. A spec that could not distinguish satisfied from vacuously-satisfied is the failure mode §1.3c names; this one distinguishes it in as many words.

3. The SPEC.md conflict resolution lost NOTHING from main

Decisive: git diff origin/main:SPEC.md ca28faf:SPEC.md is 46 insertions, 1 deletion — and the single deleted line is the superseded "Three bounds govern the path:" sentence, replaced in place. Every main-side hunk is present; the conflict resolution is additive apart from that one intended replacement.

4. Version

Cargo.toml:35 = 0.247.0 (main 0.245.0; #494 at 0.246.0 merges ahead — no collision). Cargo.lock:3034 dig-node-service = 0.247.0, agreeing. There is no package literally named dig-node in the lock; dig-node-service is the root binary crate, so that pair is the whole workspace-version surface.

5. Closing keywords

closingIssuesReferences parses as 269 only — the expected value. #268 remains Refs (not a closing keyword) and stays open for the bounded dial_share residue, per the 04:00Z analysis.

Non-gating (1) — resolved by me, does not block

SPEC.md:3233 says the meter runs "BEFORE the request is read, decoded or dispatched"; the first two are loose. By the time NodeResponder::handle_json_rpc is entered the frame has already been parsed into a serde_json::Value by the transport, and method/id are read at peer.rs:1435-1436 before admit at :1443. The code comment is the more accurate statement of the same fact ("ahead of the allowlist and every dispatch below, so a refused request costs a hex decode and a counter bump rather than a read, a decode or a DHT lookup" — i.e. body/content read, not frame parse). Every normative MUST in 10.4.0 is accurate — the ordering against 10.4.1, the codes, the six reasons, the whole-batch refusal, the reserve property — so this is a precision nit on a supporting clause, not a false contract claim. Worth tightening to "before the request is dispatched or its content read" on the next touch of this section.

Gate state

3 review threads, 0 unresolved. check-merge-preconditions.sh --allow-draft run UNPIPED: RESULT: preconditions MET [EXIT 0], all five required contexts SUCCESS by name (Commit lint, Rustfmt, Clippy, Test + coverage, Release-script tests).

Scope disclosure (§1.10): I did not re-run the test suite. The lane's code is byte-identical to the head where the reserved-slot tests were already analysed, the only new content is documentation, and CI is green on ca28faf — so a suite run would measure nothing this delta changed. Saying so rather than implying a run.

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

Copy link
Copy Markdown
Contributor Author

Branch head restored to 43215dcc (orchestrator, 2026-09-02T10:5xZ).

At 10:46:49Z a force push moved this branch to 61529b4c — a 2026-09-01 state at version 0.230.0 that discarded the reviewed head ca28faf9 (PASS at 10:3xZ) and the fresh merge of main. Root cause: the stale local loop/batch-digsex ref was still checked out in a dead lane's worktree (dn-digsex, untouched since 09-01), so the merge-main tweaker could not check the branch out, committed its merge on the sibling branch, and the push that followed carried the stale ref.

Restored with --force-with-lease on 61529b4c to 43215dcc = ca28faf9 + merge of origin/main (b9979d8, 0.246.0), version kept at 0.247.0; the Cargo.lock delta against main is only the workspace version lines. The dead worktree is removed. Merge proceeds once checks on 43215dcc are green.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 2, 2026 11:14
@MichaelTaylor3d
MichaelTaylor3d merged commit 476055f into main Sep 2, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/batch-digsex branch September 2, 2026 11:14
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.

Wire dig-sex admission (SPEC 8.5) into the inbound path, metered by authenticated identity

1 participant