Skip to content
C.W.K.
Stream
Lesson 03 of 06 · published

The Backend Became a Push Relay

~13 min · push, apns, live-activity, additive

Level 0Curious
0 XP0/80 lessons0/18 achievements
0/100 XP to next level100 XP to go0% complete

A nudge is not durable state

The moment a client can be asleep, the backend needs a way to say something happened. cwkPippa grew one: a relay that mints its own provider token, speaks HTTP/2 to Apple's notification service, keeps a hot-reloaded token store on disk, and writes one log row per attempt.

There is no retry. That is deliberate, and it is the whole design in one decision. A push is a nudge; the record is the record. If the notification is lost, the assistant row is still in SQLite and still in JSONL, and the next time the app opens it is simply there. Retrying a nudge buys nothing and risks buzzing a phone twice about a thing that already finished.

Principle: Additive, never load-bearing. No key file means no relay; no relay means no push; and every chat route returns exactly what it returned before. The supported state of a notification layer includes absent, or it is not a notification layer — it is a dependency wearing one's clothes.

What fires, and what deliberately does not

Three alert triggers, and the third line of each one is the interesting part.

A finished turn — one hook after the assistant row lands, in every brain's chat route, for the phone surfaces only. Note what the server does not do: it never tries to know whether the phone is still attached. Seven routes would each need to carry a detach flag, and the phone already knows what it is looking at — so the app suppresses its own banner when that conversation is open in the foreground. The knowledge lives where it already exists.

A finished scheduled job — but only the ones the heartbeat delivers itself, or ones that explicitly ask to push. Not every job that sends a message elsewhere. For most of those the brain decides whether anything was worth saying, and the scheduler cannot tell a quiet run from a loud one. A system that buzzes on every silent success teaches its owner to ignore it.

A failure — beside the existing backstop, never instead of it.

The dead-token protocol

Apple answers a send to an uninstalled app with a status that means this token is dead. The relay treats that as authoritative and drops every row carrying that token. This is the one piece of state a push system genuinely owns, and letting it rot turns a token store into a slow leak of sends to devices that no longer exist.

Live Activities, and a rule that bites

A Live Activity is the little live card on the lock screen. iOS lets an app start one only from the foreground — and the most valuable moment to start one is precisely when the app is not in the foreground, because the message came from the watch while the phone was in a pocket.

The way out is that the activity can also be started by push, from a token the app hands over in advance. So the app registers a start token; when it cannot start an activity itself it asks the backend to; the relay sends the start push carrying the app's own content state verbatim; the phone adopts the started activity and drives it locally while it is awake; and the finished turn ends it by push with the reply's first breath. A phone that slept through the whole turn still sees the answer land on its lock screen.

Two facts the bench taught that no documentation would have

Both were measured on Dad's own iPhone, and both are the kind of thing you only learn by sending real pushes to real hardware.

One device, two environments. A development build's ordinary alert token belongs to the sandbox host — but its Live Activity tokens were accepted only by the production host. Same device, same build, two different destinations. The relay now tries one host, retries the other once on a bad-token answer, and remembers which one worked for that device.

Registration order is not the order you would design. At launch the push-to-start token arrives before the ordinary alert token. So the alert registration has to merge into the existing device row rather than replace it — otherwise the start token is gone a second after it arrived, and the failure is invisible until the one moment it was needed.

Where this came from: the relay was ported from a sibling's gateway, the one working implementation in the household, live on Dad's devices for twelve days before this app existed. That is Rule 2 again, in yet another material: the family's working solution is the ancestor, and the new consumer absorbs its own differences rather than starting over.

The reason this lesson belongs in a quest about a chat system at all: a push layer is where a truth-first architecture is most tempted to compromise. The nudge is fast and the record is slow, and it is very easy to start treating the fast thing as the thing. Every decision above exists to keep the answer to what actually happened in exactly one place.

Code

The absence path·text
APNS_KEY_FILE / APNS_KEY_ID / APNS_TEAM_ID present?
  no  -> no relay object at all
      -> every chat route: unchanged
      -> /api/push/status answers honestly: push_unconfigured
      -> the turn is completely unaffected

  yes -> hook after the assistant row lands
      -> one attempt, one log row, no retry
      -> a dead-token answer drops every row with that token

The nudge may be lost. The row cannot.
Starting a Live Activity the app cannot start·text
iOS rule:  Activity.request() works from the FOREGROUND only.
The case:  the message came from the watch; the phone is asleep.

  app (earlier, while awake)  -> registers a push-to-start token
  app (now, refused)          -> POST /api/push/live-activity/start
  relay                       -> start push, app's content state verbatim
  app                         -> adopts it, drives phases locally while awake
  turn finished               -> end push carries the reply's first breath

Measured on hardware: alert token = sandbox,
Live Activity tokens = production, SAME device.
The relay remembers which host took which.

Exercise

Look at a notification or webhook layer you maintain and answer one question: what happens if its configuration is entirely absent? If the answer is anything other than 'the system behaves exactly as before', you have a dependency that is being described as an enhancement.
Hint
Try deleting the credentials in a scratch environment and running the core path. Whatever breaks was load-bearing.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.