ADRs
ADR 0037 — WCAG override storage + audit
  • 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:

ColumnTypeNotes
idtext PKwco-<8-char nano>
project_idtext NOT NULL, FK → project.id ON DELETE CASCADEscoping
token_idtext NOT NULLthe failing semantic/component token (text.primary, interactive.primary, …)
fgtext NOT NULLresolved foreground hex (#rrggbb) at decision time — pinned so future token edits don't rewrite history
bgtext NOT NULLresolved background hex
rationumeric(6,3) NOT NULLmeasured contrast ratio at decision time
requirednumeric(4,1) NOT NULLthe AA threshold the row failed (4.5, 3.0, etc.)
kindtext NOT NULLnormal-text | large-text | uievaluateContrast content kind
reasontext NOT NULLdesigner's free-form explanation, ≥12 chars
user_idtext NULL, FK → user.id ON DELETE SET NULLauthor. NULL for legacy / dev-mode rows; preserved on user deletion so the audit trail survives
created_attimestamptz NOT NULL DEFAULT now()decision time
revoked_attimestamptz NULLnon-NULL = override walked back; row stays for audit
revoked_bytext NULL, FK → user.id ON DELETE SET NULLreviewer that revoked
revoke_reasontext NULLreviewer'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

  1. Validation gate. Before publish/export, the editor batches every (fg, bg, kind) pair through runWcagInWorker. Any pair that fails AA blocks publish.
  2. Override lookup. For each failing pair, query (project_id, token_id, revoked_at IS NULL). If a live override exists AND its fg/bg/kind match the current resolved values, the pair is treated as accepted — publish proceeds.
  3. 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_override row per failing pair is inserted with the pinned hex values and the AA threshold the row failed.
  4. Stale override. When a future token edit changes fg or bg but 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.
  5. 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.id deletion: 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, kind enum, 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 }. Sets revoked_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.ts typings + fetchWcagOverrides, createWcagOverride, revokeWcagOverride.
  • Storybook Foundation/Token editor story 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 char reasons 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_overrides jsonb. 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.