vsa-debugging-playbook
Load this when something on the VSA website is broken and the cause is unknown — pages showing fallback/ContentUnavailableState, Supabase queries returning empty rows or "permission denied", points/at
SKILL.md.claude/skills/vsa-debugging-playbook/SKILL.md
GitHubVSA Debugging Playbook
What this skill is for. You have a symptom, not a diagnosis. This playbook maps each of this project's real failure modes to a first check (exact command), ranked likely causes, a discriminating experiment, and where to go for the fix. It exists because several of this project's failure modes look identical on the surface (empty rows vs. denied rows; degraded mode vs. bad query; the two parallel points systems) and picking the wrong branch has historically cost hours.
When NOT to use this skill:
| If you need… | Go to |
|---|---|
| Full incident narratives (root cause, evidence, commits) | vsa-failure-archaeology |
| Whether a change is even allowed / how to get it approved | vsa-change-control |
| RLS theory, SECURITY DEFINER, migration-authoring checklist | vsa-supabase-security-reference |
| Design tokens, dark mode conventions, component layering depth | vsa-design-system-reference |
| Setting up a dev environment from scratch | vsa-build-and-env |
| Deploying, applying migrations, running the image pipeline | vsa-run-and-operate |
| Diagnostic tooling deep-dives (verify-rls-security.mjs, audits, Lighthouse) | vsa-diagnostics-and-measurement |
| What counts as verification evidence before you ship | vsa-validation-and-qa |
Jargon, defined once:
- RLS — Row Level Security: Postgres policies that filter which rows a role can see/write. A query blocked by RLS returns empty rows, not an error.
- Grant — SQL-level permission (
GRANT SELECT ON …) that a role needs before RLS is even evaluated. A missing grant returnspermission denied(code42501). - Degraded mode — the app's designed behavior when Supabase is unreachable or over quota: public pages render static fallback content instead of crashing (see
AGENTS.md"Degraded mode"). - Egress — bandwidth billed when data (mostly images) is served out of Supabase Storage. This project had a real egress crisis; see traps below.
- SECURITY DEFINER — a Postgres function that runs with its owner's privileges, bypassing the caller's RLS. Used deliberately in
is_admin_user(); dangerous when accidental (views). - CRA — Create React App (react-scripts 5). This project is CRA, not Vite/Next; jest and build behavior follow CRA rules.
Symptom → triage table
Commands run from the repo root. Commands marked [live creds] need .env.local populated or Supabase dashboard access — everything else is repo-local and safe.
| Symptom | First check (exact command) | Likely causes, ranked | Discriminating experiment | Fix route |
|---|---|---|---|---|
Page renders fallback content, ContentUnavailableState, or DegradedModeBanner |
grep -c "REACT_APP_SUPABASE_URL|REACT_APP_SUPABASE_ANON_KEY" .env.local (expect 2) |
1. Missing/wrong env vars (client never connects) 2. Supabase outage or quota exhaustion 3. A caught error that isSupabaseUnavailable() classifies as an outage |
Read src/utils/isSupabaseUnavailable.ts (L9): it returns true for HTTP 429/402/503/504, any TypeError (network/CORS), and message fragments like quota, egress, bandwidth, fetch failed. Check the browser console for the raw error, then check https://status.supabase.com. If env vars are present and Supabase is up, the error is being misclassified — inspect the actual thrown error's status/message. |
Env setup → vsa-build-and-env. Real outage/quota → vsa-run-and-operate. Fallback content itself lives in src/config/publicFallbackContent.ts (playbook agent: vsa-public-content). |
| Query returns empty rows, no error | Confirm it's not degraded mode first (row above), then run Experiment A below | 1. RLS policy filters out the rows for this role 2. The data genuinely isn't there (wrong filter, wrong table — see dual points row) 3. Querying a view whose underlying join drops rows | Experiment A (RLS vs. grant vs. no-data), below the table | RLS policy details → vsa-supabase-security-reference; schema truth is supabase/migrations/ |
Query errors with "permission denied" (42501) |
Same Experiment A | 1. Missing GRANT for anon/authenticated on a new table/view/function 2. Grants revoked but re-grant forgotten (revoke-then-grant pattern half-applied) |
Experiment A: permission denied = grants problem; [] = RLS problem. These are different fixes — don't "fix" RLS when the grant is missing |
vsa-supabase-security-reference (owns the grant/revoke patterns and migration checklist) |
| Points or attendance change not visible in UI | grep -rn "member_event_attendance|member_yearly_points|event_attendance|user_points" src/data/repos/*.ts |
1. You wrote to one points system, the UI reads the other (see Experiment B) 2. Event missing academic_term_id → excluded from yearly totals 3. Stale react-query cache (next row) |
Experiment B (dual points systems), below the table | Protected domain — do NOT modify points/leaderboard logic without explicit request (AGENTS.md "Things to never do"). Audit via playbook agent vsa-points-attendance-guardian. Architecture rationale → vsa-architecture-contract |
| Event's points appear in All-Time but missing from a yearly leaderboard | [live creds] SQL editor: select id, title, academic_term_id from events where academic_term_id is null; |
1. Event has no academic_term_id (documented hard requirement — docs/leaderboard-system.md: "Events MUST have an academic_term_id assigned to be counted in yearly totals") |
Assign the term in Admin → Events, re-check the member_yearly_points view |
Playbook agent vsa-points-attendance-guardian for anything beyond assigning the term |
| Stale data after a mutation (create/edit succeeds, list doesn't update) | grep -rn "invalidateQueries" src/hooks/useEvents.ts src/hooks/usePoints.ts |
1. Mutation's onSuccess doesn't invalidate the query key the list reads 2. Query key mismatch (['events'] vs 'event-stats' — both styles exist) 3. You bypassed the repo/hook layer |
Working examples: src/hooks/useEvents.ts L86–133 — e.g. check-in invalidates ['event', id], ['events'], ['user-points'], ['leaderboard'] in one onSuccess. Compare your mutation against these. This is react-query v3 (react-query@3.39.3 in package.json), not @tanstack v4/v5 — v5 idioms from training data may not apply |
Pattern home → vsa-architecture-contract |
Test fails: Cannot use import statement outside a module / SyntaxError inside node_modules/zod or @hookform/resolvers |
grep -n "transformIgnorePatterns" package.json |
1. A new ESM-only dependency isn't in the jest transformIgnorePatterns allowlist (currently node_modules/(?!(zod|@hookform/resolvers)/)) |
Add the failing package name inside the (?!(…)) group in package.json → jest and re-run. See trap 4 below |
vsa-build-and-env (owns setup traps); tests → playbook agent vsa-testing-qa |
npm test hangs / CI never finishes |
Use CI=true npm test -- --watchAll=false |
1. CRA jest defaults to watch mode; without CI=true … --watchAll=false it waits for input forever |
n/a | vsa-validation-and-qa (owns the verification command set) |
| Console warnings during tests (jsdom, ThemeProvider, Framer Motion) | Check the exit code, not the noise: CI=true npm test -- --watchAll=false; echo "exit=$?" |
1. Known-acceptable warnings — AGENTS.md (Testing): "Existing jsdom, ThemeProvider, and Framer Motion console warnings are acceptable when the test command exits successfully" |
If exit code is 0, ship. If non-zero, read the first failure, not the first warning | vsa-validation-and-qa |
| Build fails on TypeScript syntax that "should work" | grep '"typescript"' package.json |
1. This repo is TypeScript 4.9.5 — satisfies works (4.9+) but const type parameters, 5.x decorator syntax, etc. do not 2. Strict mode violation (any is banned; use unknown + narrowing per AGENTS.md) |
Check the TS version before assuming the code is wrong | vsa-build-and-env |
deno check on an Edge Function shows ~a dozen type errors |
deno check supabase/functions/vsa-ai-assistant/index.ts |
1. Pre-existing esm.sh type-resolution noise, not your regression — verified 2026-07-06: exactly Found 12 errors. on an untouched checkout |
Run deno check on the unmodified function first (e.g. git stash your edits mentally — read the file from HEAD via git show HEAD:supabase/functions/vsa-ai-assistant/index.ts) and diff the error count and locations against your edited version. New errors at your lines = yours; the baseline 12 = noise. See trap 5 |
Edge Function deploy/ops → vsa-run-and-operate; Ask VSA behavior → playbook agent vsa-ai-knowledge |
| Edge Function returns 500 in production | [live creds] Supabase Dashboard → Edge Functions → function → Logs (functions in repo: analytics-proxy, trigger-event-image-migration, trigger-house-event-image-migration, vsa-ai-assistant — verify with ls supabase/functions/) |
1. Missing function secret 2. Runtime error visible only in logs 3. Schema drift between function SQL and applied migrations 4. Historical functions may still be deployed even after repo source deletion; secure-ai is one such issue #353 cleanup case |
Reproduce with a curl against the function URL and compare the logged error to local deno check output |
Secrets catalog → vsa-config-and-flags; deploy order → vsa-run-and-operate |
| Wrong colors / dark mode broken on one component | grep -rn "text-gray-|bg-white|bg-gray-900" src/path/to/component |
1. Hardcoded palette classes instead of semantic tokens — tailwind.config.js defines surface, surface2, text-primary as CSS-variable-driven tokens; hardcoded grays don't flip with theme 2. Class strings concatenated raw instead of via cn() (src/lib/utils.ts) 3. Missing dark: variant where tokens are insufficient |
Toggle theme: dark mode is class-based (darkMode: 'class' in tailwind.config.js; ThemeContext sets the class on <html>). If the element doesn't change on toggle, it's hardcoded |
vsa-design-system-reference for token depth; AGENTS.md bans hardcoded color classes |
| Logged-in user not recognized as admin | cat src/hooks/useAdmin.ts — it selects is_admin from user_profiles where id = user.id |
1. user_profiles.is_admin is false/missing for that user [live creds] 2. The query errors and useAdmin fails closed — its catch sets isAdmin=false and only console.errors, so an RLS/grant problem looks like "not an admin" 3. RLS recursion regression on user_profiles (see trap 1) |
Open the browser console: Error checking admin status: present = cause 2/3 (data-access problem), absent = cause 1 (data problem). For cause 3, the fix shape is the is_admin_user() SECURITY DEFINER helper in supabase/migrations/20260620020000_fix_user_profiles_rls_recursion.sql |
vsa-supabase-security-reference; full incident → vsa-failure-archaeology (#160) |
A new view returns data to anon unexpectedly, or is writable |
grep -n "revoke all" supabase/migrations/<your_migration>.sql (expect a revoke before any grant) |
1. Default-privileges gotcha: this project's ALTER DEFAULT PRIVILEGES grants ALL on new relations including views to anon/authenticated, and definer-owned simple views are auto-updatable — writes pass through, bypassing base-table RLS |
Model migration: supabase/migrations/20260701000000_add_member_photo_requests.sql L235–260 — comment reads "Revoke everything, then grant SELECT only." Every new view needs revoke all … from anon, authenticated; then an explicit grant select |
vsa-supabase-security-reference (owns the full pattern) |
| Images broken or page slow with heavy image traffic | Inspect the failing URL in devtools Network tab: does the host contain supabase.co/storage? |
1. Image still served from Supabase Storage instead of /public (egress trap — see trap 2) 2. Migrated path wrong/missing in /public 3. Storage object actually deleted (should never happen — deletion is banned in AGENTS.md) |
npm run migrate:images:dry reports what the image-migration pipeline (scripts/migrate-supabase-images-to-public.ts) would move — dry-run only, safe. Compare its report to the broken URL |
Pipeline operation → vsa-run-and-operate; audits → playbook agent vsa-storage-egress |
| Graphify gives answers that don't match the code | grep -n "Built from commit" graphify-out/GRAPH_REPORT.md && git rev-parse --short HEAD |
1. Stale graph — the two hashes differ (e.g. report says a3d7ea60 while HEAD is newer) 2. Git hooks not installed |
./scripts/graphify-run hook status — expect post-commit: installed and post-checkout: installed. Refresh with graphify . --update (interactive sessions only; never inside a feature PR) |
Graphify workflow → docs/graphify-workflow.md and the graphify skill |
Discriminating experiments
Experiment A — empty rows vs. permission denied vs. no data
These three look similar from the UI (something doesn't show up) but have disjoint fixes. RLS filtering silently returns []; a missing grant raises permission denied; absent data is neither's fault.
[live creds] In the Supabase SQL editor (runs as a privileged role, bypasses RLS):
select count(*) from <table_or_view>; -- ground truth: does the data exist at all?Count is 0 → no data: the write never landed (check Experiment B if points-related) or your filter is wrong. Stop here.
[live creds] Same query as the
anonrole via REST, from your shell (reads keys from.env.local):set -a; source .env.local; set +a curl -s "$REACT_APP_SUPABASE_URL/rest/v1/<table_or_view>?select=*&limit=3" \ -H "apikey: $REACT_APP_SUPABASE_ANON_KEY" \ -H "Authorization: Bearer $REACT_APP_SUPABASE_ANON_KEY"[](empty array, HTTP 200) → RLS is filtering. The role has permission to query but no policy admits these rows. Fix the policy (viavsa-supabase-security-referencepatterns), never by weakening RLS broadly (banned inAGENTS.md).{"code":"42501", …"permission denied"…}→ missing grant. RLS was never reached. Add/restore theGRANT.- Rows returned → the backend is fine for
anon; if the app still shows nothing, the bug is client-side: wrong repo method, wrong query key, or anauthenticated-role policy difference (repeat with a logged-in user's JWT as the Bearer token).
To test the
authenticatedrole specifically, replace the Bearer token with a real session JWT (copy from devtools → Application → Local Storage → thesb-*-auth-tokenentry,access_tokenfield). Policies frequently differ betweenanonandauthenticated.
Experiment B — which points system did your write land in?
There are two parallel points systems (documented in docs/leaderboard-system.md; consolidation is FUTURE work, not done):
| System | Tables | Feeds which UI | Repo/consumers (verified 2026-07-06) |
|---|---|---|---|
| Member/leaderboard system (source of truth for public + admin leaderboards) | member_event_attendance + events + academic_terms, aggregated by the member_yearly_points view (and house_member_yearly_points for Houses) |
Public Leaderboard, admin Members/Points/Import, MyVSACard | src/data/repos/leaderboard.ts (reads member_yearly_points, house_member_yearly_points); src/pages/Admin/{Members,Points,Import}.tsx; src/components/features/points/MyVSACard.tsx |
| Authenticated check-in system | event_attendance + user_points |
Logged-in member dashboard, live check-in flows | src/data/repos/points.ts (reads user_points, event_attendance); src/data/repos/events.ts; src/hooks/useEventAttendance.ts; src/components/features/dashboard/MemberDashboard.tsx |
The experiment: [live creds] after the write, check both sides in the SQL editor:
select count(*) from member_event_attendance where member_id = '<id>';
select count(*) from event_attendance where user_id = '<id>';
If the row is in event_attendance but the leaderboard doesn't move — that's by design; the leaderboard doesn't read that table. If it's in member_event_attendance but yearly totals don't move — check the event's academic_term_id (yearly-totals row in the triage table). Do not "fix" this by writing to both tables or altering calculation logic: points/leaderboard are protected domains (AGENTS.md "Things to never do"); route changes through the vsa-change-control skill and audit via the vsa-points-attendance-guardian playbook agent.
Traps that cost real time
One-paragraph versions. Full chronicles with evidence live in vsa-failure-archaeology — read that before touching any of these areas.
user_profilesRLS recursion (incident #160, commit9e0c102e). An RLS policy onuser_profileschecked admin status by queryinguser_profiles— policy evaluation triggered the policy again, recursively, taking down profile reads (and with them, admin detection) in production. The fix (supabase/migrations/20260620020000_fix_user_profiles_rls_recursion.sql) extracts the check intois_admin_user(), a caller-boundSECURITY DEFINERSQL function withSET search_path = ''so policy evaluation bypasses the table's own RLS without letting callers enumerate other users' admin status. The trap for you: any policy on table T that selects from T is a recursion; the symptom is errors or hangs on reads of T, which surface in this app as "nobody is admin" becauseuseAdminfails closed. →vsa-failure-archaeologyfor the timeline;vsa-supabase-security-referencefor the pattern.The Supabase egress crisis. Serving event/gallery/House images straight from Supabase Storage burned through the bandwidth quota; when the quota exhausted, Supabase started failing requests and the whole site degraded — which is exactly why
isSupabaseUnavailable()explicitly matchesquota,egress, andbandwidthmessage fragments, and why the app has a designed degraded mode at all. The response was a whole pipeline (scripts/migrate-supabase-images-to-public.ts,npm run migrate:images:{dry,apply}, commits5fa96185, PRs #71/#72/#80,docs/event-image-migration.md) migrating image URLs to/publicassets served by Vercel. The trap: any new feature that renders Supabase Storage URLs on a public page reopens the wound. Never delete the Storage originals. → operation details invsa-run-and-operate; full story invsa-failure-archaeology.New views are born world-writable. This project's
ALTER DEFAULT PRIVILEGESconfiguration grants ALL on newly created relations — including views — toanonandauthenticated, and Postgres makes simple definer-owned views auto-updatable, so a "read-only" view silently accepts INSERT/UPDATE/DELETE that bypass the base table's RLS. This was caught during the member-photo-requests work; the canonical fix is insupabase/migrations/20260701000000_add_member_photo_requests.sql(L235–260):revoke all on <view> from anon, authenticated;then grant exactlyselectto exactly the roles that need it, for every new view, every time. → mechanics and checklist invsa-supabase-security-reference.Jest chokes on ESM dependencies. CRA's jest doesn't transform
node_modulesby default, so ESM-only packages blow up withCannot use import statement outside a module— hours have been lost reading that as a broken test rather than a config gap. The repo already allowlists the two known offenders inpackage.json→jest:"transformIgnorePatterns": ["node_modules/(?!(zod|@hookform/resolvers)/)"]. When you add a dependency and tests suddenly fail insidenode_modules/<pkg>, append<pkg>inside that negative-lookahead group — don't rewrite the test. →vsa-build-and-envowns the full setup-traps list.deno checknoise onvsa-ai-assistantis not your regression. Type-checking the Ask VSA Edge Function on a clean checkout reports exactlyFound 12 errors.(verified 2026-07-06, Deno from Homebrew) — all esm.sh remote-type resolution artifacts (Type '"public"' is not assignable to type 'never'and friends), none of them shipping-blocking since the function runs fine deployed. The trap runs both ways: don't burn an afternoon "fixing" the baseline 12, and don't let a 13th error hide among them — always diff your error count and line numbers against the untouched baseline (git show HEAD:supabase/functions/vsa-ai-assistant/index.ts > /tmp/base.ts && deno check /tmp/base.ts). →vsa-failure-archaeology.
Provenance and maintenance
Written 2026-07-06 against commit 368fbf63 (branch codex/reactbits-ui). Sources: AGENTS.md; docs/leaderboard-system.md; package.json; src/utils/isSupabaseUnavailable.ts; src/hooks/useAdmin.ts; src/hooks/useEvents.ts; src/hooks/usePoints.ts; src/data/repos/{leaderboard,points}.ts; src/lib/supabase.ts; tailwind.config.js; src/context/ThemeContext.tsx; supabase/migrations/20260620020000_fix_user_profiles_rls_recursion.sql; supabase/migrations/20260701000000_add_member_photo_requests.sql; live deno check run; git log (commits 9e0c102e, 5fa96185).
Re-verify before trusting, if this file is old:
grep -n "transformIgnorePatterns" package.json # jest ESM allowlist still zod + @hookform/resolvers?
grep -n '"typescript"\|"react-query"' package.json # still TS 4.9.x / react-query v3?
grep -n "429\|quota" src/utils/isSupabaseUnavailable.ts # degraded-mode signals unchanged?
grep -n "member_yearly_points" src/data/repos/leaderboard.ts # leaderboard still reads the view?
grep -rn "from('user_points')" src/data/repos/points.ts | head -1 # second system still live? (consolidation was FUTURE work as of 2026-07-06)
deno check supabase/functions/vsa-ai-assistant/index.ts 2>&1 | tail -2 # baseline error count still 12?
ls supabase/functions/ # Edge Function inventory
grep -n "Built from commit" graphify-out/GRAPH_REPORT.md # graph freshness
If docs/leaderboard-system.md gains a "consolidation complete" note, Experiment B's table split is obsolete — rewrite that section before anything else.