Skip to content

Presence-based push suppression is one server-side filter inside Sender.Broadcast: a device whose app is visible gets no push, every other enrolled device still does, and the service worker's always-show rule stands

Issue #160 (2026-07-13): the needs-input trigger (#99) and the done-signal trigger (#100) each broadcast their push to every push subscription — ADR-0039's device-level model, deliberately with no per-device targeting. That includes the one device the operator is looking at right now, where the live SSE-driven Chat has already rendered the state change on screen. A notification for a screen you are watching is noise the operator will learn to ignore; a notification for the phone in a pocket, or the laptop in the next room, is the entire reason the channel exists. This slice draws that line: a device whose app is visible and focused is suppressed, and every other enrolled device is delivered to exactly as before. It is purely additive to ADR-0039 — the "always show a notification, never suppress in the service worker" rule that ADR pinned is not softened here, it is relied on.

The decisions, pinned:

  • "Open" means visible-and-focused, and nothing softer. A device is suppressible only while document.visibilityState === 'visible' — the operator's eyes are on it. A backgrounded tab, a minimized PWA, a phone on the lock screen, a swiped-away app: all hidden, all still notified, because the operator is not looking and the notification is the only thing that will reach them. There is deliberately no "recently active" or "focused within N seconds" softening — the browser hands us one crisp, race-free bit (visibilitychange fires the instant the state flips), and the moment we start interpolating around it we are guessing at attention, which is the failure this bit exists to avoid guessing about.

  • Suppression is 100% server-side, at the one trigger choke point. The filter lives inside Sender.Broadcast() (internal/push/sender.go) and nowhere else — the single place every broadcast already funnels through, so needs-input and done-signal cannot disagree about what "present" means. Presence enters Sender as a narrow injected seam, PresenceView with one method Visible(deviceHash string) bool (nil ⇒ never suppress, so a Sender built without a registry behaves byte-for-byte like ADR-0039's). NewSender gains the presence parameter; Broadcast hashes each subscription's endpoint at filter time and drops the ones a present device claims. Send() — the single-subscription path — deliberately bypasses the filter, so the Settings "Send test" button always delivers to the very device you press it on; a test you cannot feel is a broken test. The chat/afk notify closures in cmd/lab/main.go are untouched: they construct a Payload and call Broadcast, and never learn the word "presence." That is the same seam discipline ADR-0038 drew between adapter and core — a caller gets exactly the contract surface it needs (build a payload, hand it to the sender) and nothing about the floor below it; presence is composed one level down, where the send actually fires, not smeared up into the triggers that decide whether an event happened.

  • The service worker still shows every notification it receives — ADR-0039 stands, untouched. web/public/sw.js continues to unconditionally showNotification on every push, with no focused-client check, no clients.matchAll race, no UA fork. This is the load-bearing reason suppression is server-side: iOS Safari revokes the subscription of a service worker that receives a push and does not show a notification, and WebKit documents no focused-client exemption — a device that reaches the SW must be shown its notification. So the decision of whether a device should be bothered is made before the push is ever sent, on the server, where dropping a send costs nothing; it is never made in the SW, where a silent drop costs the subscription. A Chromium-only carve-out (suppress in the SW where the platform tolerates it) was considered and rejected as exactly the UA-forked behavior ADR-0039 refused.

  • Presence is in-memory, connection-lived, and biased hard toward notifying. internal/presence.Registry keeps conn → {deviceHash, visible} in memory only — no table, no schema change (Connect/Disconnect/Update, and the Connected/Visible reads the filter calls). A presence entry dies the moment its transport connection does, and the whole store dies on restart: a fresh process knows about no one, so everyone reads absent and everyone is notified. Every ambiguity resolves the same direction — a benign extra notification, never a swallowed one. There is no TTL to tune, no grace window, no delayed-send queue holding a push "in case they close the tab," because each of those trades a harmless duplicate for a possibly-lost alert, which is precisely the wrong trade for a channel whose only purpose is to reach an away operator.

  • Presence transport is SSE liveness plus a visibility beacon — two signals, each covering the other's gap. The client mints a per-connection UUID and appends it to its EventSource URL (/api/v1/events?conn=<uuid>); the SSE handler (internal/httpapi/sse.go) registers that conn on connect and deletes it on handler return — a closed stream is a free, instant "not present," no timeout involved. Layered on top is authenticated POST /api/v1/presence {conn, device, visible} (internal/httpapi/presence.go), which the client sends on every visibilitychange, once after each stream (re)opens, and — critically — via navigator.sendBeacon on pagehide. The beacon closes the laptop-lid / app-swipe gap where the socket lingers half-dead: pagehide flips the device to hidden before the OS suspends the page, rather than waiting for the server heartbeat to notice the dead socket. A beacon that names a conn the server has never seen is ignored with a 204 — the client simply re-reports after the stream reopens, riding web/src/sse.ts's existing wake/reconnect logic, so a beacon that races ahead of its stream is self-correcting rather than an error to handle.

  • The CSRF requirement is carved out for exactly one endpoint, and only the redundant half. navigator.sendBeacon cannot attach a custom request header, so POST /api/v1/presence alone is exempted from the X-Lab-Csrf custom-header check (internal/httpapi/csrf.go) — while the Origin-equality check and cookie authentication apply to it in full. A cross-site page can never forge a matching Origin, so browser CSRF against this endpoint remains impossible; the custom header was redundant defense here, not the defense. And the carve-out is scoped as tight as the record can make it: the worst an attacker who somehow forged a presence write could achieve is a suppressed or an extra notification on one device — never a state change, never a credential, never a mutation to any run, claim, or subscription. That is a blast radius the Origin check already contains and the header would only have belt-and-suspendered.

  • The device key is the SHA-256 (lowercase hex) of the push subscription endpoint. The client computes it from its own live subscription via crypto.subtle (web/src/lib/deviceHash.ts) and sends only the hash; the server hashes each stored endpoint at filter time and matches. Three things fall out for free. Zero schema change — nothing is stored, the hash is derived on both sides at request time. The endpoint is a bearer capability (ADR-0039 is careful enough never to log it), and hashing keeps that capability out of URLs, logs, and presence traffic entirely. And it self-heals across subscription rotation: when the browser rotates the endpoint, the new hash simply matches the new stored row on the next report, with no persisted id the client could lose. A device with no push subscription at all computes no hash and sends no presence — no row, nothing to suppress, correctly ignored.

  • A device is present iff any one of its connections is alive and visible. Multi-tab resolves by OR: two tabs of the same device (same endpoint hash) count as present if either is visible, and the device drops to absent only when its last visible connection goes hidden or dies. Suppress-if-watched-anywhere is the safe direction — if any surface of the device is showing the live UI, the push is redundant there.

  • Suppressed means dropped, full stop — no in-app consolation prize, and no knob. A suppressed push is simply not sent; there is no toast, no badge, no in-app substitute, because when the app is visible the live SSE-driven Chat is the notification — the state change is already on screen. If being on a different page of a visible app proves lossy in practice (the operator misses a needs-input because they were three screens away), an in-app nudge is a clean follow-up issue, not a reason to hold this slice. And the whole behavior is unconditional: no setting, no per-user toggle. Suppressing a notification for a screen the operator is actively watching is strictly better in every case, so it is not a decision any operator should be asked to make.

  • The filter evaluates presence at send time, after the debounce, never before. The needs-input path already sits behind a ~2s notifyGate flap-debounce (internal/chat/notify.go); presence is read inside Broadcast when the push actually fires, i.e. after that debounce has settled. So presence reflects where the operator is at the instant the notification would reach them, not where they were when the conversational state first flapped — the two mechanisms compose in the right order without either knowing about the other.

  • The one accepted residual: a hard kill that fires no pagehide leaves a stale-visible device until its SSE heartbeat write fails (~25s), and a push in that window is lost. A power-off, a force-quit, a yanked cable — none run pagehide, so the device reads visible until the server's next heartbeat write to the dead socket errors and reaps the conn (on the order of the ~25s heartbeat interval). A notification firing in that gap is suppressed against a device that is actually gone. sendBeacon on pagehide narrows this to genuinely abrupt deaths; it is accepted, not fixed, because the only fixes are the TTL/grace-window knobs rejected above, which would cost duplicates on every normal close to save this rare case. On iOS the common path is clean: backgrounding the PWA fires visibilitychange (the beacon flips the device hidden) and the OS suspends the page (the SSE dies), so a non-foregrounded iPhone reliably receives its push — and every push that does reach a device still shows a notification, so nothing here bends Apple's rule or touches the SW.

Status

Accepted. Extends ADR-0039 (Web Push / VAPID) additively — the device-level subscription model, the fire-and-forget sender, and above all the service worker's unconditional showNotification are all unchanged; this slice adds one server-side pre-send filter and the presence plumbing to feed it. Reaffirms, rather than revisits, ADR-0039's rejection of service-worker-side focus suppression: the suppression decision moves up to the trigger layer exactly as that ADR said any do-not-disturb behavior must. Leans on the ADR-0038 seam discipline for the trigger boundary — the #99/#100 notify closures construct a Payload and call Broadcast and never learn about presence. No schema change (the device key is derived, never stored). Settled via issue #160.

Considered options

  • Suppress inside the service worker (check for a focused client in the push handler). Rejected: it recreates the exact silent-push trap ADR-0039 documented — a SW that receives a push and does not showNotification gets its subscription revoked by iOS Safari — plus clients.matchAll focus-matching is inherently racy against the moment showNotification must be called. A Chromium-only variant (suppress in the SW only where the platform tolerates a silent push) was considered and dropped: it is UA-forked behavior, the precise thing ADR-0039 refused, and it would leave the iOS path — the one this feature most needs to be correct — unable to use it anyway.

  • A TTL, a grace window, or a delayed-send queue on presence. Rejected: every one of these knobs trades a harmless duplicate notification for a possibly-lost one. A TTL keeps a device "present" past the moment it stopped watching; a grace window holds a send in case the operator closes the tab; a delayed queue does the same with extra state to manage. All three bias toward not notifying, which is backwards for a channel whose whole purpose is to reach someone who isn't looking. Presence dies with its connection and the store dies on restart precisely so the only failure mode is a benign extra push.

  • Key presence on the raw endpoint, or on the subscription row id. Rejected on two counts. The endpoint is a bearer capability — ADR-0039 is careful never even to log it — so putting it in presence request bodies would spray a credential through client traffic and server logs. And a row-id key would force the client to persist an id it can lose whenever the browser rotates the subscription, at which point suppression silently stops matching. Hashing the live endpoint needs nothing stored, keeps the capability off the wire, and self-heals across rotation because both sides re-derive the same hash from whatever the current subscription is.

  • Show an in-app toast or badge when a push is suppressed. Rejected as scope, not as a bad idea. When the app is visible the live SSE-driven UI already shows the state change — that is the in-app signal — so a toast would be redundant in the common case. The real gap (visible app, but the operator is on a different page than the run that needs them) is a distinct problem with its own design; if it proves lossy in practice it becomes a follow-up issue, and it should not gate suppression from shipping.

  • Per-user presence scoping, or a settings toggle for the whole feature. Rejected. Subscriptions are device-level trust in ADR-0039, not user-level — there is no durable per-user identity to scope presence to that the device hash does not already capture, and device granularity is exactly what "the screen I'm looking at" means. A settings knob is worse than unnecessary: suppressing a notification for a screen the operator is actively watching is strictly better in every scenario, so exposing it as a choice only asks the operator to reason about a decision with one right answer.

  • Rely on SSE teardown alone and drop the pagehide beacon. Rejected: a laptop-lid sleep and an iOS app-swipe both leave the TCP socket in limbo — the server does not learn the stream is dead until a heartbeat write fails, up to the heartbeat interval later — during which the device reads stale-visible and swallows its own notifications. The sendBeacon on pagehide flips the device to hidden before the OS suspends the page, collapsing that window to only the genuinely abrupt deaths (power-off, force-quit) that fire no lifecycle event at all.

Consequences

  • New internal/presence: an in-memory Registry (Connect/Disconnect/Update, and the Connected/Visible reads) keyed conn → {deviceHash, visible}. No persistence, cleared on restart by design.
  • internal/push/sender.go: a PresenceView interface (Visible(deviceHash string) bool) and the drop-if-present filter inside Broadcast; NewSender gains the presence parameter; Send bypasses the filter unchanged. A nil presence view reproduces ADR-0039's behavior exactly.
  • internal/httpapi/sse.go registers the ?conn=<uuid> on connect and deletes it on handler return; internal/httpapi/presence.go is the new POST /api/v1/presence handler; internal/httpapi/csrf.go carries the one-endpoint carve-out (skip the custom-header check, keep Origin-equality and cookie auth). cmd/lab/main.go wires the registry into both the SSE handler and NewSender; its chat/afk notify closures are untouched.
  • Web client: web/src/sse.ts mints the per-connection UUID and exposes a connID accessor; web/src/lib/deviceHash.ts computes the SHA-256 endpoint hash via crypto.subtle; web/src/lib/presence.ts is the reporter (report on visibilitychange, once per stream open, sendBeacon on pagehide); web/src/events.tsx wires the reporter to the stream's lifecycle. web/public/sw.js is not touched.
  • Accepted residual risk, operator-invisible by design: an abrupt device death that fires no pagehide leaves a stale-visible entry until the ~25s SSE heartbeat reaps it, and a push in that window is lost. Documented here as the deliberate cost of biasing every other case toward delivery.
  • Acceptance shape: app visible on device A → a needs-input push is not delivered to A but is delivered to enrolled device B; A hidden, backgrounded, or closed → delivered to A; the Settings "Send test" always delivers to the pressing device; immediately after a server restart, nothing is suppressed anywhere.