Uh oh!
There was an error while loading. Please reload this page.
feat(node): store-melt P2P propagation [BLOCKED — a dust coin deletes a live store network-wide] - #148
Conversation
MichaelTaylor3d
commented
Aug 2, 2026
🔴 Review gate: CHANGES-REQUIRED — the melt signal is unsound (do not merge)The production
Sound as-is (keep on re-spin): Required fix (direction): the Awaiting the loop-security + adversarial gates, then this re-spins with a proper melt-detection primitive and re-runs all three gates. The green CI + green unit tests are not sufficient assurance for an irreversible network-triggered delete. Generated by Claude Code |
MichaelTaylor3d
commented
Aug 2, 2026
Triple gate: all three independently blocked — re-implementing the melt signal (converting to draft)Three fresh, independent adversarial contexts reached the same verdict:
Unanimous root cause: the production
Sound and unchanged on the re-spin (all three cleared these): Required fix: the melt gate must key on a melt-SPECIFIC positive confirmation — the singleton lineage terminated in an owner-authorized melt spend (launcher spent + terminal child yielding no datastore output) — NOT This is the triple gate doing exactly its job: green CI and green unit tests were not assurance for a network-triggered irreversible delete. Generated by Claude Code |
1799221 to
001fc10CompareMichaelTaylor3d
commented
Aug 3, 2026
Re-implemented the melt signal + rebased onto |
001fc10 to
127a485CompareMichaelTaylor3d
commented
Aug 3, 2026
Gate-ready: CI fully green on |
| Check | |
|---|---|
| Rustfmt · Clippy · Lint commit messages · Check version increment | success |
| Test + coverage | success |
| CodeQL · Analyze (rust / actions / javascript-typescript) | success |
| build .deb amd64 · .deb arm64 · .msi windows-x64 · .pkg macos-universal | success |
Zero review threads open (no inline review comments, no GHAS comments on the PR).
Local: cargo test -p dig-node-core --lib → 683 passed, 0 failed; cargo test --workspace,cargo clippy --workspace --all-targets -D warnings, cargo fmt --check all clean.
The three gate objections, point by point
- "A genuine melt never yields
Ok(None)— it yieldsErr, so the feature is inert." — the gate no
longer consultsanchored_root()at all. A melt is now a positive observation of the terminated
lineage: launcher spent + a non-empty, all-spent hint index. - "
Ok(None)is the fail-closed sentinel;Ok(None) ⇒ deleteis a wrongful-delete vector." — the
state that produced it (launcher unspent / not minted) is now explicitlyLive, pinned byunspent_launcher_is_not_minted_never_melted, which also asserts the second read is never reached. - "All 8 tests drive a scripted
ChainSpy, so none exercise the real resolver →MeltStatus
mapping." — 8 new tests drive the realChainReadstrait with crafted coin records, against the
productionconfirm_melt_via_chain. Unused trait methods on the mock areunimplemented!()so the
gate cannot silently acquire a chain dependency, and the mock assertsinclude_spent = true.
Falsification
Each guard was inverted in the source and the corresponding test confirmed to FAIL. The harness
asserts the patch actually applied to the bytes on disk before running, so a no-op mutation reportsPATCH-DID-NOT-APPLY rather than a false "killed".
killed M1 unspent-launcher guard removed unspent_launcher_is_not_minted_never_melted
killed M2 empty hint index treated as melted an_unindexed_store_is_unknown_not_melted
killed M3 hint-read error treated as melted unreachable_chain_on_the_hint_read_is_unknown
killed M4 absent launcher treated as melted absent_launcher_coin_is_unknown
killed M5 all-spent weakened to any-spent a_single_unspent_generation_keeps_the_store_live
killed M6 launcher-read error treated as melted unreachable_chain_on_the_launcher_read_is_unknown
mutants killed: 6/6
Residual gap, stated plainly
The tests prove the mapping from chain facts to verdict. They do not prove the premise that a
melted store really presents on chain as spent launcher + all-spent hint index — that is reasoned
from sync_datastore's walk and from verify_pinned_root's reliance on the same launcher-id hint
(#1473), not observed on a simulator. Closing it properly means a first-class melt verdict indigstore-chain, where the lineage machinery and its chia_sdk_test::Simulator coverage already
live — release-first, then this consumes it. Building a second simulator harness in dig-node would
duplicate that authority.
Until then the failure mode is bounded in the safe direction for every case except one: if the hint
index were populated but omitted a live tip, a live store would read as melted. Every other
divergence — unreachable chain, malformed answer, empty index, planted coins — declines to delete.
Generated by Claude Code
MichaelTaylor3d
commented
Aug 3, 2026
Gate verdict: CHANGES-REQUIRED — do not merge. The asymmetry is inverted.The rewrite is a large improvement on the The claim that fails
That premise is false on mainnet. The gate enumerated all 53 real DataLayer launcher coins 30 of 53 live stores (57%) have an empty Those 30 resolve The attackA hint is an unauthenticated For store
No announcement is needed to start it. Fact 1 contributes no discrimination: launcher exists and is spent is true of every minted store. All Second issue, same direction — truncation reads as all-spent
The test coverage has exactly the shape of the hole
Also worth correcting before mergeThe PR body on the API is stale — it still describes the pre-rewrite Verified sound, do not re-litigate
The lesson worth keepingThe previous design was found unsound by reasoning about what |
c5c3b66 to
87234e2CompareMichaelTaylor3d
commented
Aug 4, 2026
Re-spun on coin parentage — the hint index is gone, and both gate findings are closed by testHead 1. "An honest empty index plus one planted spent coin must not resolve |
| test | scenario | asserts |
|---|---|---|
empty_hint_index_plus_a_planted_spent_coin_is_still_live | honest lineage resolvable + planted spent coin | Live |
a_planted_coin_beside_an_unresolvable_lineage_is_unknown | honest lineage unresolvable (chain fails mid-walk) + planted spent coin | Unknown |
The second is the one that matters: the planted coin must never supply the evidence the honest
lineage failed to. Structurally it cannot — a coin's parent_coin_info is fixed by which coin was
actually spent to create it, so placing a coin anywhere in this walk requires spending a generation
of the store, which requires the owner's key. Fact 2 is no longer attacker-writable; it is
owner-writable only.
2. "Fact 2 must assert completeness, not accept a page"
Taking this seriously found the same class of hole in the new design, which I have fixed. The walk
originally concluded Melted on "no ODD child". coin_records_by_parent_ids honours a server-side
limit too — so a truncated page that kept an even change coin while dropping the odd successor would
have read exactly like a terminated lineage. No attacker required, same as your 349→5 measurement.
The rule is now: only a COMPLETELY EMPTY page may conclude a melt, and never at hop 0. A
non-empty page with no singleton in it is Unknown. That asserts completeness rather than trusting a
page — truncation cannot turn a non-empty result set into an empty one short of a zero limit, which
is never sent. Pinned by a_page_with_children_but_no_singleton_is_unknown_not_melted and mutant M11.
Hop 0 additionally closes a trap you would have hit next: coin_records_by_parent_ids has an empty
default impl on the ChainReads trait, so any implementation that does not override it would
otherwise return Melted for every store instantly.
Measured against mainnet before designing, per your instruction
All 53 DataLayer launcher coins (global hint sha256("datastore")), run through this exact gate:
51 Live 1 Melted 1 previously mis-capped (real depth 599)
- The 1
Meltedis the genuinely terminated storecee3e2b0…, ending at hop 1 — spent parent,
zero children, terminal spend retrievable. - The four stores you named as live-with-empty-hint-index —
423449b6…,254b8e34…,c592b3ac…,10d15285…— all classifyLiveat depth 1 here. I re-confirmed each has hint index
size 0, so the 57% blind spot is exactly what parentage fixes. - Deepest live lineage 599 generations; 29 stores have their tip one hop from the launcher (mean
~7).MAX_LINEAGE_HOPSis sized from that. No ambiguous fork anywhere in ~380 hops. - Because the walk costs one read per generation, verdicts are memoised for a short TTL so an
announcement flood for one held store cannot multiply into repeated walks. A stale verdict can only
DELAY a real melt, never cause a delete.
Verification
Workspace green: cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, and the
full cargo test --workspace (dig-node-core 690 passed; every crate 0 failed).
12/12 mutants killed — every inverting mutation of the gate confirmed to fail its test: the
unspent-launcher guard, hop-0 emptiness, mid-walk transport error, launcher-read failure, ambiguous
fork, unspent-successor stop, even-child acceptance, the hop ceiling, hint-index consultation, the
completeness rule, the planted-coin-plus-unresolvable-lineage case, and cache expiry.
Two things worth recording about the verification itself, since a clean mutation report is exactly
the kind of evidence that can lie:
- The hop-cap test originally asserted
parent_queries <= MAX_LINEAGE_HOPS, which stays true when the
ceiling is cut to 2. It reported a kill while proving nothing. It now asserts the exact read
count. - Two mutation harnesses briefly raced on the same file; one restored the source while the other
measured, reporting a genuinely-killed mutant as UNFALSIFIED — and left an applied mutation
behind in the working tree (Ok(None) | Err(_) => Melted, i.e. an unreachable chain authorising
a delete). I caught it by grepping everyMeltedreturn rather than trusting the report, fixed it,
re-ran the full suite, and re-ran all 12 mutants single-threaded against a committed baseline with
an interference guard that aborts if the source changes underneath it. The final 12/12 above is
from that clean run, withgit statusverified clean afterwards.
Version
main is 0.93.9; this takes 0.95.0, skipping the 0.94.0 that #149 is holding, so the
version-increment gate passes whichever of us merges first.
PR body refreshed — it no longer describes the superseded Ok(None) model, so the squash records
what actually ships.
Not merging. Ready for you to re-gate the deletion path.
Generated by Claude Code
87234e2 to
a1d7326CompareMichaelTaylor3d
commented
Aug 4, 2026
Resume-ready state — gate-ready, not mergedBranch DoneThe rebase that was interrupted by the cap had in fact completed and pushed — verified from remote, The blocking gate finding is closed by removing the signal, not patching it. Verified against the
Measured on mainnet before designing: all 53 DataLayer stores → 51 Workspace green ( PR body refreshed — 0 references to the superseded Versionmain Exact next actionNone on my side. Awaiting the full re-gate of the deletion path. If the gate returns Generated by Claude Code |
First red stub for store-melt P2P propagation: the pure decide_melt gate (only a held, on-chain-confirmed melt deletes) and the set-once TombstoneSet that bounds each node to one broadcast per store. The 8 adversarial actuator tests + the receive/holder wiring follow. Refs #1316 Co-Authored-By: Claude <noreply@anthropic.com>
Piece #3 (receiver) + #4 (holder) of store-melt propagation, over spy-testable seams (MeltChain/MeltCache/MeltBroadcast) with the fail-closed NC-9 gate: only a held, on-chain-confirmed melt deletes; Err/Unknown never deletes; held-check precedes any chain read; the TombstoneSet CAS bounds each node to one rebroadcast so the epidemic terminates. All 8 adversarial tests green. Refs #1316 Co-Authored-By: Claude <noreply@anthropic.com>
…PEC (#1316) Spawn the store-melt receive ingest (opcode 221) beside holdings ingest off a second inbound receiver, and a holder watch loop that deletes + announces this node's own on-chain-melted stores — both sharing one tombstone. Bump workspace 0.75.6 -> 0.76.0 (feat/minor) + Cargo.lock. SPEC.md §14.5 documents the receive->verify->delete->rebroadcast contract, the NC-9 fail-closed gate, and the §5.4 public-broadcast exemption. Refs #1316 Co-Authored-By: Claude <noreply@anthropic.com>
a1d7326 to
e650a04Compare…-writable hint The melt gate is the authority for an irreversible, peer-triggered, network-correlated delete. Two cheaper signals were tried for it and both were unsound; this replaces the second with the singleton lineage itself, and the choice is settled by measurement against mainnet rather than by argument. What was wrong -------------- The previous cut concluded "melted" from a NON-EMPTY, all-spent `store_id` hint index. A hint is an unauthenticated CREATE_COIN memo over an arbitrary 32-byte value (#1473), so ANY party can place a record under ANY store's hint for the price of a dust coin. Enumerating all 53 DataLayer launcher coins on mainnet shows why that is fatal: 30 of the 53 LIVE stores have a completely EMPTY store_id hint index — their generations are not hinted to store_id at all. For every one of them a single planted spent coin makes the index non-empty and entirely spent, which the gate could not distinguish from a terminated lineage. Cost to erase a live store network-wide: dust plus fee, no permission, no P2P access, no key material. `run_melt_tick` would have fired it on a timer with no announcement at all. `get_coin_records_by_hint` is also truncatable, and truncation surfaces spent records first — the exact order that manufactures a false melt. What replaces it ---------------- A forward walk of the singleton lineage along real COIN PARENTAGE: 1. Identity + minted — the launcher coin whose `coin_id == store_id` exists and is SPENT. An unspent launcher is Live (not minted yet is the opposite of melted). This fact discriminates nothing by itself; it anchors where the walk starts. 2. Walk forward — follow the single ODD-amount child at each hop. An UNSPENT successor is Live. A spent coin with NO successor is Melted. A coin's `parent_coin_info` is fixed by which coin was actually spent to create it, so placing a coin anywhere in this walk requires spending a generation of the store, which requires the owner's authority. The walk is unwritable by anyone but the owner, and it never consults a hint — the mock panics if either hint query is touched. Fail-closed everywhere else: any transport error INCLUDING mid-walk (an outage must not read as "the lineage ended here"), more than one odd child, an absent launcher, and exceeding the hop ceiling. Zero children at hop 0 is Unknown, not a melt: a minted launcher always created the eve singleton, so an empty first hop means the answer is untrustworthy — which also closes the trap that `coin_records_by_parent_ids` has an empty DEFAULT impl on the trait. Measured against mainnet ------------------------ All 53 DataLayer stores: 51 Live, 1 Melted (the one genuinely terminated store, ending at hop 1), 1 previously mis-capped. Deepest live lineage 599 generations; 29 stores have their tip one hop from the launcher; no ambiguous fork anywhere. MAX_LINEAGE_HOPS is sized from that measurement. The four stores the gate named as live-with-empty-hint-index all classify Live here. Because the walk costs one read per generation and the receive path runs per inbound announcement, verdicts are memoised for a short TTL so a flood of announcements for one held store cannot multiply into repeated walks. A stale verdict can only DELAY a real melt, never cause a delete. Tests: 12 cases drive the real ChainReads trait with a crafted lineage, including the composition the gate flagged as untested and lethal — an empty hint index plus one planted spent coin — asserting Live. All ten inverting mutations of the gate were confirmed to fail their test; the hop-cap test asserts the EXACT read count, because a `<=` bound is also satisfied by a walk that stops far too early. root [workspace.package].version 0.94.0 -> 0.96.0 (minor, new capability). Skips 0.95.0, which PR #179 holds. Co-Authored-By: Claude <noreply@anthropic.com>
e650a04 to
85a115fCompareMichaelTaylor3d
commented
Aug 4, 2026
Gate-ready — |
MichaelTaylor3d
commented
Aug 4, 2026
Merge ordering, recorded so it is not lost: #179 merges first, then this. This PR sits at 0.96.0, #179 at 0.95.0, main at 0.94.0. If this lands first, #179's 0.95.0 fails the version-increment check and needs another bump; the reverse order works with no further edits. Both are green and both are under scoped security re-gates right now. Two notes on the delta while it waits: The clippy resolution is better than what I asked for. I said to assert a literal. Making the constant-to-constant relationship a compile-time The case-sensitivity bug is the one worth remembering from this PR. That is now three of this session's false-green findings that share one shape: the test drove a substitute for the thing under test. Filed as #2094. On the local |
Closes dig_ecosystem#1316 (pieces #3 + #4).
Propagates a store MELT across the peer network so every holder stops hosting the store's
.digcontent and reclaims disk. Wire is dig-gossip opcode 221 (
StoreMeltedAnnounce), a publicall-peers broadcast — §5.4-EXEMPT from recipient-sealing, mTLS-authenticated and signed, with the
signature serving as attribution/anti-spam only and never as delete authority.
The delete authority
This is an irreversible, peer-triggered, network-correlated deletion, so the melt verdict is the
whole PR. Two cheaper signals were tried for it and both were found unsound before merge; the
history is kept in
SPEC.md§14.5 and in the module docs because both shortcuts will lookattractive again.
anchored_root() == Ok(None)— rejected. That value is the node's fail-closed sentinel forno confirmed generation everywhere else, and
CoinsetResolverproduces it for a store that isnot minted yet. A genuine melt does not even produce it.
store_idhint index — rejected. A hint is an unauthenticatedCREATE_COINmemo over anarbitrary 32-byte value, so anyone can write under any store's hint for the price of a dust coin.
Enumerating all 53 DataLayer launcher coins on mainnet: 30 of the 53 live stores have a
completely EMPTY
store_idhint index, so for each of them one planted spent coin would havemade the index non-empty and entirely spent — indistinguishable from a terminated lineage.
What ships instead: a forward walk of the singleton lineage along real COIN PARENTAGE.
coin_id == store_idexists and is SPENT.coin_id == store_idis a 256-bit hash preimage that cannot be ground. An unspent launcher isLive. On its own this discriminates nothing (it holds for every minted store); it anchors wherethe walk starts.
Live. A spent coin whose children page is completely empty isMelted.A coin's
parent_coin_infois fixed by which coin was actually spent to create it, so placing a coinanywhere in this walk requires spending a generation of the store — which requires the owner's
authority. The walk is unwritable by anyone but the owner and never consults a hint; the test mock
panics if either hint query is touched.
Fail-closed everywhere else: any transport error including mid-walk (an outage must not read as
"the lineage ended here"), more than one odd child, an absent launcher, exceeding the hop ceiling,
zero children at hop 0 (a minted launcher always created the eve singleton — and this also closes the
trap that
coin_records_by_parent_idshas an empty DEFAULT impl on the trait), and any non-emptypage with no singleton in it.
That last one is the completeness rule. The children query honours a server-side limit and truncation
surfaces spent records first — measured on coinset against the sibling hint query, no limit returns
349 records with 243 unspent while
limit=5returns 5 with zero unspent. A truncated page thatkept an even change coin but dropped the odd successor would read exactly like a terminated lineage.
Requiring the page to be entirely empty asserts completeness rather than trusting a page:
truncation cannot turn a non-empty result set into an empty one short of a zero limit, which is never
sent.
Measured against mainnet, not derived
All 53 DataLayer stores (global hint
sha256("datastore")), run through this exact gate: 51Live, 1Melted— the one genuinely terminated store, ending at hop 1 — and no ambiguous forkanywhere. Deepest live lineage is 599 generations; 29 stores have their tip one hop from the
launcher (mean ~7), which is what
MAX_LINEAGE_HOPSis sized from. The four stores previouslyidentified as live-with-empty-hint-index all classify
Livehere.Because the walk costs one read per generation and the receive path runs per inbound announcement,
verdicts are memoised for a short TTL so a flood of announcements for one held store cannot multiply
into repeated walks. A stale verdict can only DELAY a real melt, never cause a delete.
Propagation
delete every held generation via the audited path-contained cache-remove, broadcast a signed
announcement, tombstone.
Live/Unknownare no-ops, retried next tick.un-held flood costs no chain work at all), then the tombstone, then the on-chain verify, then
delete + rebroadcast once, gated on a compare-and-set so only the holding→deleted transition
re-emits. The epidemic quiesces after every holder has deleted once.
Tests
8 adversarial policy cases against spy seams, plus 13 cases driving the real
ChainReadstraitwith a crafted lineage — including the composition that broke the previous design (an empty hint
index plus one planted spent coin) both beside a resolvable live lineage and beside an unresolvable
one, asserting
LiveandUnknownrespectively.All 12 inverting mutations of the gate were confirmed to fail their test. The hop-cap test
asserts the EXACT read count rather than a
<=bound, because a bound that is merely "not exceeded"is also satisfied by a walk that stops far too early.
Both new background loops wrap only their per-iteration body in
shared::catch_iterationwithrecv()/tick()outside the guard (the #173/#174/#175 pattern);TombstoneSetrecovers from lockpoisoning so one contained panic cannot disable melt propagation for the process's lifetime.
SPEC.md§14.5 documents the melt authority, the two rejected signals, the completeness rule, andthe mainnet conformance numbers.
Hardening added after the security gate's PASS
The gate passed the deletion path and then found four things worth fixing anyway; all four are in
this PR.
held_store_idsdecodes hex (case-insensitive) whiledelete_all_generationscompared the hex TEXT againsthex::encode(always lowercase).CapsuleKey::parseadmits and preserves mixed case, so for a mixed-case store directory theheld-check passed, the delete matched nothing, and the node would tombstone the store, broadcast a
melt of
generations: 0, and keep serving the content it had just announced as deleted. Nowmatched on the parsed 32 bytes.
MeltCache. Every prior test droveCacheSpy, so the only code thatactually unlinks files was untested — inverting its match to delete every other store stayed
green. Three tests now drive the real impl against a real cache directory (correct store deleted,
bystander survives, mixed-case store deleted, unheld store is a no-op).
DIG_NODE_STORE_MELT(default ON, explicitoff/0/false/nodisables), mirroring
DIG_NODE_BACKFILL_ON_MISS. This is the node's only path that irreversiblydeletes content in response to chain state and it propagates, so a fault is correlated across
holders. Disabling is lossless — melted stores just keep costing disk.
parent_coin_infois not thecurrent coin, instead of trusting the server's notion of "children of X" — that trust would have
handed back the very argument the design rests on.
Also closed two test-vacuity gaps the gate identified: the hop-cap test asserted
parent_queries == MAX_LINEAGE_HOPS, the same symbol on both sides, so cutting the ceiling to 100kept it green — it now asserts the literal
10_000, with a compile-timeconstassertion pinningthe ceiling above the deepest measured mainnet lineage (599), and the live-tip floor raised from 60
to 599. And the gate-4 tombstone CAS had no test — receipts were driven sequentially so gate 2 always
caught the echo — so dropping the CAS stayed green; a deterministic two-task race (both held at a
barrier inside
confirm_melt, guaranteeing both pass gate 2 before either inserts) now pins it.18/18 mutants killed, single-threaded against a committed baseline, tree verified clean after.
Filed as follow-ups rather than fixed here
StoreMeltedAnnounce::verifyis never called on ingest, and therebroadcast re-emits the frame verbatim including
sender_peer_id. Not a deletion vector (thereceiver re-derives from chain and the signature is never the gate); forensic/reputational only.
api.coinset.orgthatanchored_rootuses, so per-hop re-derivation buys no independence against an oracle-wide fault.Cheap mitigation is a confirmation-depth requirement on the terminal coin's
spent_block_index,already present on
CoinRecordand unused.