P0 prep: make the app tolerate a null golf_players.user_id, and classify every unledgered migration - #1692
Conversation
… user_id Preparation for 20260819200000 (preserve golf history on account deletion), which changes golf_players.user_id from NOT NULL to nullable so a deleted account leaves its rounds, shots and holes intact instead of cascading them away. How the four sites were found, rather than guessed: the generated types are built from production, where user_id is still NOT NULL, so tsc cannot see the change yet. I hand-edited a disposable copy of the golf_players Row/Insert/ Update types to make user_id nullable, ran typecheck, and took the errors as the complete compile-visible breakage list. Four, all the same shape — a golf_players -> users notification fan-out that puts user_id straight into an `in` list. The file was then restored and verified byte-identical by sha256. These four are stragglers, not a new problem: golf.ts already does exactly this filter in three other fan-outs (2869, 3459, 4793). The patch makes the four match, using the same `.filter((id): id is string => Boolean(id))`. Ships BEFORE the migration on purpose. user_id is NOT NULL in production today, so the filter removes nothing and the change is a no-op — which is what decouples this from the lockstep deploy the migration header warns about. What else the audit found, and did not change: - No code path anywhere sets golf_players.user_id to null. Verified across src/ and supabase/. The trigger's `old.user_id is not null and new.user_id is null` condition is therefore reachable only through the FK's ON DELETE SET NULL — no ordinary write can trip the anonymization by accident. - Every column the trigger nulls (first_name, last_name, email, phone, avatar_url, hometown, state, high_school_name, graduation_year, gpa) is ALREADY nullable, so no rendering path needs a change. - The two remaining user_id consumers tsc did not flag are ownership checks (round-drafts.ts:690, generate-review/route.ts:75). Both compare with !== / === against user.id, so a null denies. Fail-closed, correct. - An anonymized player keeps its golf_team_members row and would still appear on an active roster with a blank name. That is a product decision, not a compile break, and is registered in the incident rather than decided here. Verified: typecheck 0 against today's types AND 0 against the nullable types; lint 0; the three notification tests pass (8/8). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH
…ive catalog
Closes the open question inside MIGRATIONS_REPO_PRODUCTION_LEDGER_DIVERGENCE:
the gap recorded that the repo and production's ledger disagree, but not what
was actually missing.
Method, because the method is the finding. A ledger row is a claim about
history, not evidence about the schema — so each of the 42 files with no
production ledger row was parsed for the objects it DECLARES (tables, columns,
functions, policies, indexes, triggers, enum values, constraints) and those
objects were checked against the live catalog in bulk. Not filenames.
22 applied — every declared object present
5 partial
2 unapplied
11 UNKNOWN by construction — GRANT/REVOKE and data backfills leave no
catalog object to look for, and "probably applied" is not a verdict
2 self-declared no-ops
The headline: 20260819200000_preserve_golf_history_on_account_deletion is the
ONLY golf-facing migration with genuinely missing effects. 1 of its 5 objects
exists, and the FK that does exist is still confdeltype='c' (CASCADE).
Two things this corrected that a filename read would have got wrong:
- Three "absent" policy sets are name divergences, not missing RLS. Production
covers the same table and verbs under its own names
(baseball_event_acknowledgements_* vs baseball_event_acks_*). One of the
three is genuinely short a DELETE policy; the other two are equivalent or a
superset. Predicate equivalence is NOT claimed — only verb coverage was
measured.
- All seven ncaa_division enum values and all four avatar storage policies read
ABSENT until re-queried case-insensitively; the extractor lowercases SQL and
production stores them uppercase/capitalised. Two migrations would have been
reported as production gaps that are not. A case-folding bug in a classifier
is indistinguishable from a missing object unless you go and look.
Also confirms the plan's hard stop empirically rather than assuming it:
`supabase db push` would propose all 42, not the one migration the owner
authorised. `db push --dry-run` is itself covered by the permissions.deny
prefix rule and was not attempted; `supabase migration list --linked` answers
the same question and is not denied.
The incident gains the measured blast radius (104 players, all 104 with a
linked user; 521 rounds / 36,943 shots / 9,162 holes — the migration header's
2026-08-18 baseline has grown by about half again), the application
compatibility audit, and an explicit note that the local exercise is BLOCKED:
Docker Desktop was launched but its daemon never came up this session.
Verified: docs:schema-drift 0, docs:path-drift 0, docs:inventory-check 0,
markdown ratchet at baseline (net 0).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH
…ilure Follow-on to the nullable-user_id compatibility patch. The recipient guard fires on `userRows.length === 0` and logs a delivery FAILURE. After 20260819200000 a batch whose targeted players are all anonymized legitimately resolves to zero users — nobody to notify is not a failed lookup, and logging it as one is the failure-vs-empty conflation this repo keeps removing, pointed the other way. Only this site needed it. The other three fan-outs already no-op on empty without logging: tasks.ts logs only on a real `userRowsError`, golf.ts maps over an empty array, and player-notify.ts returns early on `!userRows?.length`. Verified: typecheck 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH
|
ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing |
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…'s, and say why Two coordination fixes, both from the residue checker reporting honestly. **#1691's row is removed.** It was TRANSITIONALLY closed — merged at a03ef84 while HEAD sat on that exact commit, which is the narrow exception that stops a PR carrying its own row from failing main the second it merges. The grace ends the moment any other commit lands, and it has: this branch added its own. The row going stale is the exception working exactly as designed, not a defect. **The failure now names the remedy.** It previously read only "merged at <sha>, HEAD has moved past it", which a session meeting it cold cannot distinguish from an ordinary stale row left behind by carelessness. The two have the same symptom and different histories, and only one of them has a one-line fix. It now says to delete the row in the PR already being opened. That is the whole coordination cost of the transitional exception, and it belongs in the tool's own output rather than in a comment block someone has to already know to read. **#1692's row is added** in the same change, which is the pattern the file's own $comment prescribes: the next ordinary PR clears the expired row and registers itself at the same time. Verified: the notice renders — classifyDispositionResidue exercised directly with a MERGED row whose merge sha differs from HEAD emits the new text. guards 32/32, residue suite 14/14, JSON parses. Expect one red `open-pr-residue` run between the PR opening and this commit reaching its head: in-flight arrival is proven by reading the row at the PR's own head, and the head does not carry it until this lands. That is the mechanism, not a regression. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH
… red I wrote, earlier in this run, that `Supabase Preview` "has been red on every main commit for so long that it reads as background noise." That is wrong. Measured across eight PRs (#1679, #1680, #1686–#1691), its conclusion is SKIPPED every time — never FAILURE. It is also not one of main's six required contexts, so it could not block a merge even if it did fail. A check that never runs is invisible in a way a red one is not, which makes the real situation worse than the one I described, not better. The skipping is legitimate: Supabase only builds a preview branch for a PR that touches supabase/migrations/**, and most do not. Branching is enabled and works — #1681 touches migrations and its preview branch reached FUNCTIONS_DEPLOYED today. What is genuinely unaddressed is one level down: the Supabase branch record for `main` carries status MIGRATIONS_FAILED with created_at == updated_at == 2026-07-03T21:11:11Z, so it has not been refreshed since it was written. Whether that is a live verdict on today's migrations or a stale setup artifact is NOT established — both readings fit the timestamps, and picking one would be a guess. Recorded as unresolved rather than resolved in the wrong direction. This changes the shape of the "make Supabase Preview green" task: the check is inert, not failing, so the decision is whether an inert check should be required, repaired, or removed — not how to turn it green. One consequence worth having: a preview branch is an isolated project, NOT production, which makes it the environment the plan's "do not use production for destructive behavioral tests" rule points toward. With Docker down it is the only remaining route for exercising the golf-history migration. Not taken from here — provisioning one is a real resource on the owner's account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH
The Supabase Preview correction changed a STATE_SNAPSHOT column and the inventory went stale again. CI caught it, correctly. Third time this run, same root cause, worth naming: this generator reads the git INDEX, and `--check` in CI runs against the COMMIT. Regenerating before the last content edit produces a locally-green, remotely-red result every time. The regeneration has to be the final step before the commit, not a step during it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH
…#1693 #1692 was transitionally closed at its own merge commit; the grace ended the moment this branch added a commit past it. Cleared in the next ordinary PR, which registers itself at the same time — the pattern the file's $comment prescribes, and the one the residue check's remedy text (added in #1692) now tells a session to follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH
…nsitional row (#1693) * docs(incident): turn three open questions in the P0 handoff into answers All three were things I left balanced that the evidence already leans on, or recommendations I left as options. A finding plus three options is a question wearing a hat. 1. MIGRATIONS_FAILED — the timestamps favour "stale artifact". If the Supabase integration wrote to the default-branch record on any commit since July, updated_at would have moved; across ~two months of merges it has not, while #1681's record created today does carry a current status. Evidence, not proof, and explicitly not to be read as "the migrations are fine" — but stronger than the "both readings fit" I recorded. 2. Supabase Preview — recommend REMOVING it and pointing at the classification report. The check exists to answer "does the repo agree with production's migrations"; the report answers exactly that, from the live catalog, on demand, rather than only on a PR that touches supabase/migrations/** — the condition that made the check silent in the first place. Requiring an inert check blocks every PR; repairing it rebuilds something the report does better. Overrulable, but leaving it SKIPPED forever is the one option with nothing to recommend it. 3. Docker — name the likely cause, not just the failure. Both /Applications/Docker.app and /Applications/Docker 2.app exist and the launched process came from Docker 2.app. Two installs contending over one socket is a known way to get a live GUI and a dead daemon. Removing the duplicate is a minute of work and unblocks the local exercise directly — that is an action the owner can take, where "Docker didn't start" is not. Verified: docs:schema-drift 0, docs:path-drift 0, knowledge:check 0, markdown ratchet at baseline. The doc inventory was regenerated AFTER staging this time and produced no diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH * chore(control-plane): clear #1692's expired transitional row, register #1693 #1692 was transitionally closed at its own merge commit; the grace ended the moment this branch added a commit past it. Cleared in the next ordinary PR, which registers itself at the same time — the pattern the file's $comment prescribes, and the one the residue check's remedy text (added in #1692) now tells a session to follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two halves of the golf-history P0, both of which can land before any production
schema change. Neither applies a migration.
1. Application compatibility for a nullable
golf_players.user_id20260819200000_preserve_golf_history_on_account_deletionmakesuser_idnullable so a deleted account leaves its rounds, shots and holes intact instead
of cascading them away. Its header warns the route and the schema must deploy in
LOCKSTEP. For the application half that turns out not to be necessary.
How the breakage list was obtained rather than guessed. The generated types
are built from production, where
user_idis stillNOT NULL, sotsccannotsee the change. A disposable copy of the
golf_playersRow/Insert/Updatetypes was edited to make
user_idnullable,npm run typecheckrun, and theerrors taken as the complete compile-visible set. The file was restored and
verified byte-identical by sha256.
Four errors, all one shape — a
golf_players→usersnotification fan-outputting
user_idstraight into aninlist. They are stragglers, not a newproblem:
golf.tsalready applies exactly this filter in three other fan-outs(2869, 3459, 4793). The four now match.
This ships before the migration on purpose. The filter removes nothing while
the column is
NOT NULL, which is what decouples the code deploy from the schemachange.
A fifth change:
announcements.tslogged a delivery failure when zero usersresolved. Post-migration an all-anonymized batch legitimately resolves to zero —
nobody to notify is not a failed lookup.
What the audit found and did not change
golf_players.user_idto null (verified acrosssrc/andsupabase/). The trigger fires onold.user_id is not null and new.user_id is null, so it is reachable ONLY through the FK'sON DELETE SET NULL. Worth checking before the apply: had such a path existed, themigration would have silently destroyed a live player's name, email, phone and
GPA on a write nobody thought of as a deletion.
user_idconsumerstscdid not flag are ownership checks; bothcompare against
user.id, so a null denies. Fail-closed.golf_team_membersrow and would still appearon an active roster with a blank name. Recorded in the incident as a product
decision, not decided here.
2. Every migration with no production ledger row, classified
Closes the open question inside
MIGRATIONS_REPO_PRODUCTION_LEDGER_DIVERGENCE.A ledger row is a claim about history, not evidence about the schema — so each
of the 42 files was parsed for the objects it declares and those objects
checked against the live catalog.
The 11 UNKNOWNs are GRANT/REVOKE and data backfills, which leave no catalog
object to look for. "Probably applied" is not a verdict.
Headline:
20260819200000is the only golf-facing migration with genuinelymissing effects. 1 of its 5 objects exists, and the FK that does exist is still
confdeltype='c'(CASCADE). Measured the same day: 104 golf players, all 104with a linked user; 521 rounds, 36,943 shots, 9,162 holes — all exposed.
Two things the method corrected
Production covers the same table and verbs under its own names. One of the
three is genuinely short a DELETE policy; the other two are equivalent or a
superset. Predicate equivalence is not claimed — only verb coverage was
measured.
ncaa_divisionenum values and all four avatar storage policies readABSENT until re-queried case-insensitively; the extractor lowercases SQL and
production stores them uppercase. Two migrations would have been reported as
production gaps that are not.
The hard stop, confirmed rather than assumed
supabase db pushwould propose all 42 migrations, not the one the ownerauthorised.
db push --dry-runis itself covered by thepermissions.denyprefix rule and was not attempted;
supabase migration list --linkedanswers thesame question and is not denied.
Verified
typecheck 0 (against today's types and against the nullable types) · lint 0 ·
npm test1275 files / 12,209 passed · docs:schema-drift 0 · docs:path-drift 0 ·docs:inventory-check 0 · knowledge:check 0 · markdown ratchet at baseline (net 0)
Not done, and why
The plan's local exercise of the migration is blocked: Docker Desktop is
installed and was launched, but its daemon never came up this session. Stated
rather than skipped — the migration has not been run against a local stack.
The incident now also records that the owner's real decision is which
mechanism applies this migration, not just whether to. Every apply route is
currently denied, disconnected, or out of bounds.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH