The stop rule: absence needs proven coverage – Thesis Friday #29:

Key finding

An empty search result in the Apple Unified Log only counts as evidence if you have shown that the archive covers your time window, and covers it for the kind of line you searched for. The archive’s own metadata cannot show this. On my reference iPhones the reported start date was a 1970 record, the class markers moved independently of the data, and a start date chosen during acquisition left no trace in the archive. The stop rule asks you to record which checks you made, including the ones that found nothing.


Anchored Log Reconstruction, principle 5. Method page: thesisfriday.com/alr

Every reconstruction reaches a point where you have to decide whether you can keep going. The stop rule sets that point. When an observable trigger appears in the data, you narrow your conclusion or you withdraw it.

The rule has a second part, and in casework that part matters most. You also write down the triggers you checked and did not find. If you skip that step, a report that says “I found no problem” cannot be told apart from a report by someone who never looked.

This post works out one trigger in full, using reference data from two of my own iPhones. The trigger is coverage: does the archive contain data for the period you are asking about? You would expect the archive to answer that question itself. In the measurements below, its answer could not be trusted.

Why an empty result needs checking

Suppose the question is whether a phone was unlocked between 09:00 and 10:00 on a given morning. You search the Unified Log for that hour and find nothing.

That result can mean two things. Either the phone was not unlocked, or the log no longer holds data from that hour. The first is a finding. The second is a gap in the evidence, and a gap must not be reported as a finding.

What you search for matters too. In #27 I placed the provable endpoint of an unlock in the kernel: the AppleSEPKeyStore notification that the data volume was unlocked, together with the apfs line handle_async_keybag_unlock. Higher-level transition messages are written by several processes and do not carry that endpoint, so failing to find them proves little.

Before any negative conclusion you therefore do two things. You search for the endpoint, and you establish that your archive contains data for the whole window. Then you record that you did both.

The reported start date

The obvious first step is log stats, which prints the start and end of an archive.

log stats --archive iphone12_2661_2026_09_12_17_41_53.logarchive

start:              Thu Jan  1 01:02:56 1970
end:                Sat Sep 12 17:41:53 2026

The start date is 1 January 1970: the Unix epoch, the zero point of the system clock, plus about three minutes, shown in local time. The value comes from a boot record written before the phone had set its clock. Boot records are kept in the timesync database, a separate part of the archive that stores boots and clock synchronisation and has its own lifetime.

Taken literally, this archive covers fifty-six years. The first actual log line is from 27 August 2026. I found the same 1970 value in three other archives from this phone.

Class markers

The timesync records also contain values that look like coverage limits. These are the log class markers. Each marker names a retention class, based on the time-to-live (TTL) the system assigns to a log line, and gives the time at which that class begins.

log show --archive iphone12_2661_2026_09_12_17_41_53.logarchive \
  --info --debug --predicate 'eventType="timesyncEvent"' --style compact \
  | grep "log class"

For the archive collected on 12 September at 17:41:53:

MarkerTimestampDistance to collection
TTL less than 1 day begins11 Sep 17:41:54exactly 1 day
TTL less than 3 days begins9 Sep 17:41:54exactly 3 days
TTL less than 7 days begins5 Sep 17:41:54exactly 7 days
TTL less than 14 days begins29 Aug 17:41:54exactly 14 days
TTL more than 14 days begins27 Aug 16:35:5416 days

Four of the five markers lie a whole number of days before the moment of collection, to the second. They are calculated from the collection time and are not read from the data. All four fall after the first log line. The fifth marker coincides with the first log line, because the data does not reach further back.

Later collections show that this match was a coincidence. On a collection from the same iPhone made after 12 September, the same marker still reads 27 August 16:35:54, while the first log line in that archive is from 4 September 19:17:30. The marker that matched the data in one archive sits eight days before it in the next. In the full collection of 31 August, the marker persist begins sits at 6 September 2025, almost a year before the first log line.

Before the data, on the data, after the data. A value that can land anywhere relative to the data is not a boundary. Find the first log line separately, and record which value you used.

The acquisition decides what you get

On 31 August I collected the Unified Log from the iPhone twice, twenty-eight seconds apart, with the same tool. UFADE shows a calendar dialog with an optional start date before it collects. The first time I picked a date. The second time I left it empty.

With start dateFull collection
Uncompressed size441 MB1,073 MB
Reported start31 Aug 00:00:001 Jan 1970 (boot record)
Earliest log data31 Aug 00:00:0027 Aug 16:35:54
Class markers present27

The bounded archive holds 41 percent of the data, and five of the seven class markers are missing.

One of the two remaining markers is a trap. In the bounded archive, persist begins sits at 31 August 00:00:00, midnight exactly. That time says nothing about the phone. It is the date I clicked in the dialog. An analyst who receives this archive and reads persist begins as the lower limit of the store will conclude that nothing existed before midnight, while four days of data were on the phone.

UFADE is built on pymobiledevice3 and passes the chosen date as a start_time argument to OsTraceService.collect(). The option is legitimate and useful. The archive, however, does not record whether it was used. That fact exists only in the acquisition record, so if the acquisition record does not contain it, nothing does.

Does the boundary move?

The Unified Log is a rolling store: as new data arrives, older data is removed. You might expect the lower boundary to move forward steadily and everything above it to stay intact. My two iPhones did neither.

I first compared two collections from the iPhone 12, made on 31 August and 12 September. Both begin at 27 August 16:35:54. For three processes I compared the lines themselves, from 09:30 until the end of the older archive:

log show --archive <archive> --info --debug --start "2026-08-31 09:30:00" \
  --predicate 'process == "kernel" OR process == "SpringBoard" \
               OR process == "biometrickitd"' \
  --style compact | grep -v "partial or missing" | grep "^2026-08-31" \
  | sed -E 's/\[[0-9]+:[0-9a-f]+\]//'

The sed step removes process and thread numbers, which differ between collections and are not part of the message. Both files were cut at 12:19:11 and compared with comm, a Unix tool that lists the lines found in only one of two sorted files.

Lines
Collected 31 August1,024,792
Collected 12 September1,016,748
Only in the older archive8,171
Only in the newer archive127

From 09:30 to 12:16 the two files are identical. All 8,171 lines that exist only in the older archive come from the last three minutes before its collection, and all of them are Debug or Info lines. Not one is Default, Error or Fault. Comparing two collections made with –info –debug is therefore a trigger of its own, because differences at those two levels do not have to mean that anything was lost. Of the 127 lines found only in the newer archive, 61 are decoding errors of the kind listed further down. The other 66 I have not explained.

Over those twelve days the boundary did not move and nothing was lost. In the weeks after, with the phone upgraded to iOS 26.6.2 and used for the runs in #28, it did move: the later collection begins on 4 September, eight days after 27 August. What holds for one interval does not hold for the next.

The iPhone 14 in #28 showed the opposite pattern. That phone is in daily use. Its archive began on 2 September at 00:30:19, at the first collection and again three days later. The oldest event had not moved, yet the unlock block from the first day of the run had gone completely from the second collection. Loss did not run from old to new.

This gives coverage two layers. The first is the archive: where does its data begin? The second is the kind of line you search for: does that family of lines still exist in your window? A window can lie inside the covered period of the archive and still no longer contain the lines you are looking for. For the second layer the only evidence is how long that family has been observed to survive on that build. On a phone in daily use, that can be a matter of hours.

Your own acquisition leaves traces

Between 11:10 and 12:17 on 31 August the iPhone 12 archive contains 158 kernel keybag unlock lines, 370 ChronoServices transition lines and more than 14,000 Face ID camera frames. I did not unlock the phone as part of the reference session in that hour. The phone was being acquired.

Whether those lines record my own handling of the phone during acquisition or the acquisition process itself, I have not yet established. What is clear is that without a logbook of the acquisition session, they cannot be told apart from user activity. The two counts also differ: 158 kernel lines against 370 ChronoServices lines for the same hour. The same event is written differently by different processes, so a count based on one family gives a different number of unlocks than a count based on another.

What the record looks like

The rule asks you to record the triggers you checked. Below is the record for one concrete negative question on the 12 September iPhone 12 archive: was the phone unlocked on 5 September between 09:00 and 10:00? According to the reference logbook, the phone was not touched that day, so the correct answer is known in advance. That is the reason for choosing this question.

TriggerHow checkedResultEffect on conclusion
Lower boundaryFirst log line via log show –start, not log stats or class markers27 Aug 16:35:54Window inside coverage
Acquisition boundAcquisition record: start date used or notNot usedNone. Source is the logbook, not the archive
Continuity in windowBoot records and timesync entries around the window[to fill][to fill]
Family persistenceObserved survival of the kernel unlock families on this buildNot measured on 23G83Expiry cannot be excluded
Own acquisition activityAcquisition logbookNone in windowNone
Relative time queryQuery uses –start and –end onlyYesNone
Undecodable lines in windowCount of decode mismatch lines[to fill]If present, finding limited to decodable lines
Endpoint validated on this build#27 run, build 23G83YesNone
Manual clock changeTMSetManualTime searchArtefact not revalidated on iOS 26.xRecorded as not checkable on this build

The narrowed conclusion reads: no kernel unlock endpoint was recorded between 09:00 and 10:00 on 5 September, in an archive whose data runs continuously from 27 August. How long these lines survive on this build has not been measured, so expiry cannot be excluded, and a manual clock change could not be checked. That is longer than “no unlock found”, and every added clause is something a reviewer can verify.

The same check can also stop a conclusion entirely. The query for the acquisition hour on 31 August, run on the later collection that begins on 4 September, returns nothing. The lines existed: I counted them in the earlier archive. Without the check on the first log line, that zero would read as “no unlock”. With the check, the question falls outside the archive and the conclusion is withdrawn.

Triggers to check and to record

Coverage is one trigger. These are the others on my list. Some I have worked out, others not yet.

  • Null values where a measurement was expected
  • A reset time base after a restart, which makes monotonic timestamps incomparable across the boundary
  • Missing or discontinuous timesync records
  • A manual clock or time zone change inside the window (see Lionel Notari, Don’t Trust the Clock)
  • An OS upgrade, restore or migration inside the window
  • An artefact never validated on this build
  • An artefact family whose observed survival is shorter than the age of your window
  • A discrepancy between two parsers reading the same archive
  • The same event written differently by several processes, which makes any count based on one process wrong
  • Overlap between the extraction windows of consecutive blocks
  • Lines the parser cannot decode. Both iPhone 12 archives contain a few thousand lines that read <decode: mismatch for [%s] got [SCALAR sz:4]>. They exist, but no message search will ever match them
  • A comparison between collections made with –info –debug
  • A query with –last on an older archive. –last counts back from the current time, not from the end of the archive, so it returns zero rows with a clean exit code
  • A count taken in compact style. Compact output includes signposts and splits multi-line messages. Count in ndjson and keep numerator and denominator on the same basis
  • An error message that is discarded. A query with an invalid date fails, and if its error output is sent to /dev/null the result looks exactly like zero hits

For each trigger the record states whether it was checked, whether it was present, and what was done about it.

Conclusion

A negative finding belongs in a report only together with its coverage record: the first log line of the archive, the continuity of the window, the survival of the family you searched for, and the acquisition settings. If that record cannot be produced, the negative finding should not be in the report.

What this post shows, on measured data: class markers on four archives from one iPhone that do not mark the data boundary, including one that matched the data in one collection and sat eight days before it in the next; an acquisition setting that left 41 percent of the data and changed the meaning of a marker without leaving a trace; an iPhone whose lines were identical over twelve days and whose boundary moved in the weeks after; a second iPhone whose boundary stayed put while recent lines disappeared; and a zero result that is demonstrably a gap.

What it does not show. The iPhone 12 is a lightly used reference device and the iPhone 14 was not used under controlled conditions. None of the intervals here is a retention figure that transfers to a case device. Continuity inside a window is on the list of triggers but is not demonstrated in this post. The lines from the acquisition hour are counted but not yet attributed. How long the kernel unlock families survive on build 23G83 has not been measured.

The reasoning transfers. The numbers do not.

Reference setup

iPhone 12 (iPhone13,2), iOS 26.6.1, build 23G83 at the collections of 31 August and 12 September 2026, later upgraded to iOS 26.6.2, build 23G90. Factory reset, no SIM, one test Apple ID, standard applications only. Collections on 31 August 2026 (UFADE / pymobiledevice3 OsTraceService, one bounded and one full), 12 September 2026 and [T7 date] (sudo log collect –device-udid).

iPhone 14 (A2882), iOS 26.6.2, build 23G90. SIM, Apple ID, daily use. Collections on 14 and 17 September 2026 with sudo log collect –device-udid, as described in #28.

Analysis with log show and log stats on macOS 26.6.2. Reference actions recorded with reference_logbook.py against an external clock reference. No case data was used.

Monthly digest of new posts. Double opt-in, unsubscribe anytime. Privacy

© 2026 Tim Korver — Thesis Friday. Licensed under CC BY-NC-ND 4.0. Method: Anchored Log Reconstruction (ALR). Commercial or training use requires written permission. See Copyright and Use.

Monthly digest of new posts. Double opt-in, unsubscribe anytime. Privacy