| 1 | # Aviso — Web Push as a rastrillo addon, extracted from Eleven |
| 2 | |
| 3 | **Date:** 2026-09-07 · **Status:** DRAFT for review · |
| 4 | **Source:** Eleven messenger (`github.com/elevenmessenger/messenger`): |
| 5 | `push.go`, `web/static/push.js`, the notification half of |
| 6 | `web/static/sw.js`, and their Go tests. **First consumer:** |
| 7 | birthday-alarm (reminders go out by mail today). **Second, when it asks:** |
| 8 | oficina/calendar. |
| 9 | |
| 10 | Brainstormed by Claude and Codex in two rounds on 2026-09-07; every |
| 11 | ruling below was agreed by both, and the two points they argued are |
| 12 | recorded in §14 with the position that won. |
| 13 | |
| 14 | ## 0. What this is, and the answer to the question |
| 15 | |
| 16 | The question was whether rastrillo should have a "PWA library with push |
| 17 | notifications" extracted from the Eleven PWA. The answer is **yes to |
| 18 | push, as an addon, and no to a PWA library.** |
| 19 | |
| 20 | Rastrillo today has no web app manifest, no service worker support and no |
| 21 | Web Push (`manifest.go` is the *resource* manifest, CRUD sugar; `icons.go` |
| 22 | is an icon vocabulary). Eleven is the only app in the family that has |
| 23 | push, and its code splits cleanly: a transport half — SSRF-guarded client, |
| 24 | endpoint validation, subscription CRUD, VAPID keys, a bounded fan-out that |
| 25 | prunes dead endpoints — that is generic; and a policy half — who gets |
| 26 | told, what the payload means, how the worker decrypts and routes it — |
| 27 | that is Eleven's message model and nothing else's. The transport half is |
| 28 | about 230 lines of Go, 180 of browser JS and 40 of worker JS. That is |
| 29 | what this spec extracts. |
| 30 | |
| 31 | There is no "PWA library" to extract. Eleven's service worker does no |
| 32 | caching and has no offline page; its manifest handler is twenty lines of |
| 33 | app identity. Installability is a recipe an app follows (§11), not code a |
| 34 | package can own — and it is needed, because iOS only delivers push to an |
| 35 | installed app. |
| 36 | |
| 37 | Two rulings bind everything, both inherited from the addons doctrine |
| 38 | (`docs/site/addons.md`): **the arrow points one way** — aviso depends on |
| 39 | rastrillo, rastrillo never learns aviso exists; and **the app owns |
| 40 | policy** — aviso moves bytes to devices a subject enrolled, and never |
| 41 | decides who should be told what. |
| 42 | |
| 43 | ## 1. Rulings |
| 44 | |
| 45 | Each one line, with the failure it prevents. |
| 46 | |
| 47 | - **Addon, not core.** Push is real for many apps and wrong for every app |
| 48 | to carry; a dependency (`webpush-go`) in core would be paid by apps |
| 49 | that never push. |
| 50 | - **Named `aviso`** ("notice"): it names the user outcome, in the family's |
| 51 | Spanish-word style, and collides with nothing in the family. |
| 52 | - **Keep `github.com/SherClockHolmes/webpush-go` v1.4.0, behind hidden |
| 53 | types.** Extraction must not become a cryptography rewrite; RFC 8291's |
| 54 | aes128gcm record format and RFC 8292's VAPID JWT do not fall out of |
| 55 | rastrillo/crypto's envelope (which is P-256 ECDH/ECDSA, AES-256-GCM, |
| 56 | domain-separated — the right curve, the wrong construction). A stdlib |
| 57 | implementation is separately reviewed work, inside the addon, later or |
| 58 | never. |
| 59 | - **The session subject owns a subscription.** Ownership is |
| 60 | `sessions.Current(r).Subject`, never a client-supplied field and never |
| 61 | `rastrillo.Actor` (which says human-or-agent, not who). Eleven's |
| 62 | per-subscription bearer token (`token_hash`) does not carry over: it |
| 63 | existed because Eleven has no server session. |
| 64 | - **Reject cross-owner endpoint upserts.** Eleven's unconditional |
| 65 | `ON CONFLICT(endpoint) DO UPDATE` (`push.go:88`) would let a second |
| 66 | account on the same browser silently take over the first account's |
| 67 | device. Aviso answers 409. |
| 68 | - **Revision-conditional pruning.** A 404/410 deletes a row only if the |
| 69 | revision the send captured is still current, so a slow send cannot |
| 70 | delete a subscription the browser refreshed meanwhile. |
| 71 | - **Schema merges into `BootSchema`**, namespaced `aviso`, and has no |
| 72 | foreign key to any users table: apps own their identity schema. |
| 73 | - **No boot-time key generation.** The VAPID private key is provisioned |
| 74 | like every other family secret (one env var, refused when empty, §8). |
| 75 | Eleven's mint-if-missing sidecar is the thing this rules out. |
| 76 | - **`enable` needs a gesture; `reconcile` never prompts; the worker helper |
| 77 | never calls `skipWaiting` or `clients.claim`.** Permission prompts |
| 78 | outside a click are denied by browsers and resented by people; lifecycle |
| 79 | is the app's worker's business. |
| 80 | - **Every push shows a notification.** WebKit revokes push for a worker |
| 81 | that receives without displaying; silent suppression is done |
| 82 | server-side by not sending. |
| 83 | |
| 84 | ## 2. Package layout |
| 85 | |
| 86 | A separate repository and module, `amadan.net/rastrillo/aviso`, on the |
| 87 | idear pattern (`docs/site/addons.md`; source `github.com/rastrillo/idear`): |
| 88 | |
| 89 | ``` |
| 90 | aviso.go Config, New, Service, errors |
| 91 | store.go List, DeleteSubject, Sweep, the CRUD the handlers use |
| 92 | send.go Send, SendTo, fan-out, pruning |
| 93 | http.go PublicKey, Subscribe, Unsubscribe |
| 94 | vapid.go GenerateKey, key parsing, key id |
| 95 | ssrf.go dial guard and endpoint validation (from Eleven's |
| 96 | push.go:25-70 and unfurl.go:160) |
| 97 | migrations.go Schema = migrate.MustFromFS(migrationFS, "aviso") |
| 98 | migrations/0001_init.sql |
| 99 | js.go JS() and WorkerJS() |
| 100 | js/push.mjs browser module (from push.js) |
| 101 | js/aviso-sw.js classic worker helper, exposes AvisoSW |
| 102 | js/*.test.mjs node tests, the vault/js precedent |
| 103 | cmd/aviso-key prints one private key |
| 104 | SKILL.md byte-budgeted, tested, like the framework's |
| 105 | docs/installable.md the §11 recipe |
| 106 | ``` |
| 107 | |
| 108 | Nothing in `github.com/carlosframework/rastrillo` imports aviso. The |
| 109 | directory page in `docs/site/addons.md` gains one entry. |
| 110 | |
| 111 | ## 3. Go API |
| 112 | |
| 113 | ```go |
| 114 | package aviso |
| 115 | |
| 116 | type Config struct { |
| 117 | DB *sql.DB |
| 118 | PrivateKey string // unpadded base64url, 32-byte P-256 scalar; required |
| 119 | Contact string // VAPID "sub": a mailto: or https: the push service may contact |
| 120 | Origin string // the app's origin, for csrf.SameOrigin and click-URL checks |
| 121 | Concurrency int // in-flight sends across the Service; 0 means 32 |
| 122 | Logger *slog.Logger |
| 123 | } |
| 124 | |
| 125 | type Subscription struct{ Endpoint, P256dh, Auth string } |
| 126 | |
| 127 | type Stored struct { |
| 128 | ID, Subject, VAPIDKeyID string |
| 129 | Revision int64 |
| 130 | Subscription |
| 131 | } |
| 132 | |
| 133 | type Options struct { |
| 134 | TTL time.Duration // whole seconds, >= 0; 0 means the push service's default |
| 135 | Urgency string // "very-low" | "low" | "normal" | "high"; "" means normal |
| 136 | Topic string // RFC 8030 topic, <= 32 URL-safe chars; "" means none |
| 137 | } |
| 138 | |
| 139 | type Result struct { |
| 140 | ID string |
| 141 | Status int // push-service status; 0 when Err is transport-level |
| 142 | RetryAfter time.Duration // from a 429/503, else 0 |
| 143 | Err error |
| 144 | } |
| 145 | |
| 146 | var ( |
| 147 | Schema *migrate.Set |
| 148 | ErrEmptyPrivateKey error |
| 149 | ErrInvalidPrivateKey error |
| 150 | ) |
| 151 | |
| 152 | func GenerateKey() (string, error) |
| 153 | func New(cfg Config) (*Service, error) |
| 154 | |
| 155 | func (s *Service) List(ctx context.Context, subject string) ([]Stored, error) |
| 156 | func (s *Service) DeleteSubject(ctx context.Context, subject string) error |
| 157 | func (s *Service) Sweep(ctx context.Context, notConfirmedSince time.Time) error |
| 158 | |
| 159 | func (s *Service) Send(ctx context.Context, to []Stored, payload []byte, o Options) ([]Result, error) |
| 160 | func (s *Service) SendTo(ctx context.Context, subject string, payload []byte, o Options) ([]Result, error) |
| 161 | |
| 162 | func (s *Service) PublicKey(w http.ResponseWriter, r *http.Request) |
| 163 | func (s *Service) Subscribe(w http.ResponseWriter, r *http.Request) |
| 164 | func (s *Service) Unsubscribe(w http.ResponseWriter, r *http.Request) |
| 165 | |
| 166 | func JS() []byte |
| 167 | func WorkerJS() []byte |
| 168 | ``` |
| 169 | |
| 170 | `SendTo` is the common case — every device one subject enrolled — so |
| 171 | birthday-alarm never touches `Stored`. `Send` is for apps that select |
| 172 | devices. Both return a batch `error` for what stops the batch (selection |
| 173 | query failed, payload over bound, invalid options, context cancelled) and |
| 174 | a `Result` per attempted device; a database failure must not look like |
| 175 | "zero devices". A `Result` reports the push service's *acceptance*, not |
| 176 | delivery, and aviso never retries: `RetryAfter` is for the app's own |
| 177 | scheduler. Rows whose `VAPIDKeyID` is not the Service's are skipped with |
| 178 | a Result error, not sent with a key that cannot sign for them. |
| 179 | |
| 180 | Concurrency is bounded across the Service, not per call, and the |
| 181 | semaphore wait honours `ctx`. Each request has a 30 s timeout. |
| 182 | |
| 183 | ## 4. HTTP routes and their gating |
| 184 | |
| 185 | Handlers, not a router: the app mounts them where it likes (the SKILL.md |
| 186 | suggests `/aviso/…`). Each enforces its method. |
| 187 | |
| 188 | | Route | Body | Reply | |
| 189 | |---|---|---| |
| 190 | | `GET …/public-key` | — | `{"publicKey": "<base64url>"}`; `Cache-Control: no-cache` | |
| 191 | | `POST …/subscribe` | `{"subscription": {endpoint, keys:{p256dh, auth}}, "publicKey": "…", "previousEndpoint"?: "…"}` | 204 | |
| 192 | | `POST …/unsubscribe` | `{"endpoint": "…"}` | 204, idempotent | |
| 193 | |
| 194 | The two mutations require the app's session middleware to have run, a |
| 195 | non-empty `sessions.Current(r).Subject`, and `csrf.SameOrigin(r, |
| 196 | cfg.Origin)`: 401 without a session, 403 on origin failure. `publicKey` |
| 197 | must equal the Service's, else 409 — a browser subscribed under a rotated |
| 198 | key must re-enrol, not be stored unsendable. An endpoint already owned by |
| 199 | another subject is 409. `previousEndpoint` is deleted in the same |
| 200 | transaction, and only if the same subject owns it; the subscribe body is |
| 201 | capped at 8 KiB and the endpoint at 2048 bytes (Eleven's bound). |
| 202 | Unsubscribe deletes only the caller's own row and answers 204 either way. |
| 203 | |
| 204 | A re-subscribe of an endpoint the same subject already owns replaces the |
| 205 | keys, bumps `revision`, and sets `last_confirmed_at`. |
| 206 | |
| 207 | ## 5. Schema |
| 208 | |
| 209 | `migrations/0001_init.sql`, immutable once released: |
| 210 | |
| 211 | ```sql |
| 212 | CREATE TABLE aviso_subscriptions ( |
| 213 | id TEXT NOT NULL PRIMARY KEY, |
| 214 | endpoint TEXT NOT NULL UNIQUE, |
| 215 | subject TEXT NOT NULL CHECK (length(subject) > 0), |
| 216 | p256dh TEXT NOT NULL, |
| 217 | auth TEXT NOT NULL, |
| 218 | vapid_key_id TEXT NOT NULL, |
| 219 | revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0), |
| 220 | created_at INTEGER NOT NULL, |
| 221 | last_confirmed_at INTEGER NOT NULL |
| 222 | ); |
| 223 | CREATE INDEX aviso_subscriptions_subject ON aviso_subscriptions(subject); |
| 224 | CREATE INDEX aviso_subscriptions_confirmed ON aviso_subscriptions(last_confirmed_at); |
| 225 | ``` |
| 226 | |
| 227 | Times are UTC Unix seconds. `id` is 16 random bytes, base64url, never |
| 228 | reused. `vapid_key_id` is base64url SHA-256 of the uncompressed public |
| 229 | key, so a rotated key is visible per row. Pruning and the post-send |
| 230 | confirmation bump both match `id` **and** the `revision` captured when the |
| 231 | batch was selected. |
| 232 | |
| 233 | The app merges: `BootSchema = migrate.Merge(sessions.Schema, aviso.Schema, Schema)`. |
| 234 | |
| 235 | ## 6. Browser module (`js/push.mjs`) |
| 236 | |
| 237 | ```js |
| 238 | export function capabilities() // {serviceWorker, push, notifications, standalone} |
| 239 | export async function status(registration) // {permission, subscription|null} |
| 240 | export async function enable({registration, publicKey, save}) // from a click |
| 241 | export async function reconcile({registration, publicKey, save}) // on every load |
| 242 | export async function disable({registration, remove}) |
| 243 | ``` |
| 244 | |
| 245 | `enable` calls `Notification.requestPermission()` **first**, synchronously |
| 246 | inside the gesture, then awaits `registration.pushManager.subscribe`. |
| 247 | `reconcile` never prompts: with permission already granted it compares |
| 248 | the existing subscription's `applicationServerKey` to `publicKey`, |
| 249 | re-subscribes if they differ (Eleven's self-heal, `push.js` |
| 250 | `sameAppServerKey`), and calls `save` so the server's `last_confirmed_at` |
| 251 | moves. `save(body)` is app-supplied — a same-origin `fetch` to the |
| 252 | subscribe route — and must reject on non-2xx; Eleven's version ignores |
| 253 | the response (`push.js:177`), which is how a failed save goes unnoticed |
| 254 | until the first missed notification. `disable` removes the server row |
| 255 | first, then unsubscribes the browser, so a crash between the two leaves a |
| 256 | harmless orphan rather than a row that sends to nothing. Base64url |
| 257 | conversion stays private. Worker registration is the app's. |
| 258 | |
| 259 | ## 7. Worker helper (`js/aviso-sw.js`) and the payload contract |
| 260 | |
| 261 | A classic script (workers cannot `import` reliably everywhere), loaded by |
| 262 | the app's own `sw.js` via `importScripts`, exposing: |
| 263 | |
| 264 | ```js |
| 265 | AvisoSW.handlePush(event, {decode, fallback}) // -> Promise |
| 266 | AvisoSW.handleClick(event, {fallbackURL}) // -> Promise |
| 267 | AvisoSW.handleSubscriptionChange(event, {renew, save}) |
| 268 | ``` |
| 269 | |
| 270 | The app attaches each to `event.waitUntil` in its own listener, so the |
| 271 | helper never owns the worker's lifecycle. |
| 272 | |
| 273 | **Payload contract.** The default decoder accepts JSON |
| 274 | `{"title","body","url","tag"}`; `title` is required, `url` must be a |
| 275 | root-relative path on the app's origin (§10). It yields |
| 276 | `{title, options}` with the validated URL in `options.data.url`. An app |
| 277 | with its own shape (Eleven's encrypted blob, say) supplies `decode`, |
| 278 | which may be async. `fallback` is **required** and produces the |
| 279 | notification shown when the payload is missing or malformed — because |
| 280 | every push shows a notification (§1), and the alternative is WebKit |
| 281 | revoking the subscription. |
| 282 | |
| 283 | `handleClick` closes the notification, focuses a client whose URL matches |
| 284 | exactly, else opens the validated destination, else `fallbackURL`. |
| 285 | |
| 286 | `handleSubscriptionChange` uses `event.newSubscription` or `renew(event)` |
| 287 | (a `pushManager.subscribe` with the stored key), then `save` posts to the |
| 288 | subscribe route with `mode: "same-origin"`, `credentials: "same-origin"`, |
| 289 | `redirect: "error"`. Fetch's default credentials mode is already |
| 290 | `same-origin`, so eligible cookies — HttpOnly included — go with the |
| 291 | request; what nothing can guarantee is that an installed app still has a |
| 292 | live session. When it does not, renewal fails silently, without |
| 293 | prompting, and `reconcile` repairs it on the next page open after |
| 294 | sign-in. The spec says this out loud so nobody builds a retry loop in a |
| 295 | worker. |
| 296 | |
| 297 | ## 8. VAPID custody |
| 298 | |
| 299 | `Config.PrivateKey` is the unpadded base64url encoding of a 32-byte P-256 |
| 300 | scalar (webpush-go's own format). `New` checks it is in range and derives |
| 301 | the public key; empty is `ErrEmptyPrivateKey`, malformed |
| 302 | `ErrInvalidPrivateKey`, and both stop boot. |
| 303 | |
| 304 | Provisioning is explicit and once: |
| 305 | |
| 306 | ```sh |
| 307 | go run amadan.net/rastrillo/aviso/cmd/aviso-key # prints the private key, nothing else |
| 308 | ``` |
| 309 | |
| 310 | The app reads it the way birthday-alarm reads its instance key |
| 311 | (`BIRTHDAYALARM_INSTANCE_KEY`, refused when empty): one env var, |
| 312 | `<APP>_VAPID_PRIVATE_KEY`. Not a file (Eleven's `<db>.vapid` sidecar has |
| 313 | no equivalent in a `STATE_DIRECTORY` that holds only the DB), not a table |
| 314 | row (Eleven scrubbed exactly that, `push.go:208`, because DB snapshots |
| 315 | travel), and not derived from `InstanceKey` (rotating one secret must not |
| 316 | silently rotate another). Rotation means every browser re-enrols on its |
| 317 | next `reconcile`; rows under the old key are skipped, never sent. |
| 318 | |
| 319 | ## 9. Retention and revocation |
| 320 | |
| 321 | `Sweep(ctx, t)` deletes rows with `last_confirmed_at` before `t`. That |
| 322 | time advances on `reconcile` (a page opened) and on a revision-matched |
| 323 | 2xx acceptance. It measures whether the *subscription* is alive, not |
| 324 | whether a person still wants notifications: an accepted send keeps an |
| 325 | unopened device forever, which is the push service's contract too. The |
| 326 | SKILL.md recommends 90 days, run from a `carlos.Tick` handler. |
| 327 | |
| 328 | Session expiry does **not** revoke a subscription — the subject still |
| 329 | owns the device. Apps call `disable` before sign-out or account switch, |
| 330 | `DeleteSubject` on account deletion, and re-check that a recipient is |
| 331 | still entitled before every `SendTo`. An accepted notification cannot be |
| 332 | recalled. |
| 333 | |
| 334 | ## 10. Security rulings |
| 335 | |
| 336 | - **SSRF.** Endpoints are https only, no userinfo, no fragment, ≤ 2048 |
| 337 | bytes; the client follows no redirects, uses no environment proxy, and |
| 338 | its dialer rejects loopback, private, link-local and reserved ranges |
| 339 | **at connect time** (IPv4-mapped IPv6 included), so DNS rebinding after |
| 340 | validation still fails. This is Eleven's `guardDialControl` |
| 341 | (`push.go:25-45`, `unfurl.go:160`) and its `push_ssrf_test.go`, lifted. |
| 342 | - **Bounds.** Plaintext ≤ 3993 bytes (RFC 8291 §4's one-record limit); |
| 343 | push-service response bodies read to a small cap. |
| 344 | - **Click URLs.** Root-relative paths only, resolved against the origin |
| 345 | and required to stay under it; protocol-relative (`//`) and |
| 346 | backslash-bearing paths are rejected. Applied to the default decoder, |
| 347 | to custom decoder output, and to `fallbackURL`. |
| 348 | - **Redaction.** Endpoints, `p256dh`, `auth`, payloads, `Authorization` |
| 349 | headers, push-service response bodies and URL-bearing transport errors |
| 350 | never reach logs; an endpoint is logged as its row `id`. |
| 351 | - **Ownership.** Nothing in a request body names a subject; the session |
| 352 | does. |
| 353 | |
| 354 | ## 11. Installability recipe (docs, not code) |
| 355 | |
| 356 | What an app does so that a phone can install it and iOS will deliver |
| 357 | push (16.4+, Home Screen only): |
| 358 | |
| 359 | 1. Serve `manifest.webmanifest` with a stable `id`, `name`, `short_name`, |
| 360 | `start_url`, `scope`, `display: standalone`, `theme_color`, |
| 361 | `background_color`, and 192 px and 512 px icons. |
| 362 | 2. In the layout `<head>`: `<link rel="manifest">`, |
| 363 | `<meta name="theme-color">`, a 180 px `apple-touch-icon`. |
| 364 | 3. Serve the worker at the scope it should control, `Cache-Control: |
| 365 | no-cache`, as Eleven's `serveSW` does (`main.go:4168`). |
| 366 | 4. Tell the person, in the app, to add it to the Home Screen and sign in |
| 367 | inside the installed copy before pressing the enable button |
| 368 | (`capabilities().standalone` decides whether to show that coaching). |
| 369 | |
| 370 | Eleven's `serveManifest` (`main.go:4181`) is the twenty-line model. |
| 371 | Rastrillo's scaffold is not changed by this spec. |
| 372 | |
| 373 | ## 12. Testing |
| 374 | |
| 375 | **Go.** A recording transport behind a private seam (production guards |
| 376 | stay on): asserts `TTL`, `Urgency`, `Topic` and a VAPID `Authorization` |
| 377 | header per send; 404/410 prunes only when the revision still matches; |
| 378 | 429/503 surface `RetryAfter`; cancellation stops the batch. Handlers: |
| 379 | 401/403/409 paths, cross-owner upsert refused, `previousEndpoint` only |
| 380 | deleted when owned, body and endpoint bounds. SSRF: loopback and private |
| 381 | endpoints refused at dial, redirect refused, rebinding case. Keys: |
| 382 | `GenerateKey` round-trips through `New`, invalid scalars refused, the |
| 383 | same key yields the same `VAPIDKeyID` after restart. Eleven's |
| 384 | `push_ssrf_test.go` and `push_token_hash_test.go` are the seed corpus. |
| 385 | |
| 386 | **Node.** `js/push.test.mjs` against a fake `registration`/`PushManager`: |
| 387 | permission denied, reconcile with matching and mismatched keys, failed |
| 388 | `save` rejects, `disable` ordering. `js/aviso-sw.test.mjs` against a fake |
| 389 | `self`/`clients`: default decoder, malformed payload falls back, click |
| 390 | focuses vs opens, subscription change renews then saves, expired session |
| 391 | fails silently. |
| 392 | |
| 393 | **Browser.** One chromedp test (the `webauthn/browser_test.go` precedent) |
| 394 | that registers a worker, calls `enable` with a **declared subscription |
| 395 | double** — a stubbed `pushManager` — and asserts the row lands through the |
| 396 | real handlers. It proves the wiring, not Chromium's subscription service |
| 397 | or encrypted delivery; nothing short of a real push service does, and |
| 398 | CI must not depend on one. iOS is a manual smoke test, written down. |
| 399 | |
| 400 | **Gates.** The repo's own gate (`go vet`, `gofmt -l`, `go test`), the |
| 401 | node tests, and the browser test, mirrored in `Makefile` `ci` and |
| 402 | `.amadan/ci.d/`. The example app under `example/` is its own module and |
| 403 | is tested from its own directory, as `AGENTS.md` requires. |
| 404 | |
| 405 | ## 13. Out of scope, named |
| 406 | |
| 407 | Offline caching and app-shell workers; an installability package or |
| 408 | scaffold change; Badging API and `beforeinstallprompt` UI; Eleven's |
| 409 | native APNs relay (`docs/push-relay.md`); declarative push; payload |
| 410 | encryption above RFC 8291 (Eleven's E2EE blob stays Eleven's `decode`); |
| 411 | recipient policy, mute/block, presence ("skip active users"); durable |
| 412 | queues, delivery receipts, automatic retries; replacing webpush-go. |
| 413 | |
| 414 | ## 14. The two points argued, and who won |
| 415 | |
| 416 | - **Send returns `([]Result, error)`, not `[]Result`.** Claude proposed |
| 417 | results-only for `SendTo`; Codex objected that a selection failure |
| 418 | would then read as "no devices". Codex's version stands (§3). |
| 419 | - **The browser test uses a subscription double, not a fake push |
| 420 | service.** Claude proposed a chromedp test round-tripping through a |
| 421 | local fake push endpoint; Codex objected that Chromium's own |
| 422 | subscription path cannot be pointed at it, so the test would prove |
| 423 | less than it claimed. Codex's version stands (§12). |
| 424 | |
| 425 | No open questions remain that the code cannot settle. The one decision |
| 426 | that is Paul's: whether `aviso` is the name. |
| 427 | |