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>
This commit is contained in:
@@ -438,6 +438,9 @@ explaining why it was safe.
|
||||
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
|
||||
persistence key families. Read before building a panel app. Its defect list is
|
||||
`docs/workspace-panel-todo.md`.
|
||||
- `docs/multi-server-push.md` — **design, not built**: one Officer app receiving notifications from every
|
||||
server it is signed into. Works already, bar one missing field — and why the `mv` catalogue-version
|
||||
check turns into an endless resync between servers without it. The server identifier is an SSO decision
|
||||
- `docs/visual-regression-plan.md` — **plan, not built**: nightly screenshot regression against a JWT
|
||||
dashboard on a second server. The blocker is that a JWT lives in `localStorage` and iris copies only
|
||||
cookies, so an authenticated capture silently renders the login page — plus why diffing must happen
|
||||
|
||||
@@ -12,6 +12,15 @@ below that proposed deleting it have been removed rather than left to mislead th
|
||||
What did NOT reverse: `execution` permissions (terminal, chat, tasks, files, desktop, browser) run as
|
||||
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
|
||||
|
||||
## Notifications
|
||||
|
||||
- [ ] **A push payload does not say which server sent it**, which is fine for one and breaks three things
|
||||
for several — the deep link resolves a session id against the wrong server, threading can collapse
|
||||
two servers' chats into one banner, and `mv` (a per-server catalogue hash) makes the app resync
|
||||
forever, always against whichever server pushed last. Blocks the multi-server sign-on work.
|
||||
**Settle the server identifier with that design rather than inventing one here.**
|
||||
See `docs/multi-server-push.md`.
|
||||
|
||||
## Affordances
|
||||
|
||||
- [ ] **No way to see which affordances are reaching your agents, or to turn one off.** They are global:
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# 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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```json
|
||||
"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.
|
||||
Reference in New Issue
Block a user