sportsapp

Alert preferences: one-time deployment prerequisite (#54)

This change is local and undeployed. A normal paired release must wait until the following separately authorized cutover is complete. Do not send device tests during this procedure.

Policy and compatibility

New devices enrolled through the updated UI select upset watch only; existing devices retain close-game, upset-watch and final alerts plus their existing ACC reminder and active state. A new-record POST from an old client that omits all three new fields retains legacy close/upset/final defaults, protecting enrollment between Worker activation and the paired website release. All choices are per subscription, independent of scoreboard tabs. Turning the master off preserves the subscription and choices. The API retains ownership authentication and old kickoff-only PATCH support; new clients send revisions to reject stale edits. All four choices may be off while the master remains on.

Live upset watch includes Q4/intermissions and overtime, with the underdog ahead, tied, or at most eight points behind. A ranked favorite must have a persisted pregame spread of at least seven points; absent a line, confirmed unranked opposition or a rank gap of at least ten qualifies. Pick’em, malformed/conflicting supplied odds, missing rank knowledge, and an unranked favorite do not qualify. Final upset results keep the previous rank-only semantics. Display badges are unchanged.

Exact IDs remain game:one-score-fourth, game:ranked-trailing-fourth, game:upset-final, and game:acc-kickoff. A single atomic recipient claim checks both live IDs across every delivery status. Upset watch takes precedence when both new live triggers occur together. A previous close-game attempt cannot escalate into another live attempt; optional finals remain independent. A notification already handed to the push service cannot be recalled.

Enabling a type or reactivating the master creates a pending baseline. The first successful board strictly after activation durably suppresses conditions already true; disabled types are not baselined. Delivery also requires a current qualifying condition, a post-activation event, unchanged recipient revision, active state, and enabled type. Baseline rows and delivery history must never be cleared to replay an event.

Authorized operator cutover

  1. Finish the reviewed combined release source, version and changelog. Require all checks, including browser/updater CI, green on the exact clean final main commit. Record its commit/tree and immutable tag before standalone deployment. Verify the final workflow triggers on release publication/manual dispatch, not tag push; do not publish yet. Obtain execution authorization and an exclusive production-operation window covering manual/automated deployments, queued/active workflows, old-tag dispatches and rollbacks. Check actual game schedules and reserve maintenance margin: paused delivery means no normal event alerts, including games outside Friday/Saturday.
  2. Verify the actual account, existing Worker and D1 database/binding, origins, cron, secret binding names (never values), provider deployment/source IDs, schema and migration ledger. Record the old preference-unaware deployment as never restore, plus a compatible recovery target. Inspect effective deployment controls; record an enforced compatible-tag restriction if supported and authorized, or explicitly record documentation-only protection and accepted residual human-error risk. This prohibition persists after the window. Retain non-secret counts and recovery metadata for subscriptions, settings, events, deliveries, game states, baselines and suppressions.
  3. Resolve the migration mechanism from that evidence. If the original schema was directly installed and no d1_migrations ledger exists, do not run blanket migrations apply: it would attempt 0001_alerts.sql again. After confirming the exact original schema, absence of every 0002 addition and presence of next_poll, execute only reviewed services/alerts/migrations/0002_preferences.sql using the authenticated existing-database SQL execution path. Do not fabricate a migration ledger. If a real ledger exists, apply only its verified pending migration using the established mechanism. If additions already exist, inspect every table/column/index/trigger, backfill and all four state rows and resume from verified state; never replay DDL or reset a completed cutover. Partial/conflicting schema requires a separately reviewed repair while paused. Confirm gate zero, subscription/settings coverage, insert trigger, and retained history. The old Worker ignores the gate; migration alone is not a pause.
  4. Deploy only the compatible alert Worker from the frozen release source with production settings (CLOUDFLARE_ENV unset, SOURCE_COMMIT equal to the frozen commit), following the release build/check procedure. Verify provider provenance, bindings and complete old-version replacement; retain its completion time and exact deployment ID. Confirm paused capability. Never restore old code even before activation. If maintenance margin is lost, keep compatible code paused and defer.
  5. Acquire the operator fence using the exact procedure below, then establish both drains while holding it: longer than the currently verified maximum scheduled invocation lifetime after acquisition and after full old-version replacement. Verify the provider limit at execution time (the documented scheduled limit was 15 minutes when this runbook was prepared); use a margin, not exactly the limit. The normal ten-minute lease and a missing lock cannot establish drain: an earlier poll can outlive its lease. Inspect all live invocation entry paths. The reviewed source calls poll from the scheduled handler; any additional provider/manual entry path must have independently established completion. Record acquisition and drain completion times and evidence. If ownership, remaining lease, routing or drain is uncertain, remain paused.
  6. Revalidate the exact active deployment and unexpired owner after drain, then execute the generated single guarded activation UPDATE. It resets epoch/next_poll and sets gate/cutover marker together only if the owner holds an unexpired fence and all four rows exist. Require exactly four changed rows, then read back all four values and ownership while fenced. Release only this owner’s fence and record the completion time. Do not substitute upserts, separate writes, or a read followed by an unconditional mutation.
  7. Require an accepted-board baseline whose invocation start/epoch is strictly later than the completion of the acquisition drain. A tick starting just before release and acquiring just after release is safe; requiring start after release would reject it unnecessarily. Tie epoch to that invocation’s recorded successful-poll timestamp (last_good_score); if later polls advanced current last_good_score, retain invocation evidence instead of demanding current equality. last_tick alone proves neither completion nor baseline acceptance. Verify expected no-replay behavior, preserved history, readiness and exact CORS for both configured origins within the verified freshness window. Check outside-game cadence: if fresh-feed verification cannot be satisfied, choose a suitable window and leave acceptance incomplete; never weaken readiness. If acceptance fails, pause compatible code and diagnose.
  8. Only after cutover acceptance publish the stable release on the frozen tag through the Codex App, triggering one paired deployment of that same source. Require the positive retained preflight success log, successful workflow, exact website version/commit, provider alert source/deployment identity, both origins/readiness and fresh score coverage. A partial deployment remains partial. Retain source, migration, fence/drain, accepted-baseline and deployment evidence before ending exclusivity. No device-test endpoint is authorized and real-device receipt remains unverified.

Exact fence SQL procedure

scripts/alert-preferences-fence.mjs generates SQL and performs no database/network writes. Use the established authenticated D1 execution interface against the verified existing database. Do not put credentials in commands or evidence. Generate a new public owner identifier once per attempt:

node -e 'console.log("cutover-" + require("node:crypto").randomUUID())'

Substitute that exact identifier for cutover-UUID below. Generate and review each action’s SQL, then execute the entire generated block in one complete submission to the established D1 query interface and retain all returned rows. Never split SELECT changes() into a later request: its result belongs to the mutation on the same database connection. A missing or mismatched change count fails closed; apparently correct state rows alone must never override that failure:

node scripts/alert-preferences-fence.mjs acquire cutover-UUID
node scripts/alert-preferences-fence.mjs inspect cutover-UUID
# Complete and record both drains before generating/executing activation.
node scripts/alert-preferences-fence.mjs activate cutover-UUID
node scripts/alert-preferences-fence.mjs inspect cutover-UUID
# Only after successful activation/readback:
node scripts/alert-preferences-fence.mjs release cutover-UUID

Do not execute these as an unattended sequence. Acquisition must return changed_rows=1, the exact owner and a two-hour lease measured using database time. changed_rows=0 is failure, including an expired orphan; never steal/delete another owner’s row. Wait for a due normal poll to reclaim/release an orphan, or remain paused. Repeating acquisition does not renew an existing lease. This procedure has no lease-extension operation: leave enough time for both drains, activation/readback and release; if insufficient, pause, release only your own row, acquire a fresh owner and repeat the full drain.

Before activation, inspection must return owns_unexpired_fence=1, all_four_rows_exist=1, delivery_paused=1 and minimum_acquisition_drain_elapsed=1; retain database time/expiry and sufficient remaining margin. The helper mechanically refuses activation until more than sixteen minutes after acquisition, derived from its fixed two-hour lease. Never modify the lease to bypass this guard. This minimum does not prove old-version replacement drain or adapt to increased provider limits: the longer currently verified drain and other entry-path evidence remain mandatory. Activation must return changed_rows=4, epoch/next_poll zero and gate/marker one. Its all-four existence predicate makes a missing row fail closed with zero changes; do not repair it with an unguarded insert. Activation also requires the gate to be zero, preventing a blind repeat while enabled. After activation, inspect must still show ownership and all rows, with delivery_paused=0. The guarded UPDATE alone is the atomic mutation; following SELECTs are evidence reads, not a substitute transaction. The SQL uses database execution time, so saved/generated SQL does not freeze the expiry check. Release must return changed_rows=1 for your exact owner. Lost/expired ownership or unexpected readback makes acceptance incomplete: immediately pause compatible delivery, preserve evidence and restart the reviewed procedure after diagnosis. An already submitted push cannot be recalled.

Emergency pause SQL is generated separately and only sets the existing gate row to zero:

node scripts/alert-preferences-fence.mjs pause cutover-UUID

Require one matched row and read back gate zero; absence requires investigation, never an insert. changed_rows reports matched rows, not proof that the value transitioned: pausing an already-zero gate still matches one row. Pause does not need fence ownership, because it only disables delivery; if there is no active attempt, generate any fresh well-formed cutover-UUID for this pause command. It does not clear the durable cutover marker. Keep any existing owned fence until a successful owned release or diagnose lost ownership; never delete a different owner’s row.

While paused, the first accepted poll can already create baseline rows. Those rows intentionally persist: a condition seen during the pause must not become a replay when delivery resumes. The fenced epoch reset and baseline add suppression for conditions that appeared later in the pause without deleting earlier suppression history.

The preflight requires preferencesVersion=1 and preferencesCutoverComplete=true; the latter is a durable operator attestation that the one-time cutover completed. A later pause or VAPID/feed outage does not erase that attestation. It reads public/alerts-config.json and makes a Node GET without a browser Origin header. Overall ready and future unrelated readiness reasons do not control this prerequisite.

The deployment workflow intentionally does not apply migrations or unpause delivery automatically. Missing schema fails closed. A paused or unverified cutover is not a successful release. Epoch guards alone cannot make an in-flight activation safe: an old invocation can overwrite epoch/next_poll. The fence and verified drain are mandatory, including on incident resume.

Failure and rollback

Pause with the generated pause SQL (do not clear preferences_cutover_complete) if the compatible sender needs investigation. Keep every subscription, preference, suppression, event, and delivery row. Resume only through the same exclusive window, verified compatible deployment, fresh owner/fence, full drain, guarded activation and accepted fresh-baseline procedure above. Do not casually flip the gate or blindly repeat activation. Roll back the website independently if needed. Never restore preference-unaware alert code or dispatch an old paired tag, including before activation: it ignores selected types and the pause gate. Use a compatible corrective version instead. This restriction is durable, not limited to the maintenance window. No database down-migration, alert-history reset, or automatic resend is supported.

The cutover relies on operator evidence and a bounded old-worker drain, not a distributed claim that old and new code share the new invariant. See Cloudflare invocation limits and D1 batch transactions.