Skip to content

Drift Prevention — Mechanical Enforcement Strategy

Status: Archived Last updated: 2026-06-27 Owner: TwoMore mobile team


The problem we're solving

Multi-contributor work — especially when contributors include AI agents — drifts from intended architecture in ways pure documentation never prevents. We've seen all of these in the past few weeks:

  • Agents claiming success while changes never persisted to disk (Wave A / Wave B incidents)
  • New files created when an existing primitive should have been extended (e.g., a parallel "search bar" instead of using the established one)
  • Consistency drift: a screen using pressStyle directly when CLAUDE.md says use MotionPressable
  • Quiet rule violations: a feature package importing expo-router directly despite the architecture rule
  • Plan drift: implementing layer 3 before layer 1 because "it seemed easy"

Documentation is necessary but insufficient. This strategy adds enforcement at the mechanical layer so that violating the architecture fails to compile, fails CI, or refuses to commit.


The 7 enforcement layers, ranked by reliability

┌─────────────────────────────────────────────────────────┐
│ 7. Schema-driven code generation (impossible to drift)  │  highest
├─────────────────────────────────────────────────────────┤
│ 6. Type system (won't compile if violated)              │
├─────────────────────────────────────────────────────────┤
│ 5. ESLint custom rules (won't lint if violated)         │
├─────────────────────────────────────────────────────────┤
│ 4. Architecture fitness tests (won't merge if violated) │
├─────────────────────────────────────────────────────────┤
│ 3. Pre-commit / pre-push hooks (won't push if violated) │
├─────────────────────────────────────────────────────────┤
│ 2. CI required checks (won't deploy if violated)        │
├─────────────────────────────────────────────────────────┤
│ 1. Documentation + code review (humans catch it)        │  lowest
└─────────────────────────────────────────────────────────┘

Higher layers are stronger but harder to set up. The right strategy is layered defense — every architectural rule should be enforced by at least one mechanical layer (4 or higher), with documentation (layer 1) explaining the why.


What we have today

MechanismCoverageReliability
CLAUDE.md rulesComprehensive⚠ documentation only
TypeScript strictType-level✓ blocks type-level violations
ESLint baseline (eslint-plugin-react, etc.)Generic patterns✓ blocks generic violations
Pre-commit hook (lint-staged + Prettier)Format only✓ blocks formatting drift
Pre-push hook (typecheck + Jest)Code correctness✓ blocks type/test failures
architecture-archunit.test.tsHex arch + feature/package boundaries✓ blocks 16 fitness rules
domain/entities/__tests__/zod-schema-coverage.test.tsEntity schema coverage✓ blocks domain entities without Zod schemas
Per-prompt hooks (this session)Token rules / Tamagui rules / terminology⚠ Claude-specific only

Coverage gaps (and the drift incidents that resulted):

GapDrift incident
No "MotionPressable required" rule8+ feature files added inline pressStyle instead
No "every persist store needs partialize" rulematch-board.store.ts shipped without it
No "every useMutation needs mutationKey" rule46 mutations without keys; future queue can't replay them
No "AsyncStorage forbidden outside migration shim" rulePhase 14a relies on this; nothing prevents new code from importing AsyncStorage
No "agent-claimed-done verification" gateWave A/B claimed exit 0 without persisting changes
No dependency-graph enforcement of L1 < L2 < L3 ... layeringFuture networking layers could import upward

Proposed mechanical enforcement

Layer 7 — Schema-driven code generation

What: Use Hygen or Plop to scaffold new screens, hooks, and mutations. Generated code is correct by construction — the contract block, mutationKey, register-in-barrel imports, and tests are all created at once.

Concrete templates needed:

  1. _templates/query-hook/ — generates a query hook with @freshness block, registry import, file in correct directory, barrel export entry. CLI: yarn gen:query useClubSessions.
  2. _templates/mutation-hook/ — generates a mutation hook with @optimistic / @offline block + auto-generated mutationKey UUID + idempotency-key plumbing.
  3. _templates/feature-screen/ — generates a feature screen using DetailShell / MainTabShell, <SyncStatusBadge>, the freshness contract, and the architecture-contract-test entry.
  4. _templates/persist-store/ — generates a Zustand store with partialize + _hasHydrated + MMKV adapter wired in.

Why this matters most: drift via "I'll just add this manually" is impossible if the manual path is harder than the generator path. Make yarn gen:mutation faster than typing it from scratch, and people use the generator.

Effort: ~3 days to set up Hygen + 4 templates + integrate into the workflow.


Layer 6 — Type system enforcement

What: Use the type system to make invalid states unrepresentable. Branded types, discriminated unions, phantom types.

Concrete additions:

  1. MutationKey branded type: every createMutationHook config requires a MutationKey (a branded string). Plain strings won't satisfy the type. Forces every mutation to declare a stable key.

    ts
    type MutationKey = string & { __brand: 'MutationKey' };
    export function mutationKey(key: string): MutationKey { return key as MutationKey; }
    // Required field on factory:
    createMutationHook({ key: mutationKey('rsvp.upsert'), mutationFn: ... });
  2. QueryFreshness discriminated union: every query hook accepts a QueryFreshness config that's a strict union — 'instant' | 'frequent' | 'standard' | 'stable' — plus a required realtime: ChannelKey | null. No way to omit the freshness declaration.

  3. StoragePartition phantom type: functions that read/write storage take a StoragePartition<'mmkv'> | StoragePartition<'memory'> parameter. Storage code that imports raw AsyncStorage won't satisfy the function signature.

  4. Discriminated union for screen shells: MainTabShell vs DetailShell vs WizardShell — each has its own type; mixing props (e.g., passing tabs to DetailShell) is a type error.

Why this matters: type errors block the build. They show up at the moment of writing, not in code review. Every developer (including AI agents) sees them immediately.

Effort: ~2 days for the four highest-leverage types.


Layer 5 — ESLint custom rules

What: Project-specific ESLint plugin (eslint-plugin-twomore) that codifies our rules as AST checks.

Rules to ship as part of this strategy:

@twomore/no-asyncstorage-outside-migration
   error: AsyncStorage may only be imported by lib/storage-migration.ts.
   Use the MMKV-backed adapter instead.

@twomore/no-press-style-outside-motion-pressable
   error: pressStyle on raw Stack/Card violates the worklet animation rule.
   Wrap in <MotionPressable> or use <Button>.

@twomore/require-mutation-key
   error: createMutationHook config must include `key: mutationKey('...')`.

@twomore/require-freshness-contract
   error: query hooks must have @freshness / @refetchOnFocus / @realtime
   /@offline doc-comment block above the export.

@twomore/no-cross-feature-imports
   error: feature packages may only import from @twomore/app or @twomore/ui.
   (Replaces / strengthens the architecture fitness check; ESLint
   gives instant editor feedback instead of post-merge surprise.)

@twomore/no-direct-supabase-realtime
   error: realtime subscriptions go through the registry, not supabase.channel
   directly. Use registry.realtime.subscribe(...).

@twomore/no-tamagui-pressstyle-with-animation
   error: Tamagui pressStyle + animation="quick" runs JS-thread animations
   even with the Moti driver in some component types. Use MotionPressable.

@twomore/persist-store-shape
   error: Zustand persist() config must include partialize and onRehydrateStorage.
   See store-template.

@twomore/no-stylesheet-imports
   error: StyleSheet from react-native is forbidden. Use Tamagui $tokens.

@twomore/no-className-prop
   error: className prop is forbidden (NativeWind banned). Use Tamagui props.

Why this matters: ESLint runs in editors via VSCode extension. The author sees the violation as they type. This is the closest we get to "you can't write the wrong code."

Effort: ~1 day per rule + 2 days plugin scaffolding = ~12 days for all 10 rules. Ship 3-4 highest-leverage first (no-asyncstorage, require-mutation-key, require-freshness-contract, persist-store-shape).


Layer 4 — Architecture fitness tests

What: Jest tests that scan the codebase for patterns ESLint can't catch (cross-file relationships, dependency graph shape).

Additions to architecture fitness tests:

  1. Layered networking dependency check: L2 imports L1 only; L3 imports L1 only; L4 imports nothing from L1-L3; L5 imports L2+L3; L6 imports any. Use dependency-cruiser for the dependency graph.

  2. No deep imports across packages: assert that every import from @twomore/app goes through the public barrel (./index.ts). No @twomore/app/src/... paths.

  3. Every persist store has hydration flag: scan all *.store.ts files; if persist( is present, require _hasHydrated.

  4. Every mutation has key: scan every createMutationHook call; require a key field.

  5. No realtime subscribe outside presentation/hooks/realtime/: scan source for realtime.subscribe( calls; allow only in the canonical directory.

  6. Single source of truth for query keys: scan for useQuery({ queryKey: ['...'] }) literals; require import from @/query-keys.

Why this matters: ESLint can't see across files. Architecture tests can. They run in CI and block merge.

Effort: ~2 days for the 6 additions.


Layer 3 — Pre-commit / pre-push hooks

What: Husky hooks that run the architecture tests + lint + typecheck on every push.

Existing:

  • Pre-commit: lint-staged (Prettier + ESLint on staged files)
  • Pre-push: typecheck + Jest

Additions:

  1. Pre-commit: run the new ESLint rules on changed files. Already handled by lint-staged once rules ship.

  2. Pre-push: run yarn check (includes packages/app/src/config/__tests__/architecture-archunit.test.ts, the TYPE-3 schema coverage test, and the workspace test/lint baseline).

  3. Pre-push: agent-claimed-done verification. New script yarn verify:no-stale-claims that compares the most recent commit message against git diff HEAD~1. If the commit body says "X file changed" but git shows otherwise, flag it. Specifically targets agents claiming success without verifying.

Why this matters: push-blocking failures force the contributor (human or agent) to fix the issue before it reaches the remote.

Effort: ~0.5 day (mostly wiring).


Layer 2 — CI required checks

What: GitHub Actions workflow that blocks merge unless all checks pass.

Existing:

  • typecheck
  • lint
  • tests

Additions:

  1. Architecture contracts as a separate required check. Currently runs as part of yarn check; surface it as its own status so PR authors see it specifically when they violate it.

  2. Dependency-cruiser graph check. Output a visualization of the import graph; fail if it contains forbidden edges (e.g., L4 → L2).

  3. CLAUDE.md drift check. Compare CLAUDE.md rules against actual code: if a rule says "use MotionPressable" but pressStyle is found in feature code, fail.

  4. Documentation freshness check. If files in packages/features/* change but docs/architecture/screen-blueprint.md doesn't, require a checkbox in PR description acknowledging.

Why this matters: CI is the last gate before main. Required checks make architecture violations un-mergeable.

Effort: ~1 day (workflow YAML + scripts).


Layer 1 — Documentation + code review

What: CLAUDE.md, this strategy doc, the networking foundation plan, the rebuild log.

Already strong but improvable:

  1. PR description template — required checkbox: "Architecture: I have not introduced cross-feature imports / new persist stores / direct AsyncStorage / direct realtime calls."

  2. CLAUDE.md table of mechanical enforcement. Every architectural rule has a column for its enforcement layer. Rules without mechanical enforcement are flagged for follow-up.

  3. Architecture decision records (ADR) directorydocs/adr/ for major architectural decisions. Each ADR is short (1-2 pages), dated, and irreversible without a new ADR.

Why this matters: human reviewers + author awareness still catch a lot. But this is the layer that should be doing the least heavy lifting.


Drift-specific countermeasures for AI agents

The above applies to any contributor. Agents introduce specific drift modes:

Mode A: Claiming success without verifying

Countermeasure: Mandatory git status check after every agent run. Agent must report git diff stats; main thread verifies against expected files.

Today this is manual. The proposed yarn verify:no-stale-claims automates it.

Mode B: Creating new files when extending existing ones is the right move

Countermeasure: A pre-flight grep step in agent prompts: "Before creating a new file at <path>, grep for existing files matching <pattern> and report. Justify creation if existing file exists."

This goes in the agent prompt template (a new file docs/agent-prompt-templates.md).

Mode C: Misinterpreting CLAUDE.md rules

Countermeasure: ESLint rules + arch tests that catch the violation regardless of how the agent interpreted the docs.

Layer 5 + 4 do this.

Mode D: Over-engineering / scope creep

Countermeasure: Single-file PRs preferred. If an agent's plan touches 4+ files, force a planning step where the file list is approved before implementation.

Already in CLAUDE.md ("Plan clear + 4+ files = MUST use Agent(model: sonnet). Max 3 parallel.") but no mechanical enforcement. Could be enforced by a post-commit check that flags PRs with too many files for the commit message scope.


How to enforce the networking foundation specifically

Mapping enforcement layers to networking phases:

PhaseLayer 7 (gen)Layer 6 (type)Layer 5 (lint)Layer 4 (test)
14a MMKVpersist-store templateStoragePartition phantom typeno-asyncstorage-outside-migration, persist-store-shapeevery persist store has hydration flag
14b cache(n/a)(n/a)(n/a)queryClient created exactly once
14c realtime(n/a)ChannelKey branded typeno-direct-supabase-realtimeno realtime.subscribe outside hooks/realtime
14d queuemutation-hook templateMutationKey branded typerequire-mutation-keyevery createMutationHook has key
14e sync UI(n/a)discriminated union of indicator propsonly canonical sync primitives surface refresh state(n/a)
14f contract auditquery-hook templateQueryFreshness discriminated unionrequire-freshness-contractevery query hook has contract block

Every networking phase ships with its enforcement. Phase 14a doesn't merge until the no-asyncstorage-outside-migration ESLint rule is in place. This is non-negotiable.


Step 1 (immediate, before phase 14a): Layer 4 + 5 minimum viable.

  • Set up eslint-plugin-twomore scaffolding
  • Ship 4 highest-leverage rules: no-asyncstorage-outside-migration, require-mutation-key, persist-store-shape, require-freshness-contract
  • Add architecture test for "every persist store has hydration flag"
  • Add yarn verify:no-stale-claims to pre-push

Step 2 (during phase 14a): Layer 6 — StoragePartition, MutationKey, QueryFreshness types.

Step 3 (during phase 14d): Layer 7 — Hygen templates for mutation-hook, query-hook, persist-store, feature-screen.

Step 4 (during phase 14f): Layer 2 — CI required checks for architecture, dependency graph, CLAUDE.md drift.

Step 5 (ongoing): Layer 1 — PR templates, ADR directory, mechanical-enforcement column in CLAUDE.md.


What this strategy does NOT do

  • Doesn't replace human review. Mechanical layers catch ~90%; review catches the rest. Both required.
  • Doesn't prevent all drift. Novel patterns will always slip through. The goal is making known anti-patterns un-buildable.
  • Doesn't ship instantly. Layer 5+ rules are written one at a time as needs surface. Layer 7 templates take real effort. This is its own multi-week investment.

Approval gate

Before phase 14a's first commit lands, the following enforcement must be in place:

  1. eslint-plugin-twomore scaffolded
  2. no-asyncstorage-outside-migration rule active and failing on violations
  3. persist-store-shape rule active
  4. ✅ Architecture test "every persist store has hydration flag" passing
  5. yarn verify:no-stale-claims wired into pre-push

Without these, phase 14a's MMKV migration could be undone by the next contributor (or agent) inadvertently importing AsyncStorage from a new file. With them, that import doesn't even compile.


Total effort

~10 working days of enforcement work spread across the networking foundation phases. Ship enforcement WITH the architectural change it protects, not before, not after. Each architectural change carries its own enforcement so it can't regress.


Research-validated refinements (v2, 2026-04-27)

After researching industry practice and academic studies, four critical gaps surfaced that v1 missed. Sources cited inline.

Refinement 1: Ratchet file for existing violations (must-have)

The original v1 strategy implicitly assumed zero violations as the baseline. Shopify Packwerk's 4-year retrospective (shopify.engineering/a-packwerk-retrospective) is explicit that this is impractical: "nearly four years elapsed before anyone completely resolved an entire package's todo file." Their key innovation was package_todo.yml — a machine-tracked debt register that hard-blocks new violations while allowing existing ones.

What we add:

  • arch-violations-baseline.json listing current cross-feature imports, missing partializes, missing mutationKeys
  • ESLint CI compares against the baseline; new violations fail; existing tolerated until explicitly fixed
  • Each fixed violation is removed from the baseline (the file shrinks over time)
  • Periodic review of the baseline file size as a debt metric

Refinement 2: Observability layer (sensors, not just gates)

The v1 model has 7 gates but no sensors. Building Evolutionary Architectures (Ford et al.) distinguishes "triggered" fitness functions (run on PR) from "continual" fitness functions (run constantly, surface trends). We have triggered; we lack continual.

What we add:

  • Layer 8 (sensor layer) — weekly CI job that runs dependency-cruiser --output-type json + lint + typecheck and posts the violation counts to a GitHub-tracked artifact
  • Trend visualization (simple line chart) so we can see drift accumulating before it hits a gate
  • The drift GitHub Action (github.com/marketplace/actions/drift-architectural-erosion-check) provides a composite "structural health score" 0–1 specifically calibrated for AI-generated code; threshold-based PR comments

Refinement 3: Machine-readable constraint manifest separate from CLAUDE.md

CLAUDE.md mixes policy ("never X") with rationale ("because Y") and narrative ("the reason this matters is Z"). Useful for humans, ambiguous for parsers — both human reviewers under time pressure AND AI agents that have to apply rules to novel situations.

The pattern emerging from infoq.com/articles/architectural-governance-ai-speed/ is a separate architecture.md (or ARCHITECTURE.md) with atomic, parseable constraint declarations:

## Constraints (mechanically enforced)
- features/* → import only from @twomore/app, @twomore/ui — NEVER @/ or sibling features
- domain/ → zero imports from adapters/, ports/, infrastructure
- styled() → only inside packages/ui

## Decisions (enforced by convention)
- Date formatting → packages/app/src/utils/date-format.ts only
- Mutations → createMutationHook factory only

What we add:

  • New ARCHITECTURE.md at repo root with atomic constraints (≤100 lines)
  • Nightly CI job verifies every "mechanically enforced" constraint has a corresponding ArchUnitTS test or ESLint rule
  • CLAUDE.md keeps the narrative + rationale; ARCHITECTURE.md is the parseable contract
  • Coverage metric: ratio of "constraints declared" to "constraints with mechanical enforcement"

Refinement 4: Replace hand-rolled architecture-contracts.test.ts with ArchUnitTS

Our existing architecture-contracts.test.ts (4 tests) is a hand-rolled fitness function. ArchUnitTS (lukasniessen.github.io/ArchUnitTS/) is the TypeScript port of ArchUnit, the canonical Java implementation cited in Building Evolutionary Architectures.

It expresses fitness functions declaratively:

typescript
import { projectFiles } from 'archunit';

it('domain has zero outward imports', () => {
  return projectFiles()
    .inFolder('packages/app/src/domain/**')
    .shouldNot()
    .dependOnFiles()
    .inFolder('packages/app/src/{adapters,ports,application,presentation}/**')
    .check();
});

it('feature packages only import from @twomore/app and @twomore/ui', () => {
  return projectFiles()
    .inFolder('packages/features/**')
    .shouldOnly()
    .dependOnFiles()
    .matching([/^@twomore\/(app|ui)/, /^react/, /^react-native/, /relative$/])
    .check();
});

What we add:

  • Migrate the 4 existing tests to ArchUnitTS
  • Add 15–20 more atomic assertions: cyclic-dependency check, file-count-per-module limits, no // @ts-ignore without a reason comment, etc.
  • The drift GitHub Action's "Bypass Accumulation" signal tracks @ts-ignore / eslint-disable count as a ratcheted metric

Industry-validated reordering of must-have priorities

After research, the implementation order is:

PriorityRefinementEffortImpact
#1R4 — Supabase type drift as CI gate (close Layer 1 gap)2hImmediate — schema is the strongest constraint
#2R1 — Ratchet file for existing violations4hUnblocks all subsequent ESLint enforcement
#3R2 — Migrate to ArchUnitTS + add 16 fitness functions6hCurrent architectural fitness layer
#4R3 — drift GitHub Action for AI-generated code1hAI-specific signals (Bypass Accumulation, Pattern Fragmentation)
#5Refinement 1 — arch-violations-baseline.json4hStop-the-bleeding mechanism
#6Refinement 3 — Atomic ARCHITECTURE.md manifest4hConstraint parseability for agents + humans
#7Refinement 2 — Observability trend job8hSensors, not just gates

~30 hours of foundational enforcement work before phase 14a's first commit. This is non-negotiable per the new gate.

Updated approval gate before phase 14a

Five items must be in place (was four):

  1. eslint-plugin-twomore scaffolded
  2. ✅ Top-priority ESLint rules active (no-asyncstorage, persist-store-shape, require-mutation-key, require-freshness-contract)
  3. ✅ ArchUnitTS migrated + 16 fitness function tests passing
  4. arch-violations-baseline.json ratchet file in place; CI enforces it
  5. drift GitHub Action wired; structural health score baseline captured
  6. ARCHITECTURE.md constraint manifest published; nightly CI verifies coverage
  7. ✅ Supabase type drift CI gate active

Without these, MMKV migration could regress on the next PR. With them, that PR doesn't merge.


Honest acknowledgment

The v1 strategy was directionally correct but conceptually weak in three places research surfaced:

  1. No stop-the-bleeding mechanism. Shopify learned this the hard way over 4 years.
  2. No sensors, only gates. Ford's Evolutionary Architectures explicitly differentiates these.
  3. CLAUDE.md doing the work of two documents. Industry pattern (validated by InfoQ governance article) is to separate machine-readable constraints from human narrative.

The v2 refinements above directly address these. They add ~30 hours of upfront work. They make the difference between "strategy that works on day 1 but degrades" and "strategy that holds the line for the entire networking foundation rollout (8 weeks) and beyond."

Sources

Validated against:

  • Ford, Parsons, Kua. Building Evolutionary Architectures (O'Reilly, 2017/2022 2nd ed.)
  • Martin. Clean Architecture (2017)
  • Skelton, Pais. Team Topologies (2019); Forsgren, Humble, Kim. Accelerate (2018)
  • Shopify Engineering: Packwerk announcement (2020) + retrospective (2024)
  • Google Bazel visibility rules
  • Nx @nx/enforce-module-boundaries
  • ArchUnitTS (lukasniessen.github.io/ArchUnitTS/)
  • drift GitHub Action (github.com/marketplace/actions/drift-architectural-erosion-check)
  • InfoQ: "Architectural Governance at AI Speed" + AAIF coverage
  • Linux Foundation AGENTS.md spec (donated by OpenAI, late 2025)
  • ICPC 2021: "Understanding Architecture Erosion: The Practitioners' Perspective" (Li et al., arXiv:2103.11392)
  • 2023: "Towards Automated Identification of Violation Symptoms of Architecture Erosion" (arXiv:2306.08616)
  • 2022: Wiley systematic mapping study (Li et al., 10.1002/smr.2423)
  • April 2026: "Architecture Without Architects" — vibe architecting paper (arXiv:2604.04990)
  • April 2026: "Dive into Claude Code" (arXiv:2604.14228)
  • Stripe API codegen (brandur.org/fragments/stripe-codegen)
  • Supabase type generation (supabase.com/docs/guides/api/rest/generating-types)
  • Feldman, "Making Impossible States Impossible" (Elm Conf 2016)

Markdown remains the source of truth. Run yarn docs:check before handoff.