Write module boundaries and schema rules into the project instruction file, but don't rely on it: for the agent it's context, not a ban. Back every rule with a check that fails: imports with import-x/no-restricted-paths and a TypeScript resolver, the schema with prisma migrate diff --exit-code in CI, untouched applied migrations with a comparison against the main branch and a hook that stops the agent from editing them. Review an agent's migration like code and run it on a copy of the data before merging.
After this you will be able to
- describe module boundaries and schema rules in
AGENTS.mdso that both Claude Code and Codex see them - set up
import-x/no-restricted-pathsand confirm that the rule actually fires, including on relative paths - catch a schema change without a migration and an edit to an applied migration in CI
- review an agent's migration: read the SQL, run it on a copy of the data and fix it before merging
Know beforehand
Verified with
Node.js 24 LTS (24.18)Prisma ORM 7.10.0PostgreSQL 17 (postgres:17-alpine)ESLint 10.12, typescript-eslint 8.71, TypeScript 6.0.3eslint-plugin-import-x 4.17.1, eslint-import-resolver-typescript 4.4.5actionlint 1.7.12
Checked against docs: October 12, 2026
Contents
What an agent erodes and why an instruction file isn't enough
An assistant solves a task by the shortest path it can see. Need order stats? The handler opens a database connection itself. Need the VAT rate? It imports a constant from another module's internals. Need a new column? The schema is synced with prisma db push, and if something doesn't line up, an already applied migration gets edited. Each of these edits works locally and passes the tests. A month later the modules are so entangled that every change touches everything, and migration history differs between databases.
The first impulse is to add rules to CLAUDE.md or AGENTS.md. That's needed but not enough: the Claude Code docs say plainly that these files are context, not enforced configuration, and that blocking an action regardless of what the model decides takes a PreToolUse hook. So the rules come in three layers.
- 01Instruction fileAGENTS.md: which layers exist, who may import whom, how to change the schema. The agent reads it and tries to follow it
- 02Linter and CIimport-x/no-restricted-paths, prisma migrate diff --exit-code, a migration check script. They fail the same way for people and for the agent
- 03Agent tool restrictionsA PreToolUse hook and deny rules: the agent can't edit an applied migration or run db push
- 04Human reviewThe SQL of every new migration is read and run on a copy of the data before merging
The practice project is a shop on TypeScript and Prisma 7.10 with PostgreSQL 17 in Docker: HTTP handlers, two modules and a database layer.
src/
app/ HTTP handlers: call module APIs only
orders/route.ts
orders/stats/route.ts
modules/
orders/index.ts public API of the module
orders/repo.ts the only place where orders touches the database
billing/index.ts
billing/internal/ private details of billing
db/client.ts Prisma client
generated/prisma/ generated by prisma generate, not in git
prisma/
schema.prisma
migrations/npm install -D eslint@10.12.0 typescript-eslint@8.71.1 typescript@6.0.3 \
eslint-plugin-import-x@4.17.1 eslint-import-resolver-typescript@4.4.5
npm install -D prisma@7.10.0 && npm install @prisma/client@7.10.0 @prisma/adapter-pg@7.10.0 pg@8.23.1Boundaries in the instruction file
The instruction file holds what the agent can't infer from the code: which folders are responsible for what, which way dependencies may point, how to change the database schema and what never to do. Phrase each rule so it can be checked, and name the command that checks it.
# AGENTS.md
## Architecture
- `src/app/` — HTTP handlers. They call module APIs only:
`import { createOrder } from "@/modules/orders/index.js"`.
- `src/modules/<name>/` — business modules. Other code imports a module
only through its `index.ts`; files in `internal/` are private.
- `src/db/` — the Prisma client. Handlers never import it; modules use it
from their `repo.ts`.
- Dependencies point down: app → modules → db. Never the other way.
- `npm run lint` checks these import rules. If a rule blocks you, stop and
describe the problem; do not disable the rule or add an exception.
## Database
- Change the schema only in `prisma/schema.prisma`, then create a migration:
`npx prisma migrate dev --name <change> --create-only`.
- If `migrate dev` refuses to run non-interactively, write the SQL into
a new folder `prisma/migrations/<timestamp>_<change>/migration.sql` with:
```bash
npx prisma migrate diff --from-migrations prisma/migrations \
--to-schema prisma/schema.prisma --script
```
- Show the generated SQL and wait for review before applying it.
- Never edit or delete a migration that is already committed.
- Never run `prisma db push` or `prisma migrate reset`.Claude Code
- What it reads
- The project
CLAUDE.md;AGENTS.mdwhen there's noCLAUDE.md, or via an@AGENTS.mdimport. Files in subfolders load when the agent works with files there..claude/rules/*.mdwith apathsfield load only for matching paths - What follows
- A migrations rule can live in
.claude/rules/withpaths: ["prisma/**"]
Codex
- What it reads
AGENTS.mdfrom the project root down to the working directory, one file per folder; the one closest to the working directory comes last. The combined size defaults to 32 KiB- What follows
- Codex won't see a file in
prisma/when started from the root: keep boundaries in the rootAGENTS.md
Checking boundaries with a linter: what actually works
Here's a handler an agent might write "to save time": it goes straight to the database and takes a constant from the billing module's internal folder.
// Written by the agent "to save time": the handler goes straight to the database
// and borrows a constant from another module's internals.
import { db } from "../../../db/client.js";
import { VAT_PERCENT } from "@/modules/billing/internal/tariffs.js";
export async function GET() {
const orders = await db.order.count();
return Response.json({ orders, vatPercent: VAT_PERCENT });
}The first candidate is ESLint's built-in no-restricted-imports. It matches the import string against .gitignore-style patterns and doesn't resolve it to a file. So the pattern @/db/* misses the same module imported by a relative path:
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig(tseslint.configs.recommended, {
files: ["src/app/**/*.ts"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{ group: ["@/db/*", "@/generated/*"], message: "Handlers use module APIs from @/modules/<name>, not the database." },
{ group: ["@/modules/*/internal/*"], message: "Import a module only through its index.ts." },
],
},
],
},
});$ npx eslint -c eslint.restricted-imports.config.js src
src/app/orders/stats/route.ts
4:1 error '@/modules/billing/internal/tariffs.js' import is restricted from being used by a pattern. Import a module only through its index.ts no-restricted-imports
✖ 1 problem (1 error, 0 warnings)You can widen the patterns to **/db/*, but then the rule fires on anything with a db folder in its path, including a harmless local helper:
# group: ["@/db/*", "@/generated/*", "**/db/*", "**/generated/*"]
$ npx eslint -c eslint.restricted-imports-v2.config.js src
src/app/orders/stats/route.ts
3:1 error '../../../db/client.js' import is restricted from being used by a pattern ...
4:1 error '@/modules/billing/internal/tariffs.js' import is restricted from being used by a pattern ...
# the same config on src/app/orders/label.ts, which imports a local helper ./db/format.js
src/app/orders/label.ts
1:1 error './db/format.js' import is restricted from being used by a pattern ...The more reliable option is no-restricted-paths from eslint-plugin-import-x. It first resolves the import to an actual file and then checks zones: target is where the restriction applies, from is what can't be imported, and except lists exceptions relative to from. The TypeScript resolver handles the @/ alias and .js imports of .ts files.
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
import { importX } from "eslint-plugin-import-x";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
export default defineConfig(
{ ignores: ["src/generated/**"] },
tseslint.configs.recommended,
{
files: ["src/**/*.ts"],
plugins: { "import-x": importX },
settings: {
"import-x/resolver-next": [createTypeScriptImportResolver()],
},
rules: {
"import-x/no-unresolved": "error",
"import-x/no-restricted-paths": [
"error",
{
zones: [
{
target: "./src/app",
from: ["./src/db", "./src/generated"],
message: "Handlers call module APIs (src/modules/<name>/index.ts), not the database.",
},
{
target: "./src/app",
from: "./src/modules/*/internal/**",
message: "Import a module only through its index.ts.",
},
{
target: "./src/modules/orders",
from: "./src/modules/billing",
except: ["./index.ts"],
message: "orders may use billing only through billing/index.ts.",
},
{
target: ["./src/db", "./src/modules"],
from: "./src/app",
message: "Lower layers must not depend on handlers.",
},
],
},
],
},
},
);$ npx eslint src # plus two more violations: orders → billing/internal, db → app
src/app/orders/stats/route.ts
3:20 error Unexpected path "../../../db/client.js" imported in restricted zone. Handlers call module APIs (src/modules/<name>/index.ts), not the database import-x/no-restricted-paths
4:29 error Unexpected path "@/modules/billing/internal/tariffs.js" imported in restricted zone. Import a module only through its index.ts import-x/no-restricted-paths
src/db/leak.ts
1:22 error Unexpected path "../app/orders/route.js" imported in restricted zone. Lower layers must not depend on handlers import-x/no-restricted-paths
src/modules/orders/vat.ts
1:29 error Unexpected path "../billing/internal/tariffs.js" imported in restricted zone. orders may use billing only through billing/index.ts import-x/no-restricted-paths
✖ 4 problems (4 errors, 0 warnings)$ npx eslint -c eslint.no-resolver.config.js src # same zones, resolver removed
exit code: 0
# with "import-x/no-unresolved": "error" added
$ npx eslint -c eslint.no-resolver.config.js src
src/app/orders/route.ts
1:29 error Unable to resolve path to module '@/modules/orders/index.js' import-x/no-unresolved
src/db/client.ts
3:30 error Unable to resolve path to module '../generated/prisma/client.js' import-x/no-unresolved
...The fixed handler goes through the module's public API, and the linter passes with no errors:
import { orderStats } from "@/modules/orders/index.js";
export async function GET() {
return Response.json(await orderStats());
}no-restricted-imports (ESLint core)
- Matches the import string against a pattern
- Can't see where a relative path leads
- Good for banning packages: lodash, @prisma/client in UI code
- Nothing to install
import-x/no-restricted-paths
- Resolves the import to a file and checks zones
- Catches @/db/client.js and ../../../db/client.js alike
- Good for boundaries between project folders
- Needs a resolver; without one the rule silently lets everything through
Schema changes go through migrations only
prisma db push syncs the schema with the database without migration files and without a record in _prisma_migrations; the Prisma docs position it as a prototyping tool. Agents like it: one command and the database is "fine". But the change reaches neither the history nor other environments, and the next migrate dev offers to reset the database. CI catches this state with one command: prisma migrate diff compares the result of all migrations with the schema, and with --exit-code returns 2 when they differ.
$ npx prisma db push
Your database is now in sync with your Prisma schema. Done in 2.25s
$ npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code
[*] Changed the `orders` table
[+] Added column `status`
exit code: 2
$ npx prisma migrate dev --name add_order_status
Drift detected: Your database schema is not in sync with your migration history.
[*] Changed the `orders` table
[+] Added column `status`
We need to reset the "public" schema at "localhost:55019"
You may use prisma migrate reset to drop the development database.
All data will be lost.To compare the migrations folder with the schema, Prisma 7 replays the migrations in a temporary shadow database, and its URL has to be in the config. Without it the command stops right away:
$ npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code
Error: You must set `datasource.shadowDatabaseUrl` in your `prisma.config.ts` if you want to diff a migrations directory.import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: { path: "prisma/migrations" },
datasource: {
url: env("DATABASE_URL"),
// an empty scratch database: migrate diff replays the migrations here
shadowDatabaseUrl: process.env.SHADOW_DATABASE_URL,
},
});A word on resetting the database. Prisma 7.10 has its own safeguard: migrate reset launched from Claude Code stopped and asked for the user's explicit consent. It's a useful backstop, but don't rely on it alone: it doesn't cover other tools or other commands.
$ npx prisma migrate reset --force
Error: Prisma Migrate detected that it was invoked by Claude Code.
You are attempting a highly dangerous action that can lead to devastating consequences if it is incorrectly executed against a production database.
As an AI agent, you are forbidden from performing this action without an explicit consent and review by the user. ...
exit code: 1Applied migrations are never edited
The Prisma docs are clear: don't edit or delete an applied migration; fix things with a new one. The agent knows this, but given "change the default value" it may simply edit the SQL in an existing file:
@@ -1,2 +1,2 @@
-- AlterTable
-ALTER TABLE "orders" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'new';
+ALTER TABLE "orders" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'draft';What do Prisma's commands see? In our run on Prisma 7.10, migrate status and migrate deploy against a database where this migration was already applied finished without errors or warnings, even though the checksum in _prisma_migrations no longer matched the file. Only migrate dev on the development database reported the change, and offered to reset it. The docs describe a warning from migrate deploy; we didn't get one in this version, so don't count on it.
$ DATABASE_URL=<staging> npx prisma migrate status
2 migrations found in prisma/migrations
Database schema is up to date!
exit code: 0
$ DATABASE_URL=<staging> npx prisma migrate deploy
2 migrations found in prisma/migrations
No pending migrations to apply.
exit code: 0
$ docker exec kb-g-migr-pg psql -U postgres -d shop_stage -c "SELECT migration_name, checksum FROM _prisma_migrations"
20261012135246_add_order_status | 40e36b249bea23e9d0ed732a670e80030586385158f409ea236af2ec7a115b24
$ sha256sum prisma/migrations/20261012135246_add_order_status/migration.sql
f660b1bc7a4e71aff9bf229e3cd63a0ee73f24aed73a3700949962e7288344c6
$ npx prisma migrate dev
- The migration `20261012135246_add_order_status` was modified after it was applied.
- Drift detected: Your database schema is not in sync with your migration history.A reliable check uses Git history: in a pull request, files in prisma/migrations that already exist on the main branch may only be added to. The script below fails on a modified or deleted migration and flags risky statements in new ones using the GitHub Actions annotation format.
// CI check for prisma/migrations against the base branch:
// - an applied (already merged) migration must not be modified or deleted;
// - risky statements in new migrations are reported for a human review.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
const base = process.argv[2] ?? "origin/main";
const diff = execFileSync(
"git",
["diff", "--name-status", "--no-renames", `${base}...HEAD`, "--", "prisma/migrations"],
{ encoding: "utf8" },
);
const changes = diff.split("\n").filter(Boolean).map((line) => line.split("\t"));
const RISKY = [
/\bDROP\s+(TABLE|COLUMN|INDEX|TYPE|CONSTRAINT)\b/i,
/\bALTER\s+COLUMN\s+"?\w+"?\s+(SET\s+DATA\s+)?TYPE\b/i,
/\bSET\s+NOT\s+NULL\b/i,
/\b(TRUNCATE|DELETE\s+FROM|UPDATE)\b/i,
];
let failed = false;
for (const [status, file] of changes) {
if (status !== "A") {
console.log(`::error file=${file}::Applied migration ${status === "D" ? "deleted" : "modified"}. Revert it and create a new migration.`);
failed = true;
continue;
}
if (!file.endsWith(".sql")) continue;
const sql = readFileSync(file, "utf8");
for (const re of RISKY) {
const hit = sql.match(re);
if (hit) console.log(`::warning file=${file}::Risky statement "${hit[0]}": check data loss, locks and rollback before merging.`);
}
}
console.log(`${changes.length} change(s) in prisma/migrations compared with ${base}`);
process.exit(failed ? 1 : 0);$ node scripts/check-migrations.mjs main # branch where the agent edited an applied migration
::error file=prisma/migrations/20261012135246_add_order_status/migration.sql::Applied migration modified. Revert it and create a new migration.
1 change(s) in prisma/migrations compared with main
exit code: 1You can stop the mistake even earlier, on the agent's side. A Claude Code PreToolUse hook receives JSON with the tool name and the absolute file path; exit code 2 blocks the call, and the agent gets the stderr text as the reason. The hook below forbids editing migrations already in Git and doesn't get in the way of finishing one just created with --create-only.
// PreToolUse hook for Edit|Write: blocks changes to migrations already committed to git.
// A new migration created with --create-only is still untracked and can be edited.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync(0, "utf8"));
const file = String(input.tool_input?.file_path ?? "").replaceAll("\\", "/");
if (!file.includes("/prisma/migrations/")) process.exit(0);
try {
execFileSync("git", ["ls-files", "--error-unmatch", file], { cwd: input.cwd, stdio: "ignore" });
} catch {
process.exit(0); // not in git yet: a fresh migration, editing is allowed
}
console.error(
`${file} is already committed and may be applied somewhere. ` +
"Do not edit applied migrations: change schema.prisma and run " +
"`npx prisma migrate dev --name <change> --create-only`, then ask for review.",
);
process.exit(2);{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"deny": [
"Bash(npx prisma db push *)",
"Bash(npx prisma migrate reset *)",
"Read(./.env)",
"Read(./.env.*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-migrations.mjs"]
}
]
}
]
}
}$ node cases/hook-test.mjs
Edit .\prisma\migrations\20261012135246_add_order_status\migration.sql
exit code: 2
stderr: C:/…/prisma/migrations/20261012135246_add_order_status/migration.sql is already committed and may be applied somewhere. Do not edit applied migrations: …
Write .\prisma\migrations\20990101000000_draft\migration.sql
exit code: 0
Edit .\src\modules\orders\index.ts
exit code: 0How to review an agent's migration
Say the agent renamed the name field to fullName with a full_name column. In non-interactive mode migrate dev --create-only refused to run, so the agent, following AGENTS.md, generated the SQL with migrate diff --script. The CI check flagged a risky statement:
$ npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --script
-- AlterTable
ALTER TABLE "customers" DROP COLUMN "name",
ADD COLUMN "full_name" TEXT NOT NULL;
$ node scripts/check-migrations.mjs main
::warning file=prisma/migrations/20261012140000_rename_customer_name/migration.sql::Risky statement "DROP COLUMN": check data loss, locks and rollback before merging.
1 change(s) in prisma/migrations compared with main
exit code: 0Schema and migrations agree
prisma migrate diff --exit-codereturns 0. If it returns 2, the pull request changes the schema without a migration.Read the whole SQL
Look for
DROP, type changes,SET NOT NULL,UPDATEandDELETE. A rename that Prisma generated as drop-and-add is data loss.Run it on a copy of the data
An empty database won't show the problem. A copy with data will: below, the migration failed on a copy made with
CREATE DATABASE … TEMPLATE.Fix it before merging
Until the migration is on the main branch, its SQL can still change. A rename uses
RENAME COLUMN; a change that fills in data takes several steps, as in "ORM and migrations".Decide how to roll back
Write down in the pull request which new migration and which data step would undo the change if something goes wrong after release.
$ docker exec kb-g-migr-pg psql -U postgres -c "CREATE DATABASE shop_copy TEMPLATE shop"
$ DATABASE_URL=<copy> npx prisma migrate deploy
Applying migration `20261012140000_rename_customer_name`
Error: P3018
A migration failed to apply. New migrations cannot be applied before the error is recovered from.
Migration name: 20261012140000_rename_customer_name
Database error code: 23502
ERROR: column "full_name" of relation "customers" contains null valuesPostgreSQL ran the migration in a transaction, so the name column on the copy stayed in place. Prisma marked the migration as failed, and no new migrations will apply to that database until it's resolved. Fix the SQL, mark the failed attempt as rolled back and apply again:
-- Reviewed: the field was renamed, not replaced. Keep the data.
ALTER TABLE "customers" RENAME COLUMN "name" TO "full_name";$ DATABASE_URL=<copy> npx prisma migrate resolve --rolled-back 20261012140000_rename_customer_name
Migration 20261012140000_rename_customer_name marked as rolled back.
$ DATABASE_URL=<copy> npx prisma migrate deploy
Applying migration `20261012140000_rename_customer_name`
All migrations have been successfully applied.
$ docker exec kb-g-migr-pg psql -U postgres -d shop_copy -c "SELECT id, full_name FROM customers"
id | full_name
----+---------------
1 | Test CustomerAll checks run in one workflow on every pull request. git diff against the main branch needs full history (fetch-depth: 0), and the shadow database needs a PostgreSQL service.
name: boundaries
on:
pull_request:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx prisma generate
env:
DATABASE_URL: postgresql://unused@localhost:5432/unused
- run: npm run lint
migrations:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: shadow
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/app
SHADOW_DATABASE_URL: postgresql://postgres:test@localhost:5432/shadow
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- name: Schema changes come with a migration
run: npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code
- name: Applied migrations are untouched
env:
BASE_REF: ${{ github.base_ref }}
run: node scripts/check-migrations.mjs "origin/$BASE_REF"Limits and exercises
- The linter checks imports, not meaning. If the agent copies an SQL query into a handler instead of importing the client, the boundary is formally respected. Only review finds that.
no-restricted-pathszones describe folders. When modules move, the config has to follow, or the rule stops checking anything — the same break-it-on-purpose test helps.- The hook and
denyrules only guard actions taken through the agent's tools. They don't replace access control: neither the agent nor local development should hold production database credentials. - The Git check protects migrations already on the main branch. It won't notice a migration applied by hand on the server, bypassing CI.
- Larger projects have dedicated boundary tools, such as
eslint-plugin-boundariesor dependency-cruiser; the principle is the same — a rule plus a check that it fires.
How this was verified10 · passed3 · partialchecked 12 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.