ADR-084: RLS write policies for prediction_attestations¶
Status: Accepted (migration 117 applied to prod 2026-06-01) Date: 2026-06-01
Context¶
The commit–reveal attestation flow (src/lib/attestation/commitment.ts) lets a
player cryptographically commit to the AI prediction before they choose, then
reveal it after — a tamper-evidence mechanism for the prediction pipeline.
A solo smoke test on 2026-06-01 surfaced a submit-time failure:
The error object is empty {} — no message, no code — which is the signature of
a PostgREST RLS rejection (the request is refused before a row is returned).
Diagnosis (via Supabase MCP against prod project rxhsyhpkfyysuhvoccpi):
prediction_attestationsexists, RLS enabled (relrowsecurity = true).- Policies present:
Users can view own attestations—SELECT,authenticated,auth.uid() = user_idService role manages attestations—ALL,service_role,true- No
INSERTorUPDATEpolicy forauthenticated. storeCommitment()(INSERT, commitment.ts:143) andrevealPrediction()(UPDATE, commitment.ts:164) both run through the browser client (createClientfromsrc/lib/supabase/client.ts), i.e. theauthenticatedrole — not service_role.
So every commit insert from a real player is silently rejected by RLS. The
reveal update would fail the same way if execution reached it. The table has a
non-null user_id uuid, so an ownership predicate is available.
This is a prod-only gap: the policies were never created for the write path. It only surfaced now because the attestation write path was exercised end-to-end in the smoke test.
Decision¶
Add two least-privilege RLS policies for the authenticated role, both scoped to
the owning user (migration 117_prediction_attestations_rls_write_policies.sql):
INSERT—with check (auth.uid() = user_id)(commit step)UPDATE—using (auth.uid() = user_id) with check (auth.uid() = user_id)(reveal step)
The commit→reveal flow stays client-side, matching its existing design (the integrity guarantee is the client-held nonce, not server mediation).
The migration was applied to prod on 2026-06-01 (project rxhsyhpkfyysuhvoccpi);
all four policies are live (SELECT + INSERT + UPDATE for authenticated scoped to
auth.uid() = user_id, plus the pre-existing service_role ALL).
Rationale¶
- Least privilege. Players can only write their own attestation rows; the
user_idpredicate mirrors the existing SELECT policy, so the three policies are consistent. - Smallest correct change. No code change, no new round-trip, no table changes — the bug is purely a missing policy.
- Preserves the trust model. The attestation's point is a client-committed hash; routing writes through a service-role server action (alt B) would move the commit server-side and weaken the "client committed before seeing the prediction" property.
Alternatives Considered¶
- B. Route writes through a service-role server action (API route). Move
storeCommitment/revealPredictionbehind a server endpoint that uses the service-role key (which bypasses RLS). Rejected as the default: more code, an extra network round-trip on a latency-sensitive submit path, and it changes the commit-reveal trust boundary. Reconsider only if we later need server-side validation of the commitment before persistence. - C. Disable RLS on the table. Rejected — it would expose other users' attestations and contradicts ADR-007's privacy model.
Consequences¶
- After applying 117, the commit-reveal attestation flow works for real players
in solo (and multiplayer, which shares
commitment.ts). - A pre-merge advisor/lint check should confirm no other table on a client write path is missing its write policy (this one slipped through because the write path wasn't exercised). Out of scope here; noted for a follow-up sweep.
Discussion¶
The empty-{}-error symptom is worth recording: a Supabase write that fails RLS
returns no error message client-side, so it reads as a mysterious "nothing
happened" rather than "permission denied." The tell is RLS-enabled + a policy set
that covers SELECT but not the write verb in question. The suggested triage query
(tablename like 'attestation%') would also have missed the table — the real
name is prediction_attestations, so the diagnosis started from the code
(commitment.ts → .from('prediction_attestations')) rather than a name guess.
Key files¶
migrations/117_prediction_attestations_rls_write_policies.sql— the fix (not applied)src/lib/attestation/commitment.ts—storeCommitment(INSERT),revealPrediction(UPDATE)src/lib/supabase/client.ts— the browser/authenticatedclient used at the call sitesrc/app/solo/page.tsx:643,735— call sites