- Date: 2026-06-06
- Status: Accepted
- Phase / Feature: Foundation v1 · B1 (WCAG validation) closer for ADR 0036 §7 (T6.3)
- Closes: ADR 0036 Q6 — "WCAG override mechanism + storage format"
- Builds on: ADR 0036 §7 (continuous WCAG validation), Foundation
evaluateContrast(T3.1),runWcagInWorker(T3.2)
Context
T6.3 (block-on-publish + override-with-reason) shipped as a Storybook stub: the publish modal collects a ≥12 char reason and stamps it locally, but the stamp lives in useState and disappears on reload. Without persistence:
- The reason behind a shipped violation is not recoverable later — invalidating the whole audit story (ADR 0036 §7 promises "block publish/export unless override-with-reason recorded").
- A revoke flow (designer or admin walks back an override) is impossible without an addressable record.
- Multi-user scenarios (one designer overrides, another reviews) cannot reconcile.
This ADR fixes the persistence shape, retention semantics, and revoke flow before any in-app token-editor surface lands. The editor wiring is a separate task; here we settle the substrate it will write into.
Decision
§1 — Storage table
A new top-level table wcag_override, scoped per project. One row per accepted override at a specific publish event:
| Column | Type | Notes |
|---|---|---|
id | text PK | wco-<8-char nano> |
project_id | text NOT NULL, FK → project.id ON DELETE CASCADE | scoping |
token_id | text NOT NULL | the failing semantic/component token (text.primary, interactive.primary, …) |
fg | text NOT NULL | resolved foreground hex (#rrggbb) at decision time — pinned so future token edits don't rewrite history |
bg | text NOT NULL | resolved background hex |
ratio | numeric(6,3) NOT NULL | measured contrast ratio at decision time |
required | numeric(4,1) NOT NULL | the AA threshold the row failed (4.5, 3.0, etc.) |
kind | text NOT NULL | normal-text | large-text | ui — evaluateContrast content kind |
reason | text NOT NULL | designer's free-form explanation, ≥12 chars |
user_id | text NULL, FK → user.id ON DELETE SET NULL | author. NULL for legacy / dev-mode rows; preserved on user deletion so the audit trail survives |
created_at | timestamptz NOT NULL DEFAULT now() | decision time |
revoked_at | timestamptz NULL | non-NULL = override walked back; row stays for audit |
revoked_by | text NULL, FK → user.id ON DELETE SET NULL | reviewer that revoked |
revoke_reason | text NULL | reviewer's free-form explanation when revoking |
Indices: (project_id, created_at DESC) for the editor's "recent overrides" pane, (project_id, token_id, revoked_at) for the publish gate's "is this pair already overridden, and still active?" lookup.
§2 — Decision flow
- Validation gate. Before publish/export, the editor batches every (
fg,bg,kind) pair throughrunWcagInWorker. Any pair that fails AA blocks publish. - Override lookup. For each failing pair, query
(project_id, token_id, revoked_at IS NULL). If a live override exists AND itsfg/bg/kindmatch the current resolved values, the pair is treated as accepted — publish proceeds. - Override capture. Pairs that fail AND have no live matching override block publish via the modal that already exists. The designer enters a ≥12 char reason; on confirm, one
wcag_overriderow per failing pair is inserted with the pinned hex values and the AA threshold the row failed. - Stale override. When a future token edit changes
fgorbgbut the new resolved pair still fails AA, the old override does NOT auto-apply — the designer must justify the new pair explicitly. Storing the resolved hex on the row (rather than(token_id, project_id)alone) makes this trivial: a pair-mismatch lookup at step 2 falls through to step 3. - Revoke. A reviewer (designer, admin, or the original author) sets
revoked_at+revoked_by+revoke_reason. Future publish gates will re-block the pair until a fresh override is captured.
§3 — Retention
- Rows are never hard-deleted. Revoked overrides stay in the table indefinitely — that's the audit trail. Soft-delete pattern only.
- Cascade on
project.iddeletion: when the project itself is deleted, all override rows go with it (the audit only matters as long as the project does). - No anonymization on
user_id. The override is a deliberate decision attributable to a person; the column nulls on user-row deletion (already in the FK).
§4 — API surface
Three Hono routes, all under requireOwnedProject:
POST /api/v1/projects/:projectId/wcag-overrides— body{ tokenId, fg, bg, ratio, required, kind, reason }. Server validates: hex shapes, ratio/required floats,kindenum, reason length ≥12. Returns the new row.GET /api/v1/projects/:projectId/wcag-overrides— query?activeOnly=true(default false). Returns the list, newest first.POST /api/v1/projects/:projectId/wcag-overrides/:overrideId/revoke— body{ reason }. Setsrevoked_at/revoked_by/revoke_reason. Idempotent: revoking a revoked row is a no-op.
CORS shape mirrors the existing project-scoped routes; the cors.ts allowlist already covers every Foundation surface.
§5 — Frontend integration scope (this ADR)
The in-app token editor that consumes this substrate is not part of this ADR — it lands when the broader editor surface ships (still future work). What this ADR delivers is:
- Schema + migration + API endpoints.
api-client.tstypings +fetchWcagOverrides,createWcagOverride,revokeWcagOverride.- Storybook
Foundation/Token editorstory unchanged — keeps its in-memory stamp until the editor wires the API in.
This is the same pattern used for T12.5 (drag-drop rebind UI in Storybook, persistence backend separate task).
Anti-patterns explicit
- Do not hard-delete override rows. Audit trail loses meaning.
- Do not rely on
(project_id, token_id)alone for the publish-gate lookup. The resolved hex must match — otherwise a token edit that moves to a new failing pair silently inherits a stale justification. - Do not allow zero-length or
< 12 charreasons server-side. Trust client never; the Storybook gate already enforces it, the API must too. - Do not put override events inside the project's
prop_overridesjsonb. Separate table = separate index = separate revoke flow. Don't conflate fiber-track edits with cascade-rule overrides.
Open questions / parking
- Bulk-override (designer accepts an entire set of failing pairs with one reason). Defer until usage shows it matters; per-pair captures everything you'd want anyway.
- Designer notifications when reviewer revokes. Re-uses the eventual generic project-event notification stream; not blocking this ADR.
- Export trail (CSV / JSON of override history for compliance audits). The GET endpoint is the substrate; the UI/CSV affordance is a follow-up.