Recovering Deleted Windows Notifications
Why dismissed and expired toasts survive in wpndatabase.db, where SQLite leaves them (freeblocks, freelist, WAL frames) and how to recover them without fooling yourself.
TL;DR. Deleting a row in SQLite marks its space as free; the bytes stay until reused (unless secure_delete is on). In wpndatabase.db that means dismissed and expired notifications — including chat previews — can often be recovered from free space in pages, the freelist and older page images in the file and the WAL. Treat each recovered row as a lead, not a record.
Where deleted rows hide
The SQLite file format gives four places:
- Freeblocks. A deleted cell's space becomes a freeblock in the same b-tree page. Only its first four bytes are overwritten (the pointer to the next freeblock and the block size); the rest of the record — including the payload — is intact.
- The freelist. When a page no longer holds any row, it goes on the freelist, content and all, until SQLite needs a page again.
- Superseded pages in the database file. With the WAL present, the file holds the page as it was at the last checkpoint. If a later transaction deleted a row, the old page image still contains it.
- WAL frames. The WAL holds successive versions of pages. Older frames, and frames of a previous WAL generation, can carry rows that newer versions no longer have.
A notification database is a good candidate: payloads are a few hundred bytes of distinctive XML, rows churn constantly, and the file is rarely vacuumed.
How the parser recovers them
The Windows Notification Parser uses two methods:
- Deleted by the WAL. Rows present in the checkpointed database but gone once the WAL is applied are exact: the parser reads them with the normal record decoder and labels them "deleted by a transaction in the WAL".
- Payload-anchored carving. Every
<toast,<tile,<badgeor<?xmlfound in any page or WAL frame is a candidate start of aPayloadvalue. For each, the parser looks back for a record header whose serial types match the liveNotificationschema and place the payload exactly at that offset. Column types, a plausible FILETIME and an ASCIITypemust also check out. Payloads that spilled onto overflow pages are reassembled from the chain when it is still there; otherwise the row is marked partial.
Rows identical to a live row are dropped. A carved row with the same Id as a live one but different content is shown as an earlier version.
Reading a recovered row
Each recovered notification says where it came from, for example free space in page 4, freelist page 12 or WAL frame 3 (page 5). Check three things before you use it:
- Is it complete? A partial row has a cut payload or missing fields.
- Does its handler still exist?
HandlerIdis resolved against the live handler table; a missing handler is possible. - Does it fit the timeline? An arrival time in the middle of other activity is a good sign; a time far outside the data deserves suspicion.
Limits
- Space is reused as new notifications arrive: recovery gets worse with time since deletion.
VACUUMrewrites the file and drops free space.- If a build enabled
secure_delete, freed content is zeroed. Whether any Windows build does this is not documented; an empty result is not proof of absence. - A carved record whose header was overwritten cannot be recovered by this method. Other carving approaches exist; this one favours few false positives.
FAQ
Can deleted Windows notifications be recovered?
Often, until SQLite reuses the space. A deleted row stays in the page as a freeblock, whole pages go to the freelist, and older page images remain in the database file and the WAL. Recovered rows are best-effort evidence and should be labelled as such.
Does opening the database destroy deleted records?
It can. Opening a WAL database with SQLite and closing it may checkpoint and reset the WAL, and writing to it reuses free space. Work on hashed copies.