Skip to content

Commit 3102adf

Browse files
committed
feat(storage): verify snapshot suffix recovery
Replay post-snapshot history through an independent recovered ledger and require its final state and root to match authoritative full-genesis replay. Cover genesis, behind-head, and head snapshots across restart.\n\nRefs #11
1 parent 63ff68f commit 3102adf

4 files changed

Lines changed: 86 additions & 17 deletions

File tree

‎docs/architecture/sqlite-ledger.md‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,12 @@ verifies that the durable metadata head equals the owned ledger, independently
3535
decodes the candidate bytes, and atomically replaces the one retained snapshot.
3636
It returns the exact durably stored payload. Opening independently decodes that
3737
snapshot and compares its complete state and root at the same height reached by
38-
authoritative full-genesis replay. A retained snapshot may precede the current
39-
head after later blocks; suffix replay from that snapshot remains a separate
40-
recovery path.
38+
authoritative full-genesis replay. It then starts a second ledger from that
39+
independently restored snapshot, re-queries and replays only later block and
40+
journal rows, compares every suffix transaction ID, receipt, root, header, and
41+
block ID, and requires the recovered state and root to equal authoritative full
42+
replay. A snapshot at the current head exercises the same path with an empty
43+
suffix.
4144

4245
The public header exposes no SQLite handle or SQL type. `SQLiteLedger` is
4346
move-constructible but not copyable or assignable. It owns the live
@@ -131,7 +134,8 @@ before the completed adapter is returned.
131134

132135
The ordinary durable commit, full-genesis-replay path, canonical snapshot
133136
codec, atomic latest-snapshot persistence, and independent snapshot validation
134-
at its recorded replay height are implemented. Snapshot-plus-suffix recovery,
135-
portable export/import, automatic reopen after an ambiguous commit result,
136-
fault injection around every commit phase, long seeded restart sequences, and
137-
final issue closure remain.
137+
at its recorded replay height are implemented. Independent snapshot-plus-suffix
138+
recovery reaches the identical authoritative head. Portable export/import,
139+
automatic reopen after an ambiguous commit result, fault injection around
140+
every commit phase, long seeded restart sequences, and final issue closure
141+
remain.

‎docs/project/current-state.md‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Current state
22

3-
Last updated: 2026-07-24
3+
Last updated: 2026-07-25
44

55
## Phase
66

@@ -249,18 +249,28 @@ the active roadmap slice.
249249
parameter, ordering, conservation and root failures; replaces a height-zero
250250
snapshot at height two; reopens across an older snapshot; and rejects corrupt
251251
row projections or multiple retained snapshots.
252-
- The Clang sanitizer preset now includes a fourth bounded libFuzzer smoke
253-
target for raw and structured snapshot bytes with a valid seed. Its
254-
completion evidence is pending the exact-commit GitHub matrix.
252+
- The Clang sanitizer preset includes a fourth bounded libFuzzer smoke target
253+
for raw and structured snapshot bytes with a valid seed. PR #15 merged the
254+
snapshot slice as `63ff68f`; exact-candidate Actions run 30163985474 and
255+
post-merge `main` run 30164137810 both passed GCC and Clang debug plus
256+
ASan/UBSan. The first three jobs passed 17/17 tests and Clang ASan/UBSan
257+
passed 21/21 including all four fuzz targets.
258+
- Opening now starts a second recovery ledger from the independently decoded
259+
latest snapshot, re-queries and replays only its later block and journal
260+
rows, compares every suffix transaction ID, receipt, root, header, and block
261+
ID, and requires the recovered state and root to equal authoritative full
262+
genesis replay. Restart coverage exercises genesis, behind-head, and
263+
current-head snapshots, including nonempty and empty suffixes. Focused GCC
264+
debug verification passes this path together with both existing SQLite test
265+
targets, 3/3.
255266

256267
## Exact next action
257268

258269
Continue issue #11:
259270

260-
> Start recovery from the latest independently verified snapshot, replay only
261-
> its retained journal suffix, compare every suffix output and the final head
262-
> with authoritative full-genesis replay, and add restart coverage for
263-
> snapshots before and at the current head.
271+
> Implement the canonical version-one portable archive codec and serialized
272+
> export from one verified database head, with overflow-safe framing, digest
273+
> verification, and exact history-plus-snapshot projections before import.
264274
265275
## Open autonomous decisions
266276

‎src/storage/sqlite_history_replay_v1.cpp‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,39 @@ void replay_block_row(
206206
resulting_root, header, block_id);
207207
}
208208

209+
void require_same_head(
210+
const pv1::Ledger& recovered,
211+
const pv1::Ledger& authoritative) {
212+
const auto recovered_root = recovered.current_state_root();
213+
const auto authoritative_root =
214+
authoritative.current_state_root();
215+
if (recovered.state() != authoritative.state() ||
216+
!std::holds_alternative<pv1::StateRoot>(recovered_root) ||
217+
!std::holds_alternative<pv1::StateRoot>(authoritative_root) ||
218+
std::get<pv1::StateRoot>(recovered_root) !=
219+
std::get<pv1::StateRoot>(authoritative_root)) {
220+
fail(SQLiteLedgerError::state_mismatch);
221+
}
222+
}
223+
224+
void replay_snapshot_suffix(
225+
Connection& connection,
226+
const DecodedSnapshotV1& snapshot,
227+
const pv1::Ledger& authoritative) {
228+
pv1::Ledger recovered(snapshot.ledger);
229+
Statement blocks = connection.prepare(
230+
"SELECT height, previous_state_root, transaction_root, "
231+
"resulting_state_root, admitted_count, header, block_id "
232+
"FROM blocks WHERE height>? ORDER BY height");
233+
const auto snapshot_height =
234+
encode_u64(snapshot.ledger.state().height);
235+
blocks.bind_blob(1, snapshot_height);
236+
while (checked_step(blocks) == SQLITE_ROW) {
237+
replay_block_row(connection, blocks, recovered);
238+
}
239+
require_same_head(recovered, authoritative);
240+
}
241+
209242
} // namespace
210243

211244
pv1::Ledger replay_history_v1(
@@ -228,6 +261,9 @@ pv1::Ledger replay_history_v1(
228261
if (snapshot && !snapshot_matched) {
229262
fail(SQLiteLedgerError::state_mismatch);
230263
}
264+
if (snapshot) {
265+
replay_snapshot_suffix(connection, *snapshot, ledger);
266+
}
231267
return ledger;
232268
}
233269

‎tests/storage/snapshot_v1_test.cpp‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,9 +384,20 @@ void verify_persistence(
384384
{
385385
auto reopened = take_sqlite_ledger(
386386
ps::open_sqlite_ledger(files.path(), genesis),
387-
"older snapshot plus full replay rejected");
387+
"genesis snapshot suffix replay rejected");
388388
pv::require(reopened.read_head() == ledger_head(expected),
389-
"older snapshot changed replay head");
389+
"genesis snapshot suffix changed replay head");
390+
const auto height_one_snapshot = take_snapshot(
391+
reopened.create_snapshot(),
392+
"height-one snapshot persistence failed");
393+
const auto expected_height_one_snapshot = take_encoded(
394+
ps::encode_snapshot_v1(expected),
395+
"expected height-one snapshot failed");
396+
pv::require(
397+
height_one_snapshot ==
398+
expected_height_one_snapshot.payload,
399+
"height-one snapshot bytes changed");
400+
390401
const std::vector<p::Bytes> empty;
391402
auto expected_block = expected.apply_block(2, empty);
392403
pv::require(
@@ -396,6 +407,14 @@ void verify_persistence(
396407
pv::require(
397408
std::holds_alternative<p::BlockCommit>(actual_block),
398409
"stored empty block rejected");
410+
}
411+
412+
{
413+
auto reopened = take_sqlite_ledger(
414+
ps::open_sqlite_ledger(files.path(), genesis),
415+
"height-one snapshot suffix replay rejected");
416+
pv::require(reopened.read_head() == ledger_head(expected),
417+
"height-one snapshot suffix changed replay head");
399418
const auto payload = take_snapshot(
400419
reopened.create_snapshot(), "head snapshot replacement failed");
401420
const auto expected_snapshot = take_encoded(

0 commit comments

Comments
 (0)