diff --git a/dsm_storage_node/tests/immutable_store_round_trip.rs b/dsm_storage_node/tests/immutable_store_round_trip.rs index cf6a8b538..ee8912b2b 100644 --- a/dsm_storage_node/tests/immutable_store_round_trip.rs +++ b/dsm_storage_node/tests/immutable_store_round_trip.rs @@ -147,3 +147,168 @@ async fn an_unknown_address_is_not_found() { assert_eq!(status, StatusCode::NOT_FOUND); assert!(got.is_empty()); } + +/// One member on a fresh store named `store`, and that store, so a test can +/// reach the rows the member keeps. +async fn member_and_store(store: &str) -> (Router, Arc) { + let pool = common::fresh_store(store).await; + let state = Arc::new(common::ok_or_panic( + AppState::new("member".to_string(), pool.clone(), common::set_client()), + "app state", + )); + (common::served(state), pool) +} + +/// A put that states the address its caller computed, as `x-expected-addr`. +async fn put_stating(app: &Router, namespace: &str, bytes: &[u8], stated: &str) -> StatusCode { + let req = Request::builder() + .method("POST") + .uri("/api/v2/immutable/put") + .header("x-namespace", namespace) + .header("x-expected-addr", stated) + .body(Body::from(bytes.to_vec())) + .expect("request"); + app.clone().oneshot(req).await.expect("oneshot").status() +} + +/// The content address of `bytes` in `namespace`, as the registry derives it. +fn address_of(namespace: &str, bytes: &[u8]) -> String { + text_id::encode_base32_crockford(&dsm::storage_object::immutable_addr( + dsm::crypto::domain::TaggedHashDomain::try_new(namespace.as_bytes()).expect("tag"), + bytes, + )) +} + +/// Storage spec §5 rules 1 and 2: the node computes the address, and a +/// caller-supplied address is a check, never the key. A put that states the +/// address of other bytes is refused, and the refused bytes are held under +/// neither address. The same bytes stating their own address are stored at +/// it. MUTATION CONTROL: dropping the comparison in `put_immutable` stores +/// the bytes and turns this test red. +#[tokio::test] +async fn a_put_stating_another_address_is_refused_and_nothing_is_held() { + let app = member("immutable_stated_address").await; + let namespace = "DSM/stated-address-check"; + let payload = b"the bytes this put carries".to_vec(); + let computed = address_of(namespace, &payload); + let stated = address_of(namespace, b"other bytes entirely"); + assert_ne!(computed, stated); + + assert_eq!( + put_stating(&app, namespace, &payload, &stated).await, + StatusCode::UNPROCESSABLE_ENTITY, + "an address the node did not compute is refused" + ); + for addr in [&computed, &stated] { + let (status, _, got) = get(&app, addr).await; + assert_eq!(status, StatusCode::NOT_FOUND, "nothing is held at {addr}"); + assert!(got.is_empty()); + } + + assert_eq!( + put_stating(&app, namespace, &payload, &computed).await, + StatusCode::CREATED, + "its own address is taken" + ); + let (status, _, got) = get(&app, &computed).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(got, payload); +} + +/// Storage spec §5 rule 5 and §3 rule 5: on read the node recomputes the +/// address from what it holds before serving, and a store outside the fault +/// model fails closed. Rows damaged in place are never served: one whose +/// payload changed, one whose namespace became another namespace, and one +/// whose namespace is no longer a namespace at all. An undamaged object +/// beside them still is. MUTATION CONTROL: dropping the recomputation in +/// `get_immutable` serves the first two and turns this test red. +#[tokio::test] +async fn a_held_object_that_no_longer_hashes_to_its_address_is_not_served() { + let (app, store) = member_and_store("immutable_damaged_row").await; + let namespace = "DSM/hash-on-read"; + let damage: [(&str, &[u8]); 3] = [ + ("payload", b"other bytes"), + ("namespace", b"DSM/another-namespace"), + ("namespace", b"no longer a namespace"), + ]; + let client = common::ok_or_panic(store.get().await, "store connection"); + let mut damaged = Vec::new(); + for (n, (column, value)) in damage.iter().enumerate() { + let (status, addr) = put(&app, namespace, format!("object {n}").as_bytes()).await; + assert_eq!(status, StatusCode::CREATED); + let changed = common::ok_or_panic( + client + .execute( + &format!("UPDATE immutable_objects SET {column} = $1 WHERE addr_b32 = $2"), + &[value, &addr], + ) + .await, + "damage the row", + ); + assert_eq!(changed, 1, "one row damaged at {addr}"); + damaged.push(addr); + } + let intact = b"an object nobody damaged".to_vec(); + let (status, intact_addr) = put(&app, namespace, &intact).await; + assert_eq!(status, StatusCode::CREATED); + + for addr in &damaged { + let (status, ns, got) = get(&app, addr).await; + assert_eq!( + status, + StatusCode::INTERNAL_SERVER_ERROR, + "a row that no longer hashes to {addr} is not served" + ); + assert_eq!(ns, None, "no namespace is served for it"); + assert!(got.is_empty(), "no bytes are served for it"); + } + let (status, _, got) = get(&app, &intact_addr).await; + assert_eq!(status, StatusCode::OK, "the intact object is still served"); + assert_eq!(got, intact); +} + +/// Storage spec §5 rule 4: replaying identical bytes re-acknowledges, and +/// different bytes at the same address are reported as corruption. With the +/// row at an address damaged in place, the object's own bytes put again meet +/// a different tuple there: the node reports it, and the held row is left +/// exactly as it was. MUTATION CONTROL: answering the conflict as an ack in +/// `put_immutable` turns this test red. +#[tokio::test] +async fn different_bytes_at_an_address_are_reported_as_corruption() { + let (app, store) = member_and_store("immutable_conflict_reported").await; + let namespace = "DSM/conflict-reported"; + let payload = b"the object as it was put".to_vec(); + let (status, addr) = put(&app, namespace, &payload).await; + assert_eq!(status, StatusCode::CREATED); + let (status, _) = put(&app, namespace, &payload).await; + assert_eq!(status, StatusCode::OK, "an identical replay is an ack"); + + let damaged = b"the bytes a failing disk left".to_vec(); + let client = common::ok_or_panic(store.get().await, "store connection"); + let changed = common::ok_or_panic( + client + .execute( + "UPDATE immutable_objects SET payload = $1 WHERE addr_b32 = $2", + &[&damaged, &addr], + ) + .await, + "damage the row", + ); + assert_eq!(changed, 1); + + let (status, _) = put(&app, namespace, &payload).await; + assert_eq!( + status, + StatusCode::INTERNAL_SERVER_ERROR, + "a different tuple at the address is reported, never acknowledged" + ); + let held = common::ok_or_panic( + dsm_storage_node::db::get_immutable_object(&store, &addr).await, + "read the row", + ); + assert_eq!( + held, + Some((namespace.as_bytes().to_vec(), damaged)), + "the held row is left as it was" + ); +} diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 90d361f1a..1805df2ca 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1571,6 +1571,18 @@ A finding stays until it is fixed or disproved, whatever a later change touches. |---|---| | `dsm/src/economic/provenance.rs` · P15-9 | Unchanged (§6.30): the peer walk refuses a resolved SoFi position. A trader whose setup follows a SoFi position of its own has no setup claim another verifier can accept, so its later routes stay unjudgeable by others. | +### 6.41 The object store's refusals are exercised (`test/storage-node-unexercised-refusals`, 2026-09-29) + +Four storage rows were Partial only because no test reached the branch that enforces them. Each now has a test on the assembly the node serves, on Postgres, and each test was observed red with its branch removed. + +| Row | Rule (storage spec) | Test | Mutation control | +|---|---|---|---| +| MR-STOR-0023 | §5.2: a caller-supplied address is a check, never the key | `a_put_stating_another_address_is_refused_and_nothing_is_held` | The comparison in `put_immutable` removed: the bytes are stored, red. | +| MR-STOR-0025 | §5.4: different bytes at an address are reported as corruption | `different_bytes_at_an_address_are_reported_as_corruption` | The conflict answered as an ack: red. | +| MR-STOR-0026 | §5.5: the node recomputes the address before serving | `a_held_object_that_no_longer_hashes_to_its_address_is_not_served` | The recomputation in `get_immutable` removed: the damaged rows are served, red. | +| MR-STOR-0016 | §3.4: a misresponse is detectable by hash and affects availability only | the same, with `dsm::sofi::storage::tests::wrong_bytes_never_count` on the reader's side | As MR-STOR-0026. | + +The damaged-row tests change a held row directly in Postgres, as a failing disk would. That puts the store outside the fault model (§3), which is the case these refusals exist for: such a store fails closed. ### 6.41 The device tree keeps its nodes: a write rehashes its path, a head loads in one build (`fix/smt-incremental-root`, 2026-09-29) **The finding** (found on `test/dsm-core-violated-rows-reverified`). `SparseMerkleTree::update_leaf` recomputed the root from every leaf on every write, splitting the whole key set at each of the 256 levels, and `get_inclusion_proof` did the same for each sibling. `DeviceState::restore` wrote every relationship tip and every other leaf through `update_leaf` one at a time, so loading a head was quadratic in its leaf count, and `establish_relationship` and `advance` paid for the whole tree on every step. A liveness defect, not a question of what the root is: the root is a pure function of the leaves (MR-DSM-0116, 0117, 0121), and it is unchanged. @@ -1630,8 +1642,9 @@ Found, not changed here: | DSM high-level (MR-DSM) | 272 | 73 | 114 | 38 | 0 | 29 | 18 | | SoFi (MR-SOFI) | 342 | 215 | 84 | 18 | 8 | 17 | 0 | | dBTC (MR-DBTC) | 135 | 0 | 0 | 0 | 0 | 0 | 135 | -| Storage node (MR-STOR) | 158 | 39 | 38 | 60 | 2 | 18 | 1 | +| Storage node (MR-STOR) | 158 | 43 | 34 | 60 | 2 | 18 | 1 | | Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 | +| **All** | **918** | **336** | **233** | **117** | **14** | **64** | **154** | | **All** | **918** | **336** | **237** | **117** | **10** | **64** | **154** | ## 8 Per-requirement results @@ -2287,17 +2300,17 @@ The deferral also covers MR-DSM-0198 and MR-DSM-0221–0237 (§6.1), and the dBT | MR-STOR-0013 | Met | `dsm::sofi::storage::stored`; `dsm::route_chain::evaluate` | `dsm::sofi::storage::tests::silence_never_counts`; `dsm::route_chain::tests::an_unread_leader_is_missing_and_no_other_seat_stands_in` | sofi/arith.rs was deleted in #976; an omitted answer is Unavailable in stored and LeaderUnread in route_chain::evaluate (see MR-STOR-0021 for the index-scan exception). | | MR-STOR-0014 | Met | `dsm_storage_node::api::objects::immutable::put_immutable`; `dsm_storage_node::api::cells::put_cell` | `dsm_storage_node::db::store_properties::immutable_put_is_write_once_on_the_tuple`; `dsm_storage_node::db::store_properties::a_conflicting_put_leaves_the_first_write_untouched`; `dsm_storage_node::cells_keep_everything::a_second_value_at_a_key_is_kept_after_the_first_never_refused` | The legacy object store, PaidK, `device_auth` and the registry are deleted (#992, §6.28; re-examined 2026-09-27, §6.36 H). No route overwrites or deletes held bytes. | | MR-STOR-0015 | Missing | no code found | — | No stale-snapshot / loss detection | -| MR-STOR-0016 | Partial | dsm_storage_node · api/objects/immutable.rs `get_immutable`; dsm · sofi/storage.rs `stored` | no test found | Mismatch branch untested | +| MR-STOR-0016 | Met | `dsm::sofi::storage::stored`; `dsm::sofi::storage::counts`; `dsm_storage_node::api::objects::immutable::get_immutable` | `dsm::sofi::storage::tests::wrong_bytes_never_count`; `dsm_storage_node::immutable_store_round_trip::a_held_object_that_no_longer_hashes_to_its_address_is_not_served` | A reader counts only bytes that re-hash to the address it asked for, so a misresponse leaves `Stored` unestablished, never a verdict. The node also refuses to serve a row that no longer hashes to its address (§6.41). | | MR-STOR-0017 | Not code | — | — | Fault-boundary assumption | | MR-STOR-0018 | Met | `dsm::route_chain::evaluate`; `dsm_storage_node::db::pg::require_durable_commit_posture` | `dsm::route_chain::tests::an_unread_leader_is_missing_and_no_other_seat_stands_in`; `dsm::route_chain::tests::the_state_is_the_count_of_valid_links`; `dsm_storage_node::db::pg::durable_posture_tests::a_weaker_posture_is_refused_and_the_refusal_names_the_setting` | sofi/arith.rs was deleted in #976; an unread leader leaves the cell waiting, a chain short of two further links stays below Final, and a node without durable commit settings refuses to start. The earlier citation `check_completion_proof` is reached by no shipped build (§6.39). | | MR-STOR-0019 | Missing | no code found | — | "seat" is comment vocabulary only | | MR-STOR-0020 | Partial | dsm · sofi/storage.rs `stored`; sofi/arith.rs `resolve` | arith/storage tests | LeaderHeld/Final implemented with the superseded count rule | | MR-STOR-0021 | Met | `dsm::sofi::storage::keep_verifying`; `dsm::sofi::storage::keep_all_verifying`; `dsm::sofi::resolve::Verifier::vault_genesis`; `dsm::economic::lineage::advance_validated`; `dsm_sdk::sdk::b0x_sdk::B0xSDK::retrieve_from_b0x_v2`; `dsm_sdk::sdk::inbox_poller::has_pending_settlement_work` | `dsm::sofi::storage::tests::an_unestablished_candidate_is_never_none`; `dsm::sofi::storage::tests::an_unestablished_candidate_makes_discovery_partial`; `dsm_sdk::sdk::sofi_reads::tests::an_unestablished_genesis_candidate_is_not_read_as_unpublished`; `dsm_sdk::sdk::sofi_flow::tests::a_setup_scan_that_met_an_unestablished_candidate_is_not_a_refusal`; `dsm::economic_admission_lifecycle::a_register_set_not_established_is_not_a_verdict_about_the_claimant`; `dsm_sdk::sdk::storage_node_sdk::tests::a_member_that_answers_404_took_nothing`; `dsm_sdk::handlers::online_finalize::tests::an_unreadable_counterparty_head_is_never_read_as_genesis`; `dsm_sdk::sdk::inbox_poller::tests::a_lifecycle_stop_is_declined_while_settlement_state_is_unreadable`; `dsm_sdk::handlers::node_e2e_tests::an_inbox_read_that_did_not_cover_every_delivery_is_not_a_complete_sync` | A candidate whose bytes were not established is never read as absence: the single scan is Unavailable past it, discovery is Partial, and the SDK reports a network failure, never "not published" (§6.15). Five more places read an unestablished fact as a verdict and no longer do (§6.25): a register set the resolver could not establish, a 404 from a member, an unreadable cert-chain head, unreadable settlement state, and an inbox no member answered for. | | MR-STOR-0022 | Met | `dsm::storage_object::immutable_addr`; `dsm_storage_node::api::objects::immutable::put_immutable` | `dsm::storage_object::tests::the_address_matches_the_spec_construction` | — | -| MR-STOR-0023 | Partial | dsm_storage_node · api/objects/immutable.rs `put_immutable` (x-expected-addr) | no test found | No test sends a mismatching address | +| MR-STOR-0023 | Met | `dsm_storage_node::api::objects::immutable::put_immutable` | `dsm_storage_node::immutable_store_round_trip::a_put_stating_another_address_is_refused_and_nothing_is_held` | A put stating the address of other bytes is refused, and nothing is held at either address (§6.41). | | MR-STOR-0024 | Met | `dsm_storage_node::api::objects::immutable::put_immutable` | `dsm_storage_node::db::store_properties::immutable_put_is_write_once_on_the_tuple`; `dsm_storage_node::db::store_properties::a_conflicting_put_leaves_the_first_write_untouched` | The legacy object store, PaidK, `device_auth` and the registry are deleted (#992, §6.28; re-examined 2026-09-27, §6.36 H). The immutable store has no update path and no other store is mounted. | -| MR-STOR-0025 | Partial | dsm_storage_node · db/pg.rs `insert_immutable_object_if_absent`; immutable.rs `put_immutable` | tests/immutable_store_round_trip.rs `re_putting_identical_bytes_acks_and_the_read_is_unchanged` | Tested on Postgres since the SQLite backend was deleted (2026-09-24), the conflict branch included (`db::store_properties::immutable_put_is_write_once_on_the_tuple`, `a_conflicting_put_leaves_the_first_write_untouched`). | -| MR-STOR-0026 | Partial | dsm_storage_node · api/objects/immutable.rs `get_immutable` | no test found | Recompute-and-refuse branch untested | +| MR-STOR-0025 | Met | `dsm_storage_node::api::objects::immutable::put_immutable`; `dsm_storage_node::db::pg::insert_immutable_object_if_absent` | `dsm_storage_node::immutable_store_round_trip::re_putting_identical_bytes_acks_and_the_read_is_unchanged`; `dsm_storage_node::immutable_store_round_trip::different_bytes_at_an_address_are_reported_as_corruption`; `dsm_storage_node::db::store_properties::a_conflicting_put_leaves_the_first_write_untouched` | The route reports a different tuple at an address and leaves the held row as it was (§6.41). | +| MR-STOR-0026 | Met | `dsm_storage_node::api::objects::immutable::get_immutable` | `dsm_storage_node::immutable_store_round_trip::a_held_object_that_no_longer_hashes_to_its_address_is_not_served` | Rows damaged in place, in the payload or the namespace, are never served (§6.41). | | MR-STOR-0027 | Met | `dsm::sofi::storage::stored`; `dsm::sofi::wire::STORAGE_FINALITY_COUNT` | `dsm::sofi::storage::tests::two_members_is_not_stored`; `dsm::sofi::storage::tests::stored_returns_exact_bytes` | — | | MR-STOR-0028 | Met | `dsm_storage_node::api::cells::put_cell`; `dsm_storage_node::api::cells::put_cells` | `dsm_storage_node::cells_keep_everything::an_identical_value_put_twice_is_held_twice` | — | | MR-STOR-0029 | Met | `dsm_storage_node::api::cells::get_cell`; `dsm_storage_node::db::pg::get_cell_entries` | `dsm_storage_node::cells_keep_everything::a_second_value_at_a_key_is_kept_after_the_first_never_refused`; `dsm_storage_node::cells_keep_everything::a_key_nothing_was_put_under_reads_as_an_empty_list_with_200`; `dsm_storage_node::db::cell_properties::every_value_put_at_a_key_is_held_in_arrival_order` | The db function lives in db/pg.rs (ORDER BY seq); the tests confirm arrival order, both values kept, and an empty list under 200 for an unused key. | diff --git a/specs/requirements/VERIFICATION_MATRIX.md b/specs/requirements/VERIFICATION_MATRIX.md index 606a23dd7..fae7c265e 100644 --- a/specs/requirements/VERIFICATION_MATRIX.md +++ b/specs/requirements/VERIFICATION_MATRIX.md @@ -137,4 +137,7 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-SOFI-0338, SoFi Amendment S12: a `TraderPreBalance` has one canonical encoding, a strictly positive amount and its own address namespace | `dsm` · sofi/wire/objects.rs · `TraderPreBalance::new`, `TraderPreBalance::decode`; sofi/derive.rs · `trader_pre_balance_addr`, `closure_content_address` | `dsm::sofi_v8_vault_bytes::a_trader_pre_balance_matches_the_independent_encoder_and_its_frozen_address` (the independent encoder and frozen address; zero, the wrong class, truncation and trailing bytes refused) | Zero-amount check removed → `a_trader_pre_balance_matches_the_independent_encoder_and_its_frozen_address` red (2026-09-29, restored). | — | | MR-SOFI-0339, MR-SOFI-0340, SoFi Amendment S12: `𝒞_E^pre` names exactly one `TraderPreBalance` of `P`'s trader for each balance `T°` states as present; a missing one is Invalid in hand, and a named one not in hand waits | `dsm` · sofi/validation.rs · `trader_pre_balances` (the count, the trader, one object per stated balance), `Evidence::pre_balance` (the address re-derived) | `dsm::sofi::validation::tests::a_balance_the_closure_does_not_carry_is_invalid`; `dsm::sofi::validation::tests::extra_balances_in_the_closure_are_invalid`; `dsm::sofi::validation::tests::a_balance_keyed_to_no_stated_balance_is_invalid`; `dsm::sofi::validation::tests::a_balance_of_another_trader_is_invalid`; `dsm::sofi::validation::tests::bytes_that_do_not_re_derive_the_named_address_prove_nothing`; `dsm::sofi::validation::tests::bytes_at_a_named_address_that_are_not_a_balance_are_invalid`; `dsm::sofi::validation::tests::a_named_balance_not_in_hand_waits` | Count check removed → `extra_balances_in_the_closure_are_invalid` red; trader check removed → `a_balance_of_another_trader_is_invalid` red; a stated balance without its number read as absent → `a_balance_keyed_to_no_stated_balance_is_invalid` red; address check weakened to "any address" → `bytes_that_do_not_re_derive_the_named_address_prove_nothing` red (2026-09-29, each restored). | — | | MR-SOFI-0341, SoFi Amendment S12: the carried balance counts only because it hashes to the leaf the core states, and every verifier judges the trader's side from it | `dsm` · sofi/validation.rs · `check_trader_balances` (pre and post values against `T°`), `trader_posts` | `dsm::sofi::validation::tests::a_balance_that_is_not_the_leaf_the_core_states_is_invalid`; `dsm::sofi::validation::tests::a_trade_that_does_not_debit_the_trader_is_invalid_from_the_exercise_alone`; `dsm::sofi::validation::tests::trader_post_states_are_recomputed_and_bound_to_the_stated_values`; `dsm_sdk::handlers::node_e2e_tests::a_vault_traded_through_closes_for_its_owner` | Pre-value comparison removed → `a_balance_that_is_not_the_leaf_the_core_states_is_invalid` red; post-value comparison removed → `a_trade_that_does_not_debit_the_trader_is_invalid_from_the_exercise_alone` red; post arithmetic off the carried balance → `trader_post_states_are_recomputed_and_bound_to_the_stated_values` red (2026-09-29, each restored). | — | +| MR-STOR-0023: a caller-supplied address is a check, never the key | `dsm_storage_node` · api/objects/immutable.rs · `put_immutable` (`x-expected-addr`) | `dsm_storage_node::immutable_store_round_trip::a_put_stating_another_address_is_refused_and_nothing_is_held` | The comparison removed → the bytes are stored, red (2026-09-29). | — | +| MR-STOR-0025: different bytes at an address are reported as corruption, never acknowledged | `dsm_storage_node` · api/objects/immutable.rs · `put_immutable` (`Conflict`) | `dsm_storage_node::immutable_store_round_trip::different_bytes_at_an_address_are_reported_as_corruption` | The conflict answered as an ack → red (2026-09-29). | — | +| MR-STOR-0026, MR-STOR-0016: a held object that no longer hashes to its address is never served | `dsm_storage_node` · api/objects/immutable.rs · `get_immutable` (recompute before serving) | `dsm_storage_node::immutable_store_round_trip::a_held_object_that_no_longer_hashes_to_its_address_is_not_served` | The recomputation removed → the damaged rows are served, red (2026-09-29). | — | | MR-DSM-0116, MR-DSM-0117, MR-DSM-0121: the device root is the tree of exactly the leaves the device holds, however the tree was reached (a write at a time over its kept nodes, or one build over a restored head), and every path it gives is that tree's path | `dsm` · merkle/sparse_merkle_tree.rs · `SparseMerkleTree::update_leaf`, `SparseMerkleTree::from_leaves`, `SparseMerkleTree::get_inclusion_proof`; types/device_state.rs · `DeviceState::restore` | `dsm::merkle::sparse_merkle_tree::tests::a_written_root_is_the_recomputed_root`; `dsm::merkle::sparse_merkle_tree::tests::a_kept_path_is_the_recomputed_path`; `dsm::merkle::sparse_merkle_tree::tests::a_large_tree_keeps_the_recomputed_root`; `dsm::types::device_state::tests::a_restored_head_recomputes_the_live_root` (a tip or an extra leaf left out moves the root) | A rewrite leaving the branch's old child hash → `a_written_root_is_the_recomputed_root` red; a split putting the new leaf always left → the same test red; a path dropping the sibling where an absent key leaves the tree → `a_kept_path_is_the_recomputed_path` red; the one-pass build swapping a branch's children → `a_written_root_is_the_recomputed_root` and `a_large_tree_keeps_the_recomputed_root` red; `restore` building without the extra leaves → `a_restored_head_recomputes_the_live_root` red (2026-09-29, each restored). | — |