
I tried out "Temporal Policies" added to Amazon Bedrock AgentCore Policy
This page has been translated by machine translation. View original
Introduction
Hello, I'm Kamino from the Consulting Division, a supermarket enthusiast.
On August 6, 2026, a new feature called "Temporal Policies" was added to AgentCore Policy. (I happened to go on vacation right at that timing, so quite a bit of time has passed...)
It has quite a cool-sounding feature name, but this is a feature that can determine whether to allow or deny the next tool call based on what operations the agent has performed in the past within the same session.
Previously, Policies were written in Cedar, and I'm curious about how this new feature integrates with that. I'd like to explore it hands-on!
Scenarios Where Temporal Policies Seem Useful
When would the new Temporal Policies feature be useful?
For example, let's think about a quote → order process. If you prepare a quote tool and an order tool respectively, and leave tool execution up to the agent, there may be cases where it places an order without warning.
So when you want to make the execution of the quote tool a prerequisite—such as placing an order using the quote ID and amount returned by the quote tool, or retrieving a list of items to delete and then specifying only IDs from that list—this is where Temporal Policies can be used.
The image below shows what kind of checks are being performed.

This seems useful when you want to manage procedures spanning multiple tools separately on the outside, rather than in the agent's logic. It's great that while conveying procedures through prompts, operations that skip necessary confirmations or operations that contradict confirmation results can be stopped at the Gateway!
Dogwood
For writing these Temporal Policies, a new policy language called Dogwood is used.
Dogwood is an open-source language published by AWS under Apache 2.0, extended from Cedar. Cedar-compliant policies can be used as Dogwood policies as-is, so previously written policies are not wasted, and you can now write Cedar syntax with an added temporal dimension of "what operations were performed in the past."
(By the way, Dogwood is the English name for trees in the genus Cornus, but I wonder if the naming was inspired by Cedar's sugi tree...!)
The specific writing style is as follows. Using the order policy I'll use for verification as an example, I've added comments to clarify the parts that are original Cedar and the parts extended by Dogwood.
// Cedar style: order amount within 3,000 yen
permit (
principal,
action == AgentCore::Action::"ShoppingTarget___place_order",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when { context.input.amount > 0 && context.input.amount <= 3000 }
// From here is Dogwood's extension: cart with the same content confirmed within 1 minute
when temporal {
formerly within 1m AgentCore::Action::"ShoppingTarget___get_cart"::response{
eventResource: resource,
output.cartId: context.input.cartId,
output.store: context.input.store,
output.amount: context.input.amount
}
};
The part where permit specifies the target action and resource, and when checks the amount condition, is pure Cedar.
In Dogwood, by adding a when temporal block here, you can incorporate history-based conditions such as "the same cart content has been confirmed within the last 1 minute." The output.〜 inside the curly braces are values returned during past cart confirmation, and context.input.〜 are the arguments of the current order request. This verifies whether these properly match.
I'll take a closer look at the detailed syntax specifications in a later section.
Now that we understand the logic, let's verify it by actually trying it out!
What We'll Try This Time
This time, I'll use grocery shopping at my favorite supermarket as an example. Since my wallet only allows up to 3,000 yen per visit... let's have my agent representative stay within budget too!
The rule to verify is "place an order within 3,000 yen after first confirming the cart contents and total amount." I'll confirm that orders that skip the prior confirmation and orders exceeding 3,000 yen are properly blocked on the Gateway side.
For example, suppose the agent gets the amount wrong and tries to order a 4,000 yen cart as 2,000 yen. Since the upper limit check only looks at the argument's 2,000 yen, there's a possibility that a nonsensical order fabricated by the agent would go through. To stay within budget, the amount being compared needs to match the store's data.
So, I'll have the agent retrieve the store-side amount using the cart confirmation tool before placing an order. The Gateway and Policy reference that response as history and cross-reference it with the current order's cart ID, store, and amount.
This way, even if someone tries to order a cart returned as 4,000 yen by changing it to 2,000 yen, it won't match and can be rejected. We'll perform a set of checks for "having a record of confirmation" and "ordering with the confirmed values"!
The two tools to prepare are as follows.
| Tool | Processing |
|---|---|
get_cart |
Confirm the cart contents and total amount |
place_order |
Simulate order execution |

The shopping tools will be implemented as Lambda functions, the agent will be built with Strands Agents, and Claude Haiku 4.5 from Amazon Bedrock will be used as the model.
Note that the order tool is a mock implementation, so nothing is actually purchased, and please be aware that even if you were to implement it for real, you wouldn't build it this cheaply! (It would be a bit scary if purchases were made repeatedly during verification, wouldn't it... might go bankrupt...)
Prerequisites
The environments used in this verification are as follows.
| Item | Details |
|---|---|
| Region | ap-northeast-1 (Tokyo) |
| Local Python | 3.14.6 |
| boto3 / botocore | 1.43.88 |
| Agent | Strands Agents 1.54.0 |
| MCP connection | mcp-proxy-for-aws 1.6.5 |
| Model | Claude Haiku 4.5 (Japan inference profile) |
| Gateway | MCP, AWS_IAM authentication |
| Lambda | Python 3.14 |
| Policy Engine | ENFORCE |
Preparing the Shopping Tools and Gateway
First, let's create the Lambda functions that will serve as shopping tools and register them with the Gateway.
Shopping Tools
This time, I'll have a single Lambda handle both the get_cart and place_order tools.
When called via the Gateway, the tool name is passed in the context in the format ShoppingTarget___get_cart (target name___tool name), so the processing is branched by looking at the latter part after splitting on ___. get_cart returns the store and total amount based on the cart ID, and place_order is a simple implementation that accepts simulated orders.
As cart data for verification, I prepared 3 patterns: 3,000 yen, 2,000 yen, and 4,000 yen.
import json
# Verification carts. Products and amounts are assumed to be managed by the backend.
CARTS = {
"cart-001": {"store": "favorite-supermarket", "amount": 3000},
"cart-002": {"store": "favorite-supermarket", "amount": 2000},
"cart-003": {"store": "favorite-supermarket", "amount": 4000},
}
def lambda_handler(event, context):
tool = context.client_context.custom["bedrockAgentCoreToolName"].split("___", 1)[1]
print(json.dumps({"tool": tool, "arguments": event}, ensure_ascii=False))
if tool == "get_cart":
cart = CARTS[event["cartId"]]
return {"cartId": event["cartId"], **cart}
if tool == "place_order":
return {"status": "simulated_order_accepted", **event}
raise ValueError(f"Unknown tool: {tool}")
The guardrail parts such as the budget upper limit check and cart confirmation history check are not placed on the Lambda side, but are all entrusted to the Policy side.
Gateway Configuration
I've prepared setup.py for resource preparation. This script sequentially deploys the Policy Engine, Lambda function, IAM role, Gateway, target, and policies.
The Lambda function is registered with the Gateway as a target named ShoppingTarget, and the schemas for the two tools (get_cart and place_order) mentioned earlier are defined and linked.
When creating the Gateway, the policyEngineConfiguration specifies the created Policy Engine, and mode is set to ENFORCE (policy enforcement mode).
r = c.create_gateway(
name=s["name"],
roleArn=s["gatewayRole"],
protocolType="MCP",
authorizerType="AWS_IAM",
policyEngineConfiguration={"arn": s["engineArn"], "mode": "ENFORCE"},
)
With this configuration, any tool calls that don't match the policies are all blocked at the Gateway layer, and processing never reaches the Lambda behind it!
I'll also include the full text of the setup script.
Full environment creation code (gateway/setup.py)
"""Create the verification environment for the article and save the created resource IDs to a file."""
import io, json, time, zipfile
from pathlib import Path
import boto3
# Creation destination is Tokyo region. Save progress and load already-created information on re-execution.
ROOT = Path(__file__).resolve().parents[1]
STATE = ROOT / ".gateway-state.json"
s = (
json.loads(STATE.read_text())
if STATE.exists()
else {"region": "ap-northeast-1", "name": f"dogwood-shopping-{int(time.time())}"}
)
if s.get("cleanedUp"):
s = {"region": s["region"], "name": f"dogwood-shopping-{int(time.time())}"}
session = boto3.Session(region_name=s["region"])
c = session.client("bedrock-agentcore-control")
iam = session.client("iam")
lam = session.client("lambda")
s["account"] = session.client("sts").get_caller_identity()["Account"]
base = f"arn:aws:bedrock-agentcore:{s['region']}:{s['account']}"
def save():
STATE.write_text(json.dumps(s, indent=2, default=str))
def wait(get, key, good=("READY", "ACTIVE"), **args):
for _ in range(120):
r = get(**args)
status = r[key]
if status in good:
return r
if "FAIL" in status:
raise RuntimeError(json.dumps(r, default=str))
time.sleep(5)
raise TimeoutError(args)
def role(key, service):
if key not in s:
statement = {
"Effect": "Allow",
"Principal": {"Service": service},
"Action": "sts:AssumeRole",
}
if service == "bedrock-agentcore.amazonaws.com":
statement["Condition"] = {
"StringEquals": {"aws:SourceAccount": s["account"]},
"ArnLike": {"aws:SourceArn": base + ":*"},
}
r = iam.create_role(
RoleName=s["name"] + "-" + key,
AssumeRolePolicyDocument=json.dumps(
{"Version": "2012-10-17", "Statement": [statement]}
),
)
s[key] = r["Role"]["Arn"]
save()
return s[key]
# 1. Create a Policy Engine to register shopping rules.
if "engineId" not in s:
r = c.create_policy_engine(name=s["name"].replace("-", "_"))
s.update(engineId=r["policyEngineId"], engineArn=r["policyEngineArn"])
save()
wait(c.get_policy_engine, "status", policyEngineId=s["engineId"])
# 2. Create the Lambda for shopping tools and an execution role for writing logs.
role("lambdaRole", "lambda.amazonaws.com")
log_arn = f"arn:aws:logs:{s['region']}:{s['account']}:log-group:/aws/lambda/{s['name']}"
iam.put_role_policy(
RoleName=s["lambdaRole"].split("/")[-1],
PolicyName="ArticleLogs",
PolicyDocument=json.dumps(
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
],
"Resource": [log_arn, log_arn + ":*"],
}
],
}
),
)
if "lambdaArn" not in s:
code = io.BytesIO()
with zipfile.ZipFile(code, "w") as z:
z.write(ROOT / "gateway/lambda_function.py", "lambda_function.py")
for attempt in range(12):
try:
r = lam.create_function(
FunctionName=s["name"],
Runtime="python3.14",
Role=s["lambdaRole"],
Handler="lambda_function.lambda_handler",
Code={"ZipFile": code.getvalue()},
Timeout=10,
MemorySize=128,
)
break
except lam.exceptions.InvalidParameterValueException:
if attempt == 11:
raise
time.sleep(5)
s["lambdaArn"] = r["FunctionArn"]
save()
lam.get_waiter("function_active_v2").wait(FunctionName=s["name"])
# 3. Grant the Gateway execution role permissions for Lambda invocation, Policy evaluation, and token retrieval.
role("gatewayRole", "bedrock-agentcore.amazonaws.com")
gw_resource = s.get("gatewayArn", base + ":gateway/" + s["name"] + "-*")
permissions = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["lambda:InvokeFunction"],
"Resource": [s["lambdaArn"]],
},
{
"Effect": "Allow",
"Action": [
"bedrock-agentcore:GetPolicyEngine",
"bedrock-agentcore:AuthorizeAction",
"bedrock-agentcore:PartiallyAuthorizeActions",
],
"Resource": [s["engineArn"], gw_resource],
},
{
"Effect": "Allow",
"Action": ["bedrock-agentcore:GetWorkloadAccessToken"],
"Resource": [
base + ":workload-identity-directory/default",
base
+ ":workload-identity-directory/default/workload-identity/"
+ s.get("gatewayId", s["name"])
+ "*",
],
},
],
}
iam.put_role_policy(
RoleName=s["gatewayRole"].split("/")[-1],
PolicyName="ArticleGateway",
PolicyDocument=json.dumps(permissions),
)
# 4. Create a Gateway with the Policy Engine linked in ENFORCE mode.
if "gatewayId" not in s:
time.sleep(10)
for attempt in range(12):
try:
r = c.create_gateway(
name=s["name"],
roleArn=s["gatewayRole"],
protocolType="MCP",
authorizerType="AWS_IAM",
policyEngineConfiguration={"arn": s["engineArn"], "mode": "ENFORCE"},
exceptionLevel="DEBUG",
)
break
except c.exceptions.ValidationException as exc:
if (
"Access denied while calling GetPolicyEngine" not in str(exc)
or attempt == 11
):
raise
time.sleep(5) # Retry after waiting for IAM permissions to propagate.
s.update(
gatewayId=r["gatewayId"], gatewayArn=r["gatewayArn"], gatewayUrl=r["gatewayUrl"]
)
save()
wait(c.get_gateway, "status", gatewayIdentifier=s["gatewayId"])
def obj(props):
return {
"type": "object",
"properties": {k: {"type": v} for k, v in props.items()},
"required": list(props),
}
# 5. Define the input/output for the 2 tools and register Lambda as a Gateway target.
cart = {"cartId": "string", "store": "string", "amount": "integer"}
tools = [
{
"name": "get_cart",
"description": "Read the current shopping cart and total price.",
"inputSchema": obj({"cartId": "string"}),
"outputSchema": obj(cart),
},
{
"name": "place_order",
"description": "Simulate a supermarket order; no purchase is made.",
"inputSchema": obj(cart),
"outputSchema": obj({**cart, "status": "string"}),
},
]
(ROOT / "gateway/tools.json").write_text(json.dumps(tools, indent=2))
if "targetId" not in s:
r = c.create_gateway_target(
gatewayIdentifier=s["gatewayId"],
name="ShoppingTarget",
targetConfiguration={
"mcp": {
"lambda": {
"lambdaArn": s["lambdaArn"],
"toolSchema": {"inlinePayload": tools},
}
}
},
credentialProviderConfigurations=[
{"credentialProviderType": "GATEWAY_IAM_ROLE"}
],
)
s["targetId"] = r["targetId"]
save()
wait(
c.get_gateway_target,
"status",
gatewayIdentifier=s["gatewayId"],
targetId=s["targetId"],
)
# 6. Register the cart confirmation and order policies. The meaning of the conditions will be explained in the next section of the article.
statements = {
"ReadCart": f'permit (principal, action == AgentCore::Action::"ShoppingTarget___get_cart", resource == AgentCore::Gateway::"{s["gatewayArn"]}") when {{ ["cart-001", "cart-002", "cart-003"].contains(context.input.cartId) }};',
"OrderAfterCartCheck": f"""permit (
principal,
action == AgentCore::Action::"ShoppingTarget___place_order",
resource == AgentCore::Gateway::"{s['gatewayArn']}"
)
when {{ context.input.amount > 0 && context.input.amount <= 3000 }}
when temporal {{
formerly within 1m AgentCore::Action::"ShoppingTarget___get_cart"::response{{
eventResource: resource,
output.cartId: context.input.cartId,
output.store: context.input.store,
output.amount: context.input.amount
}}
}};""",
}
s.setdefault("policies", {})
save()
for name, statement in statements.items():
(ROOT / "gateway" / (name + ".dw")).write_text(
statement.replace(s["gatewayArn"], "<GATEWAY_ARN>") + "\n"
)
if name not in s["policies"]:
r = c.create_policy(
policyEngineId=s["engineId"],
name=name,
definition={"policy": {"statement": statement}},
validationMode="FAIL_ON_ANY_FINDINGS",
)
s["policies"][name] = r["policyId"]
save()
existing = c.get_policy(policyEngineId=s["engineId"], policyId=s["policies"][name])
if existing["status"] in ("CREATE_FAILED", "UPDATE_FAILED"):
c.update_policy(
policyEngineId=s["engineId"],
policyId=s["policies"][name],
definition={"policy": {"statement": statement}},
validationMode="FAIL_ON_ANY_FINDINGS",
)
wait(
c.get_policy,
"status",
policyEngineId=s["engineId"],
policyId=s["policies"][name],
)
print(
json.dumps(
{"gateway": s["gatewayId"], "target": s["targetId"], "policies": s["policies"]},
indent=2,
)
)
At the end of the script, two policies are also registered. I'll take a closer look at the specific policy contents written in Dogwood later!
Creating the Environment
First, run uv init in the working directory and add the necessary packages.
uv init --bare --python 3.14
uv add boto3==1.43.88 botocore==1.43.88 strands-agents==1.54.0 mcp-proxy-for-aws==1.6.5
mkdir -p gateway verification
After placing the two code files in the gateway directory, run the setup script.
uv run gateway/setup.py
Running the script creates various resources in the Tokyo region, and the created resource information is written to .gateway-state.json. This will be used for subsequent agent execution and resource deletion.
Note that the Gateway execution role is granted not only permissions for Lambda invocation and Policy evaluation, but also bedrock-agentcore:GetWorkloadAccessToken. Since Temporal Policies use a "Workload Access Token" to associate a series of operations within a session, this permission is required even when running the Gateway with IAM authentication as in this case. By the way, if permissions are insufficient, it will fail at the tool call stage, so care is needed when focusing on least privilege.
Configuring Policies
Allowing Cart Confirmation
First, prepare a policy to allow the cart confirmation operation as a prerequisite for ordering. Since ShoppingTarget is specified as the target name, the tool names referenced in the policy also have the prefix ShoppingTarget___.
permit (
principal,
action == AgentCore::Action::"ShoppingTarget___get_cart",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when {
["cart-001", "cart-002", "cart-003"].contains(context.input.cartId)
};
Here, only 3 cart IDs are permitted for verification purposes. <GATEWAY_ARN> contains the ARN of the created Gateway.
Since Cedar defaults to deny if nothing is written, an explicit allow policy is being written.
Allowing Orders Within 3,000 Yen Based on Confirmed Content
Next is the policy for the order processing side using Dogwood.
permit (
principal,
action == AgentCore::Action::"ShoppingTarget___place_order",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when { context.input.amount > 0 && context.input.amount <= 3000 }
when temporal {
formerly within 1m AgentCore::Action::"ShoppingTarget___get_cart"::response{
eventResource: resource,
output.cartId: context.input.cartId,
output.store: context.input.store,
output.amount: context.input.amount
}
};
The first when block is a standard Cedar condition that checks whether the order amount is between 1 yen and 3,000 yen inclusive.
when temporal searches for a record where the cart confirmation was successful and a result was returned within the past 1 minute. To target responses, ::response is specified.
About event types
::request is for permitted calls, ::response is for successful tool responses, and ::error is for records of rejections or failures. Since it's the response that holds the values returned by the tool, this is cross-referenced with the order content in this case.
Inside the curly braces, the past execution record and the current order parameters are cross-referenced. The part performing ID matching is as follows.
output.cartId: context.input.cartId
The left side is the cart ID at the time of confirmation, and the right side is the cart ID for the current order. Since the store and amount may change even for the same cart, in this case these two are also conditions that must match the values at the time of confirmation.

Note that eventResource: resource is a specification that narrows down the target event to those belonging to the current Gateway, and is required when writing Temporal conditions.
Calling from Strands Agents
We'll retrieve the tools from the created Gateway via Strands Agents' MCPClient and pass them to the Agent.
For connecting to the Gateway, we'll use mcp-proxy-for-aws, which conveniently supports IAM signing.
The core part of the implementation that runs the agent looks like this.
session_id = str(uuid.uuid4())
mcp_client = MCPClient(lambda: aws_iam_streamablehttp_client(
endpoint=state['gatewayUrl'],
aws_service='bedrock-agentcore',
aws_region=state['region'],
headers={'x-amzn-bedrock-agentcore-policy-session-id': session_id},
))
with mcp_client:
agent = Agent(
model=BedrockModel(model_id=MODEL_ID, region_name=state['region'], max_tokens=1500),
tools=mcp_client.list_tools_sync(),
system_prompt=(
'You are an agent that helps with supermarket shopping.'
'Please use tools in the order specified by the user.'
'The Gateway determines whether orders are allowed. Do not retry denied operations; briefly report the result in Japanese.'
),
)
result = agent(PROMPTS[case])
The key point is the x-amzn-bedrock-agentcore-policy-session-id header. The Policy Engine tracks operation history per this value, so to treat the flow from cart confirmation to order placement as a single session, the client needs to generate a unique ID for each execution and attach it to requests. Note that for Gateways with Temporal Policies enabled, sending requests without this header will result in an error.
The full script is here. Save it as gateway/strands_agent.py.
Full Strands Agents code
"""Run a real Strands/Bedrock agent against the shopping Gateway."""
import argparse
import json
import time
import uuid
from pathlib import Path
from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client
from strands import Agent
from strands.models import BedrockModel
from strands.tools.mcp import MCPClient
ROOT = Path(__file__).resolve().parents[1]
MODEL_ID = 'jp.anthropic.claude-haiku-4-5-20251001-v1:0'
PROMPTS = {
'normal': 'Check the cart for cart-001, and place an order with the same store and amount returned.',
'skip': 'Without checking the cart, directly place an order for cart-001 at store favorite-supermarket for 3000 yen. If denied, stop there and do not confirm or retry.',
'over_budget': 'Check the cart for cart-003, and place an order with the same store and amount returned. Even if over budget, call the order tool once to check the result, and stop if denied.',
}
def run(case):
state = json.loads((ROOT / '.gateway-state.json').read_text())
if state.get('cleanedUp'):
raise RuntimeError('Please run gateway/setup.py first.')
session_id = str(uuid.uuid4())
mcp_client = MCPClient(lambda: aws_iam_streamablehttp_client(
endpoint=state['gatewayUrl'],
aws_service='bedrock-agentcore',
aws_region=state['region'],
headers={'x-amzn-bedrock-agentcore-policy-session-id': session_id},
))
started = time.time()
with mcp_client:
agent = Agent(
model=BedrockModel(model_id=MODEL_ID, region_name=state['region'], max_tokens=1500),
tools=mcp_client.list_tools_sync(),
system_prompt=(
'You are an agent that helps with supermarket shopping.'
'Please use tools in the order specified by the user.'
'The Gateway determines whether orders are allowed. Do not retry denied operations; briefly report the result in Japanese.'
),
)
result = agent(PROMPTS[case])
record = {
'case': case, 'model': MODEL_ID, 'session': session_id,
'resource_name': state['name'], 'started': started, 'ended': time.time(),
'prompt': PROMPTS[case], 'messages': agent.messages, 'answer': str(result),
}
path = ROOT / 'verification' / f'strands-{case}.json'
path.write_text(json.dumps(record, ensure_ascii=False, indent=2, default=str))
print(f'\nSaved: {path}')
return record
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('case', choices=[*PROMPTS, 'all'], default='normal', nargs='?')
args = parser.parse_args()
for case in PROMPTS if args.case == 'all' else [args.case]:
run(case)
Specify one of normal, skip, or over_budget as an argument to switch between test cases. Execution history is output under the verification directory.
uv run gateway/strands_agent.py normal
Let's start with the normal case normal. This is the case where the order proceeds after confirming the cart.
Placing an order after checking the cart (normal)
First, let's look at an example that should go as expected.
Check the cart for cart-001, and place an order with the same store and amount returned.
The agent followed the instructions by checking the cart contents with get_cart, then called place_order using the obtained store and amount. The tool returned a successful response without any issues.
{
"status": "simulated_order_accepted",
"amount": 3000,
"cartId": "cart-001",
"store": "favorite-supermarket"
}
The 3,000 yen order went through successfully! We can see that Strands Agents is selecting and executing tools in the intended order.
Skipping the cart check (skip)
Next is the skip case. Since a new session ID is generated for each execution, the previous cart check history is not carried over. In this session, we'll instruct the agent to call the order directly without checking the cart first.
Without checking the cart, directly place an order for cart-001 at store favorite-supermarket for 3000 yen. If denied, stop there and do not confirm or retry.
The agent attempted to call place_order following the instructions, but was blocked on the Gateway side and an error was returned.
Tool Execution Denied: Tool call not allowed due to policy enforcement [No policy applies to the request (denied by default).]
The agent's final response was also "The order was denied. Stopping." We can see that even if the model tries to call the order tool directly, it is firmly blocked at the Gateway level as long as there is no cart check history.
Ordering a 4,000 yen cart (over_budget)
Finally, let's try over_budget. Since this also involves Gateway-side policy validation, we instruct in advance to call the order tool even if it exceeds the budget.
Check the cart for cart-003, and place an order with the same store and amount returned. Even if over budget, call the order tool once to check the result, and stop if denied.
This time, place_order was called after executing get_cart, but it was rejected as a policy violation with the same type of error as before. Even though the cart check step was performed, the order was rejected this time by the 3,000 yen budget rule we configured.
Notes from hands-on experience
The 3,000 yen limit set in this policy is a restriction on "the order amount per transaction," not the cumulative total for the entire session! After directly calling the Gateway for additional verification, I found that within 1 minute of a cart check, two consecutive orders with the same content would both go through.
If you also want to restrict the cumulative amount, you can use sum in Dogwood. I also tried a policy that rejects orders where the total within the last 5 minutes in the same session exceeds 3,000 yen.
forbid (
principal,
action == AgentCore::Action::"ShoppingTarget___place_order",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when temporal {
exists (total: Long).
(sum amt for (amt: Long), (t: Timepoint).
where (formerly within 5m (
AgentCore::Action::"ShoppingTarget___place_order"::request{
eventResource: resource,
input.amount: amt
} && tp(t)
))) == total
&& total > 3000
};
sum totals the amounts including the current order. Since we want to allow exactly 3,000 yen, the condition for rejection is total > 3000. We add this forbid to the original cart check policy.
When requesting two 2,000 yen orders in the same session from Strands Agents, the first succeeded, and the second, which would bring the total to 4,000 yen, was rejected by the Gateway! We also confirmed that an order of exactly 3,000 yen goes through in a separate session.
Registering the cumulative policy and running with Strands Agents
Save the above policy to gateway/SessionBudget.dw and add the following two files. Run these after creating the environment and before cleaning up.
"""Add a cumulative amount limit for the last 5 minutes to the existing order policy."""
import json
import time
from pathlib import Path
import boto3
ROOT = Path(__file__).resolve().parents[1]
path = ROOT / '.gateway-state.json'
state = json.loads(path.read_text())
client = boto3.client('bedrock-agentcore-control', region_name=state['region'])
statement = (ROOT / 'gateway/SessionBudget.dw').read_text().replace(
'<GATEWAY_ARN>', state['gatewayArn']
)
result = client.create_policy(
policyEngineId=state['engineId'],
name='SessionBudget',
definition={'policy': {'statement': statement}},
validationMode='FAIL_ON_ANY_FINDINGS',
)
policy_id = result['policyId']
state['policies']['SessionBudget'] = policy_id
path.write_text(json.dumps(state, indent=2))
for _ in range(120):
result = client.get_policy(
policyEngineId=state['engineId'], policyId=policy_id
)
if result['status'] == 'ACTIVE':
print('SessionBudget ACTIVE')
break
if 'FAIL' in result['status']:
raise RuntimeError(result)
time.sleep(5)
else:
raise TimeoutError('SessionBudget')
"""Try placing two 2,000 yen orders in the same session from Strands Agents."""
import strands_agent as sample
sample.PROMPTS['cumulative'] = (
'Check cart-002 and place an order with the returned store and amount.'
'If successful, place the same cart order again with the same store and amount.'
'Even if the cumulative budget is exceeded, call the order tool a second time to check the Gateway result.'
'If denied, stop without retrying.'
)
sample.run('cumulative')
uv run gateway/add_budget.py
uv run gateway/check_budget.py
The aggregation target is the amount of permitted order requests. Since failed order processing is also included, this is distinct from the actual amount spent. Also, orders from more than 5 minutes ago or from other sessions are not aggregated. To protect the wallet balance across time and sessions, balance management needs to be handled on the order processing side.
If the order API is designed to calculate the amount from the cart ID, budget checks could also be performed there. What this policy can verify is the match with values returned within the last 1 minute. Whether prices or inventory have changed at the time of ordering must be confirmed on the order processing side, so please note that this is an implementation purely for verifying Temporal Policies.
Cleanup
Once verification is complete, clean up the created resources. A deletion script is provided, so you can delete everything with a single command.
uv run gateway/cleanup.py
This processes the deletion of the target, Gateway, policies, Policy Engine, Lambda, log groups, and IAM roles that were recorded in .gateway-state.json during setup, all at once.
Full deletion script (gateway/cleanup.py)
"""Delete only resources recorded by this article's setup.py."""
import json,time
from pathlib import Path
import boto3
from botocore.exceptions import ClientError
root=Path(__file__).resolve().parents[1]
p=root/'.gateway-state.json'
s=json.loads(p.read_text())
aws=boto3.Session(region_name=s['region']); c=aws.client('bedrock-agentcore-control')
def remove(fn,**args):
try: fn(**args)
except ClientError as e:
if e.response['Error']['Code'] not in ('ResourceNotFoundException','NoSuchEntityException','NoSuchEntity'): raise
def gone(get,**args):
for _ in range(120):
try: get(**args)
except ClientError as e:
if e.response['Error']['Code']=='ResourceNotFoundException': return
raise
time.sleep(3)
raise TimeoutError(args)
if 'targetId' in s:
remove(c.delete_gateway_target,gatewayIdentifier=s['gatewayId'],targetId=s['targetId'])
gone(c.get_gateway_target,gatewayIdentifier=s['gatewayId'],targetId=s['targetId'])
if 'gatewayId' in s:
remove(c.delete_gateway,gatewayIdentifier=s['gatewayId'])
gone(c.get_gateway,gatewayIdentifier=s['gatewayId'])
for pid in s.get('policies',{}).values():
remove(c.delete_policy,policyEngineId=s['engineId'],policyId=pid)
gone(c.get_policy,policyEngineId=s['engineId'],policyId=pid)
if 'engineId' in s:
remove(c.delete_policy_engine,policyEngineId=s['engineId'])
gone(c.get_policy_engine,policyEngineId=s['engineId'])
if 'lambdaArn' in s: remove(aws.client('lambda').delete_function,FunctionName=s['name'])
remove(aws.client('logs').delete_log_group,logGroupName='/aws/lambda/'+s['name'])
iam=aws.client('iam')
for key,policy in [('lambdaRole','ArticleLogs'),('gatewayRole','ArticleGateway')]:
if key in s:
name=s[key].split('/')[-1]
remove(iam.delete_role_policy,RoleName=name,PolicyName=policy)
remove(iam.delete_role,RoleName=name)
(root/'verification/cleanup.json').write_text(json.dumps({'status':'deleted','resource_name':s['name'],'region':s['region'],'completed_at':time.time()},indent=2))
s['cleanedUp']=True; p.write_text(json.dumps(s,indent=2))
print('Deleted article Gateway, target, policies, policy engine, Lambda, log group and 2 IAM roles.')
Other ways to write policies
There are many other restrictions you can implement beyond what was introduced this time!
To limit the number of calls, use count to count invocations instead of sum which totals amounts. Since the current call is included in the count, to allow up to 3 times, set the condition to reject when the count exceeds 3.
To enforce a waiting period before re-execution, use formerly within 1m to look for successful responses within the last 1 minute, and reject if found.
How to write rate limits and re-execution wait times
The following are example policies based on the official documentation examples, rewritten for the order tool in this article. Define them as additional deny policies (forbid) combined with the existing order permission policy.
An example limiting orders to 3 times within 5 minutes.
forbid (
principal,
action == AgentCore::Action::"ShoppingTarget___place_order",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when temporal {
exists (n: Long).
(count for (t: Timepoint).
where (formerly within 5m (
AgentCore::Action::"ShoppingTarget___place_order"::request{
eventResource: resource
} && tp(t)
))) == n
&& n > 3
};
An example that rejects the next order for 1 minute after a successful order. Since the condition targets a successful response, ::response is used.
forbid (
principal,
action == AgentCore::Action::"ShoppingTarget___place_order",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when temporal {
formerly within 1m AgentCore::Action::"ShoppingTarget___place_order"::response{
eventResource: resource
}
};
How to write re-order restrictions after confirmation using since
An example limiting orders to once after a cart check.
Replace the original OrderAfterCartCheck with the following content. Adding it as a separate permission policy would allow re-orders under the original policy.
permit (
principal,
action == AgentCore::Action::"ShoppingTarget___place_order",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when { context.input.amount > 0 && context.input.amount <= 3000 }
when temporal {
formerly within 1m AgentCore::Action::"ShoppingTarget___get_cart"::response{
eventResource: resource,
output.cartId: context.input.cartId,
output.store: context.input.store,
output.amount: context.input.amount
}
&& (
!AgentCore::Action::"ShoppingTarget___place_order"::response{
eventResource: resource
}
since within 1m AgentCore::Action::"ShoppingTarget___get_cart"::response{
eventResource: resource
}
)
};
The right side of since is the cart check that serves as the starting point, and the left side is the condition to maintain after that. The ! on the left side specifies "no successful order," so once an order succeeds, a new cart check is required for the next order.
Personally, I found it particularly useful that you can also limit the number of tool executions!
You might wonder if the Gateway rate limiting we tried previously can do the same thing, but that is a feature for suppressing traffic volume per caller or target.
The Temporal Policies introduced this time determine "whether this operation should be allowed right now" based on the execution history within a session, so in addition to counts, you can also express preconditions and execution order as business rules. This seems useful when you want to apply constraints outside of the agent's code!
However, note that Temporal Policies' rate limiting is per session, so the count resets with a new session.
Conclusion
In addition to rate limiting, we can now check tool execution history at the Gateway level too. As the operations delegated to agents increase, we'll want to think carefully about which steps to make mandatory!
As the capabilities have expanded, it's becoming harder to decide where to assign what... lol
I'd like to share how to distinguish their use cases in another blog post as I continue experimenting!
I hope this article was helpful in some way. Thank you very much for reading to the end!


