Skip to content

ADR-0015 · Design governance & drift reconciliation

Status: 🟢 Accepted — adopted 2026-07-26 · Depends on: ADR-0011 (two UI idioms over one token package), ADR-0012 · Related: screen reviews, QRS-188

The one-line thesis

Governance is asymmetric, split on a decidable line — not on "how small is this change". A diff that touches packages/tokens or apps/mobile/src/ui is design-first, always; a diff that touches only a feature screen's composition is code-first, freely. Divergence is recorded at the moment it happens, never discovered by a later audit, and reconciled at the develop → uat promotion that already exists.

Context

CLAUDE.md states that the Claude Design MCP project is the source of truth and that "visual drift from the design is a bug". Read absolutely, that requires updating the design system before every visual refinement — including a 4 px spacing nudge found on a device. Product's position, and it is correct, is that this trades an unacceptable amount of delivery speed for governance that nobody asked for at that granularity.

The proposal put forward was: Claude Design as the baseline; small refinements implemented directly; periodic drift audits; batch sync back to the design system. Three of those four are adopted below. The audit is not, for a reason this repo has already proved twice.

Why "periodic audit + batch sync" fails here specifically

This is not a generic caution. Measured against this repo's own history, deferred-reconciliation debt has a base rate near zero:

Deferred itemOutcome
QRS-TBD — "assign real ids later, in the real tracker"Survived ~170 items and several months, covering shipped features and two remediated security incidents. Fixed only when the user noticed (QRS-180)
~56 files still call supabase.from()``Documented as transitional with intent to converge. Still open
Two caching layers; two "shared" locationsSame. Still open
CI lint gateBelieved enforced for months, was a green no-op (QRS-013)

Any workflow whose correctness depends on a future, unscheduled, unowned audit must be assumed to be a workflow that never audits. The mechanism has to be forced by something that already happens.

Why "small vs large" is the wrong axis

The changes that actually emerged from the first real device-testing pass:

ChangeLayerBlast radius
Line-height clipping (QRS-181)token (typography)Every string in the app
Haptics coverage (QRS-183)primitive (PressableScale)~36 controls
Tab-bar active state (QRS-186)primitive (app shell)Whole app
Social brand colours (QRS-187)primitive (BrandIcon)Every social field
Dashboard grid, Social layoutscreenOne screen each

Four of six were token- or primitive-level. A workflow optimised for ad-hoc screen tweaks is optimised for the minority of what real testing surfaces. Worse, "is this small?" is not decidable by two people independently, so it cannot be a gate. "Did the diff touch packages/tokens or src/ui?" is decidable by a script.

The tab bar is the worked example, and it cuts both ways

Product asked for the tab bar's active-state background pill to be removed. Pulling components/app-shell/TabBar from the design project first showed the design already specified background: 'none' with active state carried by colour alone. So the "ad-hoc improvement" was in fact a drift correction.

Had it been free-handed: the same visual result, but recorded as a divergence from the design, and eventually "synced back" a change the design already contained. Wasted work in both directions, and a design file edited to match itself. One file read prevented that.

The risk that is specific to this architecture

Per ADR-0011, QRSETU implements components twice — shadcn/DOM for public + admin, RN/NativeWind for the merchant app — with the shared token package as the only thing binding them. If the RN side accepts an ad-hoc primitive change and the design is not updated, the DOM side receives no signal that anything moved. The seam CLAUDE.md explicitly names — a merchant previewing their card in-app versus the live public card — then breaks with no failing test and no reviewer positioned to see it, because the two halves live in different codebases. This is the reason token/primitive changes cannot be code-first, independent of how small they are.

Stale design is a poisoned input, not a neutral one

Already realised once here. CLAUDE.md carries the scar: the QR setu prototype-handoff/ files "predate the current design system … do not implement from it." And the screen reviews record what a drifted source produced — a prototype that "ended up inventing an Ad Manager and an in-house billing engine with no backend decision behind either."

Once the design source drifts, Claude Designs reasons from stale foundations and generates work that contradicts shipped reality. That is more expensive than having no design at all, because someone must adjudicate the contradiction before anything can proceed.

Decision

  1. Asymmetric governance on a mechanically decidable line.
    • Systemic surface — design-first, no exceptions. packages/tokens/** and apps/mobile/src/ui/** (plus the future apps/web/src/ui/**). Few files, changed rarely, cheap to change in the design system, catastrophic to fork across two stacks. Pull the design, change it there if it is wrong, then implement.
    • Screen composition — code-first, freely. A feature screen's layout, grouping, ordering and copy placement. Build it, ship it, record it. This is where the velocity argument is wholly correct.
  2. Record at the moment of divergence, never at audit time. One entry in documentation/portal/design-system/design-drift-ledger.md in the same PR that diverges: what changed, why, which design path is now stale. ~30 seconds while the decision is fresh, versus archaeology from a diff months later. This converts reconciliation from discovery (expensive, skippable, and the thing that never happens) into working through a list (cheap, and obviously incomplete if skipped).
  3. Reconcile at the develop → uat promotion, not on a calendar. That gate already exists, is manual, and the user is personally the gate. "Periodic" has no owner and no forcing function; a ritual attached to an existing ritual survives.
  4. Machine-enforced, because conventions here demonstrably decay. tools/check-design-drift.js (npm run check:design) fails when a diff touches the systemic surface without a ledger entry in the same range. Same shape as check-sql-grants.js and check-readmes.js — the enforcement layers that exist because review missed something once. It fails closed: if it cannot determine a base ref it errors rather than passing, since a gate that can silently pass is worse than no gate (the green-no-op lesson).
  5. A drift-correction is still a ledger entry. When implementation is changed to match the design (as with the tab bar), record it as correction. It needs no sync-back, and recording it is what stops someone later "syncing" the design to itself.

Options considered

A — Asymmetric split + record-at-divergence + release-boundary sync · [adopted]

  • For: the gate is decidable by a script rather than by judgement; protects the surface where divergence is systemic and cross-stack while leaving screen work unblocked; the recording cost lands when context is free; the cadence is bound to an event that already reliably happens.
  • Against: two rules instead of one, and the boundary is occasionally arguable (a primitive gaining a new variant for exactly one screen). Resolved by defaulting to design-first when in doubt — the cheaper error.

B — Design-first for everything (the literal reading of CLAUDE.md)

  • For: the design file is never wrong; Claude Designs is always a safe input.
  • Against: demonstrably too slow for device-testing feedback, and it would have forced a design round-trip for a line-height bug fix. Rejected as the default, retained for the systemic surface.

C — Code-first everywhere + periodic audit (as proposed)

  • For: maximum velocity; matches how most teams actually behave.
  • Against: the audit is the load-bearing element and has a measured near-zero completion rate here; leaves the two-stack token fork completely unguarded. Rejected — though its velocity intent is preserved for screens.

D — Abandon the design system as source of truth; treat code as canonical

  • For: honest about where reality lives; no reconciliation cost at all.
  • Against: forfeits the ability to design a screen before building it, which is the main reason the design project exists, and guarantees the two UI idioms diverge. Rejected.

Consequences

  • design-drift-ledger.md is created and seeded with the real divergences to date, so it starts as a true record rather than an empty file nobody believes.
  • npm run check:design is added and wired into CI. Expect it to fail PRs that touch src/ui without a ledger line; that is the intended behaviour, and the fix is one line, not a design round-trip.
  • CLAUDE.md's design-system section is amended to point here, so the absolute reading ("pull the design before ANY UI work") is replaced by the asymmetric rule rather than being quietly ignored in practice — a standard that is routinely violated is worse than one that is accurate.
  • The develop → uat promotion checklist gains a design-sync step. If the ledger has open entries, they are either pushed to the design project (via DesignSync) or explicitly deferred with a reason on the row.
  • Screen-level work no longer blocks on a design pull — but pulling first is still recommended when a design may already exist, for efficiency rather than governance. Re-inventing an existing design is wasted work even when it is procedurally allowed.
  • Open risk, accepted: the ledger can be satisfied with a low-quality entry ("tweaked spacing"). No mechanism prevents that; it is caught, if at all, at the promotion review. Recorded here rather than pretended away.