Blolabel
← Notes
analysis 8 min read

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.

Abstract technical illustration of a vertical stack of translucent bars sliding upward while a fixed set of measurement marks stays where it was, so the marks now fall on different bars

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.

One read, several deletes: the coordinates outlive the rows read (measured) after delete 1 delete 2 lands here 6:15AM, Alarm (off) 7:00 AM, owner labelled, weekdays 8:40AM, Alarm (off) 9:05AM, Alarm (off) target y2 target y1 6:15AM, Alarm (off) 7:00 AM, owner 8:40AM, Alarm (off) content shrank: iOS re-anchors the offset, the window moves 6:15AM, Alarm (off) 7:00 AM, owner deleted swipe at y2 Schematic: the direction and size of the shift are not reported by iOS; what we observed is that a batch of 7 deletes removed the owner's alarm. Rows are 107 pt tall on a 440 pt wide screen. Fix: re-read before every delete, about 10 s each.
Illustrative labels except the owner's alarm, which is the real one. The rule it produced: a position measured before a mutation is not valid after it, even when the mutation is below it.

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.

strategywhat happenedwhyrule nowsource
swipe to delete starting 92% across the row40 "deletes" removed nothing and switched 9 alarms on, several set for the small hoursthe toggle sits at about 88% across; a swipe that starts on it is taken as a tapstart the swipe at 65% and end at 5%scripts/clean_alarms.py
swipe on rows at y = 804 "deletes" changed not one rowthat band sits under the top bar of the Clock app, and a swipe there does nothing, silentlyonly rows with centre y between 110 and screen height minus 140scripts/clean_alarms.py
7 deletes from one read, bottom to topremoved the owner's weekday 7:00 AM alarmiOS re-anchors the scroll offset when content shrinksexactly one delete per readFINDINGS.md s.20
trust a read taken mid-animationone read returned 631 cells and 413 switches; another returned nothing and the loop concluded it had finishedreads during a row-removal animation come back short, with no erroraccept a read only if cells equal switches; wait 0.9 s after each deletescripts/clean_alarms.py
tap the row centre to reach its switchtap reported success, nothing changedthe switch is 29 pt tall and sits about 24 pt above the middle of a 107 pt rowuse the switch's own frame, and only while the row is on screenFINDINGS.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.

One alarm row, to scale (1 pt = 1.8 px) 7:00 label, repeat days switch x 359 to 422, 63 x 29 pt row middle: a centre tap lands here, about 24 pt below the switch 92% start, x 405: a tap swipe 65% (x 286) to 5% (x 22): a delete 107 pt 0 440 pt row off screen: the tree reports this switch at x=0, y=116, width=63, a clamped placeholder so x > 0 is a usable test for "this control can be tapped right now"
Pixel positions 405, 286 and 22 are our arithmetic from the percentages and the 440 point width. The switch frame, row height and 24 point offset are measured.

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

Sources

  1. phoneshell, FINDINGS.md section 20, 8 September 2026. https://github.com/Blomega/phoneshell/blob/main/FINDINGS.md
  2. 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
  3. 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/
  4. Appium WebDriverAgent, v16.12.8 released 11 September 2026 (npm registry). https://registry.npmjs.org/appium-webdriveragent
  5. Apple Developer Documentation, XCUIElementAttributes isSelected and frame attributes. https://developer.apple.com/documentation/xcuiautomation/xcuielementattributes/isselected