Skip to content
Chapter 4 of 7 · Architectural mitigationsPlacing a confirmation gate

14 min read

Placing a confirmation gate

On this page

Given one human confirmation in a sequence of automated steps, the placement decides almost everything the control is worth. Put it where the change feels final and it fires before the parameters exist, so the reviewer is approving an intention. Put it after the irreversible step and it is a notification. There is one position that is neither, and finding it is a mechanical exercise rather than a judgement call.

Reversibility is the criterion, and it needs stating carefully, because the intuitive version is about the commit graph and the useful version is about the world. In the mental model most engineers carry, merging to main is the moment a change becomes real: it is the point after which the change is present in the line everyone builds from. As a security property, reversibility is about external effects — what has happened outside your systems, or inside them in a way you cannot unwind, by the time you would like to change your mind. Those two orderings are not the same, and where they disagree, the second one is the one an attacker is working against.

The reason the two orderings come apart is worth deriving rather than accepting, because the derivation is what lets you sort a sequence you have never seen. An undo is an operation that puts the world back into a state it was in before, so whether one exists depends entirely on who holds the state you would have to change. A commit graph is a data structure your team owns end to end, and any state it has ever been in can be written into it again, which is why a revert is seconds of work and leaves nothing but history. A request that has been served is held by whoever received the response. A row a customer's integration has already read is held by that integration. An email is held by an inbox on a server you have no account on. For each of those the only mechanism available to you is asking somebody else to act, and asking is not an operation. So reversibility is really an ordering by custody, and feeling final is an ordering by visibility to colleagues — the merge feels final because everyone can now see it. Those two agree most of the time and they come apart in both directions, which is the part to watch for: a step that looks internal hands state to an outside party the moment it fires a webhook or a third-party analytics call, and a step that feels momentous can be entirely inside a store you own and therefore free to undo.

Sorting a deployment sequence by external effect

Take a deployment assistant that triages an issue, opens a pull request, merges it, builds an image, runs migrations and shifts traffic. Sorting those steps on external effect puts the gate in a different place than sorting them on how final each one feels.

StepExternal effect at this pointUndoAre the parameters known yet?
Open PRNone; a branch and a diff existClose the PRYes — the diff is fully resolved
MergeNone outside the repositorygit revert, secondsYes
Build imageAn artifact in the registryDelete the tagYes, plus the digest
Run migrationsSchema changed; a column may be goneOnly if the migration is reversible, and forward-only ones are notYes — the exact migration list
Shift trafficRequests served, data written, webhooks fired to third partiesRoll back the image; the served traffic stays servedYes
Notify customersEmail delivered to inboxes you do not controlNoneYes

The gate belongs immediately before the first row whose undo column is not a clean operation, which here is the migration-and-rollout stage. That is also the latest point at which every parameter a reviewer would want is resolved: the image digest exists, the migration files are enumerated, the target environment and traffic percentage are decided. Gating earlier costs information — a gate at PR-open time can only tell the reviewer what the assistant intends to do, and intent is exactly the thing an injection rewrites between the prompt and the act.

Flowchart: Issue triaged → Open PR; Open PR → Merge; Merge → Build image; Build image → Confirm: digest, migrations, env, traffic; Confirm: digest, migrations, env, traffic → Run migrations (approve); Run migrations → Shift 100% traffic; Confirm: digest, migrations, env, traffic → Stop, no external effect (reject).

Calling the placement mechanical is a claim that there is exactly one right answer, and the reason there is comes from two quantities moving in opposite directions along the sequence. Information available to the reviewer only grows: each step resolves parameters — the digest exists after the build, the migration list after the merge — and no step ever unresolves one, so later is always better on that axis. Authority to stop only shrinks, and it goes to zero at the first irreversible step, since after that a rejection is a notification. Better-later and useless-after-a-point leave one position that satisfies both, which is immediately before the first irreversible step, and it is unique because the parameter set is monotone increasing and irreversibility begins once. That also says what to do when the two collide — when some parameter is only resolved by the irreversible step itself. The answer is not to move the gate later but to split the step, doing the part that resolves the parameter separately from the part that commits it, which is the same reason a deployment pipeline builds an image before it deploys one.

The same sort applies to the file-reorganisation case that comes up whenever a team is deciding this for a shared drive. The gate does not go on "the agent has decided to tidy the folder", and it does not go after the first move either. It goes after the agent has resolved which files it will move and where — the full source-to-destination list, with real names — and before the first move executes, because on a shared drive the first move is already visible to everyone else with access and may already have broken someone's link.

The prompt has to name things, and the things have to come from you

A gate placed correctly still decays if the prompt is generic, because a reviewer confirming Deploy to production? has nothing to check against. What makes a gate reviewable is that it states resolved values a person can recognise as right or wrong at a glance.

text
Deploy checkout-api to PRODUCTION

  image      sha256:9f3c1a…be07  (built from 4471, merged 4 min ago)
  migrations 2 forward-only:
               20260311_drop_legacy_promo_code   [DESTRUCTIVE: drops column]
               20260311_add_settlement_idx
  traffic    0% -> 100%, no canary stage configured
  manifest   3 changed keys vs live:
               egress.allowedHosts: ["billing-facade.internal"] -> ["*"]
               resources.limits.memory: 512Mi -> 512Mi
               replicas: 6 -> 6

Type the environment name to approve:

The egress.allowedHosts line is the one that earns the whole gate, and it appears because the prompt diffs the resolved manifest against what is live and shows every key that differs. Nobody had to anticipate that attack for the line to be there. A useful heuristic for what to put in the prompt is to include whatever the reviewer would have to open a second window to find out, and to mark the entries that are irreversible so the eye lands on them first.

There is a subtler requirement, which is that every value in that prompt must be rendered by your own code from resolved state, never from a summary the model produced. A prompt whose body is a model-generated description of the change is an attacker-writable field: the same injection that adds the egress key can add a sentence instructing the assistant to describe the change as a test fix, and it will, convincingly. The rule is that the model may choose the action and may not narrate it to the approver.

python
def render_confirmation(plan: DeployPlan, live: LiveState) -> str:
    # every field below is read back from the registry, the migration
    # directory and the cluster — none of it comes from the model
    lines = [f"Deploy {plan.service} to {plan.environment.upper()}", ""]
    lines.append(f"  image      {registry.digest(plan.image_tag)}")
    for m in migrations.pending(plan.image_tag):
        flag = "  [DESTRUCTIVE]" if m.is_destructive else ""
        lines.append(f"             {m.name}{flag}")
    for key, (old, new) in live.diff(plan.manifest).items():
        lines.append(f"             {key}: {old!r} -> {new!r}")
    return "\n".join(lines)

What 1.4 seconds means

A gate in the right position, with a prompt full of resolved values, still measured a median time-to-approve of 1.4 seconds and a rejection rate of 0.3% over 1,180 approvals on one deployment assistant. Read that against how long the prompt takes to read: the block above is about 70 words with structure, and at a generous 300 words per minute of skimming, a reviewer who actually looked at it would spend somewhere north of 10 seconds. A 1.4-second median is a recognition-and-click response — the notification arrives, it looks like the one that arrived yesterday, the muscle memory fires. The gate is still a gate in the sense that the pipeline stops, and it has stopped being a review.

Those two quantities are in different units, so the comparison needs an exchange rate, and the chapter's own figures supply one. Three hundred words a minute is five words a second, because a minute is sixty seconds and three hundred divided by sixty is five. Seventy words at five words a second is fourteen seconds of skimming, and that is with the rate set generously and with no time allowed for the part that actually matters — recognising that a migration is destructive, or that a host list went from one entry to a wildcard, neither of which is reading in the ordinary sense. Set the observed median against it: one and four tenths of a second is a tenth of fourteen. So the reviewer is spending about a tenth of the time the prompt would take to skim, which is not a slow reader or a fast one but somebody who is not reading. The rejection figure agrees from the other side. Three tenths of one per cent of one thousand one hundred and eighty is between three and four rejections across the whole quarter, and a control that says no three or four times in three months is either guarding a process that never goes wrong or is not being consulted.

The diagnosis worth reaching for first is frequency, because approval fatigue is a rate problem before it is an attention problem. At 1,180 approvals in a quarter, the on-call rotation was seeing roughly 13 a day, nearly all of them routine deploys of a service that changes twice a day and whose manifests never differ. A control that fires on the ordinary case trains people to dismiss it, and no amount of prompt design survives that rate.

The changes that follow from the diagnosis are about making the gate rare and the remaining prompts genuinely variable. Define an envelope of pre-approved conditions — no destructive migrations, no manifest key changes outside a known-safe set, a canary stage configured (a first slice of traffic, a few per cent, routed to the new version while the old one still serves the rest), deploying during business hours — and let anything inside it proceed without a prompt, with an audit record and a Slack notification that is explicitly informational and has no button. Everything outside the envelope prompts, and now the prompt means something, because its existence already tells the reviewer that this deploy is unusual.

python
SAFE_MANIFEST_KEYS = {"replicas", "resources.limits.cpu", "image.tag"}

def requires_confirmation(plan, live) -> bool:
    if any(m.is_destructive for m in migrations.pending(plan.image_tag)):
        return True
    if set(live.diff(plan.manifest)) - SAFE_MANIFEST_KEYS:
        return True
    if plan.traffic_step > 25:            # no canary
        return True
    return False

Applied to the same quarter's history, that predicate fires on 41 of the 1,180 deploys — roughly three a week against thirteen a day, which is a rate at which reading is plausible. It is worth measuring afterwards whether the median time-to-approve moves: if it stays near two seconds even at three prompts a week, the problem is not fatigue and the prompt itself needs work.

Both of those rates come out of the same quarter, and converting them into the same unit is what makes the change legible. A quarter is about ninety days, so one thousand one hundred and eighty approvals across it is a little over thirteen a day, which is the figure quoted. A quarter is also thirteen weeks, so forty-one firings across it is a little over three a week. Between them the prompt arrives roughly thirty times less often, and the useful way to read that is against the fourteen seconds the prompt takes to skim: at thirteen a day the reviewer would spend some minutes a day reading confirmations for deploys that never differ, and at three a week the same reading is a rounding error in anybody's afternoon. The envelope did not make the reviewer more diligent. It removed the deploys on which diligence had nothing to find, and what remains is a prompt whose mere existence carries information, because inside the envelope no prompt is sent at all.

Answering the engineer who wants it deleted

The argument for removal is coherent as far as it goes: a control nobody reads provides no assurance, it costs a median of some minutes of pipeline latency, and pretending otherwise is worse than being honest about the gap. Where it fails is in treating an observed 1.4 seconds as a property of gates rather than as a property of one gate at 13 prompts a day, which is a fixable configuration and not a law.

The gate also does two things that are invisible in the median. It is the point at which the action becomes attributable to a named person, which is what makes an audit trail a chain of custody rather than a log of automation; after removal, the honest answer to "who authorised the change that dropped the column" is "nobody, and nobody could have". And its value lives entirely in the tail, on the deploys that look wrong — a destructive migration nobody expected, an egress policy that went to *. A control whose payoff is concentrated in rare events cannot be evaluated by its behaviour on common ones, which is the same reason nobody argues for removing a circuit breaker because it has not tripped this year.

What is not a good answer to that engineer is to leave the gate at 13 prompts a day and call it defence in depth, the principle that several independent and individually imperfect controls in series are worth more than any one of them alone. A gate that is not read is a control on paper, and if the team will not accept the envelope work that makes it rare, they should say plainly that the gate is a latency tax and decide with open eyes.

The limits of putting a person in the path

Human confirmation constrains the moment of action and nothing else. It does not repair the architecture underneath: if the agent holds ambient authority over three databases, a gate on the deploy stops one action while leaving every read unconstrained, and gates do not compose into least privilege however many you add.

It is also weakest exactly where agents are strongest, which is throughput. Any gate that fires often enough to be routine is a click, and any gate rare enough to be read is one the attacker will simply avoid triggering by staying inside the envelope you defined. That is not an argument against envelopes; it is the reason the envelope has to be drawn around irreversibility rather than around what feels risky.

The deeper dependency is that the reviewer is judging values — a migration name, a host list, an amount, a recipient — and those values reached the prompt from somewhere. If the sequence that produced them let attacker-authored text into the context that also holds the privileged tools, the attacker has had a hand in choosing what the human is looking at, and a plausible-looking value is one of the cheapest things to produce.

So the gate is a bound on the last step and not a repair of the path leading to it. Keeping attacker-authored text out of the privileged context entirely is the structural version of the same concern, it is a stronger property than anything a display can offer, and it has its own failure modes.

Architectural mitigations — read the current version at https://daviddominguez.dev/learn/en/architectural-mitigations/placing-a-confirmation-gate

Back to top