FoxByte
Guardrails · Benchmarks

The agent row limit: why a row counter, and what it costs

We measured two ways to stop an AI agent’s runaway UPDATE or DELETE on a million-row table. One refuses it in 14.5 ms on a small test VM. Here is the data, including what it costs people. It ships in the next release.

5 min readFoxByte engineering

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.

CaseNo guardRow counterTransition table
A person updates every row2,4433,1952,653
A person deletes every row311771476
An agent deletes every row273, not refused14.5, refused505, refused
An agent updates 50 rows5.88.85.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