Summary
- Messages stores reactions, edits, unsent parts, inline replies, Recently Deleted entries, link previews and app messages in places other than the text column. A plain message list does not show them.
- This pull request adds eight tables to iLEAPP that decode those fields from
sms.db. It was merged on 25 October 2026. - Row counts on eight iPhone extractions in the Lab's research corpus were re-counted with separate code. All 64 numbers agree.
- The tables report what the file stored. They do not show who acted or that a person saw a message.
- 8tables added
- 2,490lines added, none removed
- 64 of 64counts equal an independent recount
- 26 of 26CI jobs passed
The issue
An examiner asked whether a message was edited, taken back, answered inline, reacted to or deleted opens the Messages database and finds a list of rows. The list is correct as far as it goes, but several of these actions leave their trace somewhere other than the text column, and a list of rows does not say so.
Take the cases one at a time. A reaction (a tapback, an emoji or a sticker) is stored as a message row of its own, with the system-written phrase as its text, so in a plain list it reads like one more message and nothing links it to the message it answers. An edited message shows its current wording; the earlier versions sit in a binary property list in message_summary_info. An unsent message keeps its row but loses its text, so it looks like an empty message, and the field that marks it is again inside that property list. A message in Recently Deleted stays in the file after it has left the conversation. A link preview or an iMessage app message keeps its title, address or layout in an archive in payload_data.
The second problem is a name. The message table has a column called reply_to_guid, and the name invites the reading that a filled value marks an inline reply. On one database from the Lab's research corpus it is filled on almost three rows in four, so that reading would label most of the conversation as replies. Both problems have the same result: the report is quiet where the file is not.
Figure 1 shows the gap with a short synthetic conversation.
Show this figure as a table
| Row | Becomes |
|---|---|
| m2 | Reactions: Loved, target m1 |
| m3 | Edit History: two versions |
| m4 | Unsent Message Parts: part 0 |
| m5 | Inline Replies: replies to m1 |
| m6 | Link Previews: URL, title, site name |
The essence
The contribution adds eight tables to iLEAPP that read these fields out of the same sms.db file the existing messages artifact reads. The existing module is untouched. Each table says what the database stored and nothing about who acted: a row is a reaction, a stored version, an unsent part, a reply, a Recently Deleted entry, a link preview or an app message.
Five new modules do the reading, and they share one helper that decodes the three stored formats (typedstream text, binary property lists and keyed archives). Where the file lists a value that could not be interpreted, it is reported as stored instead of being guessed.
| Artifact | Stored fields read | One row is |
|---|---|---|
| Reactions | associated_message_type, associated_message_guid | Each reaction, sticker or removal, with the target message resolved by GUID |
| Edit History | message_summary_info (ec, otr), attributedBody, date_edited | One row per stored version of an edited part, with its time |
| Unsent Message Parts | message_summary_info (rp, otr), date_edited, date_retracted | One row per retracted part, with the original text length as stored |
| Inline Replies | thread_originator_guid, thread_originator_part | Each reply with the message it answers |
| Recently Deleted Messages | chat_recoverable_message_join | Each held message with its deletion time and whether it is still linked to a chat |
| Recently Deleted Message Parts | recoverable_message_part | Each held part with its text |
| Link Previews | payload_data (rich link metadata) | URL, original URL, title, summary and site name |
| iMessage App Messages | balloon_bundle_id, payload_data | App bundle, layout fields and caption fields as the app stored them |
Our methodology
The work started from the stored formats, not from the screen. We described each action as the rows and keys that must exist if our reading is right, wrote the decoders from the formats, and then tried to break the reading on real databases from the Lab's research corpus. The field meaning for the property-list keys follows the public documentation of an open-source exporter, which is cited for meaning only; no code was copied.
Three habits shaped the code. First, every decoder returns an empty value on input it cannot read and never raises, so one damaged blob costs one cell and not the whole report. Second, the module checks that the columns and tables it needs exist before it queries, so an older database gives an empty result and not an error. Third, times are converted from Cocoa seconds or nanoseconds to UTC with a stated threshold, and the target of a reaction or reply is looked up by GUID in chunks so that a large database is read in bounded queries (Figure 2).
Validation had four parts. The paper behind this work states nine checks as equalities, for example that the time of the last stored edit equals date_edited, and counts the exceptions. Row counts of all eight tables on eight full file system extractions were then recomputed with separate code: plain SQL, plistlib for the property lists and a small keyed-archive reader for the previews. All 64 counts agree (Figure 3). The first version of that independent reader was a heuristic and disagreed with the parser by 16, 4 and 26 rows on three databases; a proper archive reader settled each difference in favor of the parser, so the recount tested both sides. The repository's own output check first listed 45 distinct empty, constant or identical column findings, such as no named group chats; each column is now named in the artifact notes with what it was.
The test data is synthetic. A generator builds a fabricated database with reserved 555-01xx phone numbers, example.com addresses, fixed sentences and fixed dates, and the pull request adds twelve unit tests and eight test cases with recorded baselines. On the continuous-integration side all 26 jobs passed on the final change. Two findings in the PR text are stated as limits: databases from iOS 16 and 17 were not available, so those versions are unverified, and a ninth database from iOS 18.1.1 was used earlier but not re-run for this change.
Show this figure as a table
| Stored field | Table |
|---|---|
| associated_message_type, associated_message_guid | Reactions |
| message_summary_info | Edit History, Unsent Message Parts |
| thread_originator_guid | Inline Replies |
| chat_recoverable_message_join, recoverable_message_part | Recently Deleted Messages, Parts |
| payload_data, balloon_bundle_id | Link Previews, iMessage App Messages |
Show this figure as a table
| Artifact | 26.2.1 | 26.3.1 | 26.3.1 | 26.3.1 | 26.5.2 | 26.6 | 26.6.1 | 26.6.2 | Total |
|---|---|---|---|---|---|---|---|---|---|
| Reactions | 79 | 23,375 | 37 | 89 | 4,174 | 148 | 1,301 | 1,251 | 30,454 |
| Edit History | 48 | 20 | 10 | 53 | 441 | 30 | 117 | 66 | 785 |
| Unsent Message Parts | 59 | 0 | 1 | 61 | 31 | 0 | 4 | 6 | 162 |
| Inline Replies | 86 | 2,852 | 48 | 107 | 1,360 | 59 | 745 | 605 | 5,862 |
| Recently Deleted Messages | 0 | 0 | 1 | 0 | 86 | 102 | 2 | 13 | 204 |
| Recently Deleted Message Parts | 0 | 0 | 1 | 0 | 10 | 0 | 0 | 0 | 11 |
| Link Previews | 144 | 2,186 | 417 | 158 | 2,275 | 14 | 81 | 83 | 5,358 |
| iMessage App Messages | 4 | 313 | 4 | 7 | 31 | 0 | 0 | 27 | 386 |
The value
The tables let an examiner answer questions the plain list could not. Was a message edited, and what did it say before? Which parts of a message were unsent, and how long was the original text? Which message did this reaction or reply point to, and is that message still in the database? Which messages are held in Recently Deleted, and are they still linked to a chat? What did a link preview or an app message carry?
The value is largest where the reading would otherwise be wrong. The reply case is the plainest: on the database where it was measured, reply_to_guid is filled on 49,981 of 68,136 rows and equals the thread originator in only 56 of 1,360 replies, so the artifact identifies replies by thread_originator_guid (Figure 4). The notes also record that Recently Deleted rows are not limited to 30 days, that removal rows of reactions often have no matching add row, and that date_retracted was 0 on every database, so the retracted-parts list marks an unsent part.
The limits matter as much. A row is a record the device stored. It does not show who edited, unsent, reacted or deleted, from which device, or that a person read the target message. A link preview shows that Messages stored a preview, not that the link was opened. An empty result is not evidence that nothing happened, because a message removed from Recently Deleted leaves no row. The reply finding rests on one database, and iOS 16 and 17 were not tested.
Show this figure as a table
| Count | Rows |
|---|---|
| All message rows | 68,136 |
| reply_to_guid filled | 49,981 |
| thread_originator_guid filled (replies) | 1,360 |
| Replies where the two columns are equal | 56 |
Where this sits in the Lab's research
This contribution is the tooling side of Lab Technical Report LAB-TR-0008, What iMessage Keeps After You Edit, Unsend, React or Delete, published on Zenodo (doi:10.5281/zenodo.23720066, version 1.2; the concept DOI 10.5281/zenodo.23717930 always points to the newest version). The report states the nine checks, gives the results on nine databases and names this pull request as its artifact release. This post summarizes the part of the report that shaped the code.
A second report, LAB-TR-0013, asks whether removed messages leave traces in the iOS unified log. It is available on this site as a preprint; the Zenodo deposit is pending. It compares identifiers in the log with the identifiers in the Messages store, and it found that the log named 24 percent of the 2,867 messages dated inside a device's log span; it did not show that a message was removed. The message GUID that every row of these tables carries is the identifier such a comparison needs.
Sources
Every figure in the text and in the diagrams is listed here with where it comes from. Nothing else was used: no device, image or report was opened for this post.
| Number or claim | Source |
|---|---|
| Eight artifacts, five modules, one helper, +2,490 lines, 0 deleted | PR #2576 body and file list |
| Merged 25 October 2026 | PR #2576 state and mergedAt |
| 64 counts re-counted with separate code, all agree; eight extractions | PR #2576 body, checklist item 1 |
| Row counts per artifact per extraction (Figure 3) | PR #2576 body; also LAB-TR-0008 Table 4, columns D2 to D9 |
| Totals over the eight extractions (30,454; 785; 162; 5,862; 204; 11; 5,358; 386) | Sum of the PR row counts, computed for this post; LAB-TR-0008 Table 4 totals less D1 |
| 45 distinct column findings from the output check | PR #2576 body, checklist item 2 |
| Recount reader disagreed by 16, 4 and 26 rows on three databases | LAB-TR-0008 section 3.3 |
| 12 unit tests, 8 test cases, 26 of 26 CI jobs | PR #2576 body, test record; diff files test_sms_deep_fields.py and testdata.sms*.json |
| Synthetic fixture: 555-01xx numbers, example.com addresses, fixed sentences and dates | PR #2576 body; diff file synthetic_sms_deep.py |
| iOS 16 and 17 not available; iOS 18.1.1 not re-run | PR #2576 body, checklist item 1 |
| reply_to_guid filled on 49,981 of 68,136 rows; 1,360 replies; equal in 56 (one database) | LAB-TR-0008 section 1 and section 5.4 |
| date_retracted 0 on every row; Recently Deleted not limited to 30 days; removal rows often lack add rows | PR #2576 body, reviewer notes; LAB-TR-0008 sections 5.2, 5.3, 5.5 |
| Decoders return an empty value on unreadable input; Cocoa time threshold; GUID lookup in chunks | Diff file scripts/sms_deep.py |
| Column and table existence checks before querying | Diff file scripts/artifacts/smsReactions.py |
| Nine consistency checks C1 to C9 | LAB-TR-0008 Table 2 |
| Log named 24 percent of 2,867 messages inside the log span | LAB-TR-0013 abstract (preprint on this site; Zenodo deposit pending) |
| Zenodo DOI 10.5281/zenodo.23720066 (version 1.2), concept 10.5281/zenodo.23717930 | papers/zenodo/DOIS.md |