Skip to main content

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 a release_key, on a release_track — a closed three-value set: stable | dev | test (there is no beta; dev/test are private, allowlist-gated). Plus a download_url, integrity installer_sha256, and both user-facing and technical release notes.
  • app_rollouts_v2 — a rule that exposes a release to a slice of the fleet: a track (default stable), a percentage (0–100, default 100), optional experiment_key / ab_bucket (A/B/null) for A/B tests, explicit include_user_ids / exclude_user_ids / include_install_ids arrays, a priority (default 100; lower value = higher precedence), a force_update flag (default false), an enabled gate (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 specific target_type (user | install) + target_id to a release_id, with an enabled flag, force_update (default true here — note the difference from rollouts), a reason, and expires_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_v1one row per meaningful resolve outcome (25 columns), de-duplicated by should_write_resolve_audit so identical/no-op resolves don't spam rows. It records the install's current state (installed_*_version, installed_track), what was decided (assignment_sourcerollout / override / noneassignment_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

User FKs target auth.users, not playroll_users

The 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. Its urgency_tier is a strict enum T0 | T1 | T2 | T3 that carries behavior: T0 requires a CTA, forces in_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) (default it). category is free text — no CHECK (e.g. payment_readiness, app_update); it is not a closed enum. Also carries type, payload / cta (jsonb), allowed_channels (default {in_app}), the audience_filter jsonb (resolved by resolve_audience()), a denormalized recipient_count, and an optional expires_at.
  • notification_deliveries — the fan-out for external channels only: one row per (event, user, channel) attempt where channel ∈ (email, push, discord). in_app is deliberately not here — it's served from notification_state. status ∈ (queued [default], sent, failed, skipped), with error_code, queued_at, sent_at. UNIQUE on (event_id, user_id, channel) for idempotency.
  • notification_state — the per-user engagement record (and the in_app delivery surface): when a user read_at / dismissed_at / acted_at a 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 a slug/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 only enable_email (for non-T0 email sends); the three category toggles and enable_sound are stored but consumed client-side / not yet enforced at delivery. The category→toggle mapping itself lives in application code (the category strings 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.