Releases & Notifications
Plain-language summary. Two delivery systems share this page because they do the same kind of job: decide who gets what, when. The release system decides which app build a given install should update to (by track, by percentage, by A/B bucket, with emergency overrides). The notification system decides which in-app/email messages a given player sees, and remembers whether they read them.
Part 1 — App releases & rollouts
Map
How an update gets resolved
When a client asks "is there an update for me?", the resolve-update edge
function walks these tables and writes one audit row. The pieces:
app_releases_v2— a published build. It bundles the three versions that ship together (installer_version,core_version,ui_version) under arelease_key, on arelease_track— a closed three-value set:stable | dev | test(there is nobeta;dev/testare private, allowlist-gated). Plus adownload_url, integrityinstaller_sha256, and both user-facing and technical release notes.app_rollouts_v2— a rule that exposes a release to a slice of the fleet: atrack(defaultstable), apercentage(0–100, default 100), optionalexperiment_key/ab_bucket(A/B/null) for A/B tests, explicitinclude_user_ids/exclude_user_ids/include_install_idsarrays, apriority(default 100; lower value = higher precedence), aforce_updateflag (default false), anenabledgate (default true — disabled rollouts are skipped), and a time window (starts_at/ends_at).app_rollout_overrides_v2— a per-target escape hatch: pin a specifictarget_type(user|install) +target_idto arelease_id, with anenabledflag,force_update(default true here — note the difference from rollouts), areason, andexpires_at. Unique on(target_type, target_id). This is how you hotfix one user or force a critical update outside the normal ramp.app_update_resolve_audit_v1— one row per meaningful resolve outcome (25 columns), de-duplicated byshould_write_resolve_auditso identical/no-op resolves don't spam rows. It records the install's current state (installed_*_version,installed_track), what was decided (assignment_source—rollout/override/none—assignment_track,rollout_id,override_id,bucket), and the outcome (target_release_id, the target versions,update_available,update_required,force_update,reason). This is the table you query to answer "why did this machine get / not get the update?"
Resolution order: overrides win first (a user override before an
install override); otherwise the resolver tries enabled rollouts in
(priority ASC, created_at ASC) order and the first qualifying one wins
(priority is the primary selection key, not just a tie-break). When nothing is
selected, assignment_source = 'none'.
app_update_resolve_rate_limit— fixed-window counters keyed by(ip_sha256, 60s window)so the public resolve endpoint can't be hammered. Old rows are pruned on every check.
The user_current_track view derives each user's current release track from
the latest installed_track in the audit table; resolve_audience() uses it for
the app_track notification filter. See
Program Phases & Policies and
the Release & CI Workflows runbook.
Part 2 — Notifications
Map
auth.users, not playroll_usersThe user_id / added_by / created_by columns on the notification tables all
FK auth.users(id) (same UUID as playroll_users, different physical table),
ON DELETE CASCADE (except created_by/added_by, which SET NULL). The diagram
draws them to PLAYROLL_USERS for readability. Idempotency keys:
notification_deliveries is UNIQUE on (event_id, user_id, channel),
notification_state PK is (event_id, user_id).
The shape of a notification
notification_event— the authored message. One row per notification. Itsurgency_tieris a strict enumT0 | T1 | T2 | T3that carries behavior: T0 requires a CTA, forcesin_app+email, and cannot be opted out of; lower tiers route by tier + category.source ∈ (backend, portal, release, ops_admin, discord_bridge);locale ∈ (it, en)(defaultit).categoryis free text — no CHECK (e.g.payment_readiness,app_update); it is not a closed enum. Also carriestype,payload/cta(jsonb),allowed_channels(default{in_app}), theaudience_filterjsonb (resolved byresolve_audience()), a denormalizedrecipient_count, and an optionalexpires_at.notification_deliveries— the fan-out for external channels only: one row per (event, user, channel) attempt wherechannel ∈ (email, push, discord).in_appis deliberately not here — it's served fromnotification_state.status ∈ (queued [default], sent, failed, skipped), witherror_code,queued_at,sent_at. UNIQUE on(event_id, user_id, channel)for idempotency.notification_state— the per-user engagement record (and thein_appdelivery surface): when a userread_at/dismissed_at/acted_ata given event. PK(event_id, user_id). Drives the unread badge and act-on-CTA metrics.notification_groups+notification_group_members— named cohorts (founders, ops, beta…) you can target by, instead of spelling out user ids every time. A group has aslug/name; membership is(group_id, user_id).notification_user_prefs— each user's toggles (enable_payment_and_validation,enable_app_update_and_terms,enable_catalog_and_announcements,enable_sound,enable_email; categories + email default true, sound default false). Important: the row is created lazily — absence means "opted in (defaults)". And server-side delivery (emit_notification) currently consults onlyenable_email(for non-T0 email sends); the three category toggles andenable_soundare stored but consumed client-side / not yet enforced at delivery. The category→toggle mapping itself lives in application code (thecategorystrings don't literally match the column names), not in the DB.
See Notification Delivery Flow and the Notifications Composer Ops runbook for the authoring → delivery → state lifecycle.
See also: full column reference for releases and notifications.