# Plan: Simplify Static Reminder Path & Remove Dead Notify Generators

**Purpose:** Fix the Android bug where cancelling a static daily reminder
doesn't stop it from firing, by giving static reminders their own isolated
schedule namespace — and remove now-unneeded legacy code along the way.

**Status:** Ready for execution
**Date:** 2026-06-07
**Platform:** Android only (the reported bug is Android-specific; iOS was not
investigated — see "iOS" section below before assuming parity)

---

## 1. The bug this fixes

In the TimeSafari PWA (`crowd-funder-for-time-pwa`), turning off the daily
reminder (`AccountViewView.vue` → `NativeNotificationService.cancelDailyNotification()`
→ plugin `cancelDailyReminder`) reports success, but the notification **keeps
firing every day**.

`cancelDailyReminder` (`DailyNotificationPlugin.kt:754-779`) only:
1. Cancels the AlarmManager alarm for one specific `scheduleId` (the app's
   stable id, `"daily_timesafari_reminder"`), and
2. Sets `enabled = false` for that one row in the `schedules` table.

By contrast, `scheduleDailyNotification` (`DailyNotificationPlugin.kt:1096-1195`)
defensively sweeps the **entire** `schedules` table via
`ScheduleHelper.cleanupExistingNotificationSchedules()` (kills every
`kind == "notify"` row except the one being created) and cancels
prefetch/fetch WorkManager jobs via `cancelFetchRelatedWorkManagerJobs()` —
explicitly because, per its own comments, other code paths can spin up a
"second (UUID) alarm" that fires duplicate/stray notifications.

So: if **any** stray `notify`-kind schedule exists with an ID other than
`"daily_timesafari_reminder"`, cancellation completely ignores it, and it
keeps re-arming itself and firing forever — because the rollover/rearm logic
(`DailyNotificationWorker.scheduleNextNotification`,
`DailyNotificationReceiver.scheduleNextNotification`) re-arms the *next*
occurrence unconditionally on every fire, **never checking the `enabled` flag**.

---

## 2. Why the defensive sweeping exists (and why it can be deleted)

The `schedules` table (`DatabaseSchema.kt:41-55`) is shared by *four* different
generators, distinguished only by a free-text `kind` column and by ID-prefix
*conventions* that are not enforced anywhere:

| # | Generator | Schedule id pattern | Where | Used by TimeSafari? |
|---|---|---|---|---|
| 1 | **Static reminder** (the only thing TimeSafari uses) | caller-provided `id`, e.g. `"daily_timesafari_reminder"` | `scheduleDailyNotification`/`scheduleDailyReminder` → `ScheduleHelper.scheduleDailyNotification` (`DailyNotificationPlugin.kt:2743-2826`) | **Yes — the only path in use** |
| 2 | **Dual/API-retrieval** (planned, not yet used) | `dual_fetch_<ts>` / `dual_notify_<ts>` | `scheduleDualNotification` → `ScheduleHelper.scheduleDualNotification` (`DailyNotificationPlugin.kt:2890-2936`) + `DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm` (`DualScheduleNotifyScheduler.kt:19`) | No (planned for future) |
| 3 | **Standalone user notification** (legacy, separate public API) | `notify_<ts>` | `scheduleUserNotification` plugin method (`DailyNotificationPlugin.kt:1364`) → `ScheduleHelper.scheduleUserNotification` (`DailyNotificationPlugin.kt:2949-2980`) | **No** — not called anywhere in `crowd-funder-for-time-pwa/src`; only referenced in the plugin's own docs/examples |
| 4 | **Orphan rollover fallback** (accidental, not a real feature) | `daily_rollover_<ts>` (regenerated fresh — never stabilizes) | Inline fallback inside `DailyNotificationReceiver.scheduleNextNotification` (`DailyNotificationReceiver.java:416`) and `DailyNotificationWorker.scheduleNextNotification` (`DailyNotificationWorker.java:614-616`), triggered when the rearm code can't resolve a stable schedule id from intent extras/content id | **No** — pure bug-patch byproduct |

Generators #1 and #2 are real, intentional features (static reminders are live;
dual/API-retrieval is planned). Generators #3 and #4 are **not** part of either
of those — they are independent legacy/accidental code:

- **#3 (`scheduleUserNotification`)** is its own standalone public plugin
  method, parallel to (not called by) the dual flow. Confirmed unused by the
  app — eliminable as dead surface area in its own right.
- **#4 (`daily_rollover_*` fallback)** is not tied to any feature; it's a
  brittle fallback branch inside the *shared* rearm logic that can fire for
  *any* notify schedule when ID-threading fails, and it spins off a fresh
  unstable orphan id every time it triggers — i.e. it is itself a source of
  the exact "stray schedule" problem that breaks cancellation.

**Conclusion:** the cleanup-sweep machinery in `scheduleDailyNotification`
exists to paper over collisions between these four generators sharing one
table/AlarmManager namespace. If the static reminder (the only feature
TimeSafari actually uses today) is moved into its **own isolated `kind`** with
a single, always-known, stable id, none of that sweeping is needed for it —
scheduling and cancelling become trivial single-row operations, and the
"cancel doesn't stop it" bug is structurally impossible (there is nothing else
to collide with).

---

## 3. Refactor plan

### 3.1 Give static reminders their own `kind`

- Change the `kind` value used for static reminders from `"notify"` to a new,
  distinct value, e.g. `"static_reminder"`.
- Touch points (search for `kind = "notify"` and `isStaticReminder`):
  - `ScheduleHelper.scheduleDailyNotification` (`DailyNotificationPlugin.kt:2784`,
    `Schedule(... kind = "notify" ...)`) → change to `"static_reminder"`.
  - Any `getByKind("notify")` / `getByKindAndEnabled("notify", ...)` calls that
    are meant to operate on static reminders specifically (audit all call
    sites at `DailyNotificationPlugin.kt:702, 727-728, 1661, 1722, 2716-2717,
    2852, 3079, 3283, 3288` — most of these are for the dual/fetch machinery
    and should stay `"notify"`/`"fetch"`; only the ones that exist purely to
    find/clean the static reminder's row need to move to `"static_reminder"`,
    and after this refactor most of them become unnecessary for the static
    path — see 3.2).
- This alone makes `cleanupExistingNotificationSchedules(excludeScheduleId)`
  (which filters `getByKind("notify")`) blind to the static reminder row,
  which is what we want — it should never need to "clean up" the one
  legitimate static reminder.

### 3.2 Simplify scheduling for static reminders

Replace the sweep-and-recreate dance in `scheduleDailyNotification`
(`DailyNotificationPlugin.kt:1130-1190`) with a direct upsert-in-place:

- Remove the call to `ScheduleHelper.cleanupExistingNotificationSchedules()`
  (`DailyNotificationPlugin.kt:1140-1150`) for the static-reminder path — it
  exists to prevent collisions with *other kinds*; with its own `kind`, the
  static reminder can never collide.
- Remove the call to `ScheduleHelper.cancelFetchRelatedWorkManagerJobs()`
  (`DailyNotificationPlugin.kt:1155-1158`) for the static-reminder path —
  static reminders never enqueue prefetch/fetch work in the first place
  (already explicitly skipped, see comment at `DailyNotificationPlugin.kt:2775-2779`),
  so there is nothing to cancel.
- Keep `NotifyReceiver.cancelNotification(context, scheduleId)` +
  `scheduleExactNotification(...)` (the actual alarm arm/rearm) — that part is
  correct and necessary (it ensures "one alarm per id" via stable requestCode).

### 3.3 Simplify cancellation for static reminders

Rewrite `cancelDailyReminder` (`DailyNotificationPlugin.kt:754-779`) to be a
pure single-row operation, since with its own `kind` there is now guaranteed
to be at most one row to deal with:

```kotlin
@PluginMethod
fun cancelDailyReminder(call: PluginCall) {
    try {
        val reminderId = call.getString("reminderId")
            ?: call.getString("id")
            ?: return call.reject("cancelDailyReminder: missing reminderId")

        NotifyReceiver.cancelNotification(context, scheduleId = reminderId)

        kotlinx.coroutines.runBlocking {
            val db = getDatabase()
            db.scheduleDao().deleteById(reminderId)          // or setEnabled(false) — see note
            db.notificationContentDao().deleteById(reminderId) // if such a method exists; else equivalent cleanup
        }
        call.resolve()
    } catch (e: Exception) {
        Log.e(TAG, "cancelDailyReminder failed", e)
        call.reject("cancelDailyReminder failed: ${e.message}")
    }
}
```

Note: decide `deleteById` vs `setEnabled(id, false)`:
- `deleteById` is cleaner (no stale rows ever accumulate) but means
  `BootReceiver`'s `getEnabled()` sweep simply won't find the row — which is
  correct (nothing to reschedule).
- `setEnabled(id, false)` keeps history/diagnostics but requires every rearm
  path to check `enabled` before re-arming (see 3.4) — more moving parts.
- **Recommendation: `deleteById`.** It's the simplest correct option and
  matches "cancel means gone," not "cancel means disabled-but-present."

### 3.4 Make the rearm chain check for cancellation

Even with `deleteById`, there is a narrow race: the alarm could already be
in-flight (fired, worker running) at the moment the user cancels. Today,
`scheduleNextNotification` in both `DailyNotificationWorker.java:547-667` and
`DailyNotificationReceiver.java:401-...` (the latter looks unreachable — see
3.6) **unconditionally** re-arms the next occurrence with no DB check.

Add a guard at the top of `DailyNotificationWorker.scheduleNextNotification`:
look up the schedule row by id (`ScheduleHelper.getScheduleBlocking`); if it's
missing (deleted) or `enabled == false`, log and return without re-arming.
This closes the cancel race definitively and is a small, isolated change.

### 3.5 Remove generator #3 — `scheduleUserNotification`

**Confirmed unused in any executable path** — not called anywhere in
`crowd-funder-for-time-pwa/src`, and the only "usages" in the plugin repo
itself are inside doc/example files
(`test-apps/daily-notification-test/docs/VUE3_NOTIFICATION_IMPLEMENTATION_GUIDE.md`,
`examples/timesafari-integration-example.ts`) — not actual test-app source or
test suites.

**Important nuance found on closer look:** those two doc/example usages both
call it as a one-shot, event-triggered notification —

```typescript
await DailyNotification.scheduleUserNotification({
  schedule: 'immediate',
  title: change.title,
  body: change.message,
  ...
})
```

— i.e. "something happened in the app right now, show a notification." That's
a *third* semantic, distinct from both #1 (recurring static daily reminder)
and #2 (recurring fetch-then-notify cycle). **But it's also non-functional**:
the real implementation has no special case for `schedule: 'immediate'`.
`parseUserNotificationConfig` (`DailyNotificationPlugin.kt:2475-2484`) passes
the `schedule` string straight through, and
`ScheduleHelper.scheduleUserNotification` runs it through
`calculateNextRunTime` → `ScheduleCronUtils.calculateNextRunTimeMillis(schedule)`
(`DailyNotificationPlugin.kt:2487-2489`) — a **cron parser** that has no
concept of `"immediate"`. So even the one documented usage pattern would fail
or misbehave if anyone actually tried it. The docs describe a capability that
was never built; `scheduleUserNotification` itself is just a thin "schedule
one bare cron-based notification alarm" primitive that nothing exercises.

**Recommendation: remove it, and do not resurrect the same capability under
the same API by patching in `'immediate'` support.** If "notify the user
immediately when the app detects an event" is still wanted later, design it
as its own clearly-named one-shot API (e.g. `notifyNow(options)` /
`showImmediateNotification(options)`) with **no `schedule`/cron field at
all** — just `title`/`body`/optional `triggerAtMillis` (default "now"),
routed through a "display once, never rearm" path. That keeps the three
concerns (recurring static reminder / recurring fetch+notify cycle / one-shot
event notification) distinguished by API *shape*, not by a magic string a
cron parser silently chokes on.

**[DONE 2026-06-19]** Removed from Android only:
- `@PluginMethod fun scheduleUserNotification` (was `DailyNotificationPlugin.kt:1363-1411`)
- `ScheduleHelper.scheduleUserNotification` (was `DailyNotificationPlugin.kt:2899-2935`)
- No separate `methods.append(...)` registration existed — `@PluginMethod` annotation
  is the only registration mechanism in this codebase, so nothing else to clean up there.
- Verified via `./gradlew :daily-notification-plugin:compileDebugKotlin` — compiles clean,
  no other call sites referenced either removed symbol.

**Scope correction — do NOT remove the TS/iOS layer.** `scheduleUserNotification` in
`src/definitions.ts:543` / `src/web.ts:231` is a **cross-platform** method signature,
and on iOS it is genuinely load-bearing: `DailyNotificationScheduleHelper.scheduleDualNotification`
(`ios/Plugin/DailyNotificationScheduleHelper.swift:98-107`) takes a
`scheduleUserNotification` closure that's bound to the real
`@objc func scheduleUserNotification` (`ios/Plugin/DailyNotificationPlugin.swift:379`) —
i.e. on iOS this *is* the dual flow's notification half, not a dead generator.
Android's version was a standalone, never-wired-up duplicate of that concept
(Android's actual dual flow uses `scheduleDualNotification`/`DualScheduleNotifyScheduler`
instead and never called `ScheduleHelper.scheduleUserNotification`) — so removing it
from Android only does not reduce iOS capability. Leave:
- `src/definitions.ts`/`src/web.ts` — keep the TS signature
- README section `#### scheduleUserNotification(config)` (lines ~295-306) — already
  documents the correct cron-based shape (`schedule: string; // Cron expression`),
  no `'immediate'` mentioned, nothing broken here
- iOS Swift implementation — untouched, see
  [`PLAN-ios-static-reminder-fix.md`](./PLAN-ios-static-reminder-fix.md) §7 for why

Still worth doing (low-priority, independent of this fix, not yet done): strip or
correct the broken `schedule: 'immediate'` example in
`test-apps/daily-notification-test/docs/VUE3_NOTIFICATION_IMPLEMENTATION_GUIDE.md:226-240`
and `examples/timesafari-integration-example.ts:101-109` — that pattern was never
implemented on either platform's actual cron parser, regardless of the Android
generator-#3 cleanup above.

This eliminates the Android-only `notify_<ts>` id convention entirely — one less
thing that can collide in the shared `schedules` table — without touching the
cross-platform API surface or iOS's dual-flow capability.

### 3.6 Remove generator #4 — `daily_rollover_*` fallback (and dead code around it)

- In `DailyNotificationWorker.scheduleNextNotification`
  (`DailyNotificationWorker.java:556-563, 613-616`): once static reminders
  always carry a known, stable `schedule_id` intent extra (true today —
  `NotifyReceiver.scheduleExactNotification` always sets it,
  `NotifyReceiver.kt:297`), the `scheduleIdForRollover == null` branch should
  be unreachable for the static-reminder path. After confirming (via logging
  or a temporary assertion in a debug build) that it never fires for static
  reminders, delete the `"daily_rollover_" + System.currentTimeMillis()`
  fallback branches and the `getCanonicalRolloverScheduleBlocking` resolution
  hack (`DailyNotificationWorker.java:564-571`,
  `ScheduleHelper.getCanonicalRolloverScheduleBlocking` at
  `DailyNotificationPlugin.kt:2844-2853`) that exists solely to paper over it.
- `DailyNotificationReceiver.handleNotificationIntent` /
  `scheduleNextNotification` (`DailyNotificationReceiver.java:211-...,
  401-461`) appear to be **dead code** — `onReceive` only calls
  `enqueueNotificationWork`/`enqueueDismissalWork` (`DailyNotificationReceiver.java:62-84`),
  never `handleNotificationIntent`. Confirm with a repo-wide search for
  callers, then delete the whole block (it duplicates, slightly differently,
  what `DailyNotificationWorker` does — a second source of the same
  `daily_rollover_*` bug pattern).

### 3.7 Clean up DB after refactor

- Add a one-time migration (or lazy cleanup on plugin init) that deletes any
  pre-existing `notify_<ts>` and `daily_rollover_<ts>` rows from `schedules`
  (and their `notification_content` counterparts) — these are exactly the
  kind of orphan that causes the bug, and existing installs may already have
  them sitting in the DB from earlier buggy runs.
- Re-point the existing `"daily_notification"` / app-supplied static reminder
  row to `kind = "static_reminder"` in that same migration (`UPDATE schedules
  SET kind = 'static_reminder' WHERE kind = 'notify' AND id = :appReminderId`,
  or simpler: `WHERE id NOT LIKE 'dual_%' AND id NOT LIKE 'notify_%' AND id NOT
  LIKE 'daily_rollover_%'` — i.e. whatever's left after excluding known
  dual/legacy/orphan patterns is the static reminder).

---

## 4. What NOT to touch (dual/API-retrieval flow — planned for future use)

Per the user: the dual/API-retrieval flow ("device wakes by timer or push →
calls API → stores content → reschedules next check → OS later wakes app to
show stored content") is **not used today but is planned**. Leave generator #2
and its supporting machinery alone:
- `scheduleDualNotification`, `ScheduleHelper.scheduleDualNotification`
  (`DailyNotificationPlugin.kt:2890-2936`)
- `DualScheduleNotifyScheduler` (`DualScheduleNotifyScheduler.kt`)
- `FetchWorker`, `DualScheduleFetchRecovery`, content-cache (`cacheScope =
  "dual"`), circuit breakers, etc.

These keep using `kind = "notify"`/`"fetch"` and the existing sweep machinery
— which is fine, since that machinery's whole job is to keep *that* subsystem
internally consistent. The point of 3.1 is just to make sure the static
reminder's single row is no longer inside that sweep's blast radius (and vice
versa).

---

## 5. iOS

**Investigated — see [`PLAN-ios-static-reminder-fix.md`](./PLAN-ios-static-reminder-fix.md)
for the full analysis and fix plan. Summary:**

iOS does **not** have an analogous shared-table-with-multiple-generators
design (no Room/SQLite `schedules` table, no AlarmManager, no rearm chain —
it uses OS-native `UNUserNotificationCenter` with `UNCalendarNotificationTrigger`,
which natively supports "repeat forever" and needs none of that machinery).

But it has its own, structurally different bug with the same user-visible
symptom: the client's `scheduleDailyNotification`/`cancelDailyReminder` calls
land in **two unrelated, non-communicating subsystems** inside
`DailyNotificationPlugin.swift` —
- `scheduleDailyNotification` (:1392) registers under a fresh
  `content.id = "daily_<timestamp>"` each call, with `repeats: false`
  (likely **one-shot, not actually recurring** — see the linked plan's §4)
- `cancelDailyReminder` (:1174) only ever removes `"reminder_\(id)"`-shaped
  identifiers — which the schedule path above never registers, so cancel is a
  **silent no-op** that still reports success

Meanwhile a *third*, already-correct subsystem
(`scheduleDailyReminder`/`cancelDailyReminder`/`updateDailyReminder`, :1088-1300)
sits unused right next to it — a single `repeats: true` calendar trigger under
a stable caller-supplied id, with matching cancel. The fix is to repoint
`scheduleDailyNotification` onto that existing correct mechanism rather than
to build Android-style sweep/rearm machinery iOS doesn't need.

This is a fully independent fix (different files, different language, no
shared code) — see the linked plan's §5 for the side-by-side comparison.
Execute in either order, or in parallel.

---

## 6. Verification plan

1. **Unit/integration**: existing plugin test suite (`npm test`) plus new
   tests asserting:
   - Scheduling a static reminder creates exactly one `schedules` row with
     `kind = "static_reminder"`.
   - Cancelling deletes that row and cancels its `PendingIntent`
     (`isAlarmScheduled` returns false afterward).
   - No `notify_<ts>`/`dual_*`/`daily_rollover_*` rows are created or touched
     by the static-reminder schedule/cancel/rearm cycle.
   - **Note:** `grep`ing `android/src/test/java/.../*.kt` today shows **zero**
     existing coverage of `cancelDailyReminder`, `scheduleNextNotification`, or
     `cleanupExistingNotificationSchedules` — this bug had a test-coverage
     blind spot. Add a regression test asserting "after `cancelDailyReminder`,
     `scheduleDao().getByKind('notify')` (or `'static_reminder'` post-3.1)
     returns zero rows" so this can't silently regress.

2. **Manual on-device (Android emulator) — reproduce-then-verify sequence**:

   This environment already has `adb`, `emulator`, `gradle`, and two AVDs
   (`Pixel_3a_API_34_extension_level_7_arm64-v8a`, `Pixel_7`) plus a mature
   ADB-based toolkit in `test-apps/android-test-app/alarm-test-lib.sh` and
   `doc/alarms/PHASE1-EMULATOR-TESTING.md` using exactly the techniques below.
   `APP_ID = org.timesafari.dailynotification`.

   **Setup:**
   ```bash
   emulator -avd Pixel_7 -no-snapshot-load &
   adb wait-for-device
   # install a debug build of the test app or the TimeSafari PWA Android shell
   ```

   **Step A — schedule, then capture a baseline of native scheduler + DB state**
   (don't trust "the call resolved" — inspect the actual OS/DB state, since
   that's exactly where this bug hides):
   ```bash
   # AlarmManager state:
   adb shell dumpsys alarm | grep -A 5 -i "org.timesafari.dailynotification"

   # DB state — the crucial one; reveals orphans dumpsys alone won't make obvious:
   adb shell run-as org.timesafari.dailynotification sqlite3 \
     databases/daily_notification_plugin.db \
     "SELECT id, kind, enabled, nextRunAt FROM schedules;"
   ```
   Record the count and exact `id`/`kind` values — this is the "before" baseline.
   Pre-fix, expect to possibly see stray `daily_rollover_*`/`dual_notify_*`/
   `notify_<ts>` rows even before cancelling, which alone partially explains
   the bug.

   **Step B — cancel via the app UI, then immediately re-run the same two
   commands.** This is the actual test — not "did the app report success"
   (it always does), but: does `dumpsys alarm` still show an entry, and does
   `schedules` still contain *any* `kind='notify'`/`'static_reminder'` row
   (enabled or not)? Pre-fix expectation per §1: the targeted row gets
   `enabled=0` but isn't deleted, and/or a stray row survives untouched.

   **Step C — prove it actually still fires** (closes the loop — a surviving
   row could theoretically be inert):
   ```bash
   # Time-travel the emulator clock forward (technique already documented at
   # doc/alarms/PHASE1-EMULATOR-TESTING.md:412-418):
   adb shell date -s "$(date -v+1d '+%Y-%m-%d %H:%M:%S')"
   adb logcat -c
   adb logcat -s DailyNotification:* NotifyReceiver:* DailyNotificationWorker:* \
     | grep -i "notification displayed\|scheduleNextNotification\|rearm"

   # Optional visual proof — capture and pull a screenshot:
   adb shell screencap -p /sdcard/after-cancel.png && adb pull /sdcard/after-cancel.png

   # Reset the clock when done:
   adb shell date -s "$(date '+%Y-%m-%d %H:%M:%S')"
   ```
   Seeing "notification displayed successfully" / rearm log lines *after*
   cancelling is the smoking gun.

   **Step D — re-run the full A→B→C sequence after applying the fix.**
   Expected post-fix result: zero `dumpsys alarm` entries and zero `schedules`
   rows immediately after cancel, and no log lines / no notification after the
   time-jump. This A→B→C→(fix)→A→B→C before/after comparison *is* the proof
   the refactor worked — anything less ("the call resolved without error") is
   exactly the false confidence that let this bug ship originally.

   Also still worth doing once the structural fix is confirmed:
   - Let it fire and rearm at least once more (or use
     `rolloverIntervalMinutes` for fast iteration); confirm the row's id stays
     stable across rearms
   - Reboot the device after cancelling; confirm `BootReceiver` does not
     resurrect it

3. **Regression**: confirm the dual/API-retrieval test paths (if any exist in
   `test-apps/`) still pass unchanged — this refactor must not touch generator
   #2's behavior.

4. **Fast deterministic checks to run alongside the above** (don't touch the
   native bug directly, but catch regressions in surrounding plumbing and are
   cheap to run on every change):
   - `npm test` — Jest suite for the TS/JS plugin layer (web stub, type defs)
   - `cd android && ./gradlew test` — Kotlin unit tests (currently has the
     coverage gap noted in §6.1 above)

---

## 7. Suggested execution order

1. 3.5 (remove `scheduleUserNotification`) — fully independent, smallest blast
   radius, immediate dead-code win.
2. 3.1 + 3.2 + 3.3 (new `kind`, simplified schedule/cancel) — the core fix.
3. 3.7 (DB migration/cleanup for existing installs).
4. 3.4 (`enabled` guard in rearm — closes the race).
5. 3.6 (remove `daily_rollover_*` fallback + dead `handleNotificationIntent`
   code) — do this last, after 3.1-3.4 make it provably unreachable for the
   static path; double check it isn't somehow load-bearing for the dual flow
   first (it shouldn't be — dual ids never start with `"daily_"` and dual
   rearming goes through `DualScheduleNotifyScheduler`, not this fallback —
   but confirm before deleting).
