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 (2)
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...Good point about the digest needing to match exactly what the executor uses.
Binding the approval to the action is only useful if both sides agree on what that action actually is.
And yes, putting the authorization predicate inside the write is the broader principle here.
Thanks for the thoughtful addition🤝