Refusing an agent’s DELETE because its text has no WHERE clause is easy to get around: WHERE 1 = 1 deletes every row just as well. So FoxByte now limits agents by impact, the number of rows a statement actually changes, rather than by what the statement looks like.
There were two credible ways to build that, and we measured both before choosing.
Two designs
- A row counter: a BEFORE UPDATE OR DELETE trigger, for each row, whose WHEN clause only passes agent sessions. It counts rows per statement and table, and refuses at the first row over the limit.
- A transition table: an AFTER trigger, once per statement, that counts the statement’s old rows after it has run, and refuses then.
The results
PostgreSQL 18 in a throwaway VM with 4 CPUs and 4 GiB, a table of 1,000,000 narrow rows rebuilt before every measurement, seven interleaved rounds. Medians, in milliseconds.
| Case | No guard | Row counter | Transition table |
|---|---|---|---|
| A person updates every row | 2,443 | 3,195 | 2,653 |
| A person deletes every row | 311 | 771 | 476 |
| An agent deletes every row | 273, not refused | 14.5, refused | 505, refused |
| An agent updates 50 rows | 5.8 | 8.8 | 5.8 |
Reading them
The row counter stops the damage, which is the point of a guardrail. It refuses an agent’s mass delete at row 101, in 14.5 ms, 35 times faster than the transition table, which has to delete every row and keep a copy of each before it can count them and roll back.
It costs people more on bulk writes: about 0.46 µs per row deleted and 0.75 µs per row updated, against 0.17 µs and 0.21 µs for the transition table. On ordinary statements of a few rows neither is noticeable, and these were the cheapest writes PostgreSQL does, so on real tables the overhead is a smaller share.
Most of that cost is PostgreSQL’s, not ours. A per-row trigger whose WHEN is the constant false already adds about 0.3 µs per deleted row, because the executor has to fetch each old row for it.
Why the WHEN clause names the login
The cheapest filter would read a session setting, but an agent can change a session setting from inside its own WHERE clause and skip the guard. The login role is the one part of a session the session cannot change, so the trigger tests that, and names it once, because every mention of session_user looks the role up again.
Using it
The defaults are 100 rows per table per statement and 1,000 rows per table per hour for each agent. Both can be changed per table.
$ fox guardrail limit --table orders 500 $ fox guardrail budget --table orders --per 1h 5000





