Skip to content
agenticbuilders

Back to all posts

Six stages before an agent writes

A write path into a foreign system has six stages. The costly mistake: the word gate names two categorically different things.

By Bernhard Götzendorfer

Published on Category: Architecture

Any write path into a foreign system runs through six stages: evidence, proposal, gate, apply, audit, verify. Most integrations have five of them, and nobody notices while a human is watching the write. The costly mistake sits somewhere else: the word "gate" names two categorically different things, and if you collapse them, you build a gate that's fine for a CLI and then point an agent at it. Below are the six stages, the distinction, and a 13-point checklist you can hold your own write path against.

Why six stages and not five

I compared two integrations I built independently of each other. One writes working hours into a time-tracking system, unsupervised, triggered by cron. The other writes records into a CRM, as a personal command-line tool plus a server for a language model. Different domains, different years, no shared code.

Both landed on nearly the same architecture. Dry run as the default, exactly one gate function, an audit log carrying the before state, an allowlist of permitted targets. That convergence alone isn't a pattern, it's an observation. What's interesting is where the two diverge, because every divergence turned out to be justified.

My starting hypothesis had five stages. Four held, one split, and one was missing entirely: verify. Without it, an autonomous run doesn't know whether its write landed. It only knows what the target system replied, and that's a different thing.

The six stages

Evidence

Evidence is the machine-readable basis for the action, collected separately from the write. On the time-tracking path that's a calendar window plus repository activity, and the human's reply counts as its own class of evidence. On the CRM path it's the live record itself, fetched with a GET before anything gets compared.

The rule behind it isn't exciting: what you want to write, you derive from something you read first. If the evidence only comes into existence during the write, you can't check it.

Proposal

The intended effect as an inert data object. An object that does nothing by default stays inert after a refactoring. A code path with an if in front of it only stays inert as long as nobody moves the if.

Here the two systems sit at different levels, and the difference is bigger than it sounds. The CRM plan lives inside one process run: the command recomputes the plan and applies it directly, skipping the dry-run branch on the way. So the dry run is an option, not an enforced first step. The time-tracking proposal survives the process boundary instead: change opened, provenance record written, anchors frozen, and without an open change id the gate throws.

Gate

The decision whether writing is allowed at all. There are two kinds of it, and they answer different questions. Everything else hangs off that distinction, so it gets its own section below.

Apply

Apply executes exactly the checked proposal without reinventing it. Sounds trivial, isn't. The apply edge is the last place where you can check that the payload is still the one that got approved. On the time-tracking path the content hash of the checked payload is compared again right there, as a backstop against a caller that swaps the payload after the check.

Audit

A durable record with a before and an after. The before is your rollback ground and has to be collected ahead of the write, because afterwards it's gone.

One detail I had wrong at first: whether a failed audit write must abort the action depends on the audit's role. If it's pure hindsight, it must not report an already successful write as a failure. If it's the control input for the next run, its failure has to stop the action. Both policies are correct, just not in the same system.

Verify

Verify reads the result back independently and compares. The target system's response is the target system's claim, nothing more. Two rules I paid for: a missing readback counts as a mismatch, not as success. And a mismatch doesn't trigger an automatic retry, it leaves the operation open for a human. A blind rewrite over an unclear state doubles the damage when you guess wrong.

The CRM path doesn't have this stage. For interactive use that's fine, the human is looking. For any non-interactive call it's the missing sixth stage.

The main finding: "gate" names two different things

Both systems use the same word for two categorically different checks. That's exactly where most integrations come apart.

The authorization gate

It answers: is this caller allowed to do this? That's the flag on the command line, the permission precheck against the live ACL, the allowlist of permitted targets. Get this layer right and you have a good CLI.

The integrity gate

It answers a different question: is exactly the checked content being written, and only that? That means a content hash recheck at the apply edge, a recursively frozen payload whose hash is derived from the frozen value, two independent anchors for proposal and consent, and an intent comparison after consent has been claimed.

An authorization gate says "you may write". It says nothing about what gets written.

Where the difference sits in my own code

There's one place in my own code where this gets concrete. The apply handler for the language model server takes a model-supplied plan and turns it into an executable object. There's no plan hash, no plan id, no comparison against what the planning tool actually produced. The validation checks the shape of the plan, not its provenance. The time-tracking path would reject the same case, because there the payload is held against two frozen anchors.

For a personal CLI that's harmless. The human computed the plan and set the flag. For anything driven by an agent it's the most expensive item on the whole list, because between "compute the plan" and "execute the plan" there's a language model that carried the plan through its context as text.

What that means in practice: the handler checks whether a field has the right type. Whether it still has the value from the checked plan, it doesn't check. A field that changed between planning and execution looks to it exactly like a field that was always that way.

The MCP case looks harmless and isn't

An MCP server sits between the two cases above, and that's the dangerous spot: execution feels interactive, but the caller is a model. The Model Context Protocol specification of 2025-06-18 puts it plainly under Tools:

"For trust & safety and security, there SHOULD always be a human in the loop with the ability to deny tool invocations."

Source: modelcontextprotocol.io, specification 2025-06-18, Tools section, retrieved 2026-07-26. The same document says clients must treat tool annotations as untrusted unless they come from trusted servers.

That "SHOULD" isn't a politeness word. The spec binds its key words explicitly to BCP 14 and RFC 2119, where SHOULD means there may be valid reasons to ignore an item in a particular case, but that the full implications have to be understood and weighed before you do.

Read that as an architecture statement about your server. The human approval lives in the client, not in your code. Your server-side code keeps its authorization gates and nothing else. The integrity gate, the one that would check where the plan came from, is absent by default. So the path is spec-compliant and still the one where the binding from plan to apply stays open. Setting destructiveHint: true is honest signaling, but the spec itself says annotations are hints. For a server that could also run without an interactive client, delegating to the client isn't a safeguard, it's an assumption.

When each stage becomes mandatory

The dividing line isn't the size of the system. It's whether a human is watching the write.

For a personal CLI the basics are enough, the first eight points on the list below. Verify and content binding cost effort here that nobody redeems.

The moment a cron job, a scheduler or an agent triggers the write, that flips. One path of mine refused thirteen runs in a row and nobody noticed, because nothing about the refusal was observable. Without content binding, the run executes whatever was handed to it last. Without verify, it doesn't know whether it did. Without server-side re-verification of consent, a self-written flag counts as approval. And without a single-use claim, one approval is valid forever. What an agent may trigger over a chat channel at all, and what token auth and least privilege look like for it, is the material in the 24/7 assistant course.

Terraform has two modes for this problem, and neither one is free. The saved plan is the content binding, but it costs you the prompt:

"When you pass a saved plan file to terraform apply, Terraform performs the operations in the saved plan without prompting you for confirmation."

Source: HashiCorp, Command: apply, retrieved 2026-07-26. The companion page for plan names the price on the other side: changes made to the target system in the meantime can change the effect of a saved plan, so the final plan should be re-checked before applying. Recompute and skip the confirmation and you've given up both: the binding to a checked plan and a human's look at it. If you want to start on the verify side, read-only first and authorization second is the base pattern in the Loop Engineering course.

One qualifier on the last checklist item. Real idempotency in the style of Stripe's idempotency keys is a server-side capability: the server recognizes the retry by its key and replays the stored first response. If your target system doesn't offer that, you can't just use it. What you can build is a client-side substitute: a consent that's redeemable exactly once, plus a state check before the write. That protects against double execution in your own house, not against a retry at the transport layer.

The 13 points for your system

Hold your own write path against these. Each point is a rule plus its reason. The first eight apply as soon as anyone writes at all. The last five apply once nobody is watching.

Always

  1. The proposal is a data object with a fail-safe default, not a code path with a brake. An object that does nothing by default stays inert after a refactoring.
  2. Exactly one gate function, called from many places, never copied. A copy diverges. My concrete breakage: an error class redeclared in a second module makes instanceof silently return false and takes the exit code split down with it.
  3. The gate sits in the seam, not in the caller. Callers change, the seam stays.
  4. A structured diff over every requested field, not just the changed ones. "No change" is information; a diff without unchanged fields is indistinguishable from a diff that forgot them.
  5. Check the target state before the write, not just the permission. Permission says "you may", not "it's still open" and not "it doesn't collide". On ambiguity, refuse instead of guessing.
  6. Audit with before and after, complete at compile time. A hand-maintained list of operations falls behind reality. The fatality of the audit write has to match its role.
  7. Destructive operations get their own, narrower allowlist. Writable does not imply deletable. New entities are opt-out, and the second confirmation runs before the first network call.
  8. Identity and least privilege via profiles instead of shared superuser rights, plus a precheck against the live ACL. And document the limits of that precheck, or it will be mistaken for a guarantee.

Additionally, once no human is watching the write

  1. Bind the apply cryptographically to the checked plan. Otherwise "the plan was checked" and "this plan is being executed" are two different statements.
  2. Verify after the write, read independently. A mismatch stays open, with no blind rewrite.
  3. Refusals are observable, not just return values. A path that refuses silently is indistinguishable from a path that had nothing to do.
  4. Re-verify authorization server-side against the source, with a sender allowlist and an age limit. Mine is 72 hours, so a Friday evening approval doesn't still write on Monday. Locally stored consent is never proof: if you can write it yourself, you haven't proven it.
  5. Consent is claimed, not read, so it's redeemable exactly once. A consent you can present any number of times is a season ticket.

Point 11 has a real operational failure behind it: 13 consecutive refused runs without a single datable artifact. From the outside that looked exactly like "there was nothing to do". The answer was a refusal trace with an alarm-fatigue brake that only writes when the reason for the refusal changes.

Line numbers drift, symbol names don't

The note this article comes from contains no code line numbers at all. Every code citation names the symbol instead, followed in brackets by a runnable search command that finds exactly that spot. The command is the check: run it and you'll see immediately whether the claim still holds.

The trigger was unpleasant. An adversarial re-check, an agent whose explicit job was to refute my citations, proved 21 line citations in my analysis wrong, three of them re-checked independently against the committed state and confirmed. The worst one pointed at a return { kind: 'ok' } while claiming a rejection path next to it, so it was inverted in substance. Three causes: the line numbers drifted twice during the work itself, part of the hits came from an uncommitted working tree, and my verification was a self-report rather than a check. I'd built a sweep that prints line contents and reported its output as verified without reading it against my own claims. A verification claim that isn't backed is worse than a missing one.

After the switch the evidence is measurable. A later commit moved the code underneath the note, and the repeat run across all 95 search commands produced 93 hits, 2 intentional zero-hit commands and 0 failures. For the second repository the head moved on from one commit to the next while I was writing, and git diff --stat across that jump shows exactly one changed file. A line number might not have survived that. A symbol name demonstrably did.

In practice it looks like this:

# instead of: see line 214
grep -n "export function assertWriteGatesOpen" src/write-gate.ts

# a claim about an ABSENCE: the command has to return 0 hits
grep -n -iE "re-read|readback|verify" src/writes.ts

The transferable rule: a reference to code belongs on a symbol, not on a coordinate, unless the coordinate is pinned to a commit. A search command moves the burden of proof where it belongs. The reader can run it instead of having to believe the note.

What this pattern is not

It's an architecture map for a write path. It isn't build instructions for a running system. Understanding the pattern isn't the same as having built it, and everything operational sits between the two: server, container isolation, tokens, channel wiring, day-to-day operation. That doesn't fit in one article, and I'm not going to pretend otherwise.

What you can decide after reading this: which of the 13 points your write path needs. The answer hangs on a single question you can answer yourself, namely whether a human is watching the write.

Related, on what happens when an agent writes that shouldn't have: Two sessions, one working tree. And if you'd rather run the model locally: A local LLM for Claude Code.

Questions I get

Isn't a dry run default enough?

Not if it's optional. Check whether your dry run is an enforced first step or just an option. If your command recomputes the plan when the apply flag is set and executes it right away, you have Terraform's automatic plan mode without the prompt.

Do I really need verify when the API reports success?

While you're watching, no. As soon as a cron job or an agent triggers it, the target system's response is only its own claim. And an absent readback isn't success, it's a mismatch.

What if I didn't write the server I'm calling?

Then stages two to six are yours anyway, because you can't add them to somebody else's system. You still control the proposal, the gate, the binding of apply to the checked plan, your own audit log and the independent readback. Verify in particular is entirely on your side: it's a second GET against an API you already call.

The handout, plus new posts by email

No fixed schedule, no recycled filler. One email when there's something worth reporting.

More in the privacy policy.