Open Source / iLEAPP #2576

What Messages keeps after edits, unsends and deletes

A message list shows rows. It does not show that a row is a reaction, an earlier version, a message taken back or an answer to another message. This contribution reads those fields from the Messages database and says exactly what was stored.

  • Daniel Addai, SectraTEK Lab
  • Merged
  • CC BY 4.0

View iLEAPP pull request #2576 on GitHub

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.

Before and after: one message list against eight tablesLeft: six message rows as one list, where a reaction, an edit, an unsent message, a reply and a link look like ordinary rows. Right: the same rows read by what they are. Synthetic example.One list of message rowsThe same rows, read by what they arem1 Dinner at 7?an ordinary messagenothing further to decodem2 Loved “Dinner at 7?”a reaction, listed as a messageReactionsLoved, target m1 (found)m3 See you at 7:30edited; earlier wording not shownEdit Historyv1 12:00:00 See you at 7 | v2 12:01:10 See you at 7:30m4 (no text)unsent; nothing in the row says soUnsent Message Partspart 0 unsent 12:00:41, original length 18m5 Sounds goodan inline reply; the message it answers is not shownInline Repliesreplies to m1, “Dinner at 7?”m6 (a link)the preview is stored in payload_dataLink PreviewsURL, title and site name decodedSynthetic example: invented rows, texts and times.
Show this figure as a table
Synthetic example, rows and what each becomes
RowBecomes
m2Reactions: Loved, target m1
m3Edit History: two versions
m4Unsent Message Parts: part 0
m5Inline Replies: replies to m1
m6Link Previews: URL, title, site name
Figure 1A synthetic conversation read as one list of rows (left) and by what each row is (right). The dashed box marks a row with nothing further to decode. Source: pull request description of the eight tables; invented rows (synthetic example).

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.

The eight artifacts, the stored fields they read and what a row is
ArtifactStored fields readOne row is
Reactionsassociated_message_type, associated_message_guidEach reaction, sticker or removal, with the target message resolved by GUID
Edit Historymessage_summary_info (ec, otr), attributedBody, date_editedOne row per stored version of an edited part, with its time
Unsent Message Partsmessage_summary_info (rp, otr), date_edited, date_retractedOne row per retracted part, with the original text length as stored
Inline Repliesthread_originator_guid, thread_originator_partEach reply with the message it answers
Recently Deleted Messageschat_recoverable_message_joinEach held message with its deletion time and whether it is still linked to a chat
Recently Deleted Message Partsrecoverable_message_partEach held part with its text
Link Previewspayload_data (rich link metadata)URL, original URL, title, summary and site name
iMessage App Messagesballoon_bundle_id, payload_dataApp 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.

From stored fields to eight report tablesPipeline: five groups of fields in sms.db, one shared decoding helper, five artifact modules that produce eight tables.In sms.dbscripts/sms_deep.pyArtifact module and its tablesShared decoderstypedstream: text ofattributedBody, edit versions,part textbinary plist: message_summary_infoNSKeyedArchiver: payload_dataguid forms p:<part>/<guid>, bp:<guid>Cocoa time: ns above 1e15, else sconverted to UTCunreadable input gives an emptyvalue; one cell, not the reportassociated_message_typeassociated_message_guidsmsReactionsReactionsmessage_summary_infokeys ec, otr, rp; date_editedsmsEditsEdit History; Unsent Partsthread_originator_guidthread_originator_partsmsRepliesInline Replieschat_recoverable_message_joinrecoverable_message_partsmsRecentlyDeletedDeleted Messages; Partspayload_databalloon_bundle_idsmsAppPayloadsLink Previews; App MessagesA missing column or table gives an empty result for that artifact, not an error.
Show this figure as a table
Source fields and the tables they feed
Stored fieldTable
associated_message_type, associated_message_guidReactions
message_summary_infoEdit History, Unsent Message Parts
thread_originator_guidInline Replies
chat_recoverable_message_join, recoverable_message_partRecently Deleted Messages, Parts
payload_data, balloon_bundle_idLink Previews, iMessage App Messages
Figure 2Where each action leaves its trace, the shared decoders, and the module that reports it. Source: diff of scripts/sms_deep.py and the five modules in scripts/artifacts (diagram).
Rows per artifact per extraction, all equal to the independent recountCoverage matrix: eight artifacts by eight extractions, with the row count in each cell. Shaded cells have rows, dashed cells are zero. All 64 counts equal an independent recount.Rows reported, 8 artifacts x 8 extractions (iOS release across the top)26.2.126.3.126.3.126.3.126.5.226.626.6.126.6.2Reactions7923,37537894,1741481,3011,251Edit History482010534413011766Unsent Message Parts59016131046Inline Replies862,852481071,36059745605Recently Deleted Messages001086102213Recently Deleted Message Parts001010000Link Previews1442,1864171582,275148183iMessage App Messages431347310027Counts as listed in the pull request. Each of the 64 numbers was re-counted with separate code.
Show this figure as a table
Rows reported by each artifact on each extraction
Artifact26.2.126.3.126.3.126.3.126.5.226.626.6.126.6.2Total
Reactions7923,37537894,1741481,3011,25130,454
Edit History482010534413011766785
Unsent Message Parts59016131046162
Inline Replies862,852481071,360597456055,862
Recently Deleted Messages001086102213204
Recently Deleted Message Parts00101000011
Link Previews1442,1864171582,2751481835,358
iMessage App Messages431347310027386
Figure 3Rows per artifact on each of the eight re-run extractions. A dashed cell is a count of zero, which is a result, not a gap. Source: PR #2576 body, checklist item 1; LAB-TR-0008 Table 4 (D2 to D9); aggregate counts, no synthetic values.

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.

Two readings of a reply on one databaseBar chart for one iOS 26.5.2 Messages database: 68,136 message rows, 49,981 with reply_to_guid filled, 1,360 with a thread originator, and 56 replies where the two columns are equal.One database, 68,136 message rowsAll message rows68,136reply_to_guid filled49,981 (73.4 percent)thread_originator_guid filled (replies)1,360replies where the two columns are equal56 of the 1,360 (too short to see)020,00040,00060,000Message rows (one scale). Reading replies from reply_to_guid would count 73 percent of rows as replies.
Show this figure as a table
Reply readings on one database
CountRows
All message rows68,136
reply_to_guid filled49,981
thread_originator_guid filled (replies)1,360
Replies where the two columns are equal56
Figure 4How many rows each reading of a reply would select on one database. Single database, not a rate. Source: LAB-TR-0008 sections 1 and 5.4 (iOS 26.5.2 database; aggregate counts, no synthetic values).

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.

Every number and claim in this post, with where it comes from
Number or claimSource
Eight artifacts, five modules, one helper, +2,490 lines, 0 deletedPR #2576 body and file list
Merged 25 October 2026PR #2576 state and mergedAt
64 counts re-counted with separate code, all agree; eight extractionsPR #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 checkPR #2576 body, checklist item 2
Recount reader disagreed by 16, 4 and 26 rows on three databasesLAB-TR-0008 section 3.3
12 unit tests, 8 test cases, 26 of 26 CI jobsPR #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 datesPR #2576 body; diff file synthetic_sms_deep.py
iOS 16 and 17 not available; iOS 18.1.1 not re-runPR #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 rowsPR #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 chunksDiff file scripts/sms_deep.py
Column and table existence checks before queryingDiff file scripts/artifacts/smsReactions.py
Nine consistency checks C1 to C9LAB-TR-0008 Table 2
Log named 24 percent of 2,867 messages inside the log spanLAB-TR-0013 abstract (preprint on this site; Zenodo deposit pending)
Zenodo DOI 10.5281/zenodo.23720066 (version 1.2), concept 10.5281/zenodo.23717930papers/zenodo/DOIS.md