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