Skip to content

feat(collateral): per-epoch record, gossip serve, sampled sync - #398

Merged
MichaelTaylor3d merged 8 commits into
mainfrom
loop/387-epoch-record
Aug 28, 2026
Merged

feat(collateral): per-epoch record, gossip serve, sampled sync#398
MichaelTaylor3d merged 8 commits into
mainfrom
loop/387-epoch-record

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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::put had no production writer — it was test-only, so
control.collateral.requirement answered unknown / not_censused on 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 epoch

A stored record is the consensus EpochRecord (census inputs, derived required_per_store,
protocol_version) flattened onto the line, plus two node-local fields:

  • census_height — the block the census was taken at, null at epoch 1 where no census
    happened. 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.
  • provenancebootstrap / censused / adopted_from_peers { agreed, sampled }. Three
    different claims about what this node verified, rendered as three different sentences.

Flattened rather than nested so a bare EpochRecord line still parses, defaulting to the weakest
provenance — reading an unaccounted-for line as censused would upgrade it to "I verified this
myself".

Immutable. A record that DIFFERS from one already held is refused (PutOutcome::Conflict) and
the 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.getCollateralEpoch

An OPEN node method answered by the shell beside dig.health, so a node states what it holds on its
own 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 to
re-census a week of chain history, and those mean three genuinely different things.

3. Sampled sync — collateral_sync.rs

4. Retention — off by default

retention_epochs in collateral.json, absent by default, so a node keeps everything. 0 reads as
the 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 are
the 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 − handicap omits 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 (advance refuses an
unimplemented protocol_version on the candidate and the seed), about the epoch (only
consecutive 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 stores count than
the 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:

Situation Behaviour
population unknown to this node Advisory — never adopts, derive from chain
population < SYNC_MIN_POPULATION Advisory — the plan says so
no strict two-thirds agreement NoAgreement { verified, needed, best } — an unknown with a reason
more responders than the chain-derived population PopulationExceeded — the whole sample refused, not trimmed
a responder answers twice, differently both answers discarded; equivocation buys no vote

A 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_version ceiling — D1's remedy, landed

Applied at both boundaries: collateral::requirement refuses to serve the figures of a record
naming a ruleset this build does not implement, and dig.getCollateralEpoch refuses 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.

  • EpochRecordStorecollateral.rs (9), control.rs (2, both in_state_dir(); unaffected).
  • StoredEpochcollateral.rs only. Found now boxes a StoredRecord; every match arm updated.
  • collateral::requirementcontrol.rs (2). Signature unchanged.
  • buffer_advice / one_epoch_locknot touched. control_cli.rs's 3 uses are unaffected.
  • No cross-crate radius: everything is inside dig-node-service. crates/dig-wallet/src/sage/ is
    untouched (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_set built a
CollateralConfig by struct literal, which would have silently erased the new retention_epochs
preference whenever an operator set their margin. It now loads-then-modifies.

Evidence — a real node, a real command

$ dign collateral history                       # empty store, before the writer ran
no collateral epochs recorded yet (…\state\collateral-epochs.jsonl).

$ dig-node run                                  # the production writer
INFO dig_node_service::server: recorded the genesis collateral epoch path=…\collateral-epochs.jsonl
INFO dig_node_service::server: dig-node (local-node) listening version="0.162.0" addrs=http://127.0.0.1:45387

$ dign collateral history
1 epoch(s) recorded:
  epoch 1 · 1.000 DIG per store · v1 rules · 0 advertisement(s) across 0 collateralised owner(s)
    · multiplier 1.000000x · no census (epoch 1 is derived from nothing) · genesis (derived from nothing)

$ curl -s -XPOST localhost:45387 -d '{"jsonrpc":"2.0","id":1,"method":"dig.getCollateralEpoch","params":{"epoch":1}}'
{"result":{"record":{"epoch":1,"protocol_version":1,"census":{"epoch":1,"stores":0,"owners":0,
 "locked":0},…,"required_per_store_dig_base_units":1000,"census_height":null,
 "provenance":{"kind":"bootstrap"}}}}

$ … '{"params":{"epoch":2}}'   → {"record":null,"reason":"not_recorded"}
$ … '{"params":{"epoch":0}}'   → {"record":null,"reason":"invalid_epoch","detail":"…one-based epoch number"}
$ dign collateral history --epoch 42
epoch 42: NOT RECORDED — this node has not censused it and has not adopted it from peers.

Retention, on a real node, both directions:

# collateral.json: {"margin_bp":100,"retention_epochs":2}   (current epoch ≈ 103)
INFO …: truncated the collateral record history at the operator's configured retention dropped=1 policy=KeepEpochs(2)
$ dign collateral history  → no collateral epochs recorded yet     ← THE DEFECT
# after the genesis exemption, same config, same node:
$ dign collateral history  → 1 epoch(s) recorded: epoch 1 · …      ← fixed

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_requirement FAILS. The other 36 stayed
green 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

  1. dign collateral buffer still requires --roots, and D3's provenance marker stays. The
    brief expected the record to remove it. It cannot, and the reason is the money-lie rule rather
    than effort: pairs_served_by_this_node is this node's OWN served (owner, store, root) set, a
    LOCAL fact. The record carries the NETWORK census, which control.rs:3283 and the contract both
    say 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.list already made
    once and which is invisible because both produce a plausible number. Left honest; needs its own
    ticket.
  2. dign collateral requirement still answers unknown / not_censused for 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 a dig_chainsource_interface::ChainSource
    — a trait dig-node does not implement and does not depend on today (dig-mirror-coin is not in
    its manifest). That wiring is a separate, larger job and is the remaining gap between "a record
    exists" and "the requirement is known".
  3. Sampled sync's transport is not wired. collateral_sync::adopt and verify are complete,
    tested and exercised against forged records; what does not exist is the loop that dials N peers,
    calls dig.getCollateralEpoch on each, and feeds the responses in. It also cannot run
    meaningfully until (2) lands, because without a chain source the population is unknown and
    adopt correctly returns Advisory on every call. Wiring it before the census exists would
    produce a code path that can only ever refuse.
  4. No multi-node run. The serve side was exercised over real HTTP against a real node; the
    sample was exercised only in unit tests, against fixtures built through advance so they
    cannot encode arithmetic the crate does not produce.

Deps (§2.4b)

Every dig-* in dig-node-service is at its latest published version. chia-bls is declared
0.36.1 against a published 0.48.0 — that is a whole-ecosystem chia-line uplift (chia-* moves as
a set, and dig-tls / dig-identity / dig-mirror-coin are all on the 0.36 line), which dwarfs this
change 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.00.163.0 — minor. New capability (a record store with a production writer, a new
open node method, a new dign verb), no removed or renamed API, no wire break: dig.getCollateralEpoch
is additive and a bare EpochRecord line still parses.

Suite

cargo test -p dig-node-serviceall targets green, exit 0: 496 lib tests plus every
integration target (openrpc_drift_guard, control_contract_conformance, server,
content_serve, https_serve, never_log, spend_audit_e2e, …). cargo fmt --all clean,
cargo clippy -p dig-node-service --lib --all-targets clean.


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

  • 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 from 20 upward, while adopt bounded
    responders only by plan.population — up to 1000+. Seven sybils returning an internally
    consistent 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.rs now refuses a
    responder set larger than the one the threshold was computed for, whole and untrimmed, matching the
    existing PopulationExceeded discipline (AdoptOutcome::SampleExceeded).
  • An adopted lie could never be superseded by this node's own census. The provenance-upgrade arm
    sat behind held.record != record.record, so the prefer-own-census rule was reachable only when
    the figures already agreed — it held in every case except the one it exists for. collateral.rs
    now supersedes on one named direction, AdoptedFromPeersCensused; every other differing
    pair is still a Conflict.
  • A hostile census height inside an agreeing cohort could wedge every later epoch: the tally kept
    the first height in BTreeMap order over a peer-supplied id, so one member naming u32::MAX left
    a record nothing could advance past. The lowest offered height is now carried.
  • A record after genesis with no census height skipped the advancing check entirely, because the
    guard was an if let (Some, Some). It is now refused as CensusHeightMissing.

The last two are only correct TOGETHER. None orders below every Some, so the lowest-height
rule 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. RecordProvenance is an ordinary deserialisable field, so a
wire record naming censused decodes as Censused and would supersede if handed to the store
unchanged. It is not: adopt discards the provenance a responder sent and stamps AdoptedFromPeers
from 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::put reopens the hole. An earlier draft of this PR asserted the stronger property
as 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::adopt still has no production caller; this PR ships the rule, not the wiring.
Whether SampleExceeded is unreachable defence-in-depth or a one-identity denial-of-adoption
depends entirely on how the client collects responses, which is not in this diff: if an attacker can
push the collected set past plan.sample_size by answering once, they can deny adoption to everyone.
Cap the collected set at plan.sample_size at the collection site, so the refusal in adopt
stays a backstop rather than the primary bound.

Known limitations, now stated in SPEC §24.10 rather than left implicit

  • EpochRecord::advance is trusted as the re-derivation oracle. A defect in it is undetectable on
    this 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 before restrict_permissions and follows symlinks. Bounded by state
    dir access — an attacker who can plant a symlink there can already read the record file.

Version (round 2)

main moved to 0.163.0 while this branch sat at the same value, so the version gate would have
failed and the branch showed DIRTY. Rebased onto main; the conflict was Cargo.toml/Cargo.lock
only
and was resolved as a version bump to 0.164.0, one increment above what main holds. No
source file was touched in the resolution, so the round-2 gate verdict still describes this diff.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Auditing head 36dbcabb53a8cec08efa6a765aa6b82dc9097d40, merge-base 2f0f148be044d4ebc9543077d53b1f70b43df81e.
Isolated worktree C:\tmp\worktrees\dn398-sec, detached; no shared checkout touched.

Batch 1 — mechanical, established

check result
crates/dig-wallet/src/sage/ untouched (PR #391 owns it) CLEAR — dig-wallet does not appear in the changed-file list at all; 11 files, all dig-node-service + Cargo.* + SPEC.md
SPEC.md line endings (PR #391 flipped LF to CRLF and destroyed blame) CLEAR — numstat is 99 0, purely additive. CR-byte count is 0 at base and 0 at head. No flip, no blame damage.
Git authorship CLEAR — all 5 commits Michael Taylor <michael@michaeltaylor.dev>, the one configured identity. No fabricated address.
Diff size 1591+/28-, 11 files

Continuing: verification core, disagreement handling, the protocol_version ceiling re-execution, retention 0 case, provenance asymmetry.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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

Head 36dbcabb53a8cec08efa6a765aa6b82dc9097d40.

Batch 2 — the disagreement layer. Two candidate findings, probes compiling now.

Established by reading, probe pending:

sync_sample_plan (dig-mirror-collateral 0.3.0, src/sync.rs:59-99) returns
agreement_threshold = floor(2 * sample_size / 3) + 1, where sample_size = population.min(SYNC_MAX_SAMPLE)
and SYNC_MAX_SAMPLE = 9. So at any population at or above 20 the plan is sample_size 9, threshold 7,
regardless of how large the population is.

collateral_sync.rs:255-261 then bounds the responder set by plan.population, not by
plan.sample_size:

let responders = by_responder.len() as u64;
if responders > plan.population { return AdoptOutcome::PopulationExceeded { .. } }

and collateral_sync.rs:284-291 applies the threshold as an absolute count:

let best = tally.values().map(|(count, _)| *count).max().unwrap_or(0);
if best < plan.agreement_threshold { return AdoptOutcome::NoAgreement { .. } }

Candidate finding A — the two-thirds rule degrades to a plurality once responders exceed 9.
With population 1000 the threshold is still 7 while up to 1000 responders are accepted. 7 agreeing on a
forged-but-arithmetically-valid record and 5 on the true one gives best = 7 >= 7 -> Adopted at 7/12
= 58%
. SPEC.md +24.10 as written in this same PR says "A plurality MUST NOT be adopted: the
plurality is what an attacker holding a minority of identities is trying to produce"
, and the
NoAgreement doc at collateral_sync.rs:148-152 says the same. Probe running.

Candidate finding B — census_height is excluded from agreement but kept from one arbitrary responder.
collateral_sync.rs:276-281 keys the tally on the consensus record only (correct), but or_insert((0, **record))
captures the first responder's whole StoredRecord in BTreeMap<&str, _> order over the responder id, and
collateral_sync.rs:300 then emits census_height: winner.census_height. A peer inside the agreeing cohort
that returns the honest consensus record with census_height: Some(u32::MAX) poisons the adopted height, and
verify's CensusHeightNotAdvancing guard (:204-208) then rejects every later epoch. Probe running.

Candidate finding C — a null census_height on epoch >= 2 skips the guard. :204 is
if let (Some(previous), Some(found)), so a candidate with census_height: null is never height-checked.
SPEC.md +24.8 states null MUST mean "no census was taken", which is only true of epoch 1.

REACHABILITY, which is why none of these is presumptively gating: grep across crates/ finds
no production caller of adopt / verify / AdoptOutcomelib.rs:42 declares the module and
nothing consumes it. The lane's honest-negative #3 is accurate.

Confirmed correct in this batch

  • Threshold is genuinely strict two-thirds of the plan (floor(2k/3)+1, not ceil), and best < threshold
    is the only adoption path — there is no separate below-threshold branch.
  • Unknown population -> Advisory before any tally (:222-227); sub-SYNC_MIN_POPULATION -> Advisory (:229-234).
  • Population exceeded refuses the WHOLE sample, no trimming (:256-261).
  • Equivocation poisons the responder to None permanently and values().flatten() drops it (:238-250, :268);
    a third answer cannot un-poison it.
  • Re-derivation takes only candidate.record.census from the peer (:187-200) and compares the FULL record.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (3)

Head 36dbcabb53a8cec08efa6a765aa6b82dc9097d40.

Batch 3 — the LIVE surface. Clear, item by item.

The production writer cannot write anything but epoch 1. server.rs bring_up_collateral_records()
calls ensure_bootstrap(&store) -> StoredRecord::bootstrap() -> EpochRecord::bootstrap(), whose epoch
is 1 by construction; there is no epoch parameter anywhere on that path. The only other call is
store.prune(policy, epoch), which deletes and never writes a record. Confirmed.

Retention, all three properties.

  • OFF by default: CollateralConfig::retention_epochs is #[serde(default)] -> None, and
    Default sets None (collateral.rs:72-88).
  • 0 reads as the default: collateral.rs:99-106, None | Some(0) => RetentionPolicy::KeepEverything.
    Pinned by a_config_that_expresses_no_retention_keeps_everything (:1004-1011), which asserts the
    Some(0) case explicitly rather than only the None one.
  • Epoch 1 never pruned: the partition predicate rec.record.epoch >= oldest_kept || rec.record.epoch == GENESIS_EPOCH (:558-560), pinned two-sided by kept == vec![1, 8, 9, 10] (:1052) with
    store.get(7) == Absent beside it, so an off-by-one in either direction fails.
  • KeepEverything early-returns before reading the file (:544-547), so a node that never opts in
    never rewrites it. Also correct: saturating_sub on both terms (:551) means a keep larger than the
    epoch cannot wrap to a cutoff near u64::MAX.

The collateral_margin_set fix is real and has no siblings. control.rs:3357 now reads
CollateralConfig { margin_bp: parsed.margin_bp, ..CollateralConfig::load() }. Grepping the whole
crates/ tree for CollateralConfig { returns exactly one production construction site — this one —
with the other two at collateral.rs:1005 and :1137 inside #[cfg(test)]. No sibling erasure.

The protocol_version ceiling is at both boundaries as claimed. collateral.rs:701-709 refuses to
serve figures (RecordUnreadable), and server.rs collateral_epoch_answer's
StoredEpoch::Found(record) if !record.is_interpretable() returns
{ record: null, reason: "unimplemented_ruleset", protocol_version }. Both delegate to
ProtocolVersion::implemented() rather than comparing against a local constant, so there is no rival
copy of the implemented set. Revert-proof queued.

dig.getCollateralEpoch reachability — I traced the call chain rather than reading the label. The
branch sits in rpc() alongside rpc.discover / dig.health / dig.methods, i.e. above the control
token gate and above the Sec-Fetch-Site provenance gate that dig.getContent carries. That is the
correct tier for it: the payload carries nothing secret, and unlike dig.getContent it cannot drive
the node into becoming a holder. Under the DEFAULT posture the node binds loopback-only and a
non-loopback DIG_NODE_HOST is refused unless DIG_NODE_ALLOW_REMOTE=1 (#1662), so it is not an
anonymous remote surface today. Params are read typed and filter(|e| *e >= 1) rejects 0, absent and
non-numeric rather than defaulting — epoch 1 is not served in answer to a malformed question. Error
bodies carry no filesystem path (the record_unreadable branch has no detail; only the serde branch
does, and a serde error names no path).

Cost asymmetry — checked, and it is bounded, but it is not free. collateral_epoch_answer does a
full read_to_string plus a serde parse of every line on every call, uncached and unlimited, whereas
the dig.health/dig.methods tier it joined answers from memory. Today the file has exactly one line
(the only writer is ensure_bootstrap), and it grows one line per week, so the module's own bound —
"bounded by the calendar rather than by anything a caller controls" — is accurate. Not a finding at the
default posture. Worth a cache if DIG_NODE_ALLOW_REMOTE=1 ever becomes common.

No new or changed dependency. The Cargo.lock diff is one line — the workspace version. dig-mirror-collateral = "0.3" was already declared; collateral_sync adds no dep.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (4)

Head 36dbcabb53a8cec08efa6a765aa6b82dc9097d40. Probes executed in an isolated worktree
(C:\tmp\worktrees\dn398-sec, detached at the head SHA). No shared checkout touched.

CONFIRMED BY EXECUTION — finding B: one peer in the AGREEING cohort poisons census_height and wedges the walk

test collateral_sync::tests::probe_one_peer_in_the_agreeing_cohort_poisons_the_adopted_census_height ... ok

The probe asserts, and the run confirms, all of:

  1. the attacker returns the honest consensus record (so it verifies and tallies WITH the honest cohort)
    but with census_height: Some(u32::MAX);
  2. adopt returns Adopted with the honest consensus record and census_height == Some(u32::MAX);
  3. verify(&adopted, &next_honest_epoch) then returns
    Err(CensusHeightNotAdvancing { previous: 4294967295, found: 6000 }).

Mechanism: collateral_sync.rs:276-281 keys the tally on the consensus record ONLY — correct, and
deliberately so — but or_insert((0, **record)) captures the first responder's entire StoredRecord,
in BTreeMap<&str, _> order over the responder id, and :300 emits census_height: winner.census_height.
So a field that was explicitly excluded from agreement is nonetheless taken from one arbitrary member of
the cohort and then used as a load-bearing input to every later verification.

Exploit: one peer, ordering first (the responder id is peer-side and grindable), inside a sample the honest
majority won. Impact: the node's forward walk is permanently unverifiable — it can never adopt another
epoch. A denial primitive arising from a guard, which is the "persisted state can become a DENIAL
rather than only a protection" class.

CONFIRMED BY EXECUTION — finding C: a null census_height on epoch >= 2 skips the guard entirely

test collateral_sync::tests::probe_a_null_census_height_on_epoch_two_skips_the_monotonicity_guard ... ok

verify at :204 is if let (Some(previous), Some(found)), so a candidate that simply omits the height
is never height-checked, and once adopted it becomes the prior that disables the check for the whole
remaining walk. SPEC.md +24.8 (this PR) states null MUST mean "no census was taken" — true only of
epoch 1. control_cli.rs render_record compounds it: such a record renders as
"no census (epoch 1 is derived from nothing)" on an epoch that is not 1.

Probe A (plurality) — first fixture was BAD; my own control caught it, re-running

assert_ne! on the two camps' requirements failed with left: 1745, right: 1745 — both of my fixtures
landed on the same floor-clamped requirement, so the run proved nothing about the plurality rule. Recording
this rather than quietly re-rolling: an unfalsified claim is not a finding. Re-running with a fixture whose
requirements genuinely differ, plus a probe for finding D below.

NEW — finding D, established by reading, probe running

collateral_sync.rs:58-60 and SPEC.md +24.10 both assert that a node which can census an epoch itself
MUST prefer its own computation, and that put's provenance ranking is what "lets a later census
supersede an adopted record without a conflict."

collateral.rs:416-419 is:

StoredEpoch::Found(held) if held.record != record.record => return Ok(PutOutcome::Conflict { held })

The provenance-upgrade arm at :420-425 is reachable only when the two EpochRecords are identical. So
the supersede-by-own-census property holds exactly when the peers told the truth, and fails in the only case
that matters. Once a false record is adopted, this node's own honest census of that epoch is refused, the
peer-supplied figure is kept, and requirement() serves it thereafter — the user's stated down direction,
made permanent. Probe running.

Reachability, restated because it governs severity

grep -rn "collateral_sync\|adopt(\|AdoptOutcome" crates/ returns exactly one hit outside the module
itself: lib.rs:42, the pub mod declaration. There is no production caller, so none of B, C, D or A
is reachable at this head. They are defects in the trust boundary this PR specifies, not live exposures.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (5)

Head 36dbcabb53a8cec08efa6a765aa6b82dc9097d40.

CONFIRMED BY EXECUTION — finding A: a 7-of-12 PLURALITY is adopted, and it moves the money 3.2x DOWN

PROBE requirements: forged=1745 truth=5625
PROBE A CONFIRMED: 7 of 12 (58%) adopted; SPEC 24.10 says MUST NOT
test collateral_sync::tests::probe2_plurality_is_adopted_when_responders_exceed_the_planned_sample ... ok

My first attempt at this probe FAILED on its own control (both fixtures landed on the same floor-clamped
requirement, left: 1745, right: 1745) and I reported that rather than re-rolling quietly. The corrected
fixture makes the two camps name genuinely different requirements, and the result is unambiguous.

Mechanism. sync_sample_plan caps sample_size at SYNC_MAX_SAMPLE = 9, so at ANY population at or
above 20 the threshold is a fixed 7. collateral_sync.rs:256 then bounds the responder set by
plan.population — not by plan.sample_size — and :285 applies the 7 as an absolute count:

if best < plan.agreement_threshold { return AdoptOutcome::NoAgreement { .. } }

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.
Attacker action: stand up 7 peer identities, all returning the same record with a stores census input
lower than the chain holds — arithmetic impeccable, inputs fiction, so verify passes every one. Five
honest peers answer with the true record. Impact: best = 7 >= 7, the node adopts the attacker's record and
posts 1745 base units per store instead of 5625 — a 3.2x under-post. Its stores go uncollateralised and
unpaid while every surface reports success. This is the user's stated down direction, reached with 7
identities and no need for any supermajority of anything.

The attacker does not even need the honest 5 to be outnumbered in the population — only to be outnumbered
among responders, which an attacker who answers fast and often controls.

SPEC.md +24.10, added in this same PR: "Adoption requires the plan's strict two-thirds agreement
threshold... A plurality MUST NOT be adopted."
collateral_sync.rs:148-152 says the same. The code
contradicts both.

CONFIRMED BY EXECUTION — finding D: an adopted lie cannot be superseded by this node's own later census

test collateral_sync::tests::probe2_an_adopted_lie_cannot_be_superseded_by_this_nodes_own_later_census ... ok

The probe stores a peer-adopted record, then puts this node's own honest census of the same epoch, and
confirms PutOutcome::Conflict { held } with held.record == adopted.record, and that store.get()
still returns the peer's figure afterwards. The lie is permanent.

collateral_sync.rs:58-60 and SPEC.md +24.10 both assert the opposite — that a node which can census an
epoch itself MUST prefer its own computation, and that put's provenance ranking is what "lets a later
census supersede an adopted record without a conflict."
The provenance-upgrade arm (collateral.rs:420-425)
is guarded by held.record != record.record at :417, so it is reachable only when the figures already
agree
. Prefer-own-census therefore holds exactly when the peers told the truth and fails in the only case
it exists for.

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 protocol_version ceiling is genuinely load-bearing

I disabled it (collateral.rs:701, if !rec.is_interpretable() -> if false) in my own worktree and re-ran:

test collateral::tests::a_record_from_an_unimplemented_ruleset_is_not_served_as_a_requirement ... FAILED
  left:  Known { epoch: 6, protocol_version: 65535, required_per_store_dig_base_units: 5000, ... }
  right: Unknown { reason: RecordUnreadable }
test result: FAILED. 37 passed; 1 failed

Exactly the claimed shape: one test goes red naming the D1 defect, everything else stays green. The
mutation was reverted and the worktree verified clean (git status --porcelain shows only my own
untracked .sec/ scratch dir; HEAD still 36dbcab).

The lane's honest negative #1 (--roots / D3) is TRUE, not convenient

collateral.rs:811-813 states it directly: pairs_served_by_this_node MUST be this node's OWN served set,
and "is never the requirement's stores, which is a network census figure the contract says in as many
words is not a node count."
The two quantities are different in kind, and buffer_advice gives the missing
served set its own reason (ServedSetUnknown, :835-837) rather than folding it into
RequirementUnknown — pinned by no_buffer_reason_collides_with_a_census_reason_and_each_has_its_own_remedy.
Substituting a locally-resembling count is the hostedStores.list error class. Refusing was correct.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security: CHANGES-REQUIRED

Head audited: 36dbcabb53a8cec08efa6a765aa6b82dc9097d40 (resolved from
gh pr view 398 --json headRefOid; merge-base 2f0f148be044d4ebc9543077d53b1f70b43df81e).
Audited in an isolated detached worktree C:\tmp\worktrees\dn398-sec. No shared checkout was read
or written; dn387, dn383-a and dig-app-312 were not touched.

The live surface of this PR is clean. Every finding below is in collateral_sync.rs, which has
no production caller at this head (grep -rn "collateral_sync|adopt\(|AdoptOutcome" crates/
returns only lib.rs:42, the pub mod). Nothing here is exploitable against a node running
0.163.0. I am gating anyway, on two grounds stated plainly so the orchestrator can weigh them:

  1. SPEC.md +24.10 ships in this PR as normative text asserting two properties the code in this
    PR does not have. A spec is live the moment it merges — reimplementations are built against it,
    and the next lane wires the transport believing the sampler already enforces what it reads.
  2. This is the specification of the trust boundary itself, not hardening of a working feature. A
    verification module whose central documented invariant is false is a gate that does not gate, and
    both fixes are a few lines now versus a wrong shape locked into a normative spec later.

GATING

1. A 7-of-12 PLURALITY is adopted; the two-thirds rule collapses once responders exceed 9 — HIGH

crates/dig-node-service/src/collateral_sync.rs:256 and :285

sync_sample_plan caps sample_size at SYNC_MAX_SAMPLE = 9 (dig-mirror-collateral 0.3.0
src/sync.rs:59-71), so at any population at or above 20 the threshold is a fixed 7. adopt
then bounds the responder set by plan.population rather than plan.sample_size:

let responders = by_responder.len() as u64;
if responders > plan.population { return AdoptOutcome::PopulationExceeded { .. } }

and applies the 7 as an absolute count: if best < plan.agreement_threshold. With a population of
1000 the sampler accepts up to 1000 responders and still adopts on 7 agreeing.

Exploit. State: a node with a chain source syncing epoch n from peers, honest owner population
1000, plan = 9 sampled / 7 needed. Attacker action: stand up 7 peer identities, all returning one
record whose stores census input is lower than the chain holds — arithmetic impeccable, inputs
fiction, so verify passes each one. Five honest peers answer with the true record. Impact:
best = 7 >= 7, the attacker's record is adopted, and the node posts 1745 base units per store
instead of 5625 — a 3.2x under-post
. Its stores go uncollateralised and unpaid while every surface
reports success. That is the user's stated down direction, reached with seven identities and no
supermajority of anything.

Confirmed by execution:

PROBE requirements: forged=1745 truth=5625
PROBE A CONFIRMED: 7 of 12 (58%) adopted; SPEC 24.10 says MUST NOT
test probe2_plurality_is_adopted_when_responders_exceed_the_planned_sample ... ok

Contradicts SPEC.md +24.10 ("A plurality MUST NOT be adopted: the plurality is what an attacker
holding a minority of identities is trying to produce"
) and collateral_sync.rs:148-152.

Suggested shape (the lane decides): bound the counted set to plan.sample_size before tallying, or
derive the threshold from the number of verified responses actually counted rather than from the
plan's nominal sample size — and refuse when fewer than plan.sample_size distinct responders verified,
since a sample smaller than the plan supports no confidence claim either.

2. An adopted lie can never be superseded by this node's own later census — HIGH

crates/dig-node-service/src/collateral.rs:415-425

collateral_sync.rs:58-60 and SPEC.md +24.10 both assert that a node which can census an epoch
itself MUST prefer its own computation, and that put's provenance ranking is what "lets a later
census supersede an adopted record without a conflict."
It does not. The provenance-upgrade arm at
:420-425 is guarded by :417:

StoredEpoch::Found(held) if held.record != record.record => return Ok(PutOutcome::Conflict { held })

so it is reachable only when the two EpochRecords are already identical. Prefer-own-census
therefore holds exactly when the peers told the truth, and fails in the only case it exists for.

Exploit. Continue finding 1: the node has adopted the attacker's record for epoch n. It later
censuses epoch n itself and derives the true, higher requirement. put returns
PutOutcome::Conflict, keeps the peer-supplied record, and requirement() serves the attacker's
1745 thereafter. The under-post is permanent and self-healing is structurally impossible.

Confirmed by execution:

test probe2_an_adopted_lie_cannot_be_superseded_by_this_nodes_own_later_census ... ok

asserting Conflict { held } with held.record == adopted.record and store.get() still returning
the peer figure. Finding 1 gets a false record in; finding 2 makes it permanent. They are one exploit.

The immutability rule itself is right and must stay — the fix is that a Censused record must be able
to win over an AdoptedFromPeers one even when the figures differ, which is exactly the ranking
RecordProvenance::strength already encodes but that :417 short-circuits before it is consulted.


NOT GATING — defense-in-depth, file as follow-ups

3. One peer in the AGREEING cohort poisons census_height and wedges the forward walk — MEDIUM

crates/dig-node-service/src/collateral_sync.rs:280 and :300

The tally keys on the consensus record only — correct, and deliberate — but or_insert((0, **record))
captures the first responder's entire StoredRecord in BTreeMap<&str, _> order over the
peer-supplied responder id, and :300 then emits census_height: winner.census_height. A field
explicitly excluded from agreement is taken from one arbitrary cohort member and used as a load-bearing
input to every later verify.

Exploit. One peer, ordering first (the responder id is peer-side and grindable), returns the honest
consensus record — so it verifies and tallies with the honest majority — carrying
census_height: Some(u32::MAX). The adopted record keeps that height, and verify's
CensusHeightNotAdvancing guard (:204-208) then rejects every subsequent epoch forever. A guard turned
into a denial primitive, from inside a sample the honest side won.

Confirmed by execution: test probe_one_peer_in_the_agreeing_cohort_poisons_the_adopted_census_height ... ok,
asserting the adopted height is u32::MAX and that the next honest epoch fails with
CensusHeightNotAdvancing { previous: 4294967295, found: 6000 }.

4. A null census_height on epoch >= 2 skips the monotonicity guard entirely — LOW

crates/dig-node-service/src/collateral_sync.rs:204

if let (Some(previous), Some(found)) means a candidate that simply omits the height is never
height-checked; once adopted it becomes the prior that disables the check for the rest of the walk.
SPEC.md +24.8 states null MUST mean "no census was taken", which is only true of epoch 1, so a peer
record for epoch >= 2 with a null height violates the spec's own invariant and is accepted.
control_cli.rs render_record then prints it as "no census (epoch 1 is derived from nothing)" on an
epoch that is not 1. Confirmed: test probe_a_null_census_height_on_epoch_two_skips_the_monotonicity_guard ... ok.

5. dign collateral history is a third boundary the protocol_version ceiling does not cover — LOW

crates/dig-node-service/src/control_cli.rs render_record

requirement and dig.getCollateralEpoch both refuse an uninterpretable record; render_record prints
format_dig(required_per_store_dig_base_units) for one with no marker that this build cannot interpret
the named ruleset. An operator reading dign collateral history would see the D1-class figure presented
as plain fact. Bounded — writing such a record locally needs state-dir write access, which is control-token
equivalent — so a marker beside the vN rules field is sufficient, not a refusal.

6. Retention prunes against the system clock — LOW

crates/dig-node-service/src/server.rs bring_up_collateral_records, collateral.rs:551

prune(policy, current_epoch_now()). With retention opted in, a clock that jumps forward makes
oldest_kept large and drops every record except epoch 1, permanently, on the next boot. Bounded by
being opt-in, by epoch 1's exemption and by records being recomputable. Pruning against
max(held_epoch) rather than the clock would close it.

7. dig.getCollateralEpoch does a full file read + parse per call, uncached and unlimited — LOW

crates/dig-node-service/src/server.rs collateral_epoch_answer

It joined the dig.health / dig.methods open tier, which answers from memory; this one touches disk
every call. The module's own bound is accurate today — one line now, one per week, "bounded by the
calendar rather than by anything a caller controls"
— and the node binds loopback-only by default
(#1662), so this is not an anonymous remote lever. Worth a cache before DIG_NODE_ALLOW_REMOTE=1
becomes common.


CLEARED — what I checked and why it is clear

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

  1. 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.
  2. verify was exercised against forged records only through adopt and directly — not against a real
    peer over the wire, because there is no transport.
  3. I did not audit dig-mirror-collateral itself beyond reading sync_sample_plan,
    agreement_threshold and the three SYNC_* constants. EpochRecord::advance is trusted as the
    re-derivation oracle; if it is wrong, every conclusion about verify inherits that.
  4. Concurrency on the store is untested. put is read-then-append with no lock; two writers racing
    could both see Absent and append twice. Only one writer exists today (start-up), so it is not a live
    race, and get/records are last-wins so a duplicate is not a correctness break — but I did not probe it.
  5. prune's temp-file path (collateral-epochs.jsonl.rewrite) is written before restrict_permissions
    and follows symlinks. Bounded by state-dir write access being control-token equivalent; not probed.
  6. CodeQL/GHAS results were not re-readTest + coverage was still running when I checked.
  7. A totally non-JSON corrupt line reports Absent rather than Unreadable, because line_names_epoch
    needs the line to parse as Value. The module says "usually" and is honest about it; not pursued.

MichaelTaylor3d added a commit that referenced this pull request Aug 28, 2026
…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>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Round 2 — both GATING findings fixed, plus 3 and 4, with revert proofs

Head 72eec10 + this push. Still DRAFT.

Re-gate scope is what the verdict named and nothing wider: collateral_sync.rs's agreement rule
and collateral.rs's provenance arm
. The one thing that reaches slightly further is stated
explicitly below — GENESIS_EPOCH changed from private to pub(crate) so verify could name it,
and the module doc + SPEC.md §24.10 were corrected to match the fixed code. Nothing else in the PR
was touched; the live dign surface, the protocol_version ceiling, the four honest negatives and
the --roots refusal are all as they were verified.

GATING 1 — the 7-of-12 plurality

sync_sample_plan caps sample_size at 9, so agreement_threshold is a fixed 7 at any
population from 20 upward, while adopt bounded responders only by plan.population. Seven
identities out of a thousand cleared a bar that means "seven ninths".

Chosen shape: adopt must not see more responders than the plan drew for. Of the verdict's two
options — refuse the oversized sample, or recompute the threshold from what actually arrived — I
took the refusal, for three reasons:

  1. It is the discipline already in this function. PopulationExceeded refuses whole rather than
    trimming, precisely because a prefix of an attacker-writable set is a set the attacker chose. The
    defect was that the threshold had not travelled with that rule; making the sample bound match
    the population bound is the smaller, more legible change.
  2. Recomputing would require re-deriving agreement_threshold, which is private to
    dig-mirror-collateral. Restating 2k/3 + 1 here is exactly the "do not restate the model's
    arithmetic" failure §24.10 already forbids for the requirement formula, and it would drift.
  3. It fails in the safe direction. A refused sample means the node derives the epoch from chain,
    which is the fallback the whole design already treats as correct.

collateral_sync.rs:306SampleExceeded { sample_size, responders }, placed after the population
check so the more specific accusation still wins.

One behaviour intentionally changed, and it is not a silent one. The pre-existing test
more_responders_than_the_chain_says_owners_exist_refuses_the_whole_sample asserted that 20
honest responders at exactly SYNC_MIN_POPULATION were Adopted. Under the corrected rule that is
7-of-20 and must refuse. That assertion encoded the defect, so it now expects SampleExceeded,
and a new at-the-sample-bound assertion (&over[..9]Adopted) preserves what the old one was
there to prove — that this is not a guard which refuses everything. Flagging it because rewriting a
test to match new behaviour is how a fix gets faked; this one is deliberate and the reason is above.

GATING 2 — an adopted lie could never be superseded

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
.

collateral.rs:454 now excepts one named direction, own_census_supersedes at :235:
AdoptedFromPeersCensused, and nothing else. Deliberately not a strength() comparison —
strength orders evidence for one record, and this orders two different records, which strength's
own doc comment refuses to do.

Immutability is not weakened, and that is checkable by reading rather than by trusting me: every
record reachable from the network carries AdoptedFromPeers (it is the only provenance
adopt stamps, collateral_sync.rs:361), so no responder — at any number of identities — can reach
the incoming side of that predicate. a_peer_still_cannot_overwrite_a_record_this_node_censused
pins both halves: a peer cannot displace a census, and an adopted record cannot displace another
adopted record.

Non-gating 3 and 4, fixed here as suggested

  • 3collateral_sync.rs:346. The tally now carries the lowest census height offered by the
    agreeing cohort, not the first in BTreeMap order over a peer-supplied id. Safe in the other
    direction because every tallied record already passed verify, which refuses any height that does
    not advance past the predecessor's — so the minimum is still strictly above the prior epoch's.
  • 4collateral_sync.rs:243. if let (Some, Some) became a match with an explicit
    CensusHeightMissing refusal for any epoch after genesis. Omitting a field is no longer a way to
    opt out of a check.

5-7, as one comment rather than three issues

Judged and left, with the reasoning stated so the next reader does not have to re-derive it:

  • 5 — dign collateral history is a third boundary the protocol_version ceiling does not cover.
    Left as-is deliberately. history is a listing of what this node already stored, not an
    interpretation of a figure, and each row already carries its own protocol_version for the reader.
    The ceiling exists to stop an unimplemented ruleset being acted on; refusing to list a record the
    node holds would make the operator's only diagnostic tool blind exactly when a ruleset mismatch is
    what they are diagnosing. If the gate disagrees, it is a two-line change and I will take it.
  • 6 — retention prunes against the system clock. Real, and bounded: the worst case is over- or
    under-retaining local history on a node with a wrong clock, which the operator can re-derive.
  • 7 — dig.getCollateralEpoch does an uncached full file read per call. Real. Bounded today by
    the file being one line per epoch, and it is a caching question rather than a correctness one.

Also, as asked

  • EpochRecord::advance is trusted as the re-derivation oracle, and that is now stated in
    §24.10 rather than left implicit. A defect in advance is undetectable on this 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 rather than a per-peer one.
  • put's read-then-append has no lock. Correct today because there is a single writer; a second
    writer would need one, and the failure would be a lost append rather than a corrupt record.
  • prune's temp file is written before restrict_permissions and follows symlinks. Bounded by
    access to the state dir — an attacker who can plant a symlink there can already read the record
    file directly. Worth closing during a hardening pass, not a gate.

Revert proofs — committed first, then reverted by file copy

git checkout <path> is destructive on uncommitted work, so the fixes were committed and pushed at
72eec10 before any revert, and each revert was done with perl -pi against a backup copy and
restored from it. git status was clean after the last restore.

revert suite
all four fixes in place 44 passed, 0 failed
collateral_sync.rs:306 sample bound disabled 3 faileda_seven_strong_plurality_..., seven_of_nine_still_adopts_and_seven_of_ten_does_not, more_responders_than_the_chain_says_owners_exist_...
collateral.rs:454 own_census_supersedes disabled 1 failedthis_nodes_own_census_supersedes_a_peer_adopted_record_they_disagree_with
collateral_sync.rs:346 lowest-height disabled 1 faileda_hostile_census_height_inside_an_agreeing_cohort_does_not_wedge_later_epochs
collateral_sync.rs:243 missing-height refusal disabled 1 faileda_record_for_a_censused_epoch_with_no_height_is_refused_rather_than_skipped

On the fixtures, since this is where a false green would live

  • The plurality probe executes the exploit. truth_and_a_consistent_lie builds both records
    through advance, and asserts inside the helper that the lie passes verify — otherwise the
    test would prove only that verify works, which was never in doubt. It also asserts the lie
    under-posts, pinning the user's stated threat direction rather than merely "differs".
  • Only the owner count varies. stores and locked are held equal so the multiplier and base
    price are identical in both records, leaving the handicap as the single moving part. The first
    version of this fixture varied stores and produced two records with the identical
    requirement — it went red on its own direction assertion, which is the assertion earning its place.
  • Five honest responders are the control. An all-hostile sample would fail to converge for an
    unrelated reason and the test would pass while proving nothing about the threshold.
  • The bound is pinned from both sides. seven_of_nine_still_adopts_and_seven_of_ten_does_not
    asserts at-bound adopts and one-over refuses. Without the first half, a fix that refused
    every sample would pass.
  • The supersession probe reads the store back. Asserting only that the second put returned
    Written would pass for an implementation that appended without superseding.
  • The wedge probe asserts the consequence, not the field. It checks the adopted height, and then
    that a later epoch can still verify against it — the property that was actually denied.

SPEC §24.10, clause by clause against the fixed code

clause code verdict
re-derive through EpochRecord::advance collateral_sync.rs:218 holds
every field of the result must match collateral_sync.rs:227 (derived != candidate.record) holds
MUST NOT restate the model's arithmetic no formula in the module; this is also why finding 1 was fixed by refusal rather than by re-deriving agreement_threshold holds
sized by sync_sample_plan against a chain-derived population; unknown population MUST NOT adopt :262Advisory holds
below SYNC_MIN_POPULATION the plan is advisory :269 holds
strict two-thirds threshold, tallied over the FULL record; a plurality MUST NOT be adopted :326 (key is the full consensus record) + :352, now bounded by :306 was FALSE at round 1, now holds
NEW — refuse a responder set larger than sample_size :306 holds
more responders than the population refuses whole, untrimmed :296 holds
a responder answering twice differently loses both answers :287 holds
NEW — a record after genesis with no census height is refused :243 holds
NEW — carry the lowest height offered by the agreeing cohort :346 holds
NEWAdoptedFromPeers MUST be superseded by Censused, including when they differ collateral.rs:454, :235 was FALSE at round 1, now holds
NEWadvance is trusted as the oracle, stated as a limitation prose only, by construction holds

Two clauses shipped false in round 1; both are now true and both have a test that goes red without
their fix.

Version

main had moved to 0.163.0, which the branch already matched — the version gate would have failed.
Bumped to 0.164.0, one minor above what main holds now, minor because the PR adds capability.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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

Auditing head c10e3ff64fd59b05b8f7502840780ae9ef1a4ae7 (resolved from gh pr view 398 --json headRefOid),
in my own detached worktree C:\tmp\worktrees\sec398. No shared checkout touched.

CONFIRMED — the privacy claim GATING 1's whole design choice rests on is TRUE

Read against the published crate, dig-mirror-collateral 0.3.0
(~/.cargo/registry/src/index.crates.io-*/dig-mirror-collateral-0.3.0, the version
crates/dig-node-service/Cargo.toml:102 declares as "0.3"):

  • agreement_threshold is a private const fn at src/sync.rs:96, body (2 * sample_size) / 3 + 1.
  • src/lib.rs:99 re-exports only sync_sample_plan and SyncSamplePlan — the function is not in the
    public surface at all.
  • SyncSamplePlan does expose sample_size (sync.rs:22) and agreement_threshold (sync.rs:25) as
    public FIELDS, but only for the plan the crate computed from a POPULATION.
  • SYNC_MAX_SAMPLE = 9 (constants.rs:227), SYNC_MIN_POPULATION = 20 (constants.rs:232), and
    sync_sample_plan (sync.rs:59-70) sets advisory_only = population < 20 and
    sample_size = population.min(9). So above the plateau the threshold is a fixed 7 of 9, exactly as
    the lane states.

So "recompute the threshold from the responder count" would indeed mean restating 2k/3 + 1 locally,
which is the failure SPEC §24.10 already forbids. The refusal is the correct choice of the two.

CONFIRMED — adopt/verify have NO production caller at this head

grep -rn "collateral_sync" --include=*.rs . outside the module itself returns only two doc-links in
collateral.rs:46,434,441 and the pub mod collateral_sync; at lib.rs:42. The PR ships the SERVE half
(dig.getCollateralEpoch, server.rs:1073-1091) and the record store, not the adopt half.

This materially changes the exploit calculus on GATING 1 and I am recording it before continuing rather
than at the end: at this head the plurality bug and its fix are both dormant — no peer input reaches
adopt. That does not excuse the defect (the module is pub, and the wiring lane is next), but it means
the round-1 exploit was a library-level demonstration rather than a live one, and it means the new
SampleExceeded refusal cannot presently be used as a denial primitive either. I am still auditing the
denial direction, because the wiring lane will inherit whatever this decides.

Continuing: adjudicating the changed test, attacking the AdoptedFromPeers -> Censused immutability claim,
and re-executing the round-1 probes.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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

Head c10e3ff64fd59b05b8f7502840780ae9ef1a4ae7. Probes run in my own detached worktree
C:\tmp\worktrees\sec398; no shared checkout touched.

GATING 1 probe RE-EXECUTED — the round-1 exploit reproduces on revert, and refuses at head

state cargo test -p dig-node-service --lib collateral
head, unmodified 44 passed / 0 failed (458 filtered) — matches the lane's claim exactly
collateral_sync.rs:306-311 sample bound deleted 41 passed / 3 failed — matches the lane's claim of 3

The three that go red are the right three:
a_seven_strong_plurality_is_not_adopted_when_responders_exceed_the_planned_sample,
more_responders_than_the_chain_says_owners_exist_refuses_the_whole_sample,
seven_of_nine_still_adopts_and_seven_of_ten_does_not.

The exploit output on revert, quoted rather than summarised:

left:  Adopted { record: StoredRecord { record: EpochRecord { epoch: 2, ..., census: EpochCensus
       { epoch: 2, stores: 5625, owners: 2, locked: 112500000 }, ..., handicap_dig_base_units: 3992,
       base_price_dig_base_units: 5625, required_per_store_dig_base_units: 1633 },
       census_height: Some(9000), provenance: AdoptedFromPeers { agreed: 7, sampled: 12 } } }
right: SampleExceeded { sample_size: 9, responders: 12 }

7 sybils out of a 1000-strong population carry a record over 5 honest peers, and the operator's posted
requirement lands at 1633 instead of the truthful 1785 (5625 - 3840). Adopted, stamped
agreed: 7, sampled: 12, in the down direction the module doc names as the user's stated threat. At head
it refuses. The fix is load-bearing and the test is not vacuous.

One honest note on magnitude: the corrected fixture varies only owners, so the under-post it
demonstrates is ~8.5%, not round 1's 3.2x. That is a property of the fixture, not of the defect — an
attacker chooses census inputs freely and would move stores too, which reaches the requirement through
participation_micros -> saturation -> band -> multiplier -> base_price. The test proves the guard
fires; it deliberately understates the damage. That is the right trade for a regression test.

The fixture correction is a REAL discriminator — verified from the failure output

truth_and_a_consistent_lie holds stores and locked equal and varies only owners (40 vs 2).
required_per_store(multiplier_micros, owners) (dig-mirror-collateral-0.3.0/src/requirement.rs:51)
takes no stores argument, and handicap_for_owners (handicap.rs:28) is linear to zero at 1000
owners — so handicap(40) = 3840 and handicap(2) = 3992, giving 1785 vs 1633. The two records
genuinely differ in the money, both verify, and the fixture's own one-sided assertion
(lie.required < truth.required, collateral_sync.rs:447-452) is what stops it silently going vacuous
again. Confirmed against the live failure output above, not inferred.

The changed test — adjudicated independently; the lane's realization is DIRECTIONALLY right but OVERSTATED

The old assertion was at_bound = &over[..20] -> Adopted, where all 20 responders are honest and
unanimous
. 20-of-20 is not a plurality, so the assertion did not itself assert that a plurality is
adoptable. What it ratified was the enabling condition — counting a fixed threshold of 7 against an
unbounded responder set. The lane's sentence "quietly ratified the plurality" should read "ratified the
precondition for it"
. That distinction does not change the verdict on the change; I record it because the
realization is being promoted as a durable lesson and it should be precise.

The replacement does preserve what the original was for. The original's stated purpose was a
two-sided bound ("could not tell a correct guard from one that refuses every sample"). The new
at_sample = &over[..9] -> Adopted (collateral_sync.rs:657-664) supplies exactly that: population 20
gives sample_size = min(20, 9) = 9 and threshold = 7, so 9 honest agreeing responders adopt. An
off-by-one to responders >= plan.sample_size fails it, and so does any mutation that refuses every
sample. seven_of_nine_still_adopts_and_seven_of_ten_does_not pins the same bound from both sides at a
different population, so the guard is confirmed from below twice, independently.

Nothing else in the suite passes only because this test moved. I enumerated every adopt(...) call
site in the module: an_unknown_population_is_advisory_and_never_adopts (9 responders),
one_liar_among_honest_peers_... (9 distinct, incl. the equivocator),
a_sample_that_does_not_converge_... (9), a_hostile_census_height_... (7). All are at or under the
planned sample of 9, so none crosses the new bound and none changed outcome. The only test whose outcome
moved is the one that was edited.

Still to come: revert-probe 2 (the supersession arm), the attack on the immutability claim, SPEC §24.10
clause by clause, and a ruling on finding 5.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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

Head c10e3ff64fd59b05b8f7502840780ae9ef1a4ae7.

GATING 2 probe RE-EXECUTED — confirmed

state result
collateral.rs:452-457 supersession arm reverted to if held.record != record.record 43 passed / 1 failed — matches the lane's claim

The failure is this_nodes_own_census_supersedes_a_peer_adopted_record_they_disagree_with, and it fails
with Conflict { held: ... required_per_store_dig_base_units: 900 ... AdoptedFromPeers { agreed: 7, sampled: 9 } } where Written was expected — i.e. the adopted lie stays permanent and this node's own
census cannot displace it. Round 1's second exploit reproduces, and refuses at head.

The AdoptedFromPeers -> Censused naming (rather than a strength() comparison) is correct as reasoned:
strength()'s own doc (collateral.rs:209-220) says it orders EVIDENCE for one record and never two
different records, and put's second arm still uses it for the identical-record case only
(collateral.rs:458-462). The two arms do genuinely different jobs and using strength() for both would
have made Bootstrap supersedable by Censused, which is not wanted.

I ATTACKED THE IMMUTABILITY CLAIM AND IT DOES NOT HOLD AS WRITTEN — non-gating, but the SPEC sentence is false

The claim, in three places — SPEC.md:7678-7680, collateral.rs:232-234, collateral_sync.rs:66-67:

"a peer answer can only ever carry AdoptedFromPeers provenance, so no responder — however many
identities it holds — can reach the superseding side"

I wrote an executable probe (crates/dig-node-service/tests/sec398_probe.rs in my own worktree; not
committed, removed after the run
). Verbatim output:

PROBE wire bytes: {"epoch":2,...,"census_height":9001,"provenance":{"kind":"censused"}}
PROBE deserialised provenance = Censused
PROBE adopt() -> provenance AdoptedFromPeers { agreed: 7, sampled: 7 }
PROBE direct put -> Written
PROBE store now holds required=1785 provenance=Censused

Three separate facts, and they need separating:

  1. A peer answer CAN carry censused on the wire. StoredRecord.provenance is pub, Deserialize,
    #[serde(tag = "kind", rename_all = "snake_case")] (collateral.rs:192-193, 271-272), so
    {"kind":"censused"} deserialises to RecordProvenance::Censused. That is not a hypothetical shape —
    it is byte-for-byte what collateral_epoch_answer (server.rs:2493-2497) emits for a record the
    serving node censused, so it is trivially copyable off the wire.
  2. adopt DOES sanitise it. It discards the peer's provenance and re-stamps
    AdoptedFromPeers at collateral_sync.rs:365-372. So the security property genuinely holds through
    the sanctioned path.
  3. A DIRECT put reaches the superseding arm. store.put(&from_wire) returned Written and the
    store then held the peer-supplied record with provenance=Censused.

So the guarantee is a discipline inside one function, not a property of the type or of the store. The
sentence as written attributes it to the peer answer, which is false.

Why this is NOT gating at this head: adopt and verify have no production caller
(grep -rn "collateral_sync" outside the module returns only doc-links and lib.rs:42), and put's only
production caller is ensure_bootstrap (collateral.rs:629-630), which stamps Bootstrap. There is no
reachable path today. No live vulnerability, so I do not gate on it.

Why it still matters: the wiring lane is the next lane, and it will read SPEC.md:7678-7680 as a
statement that no sanitisation is needed. The exploit that sentence would license is concrete — a node
holding the honest record as AdoptedFromPeers, handed one differing peer record stamped censused on a
direct-put path, supersedes it. That is round 1's exploit restored at one identity instead of seven,
and it moves the operator's posted collateral. Recommended, in this PR because it is two sentences and the
files are open: reword all three sites to say what is actually true — "adopt discards a peer's stated
provenance and re-stamps AdoptedFromPeers, so no record that came through it can reach the superseding
side; a record put by any other route MUST have its provenance re-derived locally, never taken from the
wire."

Confirmed clean

  • GENESIS_EPOCH widening (collateral.rs:48) is pub(crate), not pub — it cannot escape the
    crate. Used at exactly collateral.rs:598 and collateral_sync.rs:242,864. It is the only symbol
    widened in the diff. Necessary (verify names it) and carries nothing else.
  • Version 0.163.0 (origin/main) -> 0.164.0 in Cargo.toml AND Cargo.lock, both agreeing.
    A feat PR on a bumped base needs a minor; correct. Latest release is v0.160.0, so no tag collision.
  • SPEC diff is additive: 126 / 0 on --numstat, zero ^- lines, zero CR bytes in the whole file
    — no CRLF flip of the kind PR fix(wallet): run CAT attribution in production so a funded wallet can name its $DIG #391 did today.
  • Authorship: all 7 commits Michael Taylor <michael@michaeltaylor.dev>, the one correct identity.
  • No secrets: the only secret/token hits in the diff are prose ("carries nothing secret", "reason
    token"). No key material, no credential, no projectId.
  • No scratch files: one file added (collateral_sync.rs); nothing else new.

Verdict next.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security round 2 — PASS (with one non-gating finding)

Head audited: c10e3ff64fd59b05b8f7502840780ae9ef1a4ae7, resolved from
gh pr view 398 --repo DIG-Network/dig-node --json headRefOid, not from the dispatch brief. Merge-base
2f0f148be044d4ebc9543077d53b1f70b43df81e. All work in my own detached worktree
C:\tmp\worktrees\sec398; no shared checkout was touched, dn387 and dig-app-312 untouched, and
my worktree ended git status --porcelain empty with the probe file removed.

Scope as briefed: the agreement rule, the provenance arm, the changed test, the two things reaching
further, the fixture correction, findings 3/4/5, and the SPEC. Round 1's cleared items were not
re-opened.


The headline: the changed test does NOT hide a regression, and both fixes are load-bearing

Every claim in the lane's probe table reproduced exactly.

state result claimed
head, unmodified (--lib collateral) 44 passed / 0 failed 44 / 0
head, unmodified (full lib) 502 passed / 0 failed 502 / 0
collateral_sync.rs:306-311 sample bound deleted 3 failed 3
collateral.rs:452-457 supersession arm reverted 1 failed 1
collateral_sync.rs:346-348 lowest-height deleted see below 1
collateral_sync.rs:242-246 missing-height deleted see below 1

Round 1's first exploit reproduces on revert, verbatim from the failure output:

left:  Adopted { ... census: EpochCensus { epoch: 2, stores: 5625, owners: 2, locked: 112500000 },
       handicap_dig_base_units: 3992, base_price_dig_base_units: 5625,
       required_per_store_dig_base_units: 1633,
       census_height: Some(9000), provenance: AdoptedFromPeers { agreed: 7, sampled: 12 } }
right: SampleExceeded { sample_size: 9, responders: 12 }

7 sybils out of a 1000-strong population carry a record over 5 honest peers and drive the operator's
posted requirement to 1633 instead of the truthful 1785, in the down direction the module doc
names as the user's stated threat. At head it refuses.

Round 1's second exploit reproduces on revert: the adopted lie comes back as
Conflict { held: ... required_per_store_dig_base_units: 900 ... AdoptedFromPeers { agreed: 7, sampled: 9 } }
where Written was expected — permanent, and undisplaceable by this node's own census. At head it is
superseded.


Adjudicating the changed test, independently

Was 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
assertion was at_bound = &over[..20] -> Adopted, where all 20 responders are honest and unanimous.
20-of-20 is not a plurality, so the assertion did not itself assert that a plurality is adoptable. What it
ratified was the enabling condition — counting a fixed threshold of 7 against an unbounded responder
set. The lane's sentence "quietly ratified the plurality" should read "ratified the precondition for
it"
. I record this because the realization is being promoted as a durable lesson and it ought to be
precise; it does not change the verdict on the change.

A behaviour is genuinely lost: a node hearing unanimously from all 20 owners of a 20-owner network no
longer adopts. That loss is correct. The threshold is a supermajority of the planned sample, the plan
caps the sample at 9 (dig-mirror-collateral-0.3.0/src/constants.rs:227), and the fallback for a refused
sample is the node censusing from chain itself — which the module calls the stronger path, since adoption
"buys the ability to SKIP an expensive historical re-derivation, never the right to be wrong". This
fails in the safe direction: a denied adoption degrades to a more expensive but correct computation, never
to a wrong figure.

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
that refuses every sample"). The new at_sample = &over[..9] -> Adopted (collateral_sync.rs:657-664)
supplies exactly that: population 20 gives sample_size = min(20, 9) = 9 and threshold = 7, so 9 honest
agreeing responders adopt. An off-by-one to responders >= plan.sample_size fails it, and so does any
mutation that refuses every sample. seven_of_nine_still_adopts_and_seven_of_ten_does_not
(collateral_sync.rs:508-539) pins the same bound from both sides at a different population, so the
over-strictness guard exists twice, independently.

Is anything else in the suite passing only because this test moved?

No. I enumerated every adopt(...) call site in the module and counted distinct responders in each:
an_unknown_population_is_advisory_and_never_adopts (9), one_liar_among_honest_peers_... (9 distinct,
including the equivocator, which the code counts toward responders — verified by its sampled: 9
assertion), a_sample_that_does_not_converge_... (9), a_hostile_census_height_... (7). All sit at or
under the planned sample of 9, so none crosses the new bound and none changed outcome. grep confirms
adopt( has no call site outside the module. The full-lib run is 502/0, unchanged. The only test whose
outcome moved is the one that was edited.


GATING 1 — the design choice, verified rather than accepted

The whole choice rests on agreement_threshold being private, so I read the published crate rather
than the repo: ~/.cargo/registry/src/index.crates.io-*/dig-mirror-collateral-0.3.0, the version
crates/dig-node-service/Cargo.toml:102 declares as "0.3".

  • agreement_threshold is a private const fn at src/sync.rs:96, body (2 * sample_size) / 3 + 1.
  • src/lib.rs:99 re-exports only sync_sample_plan and SyncSamplePlan — the function is not in the
    public surface at all.
  • SYNC_MAX_SAMPLE = 9 (constants.rs:227), SYNC_MIN_POPULATION = 20 (constants.rs:232), and
    sync_sample_plan (sync.rs:59-70) gives sample_size = population.min(9) for every non-advisory
    population — so the threshold is a fixed 7 of 9 above the plateau, exactly as claimed.

The privacy claim is TRUE, so "recompute the threshold from the responder count" would mean restating
2k/3 + 1 locally, which SPEC §24.10 already forbids at SPEC.md:7636. The refusal is the correct
choice of the two
, and it matches the existing PopulationExceeded discipline. With responders <= 9
enforced and threshold = 7, best >= 7 now implies best/responders >= 7/9 > 2/3 — a supermajority is
arithmetically guaranteed, which is what makes the plurality clause true rather than merely asserted.

Both refusals remain live and distinct: 10-20 responders is SampleExceeded, >20 is PopulationExceeded,
and the test pins both.


GATING 2 — the provenance arm, and I attacked its immutability claim

The AdoptedFromPeers -> Censused naming rather than a strength() comparison is correct.
strength()'s own doc (collateral.rs:209-220) says it orders EVIDENCE for one record and explicitly
never orders two different records; put's second arm still uses it, for the identical-record case only
(collateral.rs:458-462). Using strength() for both would additionally have made Bootstrap
supersedable by Censused, which nothing wants. The distinction is real and it holds.

FINDING (non-gating) — the security sentence is false as written, in three places

SPEC.md:7678-7680, collateral.rs:232-234, collateral_sync.rs:66-67 all say:

"a peer answer can only ever carry AdoptedFromPeers provenance, so no responder — however many
identities it holds — can reach the superseding side"

I wrote an executable probe rather than reasoning about it (tests/sec398_probe.rs in my worktree only,
not committed, deleted after the run). Verbatim output:

PROBE wire bytes: {"epoch":2,...,"census_height":9001,"provenance":{"kind":"censused"}}
PROBE deserialised provenance = Censused
PROBE adopt() -> provenance AdoptedFromPeers { agreed: 7, sampled: 7 }
PROBE direct put -> Written
PROBE store now holds required=1785 provenance=Censused

Three facts, which need separating:

  1. A peer answer CAN carry censused on the wire. StoredRecord.provenance is pub, Deserialize,
    #[serde(tag = "kind", rename_all = "snake_case")] (collateral.rs:192-193, 271-272). That is not a
    hypothetical shape — it is byte-for-byte what collateral_epoch_answer (server.rs:2493-2497) emits
    for a record the serving node censused, so an attacker copies it off the wire.
  2. adopt DOES sanitise it, discarding the peer's provenance and re-stamping AdoptedFromPeers at
    collateral_sync.rs:365-372. The security property genuinely holds through the sanctioned path.
  3. A direct put reaches the superseding arm, returning Written, and the store then holds the
    peer-supplied record with provenance = Censused.

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. adopt and verify have no production caller
at this head (grep -rn "collateral_sync" outside the module returns only doc-links plus lib.rs:42),
and put's only production caller is ensure_bootstrap (collateral.rs:629-630), which stamps
Bootstrap. There is no reachable path today, so there is no live vulnerability — and my standing rule is
to gate on live defects and merely name defense-in-depth.

Why it still matters enough to write down. The wiring lane is next and it will read
SPEC.md:7678-7680 as a statement that no sanitisation is needed. The exploit that sentence would license
is concrete: a node holding the honest record as AdoptedFromPeers, handed one differing peer record
stamped censused on any direct-put path, supersedes it — round 1's exploit restored at one identity
instead of seven, moving the operator's posted collateral. Recommended in this PR (two sentences,
files already open): say what is actually true — "adopt discards a peer's stated provenance and
re-stamps AdoptedFromPeers, so no record that came through it can reach the superseding side; a record
put by any other route MUST have its provenance re-derived locally, never taken from the wire."
If that
is deferred instead, it needs a ticket that blocks the wiring lane, not a backlog entry.


Findings 3 and 4 — reasoning verified, and they are COUPLED

Both re-executed (I ran these too, not only the first two):

revert failing test result
:346-348 lowest height a_hostile_census_height_inside_an_agreeing_cohort_does_not_wedge_later_epochs 1 failed
:242-246 missing height a_record_for_a_censused_epoch_with_no_height_is_refused_rather_than_skipped 1 failed

Finding 3's safety reasoning holds. verify runs at collateral_sync.rs:319, before the min-update
at :346, and verify's (Some(previous), Some(found)) if found <= previous arm (:239-241) refuses
any non-advancing height. So every tallied height is strictly above the predecessor's, and the minimum of
a set of strictly-greater values is still strictly greater. The min is taken per tally entry, i.e. per
camp, so it is the lowest offered by the agreeing cohort exactly as SPEC §24.10 now states.

The two fixes must land together, and I want this on the record because it is the strongest thing in the
round and it is not stated anywhere in the PR.
census_height is Option<u32>, and Rust's Ord puts
None < Some(_). Taking the minimum without finding 4's refusal would be strictly worse than the
wedge it fixes
: a single peer inside an honest cohort offering census_height: null would become the
minimum, stripping the height from the adopted record — and a None prior height makes the next epoch's
advancing check fall through verify's _ => {} arm entirely, silently disabling it. Finding 4's
CensusHeightMissing refusal is what makes finding 3's min-taking sound. They are correct together and
would have been a regression apart. I checked the ordering is right at head: it is.

I also confirmed the genesis carve-out at :242 is not reachable as a bypass. A peer offering
epoch: 1, census_height: null cannot get there: advance is defined only for prior.epoch + 1
(prior is at least epoch 1), so the derived record's epoch is at least 2 and derived != candidate.record
refuses it as ArithmeticMismatch or NonSequential before the height match runs.


Finding 5 — I AGREE with the lane, and the inconsistency is deliberate, not accidental

is_interpretable is applied at exactly three call sites, and I checked all of them:

  • collateral.rs:739control.collateral.requirement, the operator's funding figure.
  • collateral_sync.rs:203,208 — into a verification, on both the prior and the candidate.
  • server.rs:2498dig.getCollateralEpoch, the peer serve.

Those are precisely the three surfaces SPEC §24.8 enumerates at SPEC.md:7610-7613: "not by
control.collateral.requirement, not by dig.getCollateralEpoch, and not into a verification."

dign collateral history is not in that list, and render_record (control_cli.rs:1294) is private to
control_cli.rs and reachable only from collateral_history. The code and the normative text agree,
so the asymmetry is a documented boundary rather than a place the ceiling was forgotten.

The distinction the lane draws is the right one and it is not merely rhetorical. The two ceilinged
surfaces both hand a figure to something that will act on it — an operator funding collateral, and a
peer using it as a verification input, where passing it on unremarked launders an unverifiable record
through a node that never checked it. History hands a listing of local state to the machine's own
operator. Refusing there would blind the only diagnostic at the exact moment a ruleset mismatch is what
is being diagnosed, and would present as "this node has recorded nothing" — the wrong diagnosis, from a
silent refusal.

One non-gating improvement I would take. render_record prints v{N} rules beside the DIG figure,
which is a fact, not a warning: an operator on a v1 build reading v2 rules has to already know v2
exceeds what the build implements to draw the conclusion. A trailing marker on those rows — e.g.
— UNIMPLEMENTED RULESET, this build cannot vouch for this figure — keeps the whole diagnostic while
removing the ambiguity, and costs one if. Not a gate: the figure is not funded from here, and the
--json form carries protocol_version for a machine consumer.


SPEC §24.10, clause by clause — every clause, not only the two that were wrong

clause file:line satisfying it verdict
re-derive via advance, every field must match, never restate the arithmetic (SPEC:7634-7638) collateral_sync.rs:216-229 (derived != candidate.record) TRUE
sample sized by sync_sample_plan against a chain population; unknown population MUST NOT adopt (SPEC:7644-7646) collateral_sync.rs:262-268 TRUE
below SYNC_MIN_POPULATION the plan is advisory (SPEC:7647) collateral_sync.rs:269-274 TRUE
two-thirds threshold over the FULL record; a plurality MUST NOT be adopted (SPEC:7648-7650) collateral_sync.rs:316-358; key is the whole EpochRecord TRUE, and red without the fix (revert 1 -> Adopted{...owners: 2...})
NEW — refuse more responders than sample_size, whole and untrimmed (SPEC:7651-7654) collateral_sync.rs:306-311 TRUE, red without it
more responders than the population refuses whole, not trimmed (SPEC:7655-7656) collateral_sync.rs:296-301 TRUE
an equivocator has BOTH answers discarded (SPEC:7657) collateral_sync.rs:278-290 + values().flatten() at :318 TRUE
NEW — epoch > 1 with no census height MUST be refused, not skipped (SPEC:7658-7661) collateral_sync.rs:242-246 TRUE, red without it
NEW — carry the LOWEST height offered by the agreeing cohort (SPEC:7662-7665) collateral_sync.rs:346-348 TRUE, red without it
the owner-vs-peer attribution limitation (SPEC:7667-7672) module doc collateral_sync.rs:56-67 TRUE as a limitation
NEWAdoptedFromPeers MUST be superseded by Censused for the same epoch (SPEC:7674-7677) collateral.rs:235-238, 450-457 TRUE, and red without the fix
NEW — "no other pair may supersede; a peer answer can only ever carry AdoptedFromPeers" (SPEC:7678-7680) collateral.rs:235-238 first half TRUE; second half FALSE as written — see the finding above
NEW — the advance oracle limitation (SPEC:7682-7687) honest statement of a real gap TRUE

Both clauses that shipped false in round 1 are now true and each is proved by a test that goes red
without its fix
— I executed both reverts rather than reading the tests. The module doc carried the same
two falsehoods and both were corrected in the same unit: the old
collateral_sync.rs doc said "put's provenance ranking is what lets a later census supersede an
adopted record without a conflict"
(false — the held.record != record.record arm returned Conflict
first, so the ranking never ran), replaced at :62-67; and the old "Agreement is a strict two-thirds
supermajority of a bounded sample"
is now bounded in fact by the new bullet at :48-51. Verified by
diffing 36dbcab against head, not by taking the claim.

The one clause I mark false is the twelfth row, and it is the finding above. It is a precision defect
rather than a behavioural falsehood — the system as built has no path where a peer answer reaches the
superseding side — but it attributes the property to the wrong mechanism, which is exactly the mistake
that costs the next lane.


The other checks

  • GENESIS_EPOCH widening (collateral.rs:48) is pub(crate), not pub — it cannot leave the
    crate, so no public API grew. Used at exactly collateral.rs:598 and collateral_sync.rs:242,864. It
    is the only symbol widened anywhere in the diff (git diff | grep '^+.*pub(crate)' returns one line).
    Necessary, and carries nothing else.
  • Version. merge-base 0.162.0, origin/main 0.163.0, branch 0.164.0, and Cargo.lock agrees
    (dig-node-service 0.164.0). A feat PR needs a minor over the base it will land on: correct. Latest
    release is v0.160.0, so no tag collision.
  • SPEC diff is purely additive: --numstat is 126 / 0, zero ^- lines, and zero CR bytes in the
    entire file
    — no CRLF flip of the kind PR fix(wallet): run CAT attribution in production so a funded wallet can name its $DIG #391 did today, and no blame destruction.
  • Authorship: all 7 commits Michael Taylor <michael@michaeltaylor.dev> — the one correct identity,
    no fabricated address.
  • No secrets. The only secret/token-shaped hits in the diff are prose ("carries nothing secret",
    "its own reason token"). No key material, no credential, no projectId, no .test-credentials value.
  • No scratch files. One file added (collateral_sync.rs). My own probe file was untracked in my
    worktree and is deleted; final git status --porcelain empty.
  • Amplification on the new peer-reachable surface. dig.getCollateralEpoch (server.rs:1073-1091,
    answered before the control-token gate, so anonymous) costs one read_to_string of the record file
    per request. The file grows one line per epoch (a week) plus one per supersession, all written locally
    by ensure_bootstrap/putno caller-controlled quantity is in that bound, so bytes-in to
    work-out stays flat. Round 1 cleared this surface; I re-derived the bound rather than assuming it,
    because the supersession arm this PR adds is a new source of extra lines. It is node-local and
    bounded by the operator's own census cadence.

Merge preconditions — NOT cleared, and not mine to clear

Stating these so nobody reads my PASS as "mergeable":

  • check-merge-preconditions.sh --repo DIG-Network/dig-node --pr 398 -> BLOCKED (exit 1).
  • All five required checks are ABSENT, not redLint commit messages, Check version increment,
    Rustfmt, Clippy, Test + coverage each report no run attached. An absent required context does
    not appear in the rollup at all, so this must be asserted by name, which the script does.
  • mergeStateStatus=DIRTY. I checked what actually conflicts with git merge-tree --write-tree
    (non-mutating): Cargo.toml and Cargo.lock only — the version line, three-way between
    0.162.0 / 0.163.0 / 0.164.0. SPEC.md auto-merges and no source file conflicts.
  • Unresolved review threads: 0. Draft: true, correctly — the gate round is still returning.

Condition on this verdict: it is a PASS against c10e3ff specifically. A version-only conflict
resolution does not change the audited code and the verdict carries. If the resolution touches
collateral.rs, collateral_sync.rs or SPEC.md §24.10, re-gate — I did not audit a merged tree.

What I did NOT cover

  • Clippy and rustfmt were not run. No CI run is attached at this head; the required checks must go
    green on their own before merge.
  • Round 1's cleared items were not re-opened, as instructed: the protocol_version ceiling being
    load-bearing, the genuineness of the real dign output, crates/dig-wallet/ being absent from the
    diff, the chia-bls split, and the four honest negatives. I relied on round 1 for those.
  • No integration/e2e run, and no running node. Everything here is --lib plus one probe binary. In
    particular I did not observe dig.getCollateralEpoch answering over a real socket, or
    dign collateral history rendering on a real machine — §2.6's end-to-end bar is not met by this audit
    and I make no claim about it.
  • dig-mirror-collateral itself was not audited. SPEC §24.10 now states plainly that advance is
    trusted as the re-derivation oracle and a defect in it is undetectable on this path. That is honest and
    correctly disclosed; it is also a real, unaudited dependency for every figure here.
  • The wiring lane's client is not in this diff, so the caller-side question — whether the collected
    responder set is capped at plan.sample_size by construction, or is whatever answers — is unanswered.
    It decides whether SampleExceeded is unreachable defence-in-depth or a one-identity denial of
    adoption. Recommendation for that lane, non-gating here: cap the collected set to the peers this
    node itself selected, before calling adopt. The failure direction is safe either way (a refused
    sample falls back to censusing from chain), which is why this is a recommendation rather than a gate.

Verdict: PASS

Both gating fixes are correct, load-bearing, and proved by execution. The changed test does not hide a
regression: the assertion it replaced ratified the precondition for the plurality rather than the
plurality itself, the replacement supplies the over-strictness guard the original existed for, and nothing
else in the suite moved. Findings 3 and 4 are correct and — a point the PR does not make — are only
correct together. Finding 5's judgement is right and consistent with SPEC §24.8's own enumeration.

One non-gating finding: the "a peer answer can only ever carry AdoptedFromPeers provenance" sentence is
false as written in three places, and I proved it by executing a probe. It is not live — nothing calls
adopt, and put's only production caller stamps Bootstrap — so it does not gate. It should be
reworded in this PR, and it must not survive into the wiring lane unreworded.

Merge still blocked on five absent required checks and a version-line conflict, neither of which is a
security matter.

MichaelTaylor3d and others added 8 commits August 28, 2026 15:27
…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>
MichaelTaylor3d added a commit that referenced this pull request Aug 28, 2026
…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>
MichaelTaylor3d added a commit that referenced this pull request Aug 28, 2026
…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>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 28, 2026 22:53
@MichaelTaylor3d
MichaelTaylor3d merged commit 26e640d into main Aug 28, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/387-epoch-record branch August 28, 2026 22:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-epoch collateral record: database, gossip serve, sampled sync, retention off by default

1 participant