Skip to content

fix(dlv): the correctness pass the close stack merged without - #681

Merged
cryptskii merged 3 commits into
mainfrom
fix/dlv-close-stack-correctness-pass
Aug 21, 2026
Merged

cryptskii merged 3 commits into
mainfrom
fix/dlv-close-stack-correctness-pass

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

The three reviewed correctness fixes for the DLV close stack, replayed onto main.

#676, #677 and #678 merged at their pre-fix heads (d4a12311, bac58261, 429f4e20) while these commits were still local, so main currently carries the stack without them. Each commit is the one written and verified against its original branch. One conflict was resolved on replay, in storage_node_sdk.rs: main now has #677's MemberClaimResult additions surrounding the dead impl KeyedPutFanout this deletes. main's content kept, the dead impl deleted.

What is currently wrong on main

A superseded artifact row is replayed over the newer one. republish_unpublished_artifacts selects a batch before it makes a single network call, and every iteration below that awaits. A close committing in that window supersedes a row already in the batch and publishes its own bytes under the same .../latest mirror key. Replaying the stale row afterwards puts an older generation back on every member, and the damage is permanent and invisible: upsert_artifact_publication_state declines to write to a superseded row, so nothing records that the fleet disagrees with us, and list_unpublished_artifacts skips published rows, so the newer bytes are never re-sent. A closed vault would serve its birth anchor to every trader, indefinitely.

The existing a_superseded_generation_is_never_swept cannot catch this: its SELECT already filters superseded rows, so it passes whether or not the pass re-checks anything. republish_rows is split out so a test can be handed a deliberately stale batch, which is exactly what the real pass holds after its own SELECT.

One acceptance path counts without attribution. A member answering Refused while naming our own digest as the value it holds is holding our bytes, and that arm counted it without asking who answered. It was the only acceptance path skipping the check its siblings apply. Quorum means a majority of DISTINCT members hold these exact bytes, so a node replying under another's id contributes twice to a majority that does not exist. Refusals stay unattributed deliberately: counting one makes the claim fail closed as Contested, so it can only make us more conservative.

The claim envelope has two durable homes. SPHINCS+ signing here is deterministic, so a rebuilt claim is byte-identical to a replayed one, and nothing on the wire, in a digest or in a test can tell which happened. FrozenClaimEnvelope makes the machinery absent rather than merely unused: one constructor, load, reading already-retained bytes, and claim_settlement_slot accepts nothing else. resume_close_intents holds no signing key at all, asking can_sign() instead, with the commit half moved to finish_prepared_close. dlv_close_intent.claim_bytes is dropped, column included, leaving settlement_slot_claim_local as the single home.

Schema note: CLIENT_DB_SCHEMA_VERSION = 5 was introduced by this stack and no v5 database has shipped, so reshaping it here owes no bump.

Mutation controls

Guard disabled Test Result
stale-row re-read removed a_row_superseded_after_selection_is_not_replayed_over_the_newer_one red, the stale row republishes
attribution check dropped a_refusal_holding_our_own_digest_still_needs_attribution red, accepted: 2 of 3, one node counted twice
envelope rebuilt from bytes n/a does not compile, E0308

The attribution fix previously had no test at all. The new one needed a fake-fleet knob for the refusal phrasing, because the current node never emits that shape, which is the reason the arm cannot be taken on trust.

Local gate

  • cargo fmt --all -- --check clean
  • cargo clippy --all-targets -- -D warnings clean
  • cargo test -p dsm_sdk --lib full suite, result posted below

…the newer one

The republish pass selects a batch before it makes a single network call, and
every iteration below that awaits. A close committing in that window
supersedes a row already in the batch and publishes its own bytes under the
same .../latest mirror key.

Replaying the stale row afterwards puts an older generation back on every
member — a closed vault serves its birth anchor again — and the damage is
permanent and invisible: upsert_artifact_publication_state declines to write
to a superseded row, so nothing records that the fleet now disagrees with us,
and list_unpublished_artifacts skips published rows, so the newer bytes are
never re-sent.

The module's contract is that a superseded row is never replayed. It is now
honoured against current state rather than state at selection time, by
re-reading each row immediately before it is sent.

republish_rows is split out so a test can hand it a deliberately stale batch,
which is exactly what the real pass holds after its own SELECT. The existing
sweep test cannot catch this: its SELECT already filters superseded rows, so
it passes whether or not the pass re-checks anything.

Also deletes clear_accepting_member and KeyedPutFanout::accepting_member_ids,
which have no callers.
…ly be loaded

Two changes to the settlement-slot claim path.

Attribution. A member answering Refused while naming OUR digest as the value
it holds is holding our bytes, and that arm counted it without asking who
answered — the one acceptance path that skipped the check its siblings apply.
Quorum means a majority of DISTINCT members hold these exact bytes, so a node
replying under another's id could contribute twice to a majority that does not
exist. The current node never phrases a re-ack this way; the arm exists for a
node that would, which is exactly the node whose attribution cannot be taken
on trust. Refusals stay unattributed on purpose: counting one makes the claim
fail closed as Contested, so it can only make us more conservative.

The fake fleet gains that phrasing so the case can be reached. Dropping the
check turns the new test red with accepted: 2 of 3 — one node counted twice.

FrozenClaimEnvelope. SPHINCS+ signing here is deterministic, so a rebuilt
claim is byte-identical to a replayed one: nothing on the wire, in a digest,
or in a test can tell which happened. The guarantee therefore has to be that
the machinery is absent rather than merely unused. The type has one
constructor — load, which reads bytes already retained — and
claim_settlement_slot accepts nothing else. A caller with a signing key but no
retained bytes cannot obtain one at all, and making recovery re-sign would
mean changing this type rather than quietly editing a call site.

frozen_claim_envelope now reads back what it just retained instead of wrapping
the bytes in hand, so a retention that silently failed surfaces before the
claim goes out rather than at recovery. The tests go through storage exactly
as production does: a test-only constructor taking raw bytes would hand them
the very capability the type exists to withhold.

can_sign asks whether signing is possible without binding a key, so a caller
that must not hold signing material while doing the work that precedes signing
no longer has to.
…pe has one home

Call-site half of the FrozenClaimEnvelope boundary.

resume_close_intents no longer binds a signing key. It asks can_sign() to
decide whether there is any point starting, and the commit half — which
genuinely must sign, because the terminal proofs did not exist when the close
was interrupted — moves to finish_prepared_close. Keeping the key out of the
claim half's scope is what makes "the parent claim is never re-signed" a
property of the code rather than of the current author's discipline.

The claim comes from FrozenClaimEnvelope::load, whose only input is the slot
it names; nothing on this path can build or sign one.

dlv_close_intent.claim_bytes is dropped, column included. The envelope lived
in two durable places that could disagree, and settlement_slot_claim_local is
the one that FrozenClaimEnvelope reads. Schema v5 is introduced within this
stack and main is still on 4, so reshaping it here owes no version bump.

The replay test's doc claimed more than the test observes. SPHINCS+ signing
here is deterministic, so a rebuilt claim is byte-identical to a replayed one
and no wire-level assertion can separate them. The doc now says what the
digests do prove — every attempt carried ONE envelope, the one frozen with the
intent — and states that provenance is enforced structurally instead.
Comment thread dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/artifact_republish.rs Dismissed
Comment thread dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/artifact_republish.rs Dismissed
@cryptskii

Copy link
Copy Markdown
Collaborator Author

Full suite on the replayed branch: 1797 passed, 0 failed, 7 ignored, 597s. cargo fmt --all -- --check and cargo clippy --all-targets -- -D warnings both clean.

That is the whole dsm_sdk lib suite against the merged main, not a filtered subset — the three fixes and everything the stack landed, together.

@cryptskii
cryptskii merged commit d0bd5d0 into main Aug 21, 2026
18 checks passed
@cryptskii
cryptskii deleted the fix/dlv-close-stack-correctness-pass branch August 21, 2026 02:21
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.

2 participants