From bcf8df9d32ef21384e577a1c891897b19e958496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 21 Aug 2026 03:19:17 +0000 Subject: [PATCH] design: notifications from every server one app is signed into MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 3 + TODO.md | 9 +++ docs/multi-server-push.md | 112 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 docs/multi-server-push.md diff --git a/CLAUDE.md b/CLAUDE.md index 84308307..3e6b74ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/TODO.md b/TODO.md index 75faf5f0..1c977644 100644 --- a/TODO.md +++ b/TODO.md @@ -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: diff --git a/docs/multi-server-push.md b/docs/multi-server-push.md new file mode 100644 index 00000000..e4553133 --- /dev/null +++ b/docs/multi-server-push.md @@ -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": "", "c": "officer/core:chat.turn-ended", "id": 42, "mv": 1873 } +``` + +`mv` then means "version of that server's catalogue", threading becomes `: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 `:chat:{id}`. +5. Copy the `.p8` into the other servers' secret stores, and have the app register its token per server.