Silo notifications — design decisions
Episode-availability notifications for a self-hosted, privacy-focused media server, designed to hold up with hundreds of users on one install.
Companion to the specs in docs/superpowers/plans/notifications/ · drafts amended 2026-06-11
How a notification flows
Ingest records availability, a worker fans out to interested profiles, and a durable inbox row is written before any channel fires. Every channel below the row is a transport, never a source of truth.
content stays in Silo
opaque wake only, device fetches from home
full content to a destination the profile chose
Trust model
| Channel | Who sees content | Why that's acceptable |
|---|---|---|
| Inbox + websocket | Only the user's own server and signed-in clients | Same auth boundary as the rest of Silo |
| Apple / Android push | Nobody — relay and Apple/Google see a device token, timestamps, and opaque IDs | The device fetches real content from the user's server after waking |
| Webhooks | The destination operator (e.g., Discord) | The profile pasted that URL; consent is explicit |
Foundation
Durable inbox is the source of truth
- Who
- Every profile on the server
- What
- One delivery row per event; every channel is just a transport on top
- Where
- The server's own PostgreSQL
- Why
- Transports fail — the row survives, so reconnect or refresh always recovers
Preferences are flat, not a matrix
- Who
- Profiles tuning what they hear about
- What
- Four reason toggles plus a master switch; per-device and per-webhook filters can only narrow further
- Where
- Hard gate at fanout, extra filters at dispatch
- Why
- A per-channel by per-reason grid is unexplainable in UI and impossible to debug
Privacy boundaries
Opaque push through a hosted relay
- Who
- Admins who opt in; official store app builds
- What
- The relay holds the Apple and Google signing credentials and forwards a device token plus opaque IDs only
- Where
- Silo-operated service in its own repo, stateless on the request path
- Why
- Only the publisher's credentials can wake store builds; content never leaves the server — the device fetches details from home after waking
Custom credentials escape hatch
- Who
- Power users and forks shipping their own signed app builds
- What
custom_apns/custom_fcmsend straight to Apple or Google with the exact same minimal payload- Where
- Entirely on the user's server
- Why
- Zero Silo infrastructure in the path, without loosening the privacy contract
Profiles, not accounts, are the unit
- Who
- Households sharing one login across several profiles
- What
- Preferences, devices, webhooks, inbox rows, and websocket bindings are all profile-keyed
- Where
- Every table, API route, and event envelope
- Why
- A kid's profile and a parent's profile must not see each other's activity
Webhooks carry full content, by choice
- Who
- Each profile, up to 10 destinations
- What
- Discord embeds or HMAC-signed JSON with series and episode details
- Where
- Direct HTTPS from the server to the URL the profile pasted
- Why
- Choosing the destination is the consent; HTTPS-only, the SSRF deny list, and no-server-origin rules stay mandatory
Scale and reliability
Fanout reads a compact interest index
- Who
- Servers with hundreds of users, roughly 1,000 profiles
- What
profile_series_interestkeyed by library and series, updated on favorite, watchlist, and watch transitions- Where
- Written at interaction time, read once per release event
- Why
- Cost scales with profiles interested in one series, not with a million-episode catalog
Back-catalog imports never flood
- Who
- Anyone adding a new library or a full season pack
- What
- First scans seed availability silently; bulk additions cap at 3 notifications per series per scan
- Where
- Scanner seeding plus the fanout worker burst cap
- Why
- One 200-episode import with 300 interested profiles would otherwise mean about 60,000 pushes in a single scan
Dispatch enqueue is durable (outbox)
- Who
- Operators who care what a crash costs
- What
- Pending push and webhook attempt rows commit in the same transaction as the inbox row
- Where
- The fanout transaction; recovery workers sweep stale rows
- Why
- A crash between commit and dispatch delays sends instead of silently losing them
One delivery per episode, across libraries
- Who
- Dual-quality setups like TV plus TV 4K
- What
- A partial unique index on profile and episode spans libraries; the first event processed wins
- Where
- The
notification_deliveriesschema - Why
- Media items are catalog-level in Silo, so the same episode landing twice must not notify twice
Source documents
00-architecture-overview.md— channel model, fanout pipeline, threat model, relay addressing01-release-events-and-inbox.md— durable inbox, fanout worker, seeding and burst suppression, sync API02-apns-relay.md— Apple push: hosted relay and custom APNs03-fcm-relay.md— Android push: hosted relay and custom FCM04-outbound-webhooks.md— Discord and generic JSON+HMAC webhooksREADME.md— reading order, status, and the 2026-06-11 amendment index