How to buildOctober 12, 20269 min read
Vibe coding: from prototype to productStep 6 of 7

Architecture boundaries and migrations in an AI project: rules for the agent

In short

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.

How-toLevel: IntermediateVibe CodingPractice: ~1–2 h

After this you will be able to

  • describe module boundaries and schema rules in AGENTS.md so that both Claude Code and Codex see them
  • set up import-x/no-restricted-paths and 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

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
  1. 01What an agent erodes and why an instruction file isn't enough
  2. 02Boundaries in the instruction file
  3. 03Checking boundaries with a linter: what actually works
  4. 04Schema changes go through migrations only
  5. 05Applied migrations are never edited
  6. 06How to review an agent's migration
  7. 07Limits and exercises

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.

  1. 01Instruction fileAGENTS.md: which layers exist, who may import whom, how to change the schema. The agent reads it and tries to follow it
  2. 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
  3. 03Agent tool restrictionsA PreToolUse hook and deny rules: the agent can't edit an applied migration or run db push
  4. 04Human reviewThe SQL of every new migration is read and run on a copy of the data before merging
Every rule in the instruction file needs a check that fails

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.

bash
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/
Project layout: dependencies point down — app → modules → db
bash
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.1
Dependencies with the versions this article was verified on

Boundaries 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
# 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`.
Architecture and database sections. Checked with markdownlint-cli2 0.23.3

Claude Code

What it reads
The project CLAUDE.md; AGENTS.md when there's no CLAUDE.md, or via an @AGENTS.md import. Files in subfolders load when the agent works with files there. .claude/rules/*.md with a paths field load only for matching paths
What follows
A migrations rule can live in .claude/rules/ with paths: ["prisma/**"]

Codex

What it reads
AGENTS.md from 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 root AGENTS.md
How the tools read instruction files, per their documentation as of 12 Oct 2026

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.

src/app/orders/stats/route.ts
// 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 });
}
Two boundary violations: direct database access and an import from internal/

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:

eslint.restricted-imports.config.js
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." },
        ],
      },
    ],
  },
});
terminal
$ 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)
The ../../../db/client.js import went unnoticed

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:

terminal
# 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 ...
A string pattern catches the relative path but gives a false positive

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.

eslint.config.js
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.",
            },
          ],
        },
      ],
    },
  },
);
Four zones: handlers stay out of the database and internal/, orders sees only billing/index.ts, lower layers don't depend on handlers
terminal
$ 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)
All four violations found, no false positive on ./db/format.js
terminal
$ 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
...
Testing the rule by breaking it on purpose

The fixed handler goes through the module's public API, and the linter passes with no errors:

src/app/orders/stats/route.ts
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.

terminal
$ 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.
The agent added a field and ran db push: CI sees a schema without a migration, migrate dev wants a reset

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:

terminal
$ 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.
prisma.config.ts
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,
  },
});
The shadow database is a separate database, never the one in url: Prisma resets it at the start of a run. process.env instead of env() lets commands that don't need a shadow database run without the variable

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.

terminal
$ 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: 1
Real Prisma 7.10 output when an agent runs migrate reset (shortened)

Applied 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:

diff
@@ -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';
An edit to an already applied migration

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.

terminal
$ 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.
Staging stays quiet, checksums differ, migrate dev notices the edit

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.

scripts/check-migrations.mjs
// 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);
terminal
$ 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: 1
A branch with an edited migration fails the check

You 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.

.claude/hooks/protect-migrations.mjs
// 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);
.claude/settings.json
{
  "$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"]
          }
        ]
      }
    ]
  }
}
The hook for Edit and Write, plus bans on db push, migrate reset and reading .env. Validated against the Claude Code settings JSON schema
terminal
$ 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: 0
The hook run with the same JSON Claude Code sends on Windows

How 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:

terminal
$ 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: 0
A rename turned into dropping and adding a column
  1. Schema and migrations agree

    prisma migrate diff --exit-code returns 0. If it returns 2, the pull request changes the schema without a migration.

  2. Read the whole SQL

    Look for DROP, type changes, SET NOT NULL, UPDATE and DELETE. A rename that Prisma generated as drop-and-add is data loss.

  3. 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.

  4. 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".

  5. 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.

terminal
$ 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 values
The agent's migration on a copy of the data: full_name can't be NOT NULL without values

PostgreSQL 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:

prisma/migrations/20261012140000_rename_customer_name/migration.sql
-- Reviewed: the field was renamed, not replaced. Keep the data.
ALTER TABLE "customers" RENAME COLUMN "name" TO "full_name";
terminal
$ 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 Customer
The data is intact, migrate diff returns 0 again, the migration check passes with no warnings

All 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.

.github/workflows/boundaries.yml
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"
Checked with actionlint 1.7.12; the steps were repeated locally in a clean clone, not run on a GitHub runner

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-paths zones 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 deny rules 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-boundaries or 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.