Guarded Automation: Agents That Run Operations Module 4 · Approval and Revocation

Approvals Expire

Last reviewed · content updated

Intermediate

What you'll learn

~18 min
  • Name every field an approval binds to, and what has to move for the signature to stop verifying
  • Explain why expiry alone never prevented a replay, and what does instead
  • State where an approval's maximum TTL (time-to-live: how long a token stays valid) comes from, and why no runtime yet enforces one

Before the detail — Decision: decide how long one approval lives and what kills it early. Outcome: a token that cannot be replayed, stretched, or spent twice. Artifact: the approval card plus a single-use wallet, accepted when a second attempt to spend the same approval is refused. Status of what follows: reusable guidance — the card and the wallet are what you build; 2.2’s runtime table, which compares what five shipping runtimes enforce, shows nobody ships this yet.

Prompt first: draft the card, not the question

MU-AI-011 — Meridian’s crew-dispatch agent — has already run its dry run when this prompt opens.

MU-AI-011 has planned send_dispatch_notice for feeder F-2291. The
dry run already computed a plan hash against a pre-state hash
captured a minute earlier. The action is irreversible and requires
two approvers.
Draft the APPROVAL CARD from the planned request - one signed
document, not a chat message:
- action, parameters, target
- why now, in one operational sentence
- blast radius, in plain words
- reversibility - name the inverse, or say there is none
- pre-state hash and plan hash - the two facts the signature binds to
- the policy version in force
- how long the approval is valid for, and what invalidates it early
- the request id, so a second use is a fact a machine checks, not
a memory a person keeps
Do not draft "OK to send?" - draft the document a person signs, with
enough on it that they are deciding about consequences, not clicking
a verb.

A signature over “OK to send?” binds to nothing: change the feeder, the crew count, or the world since the plan was shown, and “OK” still reads as an answer. A signature over the plan and the pre-state binds to exactly what was proposed, and to nothing else.

The card is a request hash wearing a form

The approval card for send_dispatch_notice (irreversible, two-person) lists action, parameters, target, requested-by, why-now, blast radius (how much of the world this action could move), reversibility, pre-state hash, plan hash, expected post-state, policy version, expiry, and the request id — then states plainly what the signature covers: “the action, the agent, the parameters, the target, the pre-state, the plan, the policy version and this request id. Change any one of them and the signature stops verifying.” Mechanically that is one hash: sha256(canon{action, agent, params, target, pre_state_hash, plan_hash, policy_version, request_id}). Eight fields, one number, one signature — and the guard names which of the eight moved when a token stops verifying: stale state, stale plan, a policy-version bump, a changed parameter, or a request id that belongs to a different request.

That is why the plan comes before the card, never after. An approver who signs a verb — “send the notice” — has signed nothing about the world it will act on. An approver who signs a plan hash computed against a captured pre-state has signed the one thing that matters: what this action does to the world as it stands right now. The card exists so the approver is answering a question about consequences, not clicking a button.

Single-use is not the same claim as short expiry

The request id on the card is a nonce, and the card says so: “This approval is SINGLE-USE: the first execution consumes request id REQ-…, and any later attempt to use it is refused. Expiry alone never prevented a replay — consumption does.” transcripts/approval-replay.txt proves it end to end on a real run: call 1 presents the token and executes, exit 0; call 2 presents the identical token again, unchanged, and is refused before execution — “approval for request REQ-732A4F7908 was already used by receipt AR-0C31FBF53DC4 at 2026-08-18T15:55:00Z - approvals are single-use, and a short expiry never prevented a replay,” exit 4. Nothing about the clock or the world moved between the two calls — the transcript’s own header notes the redeploy was “a genuine no-op” — so consumption, not staleness, is what caught the second call.

The harder proof is concurrent, not sequential. transcripts/approval-race.txt starts two processes against the same token at the same instant. Both are real guarded runs; the intended loser is given --race-wait-for-file, pointing at a file the winner writes only after its last receipt, so the transcript records a fact rather than a scheduling accident. Without that ordering nothing about the run is quotable — not just which process wins, but the order the two runs append to one hash-chained receipt file, and the shared budget counters each one reads. Process A executes and verifies, exit 0; process B is refused on the identical already-used reason, exit 4 — recorded plainly as exit codes: A=0 B=4. Afterward python3 guard/verify_receipts.py receipts/approval-race.receipts.jsonl confirms the chain still verifies: the append that records the winner is serialized even though the race that produced it was not. A TTL alone answers “is this still fresh”; it never answers “has this been spent before” — the failure mode consumption closes and expiry cannot.

Consumption is enforced where the agent cannot reach. Fatigue is a different failure than replay, and it gets its own lesson next; the measure here is calendar time, because a single-use test is something a team can run once and keep, not a habit it must maintain.

--max-ttl comes from the policy, not from the person approving

The standalone approve.py verify command accepts a caller-supplied --max-ttl; guarded execution instead passes approval.max_ttl_seconds from the loaded policy directly into token verification. The shipped ceiling is 900 seconds — fifteen minutes from issue to expiry, whatever the card’s own --ttl flag was set to when it was minted. approve.py issue can mint a longer-lived token, but neither standalone verification with --max-ttl 900 nor guarded execution will accept it: the policy’s maximum is checked independently of the token’s own claim.

The OpenID Foundation’s AuthZEN working group’s Access Request and Approval Profile (AARP), dated 2026-06-15, treats approval as a first-class prerequisite rather than a side effect of a login. When the agent resumes with an answer in hand, “policy is re-evaluated, and only then does the action proceed.” Guarded execution mirrors that shape by rechecking the bound request and the loaded policy on resume; the TTL ceiling is one part of that re-evaluation, not the whole control — a stored answer to a question asked earlier is not a standing grant on its own.

Two clocks, and neither one is this token’s

Microsoft Entra Privileged Identity Management (PIM) documentation dated 2026-04-23 describes time-boxed activation of a privileged role and writes the activation window as an ISO 8601 duration: "expiration":{"type":"AfterDuration","duration":"PT5H"}, five hours. The same documentation carries the caveat this lesson exists to generalize: “if application previously cached the fact that user has a role – when role is deactivated, user may still get access.” The role expired at the identity provider; the resource that cached the old answer did not notice. An identity provider’s expiry is not a resource’s expiry, and the gap between the two is exactly where a stale grant survives its own revocation.

This token is not a role grant. Zero Trust 6.3 (a separate training in this series) states the identity-side rule: incident-mode wrappers expire themselves. That identity artifact is distinct from this single-action approval, which binds to one request hash and is consumed once.

As of 2026-08-29, none of the five runtimes 2.2 catalogs expires a granted approval once issued — the nearest a shipping product gets is a CLI’s “for the remainder of the session.” The card and the wallet built here are what a team adds; no product ships them today.

Stop and escalate when a plan’s pre-state hash or plan hash no longer matches the world at the moment of execution — a stale-state or stale-plan denial is not a bug to retry past. Re-plan and re-request; the decision to shorten the policy’s own --max-ttl ceiling belongs to whoever owns the envelope, not to the approver on duty.

KNOWLEDGE CHECK

Two on-call engineers, racing to beat the storm window, both submit the same signed approval for pre_position_crew within the same second. What happens?

Key takeaway

An approval is not a yes stapled to a clock. It is a signature over one plan and one captured world, spent exactly once by whichever caller reaches the filesystem first, checked against a ceiling the policy sets and the token cannot exceed. Build the card, build the wallet, and the next control failure this module names is not replay — it is what happens when the card shows up too often for anyone to read it. That is 4.2.

LEADERSHIP DECISION every approval binds to a plan and a captured
pre-state, expires inside a policy ceiling, and
is consumed on first use - no exceptions carved
for a trusted caller
PRACTITIONER ACTION build the approval card and the single-use
wallet against the request hash before wiring
any action to `ask`; verify with --max-ttl set
from the policy file, not from habit
SUCCESS MEASURE a replay attempt and a concurrent double-use
attempt both produce exactly one denial and a
chain that still verifies - audit finding
avoided
Search lessons