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
142 changes: 142 additions & 0 deletions dsm_storage_node/src/api/transport/b0x.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,148 @@ mod tests {
assert_eq!(held, vec![short_id, junk]);
}

/// The page the spool at `address` answers from position `from_seq`: its
/// entries as `(position, bytes)`, and the position after them.
async fn page(app: &Router, address: &str, from_seq: u64) -> (Vec<(u64, Vec<u8>)>, u64) {
let from = i64::try_from(from_seq).expect("a position the route takes");
let (status, bytes) = retrieve(app, address, from).await;
assert_eq!(status, HttpStatus::OK, "a page from position {from_seq}");
let batch =
dsm::types::proto::SequencedBatchEnvelope::decode(bytes.as_slice()).expect("batch");
let entries = batch
.envelopes
.into_iter()
.map(|e| (e.seq_num, e.envelope))
.collect();
(entries, batch.next_seq)
}

/// Storage spec §8: a spool is read from a position, and reading it
/// changes nothing. More envelopes than one page holds are read whole in
/// two pages, in the order they were sent; the first page, read again
/// after the second, is what it was; a read from a position in the middle
/// is exactly the spool from there on. A spool that marked, hid or removed
/// what had been read, or served from anywhere but the position asked,
/// fails here.
#[tokio::test]
async fn a_spool_reads_the_same_from_any_position_however_often_it_is_read() {
let app = spool().await;
let spool_key = crate::db::test_store::unique_name(0x67);
let page_len = usize::try_from(MAX_BATCH_RETRIEVE).expect("a page length");
let sent: Vec<Vec<u8>> = (0..page_len + 3)
.map(|_| sealed_envelope().outer.encode_to_vec())
.collect();
for body in &sent {
assert_eq!(
submit(&app, &spool_key, "application/octet-stream", body.clone()).await,
HttpStatus::NO_CONTENT
);
}

let (first, after_first) = page(&app, &spool_key, 0).await;
assert_eq!(first.len(), page_len, "a page holds {page_len} entries");
let (second, after_second) = page(&app, &spool_key, after_first).await;
let whole: Vec<(u64, Vec<u8>)> = first.iter().chain(second.iter()).cloned().collect();
assert_eq!(
whole
.iter()
.map(|(_, bytes)| bytes.clone())
.collect::<Vec<_>>(),
sent,
"every envelope, once, in the order it was sent"
);
assert!(
whole.windows(2).all(|pair| pair[0].0 < pair[1].0),
"positions rise in arrival order"
);
assert_eq!(after_second, whole[whole.len() - 1].0 + 1);

assert_eq!(
page(&app, &spool_key, 0).await,
(first, after_first),
"reading the spool marked, hid or removed something"
);

let middle = page_len / 2;
let (from_middle, after_middle) = page(&app, &spool_key, whole[middle].0).await;
assert_eq!(
from_middle,
whole[middle..].to_vec(),
"a read from a position is the spool from that position on"
);
assert_eq!(after_middle, after_second);

let end = i64::try_from(after_second).expect("a position the route takes");
let (status, bytes) = retrieve(&app, &spool_key, end).await;
assert_eq!(
status,
HttpStatus::NO_CONTENT,
"nothing after the last position"
);
assert!(bytes.is_empty());
}

/// Storage spec §8: which messages a device has consumed is the device's
/// own state, and a spool is served only from a position. The requests a
/// device once made to acknowledge what it read, to ask a message's
/// status and to read with no position are not served, and the spool
/// reads the same after them.
#[tokio::test]
async fn a_device_acknowledging_what_it_read_changes_nothing() {
let app = spool().await;
let spool_key = crate::db::test_store::unique_name(0x68);
let sent = sealed_envelope();
let body = sent.outer.encode_to_vec();
assert_eq!(
submit(&app, &spool_key, "application/octet-stream", body.clone()).await,
HttpStatus::NO_CONTENT
);
let (read, after_read) = page(&app, &spool_key, 0).await;
let batch = dsm::types::proto::SequencedBatchEnvelope {
envelopes: read
.iter()
.map(|(seq_num, envelope)| dsm::types::proto::SequencedEnvelope {
envelope: envelope.clone(),
seq_num: *seq_num,
})
.collect(),
next_seq: after_read,
};

let message_id = text_id::encode_base32_crockford(&sent.outer.message_id);
for (method, uri, request_body) in [
("POST", "/api/v2/b0x/ack".to_string(), batch.encode_to_vec()),
(
"GET",
format!("/api/v2/b0x/status/{message_id}"),
Vec::new(),
),
("GET", "/api/v2/b0x/retrieve".to_string(), Vec::new()),
] {
let req = Request::builder()
.method(method)
.uri(uri.as_str())
.header(axum::http::header::CONTENT_TYPE, "application/octet-stream")
.header("x-dsm-recipient", spool_key.as_str())
.header("x-dsm-b0x-address", spool_key.as_str())
.body(axum::body::Body::from(request_body))
.expect("a request");
let status = app
.clone()
.oneshot(req)
.await
.expect("the router answers")
.status();
assert_eq!(status, HttpStatus::NOT_FOUND, "{method} {uri} is served");
}

assert_eq!(
page(&app, &spool_key, 0).await,
(read, after_read),
"the spool changed after the device's requests"
);
}

/// A spool nothing was sent to answers with no content.
#[tokio::test]
async fn an_empty_spool_answers_no_content() {
Expand Down
15 changes: 12 additions & 3 deletions specs/requirements/CONFORMANCE_GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1685,16 +1685,25 @@ Found, not changed here:

**Not driven by the tool.** A forged countersignature (σ_B) is judged by the sender's `decide_commit_ack`, which the tool does not drive; Core's `dsm::bilateral::offline::tests::an_ack_is_only_the_receivers_counter_signed_receipt` refuses it there.

### 6.46 The spool changes nothing when it is read (`test/storage-spool-reads-change-nothing`, 2026-09-30)

MR-STOR-0145 was Partial with no test. The spool's tests showed it keeps every envelope, deduplicates nothing and opens nothing. But each read a spool once, from position 0, so a node that marked, hid or removed what a device had read, or served from anywhere but the position asked, passed them all. Two tests on the spool's own router, on Postgres, now read a spool the way devices do. Each was observed red under a mutation that performs what the requirement forbids.

| Test | What it reads | Mutation control |
|---|---|---|
| `a_spool_reads_the_same_from_any_position_however_often_it_is_read` | 67 envelopes, more than one page: read whole in two pages, in the order sent, positions rising; the first page again, unchanged; from a middle position, exactly the rest; after the last, nothing | `spool_list_from_seq` deleting what it returns: red, the re-read is empty. The route passing position 0 for every read: red, the second page repeats the first. |
| `a_device_acknowledging_what_it_read_changes_nothing` | The acknowledge, status and unpositioned-read requests #976 removed, then the spool again | The acknowledge route served again: red. The delete-on-read mutation: red. |

## 7 Totals

| Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred |
|---|---|---|---|---|---|---|---|
| DSM high-level (MR-DSM) | 272 | 76 | 110 | 39 | 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 | 44 | 34 | 61 | 0 | 18 | 1 |
| Storage node (MR-STOR) | 158 | 45 | 33 | 61 | 0 | 18 | 1 |
| Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 |
| **All** | **918** | **344** | **229** | **119** | **8** | **64** | **154** |
| **All** | **918** | **345** | **228** | **119** | **8** | **64** | **154** |

## 8 Per-requirement results

Expand Down Expand Up @@ -2478,7 +2487,7 @@ The deferral also covers MR-DSM-0198 and MR-DSM-0221–0237 (§6.1), and the dBT
| MR-STOR-0142 | Met | `dsm_storage_node::db::pg::mirror_put` (ON CONFLICT DO NOTHING) | `dsm_storage_node::bytecommit_chain::a_rewritten_cycle_is_kept_beside_the_first` | Confirmed. |
| MR-STOR-0143 | Not code | — | — | Proof obligation. G15: the existing finality tests and formal model prove the superseded copy rule and must be redone for route chains (ChatGPT CG-13). |
| MR-STOR-0144 | Not code | — | — | Proof obligation. G15: the existing finality tests and formal model prove the superseded copy rule and must be redone for route chains (ChatGPT CG-13). |
| MR-STOR-0145 | Partial | dsm_storage_node · api/transport/b0x.rs; db · `spool_list_from_seq` | — | Added 2026-09-23. Ack, status, expiry and the unpositioned read removed; not yet compiled. |
| MR-STOR-0145 | Met | `dsm_storage_node::api::transport::b0x::router`; `dsm_storage_node::db::pg::spool_list_from_seq`; `dsm_storage_node::db::pg::spool_insert` | `dsm_storage_node::api::transport::b0x::tests::a_spool_reads_the_same_from_any_position_however_often_it_is_read`; `dsm_storage_node::api::transport::b0x::tests::a_device_acknowledging_what_it_read_changes_nothing`; `dsm_storage_node::api::transport::b0x::tests::an_envelope_reusing_a_message_id_is_kept_after_the_first` | Added 2026-09-23; exercised 2026-09-30 (§6.46). The spool is read only from a position, reading changes nothing, and the acknowledge, status and unpositioned-read routes #976 removed are not served. Nothing expires: a spool row holds no time, and nothing deletes one. |
| MR-STOR-0146 | Met | `dsm_sdk::sdk::b0x_sdk::seal_for`; `dsm::crypto::spool_seal::seal` | `dsm_sdk::handlers::node_e2e_tests::a_transfer_reaches_the_nodes_only_sealed_and_arrives`; `dsm::crypto::spool_seal::tests::a_sealed_payload_opens_to_its_bytes`; `dsm::crypto::spool_seal::tests::it_opens_under_nothing_else` | Added 2026-09-23; traced 2026-09-29 (§6.39). Every SDK spool submission goes through `B0xSDK::deliver` with bytes `seal_for` made, and a node's spool holds only what its submit route receives. The node checks nothing (Amendment A3): the property is the sending device's. |
| MR-STOR-0147 | Deferred | — | — | Added 2026-09-23. Continuing storage payment; outside beta. |
| MR-STOR-0148 | Partial | `dsm_sdk::sdk::route_seats::write_recorded` | — | The writer starts at the leader and carries its arrival record forward, but no test shows that nothing reaches a later seat before the leader answers. |
Expand Down
1 change: 1 addition & 0 deletions specs/requirements/VERIFICATION_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,5 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the
| 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-STOR-0145: a spool is served only from a position, and reading it marks, hides, expires or removes nothing | `dsm_storage_node` · api/transport/b0x.rs · `router` (submit, and retrieve from a position only); db/pg.rs · `spool_list_from_seq` (a read only) | `dsm_storage_node::api::transport::b0x::tests::a_spool_reads_the_same_from_any_position_however_often_it_is_read`; `dsm_storage_node::api::transport::b0x::tests::a_device_acknowledging_what_it_read_changes_nothing` | Delete on read → both red; every read served from position 0 → the first red; the acknowledge route served again → the second red (2026-09-30). | — |
| 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). | — |
Loading