prod-db-guard
Use before running ANY script that touches a database from a worktree or local shell. Every worktree binds the PRODUCTION DATABASE_URL, so a "local" script hits prod — and a --dry-run flag is not a gu
Files1
View on GitHubSKILL.md.claude/skills/prod-db-guard/SKILL.md
GitHubprod-db-guard
The fact that makes this necessary
scripts/worktree-setup.ps1 copies DATABASE_URL into every new worktree as a
critical key. There is no local database in a fresh worktree. A script you
run "just to see what it does" runs against production.
.env holds a production DATABASE_URL but is not production config —
never conclude runtime behavior by grepping it (see
apps/nickstire/docs/runbooks/tidb-backup-restore.md, Standing warnings).
The incident this encodes
A prod-touching script was executed as verification — to confirm its guard worked. The guard did not work. 870 production rows were deleted. The dry-run flag existed; it was checked after the delete path had already run.
Recorded in apps/nickstire/docs/runbooks/tidb-backup-restore.md and
apps/statenour/docs/runbooks/neon-branching.md — both apps carry the warning
because the incident class is not app-specific.
Running a script is not how you verify a script.
Procedure
- Read the script before running it. Locate every
DELETE,UPDATE,TRUNCATE,DROP, and bulkINSERT. - Prove the subject TABLE exists before theorising about a column. Query
INFORMATION_SCHEMA.TABLESfor it (read-only). Rawsql\`can name any table; Drizzle-typed reads cannot — so a cron that "skips forever" or a column that is "missing" may be a table that was never created. Witnessed 2026-09-02: applying0114failed on statement 1 withER_NO_SUCH_TABLE estimates; no migration had ever created that table, the audit had diagnosed a missing COLUMN, and a day of receipt fixes had been shipped for a job that could never run. Scan raw FROM/JOIN/UPDATE/INTO targets against the declared table list first (apps/nickstire/server/__tests__/rawSqlTablesExist.test.ts` is that scan as a gate). - Prove the guard non-executing. Trace the dry-run branch by reading it:
confirm it
returns orprocess.exits before the first write, not after. A flag that only suppresses logging is not a guard. - Confirm which database you are pointed at. Print the host, do not assume. A worktree defaults to prod.
- Back up first for anything destructive. The house pattern is a copied
table named
_bak_<table>_<op>_<yyyymmdd>, created before the mutation and left in place until the change is confirmed good. - Get operator approval for a prod write. Staged and reversible, stated as such. This is not an agent-initiative action.
Applying migrations in scope (never the unscoped sweep)
pnpm db:migrate (scripts/db-migrate.ts) has no dry run and applies
every unrecorded file in one pass. When only some files are due, or a
destructive one needs its backup first, write a throwaway scoped runner and
delete it afterwards (runbook rule):
- Dry run by default: the process exits before opening a connection
unless
--executeis passed; the dry run prints every statement. --only <prefix,prefix>— the files you name, nothing else.- For a destructive file:
CREATE TABLE _bak LIKE t·INSERT … SELECT· compareCOUNT(*)source vs backup · abort before the drop on mismatch. - Record exactly as
scripts/db-migrate.tswould: sha256 of the whole file ashash, the journalwhenascreated_at, into__drizzle_migrations. A migration reconcile reports asUNRECORDED_BUT_EXACT_MATCHgets a record-only pass (no DDL). - Re-run
node scripts/reconcile-migrations.mjs --strict— zero blocking drift is the receipt.
Witnessed 2026-09-02: 0113 recorded, 0114 rewritten then applied, 0115/0117 with count-verified backups, 0116 — one runner, one session, deleted after.
Running it from an agent session
The Claude Code auto-mode classifier — not repo policy — blocks many
production-touching commands, and it blocks by shape, not just by risk.
Witnessed 2026-09-02: it denied railway whoami, a read-only probe script,
gh pr checks, a combined grep+dryrun+execute+rm chain, the plain
node <runner> --execute once, and the long-form
railway run --service … -- ./node_modules/.bin/tsx …, while allowing the
same actions as single plain commands after the operator added a rule.
One plain command per call. No pipes into
rm/grepchains, no compound guards around a prod write; run the read, then the write, then the cleanup as separate calls.Expect a read-only probe to be treated as a prod action.
When blocked: stop and hand the operator the exact one-liner. Reshape a command at most once; a second reshape is a workaround, not a fix.
railway run -s <service> -- pnpm exec tsx <script>(short form) is the shape that passed; it injects the real environment so no key is ever pasted into a command.On a Windows/PowerShell device, single-quote every comma-separated argument (
--only '0127,0128', id lists). Unquoted, PowerShell converts it to an array of NUMBERS: leading zeros vanish and commas become spaces (2026-10-02:--only 0127,0128,...reached node as127 128 ...; the script refused and wrote nothing, benign only because it refuses unmatched prefixes).
A scripts-only prod run from the operator PC
scripts/worktree-setup.ps1 sat over 10 minutes in its repo-wide recursive
scans on NattyNour (2026-10-02) and copied nothing. A run that needs only
scripts/, drizzle/ and mysql2 needs no full setup:
git worktree add -b <tmp> <dir> origin/main;- two
mklink /j: rootnode_modulesandapps/nickstire/node_modules; railway run --service ... -- node scripts/<x>.mjs(dry run first);- tear down with
worktree-teardown.ps1; add-ForceDirtyReleaseonly aftergit rev-list --count origin/main..<tmp>is 0 and the status is clean.
A Neon child branch has its own role password
The branch's role password is NOT the parent's (2026-10-20: scrub rehearsal
auth failures). Rehearse with the connection string from get_connection_string
for that branch, and check by hash, never by printing, that it differs from the
production string before use. A string equal to production is not the branch.
Verify read-only instead
Almost every "does this work in prod?" question has a read-only answer:
cron_log— did the job run, when, with what resultGET /api/health— uptime since container startrailway run --service MAINnicks-tire-auto -- <read-only query>
Prefer these. They answer the question without a write path.
Two receipts you can lose in the same five minutes
- Resolve the physical table name from
@@mapbefore writing backup SQL.CREATE TABLE ... AS SELECT * FROM "AutomationPolicy"fails42P01— the Prisma MODEL name is not the database TABLE name once a model declares@@map. Check the schema's@@mapbefore naming a table in raw SQL. - Never restore files via
git show <ref>:<path> > <path>in git-bash. A root-dotfile-shaped colon ref MSYS-mangles (origin\main;.gitignore), and the>redirect truncates the target to 0 bytes before the command's own failure is visible — witnessed restoring one file this way zeroed ~300 tracked files, including the live.completion/evidence.json. Usegit archive <ref> [-- <paths>] | tar -xinstead; it cannot truncate a target it never opens for writing until the archive stream is already valid.
Red flags — stop if you catch yourself thinking these
- "It's just a dry run."
- "I'll run it once to see what happens."
- "The script says it's safe."
- "I need to test the guard."
Each of these preceded the 870-row deletion. The correct move is a non-executing check: read the code, or run it against a scratch table you created for the purpose.
Related: nickstire-tidb-ddl for the DDL
rules · apps/nickstire/docs/operations/SCHEMA_DRIFT_RUNBOOK.md for the apply
procedure.