Skip to content

17 min read

An unanswered approval needs a default, an owner and an expiry

On this page

An approval gate is a check placed in front of one action that suspends a run, sends a request to a person, and lets the run continue only when a decision comes back. Almost all of the design attention goes to the two buttons, because approve and reject are the outcomes that produce visible work. The state between them is where runs actually spend their time. A request that has been issued and not yet decided is pending, and pending is not a pause in the sense of a function waiting on a return value. It is a stretch of wall-clock time, sometimes seconds and sometimes days, during which a run holds an unexecuted action, a customer holds an expectation, and the facts the decision rested on carry on changing without anyone telling the run about it.

That last clause is the reason silence has to be designed rather than merely tolerated. An unanswered request does no damage in its first minute and steadily more after that, because everything an operator would be deciding about — an invoice balance, an account's standing, whether a charge is under dispute — lives in systems that have no idea a run is waiting on them. Three fields fix where that damage stops. The window is how long the request stays answerable. The expiry action is what the system does when the window closes without a decision. The owner is the single person the request is addressed to and chargeable against. Any one of them left blank hands the decision to whoever happens to scroll past, and that person arrives with none of the context the design assumed.

Pending is a state, and it needs the same design as the others

Approval flows are usually drawn with two outcomes because those are the two buttons. The states that actually occur are more numerous, and the ones with no button are where runs go to sit.

State diagram: start → pending (gate fires); pending → approved (operator decides); pending → rejected (operator decides); pending → expired (window elapses); pending → superseded (world changed); expired → escalated (policy = escalate); expired → cancelled (policy = cancel); escalated → pending (reissued to named owner); escalated → cancelled (chain exhausted); approved → executing (preconditions revalidated); approved → superseded (preconditions failed); executing → start; rejected → start; cancelled → start; superseded → start.

Two of those transitions are the ones teams add only after an incident. superseded covers the case where the decision stopped being the decision — the invoice was credited by a human, the customer cancelled the request, the ticket was merged into another, the run was cancelled upstream — and without it a stale card stays clickable for as long as anyone can see it. The transition from approved into superseded is the same idea applied at execution time, and it is what an approval needs when the approval is valid, its arguments are unchanged, and the world underneath them has moved.

expired and superseded both leave a pending request without a human decision, and it is worth being clear about why they are two transitions rather than one. expired is a statement about the clock: the window ran out, so nobody is going to answer, and the system falls back to whatever it was told to do. superseded is a statement about the world: an answer would no longer mean anything, because the question it answers is no longer the question in front of the run. A system with only the first will let an operator decide a question that has already been settled elsewhere. A system with only the second will hold a request open forever as long as nothing external happens to invalidate it.

That distinction is worth being precise about, because the payload binding from the first chapter does not cover it. A digest proves that the arguments being executed are the arguments that were approved. Preconditions are claims about state outside the payload — this invoice has no other credit against it, this account is not in dispute, this subscription is still active — and they have to be recorded when the card is issued and rechecked immediately before the effect.

The tempting belief here is that the payload binding is already the defence, and it is tempting for a good reason: the digest genuinely proves what it claims. Run the argument as a chain and see where it stops. A digest is computed over the tool name and the resolved arguments; those arguments are exactly what an operator was shown; therefore a digest that verifies proves the executed call is the call somebody approved. Every link holds. What the chain never touches is anything the decision depended on that was not an argument, and an operator approving a refund is not only judging the amount and the invoice id — they are judging them against a background they never typed into the card. The digest is a quantity that stays constant while the dangerous property varies. Two identical refund calls, one issued against a clean invoice and one against an invoice now under dispute, hash to the same string. This is the same failure the first chapter closed, one level further out: that chapter moved the check from the tool to the arguments, and this one moves it from the arguments to the state the arguments were judged against.

Reading that backwards gives the three fields a precondition record needs. It has to name the check, because the sentence being asserted is what a later reader has to evaluate. It has to name the subject the check applies to, because the same check runs against a different invoice on the next card. And it has to record the value seen at issue time, because a precondition is a comparison and a comparison needs both terms — recording only the check name gives you a test that can be re-run and no way of knowing whether re-running it produced a different answer from the one the operator was implicitly relying on.

python
@dataclass(frozen=True)
class Precondition:
    check: str          # "no_other_credit", "no_open_dispute"
    subject: str        # "in_88213"
    observed: str       # value when the card was issued

The revalidation is then a loop with nothing clever in it, which is the point: every claim recorded at issue time is re-evaluated against the world as it stands at the moment of the effect, and a single disagreement stops the run rather than reporting a warning nobody reads.

python
def revalidate(run, approval) -> None:
    for p in approval.preconditions:
        now = CHECKS[p.check](p.subject)
        if now != p.observed:
            raise Superseded(
                f"{p.check} on {p.subject} was {p.observed} at approval time, "
                f"is {now} now — {age(approval)} elapsed"
            )

Recording observed rather than only the check name gives the operator who cleans up afterwards a sentence that explains itself. A Superseded that says no_open_dispute on in_88213 was true at approval time, is false now — 63h elapsed tells the whole story of a request that sat over a weekend, without anyone having to reconstruct it from timestamps.

Window, expiry action, owner

The three fields that a pending state needs are the window it survives, what happens when the window closes, and who is answerable for it in the meantime. None of them has a sensible global default, because the right answer is a property of the action.

Action classWindowOn expiryWhy
issue_refund4 business hoursCancel, notify billing on-call, reopen the ticket to the human queueMoney leaving late is worse than not leaving; the customer needs a human
send_reply (gated)2 business hoursCancel, hand the ticket back with the draft attachedA late reply is worse than a human-written one
cancel_subscription3 business daysEscalate to the named account manager, then to the head of supportRare, consequential, and nobody should decide it in a hurry
rotate_credential before expiry45 minutesExecute anyway, alert loudly, record as auto-approvedWaiting is the harm: the credential expires and the integration breaks
apply_account_credit above cap1 business dayCancel silently, logReversible and low reach; a missed one costs nothing

That table is best read as gaps rather than as levels. The windows are not sorted, and the useful quantity in it is not how long any of them is — it is the sign of a comparison the last column makes and the other columns act on. Every row is weighing the harm of acting late against the harm of never acting at all, and the expiry action is simply which of those two is larger.

text
window closes with no decision
        │
        ├─ harm(acting late)  >  harm(never acting)   →  cancel
        └─ harm(acting late)  <  harm(never acting)   →  execute anyway

    execute-anyway is still a gate only while all of these hold:
        the effect is reversible
        a wrong effect is detected at once
        harm(never acting) already exceeds harm(acting)

    drop any one and the window is a published way past the gate: wait

The credential row is the only one that auto-approves on expiry, and it is on the list to show the shape of the case where that is defensible: the action is reversible, its effect is detected immediately if wrong, and the cost of inaction exceeds the cost of acting. Take any of those three away and auto-approval on expiry is a gate you have deleted with extra steps, since an attacker or an unlucky Friday only has to wait out the window. Teams reach for it because cancelling feels like losing work, and the honest framing is that a cancelled run is work you will redo while an auto-approved one is a decision nobody made.

Each of the three conditions is load-bearing on its own, and it is worth walking them separately, because a team that has one or two of them will feel covered. Without reversibility, waiting out the window produces an effect nobody can undo, so the window becomes the attack. Without immediate detection, the auto-approved effect lands and sits, so the alert the row promises fires into a channel while nothing downstream contradicts it. And without the cost of inaction genuinely exceeding the cost of acting, the whole comparison inverts and the safe fallback is the one the other four rows use. Only when all three hold at once does executing on silence stay a decision rather than a bypass.

Windows come from two places, and reading them off a config template is how they end up all being one hour. The first input is the shelf life of the decision: how long the facts on the card stay true, which for a refund against a live invoice is hours and for a subscription cancellation at contract end is days. The second is whatever promise has already been made outside the system, since a window longer than the response time the customer was told to expect guarantees a broken commitment even when everything works. Measuring in business hours rather than wall-clock hours matters more than it sounds: a four-hour window that starts at 17:42 on Friday and counts real time expires before anyone is awake, and expires into a cancellation nobody sees until Monday, which is the same failure with a different label on it.

The reason that distinction is a mechanism and not a unit conversion is the shape of a week. A working calendar has one long hole in it, running from the end of Friday afternoon to the start of Monday morning, and on the ordinary arrangement of a working week that hole is a little over sixty hours — nearly three whole days during which no window measured in wall-clock time can be answered by anybody. A window is chosen from a property of the action; the hole is a property of the calendar, and the two have no reason to be compatible. Measuring the window in business hours is what makes them commensurable, because it stops the clock across the hole rather than letting the hole consume the window. What does not travel is the number: a rota with weekend cover has a different hole, and a follow-the-sun rota has almost none. What travels is that the longest unattended interval in a week belongs to the calendar and must be measured before any window is chosen.

Reminders inside the window are worth building before escalation is, because most unanswered cards are unanswered by accident. A nudge to the owner at half the window, and a second that also copies the rota's secondary at three-quarters, converts a large share of would-be expiries into decisions without invoking any policy at all. What those nudges must not do is arrive as a new card, since a person who has already seen the payload and is now looking at what appears to be a second identical request will approve one of them to make the pair go away.

The step under that is worth making explicit, because it is a claim about people that can be checked. An operator's model of a queue is that each card is one decision. Two cards carrying the same payload therefore read as two decisions on the same thing, which is a state the operator believes should not exist, and the cheapest way to restore the model is to resolve one of them. Approving is the resolution that clears both, since a decided request stops nudging. So a reminder rendered as a card converts a person who was going to think about the payload into a person tidying their queue, and it does it precisely to the cards that have been waiting longest — the ones that most needed thinking about.

Ownership is the field that turns an unanswered request into somebody's unanswered request, and it is the cheapest of the three to implement. A card posted to a channel with eleven people in it belongs to nobody, which is the diffusion of responsibility that fire-safety training is built around; the same eleven people would each have answered a direct message. That is an analogy and it is worth naming where it stops. Fire-safety training solves a one-off: point at one person, give them an unambiguous instruction, and the bystander effect collapses because the instruction is executable immediately by whoever was pointed at. An approval request is not a one-off — it has a duration, and the person you pointed at goes home. So the borrowing gives you address one person and gives you nothing at all about the case where that person stops being the right person while the request is still open. Assignment means resolving the on-call rota at issue time, naming that person in the record, addressing the notification to them, and re-resolving the assignment when the rota rolls over so that a card issued at 17:42 on Friday is reassigned to whoever holds the pager on Saturday rather than staying with someone who has gone home.

yaml
gates:
  issue_refund:
    window: 4h
    calendar: business_hours          # windows measured in working time
    owner: rota:billing-oncall        # resolved at issue, re-resolved on rollover
    on_expiry:
      action: cancel
      notify: [rota:billing-oncall, ticket.assignee]
      side_effects:
        - reopen_ticket: {status: needs_human, note: "approval expired, refund not issued"}
  cancel_subscription:
    window: 3d
    calendar: business_days
    owner: account_manager(account_id)
    on_expiry:
      action: escalate
      to: [manager_of(account_manager), head_of_support]
      then: cancel                     # the chain terminates

The then: cancel line is the part people leave out, and an escalation chain without a terminal action is a loop that ends wherever attention runs out. Somebody's manager is on holiday, the head of support has 400 unread notifications, and the card is pending again with a different name on it. Writing the terminal action down forces the question of what the system does when every human path has been exhausted, and for anything irreversible the answer is to stop.

The state diagram above says the same thing structurally, and it is worth reading that way because a diagram makes a missing edge visible in a way a config file does not. expired leads to escalated, and escalated leads back to pending when the request is reissued to a named owner. Those two edges form a cycle. A cycle in a state machine terminates only through an edge that leaves it, and the only such edge is escalated --> cancelled on an exhausted chain. Delete that edge and the machine is still perfectly well formed and still has no terminal state on the silence path, so the run does not stop — it goes round until the supply of names runs out, which is not a condition the machine knows how to detect.

Making the pending state visible where the work lives

There is a second failure hiding inside the first one, and it is the reason an unanswered request can sit for days without anybody being negligent: the ticket looks handled. A run that pauses for approval typically sets some status that means "not our turn", and if that status also removes the item from the queue humans work from, the pause is invisible to everyone except the person who was already notified. The fix is unglamorous: pending approvals appear on the same board as the tickets, with their age and their owner, and the SLA clock — the service-level agreement clock support is measured against — either keeps running or is replaced by a separate clock on the approval itself. If a customer is waiting, something has to be counting.

The external promise has to match the internal state too, and this is the failure that the customer experiences rather than the one the dashboard shows. An agent that has already told a customer their refund is being processed has published a claim whose truth depends on a decision that has not been made. An approval that gates an action visible to a third party should therefore either be taken before the promise is made or should downgrade the promise: "a colleague is reviewing this and will confirm within four business hours" is accurate under both outcomes and sets the expectation the window actually delivers.

Timeouts, step budgets and understaffed rotas

An expiry is not the tool call timeout, and putting them in the same config is how they end up sharing a value. The tool timeout is a bound on a network operation, measured in seconds, and it protects a worker. The approval window is a bound on a human decision, measured in hours or days, and it protects a customer, an account and an audit trail. They fail differently and they should be tuned by different people.

The auditor's version of this chapter is a single question — what happens to an approval request that nobody answers — and the config above is an answer, while "someone usually picks it up" is not. The difference matters beyond compliance theatre, because an unanswered card in a system with a written policy produces a record with a cause and a terminal state, and an unanswered card in a system without one produces a run in an indeterminate state that a person will resolve later by clicking something. The Monday operator who approved the 63-hour-old refund did nothing that the system told her not to do.

It is also not the agent's step budget. A run that has been sitting in pending for two days has consumed no steps and no tokens, and a step budget that decrements while a human thinks is a bug that manifests as runs dying immediately after being approved.

The limitation worth stating plainly is that windows and owners describe intent, and intent does not survive contact with a rota that is understaffed. If the billing on-call is one person who is also handling incidents, a four-hour window produces cancellations rather than decisions, and a stream of cancelled runs is a signal about capacity rather than about the design. The rate of expiries is worth watching for exactly that reason, and an expiry rate above a few percent means either the window is wrong, the volume is wrong, or there is nobody to do the job.

A window, an expiry action and an owner are three cheap fields, and between them they convert the one outcome nobody designed into an outcome with a cause, a terminal state and a name attached. None of them makes a decision better. What they do is guarantee that a request which is never decided still ends, ends in a way the design chose, and ends against somebody who can be asked about it afterwards — which is the difference between a system whose behaviour under silence can be described and one whose behaviour under silence is a story about a Monday morning.

All of this presumes the pending record survives long enough to expire. A four-hour window is already longer than many deployment cycles, and the three-day window on cancel_subscription is far longer than any process that serves it will live. If the pending state exists as a suspended coroutine, a callback registered in a dictionary, or a request holding a connection open, none of the policy above executes: the process goes away, the record goes with it, and the run neither expires nor escalates nor completes.

Human in the loop — read the current version at https://daviddominguez.dev/learn/en/human-in-the-loop/an-unanswered-approval-needs-a-default-an-owner-and-an-expiry

Back to top