From 11ffb94181ebd36f10a396ed32ea65555a2f1466 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 2 Sep 2026 17:19:23 -0700 Subject: [PATCH 1/5] chore(release): bump to 0.262.0 for the dig-sex conduct + reward-ledger family Anchor commit for dig-node#268 (conduct evidence producer) and dig-node#271 (reward ledger persistence). Both land on this one branch: they share the dig-sex seam and the same node-state persistence question. Co-Authored-By: Claude --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d8a20e84..aecf6fca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.252.4" +version = "0.262.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over From cdcf1f6d0873070a9a8db6e96f949633819c7800 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 2 Sep 2026 17:58:41 -0700 Subject: [PATCH 2/5] chore(release): re-lock for 0.262.0 Co-Authored-By: Claude --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index b827726e..758e7247 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.252.4" +version = "0.262.0" dependencies = [ "async-trait", "axum", From a01e902046c69628731fb918b171573e143916cc Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 2 Sep 2026 18:31:20 -0700 Subject: [PATCH 3/5] docs(spec): normalize peer conduct exclusion-vs-ranking contract (#268) Adds SPEC 10.5 stating the three separate per-peer models (conduct exclusion, ask-routing rank, content-source rank), the non-performance floor that prevents an eviction-by-degradation attack, and that the verifiable-exclusion arm (ProvenLie/SelfContradiction) is currently vacuously satisfied rather than exercised in production. Co-Authored-By: Claude --- SPEC.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/SPEC.md b/SPEC.md index 379e848f..e6158cb5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3694,6 +3694,81 @@ repeating discovery. - Every cached entry remains a candidate to DIAL and never a fact (NC-12): the whole-resource merkle bind against the chain-anchored root is what admits bytes, so a stale entry costs one wasted dial. +### 10.5. Peer conduct: exclusion is not ranking (dig-sex SPEC 8.2A/8.3, #268) + +This node maintains THREE separate per-peer models, each with one job, and they MUST NOT be merged +or duplicated: + +- **`dig_sex::conduct`**, held in `ConductState` (`crates/dig-node-core/src/seams/dig_peer/conduct.rs`) + — a **binary exclusion gate**: which pool peers are dialable at all. +- **`dig_sex::routing`**, held in `AskRoutingState` (`ask_routing.rs`) — **ranks** the forwarded-ask + fan-out among whoever conduct left dialable. +- **`dig_peer_selector::PeerSelector`** (`selector_adapter.rs`) — **ranks** content-fetch sources. + +`download.rs:1920` states the split at the call site: *"CONDUCT gates who is dialable at all (#268, +SPEC 8.3); routing then ranks what remains"* (see also the field doc at `download.rs:904`). A fourth +per-peer quality model MUST NOT be introduced, and conduct MUST NOT become a ranking input — ordering +is `routing`'s job, and `dig_sex::routing::rank` already discharges dig-sex SPEC 8.2A's ordering +clause: its own tests `an_inconclusive_answer_ranks_above_silence` and +`a_good_answerer_outranks_an_unobserved_peer_which_outranks_a_silent_one` (dig-sex `src/routing.rs`) +prove peers are ordered by observed answer quality, never excluded by it. + +**Non-performance MUST NEVER exclude.** `dig_sex::dial_share` returns `0.0` only when a record's +`proven_faults > 0`; every non-performance penalty is floored at `MIN_NON_PERFORMANCE_DIAL_SHARE` +(0.1) and decays one `NON_PERFORMANCE_DECAY_TICKS`-sized step (600 ticks) per elapsed tick, with no +requirement that the peer be re-dialled or prove anything to recover. This is what stops an attacker +who can degrade an honest peer — load, connection-slot exhaustion, a partition — from evicting it +from every reader's dial set merely by making it slow. `conduct.rs:217` +(`sustained_non_performance_never_silences_a_peer_completely`) and `conduct.rs:277` +(`a_proven_liar_leaves_the_dial_set_while_a_merely_slow_peer_stays_in_it`) pin this at the decision +layer: only a peer with a proven fault ever leaves `ConductState::dialable`'s output +(`conduct.rs:104`); a peer with any amount of non-performance never does. + +**The exclusion arm is currently UNREACHABLE, and this MUST be read as vacuously satisfied, not +satisfied** (the same discipline dig-sex's own SPEC applies at its 2.4 and 2A.1). No production code +path constructs `ConductEvidence::ProvenLie` or `ConductEvidence::SelfContradiction` — both variants +are produced only inside `conduct.rs`'s `#[cfg(test)]` module. The one non-test call to `observe` +(`download.rs:2035-2046`) classifies every completed exchange as either `HonestAnswer` (the peer +answered, including an honest "I do not have it") or `NonPerformance` (a refusal, a timeout, an +unreachable peer). Consequently `proven_faults` is always zero on a running node, `dial_share` is +always `> 0.0` for every pool peer, and `ConductState::dialable` currently returns its whole input +unconditionally. + +**Why each verifiable class stays unproduced, and why producing either unsafely is forbidden:** + +- **`ProvenLie`** needs a per-peer verification verdict this node does not receive today. The + `dig-peer-selector` adapter (`selector_adapter.rs`) already carries a `peer_id` on every + `RangeOutcome` it records, but `dig-download`'s own engine collapses a verification failure and a + transport failure into the same `RangeResult::Failed`, which `map_range_result` + (`selector_adapter.rs:122-131`) in turn maps to one undifferentiated `OutcomeResult::Failure`. + Because `proven_faults` never decays, wiring a `ProvenLie` from today's `Failed` would let an + attacker who merely disrupts a transport connection **permanently** zero an honest peer's dial + share. A `ProvenLie` MUST NOT be synthesised from a transport-layer failure; it requires a + merkle-verification verdict attributed per-peer, which does not yet exist on this path. +- **`SelfContradiction`** is different: the raw data already exists in this node. + `forwarded_ask.rs`'s `responder_holds`/`responder_record` (`forwarded_ask.rs:364,384`) turn a + peer's own `available: true` claim into a `ProviderRecord` keyed on the same mTLS-verified + `peer_id`, and `holder_cache.rs`'s `FirstHandHolderCache` (§10.4.7) can hold that claim for up to + its TTL. It stays unproduced because a **legitimate eviction is a retraction** (dig-sex SPEC 7.1) + announced by flood rather than acknowledgement, and a cached first-hand claim can outlive it. An + attacker able to force an honest peer to evict under load, then race a correlating ask before the + retraction propagates, could brand that peer with a penalty that never decays. **A + `SelfContradiction` MUST NOT be recorded unless the window between the original claim and the + contradicting denial is bounded such that a legitimate eviction cannot explain it.** + +**Conduct state is node-local, ephemeral, and bounded.** It is never advertised, exchanged, or +written to any peer-visible surface (`conduct.rs:23-27`) — a shared reputation channel is a +defamation primitive, since one node's claim that a peer lied would become every node's belief with +no way to check it. It is not persisted: `ConductState` carries no serde derive +(`conduct.rs:47`) and is constructed only in-process via `ConductState::new()`, so a restart resets +it to empty. **If conduct is ever persisted, an unreadable record MUST resolve to +`ConductRecord::neutral()`, never to a penalised state** — losing a file MUST NOT become an +exclusion. It is bounded per dig-sex SPEC 8.4: `ConductState::dialable` calls `retain(pool)` at its +head (`conduct.rs:105,113-115`), so the map is keyed only to the current pool and cannot outgrow it. +Its key, `RoutedPeer`, is the mTLS-verified `peer_id` and is constructible ONLY from a connected-pool +key (`ask_routing.rs:16-24`) — never from an address, a provider record, or any peer-supplied frame +field — so an unauthenticated identity flood cannot grow this map either. + --- From c3652efecdbaac02c67d1954bcca29460d4027e4 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 2 Sep 2026 18:35:39 -0700 Subject: [PATCH 4/5] docs(spec): state the non-performance decay rate correctly in 10.5 The clause read 'one NON_PERFORMANCE_DECAY_TICKS-sized step per elapsed tick', which inverts the arithmetic and would have shipped as a false normative claim. dig_sex::decay computes periods = elapsed / NON_PERFORMANCE_DECAY_TICKS and subtracts that from non_performance, so one unit decays per 600 ticks of elapsed time. Also records the property the remainder-carry exists for: the penalty is a function of elapsed time alone, so a frequently-observed peer cannot be held un-decayed by the observation traffic itself. Co-Authored-By: Claude --- SPEC.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/SPEC.md b/SPEC.md index e6158cb5..59a3d212 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3715,8 +3715,12 @@ prove peers are ordered by observed answer quality, never excluded by it. **Non-performance MUST NEVER exclude.** `dig_sex::dial_share` returns `0.0` only when a record's `proven_faults > 0`; every non-performance penalty is floored at `MIN_NON_PERFORMANCE_DIAL_SHARE` -(0.1) and decays one `NON_PERFORMANCE_DECAY_TICKS`-sized step (600 ticks) per elapsed tick, with no -requirement that the peer be re-dialled or prove anything to recover. This is what stops an attacker +(0.1) and one unit of it decays for every `NON_PERFORMANCE_DECAY_TICKS` (600) of elapsed time, with +no requirement that the peer be re-dialled or prove anything to recover. The penalty MUST be a +function of elapsed ticks ALONE and never of how often this node happened to observe the peer: +`decay` carries the unspent remainder of a period forward rather than restamping the record +(`conduct.rs:158-178`), so a peer this node talks to often cannot be held un-decayed by the +observation traffic itself. This is what stops an attacker who can degrade an honest peer — load, connection-slot exhaustion, a partition — from evicting it from every reader's dial set merely by making it slow. `conduct.rs:217` (`sustained_non_performance_never_silences_a_peer_completely`) and `conduct.rs:277` From 9ff1461ac1be9b83436ad7bab0a3af9227819b78 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 2 Sep 2026 19:14:24 -0700 Subject: [PATCH 5/5] chore(release): take 0.252.6 instead of 0.262.0, under the MSI ceiling dig-node#521 measured that scripts/package-version.sh fails closed above minor 255, because Windows Installer's ProductVersion field cannot carry it and a truncated field would make two releases compare EQUAL. main is at 0.252.4 and still under the ceiling, so the first merge of any PR at >= 0.256.0 is what makes every subsequent release ship with no Windows installer. The four native-package jobs are not required checks, so nothing would have blocked that landing silently. This PR is documentation only, which is a patch under the repo's own SemVer rule, so it has no reason to be the change that spends the ceiling. 0.262.0 was assigned to dodge collisions with the 0.253-0.256 lanes; 0.252.6 dodges them equally (0.252.5 is taken by #516) while staying buildable. Also disambiguates a SPEC 10.5 citation flagged by the review gate: the decay line numbers referred to dig-sex's conduct.rs, but every other bare filename in that section resolves to dig-node's own conduct.rs, where those lines are unrelated test code. Names the crate instead. Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- SPEC.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 758e7247..1abb356b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.262.0" +version = "0.252.6" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index aecf6fca..368ed848 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.262.0" +version = "0.252.6" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index 59a3d212..74a00680 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3719,7 +3719,7 @@ prove peers are ordered by observed answer quality, never excluded by it. no requirement that the peer be re-dialled or prove anything to recover. The penalty MUST be a function of elapsed ticks ALONE and never of how often this node happened to observe the peer: `decay` carries the unspent remainder of a period forward rather than restamping the record -(`conduct.rs:158-178`), so a peer this node talks to often cannot be held un-decayed by the +(dig-sex `src/conduct.rs`, `decay`), so a peer this node talks to often cannot be held un-decayed by the observation traffic itself. This is what stops an attacker who can degrade an honest peer — load, connection-slot exhaustion, a partition — from evicting it from every reader's dial set merely by making it slow. `conduct.rs:217`