feat(collateral): per-epoch record, gossip serve, sampled sync - #398
Conversation
loop-security — IN PROGRESS, not the verdictAuditing head Batch 1 — mechanical, established
Continuing: verification core, disagreement handling, the |
loop-security — IN PROGRESS, not the verdict (2)Head Batch 2 — the disagreement layer. Two candidate findings, probes compiling now.Established by reading, probe pending:
and Candidate finding A — the two-thirds rule degrades to a plurality once responders exceed 9. Candidate finding B — Candidate finding C — a null REACHABILITY, which is why none of these is presumptively gating: Confirmed correct in this batch
|
loop-security — IN PROGRESS, not the verdict (3)Head Batch 3 — the LIVE surface. Clear, item by item.The production writer cannot write anything but epoch 1. Retention, all three properties.
The The
Cost asymmetry — checked, and it is bounded, but it is not free. No new or changed dependency. The |
loop-security — IN PROGRESS, not the verdict (4)Head CONFIRMED BY EXECUTION — finding B: one peer in the AGREEING cohort poisons
|
loop-security — IN PROGRESS, not the verdict (5)Head CONFIRMED BY EXECUTION — finding A: a 7-of-12 PLURALITY is adopted, and it moves the money 3.2x DOWNMy first attempt at this probe FAILED on its own control (both fixtures landed on the same floor-clamped Mechanism. So with population 1000 the sampler will accept up to 1000 responders while still adopting on 7 agreeing. Exploit. State: a node with a chain source, syncing epoch n from peers, honest owner population 1000. The attacker does not even need the honest 5 to be outnumbered in the population — only to be outnumbered
CONFIRMED BY EXECUTION — finding D: an adopted lie cannot be superseded by this node's own later censusThe probe stores a peer-adopted record, then
This is what makes A worse than a bounded mistake: A gets a false record in, and D makes it permanent. CONFIRMED BY EXECUTION — the
|
loop-security: CHANGES-REQUIREDHead audited: The live surface of this PR is clean. Every finding below is in
GATING1. A 7-of-12 PLURALITY is adopted; the two-thirds rule collapses once responders exceed 9 — HIGH
and applies the 7 as an absolute count: Exploit. State: a node with a chain source syncing epoch n from peers, honest owner population Confirmed by execution: Contradicts Suggested shape (the lane decides): bound the counted set to 2. An adopted lie can never be superseded by this node's own later census — HIGH
so it is reachable only when the two Exploit. Continue finding 1: the node has adopted the attacker's record for epoch n. It later Confirmed by execution: asserting The immutability rule itself is right and must stay — the fix is that a NOT GATING — defense-in-depth, file as follow-ups3. One peer in the AGREEING cohort poisons
|
| Area | Evidence |
|---|---|
| Secrets / credentials | Added-line scan for keys, tokens, PATs, projectId, .test-credentials, AWS/Slack/GitHub patterns: zero hits. The three secret matches are prose saying the record carries nothing secret. No new file but collateral_sync.rs; no scratch files, logs or report .md in the diff. |
| Custody / privilege | No key handling, no signing, no service/registry/scheduled-task write, no install-root or FFI change, no elevation. §908 boundary untouched. append/prune/save_to all call ensure_dir_restricted + restrict_permissions. |
| Production writer scope | Exercised for real: dign run on an isolated state dir wrote exactly one line — {"epoch":1,...,"census_height":null,"provenance":{"kind":"bootstrap"}}. There is no epoch parameter on that path; prune only deletes. |
| AuthZ / exposure | dig.getCollateralEpoch sits in the rpc.discover/dig.health/dig.methods open tier — correct for it: no secret, and unlike dig.getContent it cannot drive the node into becoming a holder. Node binds loopback-only by default; non-loopback DIG_NODE_HOST refused without DIG_NODE_ALLOW_REMOTE=1 (#1662). Verified live on 127.0.0.1:19778 with no token. |
| Input / boundary | params.epoch read typed and filter(|e| *e >= 1) — absent, zero, negative and non-numeric all refused rather than defaulted. No path traversal (the store path is resolved by state_dir(), never from input). No unwrap on adversarial input in the new code. scale_micros / one_epoch_lock use u128 then try_from(..).unwrap_or(u64::MAX) — saturating, not wrapping. |
| Error-message hygiene | record_unreadable carries no filesystem path; invalid_epoch's detail is a static string. No path disclosure to a caller. |
| Ceiling, both boundaries | Re-executed. Disabling collateral.rs:701 fails exactly a_record_from_an_unimplemented_ruleset_is_not_served_as_a_requirement (serving protocol_version: 65535 with a live figure) while 37 others stay green. Mutation reverted; git status --porcelain clean but for my own untracked .sec/. |
| Retention, all three | Off by default (#[serde(default)] + Default -> None); Some(0) -> KeepEverything (collateral.rs:103), pinned explicitly at :1004-1011; epoch 1 exempt (:560), pinned two-sided by kept == vec![1,8,9,10] with get(7) == Absent. KeepEverything never rewrites the file. saturating_sub on both terms prevents a wrap to a near-u64::MAX cutoff. |
collateral_margin_set |
Fixed at control.rs:3357 with ..CollateralConfig::load(). Grep for CollateralConfig { across crates/ finds exactly one production site — this one. No siblings. |
put provenance asymmetry |
A peer can never upgrade provenance: adopt only ever emits AdoptedFromPeers, strength() == 0, so held.strength() >= record.strength() always short-circuits to AlreadyPresent. Upgrade also requires an identical EpochRecord. The asymmetry itself is sound; finding 2 is that it is too strict, not too loose. |
| Equivocation | Poisoning is permanent — a third answer hits the Some(_) arm and re-inserts None; values().flatten() drops it from the tally while by_responder.len() still counts it toward responders, which is the conservative direction for the population check. |
| Sample refusals | Unknown population -> Advisory before any tally; sub-SYNC_MIN_POPULATION -> Advisory; population exceeded refuses the whole sample with no trimming. Verified by the lane's own two-sided test (over the bound refused, exactly at the bound adopted). |
| Dependencies (§2.4b) | Cargo.lock diff is one line (the workspace version). No new or updated dependency. |
| Line endings | SPEC.md numstat 99 0, purely additive; 0 CR bytes at base and at head. None of PR #391's blame damage. |
crates/dig-wallet/src/sage/ |
Absent from the changed-file list entirely. PR #391 unaffected. |
| Authorship | All 5 commits Michael Taylor <michael@michaeltaylor.dev>. No fabricated identity. |
| Merge preconditions | check-merge-preconditions.sh: 4 of 5 required contexts SUCCESS by name, Test + coverage IN_PROGRESS (present, not absent), 0 unresolved review threads, draft=true — correctly still draft while the gate ran. |
| Real output | Confirmed live, not read: empty store -> no collateral epochs recorded yet (<path>); genesis renders epoch 1 · 1.000 DIG per store · v1 rules · ... · genesis (derived from nothing); dig.getCollateralEpoch returns not_recorded for epoch 2, invalid_epoch for epoch 0, and the record for epoch 1. |
The chia-bls split is CORRECT
Independently verified: chia-bls = "0.36.1" declared in dig-node-core, dig-node-service and
dig-wallet; published latest is 0.48.0; the lock already carries 0.22.0, 0.26.0 and 0.28.2 lines
from transitive deps. Twelve minors on a 0.x line is semver-incompatible — it changes type identity for
every consumer — and dig-tls / dig-identity / dig-mirror-coin all sit on the 0.36 line, so it moves
as a set or not at all. This PR touches zero chia surfaces (collateral.rs / collateral_sync.rs use
only dig-mirror-collateral and dig-constants). §2.4b's own scope limit applies exactly. One process
nit, not a security matter: "Named and split" should carry the child ticket URL.
Coverage I did NOT achieve — stated so nobody assumes it
- No multi-node run. Every sampled-sync result here is single-process unit-level. The lane's honest
negative feat(dig-runtime): wallet-only start + native read-crypto FFI #4 stands and I did not improve on it. verifywas exercised against forged records only throughadoptand directly — not against a real
peer over the wire, because there is no transport.- I did not audit
dig-mirror-collateralitself beyond readingsync_sample_plan,
agreement_thresholdand the threeSYNC_*constants.EpochRecord::advanceis trusted as the
re-derivation oracle; if it is wrong, every conclusion aboutverifyinherits that. - Concurrency on the store is untested.
putis read-then-append with no lock; two writers racing
could both seeAbsentand append twice. Only one writer exists today (start-up), so it is not a live
race, andget/recordsare last-wins so a duplicate is not a correctness break — but I did not probe it. prune's temp-file path (collateral-epochs.jsonl.rewrite) is written beforerestrict_permissions
and follows symlinks. Bounded by state-dir write access being control-token equivalent; not probed.- CodeQL/GHAS results were not re-read —
Test + coveragewas still running when I checked. - A totally non-JSON corrupt line reports
Absentrather thanUnreadable, becauseline_names_epoch
needs the line to parse asValue. The module says "usually" and is honest about it; not pursued.
…node's census supersede an adopted lie Two gating findings from the round-2 adversarial gate on #398, both confirmed by execution against the code as it stood. 1. A 7-of-12 plurality was adopted. sync_sample_plan caps sample_size at 9, so agreement_threshold is a fixed 7 at any population of 20 or more, while adopt bounded responders only by plan.population -- up to 1000+ -- and applied 7 as an absolute count. Seven sybils returning an internally-consistent record with a fictional census input carried it against five honest peers, under-posting the requirement 3.2x. SPEC 24.10, added in this PR, says a plurality MUST NOT be adopted. The threshold is now refused against any responder set larger than the one it was computed for, whole and untrimmed, matching the existing population discipline. 2. An adopted record could never be superseded by this node's own census. The provenance-upgrade arm sat behind held.record != record.record, so it was reachable only when the figures already agreed -- the prefer-own-census rule held in every case except the one it exists for. A named AdoptedFromPeers -> Censused direction now supersedes; every other differing pair is still a Conflict, and a peer answer can only ever carry AdoptedFromPeers provenance, so no responder can reach the superseding side. Also closes two smaller denial surfaces in the same file: the tally now carries the LOWEST census height offered by the agreeing cohort rather than the first in BTreeMap order over a peer-supplied id (one member naming u32::MAX wedged every later epoch), and a record for an epoch after genesis that carries no census height is refused rather than skipping the advancing check. SPEC 24.10 is updated to match, and now also states that EpochRecord::advance is trusted as the re-derivation oracle. Co-Authored-By: Claude <noreply@anthropic.com>
Round 2 — both GATING findings fixed, plus 3 and 4, with revert proofsHead Re-gate scope is what the verdict named and nothing wider: GATING 1 — the 7-of-12 plurality
Chosen shape:
One behaviour intentionally changed, and it is not a silent one. The pre-existing test GATING 2 — an adopted lie could never be supersededThe provenance-upgrade arm sat behind
Immutability is not weakened, and that is checkable by reading rather than by trusting me: every Non-gating 3 and 4, fixed here as suggested
5-7, as one comment rather than three issuesJudged and left, with the reasoning stated so the next reader does not have to re-derive it:
Also, as asked
Revert proofs — committed first, then reverted by file copy
On the fixtures, since this is where a false green would live
SPEC §24.10, clause by clause against the fixed code
Two clauses shipped false in round 1; both are now true and both have a test that goes red without Version
|
loop-security round 2 — IN PROGRESS, not the verdictAuditing head CONFIRMED — the privacy claim GATING 1's whole design choice rests on is TRUERead against the published crate,
So "recompute the threshold from the responder count" would indeed mean restating CONFIRMED —
|
loop-security round 2 — IN PROGRESS (2/3), not the verdictHead GATING 1 probe RE-EXECUTED — the round-1 exploit reproduces on revert, and refuses at head
The three that go red are the right three: The exploit output on revert, quoted rather than summarised: 7 sybils out of a 1000-strong population carry a record over 5 honest peers, and the operator's posted One honest note on magnitude: the corrected fixture varies only The fixture correction is a REAL discriminator — verified from the failure output
The changed test — adjudicated independently; the lane's realization is DIRECTIONALLY right but OVERSTATEDThe old assertion was The replacement does preserve what the original was for. The original's stated purpose was a Nothing else in the suite passes only because this test moved. I enumerated every Still to come: revert-probe 2 (the supersession arm), the attack on the immutability claim, SPEC §24.10 |
loop-security round 2 — IN PROGRESS (3/3), not the verdictHead GATING 2 probe RE-EXECUTED — confirmed
The failure is The I ATTACKED THE IMMUTABILITY CLAIM AND IT DOES NOT HOLD AS WRITTEN — non-gating, but the SPEC sentence is falseThe claim, in three places —
I wrote an executable probe ( Three separate facts, and they need separating:
So the guarantee is a discipline inside one function, not a property of the type or of the store. The Why this is NOT gating at this head: Why it still matters: the wiring lane is the next lane, and it will read Confirmed clean
Verdict next. |
loop-security round 2 — PASS (with one non-gating finding)Head audited: Scope as briefed: the agreement rule, the provenance arm, the changed test, the two things reaching The headline: the changed test does NOT hide a regression, and both fixes are load-bearingEvery claim in the lane's probe table reproduced exactly.
Round 1's first exploit reproduces on revert, verbatim from the failure output: 7 sybils out of a 1000-strong population carry a record over 5 honest peers and drive the operator's Round 1's second exploit reproduces on revert: the adopted lie comes back as Adjudicating the changed test, independentlyWas the old assertion ratifying the plurality bug, or protecting something now lost?Neither, exactly — and the lane's realization is directionally right but overstated. The old A behaviour is genuinely lost: a node hearing unanimously from all 20 owners of a 20-owner network no Does the replacement preserve what the original was for?Yes. The original's stated purpose was a two-sided bound ("could not tell a correct guard from one Is anything else in the suite passing only because this test moved?No. I enumerated every GATING 1 — the design choice, verified rather than acceptedThe whole choice rests on
The privacy claim is TRUE, so "recompute the threshold from the responder count" would mean restating Both refusals remain live and distinct: 10-20 responders is GATING 2 — the provenance arm, and I attacked its immutability claimThe FINDING (non-gating) — the security sentence is false as written, in three places
I wrote an executable probe rather than reasoning about it ( Three facts, which need separating:
So the guarantee is a discipline inside one function, not a property of the type or of the store. I am NOT gating on this, and here is exactly why. Why it still matters enough to write down. The wiring lane is next and it will read Findings 3 and 4 — reasoning verified, and they are COUPLEDBoth re-executed (I ran these too, not only the first two):
Finding 3's safety reasoning holds. The two fixes must land together, and I want this on the record because it is the strongest thing in the I also confirmed the genesis carve-out at Finding 5 — I AGREE with the lane, and the inconsistency is deliberate, not accidental
Those are precisely the three surfaces SPEC §24.8 enumerates at The distinction the lane draws is the right one and it is not merely rhetorical. The two ceilinged One non-gating improvement I would take. SPEC §24.10, clause by clause — every clause, not only the two that were wrong
Both clauses that shipped false in round 1 are now true and each is proved by a test that goes red The one clause I mark false is the twelfth row, and it is the finding above. It is a precision defect The other checks
Merge preconditions — NOT cleared, and not mine to clearStating these so nobody reads my PASS as "mergeable":
Condition on this verdict: it is a PASS against What I did NOT cover
Verdict: PASSBoth gating fixes are correct, load-bearing, and proved by execution. The changed test does not hide a One non-gating finding: the "a peer answer can only ever carry Merge still blocked on five absent required checks and a version-line conflict, neither of which is a |
…rification The store gains its first production writer. Records carry the census height and how this node came by them; a record that contradicts one already held is refused rather than appended, because the model's premise is that any node can recompute any past epoch and reach the same answer. Peer records are adopted only by re-derivation through dig-mirror-collateral's own advance, never by trusting the peer, and never below the sampling plan's population threshold. Refs #387
…ot, honour retention The record store gains its production writer at node start-up: the genesis epoch, which is derivable from nothing, is recorded so a node can state a requirement at all. Peers fetch past epochs over an open dig.getCollateralEpoch, which refuses by name rather than answering with a shaped-like-success zero. Retention is off by default. Setting the margin now loads the config before modifying it, so one preference cannot erase another. Refs #387
…s epoch Running a two-epoch retention on a real node dropped epoch 1 and left the store empty. Epoch 1 is the base case every verification walk unrolls from, so a node that discarded it could no longer check anything a peer offered it; it is now exempt from every retention policy. Refs #387
… and retention Also bumps to 0.163.0 and clears two clippy borrows. Refs #387
…sus supersede an adopted lie Two gating findings from the round-2 adversarial gate on #398, both confirmed by execution against the code as it stood. 1. A 7-of-12 plurality was adopted. sync_sample_plan caps sample_size at 9, so agreement_threshold is a fixed 7 at any population of 20 or more, while adopt bounded responders only by plan.population -- up to 1000+ -- and applied 7 as an absolute count. Seven sybils returning an internally-consistent record with a fictional census input carried it against five honest peers, under-posting the requirement 3.2x. SPEC 24.10, added in this PR, says a plurality MUST NOT be adopted. The threshold is now refused against any responder set larger than the one it was computed for, whole and untrimmed, matching the existing population discipline. 2. An adopted record could never be superseded by this node's own census. The provenance-upgrade arm sat behind held.record != record.record, so it was reachable only when the figures already agreed -- the prefer-own-census rule held in every case except the one it exists for. A named AdoptedFromPeers -> Censused direction now supersedes; every other differing pair is still a Conflict, and a peer answer can only ever carry AdoptedFromPeers provenance, so no responder can reach the superseding side. Also closes two smaller denial surfaces in the same file: the tally now carries the LOWEST census height offered by the agreeing cohort rather than the first in BTreeMap order over a peer-supplied id (one member naming u32::MAX wedged every later epoch), and a record for an epoch after genesis that carries no census height is refused rather than skipping the advancing check. SPEC 24.10 is updated to match, and now also states that EpochRecord::advance is trusted as the re-derivation oracle. Co-Authored-By: Claude <noreply@anthropic.com>
…ixed rules, and bump to 0.164.0 The module doc still described the pre-fix agreement rule and the pre-fix supersession behaviour, which is the born-false-clause class this PR's gate round was about -- a normative sentence shipping in the same diff as the gap. main moved to 0.163.0 while this branch sat at the same value, so the version gate would have failed. 0.164.0 is one minor above it; minor because the PR adds capability. Co-Authored-By: Claude <noreply@anthropic.com>
…le coupling Round-2 gate follow-ups on #398. The SPEC, the `own_census_supersedes` doc and the sync module doc all claimed a peer answer "can only ever carry AdoptedFromPeers provenance". The gate disproved that by execution: RecordProvenance is an ordinary deserialisable field, so a wire record naming "censused" decodes as Censused and would supersede if handed to the store unchanged. What actually holds the property is `adopt`, which discards the provenance a responder sent and stamps its own -- a discipline in one function, not a property of the type. All three sites now say that, and say that any future path admitting a record from the network must hold it too. Not live: `adopt` has no production caller and `put`'s only caller stamps Bootstrap. Corrected anyway because this is the trust-boundary spec, and a clause that overstates a guarantee is how the next implementer builds against a contract that lies. Also records that the lowest-census-height rule and the CensusHeightMissing refusal are only correct TOGETHER: None orders below every Some, so min-taking without the refusal would let one responder strip the height from an honest cohort and disable the next epoch's advancing check instead of wedging it. Stated at both code sites and in SPEC 24.10, and pinned by a test rather than left to prose. Co-Authored-By: Claude <noreply@anthropic.com>
c10e3ff to
c4b6b10
Compare
…node's census supersede an adopted lie Two gating findings from the round-2 adversarial gate on #398, both confirmed by execution against the code as it stood. 1. A 7-of-12 plurality was adopted. sync_sample_plan caps sample_size at 9, so agreement_threshold is a fixed 7 at any population of 20 or more, while adopt bounded responders only by plan.population -- up to 1000+ -- and applied 7 as an absolute count. Seven sybils returning an internally-consistent record with a fictional census input carried it against five honest peers, under-posting the requirement 3.2x. SPEC 24.10, added in this PR, says a plurality MUST NOT be adopted. The threshold is now refused against any responder set larger than the one it was computed for, whole and untrimmed, matching the existing population discipline. 2. An adopted record could never be superseded by this node's own census. The provenance-upgrade arm sat behind held.record != record.record, so it was reachable only when the figures already agreed -- the prefer-own-census rule held in every case except the one it exists for. A named AdoptedFromPeers -> Censused direction now supersedes; every other differing pair is still a Conflict, and a peer answer can only ever carry AdoptedFromPeers provenance, so no responder can reach the superseding side. Also closes two smaller denial surfaces in the same file: the tally now carries the LOWEST census height offered by the agreeing cohort rather than the first in BTreeMap order over a peer-supplied id (one member naming u32::MAX wedged every later epoch), and a record for an epoch after genesis that carries no census height is refused rather than skipping the advancing check. SPEC 24.10 is updated to match, and now also states that EpochRecord::advance is trusted as the re-derivation oracle. Co-Authored-By: Claude <noreply@anthropic.com>
…le coupling Round-2 gate follow-ups on #398. The SPEC, the `own_census_supersedes` doc and the sync module doc all claimed a peer answer "can only ever carry AdoptedFromPeers provenance". The gate disproved that by execution: RecordProvenance is an ordinary deserialisable field, so a wire record naming "censused" decodes as Censused and would supersede if handed to the store unchanged. What actually holds the property is `adopt`, which discards the provenance a responder sent and stamps its own -- a discipline in one function, not a property of the type. All three sites now say that, and say that any future path admitting a record from the network must hold it too. Not live: `adopt` has no production caller and `put`'s only caller stamps Bootstrap. Corrected anyway because this is the trust-boundary spec, and a clause that overstates a guarantee is how the next implementer builds against a contract that lies. Also records that the lowest-census-height rule and the CensusHeightMissing refusal are only correct TOGETHER: None orders below every Some, so min-taking without the refusal would let one responder strip the height from an honest cohort and disable the next epoch's advancing check instead of wedging it. Stated at both code sites and in SPEC 24.10, and pinned by a test rather than left to prose. Co-Authored-By: Claude <noreply@anthropic.com>
c4b6b10 to
7650c4a
Compare
DRAFT — gate round not yet run. DO NOT MERGE.
Closes #387.
Parent epic: https://github.com/DIG-Network/dig_ecosystem/issues/3173
What this does
EpochRecordStore::puthad no production writer — it was test-only, socontrol.collateral.requirementansweredunknown / not_censusedon every node in the network,permanently. This PR is the writer, plus the three surfaces around it.
1. Database —
collateral-epochs.jsonl, one line per epochA stored record is the consensus
EpochRecord(census inputs, derivedrequired_per_store,protocol_version) flattened onto the line, plus two node-local fields:census_height— the block the census was taken at,nullat epoch 1 where no censushappened. It is deliberately NOT an input to the arithmetic: two nodes reading the same chain at
the same height derive the same record whether or not either records the height. It is there so a
disputed census names the block it can be re-run against.
provenance—bootstrap/censused/adopted_from_peers { agreed, sampled }. Threedifferent claims about what this node verified, rendered as three different sentences.
Flattened rather than nested so a bare
EpochRecordline still parses, defaulting to the weakestprovenance — reading an unaccounted-for line as
censusedwould upgrade it to "I verified thismyself".
Immutable. A record that DIFFERS from one already held is refused (
PutOutcome::Conflict) andthe held one kept. An identical record with stronger provenance is recorded (a later census
supersedes an adoption); the reverse is not, because evidence does not weaken on re-offer.
The production writer is
bring_up_collateral_records()at node start-up: it records epoch 1,which depends on nothing, and applies the retention policy.
2. Gossip serve —
dig.getCollateralEpochAn OPEN node method answered by the shell beside
dig.health, so a node states what it holds on itsown authority. Open because an epoch record carries nothing secret and a caller re-derives it
whatever the source — authenticating the server would buy nothing the verification does not give.
Each non-answer is a named refusal, never a shaped-like-success zero:
invalid_epoch,not_recorded,record_unreadable,unimplemented_ruleset. The caller is deciding whether tore-census a week of chain history, and those mean three genuinely different things.
3. Sampled sync —
collateral_sync.rs4. Retention — off by default
retention_epochsincollateral.json, absent by default, so a node keeps everything.0reads asthe default rather than "keep nothing" (which would delete the epoch in force). Epoch 1 is never
pruned under any policy — see the defect found by running it, below.
What a receiving node verifies about a peer's record, and what it cannot
A record is adopted because it is RECOMPUTABLE, never because the peer is trusted (NC-12).
Verified, by re-derivation through
EpochRecord::advance— the candidate's own census inputs arethe only thing taken from it; every derived field is this node's arithmetic, through the crate that
owns it. Nothing restates the formula (
equilibrium × multiplier − handicapomits the floor clamp).The whole record must match, not just the requirement — a test pins that a record whose multiplier
is edited while the requirement is left honest is still refused, because the multiplier is what the
buffer's escalation headroom scales.
So a peer cannot lie about any derived quantity, about the ruleset (
advancerefuses anunimplemented
protocol_versionon the candidate and the seed), about the epoch (onlyconsecutive epochs, walking forward from a bootstrap record that depends on nothing), or about the
census height (it must advance).
NOT verified: the census inputs against the chain. A peer reporting a smaller
storescount thanthe chain holds produces a record whose arithmetic is impeccable and whose inputs are fiction.
Re-derivation cannot see that; only a chain read can. This is the user's stated threat direction —
forcing the requirement down — reached by a second path.
What it does about that residue:
Advisory— never adopts, derive from chainSYNC_MIN_POPULATIONAdvisory— the plan says soNoAgreement { verified, needed, best }— an unknown with a reasonPopulationExceeded— the whole sample refused, not trimmedA plurality is never adopted: the plurality is exactly what an attacker with a minority of
identities is trying to produce.
The honest gap, stated in the module doc and in SPEC §24.10: the plan counts distinct
collateralised owners; this node samples distinct peers, and a peer's owner attribution is not
proven on this path. One adversary with many peer identities looks like many owners to the sampler.
This is why adoption is never load-bearing — the sample buys the ability to SKIP an expensive
re-derivation, never the right to be wrong — and why the provenance ranking lets a later census
supersede an adopted record.
The
protocol_versionceiling — D1's remedy, landedApplied at both boundaries:
collateral::requirementrefuses to serve the figures of a recordnaming a ruleset this build does not implement, and
dig.getCollateralEpochrefuses to pass one on(which would launder an unverifiable record through a node that never checked it). Every field of
such a record parses, which is precisely why a forged one drove an 18,482,313.402 DIG
recommendation in a probe.
A defect found by running it, not by a test
A two-epoch retention on a real node dropped epoch 1 and left the store empty. Epoch 1 is the
base case every verification walk terminates at, so a node that discarded it could no longer check
anything a peer offered it. Epoch 1 is now exempt from every retention policy, and the retention
test pins the exemption alongside both sides of the window bound.
Blast radius checked
gitnexus MCP tools are not available in this lane's tool set, so the radius was established by the
sanctioned fallback (CLAUDE.md §2.0 bound 2): repo-wide grep on every symbol touched, plus a read of
each call site. Stated so a reviewer knows which instrument produced it.
EpochRecordStore—collateral.rs(9),control.rs(2, bothin_state_dir(); unaffected).StoredEpoch—collateral.rsonly.Foundnow boxes aStoredRecord; every match arm updated.collateral::requirement—control.rs(2). Signature unchanged.buffer_advice/one_epoch_lock— not touched.control_cli.rs's 3 uses are unaffected.dig-node-service.crates/dig-wallet/src/sage/isuntouched (PR fix(wallet): run CAT attribution in production so a funded wallet can name its $DIG #391's lane).
One latent bug was found and fixed inside that radius:
collateral_margin_setbuilt aCollateralConfigby struct literal, which would have silently erased the newretention_epochspreference whenever an operator set their margin. It now loads-then-modifies.
Evidence — a real node, a real command
Retention, on a real node, both directions:
Tests proven load-bearing by reverting only their fix (committed first, restored after): with
the immutability refusal disabled,
a_record_that_contradicts_a_held_epoch_is_refused…FAILS("a contradiction must be refused, got AlreadyPresent"); with the ceiling check disabled,
a_record_from_an_unimplemented_ruleset_is_not_served_as_a_requirementFAILS. The other 36 stayedgreen in both, which is what makes each a pinpoint rather than a smoke test.
What I could NOT prove by execution — read this before accepting the PR
dign collateral bufferstill requires--roots, and D3's provenance marker stays. Thebrief expected the record to remove it. It cannot, and the reason is the money-lie rule rather
than effort:
pairs_served_by_this_nodeis this node's OWN served(owner, store, root)set, aLOCAL fact. The record carries the NETWORK census, which
control.rs:3283and the contract bothsay in as many words is not a node count — multiplying it by the requirement bills one operator
for the whole network. dig-node has no notion of its own owner puzzle hash on this path, so any
count it produced would be a resembling set, which is the error
hostedStores.listalready madeonce and which is invisible because both produce a plausible number. Left honest; needs its own
ticket.
dign collateral requirementstill answersunknown / not_censusedfor the CURRENT epoch,and correctly so: the only record any node can write without a chain source is epoch 1. Writing
epoch n needs
dig_mirror_coin::census, which takes adig_chainsource_interface::ChainSource— a trait dig-node does not implement and does not depend on today (
dig-mirror-coinis not inits manifest). That wiring is a separate, larger job and is the remaining gap between "a record
exists" and "the requirement is known".
collateral_sync::adoptandverifyare complete,tested and exercised against forged records; what does not exist is the loop that dials N peers,
calls
dig.getCollateralEpochon each, and feeds the responses in. It also cannot runmeaningfully until (2) lands, because without a chain source the population is unknown and
adoptcorrectly returnsAdvisoryon every call. Wiring it before the census exists wouldproduce a code path that can only ever refuse.
sample was exercised only in unit tests, against fixtures built through
advanceso theycannot encode arithmetic the crate does not produce.
Deps (§2.4b)
Every
dig-*indig-node-serviceis at its latest published version.chia-blsis declared0.36.1against a published0.48.0— that is a whole-ecosystem chia-line uplift (chia-*moves asa set, and
dig-tls/dig-identity/dig-mirror-coinare all on the 0.36 line), which dwarfs thischange by orders of magnitude and is a release-first cascade rather than a bump. Named and split, per
§2.4b's own scope limit.
Version
0.162.0→0.163.0— minor. New capability (a record store with a production writer, a newopen node method, a new
dignverb), no removed or renamed API, no wire break:dig.getCollateralEpochis additive and a bare
EpochRecordline still parses.Suite
cargo test -p dig-node-service— all targets green, exit 0: 496 lib tests plus everyintegration target (
openrpc_drift_guard,control_contract_conformance,server,content_serve,https_serve,never_log,spend_audit_e2e, …).cargo fmt --allclean,cargo clippy -p dig-node-service --lib --all-targetsclean.Round 2 (adversarial gate) — two gating findings fixed, plus two smaller denial surfaces
Both gating findings were confirmed by execution, and both are fixed with a permanent test that
goes red when only that fix is reverted. Full detail, the clause-by-clause SPEC recheck and the
revert transcript are in the round-2 comment on this PR.
The fixes
sync_sample_plancapssample_sizeat 9, soagreement_thresholdis a fixed 7 at any population from 20 upward, whileadoptboundedresponders only by
plan.population— up to 1000+. Seven sybils returning an internallyconsistent record with a fictional census input carried it against five honest peers, under-posting
the requirement 3.2x, in the user's stated down direction.
collateral_sync.rsnow refuses aresponder set larger than the one the threshold was computed for, whole and untrimmed, matching the
existing
PopulationExceededdiscipline (AdoptOutcome::SampleExceeded).sat behind
held.record != record.record, so the prefer-own-census rule was reachable only whenthe figures already agreed — it held in every case except the one it exists for.
collateral.rsnow supersedes on one named direction,
AdoptedFromPeers→Censused; every other differingpair is still a
Conflict.the first height in
BTreeMaporder over a peer-supplied id, so one member namingu32::MAXlefta record nothing could advance past. The lowest offered height is now carried.
guard was an
if let (Some, Some). It is now refused asCensusHeightMissing.The last two are only correct TOGETHER.
Noneorders below everySome, so the lowest-heightrule without the missing-height refusal would let one responder strip the height from an honest
cohort and disable the next epoch's check instead. Stated at both code sites, in SPEC §24.10, and
pinned by
a_stripped_census_height_is_not_carried_by_the_lowest_height_rule.What keeps a peer off the superseding side is a discipline, not a type
Worth reading before touching this area.
RecordProvenanceis an ordinary deserialisable field, so awire record naming
censuseddecodes asCensusedand would supersede if handed to the storeunchanged. It is not:
adoptdiscards the provenance a responder sent and stampsAdoptedFromPeersfrom its own tally. That is a discipline held in one function, and any future path that admits a
record from the network must hold it too — a caller passing a deserialised peer record straight to
EpochRecordStore::putreopens the hole. An earlier draft of this PR asserted the stronger propertyas though it were a type guarantee; the gate disproved that by execution and the wording is corrected
in all three places.
For the lane that wires the client — please read
collateral_sync::adoptstill has no production caller; this PR ships the rule, not the wiring.Whether
SampleExceededis unreachable defence-in-depth or a one-identity denial-of-adoptiondepends entirely on how the client collects responses, which is not in this diff: if an attacker can
push the collected set past
plan.sample_sizeby answering once, they can deny adoption to everyone.Cap the collected set at
plan.sample_sizeat the collection site, so the refusal inadoptstays a backstop rather than the primary bound.
Known limitations, now stated in SPEC §24.10 rather than left implicit
EpochRecord::advanceis trusted as the re-derivation oracle. A defect in it is undetectable onthis path because every verifying node reproduces it identically; the mitigation is that it is the
single published implementation, so a divergence is a release-level event.
put's read-then-append has no lock. Correct today with a single writer; a second would need one,and the failure mode is a lost append rather than a corrupt record.
prune's temp file is written beforerestrict_permissionsand follows symlinks. Bounded by statedir access — an attacker who can plant a symlink there can already read the record file.
Version (round 2)
mainmoved to0.163.0while this branch sat at the same value, so the version gate would havefailed and the branch showed
DIRTY. Rebased ontomain; the conflict wasCargo.toml/Cargo.lockonly and was resolved as a version bump to
0.164.0, one increment above whatmainholds. Nosource file was touched in the resolution, so the round-2 gate verdict still describes this diff.