DEV Community

Gerardo Arroyo for AWS Community Builders

Posted on Originally published at gerardo.dev

Temporal Policies in AgentCore: 98 Denied Deploys and a 409 That Never Came

Cover

I asked a Strands agent for something reasonable: “run the tests and deploy.” It issued run_tests and deploy in the same turn, and the Gateway rejected the deploy. I repeated the task in ten sessions and got ten rejections.

The policy that blocked it said exactly that: “allow deploy only if there was a completed run_tests in the last five minutes.” When the deploy reached the Gateway, the tests were still running. The policy did its job; the one that didn't wait was the agent.

AgentCore Policy's Temporal Policies let you write rules that depend on what the agent did earlier in the session, enforced at the Gateway, outside the agent's code. The documentation covers the happy path well: mandatory sequences, per-session limits, time windows. What it doesn't cover is what happens when the agent parallelizes, when a request gets denied, or when you deploy a new policy with live conversations. To find out, I built a Gateway with simulated tools, wrote down my hypotheses before running anything, and fired 5,294 calls at it.

📌 TL;DR — What I found

  • With parallel calls, the dependent deploy was denied 98 of 100 times. Sequentially, 0 of 600, even with no added delay. A Strands agent parallelized in all 10 sessions.
  • Denied requests also consume the budget of a ::request-based limit: 40 of 40 trials.
  • A stale session arrives as HTTP 200 with JSON-RPC code -32005, not as the 409 ConflictException the documentation mentions.
  • Only changes to temporal policies invalidate live sessions. Stateless Cedar policies and changing the Gateway mode don't touch them.
  • Default validation rejects the plain permit the documentation asks for, and also a forbid created before its permit.
  • Evaluating the temporal part costs between 20 and 27 ms with up to ~1,000 calls of history.

If you haven't read the AgentCore Policy article, that's where I explain the default-deny model and Cedar. Here I assume you already know it.

What Temporal Policies Are

A regular Cedar policy is stateless: it decides by looking only at the current request. A Temporal Policy adds session memory. It's written in Dogwood, an open source policy language compatible with Cedar (all valid Cedar is valid Dogwood), and its conditions go in a when temporal { ... } block with operators like formerly within, since within, count, and sum.

Two pieces make this work. The first is the policy session: you generate the identifier and send it in the x-amzn-bedrock-agentcore-policy-session-id header from the very first request. The Gateway doesn't generate it; it binds it to the caller's authenticated identity and expires it after 24 hours of inactivity. The second is the event. Each action produces up to three kinds —request, response (only if the tool finished successfully), and error— and temporal predicates look for events of those kinds within a time window.

This is the sequencing policy I used:

permit (
    principal,
    action == AgentCore::Action::"ToolsTarget___deploy",
    resource == AgentCore::Gateway::"<ARN-DEL-GATEWAY>"
)
when temporal {
    formerly within 5m AgentCore::Action::"ToolsTarget___run_tests"::response{
        eventResource: resource
    }
};
Enter fullscreen mode Exit fullscreen mode

deploy is allowed if there was a run_tests response in the last five minutes. For that response to land in the history, the documentation also asks for an unconditional permit for run_tests, because an action is only recorded as response if some policy allowed it. That plain permit comes back in Finding 4.

The documented quotas are 20 temporal policies per engine, 3 operators per policy, and a maximum window of 24 hours.

How I Measured It

The setup was deliberately simple. A simulated Lambda with five tools (run_tests, deploy, read_secret, send_webhook, and rollback), with fake data and no network egress. A Gateway with AWS_IAM authentication in us-east-1. And a Python harness that signs with SigV4 and writes each call to an immutable JSONL with timestamp, request ID, decision, latency, and the full response. Every table in this article comes out of those files by script; none is handmade. For the agent test I used Strands and Claude Sonnet with the us. inference profile.

One detail for reading the data: the Gateway returns denials as HTTP 200 with a JSON-RPC error -32002, not as a 4xx. You have to read the decision in the response body, not in the HTTP status code.

The method is the same as in the Optimization Loop A/B test: before running anything I wrote down each hypothesis with the criterion that would refute it, and I recorded every later design change (they ended up being ten). The scripts, the policies, and the harness are at github.com/codecr/agentcore-temporal-policies.

The N=5 pilot corrected an assumption before I spent for real. I had defined “zero delay” as “the client sends the dependent request as soon as it receives the response.” When I measured it, that zero was actually a gap of about 15 ms between one and the other, so I added a parallel condition to the sequencing test and logged it as the first deviation.

🔍 ProTip #1: Run a pilot with N=5 per condition before scaling any measurement against a service. Mine cost less than a cent and showed me my “zero delay” wasn't zero. Starting with N=100 I would have spent the time measuring something else.

Finding 1: If Your Agent Parallelizes, It Loses the Deploy

This is the question that started the article. My hypothesis was that the run_tests response event would take a while to become available, so a deploy sent right after receiving the response would be denied in some sessions. The criterion to refute it was zero denials with 0 delay across 100 sessions.

Condition Sessions deploy denied Rate (95% Wilson CI)
Sequential, 0 ms delay 100 0 0.0% [0.0, 3.7]%
Sequential, 50 / 100 / 250 / 500 / 1000 ms 100 per cell 0 0.0% [0.0, 3.7]% per cell
Parallel (two threads at once) 100 98 98.0% [93.0, 99.4]%
Strands agent, “parallel” prompt 5 5 N=5, no useful interval
Strands agent, “neutral” prompt 5 5 N=5, no useful interval

The hypothesis is refuted. With a median gap of about 15 ms between the run_tests response and sending deploy, there wasn't a single denial in 600 sequential sessions: the event is already available by the time the response reaches the client.

The problem shows up when both requests go out at once. deploy is evaluated almost immediately, while run_tests takes about 770 ms to respond (measured median), so the Gateway sees a deploy with no completed tests and denies it. That's the policy's correct behavior. AWS already recommends sending the dependent request after receiving the previous response (section Sequencing actions that depend on a prior response), and its Dogwood blog mentions that agents make parallel calls. What this experiment adds is the magnitude: 98 of 100.

The two deploy calls that did go through in the parallel condition (repetitions 14 and 92) had latencies of 1,045 and 1,152 ms, against a median of 764 ms, which is consistent with their evaluation happening later than usual.

With an agent in the loop, the question changes: does the model wait for the response or parallelize? I gave a Strands agent the task “run the tests and deploy” with two prompt variants, one that asked for parallelism and a neutral one, in 5 sessions each. In all 10 sessions it issued run_tests and deploy in the same turn, including the neutral variant, and the deploy was denied in all 10. No session retried the deploy: each one closed with two Gateway calls and two model cycles, so the agent received the rejection and finished without trying again.

One more detail for whoever has to debug this: when deploy was called with no prior run_tests, the message was Tool Execution Denied: Tool call not allowed due to policy enforcement [No policy applies to the request (denied by default).]. The message doesn't distinguish “not yet” from “never.”

🎓 ProTip #2: If two of your agent's actions depend on each other, don't let the model decide the order. Serialize in the client or the framework, or design a single tool that does both internally. The second option costs you visibility, because the policy no longer sees the intermediate step; the first costs you latency. Choose knowing which of the two you're paying.

Finding 2: Denials Also Burn Your Limit

Here my hypothesis was also wrong, and this time in a direction the documentation doesn't anticipate.

The event schema documentation says request is recorded “for each authorized request” and that denials produce an error event. Under that reading, a burst of denied requests shouldn't affect a limit that counts ::request. To check, I combined two policies. The first is a per-session limit that blocks send_webhook when there are more than five ::request events in ten minutes (the count includes the current request):

forbid (
    principal,
    action == AgentCore::Action::"ToolsTarget___send_webhook",
    resource == AgentCore::Gateway::"<ARN-DEL-GATEWAY>"
)
when temporal {
    exists (n: Long).
        (count for (t: Timepoint).
            where (formerly within 10m (AgentCore::Action::"ToolsTarget___send_webhook"::request{ eventResource: resource } && tp(t)))) == n
        && n > 5
};
Enter fullscreen mode Exit fullscreen mode

The second is a stateless Cedar rule that denies any send_webhook aimed at https://blocked.invalid/hook. In each new session I sent K calls to that URL, all denied, and then up to 8 allowed calls, noting which of them hit the first limit block. Twenty trials per value of K, in random order.

If denials don't count, the limit always fires on the sixth allowed call. If they do, it fires on call 6−K.

K (prior denials) Trials First limit block, among allowed calls If they DON'T count If they DO count
0 20 6 6 6
3 20 3 6 3
10 20 1 6 1

All 40 trials with K greater than zero followed the 6−K pattern without exception. The denial messages confirm it another way: of the 260 calls denied by the URL rule, 160 name only that policy and 100 name both. Those 100 are denials number 6 through 10 of each K=10 trial (five per trial, twenty trials). While one rule was rejecting the requests, the limit was counting them. One caveat, though: don't parse that list of determining policies. In the messages that name both, the order came out one way 55 times and the other way 45.

The cause isn't documented, so this is my interpretation. The Dogwood guide describes request as the only “decision” event kind: ingesting it is what triggers authorization, while response and error are history only. That's consistent with the event being recorded before the verdict is known, but the guide doesn't say so explicitly.

The scope is narrow: I only tested denials from a stateless Cedar rule, not the default denial when no policy applies. The practical consequence is uncomfortable. With broad per-user sessions, which the documentation lists as a valid strategy, an agent stuck retrying against a rule that rejects it could exhaust its own limit and stay blocked until the window passes.

⚠️ ProTip #3: If your limit counts ::request, every denied attempt counts. According to the documentation, response is only recorded when the action finishes successfully, so counting ::response should count only what actually ran. I didn't test that change, but it's the first thing I'd try.

Finding 3: The Stale Session Doesn't Arrive as a 409

According to the documentation (section Session invalidation), adding or updating a temporal policy invalidates the engine's active sessions, and the next request that reuses an invalidated session fails with HTTP 409 ConflictException. To recover, you open a new session. I tested each trigger with three live sessions: I waited for the policy to be ACTIVE, added 10 seconds, and called twice with each session.

Through the Gateway's MCP path (tools/call) I never received a 409. All 42 observations (21 invalidated sessions, two calls each) returned the same thing: HTTP 200 with this JSON-RPC body.

{"code": -32005, "message": "Tool Execution Denied: Policy session is stale. Policies were updated after the session started. Start a new session and retry."}
Enter fullscreen mode Exit fullscreen mode
Trigger Invalidates live sessions?
Create temporal policy Yes (3 of 3, in two runs)
Update temporal policy Yes (3 of 3)
Delete temporal policy Yes (3 of 3, in two runs)
update-policy with no real change (the policy was already ACTIVE) Yes (3 of 3, in two runs)
Create or delete a stateless Cedar policy No (0 of 3)
Gateway from ENFORCE to LOG_ONLY, and back No (0 of 3)

Several behaviors the documentation doesn't mention come out of this table. Only changes to temporal policies invalidate sessions; a stateless Cedar policy or a Gateway mode change doesn't affect them, even though AWS's launch blog talks about any change to the engine's policies. Deleting a temporal policy also invalidates, even though the documentation only mentions adding or updating. A stale session doesn't recover on its own: all 21 invalidated sessions were still stale on the second call, while a new session worked in 7 of 7 trials where the trigger did invalidate. And an update-policy with no effective change also invalidates.

I found that last case by accident. The trigger I had designed as “from LOG_ONLY to ACTIVE” ended up being an update-policy on a policy that was already ACTIVE, because the previous step failed (I cover it in the operational gotchas).

A design consequence follows from the documentation: a new session starts with an empty history, so deploying a temporal policy also resets the limit counters and sequencing preconditions of every live conversation.

💡 ProTip #4: Treat -32005 as a flow-control signal, not as an error for the user. Your client should catch it, open a new session, and re-establish the preconditions (for example, run the tests again). If you don't handle it, every retry with the same ID will fail: in my tests, no stale session recovered on its own.

Detection has to look at the body, because the HTTP status is 200 both for a denial (-32002) and for a stale session (-32005). This is the criterion my harness used to classify responses, with the stale session case added:

DENEGADO = -32002
SESION_OBSOLETA = -32005

def clasificar(cuerpo):
    err = cuerpo.get("error") if isinstance(cuerpo, dict) else None
    if isinstance(err, dict):
        if err.get("code") == SESION_OBSOLETA:
            return "STALE"  # sesión nueva, rehacer precondiciones, reintentar una vez
        if err.get("code") == DENEGADO:
            return "DENY"
        return "OTRO"
    return "TOOL_ERROR" if cuerpo.get("result", {}).get("isError") else "ALLOW"
Enter fullscreen mode Exit fullscreen mode

The recovery in the comment (new session, redo the preconditions, and a single retry) isn't part of the experiment: it's what I would do, not something I measured.

Finding 4: Strict Validation vs. the Documented Pattern

These two gotchas cost me an afternoon, and both come from the default policy validation (FAIL_ON_ANY_FINDINGS).

The plain permit the documentation asks for fails. For run_tests to be recorded as response, you need an unconditional permit for that action. With default validation, the policy goes to CREATE_FAILED with two reasons, one per principal type:

Overly Permissive: Policy Engine will allow every request for the specified principal (AgentCore::IamEntity), action (ToolsTarget___run_tests) and resource (arn:aws:bedrock-agentcore:us-east-1:<CUENTA>:gateway/<GATEWAY>) combination if the policy is added or updated
Enter fullscreen mode Exit fullscreen mode

The second reason is identical, but with AgentCore::OAuthUser, even though my Gateway uses AWS_IAM. I solved it with --validation-mode IGNORE_ALL_FINDINGS only for the plain permit policies, and kept strict validation on the temporal ones.

A forbid created before its permit also fails. The count limit I used to measure latency (with an unreachable threshold) failed twice with Overly Restrictive: Policy Engine will deny every request for the specified principal ..., first with n > 100000 and then with n > 5000. It passed on the third attempt by changing a single thing: I created the action's permit first and the forbid afterward. I saw this in a single case (count on read_secret), so I don't generalize it.

This leaves an operational problem. The fail-closed order, with the forbid first so there's never a window without a limit, is incompatible with strict validation. If you apply the permit and then the forbid, for a moment the action is allowed without its limit. In my case I avoided it by creating everything in an environment with no traffic. In production you'll need another strategy; one option is to create the policies in a new engine before associating it with the Gateway.

🚨 ProTip #5: If you automate policy creation in a pipeline, test the permit and forbid order with strict validation before you depend on it. And write down in your runbook which order you'll create them in and what exposure window you're accepting.

Finding 5: Temporal Evaluation Costs About 20 ms

The hypothesis was that count and sum would get slower with a large history, while formerly would stay nearly flat. I measured temporal evaluation latency with the aws.agentcore.policy.temporal.latency_ms attribute from the Transaction Search spans, in a single session per operator with 1,000 consecutive calls.

Operator Prior history P50 P99
count ~10 calls 21 ms 26 ms
count ~100 21 ms 24 ms
count ~1,000 24 ms 27 ms
formerly ~10 21 ms 39 ms
formerly ~100 20 ms 25 ms
formerly ~1,000 20 ms 24 ms

By the criterion I set before measuring (refuted if the P99 difference between 10 and 1,000 calls is under 10%), the hypothesis is refuted for count: the difference is 3.8%. The per-decile medians show something finer. count climbs from 20 to 24 ms over the 1,000 calls, about 0.29 ms per 100, while formerly stays at 21 ms across all ten deciles. There is growth, but it's so small it isn't an operational problem. The floor of about 20 ms exists even with an almost empty history, and against the ~800 ms of the full call (which includes the Gateway and my simulated Lambda) it represents around 3%.

Read this table carefully. There are 20 samples per window, so the P99 is practically the maximum. The ~10-call window always corresponds to the first 20 calls of the run, so history size and warm-up are mixed together; the 39 ms for formerly probably comes from there. I also counted calls, not events: each call leaves at least one request and one response, so the real history is larger than the nominal one. One formerly call timed out (n=640).

Your Backend Should Choose the Session ID

The documentation already covers this point; here I just quantify it. A per-session limit doesn't work against whoever picks their own session ID. With 20 trials per condition: with a fixed ID, the block arrived on call 6 in 20 of 20; rotating the ID on every call, 0 of 20 trials were blocked; and with the ID derived in a backend via an HMAC of the user, 20 of 20 again, with correct isolation between users in 20 of 20.

The documentation also warns that using a new ID on every request effectively disables temporal policies.

The derivation I used is a single HMAC line with a key only the backend knows:

import hashlib, hmac, secrets

class BackendSesion:
    def __init__(self):
        self._clave = secrets.token_bytes(32)  # en producción, desde Secrets Manager

    def session_id_para(self, usuario):
        return hmac.new(self._clave, usuario.encode("utf-8"), hashlib.sha256).hexdigest()
Enter fullscreen mode Exit fullscreen mode

The result is 64 hexadecimal characters, within the format the header accepts (1 to 128 characters [A-Za-z0-9-]). In the experiment I derived the ID from the user only, which gives one broad session per user. If you want per-conversation sessions, add the conversation ID to the HMAC message.

🔧 ProTip #6: Generate the session ID in your backend from the user and the conversation (an HMAC is enough), never in the agent. A per-session limit that the agent can reset with a new UUID protects nothing.

Operational Gotchas

These aren't specific to Temporal Policies, but you'll run into them if you repeat the experiment.

To measure latency you need Transaction Search, and the X-Ray segment destination goes through a PENDING state. Meanwhile, CreateDelivery fails with X-Ray Delivery Destination is supported with CloudWatch Logs as a Trace Segment Destination and UpdateTraceSegmentDestination with Updates are not allowed while the current status is PENDING. Wait until it reaches ACTIVE. Each policy span carries an aws.request.id equal to the response's x-amzn-RequestId, and with that you can join spans and calls.

Switching a temporal policy from ACTIVE to LOG_ONLY failed with UPDATE_FAILED in both runs. In the second, the reason was Policy references an action that does not exist on the target gateway. Verify the target and tool names, then try again. The action does exist on the Gateway.

The caller signs with SigV4 using the bedrock-agentcore service, a detail the inbound authentication documentation doesn't mention.

What a Temporal Policy Guarantees and What It Doesn't

What I observed it guarantees What it doesn't guarantee you
Denies deploy if there was no completed run_tests That your agent waits: it parallelized in all 10 sessions and the deploy was denied in all 10
Blocks call 6 of a per-session limit (20 of 20) A limit if the agent picks its own ID (0 of 20 blocks with a rotated ID)
Counts denied requests in a ::request limit That it counts only what ran, unless you count ::response (not tested)
Invalidates live sessions when temporal policies change A 409 (you get a -32005), or that the new session keeps the history
Evaluates in 20 to 27 ms with up to ~1,000 calls of history That those numbers apply to sum, since, another model, another region, or another time of day

Limits of the Experiment

The agent test used a single explicit task, a single model, and 5 sessions per variant. Everything ran on a single day (between 10:54 and 17:50, Costa Rica time), in one region, one account, one Gateway, and with a simulated Lambda, and the 20 samples per window in the latency test are few.

This is what remained untested:

  • How a Strands agent with default retries reacts to a -32005, and whether the counter reset that follows from the documentation looks the same with a real agent.
  • Counting ::response instead of ::request, and the default denial when no policy applies.
  • Whether the 409 ConflictException exists at another layer, such as the control plane.
  • Switching from LOG_ONLY to ENFORCE at the policy level. I didn't find the cause of the UPDATE_FAILED; creating the policy directly in LOG_ONLY is still pending.
  • A strategy for creating permit and forbid in production without an exposure window.
  • The sum and since operators, and CUSTOM_JWT authentication.
  • What happens when the agent runs in AgentCore Runtime and sends its own session header: whether the session ID that travels in the identity token (WAT) wins or the agent's does. The documentation doesn't say and I didn't get to test it.
  • A Gateway with no authentication, where according to the documentation all callers with the same ID share the history.
  • A deployed backend for the session ID. Mine was a local module and all the simulated users shared the same IAM identity, so I evaluated the derivation logic, not a service.

Those are the gaps I'd most like to close.

Conclusion

The experiment boils down to three design decisions. Serialize in the client the actions that depend on each other, because your agent can parallelize without you asking: mine did in all 10 sessions. Generate the session ID in your backend, because a limit the agent can reset doesn't limit anything. And handle -32005, because every time you deploy a temporal policy you'll invalidate live conversations, and the new session starts without the history the old one had.

The service behaved almost always as the documentation describes. The biggest surprise was the agent, which didn't behave the way I assumed. The rest were at the edges: what counts as an event, which code arrives when a session goes stale, in which order validation accepts your policies. Those edges are what's worth testing before production.

🚀 Final ProTip: Before trusting a temporal policy to protect something important, measure it with your own agent, your own model, and your own session strategy. In my experiment, the biggest surprise came from the agent, not the policy.

If you're coming from the AgentCore Policy article, Temporal Policies are the piece it was missing: Cedar decides on a request; Dogwood, on the session's history. And if you're interested in the method, the Optimization Loop A/B test applies it, negative result included.


Are you already using Temporal Policies, or are you still enforcing sequences and limits inside the agent's code? How are you generating the session ID? If you replicated any of these tests or measured what I left out, I'd really like to compare — comments are open.

See you in the next article! 🚀


Official Resources 📚

Top comments (0)