Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions dsm_storage_node/tests/immutable_store_round_trip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dsm_storage_node::db::DBPool>) {
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"
);
}
23 changes: 18 additions & 5 deletions specs/requirements/CONFORMANCE_GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. |
Expand Down
Loading
Loading