Skip to content

ADR-0016 · Reminders domain model ​

Status: 🟢 Accepted — adopted 2026-07-27 · Depends on: ADR-0007 (quota is entitlement-driven), ADR-0009 (reminders are cross-archetype), ADR-0012 (schemas → domain → data), ADR-0014 (TO authenticated on every policy) · Related: QRS-209, QRS-210, QRS-211

Supersession / amendment notice

PROMOTED IN SCOPE (2026-08-07) — this is the platform recurrence model, not a reminders detail. The scalability assessment §A2 found that this ADR's design — a recurrence rule plus sparse exception rows, with occurrences computed on read — is exactly the right model for every recurring commitment on the platform: a dairy standing order ("1L daily, skip Sundays, paused 3-7 Aug" — the pause is an exception row), a gym membership, an AMC, a tuition term. packages/domain/src/reminders/recurrence.ts is pure, DST-aware and carries 117 node --test cases, so this is reuse rather than new code — the single largest piece of unused leverage in the repo. Treat its folder name as historical.

The one-line thesis

Store the recurrence rule, compute occurrences on read, and persist an occurrence row only when its state diverges from the rule. The legacy tables are archived, not dropped — because the pre-flight that was supposed to confirm they were empty found 11 rows on Prod.

Context ​

Reminders is one of the features the design project itself identifies as "largely identical across all business domains" — it is framework, not vertical. A schema for it already existed in the baseline squash (20260710134136_baseline_schema_from_prod.sql), inherited verbatim from production and never designed against the standards program.

The first draft of the implementation plan chose to adopt that schema, on the reasoning that a table which already exists is cheaper than a migration. A Principal-Architect review overturned that. This ADR records why, because the reasoning generalises: "the table already exists" is an argument about cost, and it is only decisive when the existing shape can express the requirement.

Decision 1 — the legacy schema is replaced, not adopted ​

The decisive finding: it cannot represent a recurring reminder at all ​

The legacy public.reminders carries is_recurring, recurrence_pattern and recurrence_end_date alongside a single due_date, a single is_completed, and a single notification_sent.

A recurring series has N occurrences, each with its own completion and notification state. In that shape, completing one occurrence must either complete the entire series or nothing at all — there is no third option available. This is not a missing constraint that could be tightened; it is a modelling error, and recurring reminders ("file GST monthly", "reorder stock weekly", "renew the trade licence annually") are the highest-value case for every archetype we ship.

The live data confirms the incoherence rather than merely implying it. All 11 Prod rows carry is_recurring = false with recurrence_pattern = 'daily' — the column default leaking into every row, because nothing constrains the two fields to agree. A reader cannot distinguish "not recurring" from "recurring daily, flag not set".

Three further flaws, each independently sufficient ​

FlawWhy it is disqualifying
category varchar(50) free-text and a reminder_categories table, with no FK between themTwo competing sources of truth. Already drifted in production: the column default is lowercase 'general' while every stored row holds 'General', 'Work' or 'Personal'
reminder_categories.color stores raw hex (DEFAULT '#3B82F6')A hard-coded colour baked into the database, against the zero-hard-coded-colour invariant. Tokens cannot theme it, so it is wrong in dark mode by construction, and no amount of frontend work fixes a value the DB supplies
No workspace_idADR-0001 has every domain entity carrying one. Adding it later is a backfill; adding it now, nullable, costs nothing

Consequence for profiles ​

profiles.reminder_count is a cached counter with no maintainer anywhere in the tree. Measured on Prod: 0 for all 11 profiles while reminders held 11 rows — already 100% wrong. It is dropped rather than repaired with a trigger, because nothing reads it and the new model derives counts from rows (QRS-211).

reminder_notifications_enabled and reminder_notification_time are kept. The implementation plan contained a contradiction here — it directed dropping "the three profiles reminder columns" while its own scheduler specification "honours reminder_notifications_enabled". Resolved in favour of the scheduler: these two are well-shaped preferences and are now actually read.

Decision 2 — archive the legacy tables; do not drop them ​

This decision was forced by evidence, and is the more instructive half of this ADR.

The plan's first blocking pre-flight gate read: "Prove both projects are empty. A destructive migration on unverified data is not acceptable regardless of expectation." Running it, per project ref rather than by connection label (the QRS-179 trap):

ProjectRefremindersreminder_categories
qr-setu-prodygmqxyrbnemhwkiyoboc11 rows, 4 users0
qr-setu-devdyhjofjjuazhyqcvlrkx00

The premise the plan was built on — "Prod confirmed to hold no reminder data" — was false. No amount of code inspection would have revealed this: the only consumers of these tables live in legacy/**, which is neither built nor linted, so a search for live callers returns clean and tells you nothing whatsoever about stored rows.

So the migration moves both tables into a legacy schema instead of dropping them:

  • Rows are preserved, and remain queryable by an operator.
  • PostgREST reach is removed — Supabase exposes only configured schemas, and legacy is not one.
  • Privileges are revoked as well, so reachability does not depend on a single config setting holding. Belt and braces, deliberately: "not in the exposed schema list" is a good control but a poor only control.
  • The canonical public.reminders name is freed for the new model, which is the only thing the drop was actually needed for.

The real DROP becomes a separate one-line contract migration, gated on the data owner confirming the 11 rows are disposable.

This is what the standard already required

CLAUDE.md mandates expand-contract: "add-nullable → backfill → switch → drop; never a breaking change in one step." A one-shot DROP TABLE was never compliant. The pre-flight did not force a workaround — it forced us onto the process the standard already specified. The safe path and the compliant path turned out to be the same path.

Decision 3 — rule + sparse exception rows ​

Store the rule. Compute occurrences on read. Persist an occurrence row only when its state diverges.

reminders                         -- the RULE (one row per reminder, recurring or not)
  id, user_id → auth.users on delete cascade
  workspace_id uuid NULL                                 -- forward-compat (ADR-0001)
  title (<=200), description
  category_id → reminder_categories on delete set null    -- single source of truth
  priority   CHECK (low|medium|high|urgent)
  starts_at  timestamptz                                  -- the first occurrence, an instant
  timezone   text                                         -- IANA zone; local-time correctness
  recurrence jsonb NULL                                   -- NULL = one-off; RRULE subset
  recurrence_until timestamptz NULL
  status     CHECK (active|archived)
  created_at, updated_at

reminder_occurrences              -- sparse EXCEPTIONS only; absence means "as the rule says"
  id, reminder_id → cascade, user_id                      -- denormalised for RLS + index
  due_at timestamptz
  state  CHECK (completed|skipped)
  completed_at, notified_at
  UNIQUE (reminder_id, due_at)                            -- one exception per occurrence

reminder_categories
  id, user_id, name
  color_token text                                        -- a TOKEN KEY, never hex
  icon, UNIQUE (user_id, name)

Alternatives considered ​

OptionWhy not
Full materialisation — expand every occurrence into rows up frontUnbounded recurrence has no natural horizon, so it needs an arbitrary cutoff plus a cron job to extend it. Row count grows without bound for a feature whose entire read pattern is "the next few". Editing a rule means rewriting N rows
Rule only, no occurrence rowsCannot represent "I completed Tuesday's but not Wednesday's" — the same defect that disqualified the legacy schema
Legacy shape, constraints tightenedDisqualified above: one is_completed for N occurrences is not fixable by constraint

The trade-off, stated ​

A future server-side digest worker cannot answer "which users have something overdue right now" without scanning and expanding every rule, because overdue-ness is computed, not stored. That is a real cost and it is accepted for now: all consumption in R1 is per-user and client-bounded. The migration path is additive — a materialised projection can be built later without changing this model, which is why this is a deferrable cost rather than a trap.

Why CHECK and not Postgres ENUM ​

Extending a CHECK constraint is expand-contract friendly: add the new constraint, migrate, drop the old. ALTER TYPE ... ADD VALUE cannot run in a transaction block and cannot be reversed, which makes an enum the harder thing to change later — the opposite of what a growing priority/state vocabulary needs.

Why the timezone lives on the reminder, not just the profile ​

timestamptz is correct for an instant, but "every day at 09:00 local" is not an instant — it is a rule that resolves to a different instant depending on zone and DST. Storing the IANA zone per reminder (defaulted from the device at creation) handles the case a profile-level column cannot: a merchant who travels keeps "09:00 Asia/Kolkata" anchored where they set it, rather than silently shifting to wherever they opened the app.

manage-reminder requires a client-supplied idempotency_key, persisted under a UNIQUE constraint so a replay returns the original result instead of creating a second row.

This is not defensive speculation. It has already happened three times on Prod, and the duplicate rows are still there: three pairs with identical title and due_date, same user, created 0.7 s, 1.1 s and 7.7 s apart — 6 of the 11 rows are duplicates of each other (QRS-210). The legacy write path was a direct supabase.from().insert(), so no layer existed that could have deduped; routing writes through an Edge Function is what makes the fix possible.

Decision 5 — occurrence computation is pure, and bounded by iOS ​

Recurrence expansion, DST-safe local resolution, overdue bucketing and next-N selection live in packages/domain/src/reminders/ as pure functions with no I/O, per ADR-0012's schemas → domain → data DAG.

This is not merely tidiness. iOS hard-caps pending local notifications at 64 per app, silently dropping the overflow. A scheduler that "reconciles every reminder" therefore fails at scale in the least visible way possible: the alerts a merchant most needs are the ones dropped. The correct design is a rolling horizon — select the soonest N occurrences within a budget below the cap, re-arm on launch and foreground — and "select the soonest N across all rules" is exactly a pure function over rules and exceptions. Making it pure makes the cap unit-testable rather than something discovered on a device with 80 reminders.

The platform constraint therefore shapes the model, which is why it belongs in an ADR and not only in a scheduler file.

Consequences ​

Positive

  • Recurring reminders are representable, per-occurrence, which was the point.
  • No cron job, no row explosion, no arbitrary expansion horizon.
  • Category colours become token keys, so dark mode works and the DB stops carrying design decisions.
  • The iOS 64-cap becomes an L1-testable property of a pure function.
  • 11 rows of production data survive a schema replacement that was planned to destroy them.

Negative / accepted

  • Server-side "who is overdue" needs materialisation that does not exist yet (additive later).
  • Occurrence computation runs on every client rather than once on a server — bounded, memoised, and cheap at R1 scale.
  • Two schemas (public + legacy) until the contract migration lands.
  • workspace_id is present but unenforced until multi-tenancy in R2; a nullable column that nothing validates is a mild honesty cost, accepted because the backfill it avoids is worse.

Compliance ​

StandardHow
RLS on every table, TO authenticated on every policy (ADR-0014)All three new tables, from the first line — the legacy tables' 8 unqualified policies are QRS-211
Expand-contract (CLAUDE.md)Archive now, drop in a separate gated migration
Idempotent mutations (CLAUDE.md)idempotency_key + UNIQUE, with a replay test
Zero hard-coded colourscolor_token, never hex
Entitlement-gated limits (ADR-0007)Per-user quota enforced in the EF, resolved from the tier
schemas → domain → data (ADR-0012)Pure recurrence logic in packages/domain, no I/O
Reminder text is privateNever in EF logs, Sentry breadcrumbs, or the default lock-screen preview