feat(digsex): produce real conduct evidence and persist the reward ledger - #515
Conversation
…er 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 <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Headline answerA hostile peer cannot make an honest peer's conduct score reflect anything it did not itself do:
Adjacent finding, not requested, worth recording: a third quality model is also vacuous, in the safe directiondig_peer_selector::PeerSelector (via selector_adapter.rs) is a THIRD independent per-peer Claims (a) through (g) -- verdicts(a) TRUE. dial_share returns 0.0 only when proven_faults > 0 (conduct.rs:190-191); the floor (b) TRUE. Confirmed by grep across the whole crates tree: the only non-test observe call site (c) TRUE. proven_faults is always zero in a running node; dialable() returns its whole input (d) TRUE. ConductRecord and ConductEvidence carry no serde derive; ConductState derives only (e) TRUE. retain(pool) runs at the head of dialable() (conduct.rs:105) before any read or (f) TRUE. Grepped every RoutedPeer construction site in the crate: only from_pool_key (fed (g) TRUE, with a caveat worth stating explicitly. Three independent models is correct in PR-state note, separate from the security verdictThis PR (loop/268-271-digsex-conduct-ledger, head cdcf1f6) |
loop-security VERDICTPASS (on the shipped mechanism). Head audited: cdcf1f6 Full findings are in the preceding comment on this PR. Summary:
Nothing here rises to CHANGES-REQUIRED. The one adjacent, unrequested finding (PeerSelector's own Separately, and this does NOT change the verdict on the mechanism: the PR itself is a draft |
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 <noreply@anthropic.com>
loop-security ADDENDUM -- head moved during audit, re-verifiedThe head moved twice while I was auditing: cdcf1f6... to c3652ef.
Since only SPEC.md changed, my source-level audit (the two preceding comments) still applies
VERDICT UNCHANGED: PASS, now confirmed at head c3652ef. The If the head moves again before this merges, re-run check-merge-preconditions and re-diff against |
Gate 1 of 2 —
|
| claim | verdict |
|---|---|
(a) dial_share returns 0.0 only when proven_faults > 0; non-performance floors at 0.1, one unit decays per 600 elapsed ticks, remainder carried |
TRUE |
(b) no production path constructs ProvenLie/SelfContradiction; sole non-test observe at download.rs:2035-2046 |
TRUE |
(c) therefore proven_faults is always zero and dialable() returns its whole input — vacuous |
TRUE |
(d) ConductState never gossiped, never persisted (no serde derive) |
TRUE |
(e) bounded — dialable() calls retain(pool) at its head |
TRUE |
(f) RoutedPeer constructible only from a pool key (ask_routing.rs:79) |
TRUE |
| (g) three distinct per-peer models, no shared state | TRUE |
| (h) the verify/transport collapse is rooted in dig-download's engine, not dig-node's adapter | TRUE, and correctly attributed |
It also independently confirmed the two judgement calls rather than taking them on trust:
- Rejecting a
dial_shareranking is correct, not a missed opportunity.routing::rank(routing.rs:269) and its two cited tests are real and do what is claimed; a second ordering would be a third computation over the same two events from the same call site, risking drift againstrouting::rankand against the reserved exploration slot. - Wire the dig-sex reward ledger (SPEC 2A) -- record and persist; claim mechanism still blocked #271 is genuinely blocked, not avoided.
RewardLedgerhas private fields, no serde, no entry accessor; zero references toRewardClaim/RewardLedger/claimed_forexist anywhere in dig-node production code, so the journal-and-rebuild workaround would indeed have been vacuous. The sealing-boundary reasoning checks out againstmachine_key.rs.
Its verdict on the shape of this hand-back, quoted because it is the question I most wanted challenged: "every 'should have been buildable' alternative was checked and correctly rejected... This is honest engineering, not a stop for convenience."
One defect found and fixed in 9ff1461a: the decay citation read (conduct.rs:158-178), but every other bare filename in that section resolves to dig-node's own conduct.rs, where those lines are unrelated test code — the decay function lives at those line numbers in the dig-sex crate. A confusable pointer in a normative document is worth the one-line fix; it now names the crate.
Gate 2 (loop-security, fresh context) is still running. It was asked directly: what can a hostile peer make an honest peer's score look like?
loop-security FINAL VERDICTPASS. Head re-confirmed stable at 9ff1461 (fetched, diffed, The head moved twice more after my addendum: 9ff1461 changes only Cargo.lock/Cargo.toml Nothing in this round changes any prior finding. Full analysis is in the three preceding This PR remains correctly in draft while its own CI runs. Recommend undrafting only once every |
DO NOT MERGE - gate round in progress. Head
9ff1461a.What this PR ships, and what it deliberately does not
Documentation only. A version bump to 0.252.6, a re-lock, and one new normative section in
SPEC.md. No behaviour change, no.rsedit.That is the honest outcome of taking dig-node#268 and dig-node#271 as one family: after measuring both, neither ticket has a safely-buildable code change in this repo today, and building one anyway would have meant shipping either a censorship primitive or a vacuous record. The measurement itself is the deliverable, and this PR makes the durable half of it normative so the next lane does not re-derive it.
Refs #268 · Refs #271
#268 — conduct: the gate is real, correct, and dormant
dig_sex::dial_sharereturns a hard0.0only whenproven_faults > 0.proven_faultsmoves only onProvenLieorSelfContradiction, and neither is constructed anywhere outsideconduct.rs's test module — the single non-testobservecall site (download.rs:2035) produces onlyHonestAnswerandNonPerformance, which floors at0.1. Soproven_faultsis always zero, every peer always passes, anddialable()returns its whole input set unconditionally. The exclusion mechanism is implemented, correctly tested, and unreachable.Both routes out were measured; neither is safe here.
ProvenLie— blocked cross-repo. dig-download already surfaces apeer_id, but folds a verification failure and a transport failure into oneRangeResult::Failed(select.rs:117-125, folded atorchestrator.rs:986-996); dig-node's own adapter records the loss atselector_adapter.rs:109-115. Becauseproven_faultsnever decays, wiring from today'sFailedwould let an attacker who merely resets a connection permanently zero an honest peer's dial share. Filed and scoped, with the precise change it needs, at dig-download issue 49: Surface a per-peer verification verdict so a proven lie is attributable to one holder dig-download#49.SelfContradiction— data available, deliberately not built. Both halves exist on the right identity (forwarded_ask.rs'sresponder_holds/responder_recordplusFirstHandHolderCache). It stays unproduced because a legitimate eviction is a retraction (dig-sex SPEC 7.1) whose announce is flooded rather than acknowledged, so a cached claim can outlive it. An attacker can force an honest peer to evict under load and race a correlating asker — branding it with a penalty that has no floor and no decay.What was considered and rejected: making conduct RANK the dial order.
dig_sex::routing::rankalready ranks the fan-out on observed quality and already proves dig-sex SPEC 8.2A's ordering clause in its own tests (an_inconclusive_answer_ranks_above_silence,a_good_answerer_outranks_an_unobserved_peer_which_outranks_a_silent_one). Adial_shareordering on top would be a third reputation computation over the same two events from the same call site, in a second state map that can drift, and it would risk starving the reserved exploration slot that lets an unobserved peer earn a score.#271 — the reward ledger cannot be persisted from here
RewardLedger(dig-sex 0.5.0reward.rs:78) is a privateHashMapwith no serde derives and no accessor over its entries. A caller physically cannot write it out. dig-sex SPEC 2A.1 states this and names the fix as an upstream public-API change that "MUST be specified here before it is built" → DIG-Network/dig-sex#8 (open, unstarted).The workaround — journalling the
RewardClaims fed torecord()and rebuilding viafrom_chain_claims— was not built. It is a rival persistence path landing before the shape is specified, and it would be vacuous at both ends anyway: nothing produces aRewardClaim(claim mechanism deferred, out of scope by the ticket's own wording) and nothing consumesclaimed_for(SPEC 2.4 —Tier2Bribedhas no producer anywhere, and the money budget its fourth requirement needs does not exist at the seam).Separately measured and recorded on the ticket: the sealing constraint as worded ("sealed at rest to the user key, NC-2/NC-3") is not buildable — the node never holds the user's keypair (
key_mgmt/machine_key.rs:9-11, the §908 boundary), there is no generic sealed-at-rest helper (shared/at_rest.rsdoes presence checks and atomic writes with zero encryption), and every other persisted node file is plaintext.The section added
A new
### 10.5.inSPEC.mdbinding: the three distinct per-peer models and that conduct MUST NOT become a fourth ranking input; that non-performance MUST NEVER exclude; that the exclusion arm is vacuously satisfied rather than satisfied; why each verifiable class stays unproduced and that neither may be synthesised unsafely; and that conduct state is node-local, unpersisted, bounded byretain(pool), and MUST fail toneutralif ever persisted.Stating vacuity explicitly is the house style here — dig-sex's own SPEC does it in 2.4 and 2A.1, and a conformance claim that passes because the thing it governs never occurs reports a capability the system does not have.
Verification
cargo check -p dig-node-core --libon the branch base: exit 0.Cargo.tomlon disk;origin/mainis 0.252.4.Cargo.lockre-locked to match.scripts/package-version.shfails closed above minor 255 (Windows Installer'sProductVersioncannot carry it, and a truncated field would make two releases compare equal).mainis still under the ceiling at 0.252.4, so the first merge of any PR at >= 0.256.0 is what makes every subsequent release ship with no Windows installer - and the four native-package jobs are not required checks, so nothing would have blocked that landing silently. A documentation-only change is a patch under this repo's SemVer rule and had no business spending the ceiling. 0.252.5 is taken by fix(forwarded-ask): a hop's absence_established must not strengthen this node's absence claim #516, so this takes 0.252.6.Cargo.tomlon disk, never from the commit log, because a rebase can silently drop a bump commit whose patch is already upstream.Native install packagesjobs are red on every open dig-node PR branch (6 lanes, 12/12 recent runs), all failing at the same Resolve + validate the package version step. That is dig-node#521, pre-existing and repo-wide, not caused by this diff.cargo test -p dig-node-core --lib seams::dig_peer::conduct) - a real count, not a filter that matched nothing.SPEC.mdverified valid UTF-8, zero CRLF.c3652efeto "one unit per 600 elapsed ticks", which is whatdig_sex::decayactually computes.loop-reviewerandloop-securitygates run in fresh contexts; the security gate was asked directly "what can a hostile peer make an honest peer's score look like?" and asked to mark each normative claim in the new section TRUE / FALSE / UNVERIFIABLE.Not done, on purpose
No producer for either verifiable evidence class; no reward-ledger persistence; no ranking change; no
SelfContradictioncorrelator. Each is recorded with its blocker on the ticket it belongs to, cross-linked both ways.