Chapter 3 of 8 · 18 min read
An approval nobody can decide in context becomes a reflex
On this page
An approval gate is a check placed in front of one action that suspends an agent's run, sends a request to whoever is on duty, and lets the run continue only when a decision comes back. What that person sees is a card: a rendered screen, chat message or email naming the action, the arguments it will execute with, and the buttons that decide it. Choosing which actions produce a card is one design decision, made by whoever sorted the toolset on what a wrong call costs. Choosing what goes on the card is a second, usually made later and by different people, and it decides whether the gate does anything at all.
The belief that goes wrong here is that an accurate card is a sufficient card, and it is tempting for a good reason: accuracy is checkable and completeness is not. You can verify that the amount on the card matches the amount in the request, that the invoice identifier resolves, that the recipient is spelled the way the account record spells it. Having verified all of that you have established nothing about whether the person reading it can reach a decision. A card can be true in every field and still leave the decision unmade.
A decision is a comparison, and a payload is one term of it
Start from what the person is actually being asked to do, because it is narrower than it looks. By the time a card is rendered, two questions have already been answered elsewhere. Whether the agent may call this tool at all was answered when its credentials and tool list were scoped. Whether this particular person may authorise this class of action was answered by the policy layer that decides who gets shown which cards. Neither is on the operator's plate. What is left is one question about one instance: is this the call that should not go through?
That question cannot be answered from the instance alone. Calling something anomalous is a statement about its relation to something else — a rule, a history, a previous version of itself — so the judgement needs two terms, and the payload supplies one. When the card carries nothing but the payload, the operator has to produce the second term from memory. Memory is good at general terms and bad at particular ones: somebody nine months into the job can recite a refund policy without effort and cannot recall which of several thousand invoices already carries a credit against it.
That gap has a sharp form, and naming it is what makes a sparse card stop feeling safe. The set of fields the card renders is fixed. A line reading issue_refund — invoice in_88213 — $12,400.00 looks identical whether or not that invoice was credited in full a fortnight ago, whether or not the account is in dispute, whether the requester is the verified account contact or somebody who emailed in this morning. The quantity the operator is shown stays constant while the property that decides the answer varies from card to card, and a control whose reading does not move when the danger moves is not measuring the danger. Fields are not decoration on that view; they are the only way to make what the operator sees vary with the thing they are judging.
Four things a decision needs, and what happens when each is missing
Narrowing the gate set gets the request rate down to something a person can attend to. What arrives at that rate then has to be answerable, and answerable has structure to it. The four elements below are what a person needs to reach a decision without leaving the surface they are looking at.
| Element | What it looks like when present | What the operator does without it |
|---|---|---|
| The exact payload | Every argument that will execute, in full, rendered from resolved state | Approves a tool name and an amount, checking neither against the case |
| Reach and reversibility | "Money leaves; recovering it needs the customer's consent" | Applies the same caution to a refund as to a status change |
| The diff | What changed against the expected value, the current state, or a previous approval | Cannot tell a routine call from an anomalous one, since both look like a filled-in form |
| The default on silence | "Expires in 30 minutes, then cancels and pages the billing owner" | Treats every card as urgent, or treats none as urgent, depending on temperament |
The reach-and-reversibility line is not new work, because it is the same sort from the previous chapter, carried forward. Having decided that issue_refund is gated because money leaves and the undo requires the customer's cooperation, that sentence is the one to print on the card. Teams write the reasoning into a design document and then render a card that omits it, which leaves each operator to reconstruct the risk model from the tool name, twenty-eight times a day.
The last row changes behaviour more than its length suggests. An operator who does not know what happens if they close the tab has to assume the worst, which is that the customer is waiting and the run is stuck, and that assumption pushes every borderline card toward approve. Printing expires 30 min, cancels, pages billing on-call converts closing the tab into a legitimate move with a known consequence, which is what makes it possible to not decide when you genuinely cannot. The policy behind that sentence is the subject of a later chapter; the sentence itself belongs on the card from the first day the gate exists.
The diff is the element most often absent and the one that does the most work, because it is what turns a form into a signal. A refund of $80 against a $80 invoice and a refund of $12,400 against an account whose last four refunds averaged $95 are the same shape of payload and completely different decisions, and only one of them should look like anything. Diffs come in three flavours worth rendering distinctly: against the expected value that a rule or a prior computation produced, against the current state of the world, and against what was already approved once. The third is the one the payload binding from the first chapter generates for free — when a retry re-enters the gate because the recipient changed, the card knows exactly which field moved and can say so.
The diff earns that ranking by being the only one of the four elements that is itself a second term. Reach and reversibility describe an action class and read the same on every card of that class. The default on silence describes a policy and reads the same on every card under that policy. The payload describes the instance. Only the diff puts the instance beside something else, and comparison is the operation the decision consists of. A card without one is asking for a judgement and supplying the material for a recognition, which is why the fourth column of the table above says the operator cannot tell a routine call from an anomalous one: both arrive as a filled-in form, and a filled-in form is what a routine call looks like.
The same decision, rendered twice
The card below is a real one. It authorised a refund of $12,400 against an invoice that had already been credited in full, and every field on it is correct.
issue_refund
invoice_id in_88213
amount $12,400.00
[Approve] [Reject]And here is the version the same system can produce from state it already has, with no additional integrations and one extra query against the ledger.
REFUND $12,400.00 — Northwind Analytics (enterprise, customer since 2021)
⚠ ALREADY CREDITED: invoice in_88213 was credited in full
on 2026-02-26 by apply_account_credit (ref cr_5512, $12,400.00)
invoice in_88213 · issued 2026-02-11 · $12,400.00 · status PAID
refunding full balance to card •••4417
ticket #88213 "duplicate charge on February invoice"
requested by j.wu@northwind.example (verified account contact)
reach money leaves our account today
undo re-charging needs the customer's consent — treat as final
precedent 3 refunds to this account in 90 days, totalling $18,240
this refund is 130x the median refund on this queue
if nobody answers: expires 30 min, cancels, pages billing on-call
[Approve] [Reject — reason required] [Ask billing]The two lines in that precedent block are doing different jobs, and collapsing them into one number is how a card gets longer without getting better. The first counts this account's own refunds over the preceding quarter and totals them, which answers whether this customer is unusual. The second divides the refund by the median refund across the whole support queue, which answers whether this amount is unusual. Those are different denominators and different questions, and an account can be ordinary on one and extreme on the other. The multiple is also a floor rather than a level: the code below computes it with integer division, so the printed 130x is the largest whole multiple that fits and the true ratio is a little higher. Erring low is the right direction for a warning and the wrong direction for a summary statistic, and the comparison drawn a few paragraphs above — a refund of $12,400 against an account whose last four refunds averaged $95 — happens to land on almost the same multiple, which is a coincidence of this case rather than a relationship between the two statistics.
The second card takes about thirty seconds to read attentively. At twenty-eight cards a day across a four-person rota, that is roughly three and a half minutes each per day of actual reading, which is a budget any team can afford and is only affordable because of the work in the previous chapter. The two designs are coupled: a rich card at 5,312 requests a week is worse than a sparse one, because it takes longer to skip.
That arithmetic closes exactly, and doing it rather than asserting it is the whole argument for spending a fortnight on rendering. Twenty-eight cards at thirty seconds each is fourteen minutes of attentive reading in a day. Spread across four people that is three and a half minutes each, which disappears into a working day without anyone negotiating for it. Run the same multiplication against the unnarrowed queue and the rich card becomes the most expensive thing the team does, which is why the two levers are one design rather than two options: the rendering only pays because the sort in the previous chapter removed the cards that did not deserve it. The residual is that fourteen minutes is a total and three and a half minutes is a mean, and a mean over a rota holds only while cards distribute evenly across that rota. The same day's reading landing on one person is not three and a half minutes, it is fourteen, and that person will read the way somebody holding fourteen minutes of queue reads.
The order of the lines is deliberate. The warning is at the top because the card will be read on a phone at 22:40 by whoever is on call, and everything below the fold is optional reading. Cards that bury the deciding fact in a fifteen-field table are technically complete and functionally the same as the sparse version, since attention is spent on the first two lines regardless of what follows.
Assembling those facts is ordinary application code, and the volume of it is the reason teams skip it.
def refund_card(invoice, ticket, account, ledger) -> Card:
facts, warnings = [], []
prior = ledger.credits_against(invoice.id)
if prior:
warnings.append(
f"ALREADY CREDITED: invoice {invoice.id} was credited in full on "
f"{prior.date} by apply_account_credit (ref {prior.id}, {money(prior.amount)})"
)
recent = ledger.refunds_for(account.id, days=90)
facts.append(
f"{len(recent)} refunds to this account in 90 days, "
f"totalling {money(sum(r.amount for r in recent))}"
)
median = ledger.median_refund(queue="support", days=30)
if invoice.balance > 20 * median:
facts.append(f"this refund is {invoice.balance // median}x the median on this queue")
return Card(
headline=f"REFUND {money(invoice.balance)} — {account.name}",
warnings=warnings,
payload=render_payload(invoice, ticket),
reach="money leaves our account today",
undo="re-charging needs the customer's consent — treat as final",
precedent=facts,
expiry=Expiry(minutes=30, on_expire="cancel", notify="billing-oncall"),
)Every value in there is read back from the ledger, the invoice and the ticket. None of it is produced by the model, which is the rule the architectural mitigations topic established for confirmation prompts and which applies with more force here: the model chose the action, so it is the last thing that should be describing the action.
The general rule underneath that decides more than one card, and it is worth stating in a form you can apply to a field you have not met yet: every value on an approval request should be read back from a system the model cannot write to. The ledger satisfies it, the invoice table satisfies it, the ticket's requester field satisfies it. A sentence the agent composed does not, and neither does a value derived from one. The test is not whether the value looks trustworthy on the screen but whether there exists a path, however indirect, from something the model emitted to something the operator will read as evidence.
A model-written rationale raises the approval rate and not the catch rate
The tempting addition to a card is the agent's own explanation — a sentence or two on why it wants to do this. It reads well, it makes the card feel informative, and it is the single change most likely to make the gate worse. When one team ran it as an experiment across 600 refund cards, adding a two-sentence model rationale moved the approval rate from 91% to 97% and left the catch rate — the share of deliberately seeded bad payloads that operators rejected — unchanged at around one in eight. The rationale did not add information the operator could check; it added fluency, and fluency reads as competence.
Those two measurements are worth taking apart, because at first glance the result looks like the rationale helped. Approval rate is what operators did. Catch rate is what they caught, measured against payloads that were seeded to be wrong and are therefore known to be wrong without reference to anyone's judgement. Had the rationale carried information about the payload, both numbers would have moved: more of the good payloads approved and more of the bad ones rejected. Only the first moved, so whatever the rationale supplied was not evidence about this call. It was a signal about the thing that produced the call, and confident, specific, well-formed prose is what a competent colleague sounds like. Competence is precisely the property the operator is trying to assess, which is how a field can shift the estimate without touching anything the estimate is supposed to be of.
The structural version of the same objection is that a rationale is a field whose contents are influenced by whatever went into the model's context, including text the agent read from a ticket. An attacker who can write a ticket body can shape the sentence that explains the action to the person authorising it. Facts read back from your own systems do not have that property, which is why the good card above contains no prose the model wrote.
That path is short enough to check link by link, and checking it is what turns an uneasy feeling into a rule. The rationale is emitted by the model. The model emits it from its context. The context holds whatever the agent read while working the case, and on a support agent that includes the body of the ticket. The body of the ticket is written by whoever opened it, who is by construction outside the company. So a stranger with no access to anything can write text that will be read by the model and paraphrased into the sentence that explains an irreversible action to the person authorising it — and the card exists to close exactly the gap the rationale reopens.
The whole artefact is easiest to hold through an analogy, and it is worth flagging as an analogy so that nobody takes it for a claim about the system: a card is a code review with a thirty-second budget and no comment thread. A reviewer looking at a pull request has the diff, the surrounding file, the CI result and the ability to ask a question and come back tomorrow. An operator has one screen and no conversation.
Where that comparison breaks is where the card design comes from. A reviewer can go and find context, so a pull request does not have to carry it; an operator cannot, so everything the reviewer would have gone and found has to be pre-fetched onto the card. A reviewer can leave a comment and wait, so a review has an outcome that is neither approve nor reject; an operator has no such outcome unless somebody builds one, so the things a reviewer would have asked about have to become buttons. The third button in the good card — Ask billing — exists because the honest answer to some cards is neither approve nor reject, and a two-button design forces that operator into one of the two, which in practice is the one that keeps the queue moving. Pushed any further the analogy misleads: a reviewer is judging code they can read in full and run, while an operator is judging an effect they cannot see and cannot try, which is why a card has to carry consequences on its face and a pull request does not.
When the ops lead says the agent has earned it
The situation that this design is the answer to looks like a compliment: 97% of gates approved, a median time to decision of 4 seconds, and volume tripled since launch. The tripling is the fact that explains the other two, since the rate at which cards arrive determines how much attention each one gets, and 4 seconds is below the time it takes to read a card that has anything on it. The response is to work both levers — the set of actions that produce cards, and the content of the cards that remain — before drawing any conclusion about the agent.
The card lever is the cheaper of the two and it is bounded: past a certain density, adding fields lowers the fraction that gets read. Somewhere around six to eight lines plus a warning is where teams tend to land, and the discipline is to earn each line by asking what decision changes if it is absent. A field that never changes anyone's answer is noise wearing the costume of diligence.
That bound has a mechanism under it rather than a folk number. A line costs every reader a fixed slice of a fixed budget and it pays only on the cards where it changes the answer, so its cost is certain and recurring while its value is a small probability times a large saving. Past the point where those cross, another line lowers the share of the card that gets read at all, which lowers the chance that the deciding line is inside the part that was read. That is why the discipline is subtractive, and why the question to ask of a candidate field is not whether it is true or interesting but how often it moves a decision.
What a better card does not do is make an operator competent to decide something they have no basis for judging. Routing matters as much as rendering: a refund card sent to a rota with no visibility of billing produces approvals whatever is printed on it, because the person has no independent source of truth to check the facts against. Cards should go to the smallest group that can actually evaluate them, and if no such group exists, the honest conclusion is that the action should not be gated at all — it should be refused, or the workflow should be redesigned so that a person with the context is the one initiating it.
Cards are also worth versioning, because they change and because a decision made against version 3 of the refund card is not evidence about version 5. Storing a card identifier and its rendered bytes with each approval, as the first chapter's record already does, lets you ask whether the operators who saw the precedent block behaved differently from the ones who did not, which is the only way to find out whether a field earns its place. Teams that skip this end up arguing about card design from memory and adding fields to settle the argument, which is how a six-line card becomes a twenty-two-line one over two quarters.
Reject-with-reason is worth insisting on for a reason that only pays off later. A rejection with no reason is a data point that says the number 1 and nothing else, while a corpus of reasons is the material that tells you whether rejections cluster on an action class, an account tier, a time of day, or a single operator's judgement. Those clusters are what a later chapter uses to decide whether a gate is working.
A request nobody can decide in context becomes a reflex, and the mechanism is not laziness on anyone's part. Somebody asked to judge whether an instance is anomalous, and handed nothing but the instance, falls back on the only comparison available to them, which is against the class: refunds are a thing the company does, so approve. Each element of a decidable request exists to supply the term the payload cannot. The reach line names the class of harm in play. The default-on-silence line makes not deciding into a legitimate move with a stated cost. The diff sets this instance beside a rule, a state or an earlier approval, so that a routine call and an anomalous one stop looking alike on the screen. And all of it is read back from systems the model cannot write to, since a field the model influenced measures the model's fluency rather than the world.
Both levers so far have assumed the operator's only moves are yes and no. Support teams that have used a gate for a month start asking for a third: the refund is right and the amount is wrong, the reply is right and the date is wrong, and rejecting sends the whole run back to a model that will probably make the same choice again. Letting them fix it is better feedback and a wider hole.
Human in the loop — read the current version at https://daviddominguez.dev/learn/en/human-in-the-loop/an-approval-nobody-can-decide-in-context-becomes-a-reflex