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
pressStyledirectly when CLAUDE.md says useMotionPressable - Quiet rule violations: a feature package importing
expo-routerdirectly 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
| Mechanism | Coverage | Reliability |
|---|---|---|
| CLAUDE.md rules | Comprehensive | ⚠ documentation only |
| TypeScript strict | Type-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.ts | Hex arch + feature/package boundaries | ✓ blocks 16 fitness rules |
domain/entities/__tests__/zod-schema-coverage.test.ts | Entity 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):
| Gap | Drift incident |
|---|---|
| No "MotionPressable required" rule | 8+ feature files added inline pressStyle instead |
| No "every persist store needs partialize" rule | match-board.store.ts shipped without it |
| No "every useMutation needs mutationKey" rule | 46 mutations without keys; future queue can't replay them |
| No "AsyncStorage forbidden outside migration shim" rule | Phase 14a relies on this; nothing prevents new code from importing AsyncStorage |
| No "agent-claimed-done verification" gate | Wave A/B claimed exit 0 without persisting changes |
| No dependency-graph enforcement of L1 < L2 < L3 ... layering | Future 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:
_templates/query-hook/— generates a query hook with@freshnessblock, registry import, file in correct directory, barrel export entry. CLI:yarn gen:query useClubSessions._templates/mutation-hook/— generates a mutation hook with@optimistic/@offlineblock + auto-generatedmutationKeyUUID + idempotency-key plumbing._templates/feature-screen/— generates a feature screen usingDetailShell/MainTabShell,<SyncStatusBadge>, the freshness contract, and the architecture-contract-test entry._templates/persist-store/— generates a Zustand store withpartialize+_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:
MutationKeybranded type: everycreateMutationHookconfig requires aMutationKey(a branded string). Plain strings won't satisfy the type. Forces every mutation to declare a stable key.tstype MutationKey = string & { __brand: 'MutationKey' }; export function mutationKey(key: string): MutationKey { return key as MutationKey; } // Required field on factory: createMutationHook({ key: mutationKey('rsvp.upsert'), mutationFn: ... });QueryFreshnessdiscriminated union: every query hook accepts aQueryFreshnessconfig that's a strict union —'instant' | 'frequent' | 'standard' | 'stable'— plus a requiredrealtime: ChannelKey | null. No way to omit the freshness declaration.StoragePartitionphantom type: functions that read/write storage take aStoragePartition<'mmkv'> | StoragePartition<'memory'>parameter. Storage code that imports rawAsyncStoragewon't satisfy the function signature.Discriminated union for screen shells:
MainTabShellvsDetailShellvsWizardShell— each has its own type; mixing props (e.g., passingtabstoDetailShell) 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:
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-cruiserfor the dependency graph.No deep imports across packages: assert that every import from
@twomore/appgoes through the public barrel (./index.ts). No@twomore/app/src/...paths.Every persist store has hydration flag: scan all
*.store.tsfiles; ifpersist(is present, require_hasHydrated.Every mutation has key: scan every
createMutationHookcall; require akeyfield.No realtime subscribe outside
presentation/hooks/realtime/: scan source forrealtime.subscribe(calls; allow only in the canonical directory.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:
Pre-commit: run the new ESLint rules on changed files. Already handled by lint-staged once rules ship.
Pre-push: run
yarn check(includespackages/app/src/config/__tests__/architecture-archunit.test.ts, the TYPE-3 schema coverage test, and the workspace test/lint baseline).Pre-push: agent-claimed-done verification. New script
yarn verify:no-stale-claimsthat compares the most recent commit message againstgit 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:
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.Dependency-cruiser graph check. Output a visualization of the import graph; fail if it contains forbidden edges (e.g., L4 → L2).
CLAUDE.md drift check. Compare CLAUDE.md rules against actual code: if a rule says "use MotionPressable" but
pressStyleis found in feature code, fail.Documentation freshness check. If files in
packages/features/*change butdocs/architecture/screen-blueprint.mddoesn'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:
PR description template — required checkbox: "Architecture: I have not introduced cross-feature imports / new persist stores / direct AsyncStorage / direct realtime calls."
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.
Architecture decision records (ADR) directory —
docs/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:
| Phase | Layer 7 (gen) | Layer 6 (type) | Layer 5 (lint) | Layer 4 (test) |
|---|---|---|---|---|
| 14a MMKV | persist-store template | StoragePartition phantom type | no-asyncstorage-outside-migration, persist-store-shape | every persist store has hydration flag |
| 14b cache | (n/a) | (n/a) | (n/a) | queryClient created exactly once |
| 14c realtime | (n/a) | ChannelKey branded type | no-direct-supabase-realtime | no realtime.subscribe outside hooks/realtime |
| 14d queue | mutation-hook template | MutationKey branded type | require-mutation-key | every createMutationHook has key |
| 14e sync UI | (n/a) | discriminated union of indicator props | only canonical sync primitives surface refresh state | (n/a) |
| 14f contract audit | query-hook template | QueryFreshness discriminated union | require-freshness-contract | every 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.
Recommended adoption order
Step 1 (immediate, before phase 14a): Layer 4 + 5 minimum viable.
- Set up
eslint-plugin-twomorescaffolding - 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-claimsto 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:
- ✅
eslint-plugin-twomorescaffolded - ✅
no-asyncstorage-outside-migrationrule active and failing on violations - ✅
persist-store-shaperule active - ✅ Architecture test "every persist store has hydration flag" passing
- ✅
yarn verify:no-stale-claimswired 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.jsonlisting 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
driftGitHub 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 onlyWhat we add:
- New
ARCHITECTURE.mdat 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:
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-ignorewithout a reason comment, etc. - The
driftGitHub Action's "Bypass Accumulation" signal tracks@ts-ignore/eslint-disablecount as a ratcheted metric
Industry-validated reordering of must-have priorities
After research, the implementation order is:
| Priority | Refinement | Effort | Impact |
|---|---|---|---|
| #1 | R4 — Supabase type drift as CI gate (close Layer 1 gap) | 2h | Immediate — schema is the strongest constraint |
| #2 | R1 — Ratchet file for existing violations | 4h | Unblocks all subsequent ESLint enforcement |
| #3 | R2 — Migrate to ArchUnitTS + add 16 fitness functions | 6h | Current architectural fitness layer |
| #4 | R3 — drift GitHub Action for AI-generated code | 1h | AI-specific signals (Bypass Accumulation, Pattern Fragmentation) |
| #5 | Refinement 1 — arch-violations-baseline.json | 4h | Stop-the-bleeding mechanism |
| #6 | Refinement 3 — Atomic ARCHITECTURE.md manifest | 4h | Constraint parseability for agents + humans |
| #7 | Refinement 2 — Observability trend job | 8h | Sensors, 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):
- ✅
eslint-plugin-twomorescaffolded - ✅ Top-priority ESLint rules active (no-asyncstorage, persist-store-shape, require-mutation-key, require-freshness-contract)
- ✅ ArchUnitTS migrated + 16 fitness function tests passing
- ✅
arch-violations-baseline.jsonratchet file in place; CI enforces it - ✅
driftGitHub Action wired; structural health score baseline captured - ✅
ARCHITECTURE.mdconstraint manifest published; nightly CI verifies coverage - ✅ 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:
- No stop-the-bleeding mechanism. Shopify learned this the hard way over 4 years.
- No sensors, only gates. Ford's Evolutionary Architectures explicitly differentiates these.
- 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/)
driftGitHub 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)