Skip to content

fix(collateral): a degraded source can no longer seal a low requirement - #406

Draft
MichaelTaylor3d wants to merge 5 commits into
mainfrom
loop/405-census-degraded-source
Draft

fix(collateral): a degraded source can no longer seal a low requirement#406
MichaelTaylor3d wants to merge 5 commits into
mainfrom
loop/405-census-degraded-source

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

DRAFT — DO NOT MERGE. Nothing in this branch has been compiled or executed: the machine is at
ENOSPC (see "What I could not prove" below). The gate round has not run.

Closes #405. Blocker on #377, which is the core of DIG-Network/dig_ecosystem#3166.

The defect

A transient degraded chain read wrote a permanently uncorrectable low collateral
requirement. Three individually-correct properties composed into it:

  1. The census reads its candidate population through one chain view, which can be briefly thin.
  2. EpochRecordStore::put never let a later record supersede one this node had censused — the
    protection that stops a lying peer overwriting a node's own answer.
  3. catch_up never re-censused an epoch it had already recorded — what made the walk idempotent
    and cheap.

A thin view can only OMIT coins, so it under-counts, so the requirement comes out LOW — the
direction that leaves stores under-collateralised — and the two mechanisms that would repair it are
the two protecting the record from a peer. Today every record is (0,0,0) and correct; #377 is what
makes the under-count reachable.

The shape chosen, and the two I rejected

Chosen: shape (2) — self-supersession, narrowed to a one-bit, one-way ladder. put now admits
exactly one further superseding pair: a record this node censused, replaced by a later census of
its own
, of the same epoch, at the same census height, counting strictly more stores.
catch_up re-censuses its target epoch and no earlier one, which is what turns that admission
into a live repair path rather than a dormant mechanism.

Why the upward direction only — and why this needs no total order. A source can OMIT a coin for
free, by staying quiet. To add one it must have been posted on chain against the requirement, with
a creating spend the census executes. One direction is unforgeable, the other is free, so admitting
only the unforgeable one gives a strict monotone ladder — which is the answer to #405's stated
objection to this shape, that "strictly better" needs an order that does not exist. It does not: it
needs one bit, and the bit is a store count on a fixed block.

Rejected — (1) route the census through NC-12 corroboration. Not implementable in this repo.
chia-query 0.19 grades only the by-coin-id reads (peer::OptAnswer); the census's down-direction
lever is the population read, coin_records_by_puzzle_hash, which is ungraded
(router.rs:673, peer_then_coinset). And the node has no second independent view to corroborate
against: its one fabric is oracle-first, so dig-wallet/src/sage/sources.rs::independence_group_for
correctly places it in coinset's own group, and building a second pool is the defect
dig_ecosystem#2761 records. Shape (1) is a chia-query change plus a release-first cascade — real
work, but not this blocker, and it composes with what lands here rather than replacing it.

Rejected — (3) refuse to record when the source is degraded. No such signal exists, and the
obvious candidate is a trap. Exclusions::unreadable > 0 looks like the degradation flag; reading
dig-mirror-coin 0.7.0 census.rs:673-681 shows it means "not a mirror coin at all" — a stranger's
dust at the shared puzzle hash, which anyone can produce for nothing. Refusing on it would deny the
census permanently on free noise. The genuinely degraded case it resembles, an absent creating
spend, is already fail-closed upstream: Unauthenticated is an Err that ends the census, and
the crate says so in the same file. What remains is a source omitting coins coherently, and no
in-census signal can see that.

Blast radius

The gitnexus index for this repo is stale, so this was measured with grep + direct read
(§2.0 bound 2, and #405's own note). own_census_supersedes and EpochRecordStore::put have one
call path each into collateral_census::record_one; catch_up has exactly one production caller,
server.rs::spawn_collateral_census, plus the crate's own tests. CatchUp gained a field and is
constructed in one module and read in one. Nothing outside dig-node-service names any of them.

The change

file what
crates/dig-node-service/src/collateral.rs own_recensus_supersedes, and the put arm that must be checked before the evidence-strength arm — both censuses carry the same provenance, so an arm ordering evidence reads them as equal and answers AlreadyPresent, silently keeping the low count
crates/dig-node-service/src/collateral_census.rs refresh, called only for the walk's target; CatchUp::superseded
crates/dig-node-service/src/server.rs a repair is logged at WARN — the figure it replaces was served to the operator in the meantime
SPEC.md §24.8, §24.8a, §24.10 the repair clause, the re-census requirement, the remedy-table row, and §24.10's "no other pair may supersede" — which this change makes false and which is amended rather than left

Both invariants, and how each is held

A peer still cannot supersede anything. Both sides of the new arm must carry Censused, and
collateral_sync::adopt discards whatever provenance a responder sent and stamps
AdoptedFromPeers. Asserted directly, in the direction that would hurt:
a_peer_record_counting_more_stores_cannot_supersede_this_nodes_own_census — a peer able to raise
the count inflates every operator's collateral just as surely as one able to lower it.

The walk stays cheap where cheapness matters. No epoch before the target is ever re-censused, so
history is computed exactly once and #404's cold-start cost is untouched
(no_epoch_before_the_target_is_ever_re_censused pins this by asserting the stop names the target
and not an earlier held epoch). What does change: the steady state goes from zero chain reads to
one census of one epoch per pass. That is the price of the repair, it is stated in SPEC §24.8a
rather than glossed, and it buys the attacker's cost going from one badly-timed window per
seven-day epoch
to sustained degradation for the whole epoch — because a lower re-census cannot
supersede a higher one.

Tests

tests/collateral_census_degraded_source.rs is the probe #405 asked for: it degrades the source
mid-sequence, not the record, and runs the real walk against it — thin view records 1 store →
healthy view of the same block supersedes to 2 and moves the requirement → thin view again
changes nothing. Every coin is built by a genuine CAT spend, reusing dig-mirror-coin 0.7.0's
published tests/support/mod.rs verbatim, because the census authenticates a candidate by executing
its creating spend and a hand-built CoinRecord never reaches that path.

The degraded view hides one of two coins and keeps the other. Hiding both would read as the
harsher fixture and is exactly the one that cannot distinguish a repair from a walk that recorded
nothing, since no surviving figure would be left to move.

Store-layer tests assert the ladder from both sides separately rather than as one negation: the
repair, the refused under-count, a re-census of a different block still being a conflict (so the
same-height requirement is load-bearing and not decoration), and the peer refusal.

What I could not prove, stated plainly

Nothing here has been compiled or run. The machine hit ENOSPC (os error 112) — C: at 100%
with 519 MB free of 1.9 T. I reclaimed this lane's own target/ twice per CLAUDE.md §1.6 and touched
no sibling lane's; a cold rebuild of this crate needs roughly 7 GB and got to dig-wallet before the
disk reached zero. Cross-repo reclaim is the orchestrator's call (loop-disk-reclaimer), not a
lane's. So there is no red-then-green evidence, no coverage figure, and the code may not even
compile. Treat every claim above as reasoned, not measured.

The version bump to 0.167.0 was taken now because the lane cannot reach a "last step"; it must be
re-checked against main before this is undrafted, since a peer merges into this repo from another
machine.

…r-count

A degraded chain view can only OMIT coins from a census, never invent them, so
it describes a network smaller than the chain holds and the requirement derived
from it comes out LOW -- the direction that leaves stores under-collateralised.

Two properties made that permanent, each individually correct. `put` never let a
later record supersede one this node censused, which is what stops a lying peer
overwriting a node's own answer; and `catch_up` never re-censused an epoch it had
already recorded, which is what kept the walk cheap. Together, one badly-timed
ten-minute window sealed the figure for a seven-day epoch.

The repair admits exactly one new superseding pair: a census this node took,
replaced by a LATER census OF ITS OWN, of the same epoch, at the SAME census
height, counting strictly MORE stores. The direction is the whole of the rule --
raising the count requires somebody to have posted collateral on chain, while
dropping one requires only silence, so the upward direction is unforgeable and
the downward one is free.

The walk now re-censuses its TARGET epoch and no earlier one, so history is still
computed exactly once and a cold-start walk is unchanged; the steady state costs
one census of one epoch per pass instead of none.

Refs: #405
…he repair

chore(release): bump to 0.167.0

The probe dig-node#405 asked for: the same chain presented twice, once hiding one
of two mirror coins and once not, with the real walk run against each. Asserting
on hand-written records would prove only that `put` compares integers the way its
own unit tests already say it does.

Every coin is built by a genuine CAT spend, reusing dig-mirror-coin 0.7.0's
published `tests/support/mod.rs` verbatim: the census authenticates a candidate by
executing its creating spend, so a hand-built CoinRecord never reaches the path
under test.

The thin view hides ONE of two coins and keeps the other. Hiding both reads as
the harsher fixture and is exactly the one that cannot tell a repair from a walk
that recorded nothing, because no surviving figure would be left to move.

Refs: #405
…ropagates

Two defects the first run of these tests found, both real.

The walk re-entered `record_one` for the genesis epoch, which is derived from
nothing and has no predecessor, so it stopped at `PriorEpochMissing { epoch: 0 }`
-- an epoch that cannot exist -- on every pass, forever, on the one node state
that is unambiguously correct. `refresh` now returns early for genesis and for
any record that is not a census, since `put` would refuse to supersede those and
the read would be spent to reach a refusal.

And the steady-state test targeted GENESIS, so it could never reach a chain read
at all: it asserted the repair path while exercising the guard that skips it. It
now seeds and targets epoch 2, with the genesis case split into its own test.

The store-layer fixture asserted the repair moves `required_per_store`, which is
a function of the multiplier and the OWNER count -- so an under-counted `stores`
moves nothing in its own epoch and the assertion compared 5000 to 5000. That is
the more interesting fact: the harm is in the SUCCESSOR, because `advance` reads
the seed's census to derive the next multiplier, which is why sealing a low count
was permanent rather than merely wrong for one epoch. The test now advances both
records with the same following census and shows the charge differs.

Refs: #405
…g witnessed

Eight mutations, six red. The two survivors were both real gaps.

Relaxing `>` to `>=` in the repair predicate survived every test, because they all
vary the store count and the two operators differ only where it is EQUAL. That
case is not a curiosity: a record carries `owners` beside `stores`, hiding an
owner is as free to a thin source as hiding a store, and the requirement is
derived from the owner count directly -- so under `>=` a re-census keeping the
store count while reporting fewer owners would supersede, which is the downward
direction wearing the shape of a repair.

Removing the `epoch <= GENESIS_EPOCH` arm ALSO left the suite green, because it
sat in front of the provenance arm and could never fire: genesis is held as a
bootstrap record, and no path writes a censused epoch 1. Two guards where one
masks the other cannot both be witnessed, so the masked one is gone and the
comment on the surviving arm records that it is what protects genesis.

Refs: #405
@MichaelTaylor3d MichaelTaylor3d changed the title fix(collateral): corroborate the census population read so a degraded source cannot seal a low requirement fix(collateral): a degraded source can no longer seal a low requirement Aug 29, 2026
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.

A transient degraded source seals a permanently uncorrectable low collateral requirement — fix before #377

1 participant