Skip to content

fix(sdk): a bundle nobody stored is not a bundle you may bind against - #779

Merged
cryptskii merged 1 commit into
mainfrom
fix/publication-durability-before-bind
Sep 7, 2026
Merged

fix(sdk): a bundle nobody stored is not a bundle you may bind against#779
cryptskii merged 1 commit into
mainfrom
fix/publication-durability-before-bind

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

The defect

put_bundle called put_immutable_to_all_members(...).map(|_| ()). That inner call returns
Ok(KeyedPutFanout) whenever the member SDK could be constructed; per-member acceptance lives
in fanout.accepted, which the map discarded. The outer Result was Err only when
member_sdk_with_auth failed.

So the caller erased the difference between two unrelated facts:

the SDK invocation succeeded
enough committed members actually accepted B

Zero of three members accepting — a 413, a 401, an unreachable fleet — still returned Ok.
bind_settlement then placed the trader-parent fence and drove the register against a value_addr
no member holds. The bundle is unfetchable, settlement_resume can never complete, and the DLV
parent stays fenced forever. Requirement 6.15's ordering was enforced; its success was not.

The gate

accepted attributable members >= q, for the exact committed storage set
otherwise refuse BEFORE any fence row and before any binding round

q is the vault's committed quorum via set.quorum() — never a hardcoded majority, never the
locally configured fleet size.

Two things this deliberately does not add. accepted is already the attributable count on
both paths: the production fan-out counts a 2xx only when the member echoes its own configured id
(Req 15.8), and the fleet double applies the same echo rule — so no new attribution logic. And
artifact_republish already gated both of its fan-out paths on set.quorum(); this brings the
settle path in line with an existing in-repo pattern rather than inventing one. A sweep of every
other caller found no third production instance — the remaining hits are inside #[cfg(test)].

Ordering holds by construction, not convention: put_bundle is called at bind_settlement:148,
and both the fence and the register are driven inside run_fenced at :185.

Tests

Five boundary cases at beta n=3, q=2:

0/3 accepted                          -> refuse; no fence; no bind round
1/3 accepted                          -> refuse; no fence; no bind round
2/3 accepted                          -> proceed
3/3 accepted                          -> proceed
transport succeeds, none attributable -> refuse; no fence; no bind round

The last is the case the discarded result hid: every HTTP call returns normally and every member
stores the bytes, but no acceptance is attributable, so the publication is worth nothing and must
read exactly like 0/3.

Each refusal test asserts the absence of the forbidden state before it asserts the error value.
That ordering is the point: written the other way round, removing the gate reddens the test at
.unwrap_err(), which proves only that a return value changed. As written, the mutation fails on
a fence row was written despite a non-durable publication — verified, all three refusal tests.

Also resets storage_io::fake_fleet alongside the binding double in this module. Only the register
double was being reset, so injected put failures and echo overrides leaked between tests under
--test-threads=1; two pre-existing tests failed once the gate landed, and this is the true cause.

Verification

  • Board, exact CI command, release: dsm 2215/0, dsm_sdk 1855/0 — 76 suites, 4070 passed, 0 failed,
    17 ignored.
    The 17 ignored are pre-existing (bluetooth, bitcoin_query, core_bridge_adapters,
    storage_routes); none is new here, and all seven new tests report ok.
  • make lint (pinned 1.98.0, fmt --check + clippy --all-targets): exit 0.
  • ci/production_safety_checks.sh: exit 0.
  • Mutation control run and restored.

Context

Found while proving a 50,330-byte owner-close SettlementBundle clears every configured transport
path. It does — 10.4x against the binding 512 KiB device_auth body buffer, with the body moving as
raw octet-stream and no reverse proxy in front of the nodes. That margin was only load-bearing
because the client could not observe the put failing at all.

`put_bundle` called `put_immutable_to_all_members(...).map(|_| ())`. The inner
call returns `Ok(KeyedPutFanout)` whenever the member SDK could be CONSTRUCTED;
per-member acceptance lives in `fanout.accepted`, which the `map` discarded. The
outer `Result` was `Err` only when `member_sdk_with_auth` failed.

So the caller erased the difference between two unrelated facts:

    the SDK invocation succeeded
    enough committed members actually accepted B

Zero of three members accepting — a 413, a 401, an unreachable fleet — still
returned Ok. `bind_settlement` then placed the trader-parent fence and drove the
register against a `value_addr` no member holds, after which the bundle is
unfetchable, `settlement_resume` can never complete, and the DLV parent stays
fenced forever. Requirement 6.15's ORDERING was enforced; its SUCCESS was not.

The gate is the vault's committed quorum, not a hardcoded majority and not the
locally configured fleet size:

    accepted attributable members >= q, for the exact committed storage set
    otherwise refuse BEFORE any fence row and before any binding round

`accepted` is already the ATTRIBUTABLE count on both paths — the production
fan-out counts a 2xx only when the member echoes its own configured id (Req
15.8), and the fleet double applies the same echo rule — so no new attribution
logic is introduced. `artifact_republish` already gated on `set.quorum()` in
both of its fan-out paths; this brings the settle path in line with it. A sweep
of every other caller found no third instance: the remaining hits are inside
`#[cfg(test)]`.

Five boundary cases pinned at beta n=3, q=2:

    0/3 accepted                          -> refuse; no fence; no bind round
    1/3 accepted                          -> refuse; no fence; no bind round
    2/3 accepted                          -> proceed
    3/3 accepted                          -> proceed
    transport succeeds, none attributable -> refuse; no fence; no bind round

The last is the case the discarded result hid: every HTTP call returns normally
and every member stores the bytes, but no acceptance is attributable, so the
publication is worth nothing and must read exactly like 0/3.

Each refusal test asserts the absence of the forbidden STATE before it asserts
the error value, so removing the threshold fails on "a fence row was written
despite a non-durable publication" rather than merely on a changed return.
Verified: the mutation reddens all three refusal tests at that assertion.

Also resets `storage_io::fake_fleet` alongside the binding double in this
module's tests. Only the register double was being reset, so injected put
failures and echo overrides were leaking between tests under --test-threads=1.

Found while proving a 50,330-byte owner-close SettlementBundle clears every
configured transport path (it does, 10.4x against the binding 512 KiB
device_auth body buffer). That margin was only load-bearing because the client
could not observe the put failing at all.
@cryptskii
cryptskii merged commit 3a71cea into main Sep 7, 2026
16 checks passed
@cryptskii
cryptskii deleted the fix/publication-durability-before-bind branch September 7, 2026 23:02
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.

1 participant