Your agent does something wrong. How far does it reach before anyone notices?

by

Two months ago I asked here how people stay aware of what their agents are doing, and replied with something that has been quietly rearranging my roadmap since.

I had been treating "can you undo it" as the line for when an agent should stop and ask. He took that apart. Irreversibility over-fires and under-fires, he said. It over-fires on things that cannot be undone but that nobody cares about, like the agent writing a log line. It under-fires on the ones that really hurt: a mass email to 50k users, or a migration you can only roll back after downtime. "Reversible on paper, ruinous in practice."

His version is that the gate should key on reach, not on reversibility. Not "is this safe" but "how far does this reach if I'm wrong." And the part that stuck with me: the agent is the worst possible judge of its own blast radius, because it does not know there are 50k people on that list.

had pushed the same way earlier in that thread, that this gets worse the moment the agent stops writing code and starts booking meetings or sending email, where you find out after the fact. I have been sitting with both of those for two months, and they leave me with a question I still cannot answer cleanly for our own product:

If your agent gets one thing wrong today, what is the largest number of people who find out before you do?

For a coding agent that is usually one. You, reading a diff. The moment the agent sends, books, charges, replies to a customer or touches production, that number stops being one, and most of us have never actually worked out what it is. So whether you run agents or ship them to other people, which of these are you?

A. Nothing my agent does reaches past me. Genuinely low stakes, no gate needed.

B. It could reach customers, I know it, and I have not built the gate yet.

C. It stops and waits for someone to come back and look. Safe, and it quietly kills completion.

D. It emails or Slacks someone, which mostly works until the person who cares is not in that channel.

E. I built the real thing, timeouts, escalation, an audit trail, and it ate a quarter I had not planned for.

Ours, honestly: we sat on B longer than is comfortable, moved to D, and then found that delivering the question is the small part. What surrounds it is the work. Whether a timeout should mean deny or hand back to the terminal took us longer to settle than building the delivery did. So did reaching the one person who can answer

when they are not sitting in front of your product, and being able to prove afterwards who approved what.

The two I still cannot answer, and I do not think anyone has:

How do you reach the person who has to decide, when they are not looking at your product and never installed anything of yours?

And what does your system do when they simply never answer? Every answer I have heard to that one is a guess, and the guess is usually "keep waiting", which is how a job sits stalled for six hours and a customer decides your product is broken.

[Same disclosure as last time: I build Pushary, which is the ask-a-human part of this, and these days we sell it to other companies to embed as much as to people running agents in their own terminal. Obvious bias, asking anyway. The last thread taught me more about this than six months of building did.]

Which letter are you, and what did it cost you to find out? I will come back in a few weeks and post the spread.

1.4K views

Add a comment

Replies

Best

the reach > reversibility framing lands for me alongside a second failure mode: context loss

In our own workflow, the problem wasn’t only that an agent could take a risky action; it was that the reviewer could no longer see why the action existed, which customer signal it came from, or what evidence would prove it worked.

We’ve been treating the action, source, and intended outcome as part of the handoff, then bringing the shipped result back as evidence.

That makes me a cautious B: human approval is still needed when work crosses into customers, but approval only helps when the person can inspect the reason and provenance.

As in: faster execution makes missing context more costly, not less.

 

Context loss is the right second axis, and there is a sharper version of it hiding inside your own description.

You listed action, source, and intended outcome. Those three do not have the same trust properties. The action is observable, the system records it whether or not the agent cooperates. The reason is narrated by the agent, which means you are asking the thing whose judgment you are checking to also supply the justification for its own behaviour. A model that decided to send is a model that can write a persuasive account of why sending was correct. So the reason field fails in the one direction that matters, and it fails invisibly.

That is the line I would draw, captured provenance against narrated provenance. The message id, the row the query returned, the retrieval that actually happened, those are captured and they are checkable. A paragraph explaining the motivation is narrated. And narrated provenance is arguably worse than none, because a reviewer reading a plausible reason approves faster than one reading nothing. It manufactures confidence rather than supplying it.

Your closing line has a mechanism under it worth spelling out. Review time per item is roughly fixed, bounded by human attention. Production time has gone to nearly zero. So the ratio inverts and the bottleneck moves entirely into reconstruction. Once reconstructing why takes longer than producing the thing took, people stop reconstructing and start checking whether it looks right. Nobody decides to make that switch, it just happens. Surface plausibility is exactly the test a fluent narrated reason passes.

The return leg does something I had not given it credit for. If you declare an intended outcome at ask time and then cannot produce a matching observation afterwards, the outcome was decorative. So the evidence coming back grades your handoff format, not only the work. That is a free test of the thing that is hardest to evaluate directly.

Where ours falls short of your bar: our ask carries the action, the target, and free text. There is no structured field for a source, so provenance in our asks is whatever the agent chose to say about itself, which is precisely the narrated kind I just argued against. A field that resolves to an artifact instead of prose is the fix, and we do not have it.

So on yours: does source resolve to something a reviewer can open, or is it a sentence?

It needs to resolve to something the reviewer can open, as a sentence would simplify too much the original intent. In the Zentrik model for instance, the original signal (call, ticket, research item, or experiment) stays linked to the decision and work it informed.

On the return leg shipped result and customer response should be inspectable against the intended outcome, not another agent-written summary. The practical test is whether a reviewer can reconstruct why the bet existed without trusting the agent to explain itself.

That’s about the bar I’d want a structured source field to meet.

 

A reference, then, not a field. And the property doing the work is not that it is structured, it is that the referent exists in a system the agent did not author. A string can be written. A ticket cannot be made to have existed.

That puts the check earlier than it looks. If the reference only resolves when a reviewer clicks it, a well-formed identifier passes, and a reviewer under time pressure treats the presence of a link as evidence of a referent. Same failure as a narrated reason, with better formatting. To meet your bar it has to be dereferenced at ask time against the source system, and the ask has to fail when it does not resolve.

The return leg then constrains the intent field harder than I had it. If the shipped result is inspectable against the intended outcome, the intended outcome has to be written in the vocabulary the observation will arrive in. Otherwise nobody can compare them, a human writes a summary of the gap, and narration is back one level up. So the authoring-time test is whether you can name the thing that will come back. If you cannot, the outcome field was decorative before anything shipped.

Where that lands on me. We do carry an intent line. Optional, 500 characters, written by the agent, and the tool schema describes it as the user's stated task from their last prompt. Narrated twice, and exactly the sentence you just ruled out. The structural reason is that we ask from inside a tool call, where the agent is the only thing in the room that knows why. Provenance cannot originate there. It has to be attached upstream when the work is dispatched and carried through, which makes an approval layer the wrong place to author a source and the right place to refuse to lose one.

One thing I would want from Zentrik. What happens to an action with no upstream signal. Optional, and the field is empty exactly where scrutiny matters most, because unsourced work is the risky kind. Mandatory, and the agent finds something to point at, so narration returns as selection instead of fabrication. Which way did you go?

 I wouldn’t necessarily make provenance mandatory in a way that allows the agent find a plausible reference. i.e. that can become a solution in search of a problem.

I’d keep the work explicitly unsourced, surface that gap, and require a human to supply or confirm the reason and intended outcome before treating it as an evidence-backed bet. When a real call, ticket, research item, or experiment exists, that artifact should stay linked upstream. If it doesn’t, the missing signal should lower confidence and change the review path... not get papered over by a narrated justification.

A, honestly, and I'm not sure that's a badge of honor, it's mostly that my agents don't have the surface area yet, not that I engineered it that way.

Everything I run agents on is coding-related, side hustle SEO tools, so worst case is a bad diff I catch on review. No customer sends, no charges, nothing touching production without me in the loop first. So "reach" for me caps out at one person: me, reading output before it ships.


But your framing makes me suspicious of my own answer. "Nothing reaches past me" is true right up until you automate something you didn't think of as customer-facing, a slug rename, a sitemap regen, a scheduled content push — and it turns out Google or your users were the blast radius the whole time, you just didn't have a human in that chain to notice.

Reach isn't just who your agent messages, it's who's downstream of the thing it touched. I'd bet a chunk of the A's in your replies are actually B's that haven't been wrong yet


The timeout question is the one I'd want your data on more than the philosophy. "Keep waiting" isn't neutral, it's a silent default toward denial that costs you a stalled job either way, so why not make the timeout itself the escalation trigger? No answer in N minutes doesn't mean deny, it means widen the ask (next person on the list, different channel) before it means anything final. The person who never installed your product and never answers isn't a policy problem, it's a routing problem, you need a chain, not a single ask.


What's your actual N right now, and did you land on widen-then-deny or something else?

 

Numbers first, since you asked for them.

The default ask window is 60 seconds for most tools and 45 for edits. Read only work is set to zero and never asks. Destructive actions are configured so that no timeout can auto anything, they hold the full window and then ask. The phone gets the first stretch of that window and the terminal gets the remainder.

The part that surprised me when we measured it is that N is not really ours to choose. The agent harness kills the hook on its own budget, and those budgets differ by agent. Claude Code allows about two minutes, Cursor allows one. We take that number, subtract a guard so we can return a decision rather than get killed mid wait, and that is the ceiling. Any policy timeout longer than the harness budget is fiction. So the honest answer to what is your N is that it is bounded by somebody else's timeout and the policy only picks a value inside it.

Now the part where you are right and I have to concede it.

We escalate across channels, not across people. A timeout on the phone falls back to the terminal, and the terminal is where the agent asks its own question. That works because there is usually a human sitting there. But that is the easy case, and it is not the case my post was about. For the person who never installed anything, falling back to the terminal is falling back to a room they are not in. We can name a specific decider and enforce that only they may answer. We cannot sequence two of them on a timer. The chain you are describing does not exist in our product, and calling it a routing problem rather than a policy problem is the correct diagnosis.

Keep waiting is a silent default toward denial is also right, and it is worse than it sounds, because that denial is unattributed. Nobody decided it, so nobody is accountable for it, and it does not appear in any record as a decision.

On your own answer, the slug rename is a stronger example than you gave it credit for. A send has reach you can count at the moment you send it. A slug rename does not. Its blast radius is inbound links and an index, it produces no error, and it lands over days. So there are two kinds of action here: ones whose reach is a number at the time of acting, and ones whose reach only becomes a number later. Everything on the audience or spend axis is the first kind. Almost everything in SEO is the second. Thresholds and budgets only work on the first kind, which is why your domain feels like it has no natural gate.

The line I would keep is yours. A is a claim about your current wiring, not about your agent. Wiring drifts and nothing fires when it stops being true, so A is accurate as of the last time you looked and there is no event that tells you it expired. Which is why I would take that bet.

D, and I only got there by way of B.

The framing I landed on wasn't irreversibility either, it was blast radius plus time to notice. I run a support triage agent and an invoicing tool that can email clients. Both are reversible on paper. An invoice can be voided, an email can be followed with an apology. Neither of those matters, because the damage lands the moment another human reads it. I can't unsend it out of someone's head.

So the two things I gate on are how many people outside my org see this, and how long before I find out it happened. High on both, it stops. A log line, a file write, a row in a table I own, all of that runs free whether it's undoable or not.

What it cost me to find out: nothing dramatic, just a stretch of nobody looking. The agents had stopped running entirely and nothing said so, because the design treats no output as normal. Most of my incidents haven't been the agent being wrong, they've been the gap between wrong and noticed. That gap is the part I underbuilt.

On your unanswerable one, the person who never answers, I don't have a good answer either. Mine defaults to keep waiting, which is exactly the stall you describe. What I'd want is a deadline on the ask itself. No answer in N minutes means fall back to the safe option and say so loudly, rather than hold the line forever. A stalled job that took the cautious path is recoverable. A stalled job that just sat there is a support ticket.

 

The two things you gate on are not the same kind of thing, and separating them changes what can fix each.

Blast radius is a predicate on the action. You can evaluate it before anything happens, which is what makes it gateable. Time to notice is an outcome. It is not knowable at the moment you would need it, only afterwards, and it is a property of your organisation rather than of the call. So it cannot be an input to an approval decision at all. It can only change how much you let run between checks. One axis is a gate, the other is a leash length, and treating the second like the first is how it stays underbuilt.

On the org boundary you have the cleaner version of something I have been circling in another branch here. Reversible means reversible by you, and your undo authority stops exactly where the last copy you control does. Voiding the invoice reverses your ledger. It does not reach the client's inbox, their forward to their accountant, or their opinion about your billing. So the question is not whether the action has an inverse, it is who else is holding a copy by the time you want one.

The three days is the part I would sit with, and it is a limit on what I sell. A gate would not have helped. Nothing asked. The agent was doing things that were individually permitted, correctly permitted, and wrong in aggregate, and an approval layer tuned perfectly still produces your Friday. Approval is a control on discrete acts. Drift is about aggregates, and there is no per call decision that catches it.

What catches it is a system whose silence is distinguishable from health, which is also what makes your deadline fix work. Say so loudly is the load bearing clause, not the fallback. A quiet safe default is a small version of Tuesday to Friday, a decision the clock made that nobody knows about.

Ours is honest about half of that. A timeout is a first class outcome in our records rather than a missing answer, so the clock's decision is written down as itself. But the record is pull, nothing pushes it. And the only periodic report is weekly and fires only for workspaces that had activity, so it cannot report silence and cannot resolve a Tuesday to Friday inside one window.

What I would want to know from your Friday: was there a signal present that nobody was watching, or was there no signal at all? Those are different builds.

  The gate versus leash distinction is right, and sharper than what I wrote. Blast radius is knowable before the call. Time to notice isn't knowable at that moment at all, so using it as an approval input was me collapsing two different things. Leash length is the better name for what I was describing.

To your question, and it's the less flattering of the two answers: there was no signal at all. Worse, the absence of one was the designed success case.

The agents run on a schedule against a support inbox. There's a precheck that exits early when there's nothing to process, and an early exit writes no log line, because at the time not recording that nothing happened seemed obviously correct. So a quiet day and a dead agent produce identical output, which is none. I built the ambiguity myself and then read it as reassurance.

What actually broke was an auth token lapsing. Every agent that depended on it died at once. What hid it was that the other services on the same machine were unaffected, so anything asking is this host healthy answered yes, correctly, the whole time.

Which is why silence distinguishable from health is the right frame, and I'd push it further for your build. Not alert on error. Let me distinguish ran and had nothing to do from did not run. If skipped is a first class emitted outcome rather than something inferred from a gap, silence becomes meaningful and you can alarm on it. Your timeout being first class in the record is that same instinct, it just needs the push half. And a periodic report that only fires for workspaces with activity is structurally incapable of reporting the thing that matters most, which is a workspace that has gone quiet.

 

You did not have a monitoring gap, you had an encoding one, and that is the harder kind because it sits upstream of anything you could have watched. Nothing to do and did not run produced the same output, so looking more closely was never going to help. A distinction the emitter dropped cannot be reconstructed downstream.

What makes that trap so reliable is that the collapse is both the cheap option and the locally correct one. Not writing a line when nothing happened is what a careful engineer does. Noise reduction and silence detection pull against each other, and the record that costs the most to keep is the one saying nothing happened.

The health check answering yes correctly is the sharpest part of it. Monitors get placed on ownership boundaries, hosts and services, things that have an owner. Failures propagate along dependency edges. Your token's blast radius was every process holding it, which was not a unit anything watched, and it cut across the machine the checks were organised around. A true answer to a question nobody needed answered.

Taking your design point, with one addition. Emitting skipped only buys you an alarm if something declares when it should have arrived. Your schedule supplies that for free. So the primitive has two halves, the emitted non-event and the expected cadence, and the second is the one we lack, because our agents are not scheduled. A human starts them, so a quiet workspace is usually a person on holiday, and alarming on that floods everyone.

Inside a session it is unambiguous, and we already carry the seam. Sessions have a last seen time and a sweeper walks them active, idle, offline as it goes stale. Then your comment sent me to look at what offline actually means, and we have built your exact collapse. A clean exit writes offline. A session that simply stopped reporting gets swept to offline. Same value, same column, and the board files offline under done. An agent that died and an agent that finished land in the same bucket, and the bucket is named after the success case. There is a comment in that file explaining the choice and it reads as obviously correct.

One thing I would check on yours before leaning on skipped. Make sure it means I looked and there was nothing, not I could not look. An empty inbox and an unreachable inbox both return zero items, and a precheck is exactly where that ambiguity gets rebuilt one level down.

 Encoding rather than monitoring is the right correction and I am taking it. The precheck point lands too. "I looked and there was nothing" and "I could not look" both return zero, and a precheck is exactly where I would have rebuilt the ambiguity one level down without noticing.

What I take from that is that it is not a bug you fix once. Every layer that can report nothing gives "could not look" a new place to hide inside "nothing", and at each site the collapse is the locally correct choice. So vigilance is the wrong defence, because it asks someone to make the globally right call at the exact moment the locally right one is obvious. The only thing that holds is making the collapse inexpressible. If the only way to say nothing is a value that carries why it was nothing, the careful engineer cannot accidentally write the ambiguous version, because there is not one to write.

On your offline finding I would push slightly, because renaming the bucket does not fix it. The test is not whether the two states are distinguishable in the column. It is whether anything downstream branches on the difference. If the board does the same thing either way, splitting the enum relocates the collapse into whoever reads it. So the question before the schema change is what the board should do for stopped reporting that it does not do for finished, and if the honest answer is nothing, the split is cosmetic and nobody will notice when it regresses.

On cadence I think you have the half you say you are missing, you are looking for it in the wrong place. A schedule is a promise made in advance, which is why it comes free. A human started session makes its promise at start time: somebody deliberately began something, and that is a commitment that it will either progress or end. So the expectation is not "this workspace should be busy", which is unbounded and fires on holidays. It is "this specific started thing should not go quiet without finishing", which is bounded, and cannot fire when nobody started anything. Your last seen sweeper is already the mechanism. It is sweeping into the wrong bucket rather than lacking the signal.

the declared-by-the-environment fix for reach (Tyler's framing, and it's the right one) still has a gap once you look at it per-call instead of per-action. an agent that sends one email at a time in a loop looks like reach=1 to the classifier every single time, because that's genuinely what the API call is. the mass send only exists at the level of the loop, not the call, and nothing in this thread's model is watching that level. same shape as the cumulative-spend point further down for purchases, but it applies just as much to sends, writes, anything an agent can repeat. feels like reach needs a session or time-window scope in addition to a per-call one, otherwise a bad loop just walks through the gate one cleared call at a time.

 

The counter you would reach for first has a failure worth naming before you build it, because it does not show up until it is running. A rolling count gates call N plus one, which means the first N already went out. Against an irreversible send a threshold gate never prevents a mass send, it caps one. That is a different product, and better decided on purpose than discovered later.

Gating an aggregate before it starts needs something to declare the intended total up front, and the only thing that knows the total is the agent. Which is exactly the assertion the environment declared fix was built to remove. So the two properties do not come out of one mechanism. Environment declared reach is trustworthy and visible only per call. A declared budget is gateable in advance and only as good as the model.

You can have both if the declaration is allowed to move in one direction only. Let a stated budget raise the gate and never lower it. An agent announcing four hundred sends gets stopped on call one. An agent that says nothing falls back to the counter and gets capped rather than stopped. Worth being honest that this is a design for the buggy loop and not the adversarial one, since silence is strictly the better strategy for anything trying to get through, and the counter is what you keep for that case. Most bad loops are not trying.

On scope, the unit you count over has to be at least as trustworthy as the thing you are counting. Session ids are asserted by the client, ours included, so a session scoped budget inherits whatever the reporter is worth, and a restart resets it. Time is the one scope nothing can assert. Its weakness is pacing, and a loop slow enough to stay under a rate window is not stopped, but it now runs for hours instead of seconds. That is what a rate limit actually buys. Not prevention, a slow failure in place of a fast one, and a slow one can still be noticed while it is happening.

Where that leaves us is worse than unsolved. A policy row carries a tool pattern, a timeout, a timeout action, a delivery mode and a repo scope. Every field describes a single call, so there is no way to write your rule down at all. Meanwhile the tool actions we receive land with a session id and a timestamp, indexed on site plus session and on site plus created at. The data is already shaped for both of the scopes you named, and nothing at decision time reads it.

The part about the agent being the worst judge of its own blast radius is exactly right, and we learned that the hard way when one of our agents confidently acted on stale context — it had no idea the situation had changed because nobody told it, and why would they. Your unanswered question about what to do when nobody responds is the one the whole industry is quietly dodging — most timeout logic I've seen is just a number someone picked because they had to ship. What I haven't heard a good answer to either: how do you set that threshold differently depending on who the decision affects, not just what the decision is.

 

The part I would sit with is nobody told it, and why would they. That is not an oversight, it is the default condition for anything holding a read that nothing registered. Every other consumer of that state either polls or was designed into the change path. The agent was designed into neither, so nothing that mutated the world had any reason to know it had a subscriber.

Worth separating from confidence, because the repair looks different. It was not overconfident about the facts it had. It was correctly confident about facts that had expired. Calibration is measured against what you know, and no amount of it helps with something that stopped being true after you read it, which is why making the model hedge more does nothing here and a freshness check on the inputs is the only thing that does.

On the number, agreeing, and naming why it cannot be picked well. One value is doing two jobs that move with different variables. The duration is a claim about the approver, how long until they are plausibly reachable. The fallback is a claim about the action, what is safe with no answer at all. Bundle them and a single guess has to be right about two unrelated things, and no value is.

Which is where your question resolves for me. Who it affects should not set the number. It should select who answers, and then the number becomes a property of a person, which is measurable rather than picked. Answer latency is an observable distribution per approver. If someone's ninetieth percentile is four minutes, a sixty second timeout expires on a live and willing human every single time, and every expiry after that is a fact about your threshold rather than about them.

The other half inverts the intuition. You would think affects more people means wait longer. But waiting stops being neutral the moment the affected party is not the operator. If the decision only touches the person running the agent, the delay costs them and they chose the loop, so a long window is free. If a customer is sitting on a pending reply, the wait is being spent out of someone who never agreed to it. Stakes belong in the fallback, not the clock.

We built half of this and never connected it. Who answers is its own table, a role or a named person, re-resolved at answer time so a rule pointing at somebody who left the workspace addresses nobody instead of continuing to address them. The number lives in a different table keyed identically, on tool pattern and repo, and the routing table carries no number of its own. So we choose the person and choose the number independently and let neither inform the other. Elsewhere we compute median answer latency per person and timeout rate per person and put both on a dashboard. Every input a person derived threshold would need already exists, one join away from the thing doing the guessing.

the one-directional gate is the clever part, a declared budget can only raise the threshold, never lower it, so lying about it just gets you stopped early instead of getting away with more. the piece I don't see handled yet: what happens when two separate sessions, or two restarts of the same buggy loop, are both hitting the same real-world resource at once, say the same recipient list. each one declares 400 and gets capped at 400 individually, but if the budget lives per-session rather than per-resource, the actual total delivered is 800 and nothing looks wrong from inside either session. feels like the aggregate needs to be scoped to the thing being acted on, not the thing doing the acting, and that's a harder join given session ids are all you're asserting on the tool-action side right now

 

Worth separating the two failures you have stacked there, because only one of them is about trust. Session ids being client asserted is real, and it is not what breaks this. Give me a perfectly attested session id, signed, unforgeable, surviving restarts, and two honest sessions still deliver eight hundred. The scope is not weak, it is the wrong noun. Fixing the assertion buys nothing here, which is the useful part, because it means this does not get solved on the way to solving that.

The one directional trick does not survive the move either, and I would rather say so than let it stand. It works because the declarer is the thing being gated. You raise your own ceiling, so lying is self defeating. Once the number bounds a shared resource it has to be drawn down by parties who never declared it and have no reason to be bound by someone else's declaration. That is a lease, not a declaration. Advisory and self binding becomes stateful and other binding, and it is the first thing in this thread that cannot live inside a session at all.

On the join, I think you are being generous calling it hard. A join is easy once a key exists and there is no key. The resource has to be named identically by two callers who never coordinated. A recipient list is not an object with an id, it is an argument. One session built it from a query, the other from a CSV, same four hundred humans, two different strings, and nothing joins them. So a resource scoped budget needs a resource identity function per tool, and that is N functions rather than one mechanism. That is the actual reason nobody ships it, not the counting.

The version I would try is to count the leaves instead of the batch. You do not need to know that list A and list B are the same list. You need to know that one address has been sent to twice in ten minutes. The element is usually already its own canonical name, an address, an account id, a row id. The batch has no identity, the leaves do. The cost is that the tool has to enumerate its effect set before acting, which is fine for send to a list and useless anywhere the effect set is not knowable in advance.

Ours is worse than you are assuming, and then better in one place. The tool action rows are receipts. They are written from a post tool event, the target is truncated for display, nothing indexes it, and the insert sits in a try catch that logs on failure. Even with your join that reports the eight hundred, it cannot prevent it. Same bug lives in our own domain, since our targets are files and commands, and two sessions editing one file is your problem with a smaller blast radius.

Then the one place we built exactly your shape. Daily spend is a counter in Redis keyed on the site and the day, with no session anywhere in the key, so every session and every restart draws down the same number and the trip fires once under concurrency. Resource scoped, actor blind, enforced server side. It still only caps, because it counts after the spend lands. Scope and timing turn out to be separate constraints, and you have fixed the one I did not.

Thirty eight comments in and every gate described here fires on a single action. The axis nobody has put on the board yet is accumulation. Reach is being computed per action, and the thing that actually hurts is reach per window. One email to one person is low reach and passes every gate in this thread. Four hundred of them in twenty minutes is the mass email to 50k, assembled one approved step at a time, and no individual step ever looked like the thing you were guarding against. That is also why reach times signed-off-by is close but one term short. It needs a rate. A signed-off action is a licence to do that thing, not a licence to do it four hundred times, and an approval today carries no count and no clock. Two concrete moves. Gate on the running total inside a window rather than on the action. And expire the approval, so if the agent returns to the same signed-off action ten minutes later it has to ask again, because what it is doing now is a different act from the one that was approved. On your what-told-you question, the honest answer for accumulation is that nothing tells you, because every individual part looked fine on the way past. That is the whole failure mode of per-action gates.

 

Accumulation is on the board, a couple of branches up, and the conclusions there are worth having rather than re-deriving. Short version of where that one landed. A running total gates call N plus one, so it caps rather than prevents, because it counts after the effect has already landed. And the hard part is not the counting, it is the naming: the running total has to be kept against a resource that two callers who never coordinated would name identically, and a recipient list is not an object with an id, it is an argument. Same four hundred humans assembled from a query and from a CSV are two different strings and nothing joins them. That is why the first of your two moves keeps getting proposed and never shipped.

Your second move is the better one and I do not think it got its due, including from you, because you listed it second. It is a different kind of fix. The running total is a control on the aggregate and it needs to name the world. An expiring approval is a control on the grant, and it needs nothing you do not already have, since the approval has an id and a timestamp for the simple reason that you issued it. You get most of the protection without ever counting anything outside your own system.

The mechanism is that re-asking is itself the throttle. Four hundred sends against a ten minute expiry is two questions, not four hundred, and you never had to know what a list is. The failure mode is precise, though, and it is worth building against from the start. It only holds if the person answering notices that this is a re-ask. If the second question looks identical to the first, the second answer is identical too, and you have converted an accumulation problem into a fatigue one. Somebody tapping yes for the fortieth time is not a control, they are a rubber stamp with latency. So the re-ask has to carry the count. Approve this again, thirty ninth time in twenty minutes, is a different question from the first one, and it is the only moment in the entire system where the aggregate can be put in front of somebody who is already looking. Which is the real answer to nothing tells you. The gate does not have to detect the accumulation. It has to display it.

Ours does the exact inverse and I would not have gone looking without this. Repeated approvals of one pattern get mined over a month, and past a small threshold with a high enough approval ratio the system offers to stop asking. Accept and it writes a rule with a zero timeout and an approve action. No count, no window, no expiry. So repetition is our evidence for trust and yours is evidence for alarm, and the only thing standing between the two readings is how wide the window is. Same statistic, opposite sign.

The inversion is cleaner than that even. Our pending question has an expiry column. The permission it produces does not. The request expires and the grant is forever, which is precisely backwards from what you are arguing.

One place we did build your shape. A scope contract ratified for the current run is the only thing in our gate allowed to override a standing auto approve, and it dies with the run. So the general form might be that a licence should be scoped to the smallest unit that has an end. A run ends. A window ends. A row does not.

Earlier this issue was causing severe token burns for the company, now over the period of time, the dev team members solved the same by making sure each project has the library ready first, UI components fixed before the AI agent starts working on things.

 

Yours is the only control described in this thread that actually saves tokens, and I do not think that is incidental.

Everything else here gates a call that already exists. Our hook receives the tool name and its arguments on stdin, so by the time anything has an opinion the call has been generated and paid for. A deny buys back the consequence and none of the cost, and it usually costs more than an allow, because the model then re-plans and tries again. Approval is a control on what happens. Yours is a control on what gets produced, and only the second one shows up on the bill.

The difference is where the constraint lives. A library that is already there sits in the context from the first turn, so the wrong call never forms. Our rules sit behind the wall. The agent finds out what is gated by trying it, one deny string per round trip, and hitting the first rule teaches it nothing about the second.

What makes that worse rather than merely true is that we already write a block into the agent's memory file, and the comment above that code says agents only reach for a tool when something always in their context tells them to. So we know the mechanism and we use it for the behaviour, ask before anything risky, and not for the rules, here is what is auto approved and what is not. Same file, half the payload.

Where your fix stops is the part worth marking. Pre-placing works when the right answer is knowable before the run starts. A component library qualifies. Which of four hundred sends is acceptable does not, because it depends on the state of the world at the moment of the call. Everything on that side has to be generated before it can be stopped, so the burn there is the floor rather than the failure.

coming back to this after the thread moved past my original point, and I think Dale's precision-over-presence idea actually closes it. my compare-and-increment argument was that a budget check has to be atomic or two concurrent calls both read the same stale counter and both proceed. but the real fix isn't making that check atomic, it's not handing the agent the counter at all. same move as the credential case: don't give it the value and let it decide, give it a predicate, would this exceed budget, yes or no, answered by whatever owns the counter. the race disappears because there's nothing left for the agent side to read-then-act on, same as there's no card number left to leak once you're only answering matches-on-file.

 

makes a lot of sense on this one, if there's something more I can help with, I'm a nudge away.

appreciate that, thanks. this thread ended up covering a lot of ground since I first jumped in, good stuff.

 cheers mate