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.