The card said "routine cleanup, risk low." The reviewer approved in nine seconds. Then the agent touched 1,800 records that were never in the plan.
I use that scenario in design reviews. It is constructed, not a customer story. The failure mode is ordinary though: the card described the task, not the change.
So what did the reviewer actually consent to? Nobody could say. That is a design bug, not a model bug.
Today I'll build the smallest gate that fixes it. A card carries the scope delta. Consent binds to that exact delta. Missing evidence blocks the run, and the block names the missing field.
It is a tutorial, so we go from empty folder to a verification script that fails loudly.
Stage 0: Pick a place to run it
You need two things before code: an endpoint that returns a plan, and a host that stays up while you click.
For the endpoint I'm using MonkeyCode's free model access, and for the host its free server option. Both are operator-supplied availability claims; I'm not promising a quota, a model name, or a duration. If those two options are not live for you, any OpenAI-compatible endpoint and any small VM will do.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
mkdir scope-gate && cd scope-gate
python -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn requests
Verification for this stage is boring on purpose. Start the server, then confirm it answers.
uvicorn app:app --host 0.0.0.0 --port 8000
curl -s localhost:8000/health
# {"ok":true}
If that fails, stop. A gate you cannot reach is a gate nobody uses.
Stage 1: Write the card schema first
The reviewer's decision only becomes real if the card has fields. I keep six required fields, and I keep them short enough to read in one pass.
# gate.py
import hashlib, json
from dataclasses import dataclass
REQUIRED = [
"owner", # who accepts the consequence
"scope_before", # what the agent may touch today
"scope_after", # what it wants to touch after this run
"reversible_until", # ISO 8601 with timezone
"rollback", # one command or one named procedure
"stop_conditions", # when the agent must hand back
]
@dataclass
class Card:
task_id: str
owner: str
scope_before: list
scope_after: list
reversible_until: str
rollback: str
stop_conditions: list
def missing(card: dict) -> list:
return [f for f in REQUIRED if not card.get(f)]
def delta(card: dict) -> list:
return sorted(set(card["scope_after"]) - set(card["scope_before"]))
def delta_hash(card: dict) -> str:
blob = json.dumps(delta(card), sort_keys=True).encode()
return hashlib.sha256(blob).hexdigest()[:12]
Notice what is absent. No confidence score. No "risk: low." A model's self-reported risk is a mood, not evidence.
The delta is the only thing the human is really approving. Everything else is context.
Stage 2: Ask the model for the card, not the plan
Here is where a free model earns its place. It drafts the card from a task description. It never decides anything.
# plan.py
import os, json, requests
PROMPT = """Return ONLY JSON with keys: owner, scope_before, scope_after,
reversible_until, rollback, stop_conditions. scope_* are lists of resource
paths. Use ISO 8601 with timezone for reversible_until. If you cannot name a
rollback procedure, return {"refusal": "<reason>"}."""
def draft(task: str) -> dict:
r = requests.post(
os.environ["MONKEYCODE_BASE_URL"].rstrip("/") + "/chat/completions",
headers={"Authorization": f"Bearer {os.environ['MONKEYCODE_API_KEY']}"},
json={"model": os.environ["MONKEYCODE_MODEL"],
"messages": [{"role": "user", "content": PROMPT + "\n\nTask: " + task}],
"temperature": 0},
timeout=60,
)
r.raise_for_status()
return json.loads(r.json()["choices"][0]["message"]["content"])
Set the three environment variables to values your operator gives you. I am not naming a model, because a name I cannot verify is worse than no name.
Now the second half of this stage: the refusal path is a valid output. If the draft returns refusal, the gate blocks and shows the reason. A model that cannot name a rollback should not be argued with.
Stage 3: Bind consent to the delta hash
This is the design move that stops rubber-stamping. The approval token is the delta hash, not a checkbox.
# decide.py
from gate import missing, delta, delta_hash
def decide(card: dict, acknowledged: str | None = None, escalate_over: int = 3) -> dict:
if card.get("refusal"):
return {"decision": "block", "reason": "model_refused", "detail": card["refusal"]}
gaps = missing(card)
if gaps:
return {"decision": "block", "reason": "missing_evidence", "gaps": gaps}
d = delta(card)
h = delta_hash(card)
if len(d) > escalate_over:
return {"decision": "escalate", "reason": "delta_over_threshold", "delta": d, "hash": h}
if acknowledged != h:
return {"decision": "hold", "reason": "consent_not_bound_to_delta", "hash": h}
return {"decision": "approve", "delta": d, "hash": h}
The reviewer sees the delta list and the hash together. If the agent edits the scope afterwards, the hash changes and the old consent dies.
Is that friction? Yes. Friction at the exact moment scope grows is the feature.
Stage 4: Verify three gates before trusting one
A gate you never tested is decoration. Give it three cards and three expectations.
# verify.py
from decide import decide
from gate import delta_hash
base = {
"owner": "payments-team",
"scope_before": ["db/orders"],
"scope_after": ["db/orders", "db/orders_archive"],
"reversible_until": "2026-11-10T09:00:00+00:00",
"rollback": "restore-orders-archive --to nightly-2026-11-09",
"stop_conditions": ["row_count_delta_gt_500"],
}
assert decide(base)["decision"] == "hold"
assert decide({**base, "rollback": ""})["decision"] == "block"
assert decide({**base, "scope_after": base["scope_after"] + [f"db/t{i}" for i in range(4)]})["decision"] == "escalate"
assert decide(base, acknowledged=delta_hash(base))["decision"] == "approve"
print("4/4 gates behave as specified")
python verify.py
# 4/4 gates behave as specified
I wrote these asserts as a template; I have not run them against your deployment. Run them before you let anything write.
The fourth line matters most. Approval only lands when the hash matches the delta on screen right now.
Stage 5: Run the card past an accessibility check
I check four things and I check them in the browser, not in a spec doc.
# 200% zoom, then:
# 1. Is the delta list selectable text, not an image?
# 2. Does every stop condition have a word, not just a color?
# 3. Are timestamps absolute and timezone-labelled?
# 4. Does the block message name the missing field?
The fourth one keeps biting teams. "Validation failed" tells a reviewer nothing. "Missing evidence: rollback" tells them what to fix.
Share the gate on the free server option for a day. A card that nobody can read at 200% zoom is a card nobody read.
Stage 6: Decide what evidence is enough
Two questions I ask in every review: which missing evidence should stop approval, and which extra information only adds noise?
| Field | Stops approval? | Why |
|---|---|---|
rollback empty |
Yes | No recovery path, no consent |
reversible_until in the past |
Yes | The window already closed |
| Delta holds an out-of-team resource | Yes | Wrong owner, wrong consequence |
| Model confidence score | No | Not evidence, just tone |
| Served model version | No, log it | Useful later, useless at the click |
The right column is the argument. Keep the card small enough to finish reading.
Who should not use this
If your agent action is genuinely irreversible, this gate is not enough. Get a second human, and get a different design.
If nobody can be named as owner, skip the tutorial and fix ownership first. The card will just formalize confusion.
And if your team treats every card as a formality, the hash will not save you. Consent needs a person who would say no.
Build the gate. Then watch a reviewer use it. The first pause you see is the evidence your design needed all along.
Top comments (0)