A refund_order call is perfectly authorized on its own. It becomes dangerous only after a cancel_order call already fired in the same session — and per-call RBAC will never catch that combination, because it never looks at what happened five turns ago.
AWS has published this pattern as temporal policies in Bedrock AgentCore, packaged inside a managed gateway. The enforcement primitive underneath is not proprietary. It's two tables and one plpgsql function. Here's the whole thing, runnable against a local Postgres.
Why per-call authorization can't see sequence
Scoped tokens, RBAC, and per-call policy engines all evaluate one tool call in isolation. Given (principal, tool, params) they answer allow or deny. There is no slot in that signature for "what else did this session already do."
Most runtime-authorization writing right now frames the fix as evaluating permissions more often: per call instead of per session, dynamic instead of static. That's the wrong axis. Per-call checks without history are RBAC at a higher frequency, with exactly the same blind spot. The missing dimension is trajectory, not call speed.
Expressing "deny X because Y already happened" requires state, and state requires a store the agent can't rewrite. A Postgres table is a store the agent can't rewrite.
Modeling sequence as a state machine
Two tables. session_events is the append-only log of what a session did. tool_transitions declares which (from_state, tool) pairs are legal and where they lead. Everything not in tool_transitions is denied — deny-by-default, because the failure mode here is "right permission, wrong sequence," and an allow-leaning system misses exactly the combinations you forgot to enumerate.
CREATE TABLE sessions (
id uuid PRIMARY KEY,
principal text NOT NULL,
state text NOT NULL DEFAULT 'start'
);
CREATE TABLE session_events (
id bigserial PRIMARY KEY,
session_id uuid NOT NULL REFERENCES sessions(id),
tool_name text NOT NULL,
params jsonb NOT NULL DEFAULT '{}',
called_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE tool_transitions (
from_state text NOT NULL,
tool_name text NOT NULL,
to_state text NOT NULL,
PRIMARY KEY (from_state, tool_name)
);
CREATE FUNCTION authorize_tool_call(p_session uuid, p_tool text)
RETURNS text LANGUAGE plpgsql AS $$
DECLARE cur_state text; next_state text;
BEGIN
SELECT state INTO cur_state FROM sessions WHERE id = p_session FOR UPDATE;
IF cur_state IS NULL THEN
RAISE EXCEPTION 'unknown session %', p_session;
END IF;
SELECT to_state INTO next_state FROM tool_transitions
WHERE from_state = cur_state AND tool_name = p_tool;
IF next_state IS NULL THEN
RAISE EXCEPTION 'DENY: % not permitted from state %', p_tool, cur_state
USING ERRCODE = 'insufficient_privilege';
END IF;
INSERT INTO session_events (session_id, tool_name) VALUES (p_session, p_tool);
UPDATE sessions SET state = next_state WHERE id = p_session;
RETURN next_state;
END;
$$;SELECT ... INTO without STRICT assigns NULL when no row matches, so the IF cur_state IS NULL branch catches an unknown session id. It also catches a session whose state column is NULL, which the NOT NULL constraint rules out — if you drop that constraint, switch to IF NOT FOUND.
The primary key on (from_state, tool_name) is doing policy work, not just index work: it makes overlapping rules impossible, so you never have to resolve "two transitions matched, which wins." Deny wins by construction, because a missing row is a denial.
You could express the same logic as ad-hoc conditions — IF EXISTS (SELECT 1 FROM session_events WHERE tool_name = 'cancel_order' ...) scattered through each tool's handler. Don't. One transition table is one thing to audit and one thing to diff in code review; N bespoke conditions are N places to forget a case. That's a maintainability opinion, not a benchmark, but it's the one I'd defend in a design review.
Walking through refund-after-cancellation
Seed the machine, run the session, watch it deny.
INSERT INTO tool_transitions VALUES
('start', 'lookup_order', 'order_loaded'),
('order_loaded', 'refund_order', 'refunded'),
('order_loaded', 'cancel_order', 'cancelled');
-- note: no ('cancelled', 'refund_order', ...) row exists
INSERT INTO sessions (id, principal)
VALUES ('11111111-1111-1111-1111-111111111111', 'user:4711');
SELECT authorize_tool_call('11111111-1111-1111-1111-111111111111', 'lookup_order');
-- order_loaded
SELECT authorize_tool_call('11111111-1111-1111-1111-111111111111', 'cancel_order');
-- cancelled
SELECT authorize_tool_call('11111111-1111-1111-1111-111111111111', 'refund_order');
-- ERROR: DENY: refund_order not permitted from state cancelledrefund_order is a legal tool for this principal. It was legal one call earlier. It's illegal now, and the only reason is the row that isn't in tool_transitions. That's the whole trick.
Where the check has to run
A temporal check living inside the agent's tool-calling code is advisory. Prompt injection, a retry loop, or a plain bug routes around it. The check has to be a gate the agent has no code path to skip, running in the same transaction as the tool's side effect — otherwise the guard passes, the network hiccups, and the effect lands anyway.
One thing the wrapper has to respect: once the guard raises, the transaction is aborted and no further statement can run inside it. Postgres refuses everything until the block ends, so the except branch may not issue SQL — it may only translate the error and let the transaction unwind. In psycopg 3, conn.transaction() handles that unwinding: an exception propagating out of the block rolls it back, and a normal exit — including one via return — commits. Put the guard's except outside the transaction block so you are not touching an aborted transaction.
import psycopg
from psycopg.errors import InsufficientPrivilege
def execute_tool(conn: psycopg.Connection, session_id: str, tool: str, fn, *args):
try:
with conn.transaction(): # BEGIN
with conn.cursor() as cur:
cur.execute(
"SELECT authorize_tool_call(%s::uuid, %s)", (session_id, tool)
) # locks the session row
return fn(cur, *args) # side effect, same txn
# normal exit -> COMMIT; any raise -> ROLLBACK:
# no event logged, no state change, no effect
except InsufficientPrivilege as e:
raise PermissionError(str(e)) from eThe %s::uuid cast is there because session_id arrives as a Python str; psycopg 3 sends it as text, and Postgres will not silently resolve authorize_tool_call(text, text) against a uuid parameter. Pass a uuid.UUID instead and you can drop the cast.
What to do next
The whole enforcement primitive is one table of legal transitions plus one function that refuses anything not in it — everything else is plumbing around that. Drop the schema into a scratch Postgres, seed your own tool graph, and run the three-call sequence until the deny fires; then wire the wrapper in front of exactly one high-consequence tool before you widen it.