Chapter 2 of 8 · 18 min read
Gating everything that writes is how the gates stop working
On this page
An approval gate is a check placed in front of one action. It suspends the run, puts the proposed call in front of a person, and lets the run continue only when a decision comes back. The mechanism is cheap to build and the attention it consumes is not. A team holds a fixed number of operator-hours in a week, and every card issued spends a piece of them. Placing the gates is therefore a decision about where to spend attention, and it gets made before any of the mechanism exists.
The rule that makes that decision in one line is "any tool that writes requires approval", and what it substitutes is the subject of this chapter. Whether a call writes is a property of the tool's signature, visible without leaving the code. What a wrong call costs is a property of the systems the tool touches, and it has to be established by asking. A rule keyed on the first answers the placement question without consulting the second.
A support agent at a B2B analytics vendor supplies the worked example throughout this chapter: eleven tools spanning the whole of what a support agent does, from reading a ticket to ending a subscription, and a four-person rota to answer whatever the gates produce.
Eleven tools, sorted by what happens when the agent is wrong
The agent's toolset and its weekly call volumes are the material for the decision. Sorting them on "does it write" produces two groups; sorting them on what a wrong call costs produces a different and much smaller set.
| Tool | Calls/week | Worst-case reach of one call | Undo | Time to notice a bad call |
|---|---|---|---|---|
search_tickets | 4,100 | Reads up to 50 tickets in one tenant | n/a | Never, without log review |
get_customer | 3,800 | One account record | n/a | Never |
get_invoice | 900 | One invoice | n/a | Never |
search_docs | 2,400 | Public documentation | n/a | Never |
draft_reply | 1,200 | A row in the drafts table, not visible to the customer | Delete the row | At send time, by a human reading it |
add_internal_note | 1,050 | One ticket thread, seen by colleagues | Edit or delete, seconds | Minutes, by the ticket owner |
set_ticket_status | 1,600 | One ticket; may fire an SLA timer, the service-level agreement clock support is measured against | Set it back, seconds | Minutes, on the queue dashboard |
apply_account_credit | 210 | One account ledger, capped at $500 | Reversing ledger entry, seconds | Same day, on the credits report |
send_reply | 1,150 | An email in a customer's inbox, quoted forever | None | When the customer replies, or never |
issue_refund | 90 | Money leaves, up to the invoice balance | Re-charging needs consent we do not have | Days, on the settlement report |
cancel_subscription | 12 | Whole account: API keys die, dashboards go dark for every seat | Re-provision, minutes to hours; the outage stays | Immediately, loudly |
The three columns after the volume are the ones that carry the decision. Reversibility asks whether we can undo the effect unilaterally, without asking anyone's permission, and at what cost. Blast radius asks about the maximum one call can touch rather than the typical case, because the gate exists for the atypical call. Detectability asks whether a wrong action will be noticed by someone whose job it is to notice, and how quickly, since an effect that is reversible in principle and invisible in practice is irreversible in every way that matters. add_internal_note is cheap on all three. issue_refund is expensive on all three, and cancel_subscription is the only row where a single call takes out an entire tenant.
That table is best read as gaps rather than as levels. None of the three columns carries a scale you can compare across rows — "minutes, by the ticket owner" and "days, on the settlement report" are not two readings off one instrument — so the useful quantity is the sign of a comparison between two rows rather than the size of any entry. Hold add_internal_note next to issue_refund and all three comparisons run the same way: one is undoable by deleting it and the other needs consent the company does not have, one touches a thread colleagues can see and the other moves money out of the business, one is noticed within minutes by the ticket owner and the other surfaces days later on a settlement report. Three comparisons pointing the same direction is what a gated row looks like. A row where they disagree is a different case, and send_reply is that case: irreversible, narrow in reach, and detectable only if the customer chooses to reply.
The write rule's own number is in the same table, and adding it up is where the size of the problem becomes visible. Take the weekly volumes of every tool that writes anything — the draft row, the internal note, the status change, the account credit, the reply, the refund, the cancellation — and the total is 5,312 a week. That is exactly the queue the policy produces, and all but a few hundred of it comes from the four highest-volume rows in that group, none of which anyone would defend gating on its merits.
Why the rule produces that queue is worth being precise about, because "gate anything that writes" is not careless and the people who write it are not being careless. The bit it keys on is real: add_internal_note genuinely writes and get_invoice genuinely does not, so the partition is checkable and cannot be got wrong. Run the argument as a chain and every link holds. A tool that writes can change something; a change that is wrong has to be undone; therefore the tools that write are where the harm lives. What the chain never touches is how much harm, and that is the entire content of the decision. The write bit is a quantity that stays constant while the dangerous property varies. add_internal_note and cancel_subscription carry the same bit. One edits a sentence a colleague can see, and the undo is deleting it. The other kills every API key on an account and takes the dashboards down for every seat, and re-provisioning does not give the customer back the afternoon. A rule keyed on the constant cannot separate those two, and the ordering it puts on the tool list is the ordering of the signatures rather than the consequences.
Sorting on the three properties instead produces a gated set of issue_refund, cancel_subscription, and send_reply under conditions rather than always: 90 plus 12 plus roughly 8% of sends, which is 92 a week when the condition is "the reply contains a commitment our rules can match — a price, a date, a policy exception — or goes to an address that is not the ticket requester". Every term there comes off the table. issue_refund runs 90 times and cancel_subscription 12, and both contribute their whole volume because every call is gated. send_reply runs 1,150 times and contributes only the share that trips the condition, and 8% of 1,150 is the 92. Adding the three is allowed because they do not overlap — a call is one tool call, and none is simultaneously a refund and a reply — so nothing is counted twice. The total is 194 requests a week, about 28 a day across a four-person rota, or seven each, which is the figure that matters, since a queue is not experienced by a team as a total. Set 194 against 5,312 and the survivors are a little under four in every hundred: a 96% reduction in interruptions, and the whole reason the remaining ones can be read.
The cost side of that reduction is worth computing rather than asserting, because it is what makes the argument to a reviewer who wants everything gated. An approval that is genuinely read costs more than the reading: the operator is interrupted mid-ticket, loads enough context to judge the payload, decides, and returns to what they were doing. Forty-five seconds end to end is a conservative estimate for a card with four or five fields, and interruption studies in software work generally put recovery of the abandoned task well above that.
Name the quantity before putting figures into it. Operator-hours per week is cards per week multiplied by seconds per card, divided by the number of seconds in an hour. That expression has two levers in it. One is how many cards there are. The other is how long each takes to decide, and that one is not available, because a card small enough to be quick is a card nobody decides from.
At 5,312 requests a week the arithmetic runs: 5,312 cards at forty-five seconds each is a total in seconds, and dividing by the three thousand six hundred seconds in an hour gives 66 hours. Against a working week of forty hours that is 1.7 full-time people doing nothing but clearing a queue, on a four-person rota that also answers tickets. A team given that workload does not read 5,312 cards slowly; it reads none of them, and the queue becomes a throughput problem solved with the keyboard. The same expression with 194 in place of 5,312 gives 2.4 hours, since the card count is the only term that moved and the expression is linear in it. That is a load a rota absorbs without noticing, and it leaves room for a card that takes thirty seconds to read properly.
The four tools case, and why the threshold is not an amount
The reduced version of the same exercise — search tickets, draft a reply, send the reply, issue a refund — has the same answer at smaller scale. The refund is gated because money leaves and the undo requires the customer's cooperation. Sending is gated above a threshold, because a reply is irreversible in the sense that matters and yet most replies are a link to a documentation page. Searching and drafting stay ungated, since a search has no effect and a draft is a row that a human reads before anything leaves.
Two kinds of statement are doing work in that paragraph, and separating them is what makes the placement defensible later. That money leaving cannot be undone without the customer's cooperation is a requirement: a fact about card networks and consent that holds whatever anybody designs. That send_reply is gated conditionally rather than always is a design decision: the same requirement would equally support gating every send, and the team declined, because most replies are a link to a documentation page and gating all 1,150 of them puts the queue back where it started. A requirement narrows the space of acceptable designs; a decision picks a point inside what is left, and it is the decision that has to be written down with its reason attached.
The interesting part is what the threshold on sending is measured in. There is no amount on a send_reply call, so the condition has to be built from something else, and the useful predicates are about consequence rather than content quality.
That preference is the second design decision here, and it keeps the predicate affordable. A predicate on content quality asks whether the reply is any good, which no pattern answers and which a model would have to be consulted about on every send. A predicate on consequence asks what the reply commits the company to, and commitments have surface forms — a price, a date, a named policy exception — that a pattern can match. Consequence also fails legibly, because a complaint traces back to a branch of a rule rather than to a judgement nobody recorded.
COMMITMENT = re.compile(
r"\b(refund|credit|discount|SLA|by (Monday|Tuesday|Friday)|"
r"we will (?:ship|deliver|waive|extend))\b", re.I
)
def send_needs_approval(ticket, draft) -> tuple[bool, str]:
if draft.to != ticket.requester_email:
return True, "recipient is not the ticket requester"
if COMMITMENT.search(draft.body):
return True, "reply contains a commitment we would have to honour"
if ticket.account.tier == "enterprise" and ticket.reply_count == 0:
return True, "first response on an enterprise account"
if ticket.escalation_flags:
return True, "ticket is flagged: %s" % ",".join(ticket.escalation_flags)
return False, "routine reply to the requester on an unflagged ticket"The second element of that tuple is not decoration. Every call, gated or not, records why, which means the ungated 92% of sends carry a written reason for having been ungated, and a month later you can query which reason is attached to the sends that later produced a complaint. That is the difference between an exclusion you chose and an exclusion that is simply the absence of a rule.
What that record licenses is narrower than it feels. Given a set of sends that later produced a complaint, it answers which branch of the predicate let each one through, without anyone reconstructing the state of the ticket months afterwards. Sends carrying no reason support no query at all, so an exclusion that leaves no trace cannot be examined by anybody, including the person who chose it.
The residual is that the field records why the rule fired, not whether the rule was right. A complaint whose send carries routine reply to the requester on an unflagged ticket establishes only that the predicate ran to its last line and found nothing to object to. Whether it should have found something is a question the field cannot answer, because the field is the predicate's own report on itself. What it does is localise the defect, naming the branch responsible so the fix has an address. Someone still has to read the send and decide the predicate was wrong about it, and nothing in the record makes that happen.
Defending an exclusion means naming the control that replaced it
The rule that sounds conservative is attractive because it can be written by someone who does not know the domain, it survives a review meeting without argument, and it moves responsibility onto whoever clicks. Its cost arrives later and lands on different people. When an exclusion is challenged, "it seemed low risk" is not an answer; what makes an exclusion defensible is that some other control is doing the work the gate would have done.
| Excluded action | Control that replaces the gate |
|---|---|
apply_account_credit | Hard cap of $500 per call enforced server-side, 10 per day per agent identity, daily credits report to the finance channel |
set_ticket_status | Reversible in one click by the ticket owner, who is notified; SLA timers recomputed rather than lost |
send_reply (routine) | 10% sampled post-hoc review within 24 hours, plus every reply visible in the thread the customer can answer |
draft_reply | No external effect; a human reads it at the send gate, which is where the real decision lives |
search_tickets | Tenant-scoped credential, 50-row cap, queries logged; the control is scope, not friction |
This table is read as gaps too, and the gap is not between more control and less. Every row already names something doing the gate's work: a server-side cap, a one-click undo with a notification, a sampled review, a downstream gate, a scoped credential. What carries weight is the difference between a row that exists and a row that does not. An exclusion with a named control is a claim — apply_account_credit is safe ungated because the cap is enforced server-side and finance sees the daily report — and a claim can be shown to be wrong, which is what makes it worth having. An exclusion with no row is the absence of one.
Recording those replacements next to the policy is what turns a set of decisions into something a later reviewer can audit, and the format matters less than the fact that each exclusion has an owner and a date. A row that says set_ticket_status: ungated — reversible in one click by the ticket owner, who is notified; reviewed 2026-02-14 by r.okoye can be challenged on its merits. An exclusion that exists because nobody wrote a rule cannot be challenged at all, because there is nothing to disagree with; it surfaces only in an incident, where it reads as an oversight regardless of whether it was the right call.
The reads are worth stating plainly because "gate anything that writes" implies reads are safe and they are not. A search that pulls fifty tickets from the wrong tenant is a disclosure, and the reason it is not on the gated list is that an approval would not help: an operator shown search_tickets(query="password reset", tenant="acme") has no way to judge what the results will be used for, and would approve four thousand of them a week. The right control for a read is the scoping work from the architectural mitigations topic, which constrains what the credential can reach at all.
Why the approval would not help is a chain worth walking, since adding a gate is the reflex and it fails here for a reason that generalises past searches. A card can only display what is known when the call is proposed, which for a search is a query string and a tenant. The harm of a search is not in the query. It is in which rows come back and what the run subsequently does with them, and both happen after the call. So the card carries no fact the decision needs, which leaves the operator one available action, which is to approve. A gate whose only reachable outcome is approval filters nothing, and the volume it adds drives the median decision time down on every other card in the queue. A control that works on a read acts on the call rather than on the person: a tenant-scoped credential makes the wrong-tenant disclosure impossible rather than asking somebody to notice it.
Where this framing runs out
A gate set is not a privilege boundary. Deciding that cancel_subscription needs approval does nothing about the fact that the agent holds a credential that can cancel any subscription in the estate; if the gate is bypassed by a code path nobody thought about, the authority is still there. Gates and scopes answer different questions, and a team that has done the gate exercise well can still have an agent whose worst case is unbounded.
Thresholds also decay in a way that fixed rules do not. The regex above matches the commitments people were writing in March; by September the team has a new promotional credit with a name the pattern does not contain, and the sends carrying it are ungated by omission. The maintenance obligation is real and cheap to forget, which argues for reviewing the rule against a sample of recent sends on a schedule — quarterly is enough at these volumes — and for treating a class of complaint that came through an ungated path as a defect in the rule rather than an unlucky outcome.
The honest limitation is that reversibility and blast radius are estimates made before the fact, and the estimate can be wrong in a direction nobody tests. set_ticket_status looked internal until the day a status change to resolved triggered an automated satisfaction survey to 1,600 customers, at which point a tool that had been correctly excluded on Monday's model of the system was badly excluded on Tuesday's. The dependency is on downstream automation you do not own, and the mitigation is to re-run the sort whenever an integration is added rather than only when the agent changes.
The shape of that failure is worth naming, because the estimate was not sloppy. Blast radius is a claim about the maximum one call can touch, and what a call touches is settled by everything downstream of it — a set the person doing the sort cannot see. set_ticket_status had a radius of one ticket for exactly as long as nothing was listening for status changes, and attaching a listener changed the radius of a tool whose code nobody edited. That is why the trigger for re-running the sort is an integration landing rather than a change to the agent: the agent is the part under watch, and the part that moved is not.
The substitution this chapter refuses is the one that makes the write rule attractive. A write bit is available, checkable and free, and the three properties that decide what a wrong call costs are none of those things: reversibility has to be established by asking whether the undo needs somebody else's consent, blast radius taken at the maximum rather than the typical case, detectability checked against a person whose job it is to notice rather than a log that merely exists. That work on eleven rows takes an afternoon and yields a gated set of three. Skipping it yields a set of seven, a queue of 5,312 a week that nobody reads, and a policy whose only defence is that everything was gated.
Getting the set down to 194 a week is necessary and it is not sufficient. Twenty-eight requests a day can still be cleared in three seconds each if what arrives on the operator's screen is issue_refund — approve?, because a card that does not contain the facts of the decision produces a reflex regardless of how rare it is. The second lever is what the request puts in front of the person, and it is the one teams reach for last.
Human in the loop — read the current version at https://daviddominguez.dev/learn/en/human-in-the-loop/gating-everything-that-writes-is-how-the-gates-stop-working