A HITL step shows a tool name and a truncated preview.
The executor receives the payload. Nothing compares them.
The agent asks for a $100 refund, gets approved, decides mid-execution the situation is worse, retries at $200.
Different action, same approval.
Keying on an approval ID does not help.
The handle proves who approved, not what they saw.
Close the seam by recomputing a digest at execution time and consuming the grant atomically:
UPDATE approvals
SET state = 'consumed', consumed_at = now()
WHERE id = :approval_id
AND state = 'approved'
AND expires_at > now()
AND digest = :live_digest
RETURNING id;
One statement, four preconditions, zero rows means refuse.
The read-then-write version passes every test and lets forty workers through.
Full writeup with the field-classification problem:
https://nullrun.io/blog/human-approval-is-not-authorization
Top comments (1)
Recomputing the digest at execution and consuming the grant atomically is exactly the right shape, and it generalizes past approvals to every authorization check that has a read-then-write window. The same single-statement, multi-precondition trick is what a correct RLS-gated mutation looks like: the authorization predicate belongs in the write (
UPDATE ... WHERE id = :id AND owner_id = auth.uid() AND state = 'approved'), so zero rows is a refusal and there is no gap between checking and acting. The failure you name — “the read-then-write version passes every test and lets forty workers through” — is the same reason an app-levelif (allowed)before a query is not isolation: the check and the effect are two round-trips, and state can change between them. One thing I would pin down: the digest has to be over exactly what the executor will use (arguments, target, amount), not a serialized form the HITL preview and the executor build differently, or the seam re-opens one layer down. A two-account probe for the authorization half (B cannot consume A’s grant) lives here if useful: github.com/cekuu35/supabase-rls-le...