Skip to content

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:

[Attestation] Error storing commitment: {}

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_attestations exists, RLS enabled (relrowsecurity = true).
  • Policies present:
  • Users can view own attestationsSELECT, authenticated, auth.uid() = user_id
  • Service role manages attestationsALL, service_role, true
  • No INSERT or UPDATE policy for authenticated.
  • storeCommitment() (INSERT, commitment.ts:143) and revealPrediction() (UPDATE, commitment.ts:164) both run through the browser client (createClient from src/lib/supabase/client.ts), i.e. the authenticated role — 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):

  • INSERTwith check (auth.uid() = user_id) (commit step)
  • UPDATEusing (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_id predicate 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/revealPrediction behind 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.tsstoreCommitment (INSERT), revealPrediction (UPDATE)
  • src/lib/supabase/client.ts — the browser/authenticated client used at the call site
  • src/app/solo/page.tsx:643,735 — call sites