Build

Why a one-time iOS alarm disabled itself before it rang

A snooze or recovery reservation was still pending, but my startup code checked only the primary AlarmKit schedule and decided the alarm was already finished.

I opened RYZA and found a one-time alarm turned off.

It was not late. It had not rung. It was still supposed to go off in a few minutes. The app had not crashed, and the alarm had not obviously failed to schedule. It had simply changed from enabled to disabled while I was waiting for it.

That is a bad kind of alarm bug, because there is no useful warning. The user does not hear an error. They just sleep through the thing they asked the app to do.

The alarm was not gone. I was looking for the wrong part of it.

A one-time alarm is more than one schedule

A one-time alarm in RYZA can have more than one native AlarmKitApple’s system framework for alarms, which owns the alert on the lock screen schedule attached to it. There is the primary schedule, the one that first rings at the chosen time. Then there can be follow-up schedules:

  • a snooze reservation
  • a re-ring after an unfinished voice mission
  • a recovery guard for a mission handoff
  • a Wake Up Check follow-up

These are all still part of the same alarm occurrence. They are not extra alarms the user created.

The bug showed up when the primary schedule had already gone away, but one of those follow-up reservations was still counting down. My startup reconciliation code, the same kind of code that once rescheduled an alarm while it was still ringing, asked a simple question: does this one-time alarm still have its primary native schedule? If the answer was no, the app treated the alarm as finished. It synchronised the native schedule, cancelled what it thought was leftover state, and disabled the one-time alarm in the saved list.

I still had a snooze or recovery reservation waiting in the system. I removed it, because I had decided the alarm was over.

Primary alarm is gone

Snooze or re-ring is still pending

App starts and checks only primary schedules

"Alarm is finished"

Follow-up reservation is cancelled

One-time alarm is disabled

The alarm did not turn itself off. My cleanup code did it.

Why I missed it

The original rule seemed sensible. One-time alarms should not stay enabled forever. If one has already fired and finished, leaving it on would be confusing, so the app needs a way to mark that kind of alarm complete.

The mistake was treating “primary schedule no longer exists” as the same thing as “the alarm cycle is complete.” Those are usually close together. They are not the same state.

A snooze is the easiest example. The first alarm can be gone because the user snoozed it, and the user is obviously still expecting another ring. The same applies to a voice-mission recovery alarm: if the mission did not complete cleanly, the follow-up reservation exists precisely because the alarm has not finished its job.

I had modelled the normal path well and treated the in-between paths as leftovers. That is often where state bugs live. Not at the beginning or the end, but in the part where a process has changed shape and is still not done, the same gap that once left an app unable to read its own alarm data after a reboot.

I stopped matching only primary alarms

The fix was not to make reconciliation smarter in every possible way. I added one clear rule: any non-primary AlarmKit reservation means the alarm is still unfinished.

A follow-up reservation now protects its parent alarm during startup reconciliation. So when RYZA sees a one-time alarm with no primary schedule but a pending snooze, re-ring, guard, or Wake Up Check reservation, it does not disable the alarm. It leaves the native reservation alone and waits for that remaining alarm path to finish, the same deference I had to build into Wake Sequence for its own in-progress rounds.

App starts Pending follow-up reservation? YES preserve, defer reconciliation NO normal completion rules
One question added ahead of the old rule. Everything after "no" is exactly what reconciliation already did.

The implementation was small. The meaning behind it was not. I had to make the app understand that an alarm is not one row in one table. It is a family of schedules with one user-facing promise: I will alert you again.

I added tests for the names I had been ignoring

I wrote tests for every follow-up schedule type I knew about: reRing, snooze, guard, wakeUpCheck. Each one now counts as unfinished work. The test also makes the opposite boundary clear: primary, an empty schedule kind, or no schedule kind at all does not get that protection.

This may sound like a detail about labels. It is more than that. The names are part of the contract between the app’s scheduler and its cleanup code. If a new follow-up type gets added later and nobody teaches the reconciliation flow what it means, this test is a reminder that it cannot be casually treated as garbage.

The thing that made me uncomfortable

I do not know how many users could have hit this exact path before I noticed it. It took a fairly specific sequence: a one-time alarm, a follow-up reservation, then an app launch or reconciliation while that reservation was pending. That is not the most common morning.

But alarm software does not get to dismiss a bug because it has an inconvenient setup. The person who hits it is the one who misses an appointment.

I also do not like that the code which caused this was trying to be responsible. It was removing stale state. There was no reckless “disable alarm” button in the wrong place. There was a well-intentioned cleanup rule with an incomplete definition of done. That is harder to spot, and probably worth remembering.

What changed

Now a one-time alarm stays enabled while any of its follow-up schedules are waiting. If I snooze it, the app does not decide the alarm is finished just because the original ring has disappeared. If a voice mission needs recovery, the recovery reservation is not erased on the next app launch. If Wake Up Check still has work to do, its follow-up state stays attached to the alarm.

The end state is still the same: once the alarm cycle genuinely finishes, a one-time alarm can be disabled. The difference is that “genuinely finishes” now means the whole alarm has finished, not just its first schedule.

That is a narrower sentence than the one I started with. It is also the one an alarm app needs.