Stamp an Expiry on Every AI Approval Before the Agent Acts
At 9:40 on a Monday, a support agent approved a refund.
The evidence card looked clean and complete.
The order state printed on it was from Friday.
The refund went out to a customer who had already been charged twice.
Nobody re-read the order. Nobody had a reason to.
Who owned that decision? The agent did, on paper.
The real owner was the card. It never said when it stopped being true.
That is the gap I keep hitting. We design approval screens as if evidence were a photograph.
Evidence is milk. It has a date.
So here is the tutorial I wish I had that Monday: put a clock on the permission, not on the action.
I will build a small approval-expiry card from zero to a working check endpoint, with a verification step at every stage.
Name three things before you write any code
The decision owner. The consequence. The point of reversibility.
For the refund case: the owner is the support agent on shift.
The consequence is money leaving without a second charge check.
Reversibility is a manual reversal within one business day, which is to say, expensive.
If you cannot name those three, stop. No card will save you.
Stage 0 — Write the decay policy as data
Policies buried in prose rot fast. Policies in JSON can be tested.
{ "action": "refund_issue", "evidence_kind": "order_state", "max_evidence_age_s": 900, "approval_ttl_s": 600, "recheck_on": ["balance_change", "customer_reply", "agent_handoff"], "on_expiry": "hand_back" } Read it out loud. Evidence older than fifteen minutes cannot be approved. An approval older than ten minutes cannot be executed.
Verification: every field must map to a sentence a support lead would say out loud. If one does not, delete it. Fields nobody can defend become fields nobody maintains.
Stage 1 — Write the stamp function
The stamp answers one question: is this permission still alive?
import time def stamp(approval, policy, now=None): now = now or int(time.time()) evidence_age = now - approval["evidence_ts"] if evidence_age > policy["max_evidence_age_s"]: return {"state": "stale_evidence", "evidence_age_s": evidence_age} expires_at = approval["approved_ts"] + policy["approval_ttl_s"] if now > expires_at: return {"state": "expired", "expires_at": expires_at} return { "state": "live", "expires_at": expires_at, "seconds_left": expires_at - now, "evidence_age_s": evidence_age, } Two clocks, not one. Evidence age and permission age decay at different speeds, and conflating them is how Friday's order state rides shotgun on Monday's refund.
Verification: call stamp three times with a fixed now. You want live, expired, and stale_evidence in that order. If you cannot produce all three from a test, you have not built a clock. You have built a comment.
Stage 2 — Make time injectable, then abuse it
Never let a function call time.time() on its own in a test. The now argument above is the whole trick.
p = {"max_evidence_age_s": 900, "approval_ttl_s": 600} a = {"evidence_ts": 1000, "approved_ts": 5000} assert stamp(a, p, now=5200)["state"] == "live" assert stamp(a, p, now=5700)["state"] == "expired" assert stamp(a, p, now=2400)["state"] == "stale_evidence" Verification: run the file. Silence is the pass signal.
Now do the ugly one. Set approval_ttl_s to zero and confirm every approval expires instantly. That is your kill switch, and you should know it works before you need it at 2am.
Stage 3 — Render the card copy with a free model
Here is where MonkeyCode's free model access earns its place in this workflow. I do not want a model deciding anything here. I want it turning a JSON blob into one calm sentence a tired agent can read.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Keep the model on a short leash. Give it the fields, forbid new ones, and demand a fixed shape.
System: You render approval cards. Use only the JSON provided. Never invent numbers, names, or timestamps. Return one line per field. Input: {"action":"refund_issue","state":"live","seconds_left":240, "evidence_age_s":180,"evidence_kind":"order_state"} Output format: ACTION: <action> STATE: <state> EXPIRES: <seconds_left> seconds EVIDENCE AGE: <evidence_age_s> seconds I run this across several candidate drafts and keep the one that survives Stage 4. Free access matters here because card copy is a volume problem, not a prestige problem. You will render hundreds of variants before one reads well out loud.
Verification: read the output aloud in a flat voice. If a sentence sounds like policy theatre, cut it. The agent needs the expiry, not a paragraph about safety culture.
Stage 4 — Prove the copy cannot invent numbers
The dangerous failure is not a bad sentence. It is a confident fabricated timestamp.
import re def numbers(text): return sorted(re.findall(r"\d+", text)) blob = {"seconds_left": 240, "evidence_age_s": 180} copy = render(blob) expected = numbers(" ".join(str(v) for v in blob.values())) assert numbers(copy) == expected, "rendered card invented a number" Verification: run it against ten inputs, including a stale_evidence case where seconds_left is absent. The renderer must omit the line, not guess a value.
Fail this test and you do not ship the card. That is a stop condition, not a warning.
Stage 5 — Serve the check on the free server option
A clock nobody can query is a decoration. I put the check behind one endpoint using MonkeyCode's free server option, because this needs to be reachable from the agent tool, not just from my laptop.
from http.server import BaseHTTPRequestHandler, HTTPServer import json class Check(BaseHTTPRequestHandler): def do_POST(self): body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) result = stamp(body["approval"], body["policy"]) code = 200 if result["state"] == "live" else 409 self.send_response(code) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps(result).encode()) HTTPServer(("0.0.0.0", 8080), Check).serve_forever() Verification: two curls, one that should pass and one that should be refused.
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8080 \ -d '{"approval":{"evidence_ts":1000,"approved_ts":5000},"policy":{"max_evidence_age_s":900,"approval_ttl_s":600}}' A 409 on an expired permission is the whole product. The agent does not get to retry around it. It hands back.
One caveat: a free server is a starting point, not a promise. Treat it as a staging surface for the check logic, and keep your policy file in version control so you can move it later without rewriting the rules.
Stage 6 — Accessibility pass on the expiry
An expiry that only exists as a colour is not an expiry. Grey chips and amber chips vanish for a lot of people.
Three checks I run every time:
- The text says the expiry in words: Live for 4 more minutes.
- The countdown sits in a
role="status"region, announced politely, never interrupting. - Nothing depends on red versus green. State is spelled out.
Verification: tab through the card with a screen reader on. If the expiry is only announced after a focus jump, you have hidden the most important field for keyboard users.
Stage 7 — Run the failure-recovery drill
Now break it on purpose. Approve, wait past the TTL, and try to act.
Expected sequence: 1. Action refused with state=expired. 2. Draft preserved, not discarded. 3. Owner named on the hand-back card. 4. One sentence explains what to re-check. Verification: the draft must survive. If an expired approval deletes the agent's work, you have taught them to approve faster and read less. That is the opposite of the goal.
What is evidence and what is my hypothesis
Evidence: the order state was three days old and nobody flagged it. Evidence: expired approvals with no draft preservation increase re-work.
Hypothesis: a ten-minute TTL fits refunds at this team's volume. I have not measured that. Pick your own number, then measure how often agents hit the wall.
Keep those two columns separate on the card itself. Reviewers trust a card that admits what it does not know.
Who should not use this
Do not add expiry to emergency actions where delay causes harm. Do not add it to high-frequency actions with sub-second loops; the check becomes the bottleneck. And do not treat expiry as a substitute for reversibility. An expired card stops a stale action, not a wrong one.
The question I would ask first
Which missing evidence should stop approval outright, and which extra field would only add noise?
Answer that before you write the JSON. The clock is easy. Deciding what deserves to expire is the design work.
If you want to try the copy stage without a budget conversation, start with the free model access and keep the leash short. Then show me your stop condition — that is the part I actually want to read.