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.

Scan and ingest new episode lands release_events one row per episode Fanout worker burst caps + eligibility notification_deliveries durable row, dispatched after commit Websocket full content Apple push opaque wake Android push opaque wake Webhooks your chosen URL Silo push relay sees token + opaque IDs custom credentials skip the relay APNs / FCM deliver the generic wake Device wakes fetches details from home
content stays in Silo opaque wake only, device fetches from home full content to a destination the profile chose

Trust model

ChannelWho sees contentWhy that's acceptable
Inbox + websocketOnly the user's own server and signed-in clientsSame auth boundary as the rest of Silo
Apple / Android pushNobody — relay and Apple/Google see a device token, timestamps, and opaque IDsThe device fetches real content from the user's server after waking
WebhooksThe 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_fcm send 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_interest keyed 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_deliveries schema
Why
Media items are catalog-level in Silo, so the same episode landing twice must not notify twice

Source documents