0010 — OS push for server-originated events
Status: Implemented, transport only (tracking app) · Date: 2026-08-26
Amends — does not supersede — 0009, and closes the gap parked by 0007.
Context
Two notification systems existed, and neither could reach an athlete whose app was closed for something the server knew about:
- Device reminders (0009) fire from the phone, on a schedule registered in advance. The OS never runs our code at fire time, so both the condition and the copy are frozen when we schedule. That is exactly right for "07:00, unless you already checked in" and structurally incapable of "your upload finished".
- The portal's in-app feed is SSE, foreground-only, portal-only. 0007 said so in as many words: "Foreground realtime only — not a Firebase replacement… Waking a backgrounded / killed app still needs FCM / APNs."
0009 rejected push on a premise that was true when written:
There was no push infrastructure at all: no FCM or APNs configuration, no notification entitlement, no
POST_NOTIFICATIONS, no device-token endpoint in the API, and no service worker for the web fallback.
That premise no longer holds. POST_NOTIFICATIONS and the notification
permission arrived with 0009's own implementation, the google-services classpath
has been in the Android build since, and — the deciding change — the backend has
built the server half: a device-token registry keyed on a bundle id, APNs and
FCM transports, and an event model that decides targeting per app.
0009 also named the condition under which it expected to be revisited:
If a future condition depends on someone else's overnight action (a coach assigning a workout), that case genuinely needs push, and this decision would be revisited rather than stretched.
That is the case now — session-upload completion, licence changes, export readiness — so this is the revisit, taken deliberately rather than by stretching 0009.
Decision
Add OS push to the tracking app as a second, parallel notification system.
iOS via APNs, Android via FCM, both behind @capacitor/push-notifications.
Ship the transport before the handling. The app registers, carries the two identifying headers on every native request, and — when a push arrives — opens a drawer showing the payload verbatim. It does not route, deep-link, or mark anything read.
That split is the substantive part of this decision, so the reasoning matters: the backend's contract names five action types but only two of the categories it can send, by example, with no closed list. Routing written now would be routing written against a guess, on payloads nobody has yet seen leave a real device. The drawer is how the guess is replaced with evidence, and the cost of being wrong is a drawer instead of a wrong destination.
Consequences that follow directly:
bundle-idanddevice-tokenride in the existingisNativebranch ofstandardApiHeaders(), not a new registration endpoint. The backend upserts them only-when-present, so the many requests that fire before registration completes are harmless and no separate call is needed.categoryis parsed as an open string;actionTypeis narrowed to a known value ornull. An unrecognised or future action must never drop an otherwise-valid push. This mirrorsnotifications/schema.tsexactly.- A payload that fails to parse still surfaces. Hiding it would defeat the purpose of shipping the transport first.
- No routing on
notificationId. The server deletes older events of the same category, so a tap on an older banner can name a deleted one.
Also decided
- No new permission prompt. iOS's notification permission is app-wide, so 0009's contextual-prompt rule is not merely honoured here but reused: anyone who enabled the daily reminder is already granted, and is registered silently. For everyone else the ask sits in a settings row they tap, never at launch or login. This is the one place the two systems are coupled, and it is the OS's coupling, not ours.
- A status row, not a toggle. The app cannot revoke an OS grant and the backend has no deregistration endpoint, so an "off" position would keep receiving pushes while claiming otherwise. The row reports the OS state and offers the only action that changes it.
- Visible pushes only. No silent/background handling — that needs an iOS background mode, and the backend removed its silent path.
- Tracking app only. The portal stays SSE-only and its
channelwire format staysportal|mobile.
Rejected: routing now, drawer never
The handoff specifies the eventual routing, so building it was available. Two
things argued against it. The category list is admittedly incomplete, so the
switch would have been provably unfinished on day one. And openSession — the
action attached to the one category most likely to fire — has nowhere to go:
the tracking app has no session-detail screen, and its static export forbids
dynamic route segments, so that destination is a feature, not a line of routing.
Shipping a routing table where the most common case silently falls through would
read as broken in a way the drawer does not.
Rejected: one notification system
Folding push into the reminders domain, or reminders into push, was considered
and dropped. They differ at the root — one is composed on-device ahead of time
and fires without a network, the other is composed server-side at the moment it
happens — and the code that would be shared is a permission check. sync.ts on
the push side is a fraction of the reminders one precisely because a push
carries its own content; merging them would give the smaller problem the larger
one's machinery.
Consequences
- 0009 stands, minus its "no push infrastructure" premise. Local reminders keep the wall-clock, condition-bearing, offline-capable case; nothing about the rolling window, deterministic ids, device-local opt-in or fail-open changes.
- 0007's parked gap is closed. Foreground realtime remains SSE; backgrounded delivery is now push.
- Two native prerequisites are now permanent build inputs: the Push
Notifications capability on the iOS App ID, and a
google-services.jsonregistering every Android package name (debug carries anapplicationIdSuffix, so a single-package file fails the debug build outright). - The client cannot be fully verified without the backend. Until it deploys, what is provable on-device is that registration reaches APNs/FCM and yields a token. Whether anyone can send to that token is the other half.
- The next decision is deferred, not skipped. Once real payloads have been
observed, the routing table, the mark-as-read call and an
openSessiondestination are the follow-on — and the last of those may warrant its own ADR, since it means a session-detail screen the tracking app has never had.
See Push notifications for the implementation.