Skip to main content

sqlite_forensic/
lib.rs

1//! `sqlite-forensic` — Tier-2 anomaly auditor over [`sqlite_core`].
2//!
3//! WS-C skeleton. The reader (`sqlite-core`) answers "what does this database
4//! header show?"; this crate grades the forensically-notable observations into
5//! severity-ranked [`forensicnomicon::report::Finding`]s, so a `SQLite`
6//! evidence database's anomalies aggregate uniformly with the partition /
7//! container / filesystem layers.
8//!
9//! Each anomaly is an *observation* ("consistent with …"); the examiner draws
10//! the conclusions.
11//!
12//! # Capabilities
13//!
14//! - [`carve_deleted_records`] — recover deleted rows from free (unallocated)
15//!   pages, the headline capability rusqlite structurally cannot provide. Each
16//!   recovered row is confidence-graded, flagged `allocated: false`, and carries
17//!   page/offset/rowid provenance.
18//! - [`audit`] grades header reserved-space, a non-empty freelist (prior
19//!   deletions), an active WAL overlay (uncheckpointed state), and a header/file
20//!   page-count mismatch into severity-ranked
21//!   [`forensicnomicon::report::Finding`]s.
22//! - [`audit_journal`] grades the rollback-`-journal` design-§6 observations
23//!   (hot journal, recoverable PERSIST pre-images, checksum mismatch, journaled
24//!   schema page, duplicate page record, db-size delta) — additive to [`audit`].
25//!
26//! Deferred: a full anomaly suite (overflow-chain integrity, schema-format /
27//! text-encoding checks) and a fuzz harness.
28
29#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
30
31use forensicnomicon::report::{
32    Confidence, Evidence, Finding, Location, Observation, Severity, Source,
33};
34use sqlite_core::{CommitId, Database, JournalHeader, RollbackJournal, Value, WalTimeline};
35
36/// The classified `SQLite` forensic anomalies this auditor can grade.
37///
38/// `#[non_exhaustive]` so WS-E can add carving / WAL / freelist variants without
39/// a breaking change; downstream `match` arms must carry a `_` arm.
40#[derive(Debug, Clone, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum AnomalyKind {
43    /// The header's reserved-space-per-page field is non-zero. Standard
44    /// `SQLite` leaves this at 0; a non-zero value is used by page-level
45    /// extensions (e.g. encryption such as SQLCipher/SEE, or checksum VFS) and
46    /// is worth flagging on an evidence database.
47    NonZeroReservedSpace {
48        /// The reserved bytes per page reported by the header.
49        reserved: u8,
50    },
51    /// A record-shaped cell was recovered from unallocated / free space —
52    /// consistent with a deleted row that has not yet been overwritten.
53    DeletedRecordRecovered {
54        /// 1-based page the residue was carved from.
55        page: u32,
56        /// Byte offset of the cell within that page.
57        offset: usize,
58        /// Recovered rowid.
59        rowid: i64,
60    },
61    /// A `sqlite_master` row was recovered from page-1 free space whose
62    /// definition is absent from the live schema — consistent with a **dropped
63    /// (or replaced)** table/index/view/trigger whose `CREATE` statement and
64    /// existence survive the drop under `secure_delete=OFF`.
65    DroppedSchemaRecovered {
66        /// `sqlite_master.type` — `table`, `index`, `view`, or `trigger`.
67        object_type: String,
68        /// Name of the dropped object.
69        name: String,
70    },
71    /// The freelist is non-empty: the database holds free (unallocated) pages.
72    /// Consistent with prior deletions (`DELETE` without `VACUUM`); those pages
73    /// may retain recoverable deleted records.
74    NonEmptyFreelist {
75        /// Number of free pages on the freelist.
76        free_pages: u32,
77    },
78    /// A `-wal` sidecar carried committed-but-unflushed page versions that the
79    /// main database file does not yet reflect. Consistent with an evidence
80    /// database captured while a write transaction was checkpoint-pending; the
81    /// main file alone would under-report the true state.
82    WalUncheckpointedState {
83        /// Number of pages the WAL overlay superseded in the main file.
84        overlaid_pages: u32,
85    },
86    /// The in-header page count disagrees with the page count implied by the
87    /// file length. Consistent with truncation, carving, or out-of-band
88    /// modification of the database file.
89    PageCountMismatch {
90        /// Page count recorded in the file header (offset 28).
91        header_pages: u32,
92        /// Page count implied by `file_len / page_size`.
93        file_pages: u32,
94    },
95    /// A `-journal` with an intact header (valid magic) sits beside the database
96    /// — a *hot journal*. Consistent with an interrupted or in-progress write
97    /// transaction (crash, power loss, process kill, or acquisition captured
98    /// mid-write); `SQLite` would roll it back on next open, so the main db may
99    /// require rollback. The journal holds the pre-interruption state. (Design §6
100    /// item 1 — state the observation; never assert "anti-forensic".)
101    HotJournal {
102        /// Database page count recorded at transaction start (`dbOrigSize`).
103        mx_page: u32,
104        /// Number of pre-transaction page images the journal carries — the scope
105        /// of the interrupted transaction.
106        journaled_pages: u32,
107    },
108    /// A committed PERSIST `-journal` (header zeroed, bodies intact) carries
109    /// recoverable pre-images. Consistent with a normally-committed transaction
110    /// whose deleted/modified rows remain recoverable (design §6 item 2).
111    JournalRecoverable {
112        /// Number of pre-transaction page images the journal carries.
113        images: usize,
114    },
115    /// One or more journal page records failed the `pager.c` checksum (Tier A
116    /// only — Tier B has no nonce to verify against). Consistent with corruption,
117    /// a torn page (power-loss mid-sector), or post-write modification of the
118    /// journal (design §6 item 3).
119    JournalChecksumMismatch {
120        /// The page numbers whose stored checksum did not match, ascending.
121        pgnos: Vec<u32>,
122        /// Total page records the journal carries (the denominator).
123        total: usize,
124    },
125    /// The journal's prior **page-1 image** carries a different schema cookie
126    /// (file-header offset 40, 4-byte BE) than the live database. The cookie
127    /// advances only on `CREATE`/`DROP`/`ALTER`, so a difference is consistent
128    /// with a DDL change in the last transaction; the prior schema is recoverable
129    /// (design §6 item 6). Page 1 alone is NOT sufficient — it is journaled on
130    /// nearly every write (the change-counter / freelist-count / db-size header
131    /// fields update routinely), so the cookie comparison, not page-1 presence,
132    /// is the DDL signal.
133    JournalSchemaChange {
134        /// Schema cookie in the journal's prior page-1 image (offset 40, BE).
135        journal_cookie: u32,
136        /// Schema cookie in the live database's page 1 (offset 40, BE).
137        db_cookie: u32,
138    },
139    /// A `pgno` appeared more than once across the journal's page records. The
140    /// spec journals a page at most once, so a repeat is consistent with
141    /// corruption, a savepoint/super-journal artifact, or tampering (design §6
142    /// item 9).
143    JournalDuplicatePage {
144        /// The page numbers that repeated (each once, first-seen order) — the
145        /// offending values, so the finding can name them.
146        pgnos: Vec<u32>,
147    },
148    /// The database page count recorded at transaction start (`mxPage`, Tier A
149    /// only) differs from the current page count: the last transaction changed
150    /// the db size. `mxPage < current` ⇒ growth (INSERTs); `mxPage > current` ⇒
151    /// shrink, consistent with auto-vacuum/incremental-vacuum or truncation — NOT
152    /// an ordinary `DELETE` (design §6 item 5).
153    JournalDbSizeDelta {
154        /// Database page count at transaction start (`mxPage`, journal header).
155        mx_page: u32,
156        /// Current database page count.
157        current_pages: u32,
158    },
159}
160
161impl AnomalyKind {
162    /// Severity, derived from the kind.
163    #[must_use]
164    pub fn severity(&self) -> Severity {
165        match self {
166            AnomalyKind::NonZeroReservedSpace { .. } | AnomalyKind::NonEmptyFreelist { .. } => {
167                Severity::Low
168            }
169            AnomalyKind::DeletedRecordRecovered { .. }
170            | AnomalyKind::DroppedSchemaRecovered { .. }
171            | AnomalyKind::WalUncheckpointedState { .. }
172            | AnomalyKind::JournalRecoverable { .. }
173            | AnomalyKind::JournalSchemaChange { .. }
174            | AnomalyKind::JournalDuplicatePage { .. } => Severity::Medium,
175            AnomalyKind::PageCountMismatch { .. }
176            | AnomalyKind::HotJournal { .. }
177            | AnomalyKind::JournalChecksumMismatch { .. } => Severity::High,
178            AnomalyKind::JournalDbSizeDelta { .. } => Severity::Low,
179        }
180    }
181
182    /// Stable, scheme-prefixed machine code (a published contract).
183    #[must_use]
184    pub fn code(&self) -> &'static str {
185        match self {
186            AnomalyKind::NonZeroReservedSpace { .. } => "SQLITE-RESERVED-SPACE-NONZERO",
187            AnomalyKind::DeletedRecordRecovered { .. } => "SQLITE-DELETED-RECORD-RECOVERED",
188            AnomalyKind::DroppedSchemaRecovered { .. } => "SQLITE-DROPPED-SCHEMA-RECOVERED",
189            AnomalyKind::NonEmptyFreelist { .. } => "SQLITE-FREELIST-NONEMPTY",
190            AnomalyKind::WalUncheckpointedState { .. } => "SQLITE-WAL-UNCHECKPOINTED",
191            AnomalyKind::PageCountMismatch { .. } => "SQLITE-PAGECOUNT-MISMATCH",
192            AnomalyKind::HotJournal { .. } => "SQLITE-JOURNAL-HOT",
193            AnomalyKind::JournalRecoverable { .. } => "SQLITE-JOURNAL-RECOVERABLE",
194            AnomalyKind::JournalChecksumMismatch { .. } => "SQLITE-JOURNAL-CHECKSUM-MISMATCH",
195            AnomalyKind::JournalSchemaChange { .. } => "SQLITE-JOURNAL-SCHEMA-CHANGE",
196            AnomalyKind::JournalDuplicatePage { .. } => "SQLITE-JOURNAL-DUPLICATE-PAGE",
197            AnomalyKind::JournalDbSizeDelta { .. } => "SQLITE-JOURNAL-DBSIZE-DELTA",
198        }
199    }
200
201    /// Human-readable, "consistent with" note.
202    #[must_use]
203    pub fn note(&self) -> String {
204        match self {
205            AnomalyKind::NonZeroReservedSpace { reserved } => format!(
206                "file header reserves {reserved} byte(s) per page — non-standard; \
207                 consistent with a page-level extension such as encryption \
208                 (SQLCipher/SEE) or a checksum VFS"
209            ),
210            AnomalyKind::DeletedRecordRecovered {
211                page,
212                offset,
213                rowid,
214            } => format!(
215                "recovered a record-shaped cell (rowid {rowid}) from unallocated \
216                 space at page {page} offset {offset} — consistent with a deleted \
217                 row not yet overwritten"
218            ),
219            AnomalyKind::DroppedSchemaRecovered { object_type, name } => format!(
220                "recovered a deleted sqlite_master row for {object_type} \"{name}\" \
221                 from page-1 free space — consistent with a dropped (or replaced) \
222                 {object_type} whose definition survives the drop"
223            ),
224            AnomalyKind::NonEmptyFreelist { free_pages } => format!(
225                "{free_pages} free page(s) on the freelist — consistent with prior \
226                 deletions (DELETE without VACUUM); free pages may retain \
227                 recoverable deleted records"
228            ),
229            AnomalyKind::WalUncheckpointedState { overlaid_pages } => format!(
230                "the -wal sidecar carries {overlaid_pages} committed page version(s) \
231                 the main file does not reflect — consistent with capture while a \
232                 write transaction was checkpoint-pending; the main file alone \
233                 under-reports the true state; acquire the live -wal before the \
234                 application terminates, because a checkpoint (e.g. on its next clean \
235                 close) folds the WAL into the main file and discards the \
236                 uncheckpointed deleted/superseded residue, which the \
237                 post-checkpoint main file alone would no longer contain"
238            ),
239            AnomalyKind::PageCountMismatch {
240                header_pages,
241                file_pages,
242            } => format!(
243                "in-header page count ({header_pages}) disagrees with the file \
244                 length ({file_pages} pages) — consistent with truncation, \
245                 carving, or out-of-band modification"
246            ),
247            AnomalyKind::HotJournal { .. } => {
248                "a hot rollback journal (valid header magic) sits beside \
249                 the database — consistent with an interrupted or in-progress write transaction \
250                 (crash, power loss, process kill, or acquisition captured mid-write); SQLite \
251                 would roll it back on next open, so the main database may require rollback"
252                    .to_string()
253            }
254            AnomalyKind::JournalRecoverable { images } => format!(
255                "the rollback journal carries {images} pre-transaction page \
256                 image(s) (PERSIST post-commit) — consistent with a committed \
257                 transaction whose pre-images (deleted/modified rows) remain \
258                 recoverable"
259            ),
260            AnomalyKind::JournalChecksumMismatch { pgnos, total } => {
261                let list = pgnos
262                    .iter()
263                    .map(u32::to_string)
264                    .collect::<Vec<_>>()
265                    .join(", ");
266                format!(
267                    "{} of {total} journal page record(s) failed the page \
268                     checksum (page(s) {list}) — consistent with corruption, a \
269                     torn page write (power-loss mid-sector), or post-write \
270                     modification of the journal",
271                    pgnos.len()
272                )
273            }
274            AnomalyKind::JournalSchemaChange {
275                journal_cookie,
276                db_cookie,
277            } => format!(
278                "the journal's prior schema cookie ({journal_cookie}) differs from the \
279                 database's ({db_cookie}) — consistent with a DDL change (CREATE/DROP/ALTER) \
280                 in the last transaction; the prior schema is recoverable"
281            ),
282            AnomalyKind::JournalDuplicatePage { .. } => {
283                "a page number appears more than once across the \
284                 journal's page records — the spec journals a page at most once, so this is \
285                 consistent with corruption, a savepoint/super-journal artifact, or tampering"
286                    .to_string()
287            }
288            AnomalyKind::JournalDbSizeDelta {
289                mx_page,
290                current_pages,
291            } => {
292                let direction = if *mx_page < *current_pages {
293                    "the database grew (consistent with INSERTs adding pages)"
294                } else {
295                    "the database shrank (consistent with auto-vacuum / \
296                     incremental-vacuum or truncation, NOT an ordinary DELETE)"
297                };
298                format!(
299                    "the journal records a transaction-start page count \
300                     (mxPage = {mx_page}) that differs from the current page \
301                     count ({current_pages}) — the last transaction changed the \
302                     database size: {direction}"
303                )
304            }
305        }
306    }
307}
308
309/// A `SQLite` forensic anomaly: an observation graded by severity, with a stable
310/// code and note derived from its [`AnomalyKind`] so they cannot drift.
311#[derive(Debug, Clone, PartialEq)]
312pub struct Anomaly {
313    /// Severity, derived from `kind`.
314    pub severity: Severity,
315    /// Stable machine-readable code, derived from `kind`.
316    pub code: &'static str,
317    /// The classified anomaly.
318    pub kind: AnomalyKind,
319    /// Human-readable note, derived from `kind`.
320    pub note: String,
321    /// Heuristic confidence, present for inferential findings (e.g. a carved
322    /// deleted record); `None` for structurally-certain header observations.
323    pub confidence: Option<f32>,
324}
325
326impl Anomaly {
327    /// Build an [`Anomaly`], deriving severity/code/note from `kind`.
328    #[must_use]
329    pub fn new(kind: AnomalyKind) -> Self {
330        Anomaly {
331            severity: kind.severity(),
332            code: kind.code(),
333            note: kind.note(),
334            kind,
335            confidence: None,
336        }
337    }
338
339    /// Attach a heuristic confidence (used for carved deleted records).
340    #[must_use]
341    pub fn with_confidence(mut self, confidence: f32) -> Self {
342        self.confidence = Some(confidence);
343        self
344    }
345}
346
347impl Observation for Anomaly {
348    fn severity(&self) -> Option<Severity> {
349        Some(self.severity)
350    }
351    fn code(&self) -> &'static str {
352        self.code
353    }
354    fn note(&self) -> String {
355        self.note.clone()
356    }
357
358    fn confidence(&self) -> Option<Confidence> {
359        self.confidence.and_then(Confidence::new)
360    }
361
362    fn category(&self) -> forensicnomicon::report::Category {
363        use forensicnomicon::report::Category;
364        match &self.kind {
365            // Deleted-record residue and free (deallocated) pages are recoverability
366            // findings; the code keywords don't trip Category::from_code's Residue
367            // classifier, so classify them explicitly.
368            // Deleted-record residue and free pages are recoverability findings;
369            // so are a recoverable PERSIST journal and a journaled schema page
370            // (the prior schema/rows are recoverable from the journal images).
371            AnomalyKind::DeletedRecordRecovered { .. }
372            | AnomalyKind::DroppedSchemaRecovered { .. }
373            | AnomalyKind::NonEmptyFreelist { .. }
374            | AnomalyKind::JournalRecoverable { .. }
375            | AnomalyKind::JournalSchemaChange { .. } => Category::Residue,
376            // A WAL-only/uncheckpointed state, a header/file page-count mismatch,
377            // and the journal integrity observations (hot journal, checksum /
378            // duplicate corruption, db-size delta) are integrity-of-state.
379            AnomalyKind::WalUncheckpointedState { .. }
380            | AnomalyKind::PageCountMismatch { .. }
381            | AnomalyKind::HotJournal { .. }
382            | AnomalyKind::JournalChecksumMismatch { .. }
383            | AnomalyKind::JournalDuplicatePage { .. }
384            | AnomalyKind::JournalDbSizeDelta { .. } => Category::Integrity,
385            other => Category::from_code(other.code()),
386        }
387    }
388
389    fn evidence(&self) -> Vec<Evidence> {
390        match &self.kind {
391            AnomalyKind::DeletedRecordRecovered {
392                page,
393                offset,
394                rowid,
395            } => vec![
396                Evidence {
397                    field: "rowid".to_string(),
398                    value: rowid.to_string(),
399                    location: Some(Location::RecordId(u64::try_from(*rowid).unwrap_or(0))),
400                },
401                Evidence {
402                    field: "source_page".to_string(),
403                    value: page.to_string(),
404                    location: Some(Location::Other {
405                        space: "sqlite:page".to_string(),
406                        value: u64::from(*page),
407                    }),
408                },
409                Evidence {
410                    field: "cell_offset".to_string(),
411                    value: offset.to_string(),
412                    location: Some(Location::ByteOffset(*offset as u64)),
413                },
414            ],
415            AnomalyKind::DroppedSchemaRecovered { object_type, name } => vec![
416                Evidence {
417                    field: "object_type".to_string(),
418                    value: object_type.clone(),
419                    location: None,
420                },
421                Evidence {
422                    field: "name".to_string(),
423                    value: name.clone(),
424                    location: None,
425                },
426            ],
427            AnomalyKind::NonEmptyFreelist { free_pages } => vec![Evidence {
428                field: "free_pages".to_string(),
429                value: free_pages.to_string(),
430                location: None,
431            }],
432            AnomalyKind::WalUncheckpointedState { overlaid_pages } => vec![Evidence {
433                field: "overlaid_pages".to_string(),
434                value: overlaid_pages.to_string(),
435                location: None,
436            }],
437            AnomalyKind::PageCountMismatch {
438                header_pages,
439                file_pages,
440            } => vec![
441                Evidence {
442                    field: "header_pages".to_string(),
443                    value: header_pages.to_string(),
444                    location: Some(Location::Field("in_header_db_size".to_string())),
445                },
446                Evidence {
447                    field: "file_pages".to_string(),
448                    value: file_pages.to_string(),
449                    location: None,
450                },
451            ],
452            AnomalyKind::JournalRecoverable { images } => vec![Evidence {
453                field: "page_images".to_string(),
454                value: images.to_string(),
455                location: None,
456            }],
457            AnomalyKind::JournalChecksumMismatch { pgnos, total } => {
458                let mut ev: Vec<Evidence> = pgnos
459                    .iter()
460                    .map(|pgno| Evidence {
461                        field: "checksum_mismatch_pgno".to_string(),
462                        value: pgno.to_string(),
463                        location: Some(Location::Other {
464                            space: "sqlite:page".to_string(),
465                            value: u64::from(*pgno),
466                        }),
467                    })
468                    .collect();
469                ev.push(Evidence {
470                    field: "page_records".to_string(),
471                    value: total.to_string(),
472                    location: None,
473                });
474                ev
475            }
476            AnomalyKind::JournalDbSizeDelta {
477                mx_page,
478                current_pages,
479            } => vec![
480                Evidence {
481                    field: "mx_page".to_string(),
482                    value: mx_page.to_string(),
483                    location: None,
484                },
485                Evidence {
486                    field: "current_pages".to_string(),
487                    value: current_pages.to_string(),
488                    location: None,
489                },
490            ],
491            AnomalyKind::JournalSchemaChange {
492                journal_cookie,
493                db_cookie,
494            } => vec![
495                Evidence {
496                    field: "journal_schema_cookie".to_string(),
497                    value: journal_cookie.to_string(),
498                    location: None,
499                },
500                Evidence {
501                    field: "db_schema_cookie".to_string(),
502                    value: db_cookie.to_string(),
503                    location: None,
504                },
505            ],
506            AnomalyKind::NonZeroReservedSpace { reserved } => vec![Evidence {
507                field: "reserved_bytes_per_page".to_string(),
508                value: reserved.to_string(),
509                // The reserved-space-per-page byte lives at file-header offset 20.
510                location: Some(Location::ByteOffset(20)),
511            }],
512            AnomalyKind::JournalDuplicatePage { pgnos } => pgnos
513                .iter()
514                .map(|pgno| Evidence {
515                    field: "duplicate_pgno".to_string(),
516                    value: pgno.to_string(),
517                    location: Some(Location::Other {
518                        space: "sqlite:page".to_string(),
519                        value: u64::from(*pgno),
520                    }),
521                })
522                .collect(),
523            AnomalyKind::HotJournal {
524                mx_page,
525                journaled_pages,
526            } => vec![
527                Evidence {
528                    field: "db_page_count_at_txn_start".to_string(),
529                    value: mx_page.to_string(),
530                    location: None,
531                },
532                Evidence {
533                    field: "journaled_pages".to_string(),
534                    value: journaled_pages.to_string(),
535                    location: None,
536                },
537            ],
538        }
539    }
540}
541
542/// Which class of free space a deleted record was carved from. Records the
543/// recovery provenance so the examiner can weigh reliability by class.
544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
545#[non_exhaustive]
546pub enum RecoverySource {
547    /// A whole page that was freed onto the freelist (the strongest case: the
548    /// page holds only deallocated content).
549    FreelistPage,
550    /// The in-page free space (unallocated gap / freeblock slack) of a page that
551    /// is still allocated. The record is genuinely deleted but more likely to be
552    /// partially overwritten, so it is graded lower.
553    InPageFreeBlock,
554    /// A page whose table was `DROP`ped — on the freelist with no `sqlite_master`
555    /// schema, so the column count was inferred from the record itself.
556    DroppedTable,
557    /// A **prior version** of a still-live row: an `UPDATE` freed the old version
558    /// of the row into slack while the new version kept the same rowid. The
559    /// recovered values DIFFER from the current live row (e.g. an edited message
560    /// or a changed amount), so it is genuine deleted content — the edit history.
561    PriorVersion,
562    /// A record rebuilt by **freeblock reconstruction**: the freed cell's first
563    /// four bytes (payload-length + rowid varints, `header_len`, and the leading
564    /// serial type) were overwritten by SQLite's freeblock header, so the record
565    /// was rebuilt from its surviving serial-type tail plus a schema template. The
566    /// rowid is destroyed (surfaced as `0`), so this is the weakest in-page class
567    /// — a low-confidence "consistent with a deleted row" lead.
568    FreeblockReconstructed,
569    /// Residue carved from an **uncheckpointed WAL frame's page image** rather
570    /// than the on-disk pages. A `-wal` frame holds a committed page version the
571    /// main file does not yet reflect; deleted cells freed within that version
572    /// survive in the frame's slack and exist NOWHERE on disk. The record carries
573    /// the `(salt1, salt2, frame_index)` log-sequence provenance in
574    /// [`CarvedRecord::wal`].
575    WalFrame,
576    /// Residue carved from a **materialized commit snapshot** — the database state
577    /// replayed up to one COMMIT frame of the `-wal` (base image ∪ committed frames
578    /// to that commit). A row that is a live cell at this commit but deleted by a
579    /// later commit survives ONLY in this snapshot's page images. The record
580    /// carries the commit's `(salt1, salt2, commit_frame_index)` LSN in
581    /// [`CarvedRecord::wal`] — the per-commit temporal coordinate, distinct from a
582    /// raw [`RecoverySource::WalFrame`] residue's frame index.
583    CommitSnapshot,
584    /// A **prior allocated row from the rollback journal** (design §5). The row
585    /// was LIVE in the pre-transaction state the `-journal` preserves and the last
586    /// transaction deleted or modified it — it is NOT a free-space residue, so
587    /// downstream reports must not relabel it as unallocated. Carries the page /
588    /// segment / header-state / checksum provenance in [`RollbackJournalSource`].
589    RollbackJournal(RollbackJournalSource),
590}
591
592/// Whether a rollback-journal header was parsed intact (Tier A) or reconstructed
593/// from a zeroed (PERSIST post-commit) header (Tier B). A reconstructed header
594/// could not verify page checksums (the nonce was zeroed), so its recoveries
595/// rest on cross-record structural consistency — a graded distinction the
596/// examiner weighs.
597#[derive(Debug, Clone, Copy, PartialEq, Eq)]
598pub enum JournalHeaderState {
599    /// Tier A: header magic present; the checksum nonce was known and verified.
600    Valid,
601    /// Tier B: header zeroed (PERSIST post-commit); parameters reconstructed,
602    /// checksums unverifiable.
603    ReconstructedZeroed,
604}
605
606/// Provenance for a record recovered from a rollback journal (design §5):
607/// where in the journal its page image lived and how trustworthy the parse was.
608/// `Copy` (all fields are scalar) so it threads through the existing
609/// attribution → output pipeline exactly like [`WalProvenance`]; the journal's
610/// path is supplied once at the API boundary, not duplicated per row.
611#[derive(Debug, Clone, Copy, PartialEq, Eq)]
612pub struct RollbackJournalSource {
613    /// 0-based journal segment the page image came from.
614    pub segment: usize,
615    /// 1-based database page number the prior image restored.
616    pub pgno: u32,
617    /// Whether the header was intact (Tier A) or reconstructed (Tier B).
618    pub header_state: JournalHeaderState,
619    /// `Some(true/false)` when the page checksum could be verified (Tier A);
620    /// `None` in Tier B (nonce zeroed).
621    pub checksum_valid: Option<bool>,
622}
623
624/// Provenance for a record carved from a `-wal` frame: the
625/// `(salt1, salt2, frame_index)` log-sequence identity of the frame it came from
626/// (the LSN task #55 will formalize).
627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
628pub struct WalProvenance {
629    /// 0-based position of the source frame within the `-wal` file.
630    pub frame_index: usize,
631    /// WAL header salt-1 (checkpoint generation) of the source frame.
632    pub salt1: u32,
633    /// WAL header salt-2 (checkpoint generation) of the source frame.
634    pub salt2: u32,
635}
636
637/// Provenance for a record reassembled across an **overflow-page chain** (task
638/// #73): the pages whose bytes were concatenated to recover the row. An examiner
639/// citing the row as evidence can name exactly where its bytes came from. Present
640/// only on chain-reassembled rows; `None` for every contiguous recovery.
641#[derive(Debug, Clone, PartialEq, Eq)]
642pub struct OverflowProvenance {
643    /// First overflow page of the chain (the page the local-prefix pointer named).
644    pub first_page: u32,
645    /// Ordered overflow pages whose content was assembled into the payload.
646    pub chain: Vec<u32>,
647}
648
649/// A deleted record recovered from unallocated space — the headline capability
650/// rusqlite cannot provide. Carries the decoded row plus provenance so the
651/// examiner can weigh it as a "consistent with a deleted row" observation.
652#[derive(Debug, Clone, PartialEq)]
653pub struct CarvedRecord {
654    /// 1-based page the record was carved from.
655    pub page: u32,
656    /// Byte offset of the cell within that page.
657    pub offset: usize,
658    /// Recovered rowid.
659    pub rowid: i64,
660    /// Decoded column values, in column order.
661    pub values: Vec<Value>,
662    /// Heuristic confidence in `(0.0, 1.0]` that these bytes are a real record.
663    pub confidence: f32,
664    /// Always `false`: a carved record lives in unallocated space, never in the
665    /// live b-tree. Present so callers cannot mistake it for an allocated row.
666    pub allocated: bool,
667    /// Which class of free space this record was recovered from.
668    pub source: RecoverySource,
669    /// WAL log-sequence provenance, present **only** for
670    /// [`RecoverySource::WalFrame`] records (the frame the residue was carved
671    /// from); `None` for every on-disk class.
672    pub wal: Option<WalProvenance>,
673    /// Overflow-chain provenance, present **only** for rows reassembled across a
674    /// freed overflow-page chain (task #73); `None` for every contiguous recovery.
675    pub overflow: Option<OverflowProvenance>,
676}
677
678/// A **Tier-2 partial recovery**: a freed cell whose full row could not be
679/// reconstructed but at least one *distinctive* cell (TEXT ≥ 4 bytes of valid
680/// UTF-8, or REAL) survived at a structural anchor.
681///
682/// Deliberately NOT a [`CarvedRecord`]: it has no rowid (clobbered) and an
683/// incomplete column set, and it does **not** share the full-row
684/// 0-false-positive guarantee — it is a lead-generation surface with an expected
685/// non-zero false-positive rate. The type system keeps it out of the full-row
686/// output so a fragment can never be silently rendered as a recovered row
687/// (secure by design). Returned only by [`carve_with_fragments`] in the opt-in
688/// [`CarveTiers::fragments`] bucket. A fragment is "consistent with a partial
689/// deleted row" — the examiner draws the conclusion.
690#[derive(Debug, Clone, PartialEq)]
691pub struct CarvedFragment {
692    /// 1-based page the fragment was salvaged from.
693    pub page: u32,
694    /// Byte offset of the failed cell's anchor within that page.
695    pub offset: usize,
696    /// `(column_index, value)` for each column that decoded cleanly, ascending by
697    /// index — meaningful against the table's column order.
698    pub surviving: Vec<(usize, Value)>,
699    /// Number of the row's columns that did NOT decode.
700    pub missing: usize,
701    /// Always the flat Tier-2 fragment confidence (0.2) for now — strictly below
702    /// every full-row class.
703    pub confidence: f32,
704    /// Which class of free space the fragment was salvaged from
705    /// ([`RecoverySource::FreeblockReconstructed`] for chain-pass fragments,
706    /// [`RecoverySource::InPageFreeBlock`] for gap-pass fragments).
707    pub source: RecoverySource,
708    /// WAL log-sequence provenance. `None` in v1 (no WAL fragment pass yet).
709    pub wal: Option<WalProvenance>,
710}
711
712/// The two strictly-separated recovery tiers returned by [`carve_with_fragments`].
713///
714/// `full` is **byte-identical** to [`carve_all_deleted_records`] and keeps its
715/// structural 0-false-positive guarantee — the zero-config, high-precision
716/// output. `fragments` is the opt-in Tier-2 lead surface (see [`CarvedFragment`]).
717#[derive(Debug, Clone, PartialEq)]
718pub struct CarveTiers {
719    /// Tier-1 full rows — identical to [`carve_all_deleted_records`].
720    pub full: Vec<CarvedRecord>,
721    /// Tier-2 partial recoveries (opt-in; expected non-zero false-positive rate).
722    pub fragments: Vec<CarvedFragment>,
723}
724
725/// Recover deleted records by carving the database's free (unallocated) pages.
726///
727/// Each free page from [`Database::freelist_pages`] is scanned with
728/// [`Database::carve_cells`] for record-shaped cells of `column_count` columns.
729/// Free pages hold only deallocated content, so the recovered rows are deleted
730/// ones — the carver never re-surfaces a live (allocated) row. Recovered rows
731/// are **confidence-graded observations**, not certainties: a carved record is
732/// "consistent with a deleted row", and the examiner draws the conclusion.
733///
734/// Read-only and panic-free: a malformed freelist simply yields fewer (or no)
735/// carved records rather than an error.
736#[must_use]
737pub fn carve_deleted_records(db: &Database, column_count: usize) -> Vec<CarvedRecord> {
738    let mut out = Vec::new();
739    let Ok(free) = db.freelist_pages() else {
740        return out;
741    };
742    for page in free {
743        let Some(page_bytes) = db.raw_page(page) else {
744            continue; // cov:unreachable: freelist_pages only yields in-range pages
745        };
746        for cell in db.carve_cells(page_bytes, column_count) {
747            out.push(CarvedRecord {
748                page,
749                offset: cell.offset,
750                rowid: cell.rowid,
751                values: cell.values,
752                confidence: cell.confidence,
753                allocated: false,
754                source: RecoverySource::FreelistPage,
755                wal: None,
756                overflow: None,
757            });
758        }
759    }
760    out
761}
762
763/// Every currently-live row's decoded values, across ALL tables, UNCOLLAPSED.
764///
765/// [`Database::live_rows`] keys live-row identity by a global rowid, so two tables
766/// that share a rowid collapse to a single entry — a live row can then vanish from
767/// the exclusion guard and be re-surfaced as deleted (roadmap §1.1). This walks
768/// `live_table_rows()` (every row, per table) so the live-row guard is complete
769/// even under cross-table rowid collisions. Best-effort and panic-free.
770fn live_value_tuples(db: &Database) -> Vec<Vec<Value>> {
771    db.live_table_rows()
772        .into_iter()
773        .flat_map(|t| t.rows.into_iter().map(|r| r.values))
774        .collect()
775}
776
777/// Recover deleted records across **every** free-space class — the full-coverage
778/// carver. Drives, in order:
779///
780/// 1. **Freelist pages** (whole pages freed onto the freelist), with the column
781///    count inferred per record so it also recovers **dropped-table** pages whose
782///    `sqlite_master` schema is gone (e.g. a `DROP TABLE` left the page on the
783///    freelist with no recorded column count).
784/// 2. **In-page free space** of still-allocated table-leaf pages (the unallocated
785///    gap and inter-cell slack), via [`Database::carve_free_regions`], which
786///    carves only the complement of the live cells — so a live (allocated) row is
787///    **never** re-surfaced (the 0-false-positive guarantee, enforced
788///    structurally).
789///
790/// Records are de-duplicated by `(rowid, values)` keeping the highest-confidence
791/// copy, since a row can survive in more than one place. Every record is graded:
792/// freelist-page recovery highest, dropped-table next, in-page residue lowest.
793///
794/// Read-only and panic-free; a malformed structure yields fewer records, never an
795/// error or panic.
796#[must_use]
797pub fn carve_all_deleted_records(db: &Database) -> Vec<CarvedRecord> {
798    let mut out: Vec<CarvedRecord> = Vec::new();
799
800    // (1) Freelist pages, inferring the column count per record. Inference makes
801    // this recover both normal freed pages AND schema-gone dropped-table pages
802    // (a `DROP TABLE` leaves the page on the freelist with no recorded column
803    // count). When the database has no live user table at all, the freed content
804    // is necessarily from a dropped table, so mark those records accordingly.
805    let dropped_table_db = !db.has_user_table();
806    if let Ok(free) = db.freelist_pages() {
807        for page in free {
808            let Some(page_bytes) = db.raw_page(page) else {
809                continue; // cov:unreachable: freelist_pages yields in-range pages
810            };
811            let source = if dropped_table_db {
812                RecoverySource::DroppedTable
813            } else {
814                RecoverySource::FreelistPage
815            };
816            for cell in db.carve_cells_inferred(page_bytes) {
817                out.push(CarvedRecord {
818                    page,
819                    offset: cell.offset,
820                    rowid: cell.rowid,
821                    values: cell.values,
822                    confidence: cell.confidence,
823                    allocated: false,
824                    source,
825                    wal: None,
826                    overflow: None,
827                });
828            }
829        }
830    }
831
832    // (2) In-page free space of every still-allocated table-leaf page.
833    let page_count = db.page_count();
834    for page in 1..=page_count {
835        let Some(page_bytes) = db.raw_page(page) else {
836            continue; // cov:unreachable: 1..=page_count is in range
837        };
838        for cell in db.carve_free_regions(page_bytes, 0) {
839            out.push(CarvedRecord {
840                page,
841                offset: cell.offset,
842                rowid: cell.rowid,
843                values: cell.values,
844                confidence: cell.confidence,
845                allocated: false,
846                source: RecoverySource::InPageFreeBlock,
847                wal: None,
848                overflow: None,
849            });
850        }
851        // (2b) Freeblock reconstruction: the freed cells whose first four bytes
852        // were clobbered by freeblock conversion, rebuilt from their surviving
853        // serial tail plus the page's schema template. These carry an unknown
854        // (destroyed) rowid, so the value-collision pass below — not the
855        // rowid-keyed filter — is what guarantees no live row is re-surfaced.
856        for cell in db.reconstruct_freeblock_records(page_bytes) {
857            out.push(CarvedRecord {
858                page,
859                offset: cell.offset,
860                rowid: cell.rowid,
861                values: cell.values,
862                confidence: cell.confidence,
863                allocated: false,
864                source: RecoverySource::FreeblockReconstructed,
865                wal: None,
866                overflow: None,
867            });
868        }
869        // (2c) Chain-aware overflow recovery (task #73): a freed cell whose
870        // payload spilled onto an overflow-page chain, reassembled when every
871        // chain page survives as a freelist leaf. Graded below the in-page
872        // full-row tier (NOT part of the structural 0-FP guarantee — Codex
873        // ruling #1); a broken chain (e.g. a trunk-clobbered page) is rejected
874        // here and degrades to a Tier-2 fragment elsewhere.
875        for (cell, chain) in db.carve_overflow_records(page_bytes) {
876            let first_page = chain.first().copied().unwrap_or(0);
877            out.push(CarvedRecord {
878                page,
879                offset: cell.offset,
880                rowid: cell.rowid,
881                values: cell.values,
882                confidence: cell.confidence,
883                allocated: false,
884                source: RecoverySource::InPageFreeBlock,
885                wal: None,
886                overflow: Some(OverflowProvenance { first_page, chain }),
887            });
888        }
889        // (2d) Freeblock-clobbered spilled cells (task #73, Codex ruling #5;
890        // UNPROVEN-BY-CORPUS — synthetic validation only). A spilled cell whose
891        // prefix was also freeblock-clobbered: P re-derived from the surviving
892        // serial array, chain resolved through freelist leaves, rowid destroyed.
893        for (cell, chain) in db.carve_overflow_template_records(page_bytes) {
894            let first_page = chain.first().copied().unwrap_or(0);
895            out.push(CarvedRecord {
896                page,
897                offset: cell.offset,
898                rowid: cell.rowid,
899                values: cell.values,
900                confidence: cell.confidence,
901                allocated: false,
902                source: RecoverySource::FreeblockReconstructed,
903                wal: None,
904                overflow: Some(OverflowProvenance { first_page, chain }),
905            });
906        }
907    }
908
909    // (3) WAL-frame carving (additive — runs ONLY when a `-wal` overlay is in
910    // effect). The on-disk path above reads only the main file's pages, so it
911    // finds the SAME residue whether or not a WAL was supplied; the genuinely-
912    // different deleted records live in the uncheckpointed WAL frames.
913    //
914    // A `-wal` frame is a FULL page snapshot at one point in the transaction
915    // history. A row deleted late in that history is still a live cell in an
916    // EARLIER frame's image; it exists nowhere on disk and in no later frame.
917    // Three primitives over each committed frame's page image surface it:
918    //
919    //   * `carve_leaf_cells` — every cell the frame records as allocated. A cell
920    //     that is allocated in a superseded frame but ABSENT from the final
921    //     WAL-applied live view is exactly such a deleted row (recovered as a
922    //     clean, intact record — the strongest WAL case).
923    //   * `carve_free_regions` / `reconstruct_freeblock_records` — residue freed
924    //     WITHIN a frame (e.g. the DELETE-commit frame's own freeblocks), matching
925    //     the on-disk in-page classes.
926    //
927    // Every candidate is tagged `WalFrame` with the (salt1, salt2, frame_index)
928    // LSN provenance, and the shared live-row precision filter below drops any
929    // whose values match a currently-live row — so a surviving row is never
930    // re-surfaced (the 0-false-positive guarantee, against the WAL-applied view).
931    for frame in db.wal_frame_pages() {
932        let prov = WalProvenance {
933            frame_index: frame.frame_index,
934            salt1: frame.salt1,
935            salt2: frame.salt2,
936        };
937        let cells = db
938            .carve_leaf_cells(&frame.page)
939            .into_iter()
940            .chain(db.carve_free_regions(&frame.page, 0))
941            .chain(db.reconstruct_freeblock_records(&frame.page));
942        for cell in cells {
943            out.push(CarvedRecord {
944                page: frame.page_no,
945                offset: cell.offset,
946                rowid: cell.rowid,
947                values: cell.values,
948                confidence: cell.confidence,
949                allocated: false,
950                source: RecoverySource::WalFrame,
951                wal: Some(prov),
952                overflow: None,
953            });
954        }
955    }
956
957    // VALUE-AWARE classification for carved records whose rowid is currently
958    // LIVE. Two very different cases share a live rowid:
959    //
960    //   * Stale rebalance copy — a b-tree rebalance moved a still-live row to
961    //     another page, leaving a byte-identical copy in the old page's slack.
962    //     SAME rowid, SAME values → NOT deleted → drop (the 0-false-positive
963    //     precision win we must preserve).
964    //   * Prior version — an UPDATE freed the OLD version of the row into slack
965    //     while the new version kept the same rowid. SAME rowid, DIFFERENT values
966    //     → genuinely-deleted content (the edited message / changed amount, often
967    //     THE evidence) → recover, tagged `PriorVersion`.
968    //
969    // A rowid-only filter cannot tell these apart and drops both (a false
970    // negative on prior versions). Comparing decoded values does.
971    // Structural-noise rejection (precision, not the live-row guarantee): drop
972    // any carved record that carries no content — a rowid echo followed by an
973    // all-NULL tail, the inferred over-read of free-space zero bytes. Applied to
974    // every tier uniformly (no per-source special-case).
975    out.retain(|rec| !is_structural_noise(&rec.values));
976
977    let live = db.live_rows();
978    // COMPLETE value-level identity of every live row, across ALL tables and
979    // UNCOLLAPSED. `live_rows()` keys by a global rowid, so two tables sharing a
980    // rowid collapse to one entry and a live row can vanish from the guard; build
981    // the guard from every live row (roadmap §1.1) plus the CURRENT `sqlite_master`
982    // rows (a record carved from a materialized page 1 whose values equal a live
983    // schema entry is that live row re-surfaced, not deleted residue — value-based,
984    // so a genuinely-deleted PRIOR schema version is still recovered).
985    let live_values = live_value_tuples(db);
986    let live_value_keys: std::collections::HashSet<String> = live_values
987        .iter()
988        .chain(db.live_schema_rows().iter())
989        .map(|v| format!("{v:?}"))
990        .collect();
991    out.retain_mut(|rec| {
992        // Exclusion invariant (EVERY source): a record whose decoded values equal
993        // ANY currently-live row is that live row re-surfaced — never report it as
994        // deleted. This guards the cross-table rowid-collision case the rowid-keyed
995        // branch below cannot, since the global live map collapses shared rowids.
996        if live_value_keys.contains(&format!("{:?}", rec.values)) {
997            return false;
998        }
999        // Freeblock reconstructions (destroyed rowid) and WAL-frame residue carry no
1000        // rowid usable by the branch below; the value guard above already protected
1001        // them, so keep them with their source tag intact.
1002        if rec.source == RecoverySource::FreeblockReconstructed
1003            || rec.source == RecoverySource::WalFrame
1004        {
1005            return true;
1006        }
1007        // A live rowid with DIFFERING values (exact matches were dropped above) is a
1008        // deleted prior version of that row; an absent rowid is an ordinary deletion.
1009        // Either way the record is kept — only its classification changes.
1010        if live.contains_key(&rec.rowid) {
1011            rec.source = RecoverySource::PriorVersion;
1012        }
1013        true
1014    });
1015
1016    dedup_keep_best(out, &db.page_to_table_map())
1017}
1018
1019/// A carved full record is **structural noise** — not an information-bearing
1020/// deleted row — when every column past the first is NULL. The inferred carver,
1021/// lacking a column hint, can read a run of free-space zero bytes as a long
1022/// serial-type-0 (NULL) array; that surfaces as a rowid echo (the INTEGER-PK
1023/// column) followed by an all-NULL tail, carrying no recoverable content. A
1024/// genuine row — live, dropped-table, or overflow — has a non-NULL value in some
1025/// non-leading column.
1026///
1027/// The column count is deliberately NOT bounded against the live tables: a
1028/// dropped table can legitimately be wider than every surviving one, so a
1029/// width cap would discard genuine unattributed records. Requiring length >= 2
1030/// keeps a real single-column row from being mistaken for noise.
1031fn is_structural_noise(values: &[Value]) -> bool {
1032    values.len() >= 2 && values.iter().skip(1).all(|v| matches!(v, Value::Null))
1033}
1034
1035/// A schema-table (`sqlite_master`) row recovered from free space — a **dropped
1036/// or replaced** table/index/view/trigger whose definition no longer appears in
1037/// the live schema. Recovering it tells the examiner an object *existed* and its
1038/// structure (the `CREATE` statement), which in turn explains residual data on
1039/// the freelist. A finding, not a certainty: it is "consistent with a dropped
1040/// `<name>`" — the examiner draws the conclusion.
1041#[derive(Debug, Clone, PartialEq, Eq)]
1042pub struct RecoveredSchema {
1043    /// `sqlite_master.type` — `table`, `index`, `view`, or `trigger`.
1044    pub object_type: String,
1045    /// `sqlite_master.name` — the object's name.
1046    pub name: String,
1047    /// `sqlite_master.tbl_name` — the table the object belongs to (== `name` for
1048    /// a table).
1049    pub tbl_name: String,
1050    /// `sqlite_master.rootpage` — the (now-freed) b-tree root, `None` when the
1051    /// recovered cell stored it as NULL (views/triggers).
1052    pub rootpage: Option<i64>,
1053    /// `sqlite_master.sql` — the recovered `CREATE` statement.
1054    pub sql: String,
1055}
1056
1057/// Recover **dropped or replaced** schema definitions from free space: carved
1058/// `sqlite_master`-shaped rows whose `(name, sql)` no longer matches a live
1059/// schema entry. A `DROP TABLE`/`DROP INDEX` deletes the object's `sqlite_master`
1060/// row (its bytes survive in page-1 free space under `secure_delete=OFF`); this
1061/// surfaces that residue as a structured [`RecoveredSchema`] rather than a raw
1062/// 5-column carved record.
1063#[must_use]
1064pub fn recover_dropped_schemas(db: &Database) -> Vec<RecoveredSchema> {
1065    use std::collections::HashSet;
1066    // Live `(name, sql)` pairs — an exactly-live definition carved from page-1
1067    // residue is a re-surfaced live row, never a drop.
1068    let live: HashSet<(String, String)> = db.schema_sql().into_iter().collect();
1069    let mut seen: HashSet<(String, String)> = HashSet::new();
1070    let mut out = Vec::new();
1071    for rec in carve_all_deleted_records(db) {
1072        // A `sqlite_master` row lives only on page 1 (plus its overflow); restrict
1073        // there so a 5-column data record elsewhere is never mistaken for schema.
1074        if rec.page != 1 {
1075            continue;
1076        }
1077        let Some(schema) = as_schema_row(&rec.values) else {
1078            continue;
1079        };
1080        let key = (schema.name.clone(), schema.sql.clone());
1081        if live.contains(&key) {
1082            continue;
1083        }
1084        if seen.insert(key) {
1085            out.push(schema);
1086        }
1087    }
1088    out
1089}
1090
1091/// Interpret a carved record as a `sqlite_master` row
1092/// `(type, name, tbl_name, rootpage, sql)`, or `None` if it does not match that
1093/// shape. `type` must be one of the four schema object kinds, the `name` /
1094/// `tbl_name` / `sql` columns TEXT, and `rootpage` an INTEGER (or NULL for
1095/// views/triggers).
1096fn as_schema_row(values: &[Value]) -> Option<RecoveredSchema> {
1097    let [type_v, name_v, tbl_v, root_v, sql_v] = values else {
1098        return None;
1099    };
1100    let object_type = match type_v {
1101        Value::Text(t) if matches!(t.as_str(), "table" | "index" | "view" | "trigger") => t.clone(),
1102        _ => return None,
1103    };
1104    let (Value::Text(name), Value::Text(tbl_name), Value::Text(sql)) = (name_v, tbl_v, sql_v)
1105    else {
1106        return None;
1107    };
1108    let rootpage = match root_v {
1109        Value::Integer(n) => Some(*n),
1110        Value::Null => None,
1111        _ => return None,
1112    };
1113    Some(RecoveredSchema {
1114        object_type,
1115        name: name.clone(),
1116        tbl_name: tbl_name.clone(),
1117        rootpage,
1118        sql: sql.clone(),
1119    })
1120}
1121
1122/// Two-tier deleted-record recovery: Tier-1 full rows **plus** Tier-2 partial
1123/// fragments, in one pass.
1124///
1125/// [`CarveTiers::full`] is byte-identical to [`carve_all_deleted_records`] (the
1126/// high-precision, structurally-0-false-positive output). [`CarveTiers::fragments`]
1127/// is the opt-in lead surface: at every freeblock/gap anchor where full
1128/// reconstruction failed but ≥ 1 distinctive cell (TEXT ≥ 4 bytes of valid UTF-8,
1129/// or REAL) survived, the maximal decodable column prefix is salvaged as a
1130/// [`CarvedFragment`]. Three suppression layers keep the fragment bucket honest:
1131///
1132/// 1. **By construction** — a fragment is emitted only where full reconstruction
1133///    failed, so an anchor yields a full cell or a fragment, never both.
1134/// 2. **Full-row value suppression** — a fragment whose every surviving
1135///    `(column, value)` matches the corresponding column of a Tier-1 record in
1136///    `full` is dropped (the row was already recovered another way).
1137/// 3. **Live-row suppression** — a fragment whose every surviving `(column, value)`
1138///    matches the corresponding columns of a currently-live row is dropped (never
1139///    re-surface a live row, the fragment analog of the rebalance-copy drop).
1140///
1141/// Read-only and panic-free, identically to [`carve_all_deleted_records`].
1142#[must_use]
1143pub fn carve_with_fragments(db: &Database) -> CarveTiers {
1144    let full = carve_all_deleted_records(db);
1145
1146    // Salvage Tier-2 fragments from every on-disk table-leaf page. The core
1147    // walker emits a fragment only where full reconstruction failed (layer 1).
1148    let mut fragments: Vec<CarvedFragment> = Vec::new();
1149    let page_count = db.page_count();
1150    for page in 1..=page_count {
1151        let Some(page_bytes) = db.raw_page(page) else {
1152            continue; // cov:unreachable: 1..=page_count is in range
1153        };
1154        for frag in db.reconstruct_freeblock_fragments(page_bytes) {
1155            fragments.push(CarvedFragment {
1156                page,
1157                offset: frag.offset,
1158                surviving: frag.surviving,
1159                missing: frag.missing,
1160                confidence: frag.confidence,
1161                source: RecoverySource::FreeblockReconstructed,
1162                wal: None,
1163            });
1164        }
1165        // Broken-chain overflow fragments (task #73, Codex ruling #4): a spilled
1166        // cell whose overflow chain was destroyed (e.g. a trunk-clobbered chain
1167        // page) still has an intact local prefix; salvage its locally-decodable
1168        // columns. The chain-resident columns are lost (untrusted), so this is a
1169        // Tier-2 lead, never a full row.
1170        for frag in db.carve_overflow_fragments(page_bytes) {
1171            fragments.push(CarvedFragment {
1172                page,
1173                offset: frag.offset,
1174                surviving: frag.surviving,
1175                missing: frag.missing,
1176                confidence: frag.confidence,
1177                source: RecoverySource::InPageFreeBlock,
1178                wal: None,
1179            });
1180        }
1181    }
1182
1183    // Layer 3: live-row suppression. A fragment whose surviving set matches the
1184    // corresponding columns of a live row is a stale partial copy of a live row.
1185    // Complete, uncollapsed live set (roadmap §1.1): see `live_value_tuples`.
1186    let live_values = live_value_tuples(db);
1187    fragments.retain(|frag| {
1188        !live_values
1189            .iter()
1190            .any(|lv| fragment_matches_columns(frag, lv))
1191    });
1192
1193    // Layer 2: full-row value suppression. A fragment already covered by a
1194    // recovered full row in this carve is a duplicate — drop it.
1195    fragments.retain(|frag| {
1196        !full
1197            .iter()
1198            .any(|rec| fragment_matches_columns(frag, &rec.values))
1199    });
1200
1201    // Fragment dedup: one fragment per (page, offset) anchor by construction,
1202    // then collapse value-level duplicates keeping the copy with more surviving
1203    // columns (mirrors dedup_keep_best for the full tier).
1204    fragments = dedup_fragments(fragments);
1205
1206    CarveTiers { full, fragments }
1207}
1208
1209/// Whether a fragment's every surviving `(column_index, value)` pair equals the
1210/// corresponding column of `row` — the projection test shared by the full-row
1211/// (layer 2) and live-row (layer 3) suppression passes. Equality of the **whole**
1212/// surviving set is required: a fragment that coincides with a row on only some
1213/// cells is kept (it may be genuine residue), mirroring the full-row rule's
1214/// whole-values comparison.
1215fn fragment_matches_columns(frag: &CarvedFragment, row: &[Value]) -> bool {
1216    !frag.surviving.is_empty()
1217        && frag
1218            .surviving
1219            .iter()
1220            .all(|(idx, v)| row.get(*idx) == Some(v))
1221}
1222
1223/// De-duplicate fragments: first by `(page, offset)` anchor (one fragment per
1224/// anchor by construction), then collapse value-level duplicates keeping the copy
1225/// with the most surviving columns. Mirrors [`dedup_keep_best`] for the full tier.
1226fn dedup_fragments(mut frags: Vec<CarvedFragment>) -> Vec<CarvedFragment> {
1227    use std::collections::HashSet;
1228    // More surviving columns first, so the kept copy of each identity is richest.
1229    frags.sort_by_key(|f| std::cmp::Reverse(f.surviving.len()));
1230    let mut seen_anchor: HashSet<(u32, usize)> = HashSet::new();
1231    let mut seen_values: HashSet<String> = HashSet::new();
1232    let mut kept = Vec::new();
1233    for frag in frags {
1234        if !seen_anchor.insert((frag.page, frag.offset)) {
1235            continue;
1236        }
1237        let vkey = format!("{:?}", frag.surviving);
1238        if !seen_values.insert(vkey) {
1239            continue;
1240        }
1241        kept.push(frag);
1242    }
1243    kept
1244}
1245
1246/// Carve the deleted residue of **one materialized commit snapshot** of the
1247/// `-wal`, the per-commit temporal building block of the N-snapshot carve.
1248///
1249/// `id` addresses a [`CommitId`] in `timeline`; the snapshot it resolves to is the
1250/// database state replayed up to that COMMIT (base image ∪ every committed frame to
1251/// that commit, capped to `db_size_after_commit`). This runs the same three
1252/// carving primitives the on-disk path uses — [`Database::carve_leaf_cells`]
1253/// (intact cells the snapshot records as allocated, the strongest case),
1254/// [`Database::carve_free_regions`], and [`Database::reconstruct_freeblock_records`]
1255/// — over each of the snapshot's materialized page images, then applies the SAME
1256/// live-row precision filter: a record whose decoded values match a currently-live
1257/// row (the WAL-applied view) is dropped, so a row that survived to the final state
1258/// is **never** re-surfaced as deleted. Surviving records are tagged
1259/// [`RecoverySource::CommitSnapshot`] and carry the commit's
1260/// `(salt1, salt2, commit_frame_index)` LSN in [`CarvedRecord::wal`].
1261///
1262/// An unknown [`CommitId`] (absent from `timeline`) yields an empty vector, never a
1263/// panic. Read-only throughout.
1264#[must_use]
1265pub fn carve_at_commit(db: &Database, timeline: &WalTimeline, id: CommitId) -> Vec<CarvedRecord> {
1266    let Some(snapshot) = timeline.snapshot_at(id) else {
1267        return Vec::new();
1268    };
1269    let lsn = snapshot.lsn();
1270    let prov = WalProvenance {
1271        frame_index: lsn.frame_index,
1272        salt1: lsn.salt1,
1273        salt2: lsn.salt2,
1274    };
1275
1276    let mut out: Vec<CarvedRecord> = Vec::new();
1277    for page_no in snapshot.page_numbers() {
1278        let Some(image) = snapshot.page_version(page_no) else {
1279            continue; // cov:unreachable: page_numbers only yields materialized pages
1280        };
1281        let cells = db
1282            .carve_leaf_cells(&image.bytes)
1283            .into_iter()
1284            .chain(db.carve_free_regions(&image.bytes, 0))
1285            .chain(db.reconstruct_freeblock_records(&image.bytes));
1286        for cell in cells {
1287            out.push(CarvedRecord {
1288                page: page_no,
1289                offset: cell.offset,
1290                rowid: cell.rowid,
1291                values: cell.values,
1292                confidence: cell.confidence,
1293                allocated: false,
1294                source: RecoverySource::CommitSnapshot,
1295                wal: Some(prov),
1296                overflow: None,
1297            });
1298        }
1299    }
1300
1301    // Live-row precision filter (the WAL-applied view): drop any record whose
1302    // decoded values match a currently-live row, so a row that survives to the
1303    // final state is never re-surfaced as "deleted at an earlier commit". The
1304    // live set includes the CURRENT `sqlite_master` rows, because a snapshot's
1305    // materialized page 1 (the schema table) still holds the live schema cell —
1306    // value-based, so a deleted PRIOR schema version is still recovered.
1307    // Complete, uncollapsed live set (roadmap §1.1): see `live_value_tuples`.
1308    let live_value_keys: std::collections::HashSet<String> = live_value_tuples(db)
1309        .iter()
1310        .chain(db.live_schema_rows().iter())
1311        .map(|v| format!("{v:?}"))
1312        .collect();
1313    out.retain(|rec| !live_value_keys.contains(&format!("{:?}", rec.values)));
1314
1315    dedup_keep_best(out, &db.page_to_table_map())
1316}
1317
1318/// De-duplicate carved records by content identity, keeping the
1319/// highest-confidence copy of each (a row can survive in several free regions).
1320///
1321/// `Value` carries an `f64` (`Real`) so it is not `Hash`/`Eq`; the identity key
1322/// is the record's `rowid` plus a stable `Debug` rendering of its values, which
1323/// is sufficient to collapse byte-identical recoveries.
1324fn dedup_keep_best(
1325    mut records: Vec<CarvedRecord>,
1326    page_table: &std::collections::BTreeMap<u32, String>,
1327) -> Vec<CarvedRecord> {
1328    use std::collections::HashSet;
1329    let content = |r: &CarvedRecord| format!("{}:{:?}", r.rowid, r.values);
1330
1331    // Identities (rowid, values) that have at least one record attributed to a
1332    // LIVE table. A copy of such an identity carved from a freed/unattributed page
1333    // is the SAME deleted row surviving in two places; it is folded into the
1334    // attributed copy rather than double-listed (roadmap §1.2).
1335    let attributed: HashSet<String> = records
1336        .iter()
1337        .filter(|r| page_table.contains_key(&r.page))
1338        .map(&content)
1339        .collect();
1340
1341    let mut seen: HashSet<String> = HashSet::new();
1342    let mut kept: Vec<CarvedRecord> = Vec::new();
1343    // Highest confidence first, so the kept copy of each identity is the best one.
1344    records.sort_by(|a, b| {
1345        b.confidence
1346            .partial_cmp(&a.confidence)
1347            .unwrap_or(std::cmp::Ordering::Equal)
1348    });
1349    for rec in records.drain(..) {
1350        let c = content(&rec);
1351        // Table-aware identity (roadmap §1.2): a record from a page a live table
1352        // owns is keyed by (table, rowid, values) — the same row recovered from
1353        // several free regions of ONE table collapses, but two DIFFERENT live
1354        // tables sharing (rowid, values) stay distinct. An unattributed copy of an
1355        // identity that IS attributed elsewhere is the same deleted row → fold it
1356        // in. Otherwise (no live-table copy at all) keep the plain content identity.
1357        let key = match page_table.get(&rec.page) {
1358            Some(table) => format!("T:{table}:{c}"),
1359            None if attributed.contains(&c) => continue,
1360            None => format!("U:{c}"),
1361        };
1362        if seen.insert(key) {
1363            kept.push(rec);
1364        }
1365    }
1366    kept
1367}
1368
1369/// A row recovered from the prior (pre-transaction) snapshot of a rollback
1370/// journal (design §5). In the prior state it was a LIVE allocated row that the
1371/// last transaction DELETED — so it is recovered at full fidelity and is NOT a
1372/// free-space carve (`allocated` was true in the prior state). Carries full
1373/// journal provenance in [`RollbackJournalSource`].
1374#[derive(Debug, Clone, PartialEq)]
1375pub struct PriorRow {
1376    /// The table the row belonged to (prior schema).
1377    pub table: String,
1378    /// The row's rowid (its INTEGER PRIMARY KEY for a rowid table).
1379    pub rowid: i64,
1380    /// The decoded prior column values, in column order.
1381    pub values: Vec<Value>,
1382    /// Journal provenance: page / segment / header-state / checksum status.
1383    pub source: RecoverySource,
1384}
1385
1386/// A row present in BOTH the prior snapshot and the live db with DIFFERENT values
1387/// — the last transaction modified it (design §4). Carries the PRIOR (pre-edit)
1388/// values and the CURRENT values. `replaced_rowid` is set because a delete +
1389/// insert reusing the same rowid is indistinguishable from an in-place UPDATE
1390/// here, so identity may differ — never asserted as "UPDATE".
1391#[derive(Debug, Clone, PartialEq)]
1392pub struct PriorVersionRecord {
1393    /// The table the row belonged to (prior schema).
1394    pub table: String,
1395    /// The rowid present in both states.
1396    pub rowid: i64,
1397    /// The decoded PRIOR (pre-transaction) column values.
1398    pub prior_values: Vec<Value>,
1399    /// The decoded CURRENT (live) column values.
1400    pub current_values: Vec<Value>,
1401    /// Always `true`: the rowid is shared but identity may differ (delete+insert
1402    /// reusing a rowid is indistinguishable from an UPDATE), so the prior value is
1403    /// edit history, not an asserted in-place update.
1404    pub replaced_rowid: bool,
1405    /// Journal provenance for the prior image.
1406    pub source: RecoverySource,
1407}
1408
1409/// Structured counts of what the rollback-journal recovery found — the NIST
1410/// SFT-03 "number of deleted/modified records" report (design §5/§6.11).
1411#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1412pub struct JournalCounts {
1413    /// Rows deleted by the last transaction (prior \ current by rowid).
1414    pub deleted: usize,
1415    /// Rows modified by the last transaction (present in both, values differ).
1416    pub modified: usize,
1417}
1418
1419/// The result of recovering deletions + modifications from a rollback journal
1420/// (design §5). Deleted rows are full-fidelity prior allocated rows (NOT
1421/// free-space carves); modified rows carry the prior and current values.
1422#[derive(Debug, Clone, PartialEq)]
1423pub struct JournalRecovery {
1424    /// Rows present in the prior snapshot but absent now — deleted by the last
1425    /// transaction, recovered at full fidelity.
1426    pub deleted: Vec<PriorRow>,
1427    /// Rows present in both, values differ — prior (pre-edit) + current values.
1428    pub modified: Vec<PriorVersionRecord>,
1429    /// Structured counts (the NIST source-file report).
1430    pub counts: JournalCounts,
1431}
1432
1433/// Recover the last transaction's **deletions and modifications** from a rollback
1434/// `-journal` by diffing the pre-transaction snapshot against the live database
1435/// (design §4) — the temporal inverse of WAL recovery.
1436///
1437/// For each prior rowid table, the prior rows (read through `PriorSnapshot`)
1438/// are diffed against the current rows by rowid:
1439/// - **deleted** = rowid in prior but not current → a full-fidelity [`PriorRow`];
1440/// - **modified** = rowid in both with different values → a [`PriorVersionRecord`]
1441///   flagged `replaced_rowid` (identity may differ; never asserted as UPDATE);
1442/// - **inserted** (rowid only in current) is a timeline fact, not a recovery target.
1443///
1444/// `WITHOUT ROWID` tables are excluded (no integer rowid key; design §4 limit).
1445/// Read-only and panic-free: a malformed/truncated journal, or a WAL-applied db
1446/// (the modes are exclusive), yields an empty recovery rather than an error or
1447/// panic — the journal source is bound to THIS database via
1448/// [`Database::rollback_prior`].
1449#[must_use]
1450pub fn carve_rollback_journal(db: &Database, journal: &[u8]) -> JournalRecovery {
1451    let mut deleted = Vec::new();
1452    let mut modified = Vec::new();
1453
1454    let Ok(prior) = db.rollback_prior(journal) else {
1455        return JournalRecovery {
1456            deleted,
1457            modified,
1458            counts: JournalCounts::default(),
1459        };
1460    };
1461    // Header state + checksum status are uniform across a single journal; carry
1462    // them on every recovered row's provenance.
1463    let Some(header_state) = prior_header_state(journal, db) else {
1464        // cov:unreachable: rollback_prior above already parsed the same journal,
1465        // so a re-parse here cannot fail; the arm degrades to an empty recovery.
1466        return JournalRecovery {
1467            deleted,
1468            modified,
1469            counts: JournalCounts::default(),
1470        };
1471    };
1472    // Per-row checksum status is not threaded through PriorSnapshot in v1, so the
1473    // verifiable fact recorded here is the journal-level one: Tier A could verify
1474    // checksums (nonce known), Tier B could not (nonce zeroed). A finer per-image
1475    // status is a later enhancement; `header_state` carries the Tier either way.
1476    let checksum_valid = match header_state {
1477        JournalHeaderState::Valid => Some(true),
1478        JournalHeaderState::ReconstructedZeroed => None,
1479    };
1480
1481    // Index the current (live) rows per table by rowid, so the diff is O(rows).
1482    let live = db.live_table_rows();
1483
1484    for ptable in prior.tables() {
1485        if ptable.without_rowid {
1486            continue; // design §4 limit: WITHOUT ROWID excluded from the rowid diff.
1487        }
1488        let col_count = ptable.columns.len();
1489        let Ok(prior_rows) = prior.read_table_with_pages(ptable.rootpage, col_count) else {
1490            continue;
1491        };
1492        // Current rows of the same-named table (live schema), by rowid.
1493        let current: std::collections::BTreeMap<i64, Vec<Value>> = live
1494            .iter()
1495            .find(|d| d.name == ptable.name)
1496            .map(|d| d.rows.iter().map(|r| (r.rowid, r.values.clone())).collect())
1497            .unwrap_or_default();
1498
1499        for (rowid, values, leaf_page) in prior_rows {
1500            let source = RecoverySource::RollbackJournal(RollbackJournalSource {
1501                segment: 0,
1502                pgno: leaf_page,
1503                header_state,
1504                checksum_valid,
1505            });
1506            match current.get(&rowid) {
1507                None => deleted.push(PriorRow {
1508                    table: ptable.name.clone(),
1509                    rowid,
1510                    values,
1511                    source,
1512                }),
1513                Some(cur) if *cur != values => modified.push(PriorVersionRecord {
1514                    table: ptable.name.clone(),
1515                    rowid,
1516                    prior_values: values,
1517                    current_values: cur.clone(),
1518                    replaced_rowid: true,
1519                    source,
1520                }),
1521                Some(_) => {} // unchanged: the page was journaled for another row.
1522            }
1523        }
1524    }
1525
1526    let counts = JournalCounts {
1527        deleted: deleted.len(),
1528        modified: modified.len(),
1529    };
1530    JournalRecovery {
1531        deleted,
1532        modified,
1533        counts,
1534    }
1535}
1536
1537/// Determine whether the journal header was intact (Tier A) or zeroed (Tier B),
1538/// by re-parsing it against the db's authoritative page size.
1539fn prior_header_state(journal: &[u8], db: &Database) -> Option<JournalHeaderState> {
1540    let parsed = RollbackJournal::parse(journal, db.header().page_size).ok()?;
1541    Some(match parsed.header() {
1542        JournalHeader::Valid { .. } => JournalHeaderState::Valid,
1543        JournalHeader::ReconstructedZeroed { .. } => JournalHeaderState::ReconstructedZeroed,
1544    })
1545}
1546
1547/// Audit an opened [`Database`] for forensically-notable anomalies.
1548///
1549/// Covers the header reserved-space field, a non-empty freelist (prior
1550/// deletions), an active WAL overlay (uncheckpointed state), and a header/file
1551/// page-count mismatch. Deleted-record recovery is offered separately via
1552/// [`carve_deleted_records`] / [`audit_carved_findings`] because it requires the
1553/// table's column count.
1554#[must_use]
1555pub fn audit(db: &Database) -> Vec<Anomaly> {
1556    let mut out = Vec::new();
1557
1558    let reserved = db.header().reserved;
1559    if reserved != 0 {
1560        out.push(Anomaly::new(AnomalyKind::NonZeroReservedSpace { reserved }));
1561    }
1562
1563    let free_pages = db.freelist_count();
1564    if free_pages != 0 {
1565        out.push(Anomaly::new(AnomalyKind::NonEmptyFreelist { free_pages }));
1566    }
1567
1568    if db.wal_applied() {
1569        // The overlay supersedes at least one page; report it as uncheckpointed
1570        // state. (The exact page count is not separately exposed; report ≥1.)
1571        out.push(Anomaly::new(AnomalyKind::WalUncheckpointedState {
1572            overlaid_pages: 1,
1573        }));
1574    }
1575
1576    let header_pages = db.header_page_count();
1577    let file_pages = db.file_page_count();
1578    if header_pages != 0 && header_pages != file_pages {
1579        out.push(Anomaly::new(AnomalyKind::PageCountMismatch {
1580            header_pages,
1581            file_pages,
1582        }));
1583    }
1584
1585    // Dropped/replaced schema objects whose sqlite_master row survives in page-1
1586    // free space (a DROP under secure_delete=OFF). Surfaced as findings so a
1587    // dropped table's existence + name is reported, not just buried in the carve.
1588    for schema in recover_dropped_schemas(db) {
1589        out.push(Anomaly::new(AnomalyKind::DroppedSchemaRecovered {
1590            object_type: schema.object_type,
1591            name: schema.name,
1592        }));
1593    }
1594
1595    out
1596}
1597
1598/// Audit an opened [`Database`] and convert each anomaly to the canonical
1599/// [`Finding`] under the supplied [`Source`], ready to merge into a `Report`.
1600#[must_use]
1601pub fn audit_findings(db: &Database, source: &Source) -> Vec<Finding> {
1602    audit(db)
1603        .into_iter()
1604        .map(|a| a.to_finding(source.clone()))
1605        .collect()
1606}
1607
1608/// The database schema cookie: file-header bytes `40..44` as a big-endian `u32`
1609/// (file-format §1.3). Returns `None` when the page is too short to hold the
1610/// field — bounded and panic-free, so a truncated page-1 image degrades to "no
1611/// cookie" rather than indexing out of range.
1612fn schema_cookie(page: &[u8]) -> Option<u32> {
1613    let bytes = page.get(40..44)?;
1614    Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
1615}
1616
1617/// Audit a rollback `-journal` (raw bytes) bound to `db` for the design-§6
1618/// observations, additive to [`audit`] (which covers main-db anomalies only).
1619///
1620/// The journal is parsed with the database's authoritative page size and graded
1621/// into observations, each derivable from the parser API and each "consistent
1622/// with …" (the examiner draws the conclusion):
1623/// - a **hot journal** (valid header magic) — an interrupted/in-progress write;
1624/// - a **recoverable** PERSIST journal (zeroed header, page images intact);
1625/// - **checksum mismatch(es)** (Tier A) — corruption / torn page / modification;
1626/// - a **schema-cookie advance** (journal page-1 image vs live db, offset 40) — a
1627///   DDL change, prior schema recoverable;
1628/// - a **duplicate page record** — corruption / savepoint / tampering;
1629/// - a **db-size delta** (Tier A `mxPage` vs current) — growth or shrink.
1630///
1631/// Read-only and panic-free: an unparsable/garbage journal simply yields no
1632/// observations rather than an error or panic.
1633#[must_use]
1634pub fn audit_journal(db: &Database, journal: &[u8]) -> Vec<Anomaly> {
1635    let mut out = Vec::new();
1636
1637    let Ok(parsed) = RollbackJournal::parse(journal, db.header().page_size) else {
1638        // A page size outside [512, 65536] / non-power-of-two is the only parse
1639        // error; a database that opened cannot have one, so this degrades to no
1640        // observations rather than masking a real failure.
1641        return out;
1642    };
1643
1644    let images = parsed.page_images();
1645
1646    match parsed.header() {
1647        JournalHeader::Valid { mx_page, .. } => {
1648            // A valid-magic header is a hot journal (interrupted/in-progress).
1649            out.push(Anomaly::new(AnomalyKind::HotJournal {
1650                mx_page: *mx_page,
1651                journaled_pages: u32::try_from(images.len()).unwrap_or(u32::MAX),
1652            }));
1653
1654            // mxPage (db size at txn start) vs the current page count. 0 is the
1655            // "size unknown" sentinel, so only a non-zero, differing value is a
1656            // reportable size delta.
1657            let current_pages = db.page_count();
1658            if *mx_page != 0 && *mx_page != current_pages {
1659                out.push(Anomaly::new(AnomalyKind::JournalDbSizeDelta {
1660                    mx_page: *mx_page,
1661                    current_pages,
1662                }));
1663            }
1664        }
1665        JournalHeader::ReconstructedZeroed { .. } => {
1666            // A zeroed (PERSIST post-commit) header with >=1 page image carries
1667            // recoverable pre-images.
1668            if !images.is_empty() {
1669                out.push(Anomaly::new(AnomalyKind::JournalRecoverable {
1670                    images: images.len(),
1671                }));
1672            }
1673        }
1674    }
1675
1676    // Checksum mismatches (Tier A only — Tier B's checksum_valid is None).
1677    let bad: Vec<u32> = images
1678        .iter()
1679        .filter(|i| i.checksum_valid == Some(false))
1680        .map(|i| i.pgno)
1681        .collect();
1682    if !bad.is_empty() {
1683        out.push(Anomaly::new(AnomalyKind::JournalChecksumMismatch {
1684            pgnos: bad,
1685            total: images.len(),
1686        }));
1687    }
1688
1689    // A DDL change is signalled by the schema cookie (file-header offset 40, BE)
1690    // advancing — NOT merely by page 1 being journaled. Page 1 carries the
1691    // change-counter / freelist-count / db-size header fields that update on
1692    // nearly every write, so it is present in almost every rollback journal; only
1693    // a cookie that differs between the journal's prior page-1 image and the live
1694    // database indicates CREATE/DROP/ALTER. Read both cookies defensively: if the
1695    // page-1 image is absent or the live page 1 is unreadable, do not fire.
1696    //
1697    // Caveat: for an UNCOMMITTED hot journal the live main-db file may not yet
1698    // carry the new cookie, so a DDL in a hot journal can be undetectable here.
1699    // That is acceptable — the detector never false-positives; it just cannot
1700    // always observe the committed-cookie case mid-flight.
1701    if let (Some(journal_cookie), Some(db_cookie)) = (
1702        images
1703            .iter()
1704            .find(|i| i.pgno == 1)
1705            .and_then(|i| schema_cookie(&i.bytes)),
1706        db.raw_page(1).and_then(schema_cookie),
1707    ) {
1708        if journal_cookie != db_cookie {
1709            out.push(Anomaly::new(AnomalyKind::JournalSchemaChange {
1710                journal_cookie,
1711                db_cookie,
1712            }));
1713        }
1714    }
1715
1716    // A page journaled more than once (the spec forbids it).
1717    if parsed.has_duplicate_pgno() {
1718        out.push(Anomaly::new(AnomalyKind::JournalDuplicatePage {
1719            pgnos: parsed.duplicate_pgnos().to_vec(),
1720        }));
1721    }
1722
1723    out
1724}
1725
1726/// Audit a rollback `-journal` and convert each design-§6 observation to the
1727/// canonical [`Finding`] under `source`, ready to merge into a `Report`. The
1728/// additive counterpart to [`audit_findings`] for the journal sidecar.
1729#[must_use]
1730pub fn audit_journal_findings(db: &Database, journal: &[u8], source: &Source) -> Vec<Finding> {
1731    audit_journal(db, journal)
1732        .into_iter()
1733        .map(|a| a.to_finding(source.clone()))
1734        .collect()
1735}
1736
1737/// Carve deleted records and convert each to a canonical [`Finding`] under
1738/// `source`. The per-record confidence is threaded into the finding's context so
1739/// downstream consumers can filter low-confidence recoveries.
1740#[must_use]
1741pub fn audit_carved_findings(db: &Database, column_count: usize, source: &Source) -> Vec<Finding> {
1742    carve_deleted_records(db, column_count)
1743        .into_iter()
1744        .map(|rec| {
1745            Anomaly::new(AnomalyKind::DeletedRecordRecovered {
1746                page: rec.page,
1747                offset: rec.offset,
1748                rowid: rec.rowid,
1749            })
1750            .with_confidence(rec.confidence)
1751            .to_finding(source.clone())
1752        })
1753        .collect()
1754}
1755
1756// ---------------------------------------------------------------------------
1757// Table attribution: reattach a carved deleted row to its source table in three
1758// honest tiers (CERTAIN / INFERRED / UNATTRIBUTED).
1759// ---------------------------------------------------------------------------
1760
1761/// How confidently a carved deleted row has been reattached to a live table.
1762///
1763/// The three tiers encode distinct epistemic strengths (observed fact / forensic
1764/// inference / unattributed), and the renderer keeps them honest: a Tier-2 guess
1765/// is "consistent with" a table, never asserted as the table.
1766#[derive(Debug, Clone, PartialEq, Eq)]
1767pub enum Attribution {
1768    /// **Tier 1 — CERTAIN.** The record was carved from a page still part of a
1769    /// live table's b-tree, so the owning table is known for sure. Carries that
1770    /// table's name.
1771    Known(String),
1772    /// **Tier 2 — INFERRED.** The whole page was freed (linkage cut); the
1773    /// record's *shape* (column count + per-column affinity) matched one or more
1774    /// surviving tables. A forensic inference, never asserted as fact.
1775    Inferred {
1776        /// The candidate table whose shape the record matches.
1777        guess: String,
1778        /// `true` when more than one surviving table matched the shape, so the
1779        /// guess is one of several equally-consistent candidates.
1780        ambiguous: bool,
1781    },
1782    /// **Tier 3 — UNATTRIBUTED.** Dropped-table residue, or a shape matching no
1783    /// surviving table.
1784    Unattributed,
1785}
1786
1787/// Whether a carved value is **storage-compatible** with a column of the given
1788/// declared affinity — the per-cell test the shape matcher applies.
1789///
1790/// Rules (permissive enough to survive `SQLite`'s numeric coercions, strict
1791/// enough to discriminate tables of equal arity but different type layout):
1792/// a NULL is compatible with anything; INTEGER/REAL are mutually compatible and
1793/// also satisfy NUMERIC; TEXT requires a text-leaning affinity (TEXT) or the
1794/// catch-all BLOB/NUMERIC; a BLOB requires BLOB. The BLOB affinity (or a column
1795/// with no declared type) accepts any value, matching SQLite's "no datatype"
1796/// semantics.
1797#[must_use]
1798pub fn value_fits_affinity(value: &Value, affinity: sqlite_core::attribution::Affinity) -> bool {
1799    use sqlite_core::attribution::Affinity;
1800    // BLOB affinity (incl. no-declared-type columns) is SQLite's catch-all: a
1801    // value of any storage class can live there.
1802    if affinity == Affinity::Blob {
1803        return true;
1804    }
1805    match value {
1806        // A NULL fits any column.
1807        Value::Null => true,
1808        Value::Integer(_) | Value::Real(_) => matches!(
1809            affinity,
1810            Affinity::Integer | Affinity::Real | Affinity::Numeric
1811        ),
1812        // NUMERIC accepts text too (SQLite stores a non-numeric string as text in
1813        // a NUMERIC column), so a TEXT value is consistent with TEXT or NUMERIC.
1814        Value::Text(_) => matches!(affinity, Affinity::Text | Affinity::Numeric),
1815        // A BLOB only sits in a BLOB column (handled above) — never a typed one.
1816        Value::Blob(_) => false,
1817    }
1818}
1819
1820/// Whether a carved record's values match a live table's shape: equal column
1821/// count AND every value storage-compatible with the corresponding column
1822/// affinity ([`value_fits_affinity`]).
1823#[must_use]
1824pub fn shape_matches(values: &[Value], table: &sqlite_core::attribution::LiveTable) -> bool {
1825    if values.len() != table.affinities.len() {
1826        return false;
1827    }
1828    values
1829        .iter()
1830        .zip(&table.affinities)
1831        .all(|(v, &aff)| value_fits_affinity(v, aff))
1832}
1833
1834/// The names of every live table whose shape a record's values match, in the
1835/// order the tables were listed. Zero matches → unattributable shape; one →
1836/// a clean guess; more than one → ambiguous.
1837#[must_use]
1838pub fn matching_tables(
1839    values: &[Value],
1840    tables: &[sqlite_core::attribution::LiveTable],
1841) -> Vec<String> {
1842    tables
1843        .iter()
1844        .filter(|t| shape_matches(values, t))
1845        .map(|t| t.name.clone())
1846        .collect()
1847}
1848
1849/// Attribute one carved record to a tier given the live tables and the
1850/// page→table map. Pure (no DB access) so it is directly unit-testable.
1851///
1852/// - Tier 1 (CERTAIN): `source` is an in-page class ([`RecoverySource::InPageFreeBlock`]
1853///   or [`RecoverySource::FreeblockReconstructed`]) AND the record's page is in
1854///   `page_table_map` → [`Attribution::Known`].
1855/// - Tier 2 (INFERRED): `source` is [`RecoverySource::FreelistPage`] → match the
1856///   record's shape against `tables`; exactly one match → a guess, more than one
1857///   → ambiguous, none → [`Attribution::Unattributed`].
1858/// - Tier 3 (UNATTRIBUTED): [`RecoverySource::DroppedTable`], any other source,
1859///   or a shape matching no surviving table.
1860#[must_use]
1861pub fn attribute_record(
1862    rec: &CarvedRecord,
1863    tables: &[sqlite_core::attribution::LiveTable],
1864    page_table_map: &std::collections::BTreeMap<u32, String>,
1865) -> Attribution {
1866    // Tier 1 — CERTAIN: an in-page record on a page still owned by a live
1867    // table's b-tree. The page→table map is the hard linkage.
1868    if matches!(
1869        rec.source,
1870        RecoverySource::InPageFreeBlock | RecoverySource::FreeblockReconstructed
1871    ) {
1872        if let Some(name) = page_table_map.get(&rec.page) {
1873            return Attribution::Known(name.clone());
1874        }
1875        // In-page residue on a page that is NOT a live-table page (e.g. the
1876        // owning table itself was freed): no certain linkage, and in-page is not
1877        // the freelist class, so it is unattributed rather than inferred.
1878        return Attribution::Unattributed;
1879    }
1880
1881    // Tier 2 — INFERRED: a whole-page freelist record; the linkage is cut, so
1882    // attribute by shape against the surviving tables.
1883    if rec.source == RecoverySource::FreelistPage {
1884        let mut matches = matching_tables(&rec.values, tables);
1885        return match matches.len() {
1886            0 => Attribution::Unattributed,
1887            1 => Attribution::Inferred {
1888                guess: matches.remove(0),
1889                ambiguous: false,
1890            },
1891            _ => Attribution::Inferred {
1892                guess: matches.remove(0),
1893                ambiguous: true,
1894            },
1895        };
1896    }
1897
1898    // Tier 3 — UNATTRIBUTED: dropped-table residue or any other source.
1899    Attribution::Unattributed
1900}
1901
1902/// Attribute every carved record, reading the live tables and page→table map
1903/// from `db` once. The returned vector is parallel to `records`.
1904#[must_use]
1905pub fn attribute_records(db: &Database, records: &[CarvedRecord]) -> Vec<Attribution> {
1906    let tables = db.live_tables();
1907    let page_table_map = db.page_to_table_map();
1908    records
1909        .iter()
1910        .map(|rec| attribute_record(rec, &tables, &page_table_map))
1911        .collect()
1912}
1913
1914/// A **non-overclaiming diagnostic HINT** about a recovered record's relationship
1915/// to the *instance* of the table it was attributed to — ORTHOGONAL to
1916/// [`Attribution`] (it never changes the tier, the routing, or the table claim).
1917///
1918/// Detector A (`docs/design/drop-recreate-attribution.md`): when a record
1919/// attributed `Known(T)` has a rowid exceeding `T`'s `AUTOINCREMENT`
1920/// `sqlite_sequence` high-water mark, that is *consistent with* residue from a
1921/// prior incarnation of `T` (a drop-recreate reset `sqlite_sequence`) — but it is
1922/// **not proof**: the Codex review confirmed `rowid > seq` is equally reachable by
1923/// an `UPDATE` of the rowid, a manual `sqlite_sequence` edit, or a current-instance
1924/// deletion. So the flag is surfaced as a hint that names its evidence, and the
1925/// examiner draws the conclusion. The `note` never asserts "predecessor".
1926///
1927/// `#[non_exhaustive]` so the documented follow-up `SidecarSchemaChanged` variant
1928/// (Detector B — a sidecar `-wal`/`-journal` DDL boundary) can be added without a
1929/// breaking change.
1930#[derive(Debug, Clone, PartialEq, Eq)]
1931#[non_exhaustive]
1932pub enum TableInstanceRisk {
1933    /// No instance-provenance hint applies to this record.
1934    None,
1935    /// The record was attributed `Known(table)`, `table` is `AUTOINCREMENT`, has a
1936    /// `sqlite_sequence` entry `seq`, and the record's `rowid` exceeds it. The
1937    /// current instance never assigned a rowid above `seq` via `INSERT`, so this is
1938    /// *consistent with* prior-incarnation residue — and equally with an `UPDATE`
1939    /// to the rowid, a `sqlite_sequence` edit, or a current-instance deletion.
1940    RowidExceedsAutoincHighwater {
1941        /// The live table the record was attributed to.
1942        table: String,
1943        /// The record's recovered rowid (the value exceeding the high-water mark).
1944        rowid: i64,
1945        /// The table's `AUTOINCREMENT` high-water mark (`sqlite_sequence.seq`).
1946        seq: i64,
1947    },
1948    /// Detector B (`docs/design/drop-recreate-attribution.md`): the record was
1949    /// attributed `Known(table)`, a `-wal`/`-journal` sidecar is in play, and the
1950    /// sidecar's PRIOR `sqlite_master` for `table` is **absent** OR carries a
1951    /// **different CREATE SQL text** than the current schema — an UNAMBIGUOUS
1952    /// table-level schema change within the captured window. It is *consistent
1953    /// with* a `CREATE`/`ALTER` or a drop+recreate of `table`, so residue
1954    /// attributed to `table` may predate the current schema. It is **table-level**,
1955    /// NOT row-level provenance, and deliberately does NOT fire on a rootpage move
1956    /// with identical CREATE SQL (a `VACUUM`), a schema-cookie advance alone, or a
1957    /// same-schema drop+recreate (indistinguishable from a benign page move).
1958    SidecarSchemaChanged {
1959        /// The live table whose sidecar prior schema differs from the current.
1960        table: String,
1961    },
1962}
1963
1964impl TableInstanceRisk {
1965    /// A human, evidence-bearing note for the examiner — or `""` for
1966    /// [`TableInstanceRisk::None`]. The wording deliberately states what the
1967    /// observation is *consistent with* and lists the equally-consistent benign
1968    /// explanations; it never asserts the row is a predecessor.
1969    #[must_use]
1970    pub fn note(&self) -> String {
1971        match self {
1972            Self::None => String::new(),
1973            Self::RowidExceedsAutoincHighwater { table, rowid, seq } => format!(
1974                "rowid {rowid} exceeds table {table}'s AUTOINCREMENT high-water mark \
1975                 (sqlite_sequence={seq}) — consistent with residue from a prior incarnation of \
1976                 this table, but also explainable by an UPDATE to the rowid, a sqlite_sequence \
1977                 edit, or a current-instance deletion; the examiner should cross-check the RowID \
1978                 and any WAL/journal schema history."
1979            ),
1980            Self::SidecarSchemaChanged { table } => format!(
1981                "the schema for table {table} differs between the sidecar's (-wal/-journal) prior \
1982                 state and the current database (present-with-different-SQL or absent in the prior) \
1983                 — consistent with a CREATE/ALTER or drop+recreate within the captured window; \
1984                 residue attributed to {table} may predate the current schema, so reconcile against \
1985                 the prior schema. (A same-schema drop+recreate is indistinguishable from a benign \
1986                 page move and does NOT raise this.)"
1987            ),
1988        }
1989    }
1990
1991    /// A short, stable provenance token for column/field output (`null`-like for
1992    /// [`TableInstanceRisk::None`] → empty string), carrying the evidence inline:
1993    /// `rowid_exceeds_autoinc_highwater(r=…,seq=…)`.
1994    #[must_use]
1995    pub fn token(&self) -> String {
1996        match self {
1997            Self::None => String::new(),
1998            Self::RowidExceedsAutoincHighwater { rowid, seq, .. } => {
1999                format!("rowid_exceeds_autoinc_highwater(r={rowid},seq={seq})")
2000            }
2001            Self::SidecarSchemaChanged { table } => {
2002                format!("sidecar_schema_changed({table})")
2003            }
2004        }
2005    }
2006}
2007
2008/// Per-record [`TableInstanceRisk`], parallel to `records` and their
2009/// `attributions` — the Detector-A diagnostic pass.
2010///
2011/// A record trips [`TableInstanceRisk::RowidExceedsAutoincHighwater`] **only** when
2012/// ALL hold: it is `Attribution::Known(table)`; `table` is an `AUTOINCREMENT`
2013/// rowid table ([`sqlite_core::is_autoincrement`] on its `CREATE TABLE`); `table`
2014/// has a `sqlite_sequence` entry `seq` ([`Database::sqlite_sequence`]); and
2015/// `rec.rowid > seq`. Every other record is [`TableInstanceRisk::None`]. This is
2016/// orthogonal to attribution — it reads the same inputs but never alters the
2017/// attribution, tier, or routing.
2018#[must_use]
2019pub fn table_instance_risks(
2020    db: &Database,
2021    records: &[CarvedRecord],
2022    attributions: &[Attribution],
2023) -> Vec<TableInstanceRisk> {
2024    // The set of AUTOINCREMENT table names, from the live schema's CREATE sql.
2025    let autoinc: std::collections::BTreeSet<String> = db
2026        .live_tables()
2027        .into_iter()
2028        .filter(|t| sqlite_core::is_autoincrement(&t.create_sql))
2029        .map(|t| t.name)
2030        .collect();
2031    let sequences = db.sqlite_sequence();
2032
2033    records
2034        .iter()
2035        .zip(attributions)
2036        .map(|(rec, attr)| {
2037            let Attribution::Known(table) = attr else {
2038                return TableInstanceRisk::None;
2039            };
2040            if !autoinc.contains(table) {
2041                return TableInstanceRisk::None;
2042            }
2043            match sequences.get(table) {
2044                Some(&seq) if rec.rowid > seq => TableInstanceRisk::RowidExceedsAutoincHighwater {
2045                    table: table.clone(),
2046                    rowid: rec.rowid,
2047                    seq,
2048                },
2049                _ => TableInstanceRisk::None,
2050            }
2051        })
2052        .collect()
2053}
2054
2055/// The set of live tables whose CURRENT `CREATE TABLE` SQL differs from a sidecar
2056/// PRIOR `sqlite_master` — Detector B's UNAMBIGUOUS schema-change predicate
2057/// (`docs/design/drop-recreate-attribution.md`).
2058///
2059/// A table `T` is schema-changed when it is present in `current` and the prior
2060/// snapshot either (a) **lacks** `T` (absent in the prior `sqlite_master`) or
2061/// (b) carries `T` with a **different CREATE SQL text**. A table whose prior SQL
2062/// is byte-identical (the DML-only case, and the same-schema drop+recreate the
2063/// design refuses to claim) is NOT included — that is the anti-false-positive
2064/// boundary. An EMPTY `prior_schema` (no sidecar in play) yields the empty set, so
2065/// Detector B is silent unless a sidecar genuinely captured a different schema.
2066fn sidecar_schema_changed_tables(
2067    current: &std::collections::BTreeMap<String, String>,
2068    prior_schema: &std::collections::BTreeMap<String, String>,
2069) -> std::collections::BTreeSet<String> {
2070    // No sidecar (empty prior) ⟹ no Detector-B signal at all. Guarding here keeps
2071    // the "when in doubt, do NOT fire" rule explicit rather than implicit.
2072    if prior_schema.is_empty() {
2073        return std::collections::BTreeSet::new();
2074    }
2075    current
2076        .iter()
2077        .filter(|(name, current_sql)| match prior_schema.get(*name) {
2078            // Present in the prior with a DIFFERENT CREATE SQL ⟹ schema changed.
2079            Some(prior_sql) => prior_sql != *current_sql,
2080            // Absent in the prior (the table did not exist) ⟹ created since.
2081            None => true,
2082        })
2083        .map(|(name, _)| name.clone())
2084        .collect()
2085}
2086
2087/// Per-record [`TableInstanceRisk`] over BOTH Detector A (bare AUTOINCREMENT
2088/// high-water) AND Detector B (sidecar schema-change), the sidecar-aware pass the
2089/// CLI composes when a `-wal`/`-journal` is in play
2090/// (`docs/design/drop-recreate-attribution.md`).
2091///
2092/// `prior_schema` is the sidecar's PRIOR `sqlite_master` as `name -> CREATE SQL`
2093/// ([`sqlite_core::PriorSnapshot::schema_sql`] for a `-journal`;
2094/// [`Database::schema_sql`] of the pre-WAL base bytes for a `-wal`). Pass an EMPTY
2095/// map to run Detector A only (the no-sidecar path), so this is a strict superset
2096/// of [`table_instance_risks`].
2097///
2098/// Detector B trips [`TableInstanceRisk::SidecarSchemaChanged`] for a record that
2099/// is `Attribution::Known(table)` whose `table` has a sidecar schema change —
2100/// prior absent, or prior CREATE SQL differs from current.
2101/// **Precedence:** Detector A wins where it fires — its rowid+seq evidence is more
2102/// specific than B's table-level boundary — so a record qualifying for both is
2103/// surfaced as `RowidExceedsAutoincHighwater`; Detector B fills only records A left
2104/// as [`TableInstanceRisk::None`]. Neither alters attribution, tier, or routing.
2105#[must_use]
2106pub fn table_instance_risks_with_sidecar(
2107    db: &Database,
2108    records: &[CarvedRecord],
2109    attributions: &[Attribution],
2110    prior_schema: &std::collections::BTreeMap<String, String>,
2111) -> Vec<TableInstanceRisk> {
2112    let mut risks = table_instance_risks(db, records, attributions);
2113    let changed = sidecar_schema_changed_tables(&db.schema_sql(), prior_schema);
2114    if changed.is_empty() {
2115        return risks; // no sidecar schema-change signal ⟹ Detector A result unchanged.
2116    }
2117    for (risk, attr) in risks.iter_mut().zip(attributions) {
2118        // Detector A takes precedence: only fill records A left as None.
2119        if *risk != TableInstanceRisk::None {
2120            continue;
2121        }
2122        let Attribution::Known(table) = attr else {
2123            continue;
2124        };
2125        if changed.contains(table) {
2126            *risk = TableInstanceRisk::SidecarSchemaChanged {
2127                table: table.clone(),
2128            };
2129        }
2130    }
2131    risks
2132}
2133
2134/// The core per-rowid VERSION HISTORY ([`Database::row_histories`]) augmented with
2135/// free-space CARVED RESIDUE — the forensic-layer completion of Phase 1.
2136///
2137/// Carved residue is ORDER-UNKNOWN: a freeblock persists across commits, so
2138/// [`carve_at_commit`] / [`carve_with_fragments`] tag where residue was OBSERVED,
2139/// not where the row was deleted. Each carved record is therefore emitted as a
2140/// [`CarvedResidue`](sqlite_core::row_history::VersionOrigin::CarvedResidue) /
2141/// [`CarvedResidue`](sqlite_core::row_history::ViewState::CarvedResidue) version with
2142/// `commit_seq: None` (never a fabricated commit position), `is_deleted: true`,
2143/// and `is_guessed` set when its [`Attribution`] is `Inferred`. Residue is
2144/// attributed to a table via [`attribute_records`] and DEDUPED against any WAL
2145/// `AbsentInFinalView` version of the same rowid + values (the WAL holds it at
2146/// higher fidelity), so a row is never double-listed.
2147#[must_use]
2148pub fn row_histories_with_residue(db: &Database) -> Vec<sqlite_core::row_history::TableHistory> {
2149    use sqlite_core::row_history::{RowVersion, VersionOrigin, ViewState};
2150
2151    let mut histories = db.row_histories();
2152
2153    // Gather every carved residue record: the on-disk free space (Tier-1 full
2154    // rows) plus each materialized commit snapshot of the WAL. carve_at_commit /
2155    // carve_with_fragments already de-duplicate within their own pass; we collect
2156    // across passes and de-duplicate by content identity below.
2157    let mut records: Vec<CarvedRecord> = carve_with_fragments(db).full;
2158    if let Some(timeline) = db.wal_timeline() {
2159        for snapshot in timeline.commit_snapshots() {
2160            records.extend(carve_at_commit(db, &timeline, snapshot.id()));
2161        }
2162    }
2163    // Collapse byte-identical carves (a row can recur across commits / regions).
2164    let records = dedup_keep_best(records, &db.page_to_table_map());
2165
2166    // Attribute each carved record to a table. The strongest signal is page
2167    // linkage: a record carved from a page still owned by a live table's b-tree
2168    // (e.g. a PRIOR-version remnant in that table's slack) belongs to THAT table
2169    // for certain — stronger than, and independent of, the source-class tiering in
2170    // `attribute_record`. We consult the page→table map first, then fall back to
2171    // the shape-based `attribute_records` for off-table (freelist-page) residue.
2172    let page_table_map = db.page_to_table_map();
2173    let attributions = attribute_records(db, &records);
2174
2175    // A residue carve is deduped against a WAL `AbsentInFinalView` version of the
2176    // SAME rowid + values already in that table's history (the WAL holds it at
2177    // higher fidelity). Build the set of those (table, rowid, values) keys.
2178    let absent_keys: std::collections::HashSet<String> = histories
2179        .iter()
2180        .flat_map(|h| {
2181            h.versions
2182                .iter()
2183                .filter(|v| v.view_state == ViewState::AbsentInFinalView)
2184                .map(move |v| residue_key(&h.table, v.rowid, &v.values))
2185        })
2186        .collect();
2187
2188    // Group new residue versions by target table name.
2189    let mut to_add: std::collections::BTreeMap<String, Vec<RowVersion>> =
2190        std::collections::BTreeMap::new();
2191    for (rec, attr) in records.iter().zip(attributions.iter()) {
2192        // Page linkage (CERTAIN) wins; else the shape-based tier.
2193        let (table, is_guessed) = if let Some(name) = page_table_map.get(&rec.page) {
2194            (name.clone(), false)
2195        } else {
2196            match attr {
2197                Attribution::Known(name) => (name.clone(), false),
2198                Attribution::Inferred { guess, .. } => (guess.clone(), true),
2199                // No surviving table to attach the residue to — skip rather than
2200                // invent a home for it (no fabricated attribution).
2201                Attribution::Unattributed => continue,
2202            }
2203        };
2204        // A clobbered rowid (carving surfaces it as 0) is genuinely unknown.
2205        let rowid = if rec.rowid == 0 {
2206            None
2207        } else {
2208            Some(rec.rowid)
2209        };
2210        let key = residue_key(&table, rowid, &rec.values);
2211        if absent_keys.contains(&key) {
2212            continue; // already listed as a higher-fidelity WAL AbsentInFinalView
2213        }
2214        to_add.entry(table).or_default().push(RowVersion {
2215            rowid,
2216            values: rec.values.clone(),
2217            origin: VersionOrigin::CarvedResidue,
2218            // Order-unknown: a freeblock persists across commits, so a carve has
2219            // no trustworthy commit position. NEVER fabricate one.
2220            commit_seq: None,
2221            view_state: ViewState::CarvedResidue,
2222            is_deleted: true,
2223            is_guessed,
2224            rowid_reused: false,
2225            // An inferred (shape-matched) attribution is uncertain by nature.
2226            attribution_uncertain: is_guessed,
2227        });
2228    }
2229
2230    // Merge the residue versions into their tables, de-duplicating residue that is
2231    // byte-identical to a version already present, then re-sort.
2232    for h in &mut histories {
2233        if let Some(extra) = to_add.remove(&h.table) {
2234            let existing: std::collections::HashSet<String> = h
2235                .versions
2236                .iter()
2237                .map(|v| residue_key(&h.table, v.rowid, &v.values))
2238                .collect();
2239            for v in extra {
2240                if existing.contains(&residue_key(&h.table, v.rowid, &v.values)) {
2241                    continue;
2242                }
2243                h.versions.push(v);
2244            }
2245            sqlite_core::row_history::sort_table_versions(&mut h.versions);
2246        }
2247    }
2248    histories
2249}
2250
2251/// A content-identity key for residue de-duplication: table + rowid + a stable
2252/// `Debug` rendering of the values (`Value` carries an `f64`, so it is not
2253/// `Hash`/`Eq` directly).
2254fn residue_key(table: &str, rowid: Option<i64>, values: &[Value]) -> String {
2255    format!("{table}:{rowid:?}:{values:?}")
2256}
2257
2258#[cfg(test)]
2259mod structural_noise_tests {
2260    use super::is_structural_noise;
2261    use sqlite_core::Value;
2262
2263    #[test]
2264    fn rejects_rowid_echo_with_all_null_tail() {
2265        // The inferred over-read: a rowid echoed as the INTEGER-PK first column
2266        // followed by a long serial-type-0 (NULL) tail — no recoverable content.
2267        let mut overwide = vec![Value::Null; 102];
2268        overwide[0] = Value::Integer(14);
2269        assert!(is_structural_noise(&overwide));
2270    }
2271
2272    #[test]
2273    fn rejects_all_null_record() {
2274        assert!(is_structural_noise(&[
2275            Value::Null,
2276            Value::Null,
2277            Value::Null
2278        ]));
2279    }
2280
2281    #[test]
2282    fn keeps_ordinary_recovered_row() {
2283        let good = vec![
2284            Value::Integer(3),
2285            Value::Text("Bowl".into()),
2286            Value::Real(11.23),
2287            Value::Null,
2288        ];
2289        assert!(!is_structural_noise(&good));
2290    }
2291
2292    #[test]
2293    fn keeps_dropped_table_row_wider_than_any_live_table() {
2294        // A dropped table can be wider than every surviving live table; such an
2295        // unattributed record carries real values and MUST be kept (a width cap
2296        // would discard it — the regression this guards).
2297        let wide_real = vec![
2298            Value::Integer(7),
2299            Value::Text("secret".into()),
2300            Value::Integer(42),
2301            Value::Text("more".into()),
2302        ];
2303        assert!(!is_structural_noise(&wide_real));
2304    }
2305
2306    #[test]
2307    fn keeps_single_column_row() {
2308        // A genuine one-column row is not noise (length < 2 short-circuits).
2309        assert!(!is_structural_noise(&[Value::Text("solo".into())]));
2310    }
2311}
2312
2313#[cfg(test)]
2314mod attribution_tests {
2315    use super::{
2316        attribute_record, attribute_records, matching_tables, shape_matches, value_fits_affinity,
2317        Attribution, CarvedRecord, RecoverySource,
2318    };
2319    use sqlite_core::attribution::{Affinity, LiveTable};
2320    use sqlite_core::Value;
2321
2322    fn table(name: &str, root: u32, affinities: Vec<Affinity>) -> LiveTable {
2323        LiveTable {
2324            name: name.to_string(),
2325            rootpage: root,
2326            column_names: None,
2327            affinities,
2328            create_sql: String::new(),
2329        }
2330    }
2331
2332    fn rec(page: u32, source: RecoverySource, values: Vec<Value>) -> CarvedRecord {
2333        CarvedRecord {
2334            page,
2335            offset: 0,
2336            rowid: 1,
2337            values,
2338            confidence: 0.9,
2339            allocated: false,
2340            source,
2341            wal: None,
2342            overflow: None,
2343        }
2344    }
2345
2346    #[test]
2347    fn value_affinity_compatibility() {
2348        assert!(value_fits_affinity(&Value::Null, Affinity::Integer));
2349        assert!(value_fits_affinity(&Value::Integer(1), Affinity::Integer));
2350        assert!(value_fits_affinity(&Value::Integer(1), Affinity::Real));
2351        assert!(value_fits_affinity(&Value::Real(1.0), Affinity::Numeric));
2352        assert!(value_fits_affinity(
2353            &Value::Text("x".into()),
2354            Affinity::Text
2355        ));
2356        assert!(value_fits_affinity(&Value::Blob(vec![1]), Affinity::Blob));
2357        // A text value does NOT fit an integer column.
2358        assert!(!value_fits_affinity(
2359            &Value::Text("x".into()),
2360            Affinity::Integer
2361        ));
2362        // A blob does not fit a text column.
2363        assert!(!value_fits_affinity(&Value::Blob(vec![1]), Affinity::Text));
2364        // BLOB (or no-type) affinity is the catch-all: any value fits.
2365        assert!(value_fits_affinity(
2366            &Value::Text("x".into()),
2367            Affinity::Blob
2368        ));
2369        assert!(value_fits_affinity(&Value::Integer(1), Affinity::Blob));
2370    }
2371
2372    #[test]
2373    fn shape_match_requires_equal_arity_and_compatible_cells() {
2374        let t = table("people", 2, vec![Affinity::Integer, Affinity::Text]);
2375        assert!(shape_matches(
2376            &[Value::Integer(1), Value::Text("a".into())],
2377            &t
2378        ));
2379        // Wrong arity.
2380        assert!(!shape_matches(&[Value::Integer(1)], &t));
2381        // Incompatible cell (text in an integer column).
2382        assert!(!shape_matches(
2383            &[Value::Text("a".into()), Value::Text("b".into())],
2384            &t
2385        ));
2386    }
2387
2388    #[test]
2389    fn clean_single_match() {
2390        let tables = vec![
2391            table("people", 2, vec![Affinity::Integer, Affinity::Text]),
2392            table("amounts", 3, vec![Affinity::Real, Affinity::Real]),
2393        ];
2394        let m = matching_tables(&[Value::Integer(1), Value::Text("a".into())], &tables);
2395        assert_eq!(m, vec!["people"]);
2396    }
2397
2398    #[test]
2399    fn ambiguous_two_tables_same_shape() {
2400        let tables = vec![
2401            table("a", 2, vec![Affinity::Integer, Affinity::Text]),
2402            table("b", 3, vec![Affinity::Integer, Affinity::Text]),
2403        ];
2404        let m = matching_tables(&[Value::Integer(1), Value::Text("x".into())], &tables);
2405        assert_eq!(m, vec!["a", "b"]);
2406    }
2407
2408    #[test]
2409    fn no_match_is_unattributed_shape() {
2410        let tables = vec![table("a", 2, vec![Affinity::Integer, Affinity::Text])];
2411        let m = matching_tables(&[Value::Blob(vec![1]), Value::Blob(vec![2])], &tables);
2412        assert!(m.is_empty());
2413    }
2414
2415    #[test]
2416    fn tier1_inpage_page_in_map_is_known() {
2417        let tables = vec![table("people", 2, vec![Affinity::Integer, Affinity::Text])];
2418        let mut map = std::collections::BTreeMap::new();
2419        map.insert(5u32, "people".to_string());
2420        let r = rec(
2421            5,
2422            RecoverySource::InPageFreeBlock,
2423            vec![Value::Integer(1), Value::Text("a".into())],
2424        );
2425        assert_eq!(
2426            attribute_record(&r, &tables, &map),
2427            Attribution::Known("people".to_string())
2428        );
2429    }
2430
2431    #[test]
2432    fn tier1_freeblock_reconstructed_in_map_is_known() {
2433        let tables = vec![table("people", 2, vec![Affinity::Integer, Affinity::Text])];
2434        let mut map = std::collections::BTreeMap::new();
2435        map.insert(7u32, "people".to_string());
2436        let r = rec(7, RecoverySource::FreeblockReconstructed, vec![Value::Null]);
2437        assert_eq!(
2438            attribute_record(&r, &tables, &map),
2439            Attribution::Known("people".to_string())
2440        );
2441    }
2442
2443    #[test]
2444    fn tier2_freelist_single_match_is_inferred_unambiguous() {
2445        let tables = vec![
2446            table("people", 2, vec![Affinity::Integer, Affinity::Text]),
2447            table("amounts", 3, vec![Affinity::Real, Affinity::Real]),
2448        ];
2449        let map = std::collections::BTreeMap::new();
2450        let r = rec(
2451            9,
2452            RecoverySource::FreelistPage,
2453            vec![Value::Integer(1), Value::Text("a".into())],
2454        );
2455        assert_eq!(
2456            attribute_record(&r, &tables, &map),
2457            Attribution::Inferred {
2458                guess: "people".to_string(),
2459                ambiguous: false
2460            }
2461        );
2462    }
2463
2464    #[test]
2465    fn tier2_freelist_multi_match_is_ambiguous() {
2466        let tables = vec![
2467            table("a", 2, vec![Affinity::Integer, Affinity::Text]),
2468            table("b", 3, vec![Affinity::Integer, Affinity::Text]),
2469        ];
2470        let map = std::collections::BTreeMap::new();
2471        let r = rec(
2472            9,
2473            RecoverySource::FreelistPage,
2474            vec![Value::Integer(1), Value::Text("x".into())],
2475        );
2476        assert_eq!(
2477            attribute_record(&r, &tables, &map),
2478            Attribution::Inferred {
2479                guess: "a".to_string(),
2480                ambiguous: true
2481            }
2482        );
2483    }
2484
2485    #[test]
2486    fn tier2_freelist_no_match_is_unattributed() {
2487        let tables = vec![table("a", 2, vec![Affinity::Integer, Affinity::Text])];
2488        let map = std::collections::BTreeMap::new();
2489        let r = rec(
2490            9,
2491            RecoverySource::FreelistPage,
2492            vec![Value::Blob(vec![1]), Value::Blob(vec![2])],
2493        );
2494        assert_eq!(
2495            attribute_record(&r, &tables, &map),
2496            Attribution::Unattributed
2497        );
2498    }
2499
2500    #[test]
2501    fn tier3_dropped_table_is_unattributed() {
2502        let tables = vec![table("a", 2, vec![Affinity::Integer, Affinity::Text])];
2503        let map = std::collections::BTreeMap::new();
2504        let r = rec(
2505            9,
2506            RecoverySource::DroppedTable,
2507            vec![Value::Integer(1), Value::Text("x".into())],
2508        );
2509        assert_eq!(
2510            attribute_record(&r, &tables, &map),
2511            Attribution::Unattributed
2512        );
2513    }
2514
2515    #[test]
2516    fn tier1_inpage_page_not_in_map_falls_through_to_unattributed() {
2517        // An in-page record on a page that is NOT a live-table page (e.g. its
2518        // table was the one freed): no Tier-1 certainty, and in-page is not the
2519        // freelist class, so it is unattributed rather than inferred.
2520        let tables = vec![table("people", 2, vec![Affinity::Integer, Affinity::Text])];
2521        let map = std::collections::BTreeMap::new();
2522        let r = rec(
2523            42,
2524            RecoverySource::InPageFreeBlock,
2525            vec![Value::Integer(1), Value::Text("a".into())],
2526        );
2527        assert_eq!(
2528            attribute_record(&r, &tables, &map),
2529            Attribution::Unattributed
2530        );
2531    }
2532
2533    #[test]
2534    fn attribute_records_reads_schema_from_a_real_database() {
2535        // Mint a tiny valid db via the writer (no sqlite3 needed), open it, and
2536        // drive the db-backed attribute_records wrapper end to end.
2537        use sqlite_core::rebuild::{build_recovered_db_tables, RecoveredTable};
2538        use sqlite_core::Database;
2539        let seed = vec![RecoveredTable {
2540            name: "people".to_string(),
2541            columns: vec!["id".to_string(), "name".to_string()],
2542            rows: vec![vec![Value::Integer(1), Value::Text("a".into())]],
2543        }];
2544        let db = Database::open(build_recovered_db_tables(&seed)).expect("minted db opens");
2545        let records = vec![super::CarvedRecord {
2546            page: 99,
2547            offset: 0,
2548            rowid: 0,
2549            values: vec![Value::Integer(2), Value::Text("b".into())],
2550            confidence: 0.9,
2551            allocated: false,
2552            source: RecoverySource::FreelistPage,
2553            wal: None,
2554            overflow: None,
2555        }];
2556        let attrs = attribute_records(&db, &records);
2557        assert_eq!(attrs.len(), 1);
2558        // (INTEGER, TEXT) freelist row matches the people table's shape.
2559        assert!(matches!(attrs[0], Attribution::Inferred { .. }));
2560    }
2561}
2562
2563#[cfg(test)]
2564mod tests {
2565    use super::{dedup_fragments, CarvedFragment, RecoverySource};
2566    use sqlite_core::Value;
2567
2568    fn frag(page: u32, offset: usize, surviving: Vec<(usize, Value)>) -> CarvedFragment {
2569        let missing = 3usize.saturating_sub(surviving.len());
2570        CarvedFragment {
2571            page,
2572            offset,
2573            surviving,
2574            missing,
2575            confidence: 0.2,
2576            source: RecoverySource::InPageFreeBlock,
2577            wal: None,
2578        }
2579    }
2580
2581    /// `dedup_fragments` orders by survivor count (richest kept), drops a repeated
2582    /// `(page, offset)` anchor, and collapses a value-level duplicate at a fresh
2583    /// anchor — exercising both dedup arms and the survivor-count sort over a
2584    /// multi-element input (a single-element vector never invokes the comparator).
2585    #[test]
2586    fn dedup_keeps_richest_and_drops_duplicates() {
2587        let rich = frag(
2588            1,
2589            10,
2590            vec![(0, Value::Integer(1)), (1, Value::Text("a".into()))],
2591        );
2592        let poor_same_anchor = frag(1, 10, vec![(0, Value::Integer(1))]);
2593        let value_dup_new_anchor = frag(
2594            2,
2595            20,
2596            vec![(0, Value::Integer(1)), (1, Value::Text("a".into()))],
2597        );
2598
2599        let out = dedup_fragments(vec![poor_same_anchor, rich, value_dup_new_anchor]);
2600
2601        // The richest copy of the shared identity survives; the poorer same-anchor
2602        // copy and the value-identical fresh-anchor copy are both dropped.
2603        assert_eq!(
2604            out.len(),
2605            1,
2606            "duplicates collapse to the richest single copy"
2607        );
2608        assert_eq!(
2609            out[0].surviving.len(),
2610            2,
2611            "the 2-column copy is the one kept"
2612        );
2613    }
2614}