You should freeze architecture before any agent writes code.
Let the model write functions instead of foundations.
If the shape can drift, you do not have a system.
Take the position, then enforce it
An unbounded coding agent will invent a stack.
That invention becomes your production debt next week.
You should treat the invention as untrusted output.
You already know agents fill every missing fact.
They pick popular defaults, not your real constraints.
Those defaults look helpful until the first deploy.
This is not a style preference. It is ownership.
You own the runtime, the data, and the blast radius.
The agent owns a draft inside that fence. Nothing else.
Agents invent architecture when you leave gaps
You leave a blank repo and a vague prompt.
The agent picks a framework, a queue, and a database.
Those picks are only guesses dressed as competence.
You did not review a design document at all.
You only reviewed a diff that already assumed a stack.
That is how quiet brownfield messes start this year.
Popular agent demos still hide this ownership cost.
They celebrate a running endpoint, not a durable shape.
You inherit that shape after the demo energy fades.
Stop asking the agent what you should build on.
Ask it to implement one behavior inside a locked shape.
If it cannot work inside the lock, the prompt is wrong.
Freeze a shape, not a feeling
Architecture here means four locked, testable facts.
- Runtime language, version, and entrypoint file
- Package boundaries and forbidden dependencies
- Data store, migrations, and allowed ORM surface
- Paths the agent may edit, and paths it must not
Write them in a file the gate can parse.
Do not store the contract only in chat threads.
Put the contract in Git where checks can run.
If it is not checked, the shape will move.
Here is a minimal shape lock you can copy.
Treat it as a proposal, not a measured standard.
Pin values that match your actual repository.
{
"runtime": {
"language": "python",
"version": "3.12",
"entry": "src/app.py"
},
"boundaries": {
"forbidden_packages": ["django", "celery", "redis", "mongodb"]
},
"data": {
"store": "sqlite",
"migrations": "alembic"
},
"agent": {
"may_edit": ["src/services/", "tests/"],
"must_not_edit": ["shape.lock.json", "scripts/", "deploy/", ".github/"]
}
}
Keep the file boring on purpose.
Boredom is easier to test than a slide deck.
If a new dependency is required, you edit the lock first.
A gate that fails closed
You need a checker that exits non-zero.
Warnings will not stop an agent loop.
Agents ignore yellow text. They respect exit codes.
#!/usr/bin/env python3
"""Fail if touched files violate shape.lock.json.
Proposal: run locally and in CI. This script is a gate,
not a production security scanner.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOCK = json.loads((ROOT / "shape.lock.json").read_text())
FORBIDDEN = set(LOCK["boundaries"]["forbidden_packages"])
MUST_NOT_EDIT = LOCK["agent"]["must_not_edit"]
def scan_imports(path: Path) -> list[str]:
hits: list[str] = []
text = path.read_text(encoding="utf-8", errors="ignore")
for pkg in FORBIDDEN:
if f"import {pkg}" in text or f"from {pkg}" in text:
hits.append(f"{path}: forbidden import {pkg}")
return hits
def main() -> int:
errors: list[str] = []
for raw in sys.argv[1:]:
rel = raw.replace("\\", "/")
for blocked in MUST_NOT_EDIT:
prefix = blocked.rstrip("/") + "/"
if rel == blocked or rel.startswith(prefix):
errors.append(f"{rel}: must not edit {blocked}")
path = ROOT / raw
if path.suffix == ".py" and path.exists():
errors.extend(scan_imports(path))
if errors:
print("SHAPE GATE FAILED")
print("\n".join(errors))
return 1
print("SHAPE GATE OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run it on every path the agent touched.
python scripts/assert_shape.py src/services/billing.py tests/test_billing.py
echo $?
You want 1 when a forbidden import appears.
You want 0 when only service files changed.
That binary result is the entire control loop.
Wire the same command into a Makefile target.
Do not hide it behind a chat plugin.
Make the failure local, fast, and boring.
TOUCHED ?= src/services/billing.py tests/test_billing.py
assert-shape:
python scripts/assert_shape.py $(TOUCHED)
test:
python -m pytest tests/ -q
promote: assert-shape test
@echo "shape and tests passed; now read the service diff"
How a real failure should look
Suppose you asked for invoice totals only.
The agent “helps” by adding a Redis cache.
Your gate should look like this, then stop.
SHAPE GATE FAILED
src/services/billing.py: forbidden import redis
Do not start a design debate with the model.
Reset the tree and tighten the prompt.
Name the forbidden package in the next instruction.
Implement invoice totals in src/services/billing.py.
Do not add caches, queues, or new listeners.
Do not edit shape.lock.json, scripts/, or deploy/.
Add tests under tests/. Stop after pytest passes.
If it fails the gate twice, you stop the session.
Repeated shape violations are a prompt bug.
They are not a reason to loosen the lock.
Use a free sandbox. Do not promote from it.
A coding agent still needs a machine to run.
Your laptop is a poor isolation boundary.
A disposable server is a better scratch pad.
MonkeyCode can host that pad for a first pass.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The open-source project currently offers free model access and a free server option.
Treat both as a lab, not as staging, and verify live limits on the project page before you plan capacity.
This article will not name models or quote quotas.
Those figures change. Your lockfile should not.
Copy only synthetic fixtures onto that box.
Keep secrets off the sandbox.
Keep production data off the sandbox.
Clone the lockfile, the tests, and a fake dataset.
A practical loop looks like this.
- Commit
shape.lock.jsonon your real branch. - Clone that commit onto the free server.
- Point the agent at
src/services/andtests/only. - Run
assert_shape.pyon every touched path. - Pull the diff back. Re-run the gate locally.
- Promote only if tests and shape both pass.
If step 4 fails, discard the server tree.
You do not negotiate with a broken shape.
You reset the box and shrink the allowed paths.
Decision table: what the agent may touch
Print this table next to the prompt.
The agent should see every hard no.
You should see it before you merge.
| Change | Agent may draft? | Promote from sandbox? | Who decides? |
|---|---|---|---|
| New service function | Yes | Only after local gate | You review test names |
| New dependency | No | Never | You edit the lock first |
| New HTTP listener | No | No | Architecture review |
| Schema migration | Draft SQL only | No | Data owner review |
| CI or deploy scripts | No | No | Humans only |
| Types and copy fixes | Yes | Yes | Skim the diff |
If a task needs a “No” cell, stop the agent.
Do that work in a human-owned change.
Then reopen the sandbox for implementation only.
Tests that encode the opinion
Do not only test business logic.
Test that the architecture did not move.
These tests should be short on purpose.
# tests/test_shape_policy.py
import ast
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOCK = json.loads((ROOT / "shape.lock.json").read_text())
def test_lockfile_is_committed():
assert (ROOT / "shape.lock.json").is_file()
def test_entry_point_exists():
assert (ROOT / LOCK["runtime"]["entry"]).is_file()
def test_no_forbidden_imports_in_src():
forbidden = set(LOCK["boundaries"]["forbidden_packages"])
hits = []
for path in (ROOT / "src").rglob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
root = alias.name.split(".")[0]
if root in forbidden:
hits.append(f"{path}: import {alias.name}")
if isinstance(node, ast.ImportFrom) and node.module:
root = node.module.split(".")[0]
if root in forbidden:
hits.append(f"{path}: from {node.module}")
assert hits == [], hits
Run the same tests on every host you use.
python -m pytest tests/test_shape_policy.py tests/ -q
If the sandbox cannot run pytest, skip that host.
An agent without tests is a story generator.
You do not ship stories into main.
Cheap generation invites extra architecture
Retries feel free when inference is free.
Extra retries invite extra architecture churn.
The model will add Redis on the third pass.
Spend those retries on behavior, not on stack churn.
Do not replace SQLite because the prompt felt stuck.
Do not introduce a second web framework for speed.
This is the opinion, restated without softness.
Cost-free generation is not cost-free ownership.
The gate is how you keep those two costs separate.
You are not anti-agent. You are anti-drift.
Agents are fast at filling files you already scoped.
They are reckless at choosing the scope itself.
Limitations
This workflow will annoy people who want magic.
It will also miss clever policy violations.
String and AST import scans are not a sandbox.
It does not prove runtime isolation.
It does not prove load, privacy, or correctness.
It only proves the agreed shape did not drift.
Do not use this if nobody owns the lockfile.
An unowned lockfile becomes ignored folklore.
Folklore does not fail CI, so drift returns.
Do not use this for regulated production cuts.
A free shared server is the wrong trust zone.
Keep customer data off any community box.
Do not use this as your only review step.
Humans still read the service diff line by line.
The gate only reads the skeleton you froze.
What you should do on the next agent session
Write the lockfile before you open the loop.
Wire assert_shape.py into the pre-push hook.
Give the agent a directory, not a product mandate.
If you need a throwaway machine for that loop, a disposable server with free model access is enough for the experiment. Keep the lock. Discard the box.
Top comments (0)