Repository navigation
perf(sdk): a SoFi trade reads each final cell once and overlaps its independent I/O - #1094
Merged
Merged
Conversation
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.
…ndex once (§6.60)
…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)
… aborts concurrent object puts (§6.61)
…current-put abort
cryptskii
marked this pull request as ready for review
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A SoFi trade, made faster in two steps.
On the rig, against main at #1093's 53 s:
What repeated
Each node's requests were counted during one
resolve_pending_positionin the node harness. One rig settle read 33 cells, of which only 6 were distinct.The change
A verifier context is one per resolution, quote or walk. It keeps what it read for its life (
ReadOnceinsofi_reads.rs). It keeps only readings nothing later can change:SofiReads::keep_completionnow carries the reads along with the proof.read_root_cellreads aFinalclaim from it.Keptprecommit, fulfillment or setup.Storedobject.Completegenesis scan.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_alonean_unsigned_exercise_at_a_successor_key_takes_nothingBoth 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:
Storedobjects;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:
dsmsofi::, 237 passed, 0 failed.Static checks:
make lintexit 0, guard and self-test clean, no-clock gate OK.Rig (three phones, APK = this branch, the GCP fleet)
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:
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 lintexit 0, guard clean, no-clock gate OK.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.