Declaring Off-Limits Paths: Generated Code, Migrations and Secrets
Generated files are valid source. Applied migrations are valid SQL. Nothing in the tree tells an agent which files are history and which are open for editing.
The directories that must not be touched
Most repositories contain code that is technically editable and practically must not be edited. Generated files. Vendored dependencies. A legacy module frozen pending deletion. Migrations that have already run in production.
To an agent reading the tree, none of this is distinguishable from ordinary source. A generated protobuf file is valid Go. A migration is valid SQL. If a task nominally requires changing something in one of them, the agent will change it, and the damage ranges from a wasted review cycle to a production incident.
The four categories
Generated code
Edits get silently destroyed on the next generation run, which is the most confusing possible failure — the change worked, the tests passed, and three days later it is gone.
## Generated — never hand-edit
- `**/*_pb2.py`, `**/*_pb2.pyi` — from `proto/`. Run `make proto`.
- `src/api/client.ts` — from the OpenAPI spec. Run `npm run gen:api`.
- `schema.sql` — dumped from migrations. Run `make schema`.
To change any of these, change the SOURCE and regenerate.
The final line is the escape hatch. Without it an agent that needs a new field in a generated client is stuck: it cannot edit the client and does not know where the client comes from.
Applied migrations
This is the category with the worst downside. A migration that has run in production is history. Editing it produces a schema that disagrees with what is actually deployed, and the disagreement surfaces later, somewhere else, in a form that is hard to trace.
## Migrations
- Everything in `migrations/` that is already committed has RUN in
production. Never edit, never delete, never renumber.
- Schema changes are a NEW migration: `alembic revision -m "..."`.
- If a committed migration is wrong, write a corrective migration.
Do not fix it in place.
Frozen legacy
Code that still runs, must keep running, and should not grow. The risk is not that the agent breaks it — it is that the agent extends it, because it is there and it works and it looks like a reasonable place for related code.
## Frozen
- `src/legacy_billing/` — serves customers on the pre-2024 plan.
Bug fixes only, and only if the bug is causing incidents.
New billing work goes in `src/billing/`. Do not extend legacy.
"Do not extend" is the operative phrase, and it is different from "do not touch". Say which one you mean.
Vendored and licence-constrained
Third-party code checked into the repo. Local modifications are lost on upgrade and, depending on the licence, may create obligations.
Secrets are a different problem
People often lump "do not commit secrets" in with the do-not-touch list. It belongs in a different mental category, because the failure mode is not "wasted work" but "credential in a public git history forever".
## Secrets
- Config comes from env vars via `src/config.py`. Never a literal.
- `.env` is gitignored and stays that way. Never commit it, never
create `.env.backup`, `.env.local.bak` or similar.
- Test fixtures use obviously-fake values: `sk_test_FAKE_...`.
Never a real key, not even an expired one.
- Never print config objects in logs or error messages.
Every instruction above reduces the probability of a leak. None of them make it impossible. A secret-scanning pre-commit hook does. If your repository does not have one, install it before you spend another minute wording the context file — the hook is worth more than every sentence in this article.
Backing the list with mechanical controls
The pattern that holds throughout this pillar: state the rule in the context file so the agent does the right thing by default, and enforce it in CI so the wrong thing cannot merge.
- name: Protected paths
run: |
CHANGED=$(git diff --name-only origin/main...HEAD)
BLOCKED=$(echo "$CHANGED" | grep -E \
'(_pb2\.py|^migrations/|^src/vendor/|^src/legacy_billing/)' || true)
if [ -n "$BLOCKED" ]; then
echo "Protected paths modified:"; echo "$BLOCKED"
echo "If intentional, add the 'protected-path-override' label."
exit 1
fi
Note the override. A protection with no legitimate escape route gets disabled the first time someone genuinely needs to change a vendored file at 2am. A protection with a deliberate, visible, logged override survives, because using it is possible but conspicuous.
Read-only is not the same as do-not-extend
Four different meanings hide behind "do not touch", and conflating them produces either violations or unnecessary stalls. Say which one you mean.
| Meaning | Applies to | How to phrase it |
|---|---|---|
| Never modify, at all | Applied migrations, vendored code | "Never edit. To change behaviour, do X instead." |
| Do not hand-edit; regenerate | Generated clients, protobufs | "Edit the source at Y and run Z." |
| Fix bugs, do not add features | Frozen legacy | "Bug fixes only. New work goes in W." |
| Ask before touching | Security-sensitive code, auth | "Stop and flag it. Do not proceed unprompted." |
The fourth is underused and valuable. Some code should not be off-limits, but should not be changed by an agent working unsupervised either — authentication logic, permission checks, anything with a security invariant. An instruction to stop and surface it gets you a flagged decision rather than a silent change buried in a large diff.
## Flag before changing
- `src/auth/` — any change to session handling, token validation
or permission checks. Describe what you would change and why,
then stop. Do not implement it unprompted.
- Anything that alters what data crosses a tenant boundary.
Protect the guardrails themselves
One entry that belongs on every do-not-touch list, and is almost never there: the configuration of the tools doing the enforcing.
## Do not modify to make a check pass
- `.github/workflows/` — CI definitions
- `.pre-commit-config.yaml`, `ruff.toml`, `mypy.ini`, `.eslintrc*`
- Adding `# noqa`, `# type: ignore`, or `eslint-disable` to silence
an error is not a fix. If a check is genuinely wrong, say so and
stop; do not route around it.
An agent that cannot satisfy a check will, given enough persistence, consider disabling the check. This is not malice or laziness — it is a reasonable-looking path to the stated goal of "make the build pass". Naming that path as out of bounds closes it. This one line has prevented more confusing pull requests, in my experience, than any other single entry.
Four categories: generated, applied migrations, frozen legacy, vendored. Each entry needs an escape hatch saying what to do instead. Secrets are a separate and more serious case that needs a scanner, not prose. And put your CI and linter configs on the list — an agent blocked by a check will otherwise consider editing the check.