# Enforcement

Which rule is checked by what: type checks, lint, tests, build and deploy gates, scripts, and the rules that still rely on review.

A rule that nothing checks decays. This page lists the repository's rules, where each one is stated and what fails when it is broken. Use it to decide how much to trust a rule, and to find the next rule that should get a check.

## Levels [#levels]

| Level | Meaning |
| --- | --- |
| `[type]` | `pnpm check-types` rejects the violation. |
| `[lint]` | `pnpm lint` (oxlint plus repository checks) rejects it. |
| `[test]` | A test in `pnpm test`, the integration suite or the RLS matrix fails. |
| `[build]` | `pnpm --filter web build` fails. |
| `[deploy]` | A preview or production workflow refuses to publish. |
| `[tool]` | A repository script refuses the action. |
| `[manual]` | A verification script exists, but you run it yourself; CI does not. |
| `[convention]` | Only review holds it. These are the fragile ones. |

## Coverage [#coverage]

| Rule | Stated in | Enforcement |
| --- | --- | --- |
| Clients import leaves, contracts or types only | [ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules) | `[lint]` `packages/config/oxlint-client-boundary.json` (server-only specifiers, including `@template-cloudflare/api/mcp/*`), extended by `apps/web/src`, `apps/mobile/src`, `packages/ui/src` · `[test]` `scripts/leaf-modules.test.mjs` (D1) · `[build]` `apps/web/scripts/assert-client-bundle-clean.mjs` |
| A feature contract carries the `LEAF MODULE` header | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `scripts/leaf-modules.test.mjs` |
| Package exports are enumerated and documented | [ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules), [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `packages/api/src/exports.test.ts` · `[test]` the package-exports check in [`apps/web/src/features/docs/docs.source.server.test.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/docs/docs.source.server.test.ts) |
| Mobile aliases point at the canonical file; the web app does not use them | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `packages/api/src/exports.test.ts` (a test, not lint: see the note below) |
| Only the app router and the tool catalog import `*.procedures` (D3) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[lint]` `.oxlintrc.json`, override for `packages/api/src/**` |
| Only the job runner imports `*.jobs` (D4) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[lint]` `.oxlintrc.json`, override for `packages/api/src/**` |
| `platform/**` does not import features, except `admin.roles` and `admin.api` (D5) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[lint]` `.oxlintrc.json`, override for `packages/api/src/platform/**` |
| Features import each other through public roles or declared edges (D6) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `scripts/feature-boundaries.test.mjs` |
| No import cycles in `packages/api/src` and `packages/db/src` (D7) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `scripts/import-cycles.test.mjs`, run by `pnpm test` |
| Migrated procedures hold no queries (D8) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[lint]` `.oxlintrc.json`, override listing `todo` and `documents` procedures: refuses `drizzle-orm`, the schema barrel `@template-cloudflare/db/schema` and `@template-cloudflare/db/schema/<table>` · `[convention]` `context.db.query.*`, which needs no import |
| Web imports flow routes → features → platform (W1) | [Web conventions](/docs/development/web-conventions) | `[test]` [`scripts/web-feature-boundaries.test.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/scripts/web-feature-boundaries.test.mjs) |
| Web features meet through public roles (queries, hooks, …) or a declared edge (W2) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` |
| Web server-only code is named `.server`; only server surfaces and the two isomorphic bridges import it (W3) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` · `[build]` TanStack Start import protection · `[build]` `assert-client-bundle-clean.mjs` |
| No `createServerFn` in the web (W4) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` · `[build]` `apps/web/scripts/assert-server-fn-manifest.mjs` |
| Web components hold no data access; hooks do (W5) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` (no exemptions) |
| No `.client.` file names in the web (W6) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` |
| No new dynamic `import()` in the web (W7) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` |
| No barrel in the web (W8) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` |
| `vi.mock`, test `import()` and `new URL` specifiers resolve (W9) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` |
| Web feature folders are flat and prefixed (W10) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` |
| No import cycles in `apps/web/src` (W11) | [Web conventions](/docs/development/web-conventions) | `[test]` `scripts/web-feature-boundaries.test.mjs` (W11 case: oxlint `import/no-cycle` with the web `tsconfig`), run by `pnpm test` |
| Web files stay short (W12) | [Web conventions](/docs/development/web-conventions) | `[lint]` `max-lines` warning in [`apps/web/src/.oxlintrc.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/.oxlintrc.json) |
| Nothing server-only in the web client (W13) | [Web conventions](/docs/development/web-conventions) | `[lint]` client boundary, with server files carved out by path in `apps/web/src/.oxlintrc.json` · `[build]` `assert-client-bundle-clean.mjs` |
| New web locale keys use their feature's prefix | [Web conventions](/docs/development/web-conventions) | `[convention]` · `[test]` catalog parity in `apps/web/src/locales/locales.test.ts` |
| Tenant data only through `context.db` | [ADR-0009](/docs/adr/0009-row-level-security) | `[test]` `packages/api/src/platform/rpc/rls.isolation.test.ts`, `packages/db/src/rls-catalog.test.ts`, `test:rls-matrix` (CI job `rls-matrix`) |
| RLS survives restores and host moves | [ADR-0009](/docs/adr/0009-row-level-security) | `[deploy]` `db:verify-rls` at the end of `db:apply-pending` |
| Cross-tenant paths are listed | [ADR-0009](/docs/adr/0009-row-level-security) | `[lint]` `scripts/check-system-call-sites.mjs` against `packages/db/src/system-call-sites.txt`; pool-import restriction in `.oxlintrc.json` |
| No network I/O inside `withTenant` | `AGENTS.md` | `[convention]` |
| Isolation holds on a real deploy | [ADR-0009](/docs/adr/0009-row-level-security) | `[manual]` `scripts/rls-preview-proof.mjs` against the preview |
| Hyperdrive query caching stays off | [ADR-0009](/docs/adr/0009-row-level-security) | `[deploy]` `packages/project-setup-cli/src/hyperdrive.mjs check` in `.github/workflows/deploy-prod.yml` |
| `TENANCY_PROBE` only on previews | [ADR-0009](/docs/adr/0009-row-level-security) | `[deploy]` `scripts/check-production-config.mjs` |
| Domain failures are tagged and mapped once | [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program) | `[type]` the `satisfies never` mapper in each `<feature>.errors.ts` · `[lint]` D2 in `.oxlintrc.json`: no `@orpc/*` in services, rules and policies of migrated features · `[test]` `packages/api/src/testing/characterization/*.client-errors.test.ts` |
| Every error code has a message in every language | [error handling](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/error-handling.md) | `[type]` `satisfies` in `packages/errors/src/keys.ts` · `[test]` `packages/errors/src/locales.test.ts`, `apps/web/src/locales/locales.test.ts` |
| Effect never reaches a client | [ADR-0005](/docs/adr/0005-effect-boundary) | `[build]` `apps/web/scripts/assert-client-bundle-clean.mjs` |
| A procedure runs one program; a client abort after it starts does not roll back a write; database work drains | [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program) | `[test]` `todo.abort.test.ts`, `documents.abort.test.ts`, `packages/api/src/platform/rpc/procedure-edge.test.ts`, `packages/api/src/platform/effect/db.test.ts` |
| One `run*` per entry point, no module-scope I/O runtime | [ADR-0005](/docs/adr/0005-effect-boundary), [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program) | `[convention]` |
| Only idempotent reads retry | [ADR-0005](/docs/adr/0005-effect-boundary) | `[test]` `packages/api/src/platform/effect/retry.test.ts` · `[convention]` for new call sites |
| Deadlines use production values | [ADR-0005](/docs/adr/0005-effect-boundary) | `[test]` `@effect/vitest` with `TestClock`, e.g. `documents.jobs.test.ts`, `imports.jobs.test.ts` |
| Queue persistence changes pass the fault gate | [ADR-0006](/docs/adr/0006-effect-persisted-queue) | `[manual]` `scripts/test-persisted-queue-spike.mjs` |
| Configuration is declared in `packages/env` | [Backend conventions](/docs/development/backend-conventions) | `[lint]` `node/no-process-env` in `.oxlintrc.json` (`packages/**`) and `apps/web/src/.oxlintrc.json`, with named exceptions |
| Runtime switches fail safe on an invalid value | [Environment and secrets](/docs/development/environments#runtime-switches) | `[test]` `packages/env/src/runtime-flags.test.ts` |
| Every RPC response carries `x-request-id`, and the error log records the ID | [Error handling](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/error-handling.md) | `[test]` `packages/api/src/platform/rpc/request-id.test.ts`, `apps/web/src/routes/api/rpc/$.test.ts` |
| Input validation answers HTTP 400 with `INPUT_VALIDATION` | [Error handling](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/error-handling.md) | `[test]` `packages/errors/src/server.test.ts` (each `APP_ERRORS` status in `errorStatusMap`), `apps/web/src/routes/api/rpc/$.test.ts` (400 on the wire), characterization tests |
| Org role checks fail closed and only follow `orgProcedure` | [Backend conventions](/docs/development/backend-conventions) | `[type]` the middleware's input context requires `organizationRole` · `[test]` `packages/api/src/platform/rpc/require-org-role.test.ts` (including `@ts-expect-error` on other procedures) · `[convention]` where it is applied |
| Expected failures are not logged as errors | [Backend conventions](/docs/development/backend-conventions) | `[test]` `packages/errors/src/server.test.ts` |
| Schema changes are migrations, never pushes | [Database](/docs/features/database) | `[tool]` `db:push` runs `packages/db/scripts/refuse-push.mjs` · `[test]` CI applies migrations twice |
| Optional features can be removed cleanly | [Optional features](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/optional-features.md) | `[manual]` `scripts/verify-optional-client-removal.mjs` |
| Dependencies respect the release quarantine and exact pins | [Library reference](/docs/libraries) | `[test]` `scripts/tooling-tests.mjs` |
| Formatting | [Testing and verification](/docs/development/testing) | `[lint]` `pnpm format:check` (`scripts/check-format.mjs`) |
| Docs links, ADR pages and package exports pages are valid | [Writing documentation](/docs/development/docs) | `[test]` [`apps/web/src/features/docs/docs.source.server.test.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/docs/docs.source.server.test.ts) |
| Runtime and bundle changes are measured | [Conventions](/docs/development/conventions) | `[convention]` |
| Pagination strategy per endpoint | [Backend conventions](/docs/development/backend-conventions) | `[convention]` |

## Notes on the lint configuration [#notes-on-the-lint-configuration]

- **An oxlint override replaces a rule's options; it does not merge them.** Every `no-restricted-imports` override in `.oxlintrc.json` therefore repeats the system-pool paths of the base rule, and the most specific files come last. A ban added in one override disappears for files a later override matches unless it is repeated there. For the same reason, the web app's ban on the mobile aliases is a test in `packages/api/src/exports.test.ts`: as a lint rule it would have to repeat the whole client boundary inside `apps/web/src/.oxlintrc.json`.
- **D2 and D8 list the migrated features by path.** A feature that moves to tagged errors and an Effect service adds its `*.service.ts`, `*.rules.ts`, `*.policy.ts` and `*.procedures.ts` to those overrides in the same change. The comment above D2 names the features still to migrate; that list only shrinks.
- **Import cycles are a test, not a lint rule.** `import/no-cycle` needs oxlint's `import` plugin, whose module graph made `pnpm lint` about 3.7 times slower. `scripts/import-cycles.test.mjs` runs the rule on the two server trees inside `pnpm test`.
- **The web direction rules are a test for the same reason.** `apps/web/src/.oxlintrc.json` extends the client boundary; a direction rule in one of its overrides would replace that boundary in the files it covers. W1–W11 live in `scripts/web-feature-boundaries.test.mjs`; its W11 case runs `import/no-cycle` on `apps/web/src` the way `scripts/import-cycles.test.mjs` does for the server trees. The web config keeps only the boundary, its server carve-out and the `max-lines` warnings ([ADR-0012](/docs/adr/0012-web-features-mirror-the-backend-shape)).
- **Nothing in the root overrides matches `apps/mobile`**, whose lint output stays unchanged.

## Next rules to automate [#next-rules-to-automate]

- **No network I/O inside `withTenant`.** A test could wrap the tenant handle and fail when a `fetch` starts inside the callback.
- **One `run*` per entry point.** A lint pattern could refuse `Effect.run*` outside `platform/rpc/procedure-edge.ts`, `platform/effect/edge.ts` and the job runner.
- **Tenant isolation on a deploy.** `rls-preview-proof.mjs` could run in the preview workflow after the runtime check.
- **Optional removal.** The six removal runs could become a scheduled CI job. They currently stop on `main` at a stale recipe (`apps/mobile/src/app/(tabs)/(profile)/profile.tsx` no longer matches), so the recipes need repair first.

