ADR-0030: Delivery-only Reminder primitive + channel routing — implementation shape¶
Status: Accepted (2026-06-10)
Context: Personal-assistant upgrade Phase 2 Track 2D. Parent: ADR-0005
(Task vocabulary + soft migration of src/iris_harness/reminders.py).
Context¶
ADR-0005 locked the new vocabulary: Reminder is the delivery layer
(notification-at-a-time pointing to a Task/Goal/Bill/Event) with no
status of its own; status lives on the target. The 974-line legacy
src/iris_harness/reminders.py (status-bearing rows + ICS files + markdown
ledger) stays untouched until callers drain organically.
Phase 2 needs the new Reminder primitive available now — followups
(Track 2B) are the immediate consumer ("remind me if no reply by
due_at") and bills (Phase 4) will be the second. The legacy module
cannot serve that role because its ReminderRecord carries the
status field ADR-0005 specifically removed.
This ADR records the shape calls made to ship the new primitive
without touching the legacy module, plus the channel-routing
subscriber that turns reminder.fired events into user-facing
notifications.
Decision¶
Ten implementation decisions:
-
New package
src/iris_harness/notifications/(notsrc/iris_harness/reminders_v2/orsrc/iris_harness/tasks/reminders.py). The primitive will absorb Bill (Phase 3) and Event (Phase 6) targets, so a tasks-subpackage home would force a future rename. The legacysrc/iris_harness/reminders.pycannot be reused as the module name.notifications/is the clean semantic for "delivery-time event about a target" without overloading either existing name. -
Schema lives in
data/tasks.db(tablenotification_reminders), not a newdata/notifications.dbordata/reminders.db. Phase 2 only ever points at Tasks and Goals — both in tasks.db — so a separate file would mean a cross-file FK for no benefit. Table name prefixed withnotification_to coexist with the legacy concept without risk of collision. Indexes:(target_kind, target_id)for visibility queries; partial onremind_at WHERE fired_at IS NULL AND dismissed_at IS NULLfor the tick path. -
Remindermodel carries no status field. Three terminal columns instead:fired_at,dismissed_at, plus the absence of either =pending. Rejected: a singlestatus: Literal["pending", "fired", "dismissed"]field — would mirror the legacy module's exact shape and re-introduce the conflation ADR-0005 set out to remove. The two-timestamp shape lets queries cheaply distinguish "fired but not dismissed" (delivered) from "dismissed but not fired" (cancelled before delivery). -
fireis idempotent on already-fired rows, raises on dismissed rows. Re-firing a fired reminder is a delivery retry (delivery dedup is the channel's job, not the store's) and returns the row unchanged without re-emitting the event. Firing a dismissed reminder is a logic error worth raising for. Rejected: idempotent on both (would silently deliver reminders the user explicitly cancelled). -
cancelis idempotent on already-dismissed rows, no-op on fired rows. Re-cancelling a dismissed reminder is safe. Cancelling a fired reminder doesn't undeliver — the notification already went out — so it returns the unchanged row. The CLI surfaces this distinction with an "already fired — no change" message instead of silent success. -
reschedulerejects terminal (fired or dismissed) rows. Forces create-new + cancel-old instead of reviving a terminal row. Reason: a fired reminder's row carries the timestamp of when delivery happened, which is observable history; mutating itsremind_atwould rewrite that history. A new row is cheaper than that footgun. -
CLI
iris reminder addauto-resolves the target prefix against tasks first, then goals, with--kindforcing one or the other when a prefix matches both. Rejected: requiring--kindon every add (operationally hostile when 90% of reminders are about tasks) or trying multiple kinds in parallel (the disambiguation message is clearer than a silent "first match wins"). -
--kind billand--kind eventraisePhase 3+/Phase 6deferral messages. The model'sTargetKindalready includes them so future phases need no model change, but the CLI explicitly refuses to create rows pointing at non-existent target stores. Rejected: accepting the kinds silently — would store rows with no target store to dereference, andreminder.firedconsumers would have to handle ghost targets. -
New heartbeat
notification_reminder_tickregistered alongside (not replacing) the legacyreminder_tick. Schedule:interval:60matching the legacy. Heartbeat handler constructs aReminderStorewith the runtime's default bus and callstick(). Heartbeats.yaml description is explicit that the two are distinct so operators don't conflate. Rejected: replacing the legacy heartbeat with the new one — ADR-0005's soft-migration stance is "both coexist until callers drain". -
Channel routing via a separate subscriber module
iris_harness.notifications.channels.subscribe_reminder_fired_to_channels. The store fires + emits the event; the subscriber turnsReminderFiredPayloadinto aChannelMessageand dispatches viaChannelGateway.send. Channel selection:payload.channelif registered AND not the literal string"default"; otherwise fall back to the runtime's configureddefault_channel. Body enrichment: looks up the target'stitlefromdata/tasks.dbto render"Task: <title> — <note>"instead of generic"Reminder: <note>". Soft-fails per-message — a connector raising does not crash the subscriber.
Alternatives considered¶
-
Reuse the legacy
src/iris_harness/reminders.pymodule. Rejected by ADR-0005 — the module's status field is the exact conflation we're moving away from, and the soft- migration stance requires both models to coexist. -
One unified table for reminders across all schemas (a new
data/iris.db.notificationstable). Cleaner for future cross-domain queries. Rejected: Phase 2 targets are all indata/tasks.db; one more file boundary buys nothing today and is one more migration risk later. -
Store
status: ReminderStatusinstead of the two timestamps. Mirrors the legacy shape exactly. Rejected per decision #3. -
Auto-complete the target task when its associated reminder fires. Rejected: a reminder fires to prompt the user, not to do the work for them. The user completes tasks; reminders surface them.
-
Channel routing inside the store's
firemethod (i.e.ReminderStorecallschannels.senddirectly). Rejected: couples the store to the channel layer; the store knows nothing about runtime configuration. The event-bus indirection lets tests assert against the event payload without instantiating a ChannelGateway and lets future subscribers (audit log, telegram dedup) attach without touching the store. -
Heartbeat that fans the firing out to channels itself (same shape as the legacy
_build_reminder_tick_handlerwhich callsruntime.channels.sendinline). Rejected: would skip the event-bus indirection and lose the "store fires → bus carries → subscriber delivers" separation. The legacy handler does it that way because the legacy module pre-dates the event bus; the new path doesn't have that constraint.
Consequences¶
Positive:
- The new primitive is available to every downstream
producer (followups today, bills in Phase 4, calendar prep
in Phase 6) with one stable contract:
ReminderStore.create(target_kind=..., target_id=..., remind_at=...)+ subscribe toreminder.fired. - The legacy module continues to function — its callers keep working unchanged — and the legacy heartbeat keeps delivering legacy-shaped reminders. No operator-visible flag day.
- The two-timestamp shape supports observability queries out of the box: "reminders fired in the last 24h", "reminders cancelled before delivery", "pending reminders past their remind_at" (= the system is failing to tick).
- 19 store + channel tests cover idempotence on every
terminal transition, reschedule rejection of terminal
rows, target-title enrichment, soft-fail on connector
failure, and end-to-end
store.fire()→ bus → connector.
Negative:
- Two heartbeat handlers (
reminder_tickandnotification_reminder_tick) running on the same 60 s interval. Trivial cost (both finish in <10 ms when the queue is empty); the duplication is the price of soft-migration. Cleaned up when the last legacy caller drains. - The CLI's
--kind bill/--kind eventrejection means Phase 3 and Phase 6 have to land their respective stores AND a per-kind target resolver before this CLI accepts the kind. The deferral message points at the correct phase so the operator isn't surprised. - Channel selection rule "use payload.channel if registered, else default" silently swallows a typo in the payload's channel name. The subscriber logs the fallback at WARN, but a stricter mode (raise on unknown channel) would be more debuggable; deferred until a real misroute is observed.
Reversibility: high. The package is one folder with no
cross-cutting touches; the heartbeat registration is one
line in _register_default_heartbeats; the subscriber
wiring is two lines next to it. Removing the new primitive
restores Phase 1 state exactly; the legacy module is
untouched throughout.
Related ADRs¶
- ADR-0005 (parent) — Task vocabulary + soft migration of the legacy reminders module. The "Reminder is delivery, not status" decision originates there; this ADR makes it real.
- ADR-0013 — event bus promotion convention. This module's
REMINDER_FIREDtopic lives with the producer per that convention. - ADR-0028 (sibling) —
email-followupskill that produces the followup tasks reminders will most often target. - ADR-0029 (sibling) — brief integration that could surface recently-fired reminders in a future slot once a real use case appears.
- A future ADR will handle the legacy reminder_tick retirement once its callers have drained.