Tier 1 of 4 · Prototype
Prototypes & landing pages
A light setup for work that may be thrown away. You get one file of project rules, a few basic checks, and a place to note decisions.
1 person · Weeks+ A SQL database
Set this repo up to ntent tier 1.
Fetch https://ntent.app/r/tier/1.json?with=database and follow the plan in it exactly. Write every file verbatim, and check each file's sha256 against its step before you write it. It also carries steps that follow from what the product is: it stores data in postgres, sqlite or supabase. Those are as required as the tier's own.
Skip any step whose "needs" this repo does not satisfy, and do not install a framework just to satisfy one. The backend-deps step is the one exception: it adds the pinned devDependency the database check runs on. Run each step's verify line before you call it done, write the manifest the plan describes, then tell me what you installed and what you skipped.Reads https://ntent.app/r/tier/1.json?with=database
- 5checks that block the commit
- 2steps a person checks
- 7files, notes and settings
The full plan14 steps
Your agent follows this, so you don’t need to read it. It includes 6 steps for what the product does. Open any step to see the file it writes and how to tell it works.
Prototypes & landing pages
8 steps · 01–08- 01Shared project instructionsA person checks this
Everyone inventing their own conventions, and every coding agent inventing a different set again. It also says in words what the gates assume: a failing test is a finding, and the test is not edited to get a commit through.
Details
writes AGENTS.md · point CLAUDE.md at it with a one-line @AGENTS.md import
Edit it: It is a template. Replace every <angle bracket>, delete the sections marked for tiers above yours, and make exactly these other edits. Every command in it is the one a step installs, a file in scripts/ run with node. (1) Delete each table row, and each hard rule, whose command names a scripts/ file that is not on disk: the step was skipped or is not in this plan. Rule 1 goes with check-locale-keys.mjs, rules 5 and 6 with check-api-routes.mjs, rule 7 with check-env.mjs. (2) Rules 2, 3, 4 and 8 are enforced by `pnpm lint` through scripts/eslint-rules.mjs. Keep them only if that file is on disk and wired into the lint config, otherwise delete them. (3) Keep the `pnpm typecheck` and `pnpm lint` rows only if package.json defines those scripts, under the names it uses. Do not add a script to make a row true. (4) Renumber the rules that are left and change every "Rule N" cell in the table to match. (5) Delete the doctor line under "Running it" if scripts/doctor.sh is not on disk, and write the repo’s own install and dev commands in the two angle brackets there. (6) Where the template gives a path (src/env.ts, messages/, <lib dir>), write the repo’s own. Change no other prose.
How to tell it works: Ask your agent "what are the rules in this repo" and it answers from the file.
- 02
2 rule files that disagree, because one tool reads CLAUDE.md and another reads AGENTS.md.
Details
Skipped unless the repo has AGENTS.md.
How to tell it works: Run it twice: the second run says there is nothing to do. Then start a session and ask your agent to name a rule that only exists in AGENTS.md. In Claude Code, /context lists CLAUDE.md under Memory files. Copy a line of AGENTS.md into CLAUDE.md and run it again: it names that line and leaves it where it is.
- 03Pre-commit checksBlocks the commit
A mistake caught an hour later on the build server, after someone has already spent time reviewing it. This catches it on your machine, before the commit.
Details
writes scripts/git-hooks/pre-commit · make it executable
How to tell it works: Stage a file that fails a check this tier installs and the commit is refused, naming the check and the fix. At tier 1 that is check-env: add a variable to the env schema and not to .env.example. The hardcoded-colour case needs the tier 2 lint rules. From tier 2, edit a test file a built journey cites in contract.json and stage only that file: the contract gate runs anyway and refuses the commit, naming the journey. Rename or delete it instead and the same happens. Accept the journey again in the same commit and it passes, with a notice under the passed line that the test and the record moved together. The notice can be avoided by spreading the change over several commits, and a merge or a rebase does not run this hook at all: CI is the check there.
- 04
Git hooks that only exist on one person’s machine, so a teammate or a coding agent commits without the checks.
Details
merges into package.json · inside the "scripts" block
Edit it: Say which of these this repo is, and do that one. (1) No package.json at the root (a Python, Go or Rust repo): add no prepare line. Run `git config core.hooksPath scripts/git-hooks` once and put that line in the setup step the repo already documents (README, Makefile, a bootstrap script), because the setting is local to a clone. (2) package.json already has a `prepare` script: merge, never replace. Keep the existing command first and append ours after a `;`, so `"prepare": "husky; <our line>"`. The line ends in `exit 0`, so it has to be last. (3) Husky (a .husky/ folder, or core.hooksPath pointing at .husky/_): leave the path alone and chain. Add one line to .husky/pre-commit, after the husky.sh line if the file has one: `bash scripts/git-hooks/pre-commit || exit 1`. It says bash, not sh, because the hook is a bash script and on Debian and Ubuntu `sh` is dash, which fails it on every commit. (4) A .githooks/ folder, or any other core.hooksPath: same chain, one line in that folder’s pre-commit, creating the file with a `#!/bin/sh` first line if there is none. (5) The Python pre-commit framework (.pre-commit-config.yaml): do not set core.hooksPath, because `pre-commit install` refuses to run once it is set. Add a local hook under `repos:` instead: `- repo: local`, `hooks:`, `- id: ntent`, `name: ntent checks`, `entry: scripts/git-hooks/pre-commit`, `language: system`, `always_run: true`, `pass_filenames: false`. In every case where you chained rather than set the path, the pre-commit step still goes to scripts/git-hooks/pre-commit. Then, whatever the case, run `git check-ignore -v scripts/ AGENTS.md scripts/git-hooks/pre-commit`. A line of output means .gitignore hides it and the install would never be committed: add a negation line for it (`!scripts/`, `!AGENTS.md`), run the command again until it prints nothing, and say what you changed.
How to tell it works: Run the install, then `git config core.hooksPath` prints scripts/git-hooks. A fresh clone gets the same answer with no manual step. The line cannot fail an install: with no .git directory it prints nothing and exits 0, and where core.hooksPath already points somewhere else it prints one line to stderr and exits 0. Leave .git/hooks alone: it may hold hooks somebody else installed. Where the repo had hooks of its own (Husky, .githooks, a Python pre-commit config), do not trust the config to tell you they work. Stage a change that breaks a gate and commit it: the commit must be refused, naming the gate. Say which of the five cases in customise you took.
- 05Environment variable checksBlocks the commit
A setting the app needs that is missing from .env.example, so someone who downloads the repo cannot start it and has no idea why. Also the reverse, and code that reads process.env directly instead of through the schema. And a secret that reaches the browser: a service-role key behind NEXT_PUBLIC_ or VITE_, or read in a client component.
Details
writes scripts/check-env.mjs
Edit it: Point CONFIG.schemaFile at this repo’s env schema, CONFIG.exampleFile at its example file, and CONFIG.sourceDirs at the directories it keeps source in, if they are not src/env.ts, .env.example and src. The pre-commit hook reads those three back out of the file, so moving them keeps the gate wired.
How to tell it works: Add a variable to the schema, do not add it to .env.example, and the check names it. Rename a key to NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY and the check names it as a secret in the client bundle.
- 06Credential scanBlocks the commit
A real token pasted into a script, a doc or a fixture and committed. .gitignore only protects the paths it names, and a token that reaches history is in every clone and may already be in a model’s context.
Details
writes scripts/check-secrets.mjs
Edit it: It needs the gitleaks binary (brew install gitleaks). Without it the hook warns and passes, and doctor names the gap. Add one CI step that runs `node scripts/check-secrets.mjs --all` after a full-history checkout (fetch-depth: 0), which fails if gitleaks is missing. Add a .gitleaks.toml only when the default rules read one of your own identifiers as a secret, and allowlist that exact shape, with a description.
How to tell it works: Stage a file holding a token-shaped string and the commit is refused with the finding redacted, telling you to rotate the credential as well as delete the line. A finding means rotate it, not just remove it: a token that reached a diff may already be in a model context, a log or a clone. Unstage it and the commit goes through. On a machine without gitleaks the commit passes with a warning that the diff was not scanned; in CI the same run fails.
- 07
Test that a missing gitleaks warns locally and fails in CI, and that a staged token is refused, so an edit to the checker cannot quietly stop it scanning.
Details
writes scripts/check-secrets.test.mjs
Skipped unless the repo has check-secrets.mjs.
How to tell it works: node --test passes with or without gitleaks installed: the cases that need the binary are skipped, with the reason printed. Stage an edit to check-secrets.mjs that drops --redact and the commit is refused, naming this file.
- 08Decision logA person checks this
The same decision being re-made in month 6 because nobody remembers the reason for the first one.
Details
writes DECISIONS.md
Edit it: The first entry is an example from a billing app: delete it. Then write the decisions this project has actually made, one entry each, and label any whose source is memory rather than a thread or a call as "(from memory)". A fresh project may have none yet, and a file with only the template entry in it is correct.
How to tell it works: Every entry names a real decision, who agreed it and when. Nothing in the file is invented, and an entry reconstructed from memory says so.
It stores data in Postgres, SQLite or Supabase
6 steps · 09–14- 09
"Mostly done" as an answer. With this you can say the screens lift 80% of the model, here are the 3 named gaps, and one journey has no UI at all.
Details
writes contract.json
Edit it: It is an example billing product. Keep the shape and replace the actors, entities, journeys, rules, constraints and open questions with this product’s own. Delete the example acceptance records: they describe journeys you have not built. When a journey of yours is accepted against a test file, put the file’s sha256 under `pins` in its record: `node scripts/check-contract-coverage.mjs --pins <journey>` prints the block. The example cites no test file, so it needs none.
How to tell it works: The coverage check runs and prints a number, and `--refs` says the references resolve. Start with entities and journeys; add a pillar when you can name the screen it changes. Mark a journey built only with its acceptance record beside it. Cite a test file with no pin and `--refs` refuses it, naming the journey and the command that prints the pin.
- 10
Nothing on its own. It is the one dependency the Postgres checks run on (SQLite needs none): Postgres 17 compiled to WebAssembly, run in-process, so the check needs no Docker, no network and no running database.
Details
merges into package.json · inside the "devDependencies" block. Then run the repo’s own install command, so the lockfile records it
How to tell it works: node -e "import('@electric-sql/pglite').then(() => console.log('ok'))" prints ok.
- 11Database access checksBlocks the commit
A table any signed-in user can read in full, a policy that lets one customer read another customer’s rows, and tables that contradict the model: a required field in a column that allows empty, or a model entity with no table. It builds the database from the migrations in-process and tries the reads the model says must fail. The table checks run on Postgres, Supabase and SQLite; the read tests run on Postgres and Supabase only, because SQLite has no row security.
Details
writes scripts/check-backend.mjs
Edit it: First run node scripts/check-backend.mjs --print-config. It searches the repo at any depth and prints the detected platform (and why), clientAccess, enforcement and, when it finds no inputs, ranked candidate lines. Accept the detected platform, or paste the top candidate as CONFIG.sources (a candidate line is a ready CONFIG sources line; where migrations apply in file order, use order: 'listed'). CONFIG.platform and CONFIG.enforcement default to auto, so accept what it detects: it ignores templates and examples, and sets postgres or supabase for you. On plain Postgres with no RLS anywhere in the schema, auto treats access as enforced in app code: exit 0, a note, and 'access NOT checked' in the summary line; set enforcement: 'app' to confirm. It never does this on Supabase. If the schema does have RLS on plain Postgres, set CONFIG.actAs to the role your app connects as, and the migrations must create that role and grant it the tables. A candidate for a SQLite or D1 schema carries dialect: 'sqlite'. If no client reaches the database directly (a server holds the only connection), set CONFIG.clientAccess to server-only. Access enforced in app code (a missing owner filter, an IDOR) is not checked by this script. In contract.json, give each entity you want checked a table, and set access.tenancy.column (and membership, if users join a tenant through a table). Before you set an entity's read scope, read the table's SELECT policies: a policy that is using (true), is named for the public or everyone, or filters only on a published-style column (is_published, active, status = 'published') means the table is probably public on purpose, so ask, or decide from the product, whether readers get scope all, and add a visitor actor (id visitor) with a read row at scope all when anon can read it. Take owner from the policy or foreign key that enforces ownership, not from the column name, and mark a field required only when the schema says NOT NULL. For Prisma, set CONFIG.sources to [{ kind: 'command', preset: 'prisma' }] (Prisma 7 also needs datasource.url set to process.env.DATABASE_URL in prisma.config.ts, even to print SQL), then let install fetch its schema engine: with pnpm 10, add "@prisma/engines" to pnpm.onlyBuiltDependencies in package.json; with pnpm 11, which ignores that key, add @prisma/engines: true under allowBuilds in pnpm-workspace.yaml; then run the install once online. For Drizzle set [{ kind: 'command', preset: 'drizzle' }], for Alembic [{ kind: 'command', preset: 'alembic', dialect: 'postgres' }] (alembic must be in .venv or on PATH, and the migrations must run offline), and for SQLite migration files [{ kind: 'sql', paths: ['db/migrations/*.sql'], dialect: 'sqlite' }]. SQLite and Turso have no row security, so the access gate says it did not check and the schema gate runs as normal. Add a package.json script "backend:check": "node scripts/check-backend.mjs --check", a row for it in the AGENTS.md enforcement table, and a CI step that runs node scripts/check-backend.mjs --check --allow-empty, so CI passes before the first migration exists. Then run node scripts/check-backend.mjs --check once. If the migrations do not load (a table made in the dashboard, a dump that does not run, a real SQL error), do not leave the commit blocked and do not drop the check: first try the escapes its LOAD-FAILED lines name (a base schema file first in CONFIG.sources, CONFIG.preamble, late: true, CONFIG.skipStatements). If it still does not load, set loadGate: 'warn' in CONFIG and tell the person plainly that the database is not being checked yet. Warn mode prints every load failure on every run and does not block. The first clean load exits 1 with LOAD-GATE-READY; set loadGate back to 'gate' and commit that, and from then on a migration set that does not load blocks the commit. Never set 'warn' on a repo that loads.
Skipped unless the repo has a Postgres, SQLite or Supabase database, now or planned.
How to tell it works: Write a migration that creates a table in public without enabling row level security, and the commit is refused naming RLS-OFF and the table. Enable RLS with a policy on auth.uid() and it passes. Map an entity to that table with scope own, change the policy to using (true), and the refusal says LEAK-READ: user A read a row that belongs to user B. A table the check cannot fill with test rows refuses the commit with the column it could not fill, never a pass.
- 12
Test that the checker still fails a table with RLS off, still refuses a cross-tenant read, and still says it could not check when it cannot seed a table, so an edit to the checker cannot quietly turn it green.
Details
writes scripts/check-backend.test.mjs
Skipped unless the repo has check-backend.mjs.
How to tell it works: node --test passes. Remove the grant pre-check so a permission error on a probe counts as denied, and the missing-grants test fails.
- 13Migration decision checkBlocks the commit
A migration that drops a table or a column, narrows a column’s type, or edits a migration that already ran on the base branch, with nobody having written it down. It compares the schema this branch builds with the base branch’s, on Postgres or SQLite, from SQL files or a Prisma, Drizzle or Alembic schema, and lets the change through only when DECISIONS.md names the migration file and the table or column.
Details
writes scripts/check-migrations.mjs
Edit it: It reads the same CONFIG.sources as check-backend.mjs, so keep the two scripts together. Set CONFIG.decisionsFile if the decision log is not DECISIONS.md, CONFIG.baseRef if the base branch is not main or master, and add to CONFIG.mutable any file that is meant to change after it is committed, such as a declarative schema. While nothing is live, set CONFIG.stage to pre-production and findings print as notes. Add a package.json script "migrations:check": "node scripts/check-migrations.mjs --check", a row for it in the AGENTS.md enforcement table, and a CI step that runs node scripts/check-migrations.mjs --check --against origin/main, with full git history fetched (fetch-depth: 0).
Skipped unless the repo has a Postgres, SQLite or Supabase database, now or planned.
How to tell it works: Commit a migration that creates invoices with a pdf_url column. On a branch, stage a second migration with alter table public.invoices drop column pdf_url, and the commit is refused naming DROP-COLUMN and public.invoices.pdf_url. Add an entry to DECISIONS.md that names the migration file and `public.invoices.pdf_url` in backticks, and it passes. A decision that names only the column does not count.
- 14
Test that the checker still refuses a dropped table or column, a narrowed type and an edited migration, still accepts a change that DECISIONS.md names, and still reads the index rather than the worktree, so an edit to the checker cannot quietly turn it green.
Details
writes scripts/check-migrations.test.mjs
Skipped unless the repo has check-migrations.mjs.
How to tell it works: node --test passes. Make the checker compare a column’s type only by name and the widening test fails.