An agent gateway blocks a sequence: identity mutation followed by credential recovery.
The rule is simple. If a session changes the contact email on an account, the same session cannot request credential recovery. The predicate is correct. Tests cover it. Receipts show the prior action list, the mutation event, the recovery attempt, and the block decision. During review the guard looks solid, because the dangerous pair appears in one place and the system says no.
Now split the same two calls across two sessions. Session one changes the contact email on the customer record and ends. Session two starts fresh and asks for credential recovery. The prior actions list comes back empty. Same principal. Same customer record. Same attack. The guard allows it.
Nothing in the predicate failed. The memory it consulted was indexed by session_id, while the attack was indexed by the customer record.
Every runtime guard is backed by state, and that state has a primary key. Most guard failures are key bugs. The check's logic is right, yet the memory it consults is indexed by the wrong thing: a session, a resolve instant, a whole fleet. The attack composes over some other coordinate. The adversary wins without ever touching the predicate, by picking coordinates the index cannot see. The key under a guard's state table is a security decision, and almost nobody writes it down.
The failure tends to appear in a few shapes. The key is too narrow in lifetime, so state dies before the invariant does. The key is frozen in time, so a live task carries an old answer. The key is too wide in population, so a fleet statistic washes out the single binding that mattered.
The opener is the narrow lifetime case. The invariant says, "no credential recovery after identity mutation on this record." Read that sentence slowly. The noun that carries the risk is the record. A session is an implementation container, useful for tracing, authentication freshness, rate limits, and user experience. It indexes the attack; it does not contain it.
A session-keyed guard enforces a smaller invariant: no recovery after mutation inside this session. That sentence sounds related to the design goal, and the resemblance is exactly the trap. The session version passes every test that puts the whole attack inside one session. It even produces clean logs. The evidence looks complete because the query asked a database question whose answer was truly empty.
This is a hard kind of bug to review. The missing fact exists. It just sits under another key.
The fix changes the state table before it changes the predicate. Store identity mutation history under the record identity, or under a stable authorization object that maps cleanly to the record. When recovery begins, ask for recent identity mutations on that object. The query now speaks the same language as the invariant.
The narrow key gets worse in multi-agent systems. One agent updates the contact email as part of account maintenance. Another agent handles credential recovery as part of support. Each agent has a clean session. Each session performs one innocent-looking operation. The sequence guard sees no sequence because its state table has no row where the sequence exists. Object-keyed history sees it immediately.
The second shape is time. A multi-tenant policy cascade computes each workspace's effective ceiling as an intersection of parent policy, workspace policy, skill policy, and task policy. At resolve time, the system calculates the current ceiling. Skills bind their grants when they load. The resolver re-evaluates on the next access. The dashboard shows a neat answer for the workspace.
Then a ceiling tightens.
All the interesting points are already in the past for work that is running. A task that started earlier carries the old intersection for its lifetime. A resident poller resolved once and keeps doing useful work. A loaded skill holds the grants it saw at load time. The dashboard question, "what is this workspace's policy right now," is answered by data that really means "as of last resolve."
Again, the predicate can be correct. The resolver can compute the exact intersection. The problem is the key under the resolved grant. If resolved policy is keyed by the task or the cached access path, then "current ceiling" has quietly become "ceiling when this thing last asked."
Revocation then turns into a drain problem. Wait for tasks to end. Restart workers. Flush caches. Hope resident components touch the resolver again soon. The guard's memory has no direct way to distinguish current permission from stale permission, so the runtime has to manage time indirectly.
A cleaner shape is to stamp each resolved policy with a generation counter. The policy authority increments the generation whenever the effective ceiling changes. In-flight work carries the generation it resolved against and presents it on each outbound call. The callee compares that generation with the current one for the workspace or object, and rejects stale generations. Revocation becomes comparison.
This adds a new runtime behavior. A task can lose permission mid-task. That rejection path has to be real, handled, logged, and made visible to the caller. It will surface failures that used to hide behind long-lived grants. That discomfort is the price of saying "current ceiling" and meaning current.
(Database people settled their arguments about key choice decades ago; agent systems managed to reopen them inside policy caches.)
The third shape points the other direction. The key is too wide.
Consider a marketplace monitor watching payment addresses across thousands of listings. The system records address changes and promotes every observed change to a swap-attack label. It sounds reasonable. Payment address changed, payment fraud is a concern, alert.
The alert stream is useless.
Most changes are honest. Vendors mint fresh addresses per quote, and treasuries migrate. A listing crawled in the middle of a migration looks unstable from the outside even when nothing is wrong. The monitor tries to improve the rule with speed thresholds: two addresses within ten seconds, or three changes inside one crawl window. The false positives remain, because per-quote minting is fast too.
The fleet view has the wrong key. It asks whether this listing changed addresses across a population of observations. The attack lives inside one request lifecycle: this caller was quoted address A and is now being asked to sign a payment to address B. Those are different facts. No amount of fleet statistics converges on the intra-request binding, because honest rotation and malicious substitution both look like address churn from the fleet coordinate.
The fix is client-side pinning. Bind the payment address at the spend decision. When the caller receives a quote, store the address alongside the request identity. When a spend is prepared, compare the address being signed with the pinned address. Halt on mismatch.
False positives against honest rotation become structurally zero. A vendor that mints per payer gives one payer one address for that request. It can rotate freely across other payers and later quotes without ever contradicting itself toward the signer. The guard stops asking whether this vendor changes addresses and starts asking whether this caller is being asked to pay the address they were quoted. The second question is the invariant.
This is the general method. Read the invariant aloud and find its noun. That noun is the key. "The payer pays the address they were quoted": the key is the request. "No recovery after mutation on this record": the key is the record. "Work runs under the current ceiling": the key includes the current policy generation, because time is part of the claim. After the noun, ask two questions.
Who can span this key? That is the split attack. If the guard is keyed by session, can the adversary use two sessions against one record? If the guard is keyed by task, can old permission ride into a later call? If the guard is keyed by listing, can the fraud move into the individual request?
Who can hide inside it? The averaging failure. If the guard watches a fleet, can the attacker operate inside one caller's lifecycle while fleet behavior stays normal? If the guard watches a workspace, can one object carry the sensitive history that the workspace aggregate smooths away?
These questions are mechanical, which is what makes them useful. They force the design review away from predicate aesthetics and toward the state table. What rows exist? What columns identify them? How long do they live? Which services agree on the identifier? What does the guard actually query when it says "previous," "current," "same," or "this"?
The hard part is that better keys cost more. Object-keyed history needs a durable store, a retention policy, and cross-service agreement on object identity, including merges, deletes, aliases, and migrations. It creates privacy questions, because security memory now outlives the interaction that produced it. None of that fits inside a tidy session object.
Generation checks cost more than cached grants. In-flight work must handle rejection after it has already started. Retries need to re-resolve policy. Partial progress needs a consistent story. A stale grant stops being a theoretical concern and becomes a production outcome.
Client-side pinning has costs too. The signing component needs a place to store the binding, the quote and the spend need a shared request identity, and recovery flows need care so a restarted payment does not accidentally preserve a stale address. The server side loses the comforting illusion that a fleet monitor can settle the question alone.
Concede those costs.
Then be precise about the trade. Session-keying was never a cheaper enforcement of the same invariant; it enforced a weaker one that happened to share a sentence with the design doc. The fleet monitor answered a real question about population behavior, just a different question. And a cached grant is a past policy with no expiry the guard can see.
Security design often treats state as plumbing beneath the predicate. For runtime guards this is backwards. The predicate describes the shape of the forbidden thing, and the key decides whether the guard can remember that shape long enough, freshly enough, and locally enough to stop it.
When you write a guard, write its primary key next to its predicate. A mismatch between the key and the invariant's noun is a bug of the same severity as a wrong predicate.
Top comments (0)