feat(digsex): wire the dig-sex economic layer into the node - #456
Conversation
|
IN PROGRESS — not the verdict. Gate on head Confirmed so far:
Still open in this review: the |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
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
peer.rs:1423—max_request_units(256, dig-sex default) refuses availability batches of 257-512 items that this crate's ownMAX_AVAILABILITY_ITEMS = 512documents and tests as served, truncated. Wire-visible narrowing; the existing 513-item test callsavailability_batchbelow the responder so the suite cannot see it; two doc claims become false. Do not fix by deleting the clamp.peer.rs:1351— the admission gate makesserve_peer_session's own doc (peer.rs:999-1001) false for the JSON-RPC and availability paths on apubAPI, 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-sexyields 0.3.0 / 0.4.0 / 0.5.0;Cargo.tomldeclares"0.5". #287's ticket body was stale and the close was justified on that point;dig_sex::acquisition::decidefor #270 is genuinely live atcapsule_store.rs:587. - #269's meter key cannot be influenced by an unauthenticated caller. Every path reaches
admitwithcaller.map(|c| c.peer_id)(peer.rs:1139/1151/1161/1172);callercomes only fromcaller_from_tls(peer.rs:3324-3332, SHA-256 of the verified client leaf SPKI) orrelayed_caller_contactoff the mTLS-verifiedPeerConnection.peer_id(peer.rs:2371). Never a wire field. Refusal is reachable, not theoretical, andAdmissionGuard: Dropreleases on every path including panic. - No lie is synthesised from a transport error. Grepping every
.rsin the crate at this head:ConductEvidence::appears in production at exactly two lines,download.rs:1975and:1978, bothHonestAnswer/NonPerformance.ProvenLieandSelfContradictionappear only inconduct.rstests. 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-sexconduct.rs:116/194), so a> 0.5threshold drops theslowcontrol at 0.1 whilequiet(1.0) and theliarexclusion (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-2803documents 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 cites2740-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_sharehas exactly one consumer — the> 0.0filter indialable(conduct.rs:104-110) — and neitherHonestAnswernorNonPerformancecan ever produce0.0, becausedig_sex::dial_sharefloors non-performance at 0.1. So in productiondialableis identicallyroutableand 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:1978scoresAskOutcome::RefusedasNonPerformance, 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::MeterFullis unreachable under the node's defaults, and that is good news.releaseremoves a peer's entry at zero (dig-sexadmission.rs:175-181), soin_flight.len()is bounded byglobal(64) which is far belowmax_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 isGlobalCeiling(8 identities xper_peer_share8 = 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 --librun is 632s per the body). The revert-proofs and the test-count claim are therefore checked by reading, not by execution. The body's1037 passed; 0 filtered outis 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.rsandconduct.rs.
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.
b2e6a81 to
caf4831
Compare
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.
Lane update — conflicts resolved, rebased onto main via mergeOutcome 1: still needed and distinct. §2.0 already-shipped check against State
One thing worth flagging to the merge orderThis PR changes §2.4b dependency freshness
Next action: wait for CI on |
|
Local verification on the merged head 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. |
|
Head moved —
Next action: watch CI on |
loop-security — IN PROGRESS, not the verdict (PR #456, head
|
loop-security — IN PROGRESS, not the verdict (2/3) — S1 is now PROVEN BY EXECUTIONHead The S1 exploit, executedI did not take the claim on a read. I built a standalone crate in lane-private scratch that depends Real counts — What the five assertions establish, and note that they are one-sided in the way that matters
So S1 is not a modelling argument. Under the limits this PR ships, eight free identities take the entire Test counts at head — real, and adequate in number14 in the two new modules + 2 new responder-level tests in
Related: Corrections to the recordThe mux availability leg's refusal shape is safe, and I checked it rather than assuming. Success is The forwarded-ask leg is likewise correct: NextMutation probe: I am about to set |
loop-security VERDICT: CHANGES-REQUIREDAudited head: Blast radius was established by grep + direct read, not gitnexus: the registered dig-node index is No money finding. I traced every path this PR introduces: it moves no $DIG and no XCH, builds no Two GATING findings. Both are about the same thing: the shipped limits are wrong, and nothing G1 — HIGH, LIVE — 8 free identities deny the node's entire peer read surface
Exploit — state, attacker action, impact.
Proven by execution, not by reading. I built a standalone crate depending only on The seven-identity case is the control: with 7 the honest peer is still served. The cost is pinned at Three reasons this is a regression rather than a limiter doing its job.
What must change (the property, not a number). A peer holding zero work in flight must not be Credit where due, so nobody "fixes" it away: the guard is dropped when the handler returns, before G2 — HIGH — the shipped configuration is completely unguarded, which is how G1 landed greenMutation probe, run by me at this head. I set Nothing failed. Real counts, The cause is precise: Worse, the ratio the tests do exercise is not the one that ships: Required: a test asserting the shipped Non-gating findingsS3 — MEDIUM (defense-in-depth) — the denial is invisible and unattributable to the operator
So the module's own stated design goal — S4 — MEDIUM (vacuity) — the relay budget does nothing, and this is not in the PR bodyThe body's vacuity section is genuinely good and I want to be clear it caught the conduct half correctly. But it misses one. A grep for
This is the S5 — LOW — two statements in the PR body are false in a way that would mislead the next readerThe body says Also worth recording: S6 — LOW (future, currently dormant) — the durable exclusion resets on reconnect
S7 — informational, merge-ordering hazard — FLAGGING ONLY, per the briefThe version collision with #467 is inverted, which is worse than a re-bump:
This PR takes the lower workspace version and the higher core version. And Areas checked and clear
What I could not reach
Shared-state disclosureI worked in Verdict: CHANGES-REQUIRED on G1 and G2. G2 is the cheaper of the two and is what stops this |
…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>
G1 + G2 addressed at
|
loop-security round 2 — IN PROGRESS, not the verdict (1/2)Head FIRST: the branch cannot merge as it stands, and one of the two red checks is the round-1 S7 hazard landingRead from disk at head, not from the rollup:
The root versions are EQUAL, not incremented. It is already red, so the gate caught it — Second red check, unrelated to security: Neither is a security finding and neither is my gate — flagging because a PASS from me would otherwise The "by construction" argument — I checked it, and it HOLDSThe claim is that with one shared counter the Sybil cost is
Since an identity is one keypair, the count is not a defence at any value. And the ceiling would have to The reserve does what it claims — verified by reading, mutations still to run
The discrimination claim is correct, and I worked the counterfactual
Under a single pool at those same limits the two peers hold 5 and 3, global is 8, and both the busy And it is the necessary complement to the Sybil test, which on its own is not immune: a single-pool Next: my own mutation probes, run independently rather than taking the lane's revert-proof on trust. |
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 evidenceI ran three mutations against head and all three reported The mutations never applied. Every one died with: The Windows I caught it only because the script was required to print a The baseline from that run is still valid and confirms the lane's module count independently: Worktree restored and verified between passes: The finding you routed to me: the dig-sex ordering is NOT a defect, and changing it would buy zeroYou asked whether working around dig-sex's global-before-per-peer ordering from outside is acceptable, or 1. The ordering is not the cause of G1 and reordering cannot fix anything. What actually caused G1 was the policy: a single shared pool with no per-peer floor, whose Sybil cost One ordering in that function is load-bearing and should not be disturbed: 2. 3. Working around it from outside is acceptable here, and is arguably the better layering — but the Recommendation: do not gate this PR on a dig-sex change. File a follow-up on dig-sex to offer the |
loop-security round 2 — IN PROGRESS (3/3) — four independent mutations, all caughtRe-run with a Windows path and a hard abort when the edit does not confirm. Every pass printed
A closes G2. The exact mutation that passed 1061 green tests in round 1 now fails. The shipped B closes the "no extra concurrency" question you asked me to confirm — and it is guarded three times C confirms the derivation is load-bearing, not decorative. Sizing the reserve to the burst ceiling D independently reproduces the lane's revert-proof shape — And D confirms the lane's argument for why BOTH G1 tests are needed. Under D the Sybil test still Worktree restored and verified after every pass: Full-suite confirmation of the 1067 count is running now; the verdict follows it. |
loop-security ROUND 2 VERDICT: PASS (security) — but the branch cannot merge yetAudited head: Both round-1 GATING findings are genuinely fixed. I did not take the lane's revert-proof on trust — G1 — CLEARED. The fix is right in kind, and I checked the reasoning before the codeThe lane declined to raise With one shared counter, refusing an honest peer requires filling The three things you asked me to confirm specifically:
The denial cost is now what the lane claims. Filling the reserve needs 512 distinct identities, because Mechanically sound on the points that would bite later: dig-sex's One direction worth stating so nobody "re-tightens" it later. The node's concurrent capacity on these G2 — CLEARED. My round-1 mutation now fails
My four independent mutations — all caughtEach pass printed a
D independently reproduces the lane's And D empirically confirms why BOTH G1 tests are needed. Under D the Sybil test still passes: 8 Counts confirmed independently at head: module S3 / S4 / S5 — all three addressed, verified
NOT security, but the branch cannot merge in this state — two required checks are REDI am not gating on these; they block independently and the orchestrator merges only on all-checks-green. 1.
The root versions are equal, not incremented. Re-bump after the merge and re-read the version from the file on disk, not from the commit log — a 2. The finding you routed to me: the dig-sex ordering is NOT a defect1. Reordering cannot fix anything. One ordering there is load-bearing and must not be disturbed: 2. 3. Working around it from outside is acceptable, and is arguably the better layering. The reserve's Recommendation: do not gate this PR on a dig-sex change. File a follow-up offering the floor as a A false green I had to catch in my own workMy first mutation run reported Residual, non-blocking
Shared-state disclosureWorked in What I did not re-check
Verdict: PASS on security. G1 and G2 are genuinely fixed, the fix is right in kind rather than a |
Co-Authored-By: Claude <noreply@anthropic.com>
# Conflicts: # Cargo.lock # Cargo.toml
…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>
3494ddf to
61529b4
Compare
# Conflicts: # Cargo.lock # Cargo.toml
loop-reviewer (correctness) — IN PROGRESS, not the verdictAudited head 1. Security re-gate: NOT needed — the post-PASS delta is provably empty of lane codeloop-security round 2 PASSed at The two trees are byte-identical. The only content in 2. Net diff against main is small and confined
3. Version
4. §2.4b —
|
loop-reviewer — IN PROGRESS (2/3), not the verdict — head
|
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
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.
loop-reviewer — VERDICT: CHANGES-REQUIREDAudited head: Gating (1)§4.2 — Non-gating (1) — resolved by me, does not block
Verified — held at this head
Security re-gate: NOT neededloop-security round 2 PASSed at |
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
|
# Conflicts: # Cargo.lock # Cargo.toml
# Conflicts: # Cargo.lock # Cargo.toml
loop-reviewer — SCOPED re-gate, IN PROGRESS (not the verdict)Auditing head Delta
|
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
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:3231now 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-70produces those six literals and no others. The envelope matches too — SPEC says-32000/message: "request refused"/data.reason, andpeer.rs:1269emits precisely that shape. - The reserve numbers are derived, not transcribed. SPEC states
RESERVED_FIRST_SLOTS=MAX_INFLIGHT_PEER_CONNECTIONS(= 512) and a reserveper_peer_shareof exactly 1;admission.rs:108derives the constant fromcrate::peer::MAX_INFLIGHT_PEER_CONNECTIONS(peer.rs:155= 512) andadmission.rs:221setsper_peer_share: 1, with:229subtracting 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:193setsmax_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>
ca28faf to
61529b4
Compare
|
Branch head restored to At 10:46:49Z a force push moved this branch to Restored with |
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@3e480ddbefore building:Cargo.toml:348pinsdig-sex = "0.2"; the file declaresdig-sex = "0.5"atcrates/dig-node-core/Cargo.toml:509.run_roundalready takesheld, populatesresidentfrom it, and is fed a real cache-derived set every round byNodeHeldCapsules::held_content_ids(tier0_live.rs:423). The acceptance bar is asserted as an outcome byan_already_held_candidate_is_not_refetched_while_an_unheld_one_still_is.dig_sex::acquisition::decideis called atseams/capsule/capsule_store.rs:587, with a control-plus-three-refusals test atlib.rs:14217. Left open for the gate to confirm; not rebuilt.dig-sex latest published is 0.5.0, not the 0.4 the tickets name (
index.crates.io/di/g-/dig-sex, with the requiredUser-Agent).Blast radius checked
.gitnexusis stale — the registered dig-node index is ~301 commits behind and would return a false-safeimpactedCount: 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) — gainsconduct+conduct_epochfields and a filter betweenroutableanddecide. The singleask_routing.recordcall site is the only writer; conduct is fed from the same place.RoutedPeer(ask_routing.rs) — gainsHashso it can key the conduct map. Additive; no existing behaviour reads it.Node(lib.rs) — gainspeer_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:
HonestAnswerandNonPerformanceare produced on every forwarded ask, so the conduct record is genuinely maintained.ProvenLieandSelfContradictionare produced nowhere.ConductState::dial_share's only non-test consumer isdialable()'s> 0.0filter, anddig_sex::dial_sharefloors a non-performing peer atMIN_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 futuredig-downloadattribution surface will read.The reason is a real architectural boundary, not an oversight: per-chunk verification attribution lives inside
dig-download's engine againstchunk_hashes(module_transport.rs:1587says so in as many words), and the node's ownDownloadErrorsurface offers onlyTransport/Sink/NotDownloadable. A whole-capsule mismatch (module_reshare.rs:1858) is assembled from several holders and names no single peer. Synthesising aProvenLiefrom 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 adig-downloadchange, release-first.Three further observations from the gate, folded in rather than fixed
Refused → NonPerformancemeans 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.MeterFullis 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 ismax_tracked_peers= 1024 entries andAdmissionMeter::releaseREMOVES 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.GlobalCeilingat 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 onDropand 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:2746maps an unreadable forwarding budget toremaining → None, whichdig_sex::discovery::decide_forward(discovery.rs:170) turns intoForwardRefusal::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 atdownload.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:
exhausting_one_peers_share_does_not_refuse_a_different_peerfails (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.> 0.0→> 0.5(above the non-performance floor): onlya_proven_liar_leaves_the_dial_set_while_a_merely_slow_peer_stays_in_itfails (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-*andchia-*declaration indig-node-corewas 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-core0.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-verifiedpeer_idand 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_fromwith 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-core— RC=0cargo clippy -p dig-node-core --all-targets -- -D warnings— RC=0, zero errorscargo fmt --check— RC=0cargo test -p dig-node-core --lib— 1037 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 printsrunning 0 tests, and a piped$?is the last command's.0 filtered outis 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 calledhandle_json_rpcwith an empty conn_key — started getting-32000instead 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:3306derives it from the client leaf viacaller_from_tls;peer.rs:2373builds a relayed caller). The publicserve_peer_sessiondoes passNone, 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, whileMAX_AVAILABILITY_ITEMS= 512, so a 257–512 itemdig.getAvailabilitybatch 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()derivesmax_request_unitsfromMAX_AVAILABILITY_ITEMSrather 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.rsresponder trait,lib.rs:3928) now say what the code does.The old 513-item test could not see any of this —
lib.rs:10273callsavailability_batchBELOW 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 reasonrequest 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 thepub-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 oncedig-downloadsurfaces 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::admitchecks thenode-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 sothe 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_shareis exactly 1 andwhose size is
RESERVED_FIRST_SLOTS=MAX_INFLIGHT_PEER_CONNECTIONS(derived, not restated). Denying anhonest 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_CONNECTIONallowance, and 32 honest 65s forwarded asks now consume 32 of 512reserved slots rather than half of 64.
The reserve grants no extra concurrency: a peer's total share is still
per_peer_share, with thefirst 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 thehonest 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 ofnode_limits(), not one.The gate's
global_ceiling: 1mutation now fails here.the_reserve_is_never_scarcer_than_the_connections_it_serves— asserted as the relation toMAX_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 anewcomer 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 passesOwn.node_limits()and the relayed test bothstate 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::releaseremoves an entry at zero, so the tracked-peer tableIS 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 statussurface 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).PeerAdmissionis constructed in 9 places, all::default()inlib.rs;admithas exactly 2 production call sites (peer.rshandle_json_rpc,handle_availability);node_limits,AdmissionGuardandin_flight_totalhave no callers outsidethis module and its tests.
admission_refusedgained aconn_keyparameter at both call sites. Nopublic API of the crate changed shape for any external consumer.