feat: Snapchat arroyo.db rows the WAL removes, with row provenance columns - #1053
Conversation
Follow-up to #1051. While validating that work I noticed the row count moves the wrong way: arroyo.db read on its own has 11 conversation_message rows, and reading it with its write-ahead log applied gives 8. The log carries removals, not just additions. Those rows are still in the database file as of its last checkpoint, and nothing surfaces them. Three of the eight decode to plaintext at the same protobuf path the committed artifact uses, and one whole conversation is present in conversation, feed_entry and conversation_identifier before the log is applied and gone after it, with a participant that does not appear in the Friend table in main.db. Adds two artifacts: Snapchat - Messages Not In Committed State (arroyo.db) 8 rows Snapchat - Conversations Not In Committed State (arroyo.db) 1 row Method is a two-view diff. The same file is opened twice through SQLite, once with immutable=1 which ignores the log, and once with mode=ro which applies it, and the results are compared on the primary key. SQLite does the decoding on both sides, so column names, type affinity and overflow pages come for free and there is no hand-written page parser to maintain. Comparing row counts instead of keys would have missed most of this. On the tested image counting flags 2 of 30 tables in arroyo.db while comparing primary keys flags 6, because four tables hold the same number of rows under different keys. The conversation finding is one of those four. This is not deleted-record carving. It does not touch freelist pages, unallocated space or freeblocks, and it does not parse WAL frames, so records that only ever lived inside the log are not recovered. The notes say so, and say that why a row did not survive is not established. It returns nothing when no -wal accompanies the database, verified by removing the sidecar and confirming both reads agree at 11. Neither artifact declares a conversation data view, deliberately, so these rows cannot render as chat messages in LAVA. A Record State column travels on every row into the TSV and LAVA exports. The immutable read is strictly read-only and, unlike mode=ro, does not even create a -shm sidecar. Confirmed against a lone copy of the database: no new files and the same MD5 afterwards. The diff helper is deliberately kept local to this module rather than put into ilapfuncs, so that issue #957 gets to decide the shared shape. Written so lifting it into core is a move rather than a rewrite. Reasoning posted there. Validated against corpus hc_pixel8pro_a17. The two artifacts from #1051 are refactored to share the row builders and still return 8 and 4 rows, and the committed and superseded message key sets do not intersect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the separate "Not In Committed State" artifacts with three provenance columns on the existing two, so live rows and rows the WAL removes sit in one table in chronological order. Record Origin Live | Recovered. Populated on every row. Recovery Method the technique. Empty on Live rows. Recovery Location where in the evidence the row came from. Empty on Live. Record Origin is a closed two-value set so a viewer can branch on it, and is populated on every row rather than left blank for live ones, because a blank cell is ambiguous between "live" and "not populated". Recovery Method is naturally empty on live rows, so whichever of the two an examiner picks to show under a LAVA conversation bubble, they get a sensible result: the first marks every message, the second marks only recovered ones. Naming checked against all five cores first rather than invented. Record Source was the obvious choice and is already taken, meaning "which table did this row come from" in allTrails and slack. Source is overloaded 51 times across the cores for source file or table. Recovery Status is taken in galleryVault for how many bytes of a decrypted file came back. Record Origin, Recovery Method and Recovery Location are unused anywhere. Deliberately not called Deleted. Seventeen ALEAPP artifacts already use Deleted, Is Deleted and Deleted At for the app's own deletion flag, and reusing that name for tool-recovery provenance would conflate "the app recorded this as deleted" with "our tool recovered this". Recovered says how the row was obtained, which is known, rather than what happened to it, which is not. The long explanation moved out of the per-row value and into the notes. The old value was a sentence repeated on every row, which is fine as prose and wrong as an enum. Counts on hc_pixel8pro_a17: messages 16 rows (8 Live, 8 Recovered), conversations 5 rows (4 Live, 1 Recovered). The two sets cannot overlap and the run confirms zero primary keys appearing under both origins. Rows sort chronologically across the merged set, which puts the recovered Feb to Jul rows between the live 2025 and live July 2026 messages. Combining also removes the timeline double-count the split design carried. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e it
Ships the WAL diff as the recovery this artifact does, and says plainly what
it does not do, with the numbers behind it.
Checked where each field actually surfaces before relying on it, which
changed the plan:
description reaches the artifact's HTML page AND LAVA
notes reaches LAVA only, never the HTML report
logfunc reaches _HTML/index.html and the Script Logs page
So the boundary statement goes in the description and the run log, not only
the notes. Putting it only in notes would have left an HTML reader with no
warning at all, which is the same silent gap this change exists to close.
The pre-existing MEO note behaves the same way, so this is how ALEAPP works
rather than anything specific here.
Three additions:
1. Both descriptions now name the Record Origin column and end with "WAL
frames are not parsed, so absence of a message here is not evidence it
did not exist." That renders directly above the table on the artifact
page.
2. A per-image log line reporting how much write-ahead log is left
unparsed, read from the WAL header and 24-byte frame headers only, no
page images loaded:
Snapchat arroyo.db-wal holds 1011 frames of 4096 bytes (386 in the
current log generation, 625 from previous generations). This artifact
does not parse WAL frames...
It lands on the report index. A frame whose salt pair does not match the
WAL header is from a previous log generation the current one has cycled
past, so the split tells the examiner how much older content is sitting
there. Silent when no -wal accompanies the database or the header is not
a WAL, both tested.
3. Notes carry the measured size of the gap for LAVA readers: a one-off
frame parser written during development read a further 29
conversation_message rows across 10 conversations, 9 of which appear in
neither view of the conversation table, timestamps spanning 2025-11-18
to 2026-07-24. Plus a sqlite3 command to re-derive a Recovered row
without this tool.
Frame parsing is deliberately NOT added here. 111 of the 158 records that
parser matched had 32 columns and 47 had 33, schema drift inside one WAL
file, so a per-artifact parser assuming the current schema would misalign
111 records and emit confidently wrong values. That belongs in the shared
capability being built, not in a third independent implementation after
L360noshowalerts and Honeyboard.
Counts unchanged: 16 message rows, 5 conversation rows on hc_pixel8pro_a17.
Conversation view kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Updated with the recovery boundary stated where an examiner will actually see it. Checked where each field surfaces before relying on it, which changed the plan:
So it goes in three places:
Why the gap is quantified rather than hand-waved. A one-off frame parser written during development read a further 29 Frame parsing is deliberately not added here. Of the 158 records that parser matched, 111 had 32 columns and 47 had 33 — schema drift inside a single WAL file. A per-artifact parser assuming the current 33-column schema would misalign 111 of them and emit confidently wrong values, which is the worst failure mode available. That belongs in the shared capability, not in a third independent implementation after Counts unchanged: 16 message rows (8 Live, 8 Recovered) and 5 conversation rows (4 Live, 1 Recovered) on |
Declares extraColumns on the arroyo.db messages conversation view, so every bubble carries Live or Recovered without the examiner having to know the column picker exists. That closes the last gap from abrignoni#1053: the provenance was visible in the table and invisible in the chat view, which is exactly where a recovered row could be misread as a live message. Record Origin alone rather than all three. It is populated on every row, so every bubble gets a marker, while Recovery Method and Recovery Location stay one click away in the picker and would only add noise under a live message. Depends on LAVA PR abrignoni#166 (feat/convo-extras), which introduces the field. Alexis confirmed shipping ahead of that merge is fine. Verified rather than assumed, since an unrecognised declarative option fails open silently: - The literal `extraColumns` exists in the consumer, read at conversationExtras.js:104. It is not on LAVA main yet. - Ran James's actual resolver against the manifest this artifact produces: "Record Origin" resolves to `record_origin`, a real column in the artifact table. - Ran current LAVA main's translation logic against the same manifest. The array coerces to a string via JS key lookup, nothing reads it, no throw, and all required layout fields still resolve. So the field is inert rather than harmful until abrignoni#166 lands. - The resolver is tolerant either way: display name "Record Origin" and sanitized "record_origin" both resolve correctly, so it keeps working if lavafuncs later learns to sanitize this key like the others. - Record Origin is not one of the layout columns, which the view ignores. Counts unchanged: 16 message rows, 5 conversation rows on hc_pixel8pro_a17. 765 plugins load with no duplicate names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to #1051. Contributes to #957, where the reasoning behind the approach is written up.
What prompted it
While validating #1051 I noticed the row count moves the wrong way.
arroyo.dbread on its own has 11conversation_messagerows. Read with its write-ahead log applied it has 8. The log carries removals, not just additions, so the smaller number is the correct current state and the larger one is stale.Those rows are still in the database file as of its last checkpoint, and nothing surfaced them. Three of the eight decode to plaintext at the same protobuf path the live rows use. One whole conversation is present in
conversation,feed_entryandconversation_identifierbefore the log is applied and absent from all three after, with a participant that does not appear in theFriendtable inmain.dbat all.One table per subject, not two
An earlier revision of this PR added separate "Not In Committed State" artifacts. This version instead adds three provenance columns to the existing two artifacts, so recovered rows sit in chronological context with the live ones:
Record OriginLiveorRecovered. Populated on every row.Recovery MethodWAL diff. Empty on Live rows.Recovery LocationRecord Originis a closed two-value set so a viewer can branch on it, and it is populated on every row rather than blank for live ones, because a blank cell is ambiguous between "live" and "not populated".Recovery Methodis naturally empty on live rows. So whichever column an examiner picks to display under a LAVA conversation bubble, they get a sensible result: the first marks every message, the second marks only the recovered ones.Combining also removes the timeline double-count that the split design carried.
Naming was checked, not invented
Against all five cores before choosing:
Record Sourcewas the obvious pick and is already taken, meaning "which table did this row come from", inallTrails.pyandslack.py.Sourceis overloaded 51 times across the cores (31 iLEAPP, 17 RLEAPP, 3 VLEAPP) for source file or table.Recovery Statusis taken ingalleryVault.pyfor how many bytes of a decrypted file came back.L360noshowalerts.pyalready does live-vs-recovered withSource/WAL Location/WAL Offset, which is the precedent this generalises.Record Origin,Recovery MethodandRecovery Locationare unused anywhere in the five cores.Deliberately not called
Deleted. Seventeen ALEAPP artifacts already useDeleted,Is Deleted,Deleted Atfor the app's own deletion flag. Reusing that name for tool-recovery provenance would conflate "the app recorded this as deleted" with "our tool recovered this", which are different claims that can appear on the same row.Recoveredsays how the row was obtained, which is known, rather than what happened to it, which is not.Method
The same file is opened twice through SQLite, once with
immutable=1which ignores the log, and once withmode=rowhich applies it, and the results are compared on the primary key.SQLite does the decoding on both sides, so named columns, type affinity and overflow pages come for free and there is no hand-written B-tree parser to maintain. That is the main argument for it over the frame parser in #956, and the two find different things, so neither replaces the other. Written up properly in #957.
Comparing row counts would have missed most of this. A count check flags 2 of 30 tables in
arroyo.db; comparing primary keys flags 6. The four it misses hold the same number of rows under different keys, and the conversation finding is one of those four.What it is not
Not deleted-record carving. It does not read freelist pages, unallocated space or freeblocks, and it does not parse WAL frames, so records that only ever lived inside the log are not recovered. It compares keys rather than full row content, so a row whose key survives while its content changed is not reported either.
Why a Recovered row is not in the live read is not established, and the notes say so. Removal by the app, a server re-sync rewriting those pages, and deletion are all consistent with the same result. On this image most of the recovered message rows carry Team Snapchat broadcast content, which is consistent with a re-sync.
It yields no Recovered rows when no
-walaccompanies the database, verified by removing the sidecar and confirming both reads agree at 11.Evidence handling
The
immutable=1read is strictly read-only and, unlikemode=ro, does not even create a-shmsidecar. Confirmed against a lone copy of the database: no new files afterwards and the same MD5. It routes through the sameget_sqlite_db_path()thatopen_sqlite_db_readonly()uses, so Windows long paths and URI-special characters behave identically.Scope
The diff helper is kept local to
snapchat.pyrather than put intoilapfuncs.py, so #957 gets to decide the shared shape rather than inheriting mine. It is written so lifting it into core is a move rather than a rewrite.Validation
Profile run against corpus key
hc_pixel8pro_a17(Android 17, com.snapchat.android vc 302522).csv.writerquotes it correctly; it needs a real CSV reader rather than a line-based check to verify.data_viewson the messages artifact only.CI run locally:
check_claim_language.pyclean,check_html_safety.pyclean, pylint 10.00/10 at CI flags,lint_changed.pyno new warnings.Note on the LAVA side
Until the conversation view can display an arbitrary column under the bubble, recovered rows render there like any other message. The columns are visible immediately in the HTML report, the TSV and the LAVA table view, so only the bubble lags. James is building the picker that closes it.
Not covered
One image. The zero case is proven by removing the sidecar rather than against a second corpus whose WAL is genuinely checkpointed, and that is the corpus I would most like to run this against.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com