Files
platform/docs/multi-server-push.md
pastilhasandClaude Opus 5 bcf8df9d32 design: notifications from every server one app is signed into
The good news survives checking. An APNs auth key belongs to the developer account, not a machine, and
a device token belongs to the app, not a server — so N installs holding the same .p8 all push to one
phone with no coordination, and the app posting its token per server is something the multi-server
manager does anyway.

One field is missing: the payload carries no origin. That breaks the deep link (a session id is
meaningful only on the server that issued it), and threading, and — least obviously and most
expensively — `mv`. That is a content hash of THAT server's catalogue, and the client's rule is
`mv !== version → resync`. One stored catalogue and five servers means every push from B invalidates
what was synced from A, forever. It would present as a battery and data problem rather than a bug.

Where the identifier comes from is deliberately left to the sign-on design: the multi-server manager
already needs a key per server, and notifications should adopt it rather than invent a second identity
that drifts the first time a host is renamed.

Records what stays per server on purpose — devices, per-chat mute preferences, the catalogue itself —
because the app is what joins them, which is the same shape as the sign-on work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 03:19:17 +00:00

5.4 KiB

Notifications from more than one server

Design, not built. Written 2026-08-21, for the single-sign-on work.

The good news first, because it is the part that needed checking and it holds: one Officer app can receive notifications from every server you are signed into, and almost nothing has to change to allow it. One field is missing. This is what it is and why it matters more than it looks.

Why it works at all

An APNs auth key (.p8) belongs to the developer account, not to a machine. There is no server binding in it. So every Officer install can hold the same key, mint its own provider token from it, and push to the same app.

The device token belongs to the app, not to a server either. One Officer app has one token, whichever servers it talks to.

So N servers pushing to one phone needs no coordination between them. Each just needs the token, which the app posts when it signs in — which the multi-server manager is doing anyway.

Two facts about the shared key, neither blocking:

  • Apple allows 2 active auth keys per account. Sharing one across servers is the intended pattern; one key per server is not an option past two.
  • Revoking it kills push everywhere, with no way to tell which machine leaked it. That is the real cost of copying it to ten boxes, and the reason it belongs in each one's secret store rather than in .env.

The missing field

A payload today:

{
  "aps": { "alert": { "title": "Officer" }, "thread-id": "chat:abc", "mutable-content": 1 },
  "officer": { "c": "officer/core:chat.turn-ended", "id": 42, "mv": 1873 }
}

There is no origin. With one server that is correct and minimal. With five it breaks three things, and only the first is obvious.

1. The deep link opens the wrong thing. officer-chat://session/{id} — that id is meaningful only on the server that issued it. The app has no way to know which, so it picks one. Sometimes it opens nothing; sometimes it opens somebody else's conversation with a matching id, which is worse.

2. mv starts a resync fight. mv is a content hash of that server's catalogue, and servers with different plugins have genuinely different catalogues. The client's rule is mv !== version → resync. With one stored catalogue and five servers, every push from server B invalidates the copy synced from server A, and the app resyncs forever, always against whichever pushed last.

This is the one that would look like a mysterious battery and data problem rather than a bug.

3. Threading collapses across servers. chat:{id} groups per conversation. Two servers' ids coinciding is unlikely with UUIDs and not impossible, and "these two unrelated chats are one banner" is a confusing failure to diagnose.

The change

A server identifier in the officer block, and the app keeping one catalogue per server keyed on it:

"officer": { "s": "<server-id>", "c": "officer/core:chat.turn-ended", "id": 42, "mv": 1873 }

mv then means "version of that server's catalogue", threading becomes <server>:chat:{id}, and the deep link resolves against a known server.

Where the identifier comes from is an SSO decision, not a notifications one

This is the part to settle tomorrow, and it belongs to the sign-on design rather than to this file.

The multi-server manager already needs a key for each server — to store its API key against, to label it in a switcher, to route a deep link. Whatever that key is, notifications should adopt it. Inventing a second identity here means two ids for one server, drifting the first time somebody renames a host.

It has to survive a hostname change, be stable across reinstalls of the app, and not be a secret — it travels in a push payload, which is the one place content is deliberately withheld. A server-generated opaque id created at bootstrap fits; a URL does not, because URLs change.

What stays per server, correctly

Worth stating so nobody tries to unify it:

  • push_devices — each server holds its own registrations. The same phone appears once per server.
  • chat_notification_prefs — muting a conversation is per server, because the conversation is.
  • The catalogue — a server's codes come from its own plugins. Two installs with different plugins legitimately have different vocabularies.

None of that wants a shared store. The app is what joins them, which is the same shape as the sign-on work: the app holds the list, each server holds its own truth.

Current state, verified 2026-08-21

alpha officer-dev
iOS devices registered 2 0
APNs credentials partly present absent

So only alpha can push today, and notifications seen elsewhere are the in-app bell rather than push. That is not a limitation of the design — it is two things absent on the other machines.

Order of work

  1. Settle the server identifier with the SSO design. Everything else waits on it, and it is a half-hour conversation rather than a build.
  2. Add s to the payload; keep it optional so an older app ignores it.
  3. Per-server catalogue storage in the app, keyed on s. This is the one that must land before a second server starts pushing, or the resync fight above begins.
  4. Thread ids become <server>:chat:{id}.
  5. Copy the .p8 into the other servers' secret stores, and have the app register its token per server.