Most write-ups about AI agents and databases are about the dramatic failure: the dropped table, the deleted volume. This one is about a quieter failure, the kind that leaves no error anywhere. An agent runs a correct, narrow UPDATE. The tool reports success and one row. The agent tells you it updated one row. Everything in the chat looks right. Postgres committed 301.
In our demo the agent said 1 row; Postgres changed 301.1 The output below comes from a run of that demo on a laptop, on throwaway Postgres 18.6 clusters, pasted rather than retyped.
The setup
The demo is a small store database: customers, products, 500 orders, refunds. A support agent called support-bot is allowed one kind of write: change the status of one order, by id. Deedglass sits between the agent and Postgres. Its rules decide each call before it runs, and its check reads Postgres’s change log afterwards and reports Matches or Doesn’t match.3
The rule that allows the write also says how much evidence the result needs:
- id: allow-order-status-updates
match: { tool: postgres.execute }
when: >-
sql.kind == "update" && sql.tables == ["public.orders"] && sql.tables_complete
&& !sql.unbounded_write && !sql.multi && !sql.cte_write && size(sql.unknown_functions) == 0
decision: allow
verify: L2
verify: L2 is the policy file’s name for the strongest level, which the output calls Proven: the rows Postgres committed, read from its own change log.
The normal write
The agent ships order 42, sending UPDATE public.orders SET status = 'shipped' WHERE id = 42 through the postgres.execute tool:
✓ executed · postgres.execute · 3 ms
action act_01m4x828pwcreh4nrxf7z847j1
check Proven · Matches · Postgres committed 1 row in public.orders, as the statement said.
Proven by Postgres · commit 0/1D4FBC0 · 2026-10-14 13:01:44 UTC
record deed_01m4x828… · seq 13 · sealed
One row named, one row committed, Matches. This is what you expect to see every time, and it’s what the agent believes happened every time.
The same write, with the trigger on
Now we turn on a trigger that has been sitting in this database’s history. It’s the kind of thing every old schema has: “when one order ships, mark the rest of the open batch shipped too.” Someone wrote it years ago for a warehouse flow that no longer exists:
CREATE TRIGGER orders_ship_open_batch AFTER UPDATE OF status ON public.orders
FOR EACH ROW WHEN (NEW.status = 'shipped' AND OLD.status IS DISTINCT FROM 'shipped')
EXECUTE FUNCTION acme_legacy.ship_open_batch();
The function behind it ships the next 300 orders that aren’t shipped yet. Order 42 is set back to paid, and the agent sends exactly the same statement:
✕ executed · postgres.execute · 3 ms
check Proven · Doesn't match · Postgres changed 301 rows in public.orders. The agent reported 1.
✕ Proven 300 changed rows in public.orders are outside the rows the statement named (id = 42).
expected UPDATE 1 row in public.orders where id = 42 · sets status
observed UPDATE 301 rows in public.orders · 300 outside id = 42
public.orders id status
✓ expected 42 paid → shipped
✕ unexpected 251 cancelled → shipped
✕ unexpected 82 cancelled → shipped
✕ unexpected 107 cancelled → shipped
✕ unexpected 400 cancelled → shipped
… first 20 of 301 rows · the rest in Console › Actions
Proven by Postgres · commit 0/1D706F8 · 2026-10-14 13:01:44 UTC
record deed_01m4x828… · seq 14 · sealed
Look at the first unexpected rows. Those orders were cancelled. They are now marked shipped. In a real store that is a warehouse picking orders nobody paid for, or customers emailed about parcels that will never come.
In the Deedglass demo, an agent’s one-row UPDATE fires a legacy trigger, Postgres commits 301 rows, and the check records Doesn’t match; an undo countersigned by a person restores the 301 rows.1
The agent wasn’t lying
It’s tempting to read this as “the model hallucinated”. It didn’t. Here is the same statement in plain psql, with the trigger on, as the database owner:
SELECT count(*) AS shipped_before FROM public.orders WHERE status = 'shipped';
shipped_before
----------------
160
UPDATE public.orders SET status = 'shipped' WHERE id = 42;
UPDATE 1
SELECT count(*) AS shipped_after FROM public.orders WHERE status = 'shipped';
shipped_after
---------------
461
Postgres answered UPDATE 1. The shipped count went from 160 to 461. The command tag counted the row the statement named; the trigger’s 300 rows were committed alongside it and appear nowhere in the answer. A tool that relays the command tag, as SQL tools do, reports one row. The agent repeats it. The person reading the chat believes it.
That’s the uncomfortable part. Nothing failed. The statement was narrow, it had a WHERE, it touched one table. A reviewer looking at the SQL would approve it. The rules approved it too, correctly: the rules judge what the agent asks for, and the request was fine. The problem only exists in what the database did next.
The same gap appears without triggers: a rule in a view, a function that writes more than its name suggests, a cascade, an extension. Anything the database does on its own when a statement runs is invisible to the caller.
What the check compares
Two lines in the output carry the whole idea:
expected UPDATE 1 row in public.orders where id = 42 · sets status
observed UPDATE 301 rows in public.orders · 300 outside id = 42
The expected line is what the statement named: which table, which rows, which columns. The observed line comes from Postgres’s change log for that action’s transaction, not from the tool’s answer and not from a later SELECT.3 The change log is the stream Postgres writes for replication, and as this run shows, it holds the rows a trigger wrote in the same transaction too.
When the two agree, you get Matches. When they don’t, you get Doesn’t match with a row-by-row diff, and the result is sealed into a signed record you can check offline, without trusting Deedglass’s server.3
The longer output for the same action shows each level of evidence separately:
check Proven · Doesn't match · postgres.pgoutput
✓ Claimed The tool reported success.
✓ Checked Postgres shows row id = 42 in public.orders was last written by this action's transaction.
✕ Proven Postgres changed 301 rows in public.orders. The agent reported 1.
✕ Proven 300 changed rows in public.orders are outside the rows the statement named (id = 42).
· Proven public.orders has triggers (orders_ship_open_batch). Their writes to watched tables are checked; writes elsewhere aren't visible.
Claimed and Checked both pass. The tool said success, and reading row 42 back shows the action wrote it. Only the change log shows the other 300. That’s why the levels are kept apart: a read-back of the row you asked about can only ever confirm the row you asked about.
Putting the 301 rows back
Because the change log holds the before and after of every row, the action can be undone row by row. Preview first:
$ deedglass undo preview act_01m4x828y6bxmt16p7n7ermjp2
✓ Undo available · 301 rows in public.orders · 301 updates
plan sha256:94f5…d1d9
sample id 144 status shipped → paid
id 310 status shipped → paid … 299 more
policy undo-needs-approval · a person countersigns first
An undo changes data again, so in this setup a person countersigns it. Once they do, it runs, and the undo itself is checked against the change log like any other write:
✓ Countersigned · apr_01m4x82a… · ops@example.test · 1 of 1
✓ executed · postgres.undo · 72 ms
check Proven · Matches · Restored 301 rows in public.orders exactly as planned
record deed_01m4x82a… · sealed
The status counts after the undo are exactly the counts before the agent’s write: 160 shipped, 223 paid, 37 cancelled.2
What this doesn’t catch
Be clear about the edges, because they matter more than the demo:
- It runs after the commit. The rules decide before a call runs; the check reports what committed. For this failure that’s the right order, since the request itself was fine, but the 301 rows did exist until the undo ran.
- Only watched tables. The output says it plainly: writes to tables outside the watched set aren’t visible. Watch every table an agent can reach, directly or through triggers.
- It trusts Postgres. The change log is Postgres’s own account. A compromised database server or superuser is outside what this can see.
- Postgres only. Other tools get the levels their targets allow: a tool’s success response, or a read-back from its API.
None of this replaces a narrow role. The guide on giving Claude Code or Cursor write access to Postgres covers the role, the column grants and row-level security first, with no Deedglass needed. The check is for the part a role can’t help with: knowing what the database actually did.
Try it
You can run this exact scenario in your browser at /play: a real Postgres in a tab with the same legacy trigger. The playground compares table snapshots in your browser; the real check reads Postgres’s own change log.4 To see what your own agent’s database role can change today, run the free scan, npx deedglass scan. It grades the setup and nothing leaves your machine; here is what the grade means. For why the signed record matters more than a log line, read why logs aren’t evidence.