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.
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.
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.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.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.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.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.
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.