Skip to content

perf(sdk): a SoFi trade reads each final cell once and overlaps its independent I/O - #1094

Merged
cryptskii merged 5 commits into
mainfrom
perf/sofi-trade-read-once-and-overlap
Oct 2, 2026
Merged

cryptskii merged 5 commits into
mainfrom
perf/sofi-trade-read-once-and-overlap

Conversation

@cryptskii

@cryptskii cryptskii commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

A SoFi trade, made faster in two steps.

  1. One resolution reads each final cell, each object and each index once (§6.60).
  2. A trade's independent reads and writes overlap (§6.61).

On the rig, against main at #1093's 53 s:

  • a chain trade takes 23 s, with a settle of about 5 s;
  • a split takes 19 s.

What repeated

Each node's requests were counted during one resolve_pending_position in the node harness. One rig settle read 33 cells, of which only 6 were distinct.

  • Cells. The walks and the facts re-read cells the resolution had already read.
  • The precommit. The settle fetched it, and then the walks fetched it again.
  • The owner walk (A8). Each step of the vault owner's lineage fetched an object that every step shares: 7 times in a 7-step walk.
  • The genesis locator. Each vault's locator was scanned four times.

The change

A verifier context is one per resolution, quote or walk. It keeps what it read for its life (ReadOnce in sofi_reads.rs). It keeps only readings nothing later can change:

  • Cells, once Core evaluated them final.
    • SoFi cells: SofiReads::keep_completion now carries the reads along with the proof.
    • An owner walk's register cell: kept once read_root_cell reads a Final claim from it.
    • A cell is keyed by its namespace, key, seed and committed set.
  • A Kept precommit, fulfillment or setup.
  • A Stored object.
  • A Complete genesis scan.
  • An owner walk's object (OnceFetcher). It is kept only when its identity, recomputed from its bytes, is the one asked for.

Open or undecided cells, and unavailable or partial readings, are read again every time. Core evaluates exactly the evidence it did before. The settle now reads its precommit through the verifier's reads.

A first rule was unsound, and two existing tests caught it

The first version kept a cell once every seat had answered, and counted an empty seat as complete. So it kept open cells, and a later write in the same context was invisible:

  • a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone
  • an_unsigned_exercise_at_a_successor_key_takes_nothing

Both went red, and the rule is now Core's own verdict. The first of those tests now walks in a context of its own, so its request log still shows everything the walk reads.

Tests (release, Postgres node fleet)

  • The new test: one_resolution_asks_each_node_for_a_final_cell_or_an_object_once. The order splits across two vaults A opened in turn, so the resolution walks A's lineage twice. The position is left pending by a refused pair write, then completed. One resolution realizes it, and no node is asked twice for anything except each vault's open next key. A fresh verifier confirms those keys are open.

  • Mutation controls, all red, each naming what repeated:

    • the final-cell memo;
    • the walk-object memo;
    • the walk-register-cell memo;
    • genesis scans;
    • precommits, fulfillments and setups;
    • Stored objects;
    • the settle fetching the precommit itself again.

    The tree was restored byte for byte after each.

  • SDK suites: 81 passed, 0 failed (node e2e, sender and recipient admission, token create, faucet, frontier verification, realized records, sofi_flow, route_seats, storage_node_sdk, storage_io, sofi_reads, sofi_advance).

  • Core: dsm sofi::, 237 passed, 0 failed.

  • Static checks: make lint exit 0, guard and self-test clean, no-clock gate OK.

Rig (three phones, APK = this branch, the GCP fleet)

Trade Quote Trade Settle
Split, 1500 RIGT → 114.90 ERA, two vaults (position 15) 11 s 26 s 4.5 s
Chain, 20 ERA → 175.67 HOP (position 16) 10 s 27 s 5.3 s
#1093, before this change 20 s 53 s 28.7 s

The split was sized against the reserves: one vault alone would have given about 76.6 ERA. C's balances moved by exactly the quoted amounts, and each history row names both of its vaults.

Second step: independent I/O overlaps (§6.61)

  • Vault heads at once. The trade walks each vault's head on its own scoped thread over the one verifier (chains_at_once). A walk reads only its own owner and cells; the kept reads are shared, and no memo lock is held across a read.

  • Leg writes at once. Each leg's exercise is written at the same time (write_exercise). P's legs name strictly ascending vaults (R15-4), so every leg is its own cell, and cell puts take a per-key advisory lock.

  • First verified answer wins. An object fetch keeps the first answer whose bytes re-hash to the address, instead of waiting for the slowest member.

  • Withdrawn: publishing a produced operation's objects at once. It failed the trade. The cause is in the storage node, which aborts concurrent immutable puts:

    • each put runs as a SERIALIZABLE transaction, with no retry on SQLSTATE 40001;
    • a probe of 16 concurrent distinct puts at one node had 6 answered HTTP 500; the same puts one at a time had none;
    • the Postgres log gained exactly 6 serialization failures, and the same failure is logged locally since 2026-09-25.

    It is recorded in §6.61 as an open storage finding: two clients putting at once lose puts today. STORAGE has it, through CORE.

  • Test. a_fetch_passes_over_a_member_that_answers_first_with_other_bytes: a member that answers first with other bytes is passed over. Reducing the re-hash check to a namespace check turns the test red.

  • Suites. SDK node and handler suites: 82 passed, 0 failed. make lint exit 0, guard clean, no-clock gate OK.

Rig, APK = this branch Trade Head walks Completing Settle
Chain, 20 ERA → 106.95 HOP (position 17) 23 s 6.1 s 4.4 s 4.6 s
Split, 600 RIGT → 32.63 ERA (position 18) 19 s 3.7 s 4.4 s 4.9 s
First step only, chain (position 16) 27 s 8.5 s 6.8 s 5.3 s

Quotes take 10–12 s, unchanged. Their owner walks happen inside Core's vaults_of_token.

Pins

INTENT_PINS will be repinned once, from this PR's Code map.

A verifier context (one per resolution, quote or walk) keeps what it
read for its life (ReadOnce), and only what nothing later can change:

- a cell's reads, once Core evaluated them final. SofiReads::keep_completion
  now carries the evidence with the proof. An owner walk's register cell is
  kept once read_root_cell reads a Final claim from it.
- a Kept precommit, fulfillment or setup, and a Stored object.
- a Complete vault-genesis scan.
- an owner walk's object, kept only when its identity, recomputed from its
  bytes, is the one asked for.

An open or undecided cell is read again each time it is asked for. The
settle reads its precommit through the verifier's reads.

Tests:
- one_resolution_asks_each_node_for_a_final_cell_or_an_object_once: an
  order split across two vaults, so the resolution walks the owner's lineage
  twice. Nothing is asked twice except each vault's open next key. Nine
  mutations turn it red.
- a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone
  now walks in a context of its own, so its request log still shows
  everything the walk reads.

Rig (APK = this branch), against 53 s / settle 28.7 s before this change:
- split 1500 RIGT -> 114.90 ERA: 26 s, settle 4.5 s.
- chain 20 ERA -> 175.67 HOP: 27 s, settle 5.3 s.
- quotes: 10-11 s.
…s the first bytes that verify

- The trade walks each vault's head at once (sofi_flow::chains_at_once):
  a scoped thread per vault over the one verifier. A walk reads only its
  owner's lineage and its own cells. Its kept reads are shared, and no
  memo lock is held across a read.
- Each leg's exercise is written at once (write_exercise). P's legs name
  strictly ascending vault ids (R15-4), so each leg is its own cell, and
  a cell put takes a per-key advisory lock.
- An object fetch keeps the first answer whose bytes re-hash to the
  address, instead of waiting for the slowest member.

Publishing a produced operation's objects at once was tried and
withdrawn. The storage node aborts concurrent immutable puts:
SERIALIZABLE, no retry on 40001, HTTP 500. That is recorded as an
open storage finding (§6.61).

Test: a_fetch_passes_over_a_member_that_answers_first_with_other_bytes.
A member that answers first with other bytes is passed over. Its
mutation turns the test red.

Rig (APK = this branch):
- chain 20 ERA -> 106.95 HOP: 23 s (was 27 s)
- split 600 RIGT -> 32.63 ERA: 19 s (was 26 s)
@cryptskii cryptskii changed the title perf(sdk): a SoFi resolution reads each final cell, object and index once perf(sdk): a SoFi trade reads each final cell once and overlaps its independent I/O Oct 1, 2026
@cryptskii
cryptskii marked this pull request as ready for review October 2, 2026 01:33
@cryptskii
cryptskii merged commit 99e9019 into main Oct 2, 2026
25 of 27 checks passed
@cryptskii
cryptskii deleted the perf/sofi-trade-read-once-and-overlap branch October 2, 2026 01:33
cryptskii added a commit that referenced this pull request Oct 2, 2026
* fix(storage): bound what one client can hold on a node

Security pre-audit, item 10, from the storage side. Each bound has a test
that holds the resource the way a hostile client would, and a mutation
control that turns it red.

- One count of requests in flight for the whole node. A router applies a
  layer to each route on its own, so ConcurrencyLimitLayer gave every route
  its own limit; GlobalConcurrencyLimitLayer shares one.
  (one_count_of_requests_in_flight_holds_across_every_route; per-route
  limit → red)
- A request time, outside the limit, so waiting for a slot is bounded too.
  `[http] request_timeout_secs`, 60 by default; answered 408.
  (a_request_whose_body_never_arrives_is_answered_408; disabled → red)
- A header-read bound on every connection, with the timer the HTTP library
  needs in order to keep any bound. axum-server never gave it one.
  `[http] header_read_timeout_secs`, 10 by default.
  (a_connection_that_never_finishes_its_headers_is_closed. Red without the
  bound, and red without the timer: hyper then drops the connection at once,
  which the test refuses as too early.)
- The pool is bounded: 32 connections, a 10 s wait. Closers queue on the
  node's own lock before they take a connection, so queued closers hold
  none. (closers_waiting_their_turn_leave_the_pool_to_everyone_else; a lock
  taken and dropped → red)
- A set-mate's answer is read no further than the largest ByteCommit
  encoding (221 bytes, derived from the fields).
  (a_set_mate_whose_answer_never_ends_is_read_no_further_than_a_byte_commit;
  uncapped → red)
- The store-wide SMT builds for a close and for a proof run off the
  request threads (spawn_blocking).
- The mirror write is durable like every other write.
- Health answers "postgres unavailable" and logs the error; it no longer
  sends the driver's message to whoever asks. (health test; old text → red)
- Spool keys are kept under their one canonical Base32 Crockford spelling,
  so a writer's spelling cannot put a message where its recipient never
  reads. (a_message_sent_under_another_spelling_of_its_key_reaches_its_spool;
  raw key → red)
- The immutable put no longer logs the caller's header text.
- Deploy: SSH host keys are accept-new, not ignored. node.key is owned by
  the uid and gid the image runs as, at 600, not 644. Both services' logs
  rotate (5 × 50 MB).

The timeouts are Durations handed to the HTTP and pool libraries. No node
source reads a clock; no_clock_reads stays green.

Node suite (release, Postgres): lib 39, bin 4, nine integration suites
including resource_limits 4/0 and bytecommit_chain 9/0. fmt, node clippy,
the guard and the clock gate are clean.

* fix(storage): immutable puts made at once never refuse each other

insert_immutable_object_if_absent ran SERIALIZABLE (SELECT … FOR UPDATE,
then INSERT) with no retry. Concurrent puts of different objects failed
with serialization failures (40001), which the node answered as 500. SOFI
measured 6 of 16 on the rig node set.

The put is now read committed and idempotent:
- INSERT … ON CONFLICT (addr_b32) DO NOTHING;
- when nothing was inserted, the held tuple is read to tell an identical
  copy from a conflicting one.

Different objects never contend, and puts at one address wait for each
other at the insert. It runs through begin_durable_write, like every other
write.

Test: puts_made_at_once_are_each_taken. Three rounds of 64 distinct objects
put at once, each taken, and 16 identical puts at once, each acknowledged
under one address. Mutation control: the serializable put → red, 29 of 64
refused with 500 in round 0. The corruption test still reports a different
tuple at an address. immutable_store_round_trip: 8/0.

* fix(sofi): a trader's single-root parent is what its lineage holds at K_root(p), never a claim P carries

Pre-audit item 2 (Critical), audited at main 076792e.

The flaw:
- A trader could name, as P's parent, a single-root claim it signed for
  itself, with a root it never held and invented pre-balances under it.
- Conformance verified that claim under the key it carries.
- The facts mapped every single-root parent to compatible.
- So the swap realized, and walkers took the vault's key as consumed: a
  drain, and a mint on Close for a vault-owning attacker.

The fix:
- A single-root parent now counts only as what the trader's lineage holds
  at p: the claim final at K_root(p) and its root. The verifier establishes
  this itself, from its own admitted position or the A8 walk
  (trader_root_at -> peer_root_at). No separate mechanism is added.
- ResolvedParent carries the claim that holds K_root(p).
- ParentPosition::SingleRoot { named, held, held_root }: the ladder
  computes compatibility (held exactly as named, at P's root) and
  impossibility (its negation), so the old SingleRoot => true/false arms
  are gone.
- The S15 resolver hands over the walk's own authenticated parent for both
  kinds of claim.

Tests:
- Adversarial: a claim the trader doesn't hold at p, and the claim at
  another root, each make the position Invalid and the key Skipped. These
  were Ok(Realized) before the fix.
- A parent that is not established is no fact.
- Three mutations turn the tests red.
- Lean: ParentState.singleNotHeld, with theorem
  single_root_parent_not_held_is_invalid. It fails to check if the case is
  dropped from parentImpossible.

Suites: dsm sofi 240/0, SDK node suites 80/0, lint 0, guard clean.
Still depends on pre-audit item 1 (CORE: claims at K_root bound to the DevID key).

* docs(conformance): pre-audit item 2, a single-root parent is what the lineage holds at K_root(p) (§6.62)

* fix(sdk): a member is reached over https and known by its certificate; junk cannot wedge an inbox

Security pre-audit, items 7, 8 and 11 from the storage side. Each gate has
a test and a mutation control that turns it red.

- https:// only, in the env config loader and in every member client
  (storage_node_sdk::require_https). Tests:
  network::tests::an_endpoint_that_is_not_https_is_refused and
  a_member_endpoint_that_is_not_https_gets_no_client. Mutation: http
  accepted → both red.
- A member client trusts only the CAs the env config names, not the system
  store. It accepts only a certificate naming the member id as a DNS
  subject alternative name, whatever address it dialled (NamesTheMember).
  MemberClient::new(member_id, endpoint) builds its own client, so a
  member's name and its connection's identity cannot disagree. The inbox
  client holds one such client per member endpoint. The five fleet certs
  were read in a handshake and each carries DNS:dsm-node-N for its member.
  Test: a_members_latest_bytecommit_is_its_own_or_there_is_none (a client
  for dsm-node-2 at dsm-node-1's node is refused on the certificate).
  Mutation: verify the dialled address → red.
- The SDK test harness serves its nodes over TLS from a per-set CA, each
  certificate naming its member and 127.0.0.1, as the fleet's do. There is
  no localhost exception. point_sdk_at names the CA as the bundled config
  does.
- Transport errors carry their causes, so a refused certificate says why.
- Inbox:
  - a node-supplied spool position is used only if a spool could hold it.
    u64::MAX + 1 panicked the poller, and a position past i64::MAX aborted
    the read (a_position_past_what_a_spool_can_hold_is_none; unchecked →
    red).
  - an envelope that will not open with this device's key is passed over,
    not stopped at for good
    (what_never_opens_for_this_device_holds_up_nothing_behind_it; no
    advance → red). The key is derived once per read.
  - the poller's running flag is cleared however its task ends
    (a_poller_that_panics_is_no_longer_running; never cleared → red).

CONFORMANCE §6.62 records items 7, 8, 10 and 11, the decisions left to the
owner (paging, write budgets, the mirror cap, committing the CA) and the
sync-limit question left to SOFI.

dsm_sdk whole suite (release, Postgres, TLS harness): 1093/0. Clippy for
dsm_sdk and dsm_storage_node --all-targets -D warnings is clean. fmt, the
guard and conformance_evidence are clean.

* fix(wallet): a send names its recipient by device id, never by alias

Pre-audit item 6 (P0), audited at main 076792e.

The flaw: the send screen chose a contact by its device id but sent the
contact's alias. wallet.sendSmart resolved that alias to the first matching
row, and aliases are not unique, so a send to the second "Bob" paid the
first.

The fix:
- OnlineTransferSmartRequest reserves field 1 (recipient) and carries
  bytes recipient_device_id = 5 [(dsm_fixed_len)=32].
- The SDK takes those 32 bytes or refuses the request, and resolves
  nothing by alias. get_contact_by_alias had no caller left and is removed.
- The frontend sends the chosen contact's device id, the key SendTab
  selects it by.
- Practice mode finds recipients by device id.

Test: a_send_pays_the_contact_chosen_not_the_first_of_its_alias. Two
contacts share an alias, and each is paid what was sent to it. It was red
on the old wire. Resolving by alias turns it red again.

Frontend: type-check clean, 7 suites, 51 tests. make lint exit 0.

* docs(conformance): pre-audit item 6, a send names its recipient by device id (§6.63)

* chore(deps): the client workspace's lock records the merged storage dependencies

STORAGE's branch added dsm_sdk -> axum-server (a dev-dependency) and
dsm_storage_node -> hyper-util, plus tower-http's timeout feature. These
were recorded in the root lock only. The client workspace builds
dsm_storage_node as a dev-dependency, so its lock carries the same three
edges. No version changed.

* feat(core): a root-cell occupant proves its own authority (A10, S20)

Owner ruling, 2026-10-01, on the audit finding that anyone could occupy
any identity's next root cell: "If an object can win/occupy a root
position, the object itself proves authority for that position."

Every object recognized at K_root(q) is now signed by a key that, with the
AttA the object carries, derives the device it names. Nothing is fetched.

- **Root claim.** `EconomicRootClaimBody` moves to schema 2 and carries
  `claimant_att_a`. `decode_and_verify_economic_root_claim` refuses a claim
  whose key and AttA do not derive `trader_devid`
  (`KeyIsNotTheNamedDevices`).
- **C_q.** It occupies K_root(q) only as the trader-signed
  `SignedSofiResolutionClaim` (class 0x0062, signed under
  `DSM/sofi/resolution-claim-sign/v1`). A bare C_q is refused by name.
  `sofi::signature::verify_resolution_claim` checks the signature and the
  binding, and `sign_resolution_claim` refuses a key that does not derive
  the named device.
- **Identity at a root cell.** A conditional claim is identified by its
  derived body, so readers keep comparing `derive::resolution_claim(P, F)`.
- **Producers.** The admission flow signs its root claim with this device's
  AttA. `sofi_register` signs the trader's C_q. The relay carries the
  trader's signed C_q from the cell, because it cannot author one.
- **Tests.** The SoFi fixture device and the peer device have ids derived
  from their keys. Adversarial tests:
  - a root claim and a C_q signed by another device (with its own AttA or
    the victim's) name no cell;
  - an unsigned C_q names no cell;
  - a squatter's claims written first at every seat leave the cell open
    until the trader's own claim holds it.

* docs(intent): MR-SOFI-0356 names held_at, the function that replaced parent_named

Item 2 (§6.62) replaced resolve.rs's parent_named with held_at, which counts
a walked root only at the position it reached, and moved the comparison of
the claim P names with the claim held into the facts (establish_ground).
The manifest row still named parent_named, so the requirement map refused
the root query and #1096's Code map failed before producing a map.

The row now names held_at, evidenced by
what_a_walk_holds_counts_only_at_the_position_it_reached. Mutation control
(2026-10-01, restored): held_at taking a root of another position turns that
test red. The matrix and the §8 row say what parent_named's test covered
that no test covers now, a conditional parent held by another fulfillment,
which pre-audit item 12 restores.

* feat(core): a fulfillment proves its device at K_ful(q) (A10, S20)

The owner's item-1 rule reaches the fulfillment cell: an F at K_ful(q)
can win the position, so it proves its own authority for it.

- `TraderFulfillmentBody` carries `claimant_att_a`.
- `registration::fulfillment_proves_the_device` checks that F's key with
  that AttA derives the cell's device, from F's bytes alone.
  `names_fulfillment_key` applies it before it looks up P, so a
  foreign-key F neither holds the cell nor makes a reader fetch a P it
  names.
- Its P key equality then binds P's key too.
- The SDK's `build_fulfillment` takes this device's AttA and refuses a P
  whose key does not derive the device it names.
- Test: a squatter's own P and F naming the victim's device, verifying
  under the squatter's key, name no cell with either AttA, even with that
  P in hand. The trader's own F names it.
- The independent encoder and every F fixture carry the AttA.

* docs(specs): DSM A10 and SoFi S20, the occupant proves its own authority

- **DSM Amendment A10.** It follows the explainer's root-claim paragraph.
  "Only Alice can sign one" is enforced from the bytes in hand: every
  occupant of a device's position cell carries its key and AttA, and is
  recognized only when its signature verifies and `derive_devid(key, AttA)`
  is the cell's DevID.
- **SoFi Amendment S20.** It sits at §17.4.
  - K_root(q) holds the trader-signed C_q (class 0x0062).
  - F carries AttA, and is bound before P is fetched.
  - The exercise carries the trader's signed C_q.
  - A relayer completes the pair with the trader's C_q and never authors
    one.
  - It names every section it amends.
- **MASTER.** §1 re-pinned. MR-DSM-0277 and MR-SOFI-0360 to MR-SOFI-0362
  added from the amendments. §7.1 entry added.
- **CONFORMANCE §6.65 (item 1).** MR-DSM-0277, MR-SOFI-0360 and
  MR-SOFI-0361 are Met with mutation controls. MR-SOFI-0362 is Missing: the
  exercise's C_q lands on #1096. §7 totals regenerated.
- **VERIFICATION_MATRIX.** Rows for the three gates and their mutations.
- **Code.**
  - `RootClaimSettlement::Lost` boxes its holder: the claim grew by its
    AttA.
  - The node test's re-aimed F carries the honest F's AttA.

Board: dsm lib 1381/0 (four runs), dsm integration all pass, SDK
integration all pass, make lint 0. SDK lib: 1045/4 under load; the four
(funding writes Preserved, not final, while the shared Postgres was busy)
pass 4/0 when rerun alone.

* docs(conformance): the item-1 matrix row names its test once

The negative-test column of MR-SOFI-0361 put P and F in backticks inside
its prose, which the evidence check read as test names. Plain words now;
conformance evidence passes.

* fix(sofi): junk can no longer exhaust a walk or hold a key or a position for good

Pre-audit item 12. Four paths by which cheap junk froze a vault or a
trader's position, each with a node or unit test that went red on the
old code:

- The walk's budget ended walks. Conformance item 5 read K^(0) to
  K^(min(a,16)-1), though it needs K^(a-1) alone, so a fulfillment at
  attempt 17 never had the key it names. Extending a chain and a leg's
  liveness walk both stopped at Continue. Junk exercises at the first 17
  keys of a root therefore froze the vault there. Item 5 now reads exactly
  K^(a-1), and every caller resumes at the cursor (SoFi §23.6).
- Recognition counted an exercise's closure objects and checked nothing
  else, so a copy of an honest exercise with other closure bytes, written
  first at a leg's leader, held the key and could never be classified. Each
  closure object must now be the object its reference names, by
  conformance item 8's rule (§17.5).
- A registration read fetched the precommit of every fulfillment any seat
  held at K_ful(q), and failed when one could not be read, so a value at
  any member, written at any time, stopped every read of that position.
  Only the leader's log decides the cell: precommits are fetched only for
  its copies, in arrival order, until one names the key.
  route_chain::leader_copies is the one rule the leader link and this read
  share.
- A conditional parent another claim holds at p was "not established"
  forever, so no key its exercise held was ever skipped. It selects no root,
  ever: ConditionalNoRoot, impossible, skipped without evidence (§23.3).

Tests: junk_at_more_attempt_keys_than_one_walk_examines_is_walked_past,
an_exercise_carrying_other_bytes_than_its_closure_holds_no_key,
nothing_but_the_leaders_first_fulfillment_decides_k_ful (node);
a_conditional_parent_another_claim_holds_selects_no_root (facts). Seven
mutation controls, one per gate, each red and restored.

* fix(core): decoders sized by their input; a nonce spent in its relationship

Security pre-audit items 15 and 5.

- **Operation decoder.** `dec_vec_bytes` no longer preallocates from a
  sender-written count. A recovery operation claiming u32::MAX authority
  signatures asked for ~103 GB before decoding any: a panic on 32-bit,
  and an abort under a refusing allocator. The vector now grows only as
  elements decode.
- **Proof decoders.** `DevTreeProof` and `SmtInclusionProof` compute their
  length checks in u64. On 32-bit, `count * 32` wrapped for count 2^27
  and the copies indexed past the input.
- **Nonce scope.** A transfer nonce is spent under
  `relationship_nonce_hash(rel_key, nonce)` (`DSM/relationship-nonce/v1`)
  in the canonical apply's identity, in `spent_nonces` and in staging.
  - Before, a nonce was spent device-wide. Nonces derive from public
    inputs, so one contact could spend another contact's predicted nonce
    and have that contact's real transfer dropped as decided.
  - Replay is still refused: the same transfer carries the same nonce in
    the same relationship, and the (relationship, parent) record holds.
  - The unused device-wide `is_nonce_spent` and `mark_nonce_spent` are
    deleted.
- **Tests:** the three hostile decoder inputs, and a nonce spent in one
  relationship staying unspent in another.

* docs(conformance): §6.67, core decoders and the nonce scope (pre-audit items 15, 5)

- Item 15. Each fix and its test are recorded. So is what this 64-bit
  macOS host cannot show: the old failures were 32-bit or
  allocator-specific, so no mutation turns these tests red here. Out of
  this round's scope: recovery capsule counts. The phone's response sizes
  are STORAGE's.
- Item 5. The cross-sender nonce finding, its relationship-scoped fix,
  and its mutation control (the scope removed: the cross-relationship
  test red).

* fix(core): a credit's source is held to account through its own segment (A8)

Security pre-audit item 3. Owner ruling 2026-10-01, "Segment now,
proofs later".

- **The gap.** A credit's source step was validated, but the source's own
  history was authenticated by root claims alone. "Admitted" means only
  that the source signed its root, so two wallets of one owner could
  create value one hop back: C registers an invented root, pays its own A
  out of it, and A pays honest B.
- **The fix.** `source_step` validates the source's segment from this
  receiver's frontier for it with `chain_through`, exactly as a payer's
  segment is validated. That segment's own credits' sources are held to
  account the same way, back to frontiers this verifier already
  validated. The source step itself is a debit that names no source.
- **Fail closed.** When the ancestry exceeds the shared walk budget before
  reaching a frontier, the result is Incomplete, never weaker evidence.
- **Renames.** The provenance types now say what they do: `SourceAncestry`,
  `DebitHasNoSources`, `StepRole::Source`, `source_step`.
- **Tests.**
  - The source's step at 1 is validated from its witness and successor
    evidence. This was the old "one hop and no further" test, now
    inverted.
  - A source's segment stops at the frontier the receiver holds: nothing
    of C at or behind it is read.

* test(core): a source walk past the budget is Incomplete and reads nothing

Owner ruling 2026-10-01, pre-audit item 3, test 4: a source's ancestry
that exceeds the walk budget before a frontier this verifier holds is
Incomplete, never accepted on weaker evidence.

* test(sdk): a walk an outage cut short records no frontier; recovery records one

A frontier becomes trusted only once the whole segment to it has passed
(A8, owner ruling 2026-10-01, pre-audit item 3 test 5).

* docs(conformance): pre-audit item 12, junk cannot exhaust a walk or hold a key (§6.66)

§6.66 records item 12's findings apart from their fixes:
- 12a–12d closed by `96b870fdd`, each with its adversarial test, red on
  the old code, and its mutation controls.
- 12j: a fulfillment's registration at K_ful(q) depended on when the
  precommit it names is published. A trader can make early verifiers
  see its trade realized and later ones see the position unresolvable,
  with the vault key frozen. Confirmed by a probe on the nodes. The
  closure is agreed with CORE: decide K_ful(q) from F's bytes alone,
  per the item-1 ruling. In progress.
- Open: 12e (the exercise carries the signed C_q, S20), 12f (a parent
  on a lineage known invalid), 12g and 12h (owner rulings), and 12i
  (locators computable in advance).

§8 corrections:
- MR-SOFI-0244 was Met on the pure walk while three of its callers
  stopped at the budget.
- MR-SOFI-0213, MR-SOFI-0170, MR-STOR-0132 and MR-SOFI-0241 name the
  new gates and tests.
- MR-SOFI-0234 is Partial on 12f. §7 is regenerated.

Matrix rows for items 2, 6 and 12, the first two of which had none.

* feat(core): a source's validated frontier is cached with the acceptance (A8)

Each credit source's segment that passes whole leaves a frontier at the
source step. The walk reuses it for later sources of the same identity, and
the accept transaction records it beside the sender's, so the next credit
from that source validates only the suffix (owner ruling 2026-10-01:
"Cache/update validated frontiers so subsequent interactions validate only
the new suffix"). validate_peer_lineage returns ValidatedPeerLineage.

* docs(spec): A8 holds a credit's source to account through its own segment; §6.68

Owner ruling 2026-10-01 (pre-audit item 3, "Segment now, proofs later"),
quoted in A8. The One hop bullet becomes Credit sources; S15's SetupValid
wording follows. MR-DSM-0273-0275 and MR-SOFI-0347 rewritten; §1 re-pinned.
Tests 1 and 2 of the ruling recorded as owed (hostile-device harness).

* docs(spec): S20 decides and matches the position pair from its own bytes (12j)

A reader passed over an F whose P it did not hold yet, so an F naming an
unpublished P, written ahead of the F a trader traded with, flipped the
position between verifiers reading before and after the P appeared, and
froze the vault key the trade's exercise held (SOFI's node probe).
Applying the item-1 ruling ("the object itself proves authority for that
position"): K_ful(q) is decided from F's bytes alone; the pair is matched
by FulfillmentId(F); an F whose K_ful(q) another F holds in any state is
lost; the C_q body is compared with derive(P, F) where P is in hand, a
difference losing F and making the position Invalid. MR-SOFI-0168, 0343,
0360, 0361 rewritten and Partial until SOFI's change lands on #1096.

* fix(sofi): a position pair is decided from its own bytes, the same whenever it is read (12j)

Pre-audit item 12, 12j (SoFi Amendment S20, extended). Recognizing a
fulfillment at K_ful(q) used to need the precommit it names, and a precommit
not published yet (Resolved::None) made the reader pass over that
fulfillment. A trader could write F_a, naming an unpublished P_a, ahead of
the F_b it traded with:
- verifiers reading before P_a appeared saw F_b registered and the trade
  realized;
- verifiers reading after saw F_a holding the cell and F_b neither
  registered nor lost, with the vault key frozen.

Now:
- names_fulfillment_key decides from F's bytes alone: it verifies under its
  own key, names q, and its key and AttA derive the cell's DevID. No
  precommit is read, so read_registration fetches none, and
  NAMED_PRECOMMIT_BUDGET and PrecommitLookup go.
- The pair is matched by FulfillmentId(F). Registration::Held names the
  fulfillment that holds K_ful(q)'s leader link before the pair is final,
  and RegistrationRead carries the id of the claim at K_root(q).
- RegistrationRead::standing_of (P in hand) loses F to any other
  fulfillment holding K_ful(q)'s leader link, in any state (§23.1), and to
  any claim at K_root(q) other than derive(P, F). That covers rule 4:
  a claim naming F with another body. The facts read the pair through it.
- The S15 resolver compares the held claim with derive(P, F) once P is in
  hand: Invalid when they differ.
- route_chain's public leader_copies wrapper had no caller left and goes;
  leader_link keeps its iterator.

Tests:
- a_position_reads_the_same_before_and_after_a_precommit_is_published
  (node): red on the old code by the probe, where the read flipped and the
  key froze.
- a_fulfillment_is_lost_to_any_other_leader_and_to_any_claim_not_its_own
  (unit).
- Mutations: another fulfillment's leader link losing nothing turns both
  red; skipping the body check of a registered pair turns the unit test red.

* test(sdk): the nonce tests spend the nonce a transfer derives, not a literal

CodeQL flagged `let nonce = b"predicted-nonce"` (hard-coded cryptographic
value) in the item-5 test, and `b"unique-nonce-42"` beside it has the same
shape. Both tests now spend the nonce the SDK derives for a transfer
(`transfer_nonce`, §4.1) from a relationship's public inputs. That is the
exact attack the item-5 test describes: a contact predicts relationship B's
next nonce from B's public inputs and spends it in A first.

Mutation: the relationship key left out of relationship_nonce_hash turns
a_nonce_spent_in_one_relationship_is_not_spent_in_another red at the
attack's assertion. Restored byte for byte.

* docs(conformance): 12j closed; S20's pair rows Met; the nonce tests' note

- §6.66 12j is closed by `51359a6b6`, with its change, both tests, and
  two mutation controls. The resolution's body check has no test of its
  own yet, and the record says so. 12c's leader-log loop is superseded:
  registration reads no precommit at all.
- §8:
  - MR-SOFI-0168, 0343, 0360 and 0361 are Met (CORE left them Partial
    pending this change).
  - MR-STOR-0132 no longer names the removed `leader_copies`.
  - The renamed test
    `only_a_fulfillment_signed_under_a_key_of_this_device_names_the_key`
    is cited by its new name.
  - §7 is regenerated.
- §6.67 notes why item 5's tests now spend a derived nonce (CodeQL,
  `c1e4e6f8d`).
- The matrix gains a 12j row, and the 12c row no longer cites
  `leader_copies`.

SDK suites at `c1e4e6f8d`: 465 passed, 0 failed.

* test(storage): a cell takes the largest route entry and refuses a byte more

No test covered the edge of the cell value limit. A cell put, alone or in a
batch, takes a value of exactly dsm::route_chain::MAX_ENTRY_BYTES, refuses
one byte more with 413, and holds nothing from the refused put. The test
reads the constant, so it follows a change to Core's largest value (SoFi
12e raises it for the exercise that carries the trader's signed C_q). Both
values are under the request body limit, so the refusal is the cell's own.

Mutation controls, red and then restored:
- the node's limit one byte looser → 200 where 413 is required;
- the limit set to the value length instead of the entry length → a value
  at the limit refused.

cells_keep_everything: 9/0. fmt and node clippy are clean.

* fix(sofi): the exercise carries the trader's signed C_q, so a withheld pair can be registered from it (12e)

SoFi Amendment S20 makes C_q trader-signed, so no relayer can author it. A
trader that wrote its exercise at a vault's key and withheld its position
pair held that key for good: its F could never register, rung 0 waited, and
every later trade at the vault waited behind it.

- SofiExercise carries the trader's signed C_q (class 0x0062) after F.
- recognize_exercise requires F to prove P's device from its own bytes, and
  the carried claim to be recognized exactly as K_root(q) recognizes it
  (root_claim_naming) with the body derive(P, F). An exercise failing either
  is nothing at the key.
- The install returns the signed C_q it wrote, and the exercise carries those
  bytes.
- sofi_relay::relay_exercise registers the fulfillment whose exercise holds a
  vault key from the exercise's bytes alone, and carries the exercise.
  relay_fulfillment carries the pair with the claim K_root(q) holds, or the
  one its exercise carries. relay_position_pair had no caller and is gone.
- The cell value cap rises from 256 KiB to 320 KiB (route_chain::MAX_VALUE_LEN,
  the proto bounds, MAX_EXERCISE_BYTES): a two-leg exercise measured 218,735
  bytes before C_q, about 269 KB with it. STORAGE agreed; the node's cap
  follows MAX_ENTRY_BYTES.

Tests: node test
an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes;
unit tests an_exercise_carries_the_traders_signed_claim_of_its_own_p_and_f and
an_exercise_whose_fulfillment_does_not_prove_the_traders_device_is_nothing.
Mutations, each red: the relay ignoring the exercise's claim; the body
comparison dropped; the claim's verification dropped; the device check
dropped.

* test(sdk): the member that answers first with other bytes is reached over TLS

#1094's a_fetch_passes_over_a_member_that_answers_first_with_other_bytes
stood up member 0 as a plain-HTTP responder at the scheme of the member's
endpoint. Under https-only member clients that scheme is https, so the
device's TLS ClientHello reached a responder that read it as an HTTP head
("the request ended before its head did"). The responder never answered,
the relays never opened, and the fetch returned None. The test was red at
#1096's head.

- The node set keeps its CA, and NodeSet::tls_for (with Fleet::tls_for)
  issues the certificate a member serves, naming it, for a test that
  stands a member up at another address.
- The substitute serves that certificate (tokio-rustls, already an SDK
  dependency), so the device reaches it as it reaches the member, and the
  test again exercises fetch_verified.
- Mutation control: fetch_verified keeping the first answer whatever it
  hashes to → red ("other bytes" kept). The test is load-bearing again.

storage_node_sdk tests: 8/0 (release, Postgres, TLS harness). fmt and
dsm_sdk clippy --all-targets are clean.

* fix(sofi): the next operation at a vault registers a withheld pair from its exercise before passing the key (12e)

Owner ruling, 2026-10-01: "If a vault key is held by an exercise whose pair
is not yet registered, the relayer MUST use the signed C_q carried by that
exercise to register the trader's missing pair before advancing past that
held key. ... After successful pair registration, the relayer retries
progression."

- The walk a trade, route or close makes for its live attempt
  (walk_for_attempt) checks each held key it would pass: when the exercise's
  pair stands Pending (neither registered nor lost), it registers the pair
  with relay_exercise, carrying the trader's bytes exactly, and walks again.
  A registered or lost pair, or one the reads do not decide, is not written.
- A trade and a close walk each vault's chain past withheld pairs before
  drafting (chain_past_withheld_pairs), so they draft at the head those
  registrations leave.

Tests:
- an_exercise_whose_trader_withholds_its_pair_is_registered_from_its_own_bytes
  now has the vault's owner close the vault. A claim A signs for B's pair
  occupies nothing; A's close registers B's pair from B's exercise, closes at
  the head B's trade left, and B's position realizes.
- a_held_key_whose_pair_is_registered_is_passed_without_writing_the_pair:
  every member holds the same values at B's two cells before and after A's
  close.

Mutations, each red: the walk never relays (A's close is RetriesExhausted
behind the key); a registered pair counts as withheld (A's close is refused
after relaying it round after round).

* docs(conformance): 12e closed, with the owner's ruling on who relays a withheld pair

- §6.66 12e: the record (exploit, closure, the owner's 2026-10-01 ruling
  "Choose \"Next trader.\" ... MUST ... before advancing past that held key",
  the cell value cap raised to 320 KiB and why), and six mutation controls.
- §8 MR-SOFI-0362 Missing -> Met. MR-DSM-0213 cites relay_exercise.
- §6.64: the new per-entry maximum beside the cell GET paging item (STORAGE).
- §6.65: its open note points at the closure.
- VERIFICATION_MATRIX: the 12e row.
- §7 regenerated.
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