You treat every generated infrastructure file as untrusted input. You do not merge it because a model sounded confident. You review it like a stranger opened production's pull request.
An agent will fill the gaps you leave. That habit is the product, not a bug. It invents a retry policy when you stay silent.
It draws a box and names the box a service. You stop that habit in the constraint file. The meeting after the outage is already too late.
Think of the model as a contractor without your blast radius. You would not hand that contractor the DNS cutover. You hand them a scratch pad, then inspect every line.
Freeze the assumption boundary
You write the assumption boundary before the first prompt. The file stays small on purpose. It names decisions the agent must never make.
# constraints.yaml
owner: platform
review_plane: local
scratch_runtime: ephemeral
control_plane: forbidden_on_scratch
data_flow:
inbound: public_https
outbound:
- payments_api
- audit_log
forbidden:
- raw_card_data_to_scratch
- prod_secrets_to_prompt
assumptions_agent_must_not_invent:
- retry_budget
- failover_target
- data_classification
- tls_termination_owner
failure_domains:
- edge
- app
- payments
- audit
promote_if:
- validator_exit_0
- human_ack
You keep this file in git. You do not let the prompt rewrite it. The agent may read the law. It does not own the law.
This is architecture review, not prompt theater. Constraints go first. Generation goes second.
Promotion waits for both gates. A frozen file beats a clever system prompt. Prompts drift under pressure.
Files get blamed in the postmortem, which is what you want. Blame needs an object with a hash. A chat transcript is not that object.
Data flow is a fence, not a story
A generated diagram will tell a comforting story. Stories hide arrows behind pretty boxes. You force every arrow into a spoken name.
You ask one question of every generated box. What data crosses this line, and who encrypts it. Missing answers mean you reject the sketch now.
You do not clarify later on a data hop. Later is how card data lands on scratch. The fence is the review, not the slide.
Customs officers do not accept a charming itinerary. They accept a declared path. You should treat generated hops the same way.
Here is a checker that treats architecture JSON as hostile. It fails closed. It does not negotiate with the sketch.
# review_arch.py
import json, sys, yaml
ALLOWED_OUT = {"payments_api", "audit_log"}
FORBIDDEN_ASSUME = {
"retry_budget",
"failover_target",
"data_classification",
"tls_termination_owner",
}
def load():
constraints = yaml.safe_load(open("constraints.yaml"))
generated = json.load(open("generated_arch.json"))
return constraints, generated
def fail(msg):
print(f"REJECT: {msg}")
sys.exit(1)
def main():
c, g = load()
if g.get("control_plane_host") == "scratch":
fail("control plane landed on scratch runtime")
for hop in g.get("outbound", []):
if hop not in ALLOWED_OUT:
fail(f"undeclared outbound hop: {hop}")
invented = set(g.get("assumed", [])) & FORBIDDEN_ASSUME
if invented:
fail(f"agent invented {sorted(invented)}")
domains = set(c["failure_domains"])
drawn = set(g.get("failure_domains", []))
if not drawn.issubset(domains):
fail("unknown failure domain in sketch")
if g.get("secrets_in_prompt"):
fail("production secrets entered the prompt")
print("PASS: architecture stays inside the fence")
if __name__ == "__main__":
main()
Feed it a sketch that looks tidy and still cheats. Tidy is not evidence. Evidence is an allowed hop list.
{
"control_plane_host": "scratch",
"outbound": ["payments_api", "telemetry_vendor"],
"assumed": ["retry_budget"],
"failure_domains": ["edge", "app", "shared"],
"secrets_in_prompt": false
}
python3 review_arch.py
# REJECT: control plane landed on scratch runtime
You run the checker on every generation. A green log is not a feeling. It is a mechanical gate in front of promote.
If the checker cannot parse the sketch, you fail closed. Unreadable architecture is still untrusted input. You ask for a new draft, not a better story.
Fix the host, and the next rejection still waits. Undeclared telemetry is a new failure domain wearing a vendor name. You still do not merge it.
# after you move control_plane_host off scratch
python3 review_arch.py
# REJECT: undeclared outbound hop: telemetry_vendor
That second failure is the whole lesson. The agent did not become safer after one fix. It became quieter. Quiet is not a review.
Scratch runtime is its own failure domain
You need a place to let the agent be wrong. That place is not your cluster. It is a scratch runtime with no path to canonical state.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option. You park generation there and keep review local.
The scratch host is a sandbox with a short memory. It is not a region. It is not a failover target. It is not allowed to hold the apply key.
The free server sees a redacted prompt and a throwaway repo. It does not see production tokens. It does not see the customer table.
If the scratch host dies, you lose a draft. That outcome is acceptable by design. A draft is cheap. A control plane is not.
You already treat cache nodes as disposable. Treat generation hosts the same way. Do not promote a scratch box into the traffic path.
Do not copy secrets into the prompt once. The once becomes a log line you do not own. Redact first. Generate second.
# redact.sh
set -euo pipefail
src="$1"
out="$2"
sed -E \
-e 's/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/REDACTED_EMAIL/g' \
-e 's/AKIA[0-9A-Z]{16}/REDACTED_KEY/g' \
-e 's/(postgres|mysql):\/\/[^[:space:]]+/REDACTED_DSN/g' \
"$src" > "$out"
echo "redacted prompt at $out"
chmod +x redact.sh
./redact.sh prompt.raw.md prompt.redacted.md
less prompt.redacted.md
You read the redacted file with your own eyes. Then you send only that file. If you cannot explain a remaining token, you delete the line.
A scratch runtime that never sees secrets cannot leak them later. That is not a vendor promise. That is a data-flow choice you made on purpose.
Failure domains stay human-owned
The agent will collapse domains to look tidy. Tidy is not an availability strategy. Edge, app, payments, and audit fail for different reasons.
You keep those names frozen in the constraint file. The sketch may only place boxes inside written names. A new name is a rejected draft.
When generated YAML adds a fifth domain called shared, you reject it. Shared is where ownership goes to die. Shared is how a cache outage takes payments.
You encode that rudeness as a test. Tests do not get polite in standup. They fail on the laptop before anyone pages.
# test_domains.py
import json
def test_no_shared_domain():
g = json.load(open("generated_arch.json"))
assert "shared" not in g.get("failure_domains", [])
assert "misc" not in g.get("failure_domains", [])
def test_payments_has_no_scratch_dependency():
g = json.load(open("generated_arch.json"))
deps = g.get("domain_deps", {}).get("payments", [])
assert "scratch" not in deps
def test_audit_is_not_best_effort_guess():
g = json.load(open("generated_arch.json"))
assert g.get("audit_mode") != "invented"
assert g.get("audit_mode") in {"required", "dual_write"}
pytest -q test_domains.py
A failing test is cheaper than a war room. You let the test stay blunt. The agent will not take offense at the stack trace.
Watch domain_deps the same way. Payments must not list scratch as a dependency. If it does, the sketch tried to live in the wrong failure domain.
Audit is the easy one to degrade in a generated design. The model will mark it best effort to keep the happy path pretty. Pretty is how you lose the only trail you needed.
What you would change next
This gate is still thin on purpose. It catches invented retries and stray hops. It does not catch a polite lie inside a legal name.
A model can cite payments_api and still dump PII into logs. You would add schema lint on every event payload next. Field names would need a classification tag.
You would also sign the review in git. A validator exit code is not a person. You would require an Acked-by trailer on the promotion commit.
git interpret-trailers --trailer "Acked-by: $(git config user.name)" \
--in-place .git/COMMIT_EDITMSG
No trailer means no deploy. That keeps a human in the loop. It does it without a two-hour architecture theater.
You would split generate and apply into two identities. The scratch runtime may write generated_arch.json. It must never run Terraform against production.
# generate identity: write artifacts only
export AWS_PROFILE=scratch-writer
# apply identity: never present on the scratch host
# export AWS_PROFILE=prod-applier
Different keys belong to different actions. Different accounts if your budget allows it. Same laptop is fine. Same cloud role is not.
You would version the constraint file with the sketch. A passing validator against yesterday's fence is a miss. Pin both hashes in the promotion note.
echo "constraints=$(git rev-parse HEAD:constraints.yaml)" >> promote.note
echo "sketch=$(sha256sum generated_arch.json)" >> promote.note
That note is the architecture review artifact. It survives the chat window. It survives the intern who was on call.
Who should not use this loop
Do not use a free scratch host for regulated workloads. Do not paste patient records or card data into prompts. Do not point that host at a production kubeconfig.
This method assumes the draft is disposable. If the draft is the business, you stop. You need a contract and a private runtime first.
Do not skip the validator because the diagram looks clean. Clean diagrams are how assumptions travel between teams. If you cannot run review_arch.py, you are not ready.
Teams without written data classification should not start here. Write the classification before any generation. Generation without classification is only faster guessing.
If you cannot name inbound data and outbound hops, pause. The agent will name them for you. That naming is the incident report, written early.
Close the loop
You freeze assumptions in constraints.yaml. You redact the prompt on your machine. You generate on a scratch runtime that can disappear.
You run review_arch.py and the domain tests. You read the diff like a hostile pull request. Then you promote, or you throw the draft away.
The agent sketches. You own the fence. Generated infra stays untrusted input until those gates pass.
You can hold the sketch on a disposable draft host. MonkeyCode's free model access and free server option fit that role. Keep constraints local. Keep production off that host.
Top comments (0)