Batch deletes in an iOS list removed an alarm nobody verified, because the list re-anchors its scroll offset
Deleting bottom to top looked provably safe. It was not: the list moved under every coordinate the read had measured. One delete per read, at about 10 seconds each, is the only version that has not touched a row it did not mean to.
Deleting rows from an iOS list in a batch, bottom to top, removed an alarm that no read had verified. Our cleanup script deleted seven benchmark-leftover alarms from one read of the Clock app, and one of the seven was the phone owner's labelled, weekday-repeating 7:00 AM alarm. When the list's content shrinks, iOS re-anchors its scroll offset, so every position measured before the first delete is stale after it. The script now does one delete per read, at about 10 seconds a delete, and that is the only version that has not touched a row it did not mean to.
How 640 alarms got there, and why the obvious batch was wrong
The mess was the benchmark's own. Runs saved an alarm every time an agent opened Add Alarm and confirmed it, despite being told not to, and by 8 September 2026 the phone held 640 of them. At that length one accessibility read of the alarm list cost 2.6 seconds, and 24 seconds with the Add Alarm sheet open, against 154 ms on the timer screen, which is why one alarm task could never finish inside its budget. We measured that decay in an earlier note; this one is about the cleanup.
The cleanup rule for what may be deleted is narrow on purpose. A row is an artifact only if its label
is exactly H:MM(AM|PM), Alarm, meaning no name and no repeat schedule, and its switch reads
off. A switch whose state cannot be read counts as on, and the row is kept.
Doing that one row at a time costs a full tree read per delete, about 10 seconds each, so 640 rows is on the order of 107 minutes of wall clock (our arithmetic, 640 times 10 seconds). Batching looked provably safe. Removing a row only shifts the rows below it, so if you delete from the bottom of the screen upwards, every target above keeps the position the read measured.
On iOS that reasoning fails. When the content gets shorter, the list re-anchors its scroll offset, the whole visible window moves, and the remaining "verified" coordinates point at different rows. A batch of seven deletes took out the owner's 7:00 AM alarm. We rebuilt it from what the tree had recorded (time, label, repeat days), and the incident is recorded as section 20 of our findings file.
Five ways a delete did the wrong thing
The batch was not the only failure. Every cleanup strategy we tried before the current one either did nothing and reported progress, or changed something it should not have.
| strategy | what happened | why | rule now | source |
|---|---|---|---|---|
| swipe to delete starting 92% across the row | 40 "deletes" removed nothing and switched 9 alarms on, several set for the small hours | the toggle sits at about 88% across; a swipe that starts on it is taken as a tap | start the swipe at 65% and end at 5% | scripts/clean_alarms.py |
| swipe on rows at y = 80 | 4 "deletes" changed not one row | that band sits under the top bar of the Clock app, and a swipe there does nothing, silently | only rows with centre y between 110 and screen height minus 140 | scripts/clean_alarms.py |
| 7 deletes from one read, bottom to top | removed the owner's weekday 7:00 AM alarm | iOS re-anchors the scroll offset when content shrinks | exactly one delete per read | FINDINGS.md s.20 |
| trust a read taken mid-animation | one read returned 631 cells and 413 switches; another returned nothing and the loop concluded it had finished | reads during a row-removal animation come back short, with no error | accept a read only if cells equal switches; wait 0.9 s after each delete | scripts/clean_alarms.py |
| tap the row centre to reach its switch | tap reported success, nothing changed | the switch is 29 pt tall and sits about 24 pt above the middle of a 107 pt row | use the switch's own frame, and only while the row is on screen | FINDINGS.md s.20 |
Two of the five produced a success signal from the gesture layer while the phone did nothing, and one did damage. None raised an error.
Where the switch really is, and why the tree put it at x = 0
The swipe that turned nine alarms on is explained by a frame that looks wrong and is not. Read while
its row is off screen, an alarm's switch reports x=0, y=116, width=63. Read while the row is
visible, the same switch reports x=359, width=63 on a 440 point wide screen, which is where it
is drawn. iOS clamps the frame of a control in a row it has not rendered. So the tree could not be used
to find the toggle in the first version of the script, and a hand-picked 92% start point landed on it.
Two rules come out of this. x > 0 is a cheap and usable test for "this control is on
screen and can be acted on now". And a row's centre is not a stand-in for a control inside it: the
toggle is aligned with the alarm's time, not with the row's middle, so a row-centre tap misses a 29 point
switch entirely and reports success anyway.
What it means for an agent that acts on a list
The expensive rule failed and the cheap one held. The first design protected the owner's alarms by
checking after every delete that all keepers were still present. That needed a complete tree, and at
640 rows /source truncates positionally: it returns rows near the current scroll offset and
drops the rest, so a keeper far from the viewport is absent from every read and three reads agreeing
mean nothing. The rule that works needs only the row being acted on: exact artifact label, switch off,
unknown means on. It stays correct when the tree comes back short.
Any agent that emits several actions from one observation carries the same exposure. Our judgement: for mutations on a list, one action per observation is the correct default even though it is the slow one, and the agent loop should re-observe after a delete, a reorder or an insert before it trusts any coordinate. Taps that do not change content, such as moving focus between fields, do not carry this risk in the same way; we have not measured how far the batch can safely go for those.
The cost is real and should be budgeted rather than optimised away. One read per delete at about
10 seconds turned a 640-row cleanup into roughly an hour and three quarters. A benchmark that creates
state should also delete it inside the task: ours now has a clean_alarms teardown, and the backlog script carries a hard ceiling equal to the number of artifacts it counted
at the start, so a bug can never delete more than that.
Check it yourself
Use a test phone. The destructive part of this is the whole point.
# the cleanup, its artifact rule, its ceiling and its one-delete-per-read loop
grep -n "ARTIFACT = " scripts/clean_alarms.py
grep -n "Exactly ONE delete per read" -B 2 -A 8 scripts/clean_alarms.py
grep -n "Start the swipe at 65%" -A 8 scripts/clean_alarms.py
sed -n '/^## 20\./,/^## 21\./p' FINDINGS.md
# read the switch frames yourself from WebDriverAgent (no session needed for /source)
curl -s 'localhost:8100/source?format=json' \
| jq -r '.. | objects | select(.type=="XCUIElementTypeSwitch")
| [.label, .value, .rect.x, .rect.y, .rect.width, .rect.height] | @tsv'
# rows scrolled out of view report x = 0; rows on screen report their drawn x
# the re-anchor: note the y of every row ABOVE a target, delete the target, read again
curl -s 'localhost:8100/source?format=json' \
| jq -r '.. | objects | select(.type=="XCUIElementTypeCell") | [.label, .rect.y] | @tsv'
The comparison that proves the mechanism is the last one: if the rows above the deleted row change
their y, the offset moved and any batch plan built on the first read is stale.
What would prove this wrong
- On iOS 26.7 or iOS 27, delete one alarm from a list long enough to scroll, and find that every row
above it keeps its
yto the point. That would mean the re-anchor is conditional (on scroll position, list length or version) and batching is safe in some states. We predict the shift appears in the Clock alarm list on every iOS 27.x release through 30 June 2027. - Reproduce the off-screen switch frame and get the drawn position rather than
x=0. That would mean the clamping we saw is specific to iOS 26.6 or to lists of this length. - The incident count is one lost alarm in one batch of seven. It is enough to change the rule, and it is not enough to say how often a batch goes wrong.
Sources
- phoneshell, FINDINGS.md section 20, 8 September 2026. https://github.com/Blomega/phoneshell/blob/main/FINDINGS.md
- phoneshell, scripts/clean_alarms.py: artifact rule, 65% swipe start, one delete per read, cell and switch count check. https://github.com/Blomega/phoneshell/blob/main/scripts/clean_alarms.py
- Blolabel, The accessibility tree grew with the benchmark's own leftovers (154 ms, 2,612 ms, 24,195 ms reads). https://blolabel.ai/blog/accessibility-tree-size-latency/
- Appium WebDriverAgent, v16.12.8 released 11 September 2026 (npm registry). https://registry.npmjs.org/appium-webdriveragent
- Apple Developer Documentation, XCUIElementAttributes isSelected and frame attributes. https://developer.apple.com/documentation/xcuiautomation/xcuielementattributes/isselected