Undo for agent sessions: putting back what an AI agent changed in Postgres

A backup restore throws away everyone's work since the backup. What you want is the rows the agent changed, before and after, and a safe way to put them back. Postgres already records both.

14 October 2026 · 7 min read

Here is a situation that’s easy to picture. You let an agent loose on a cleanup job: tidy stale orders, merge duplicate customers, fix a batch of bad statuses. It runs for an hour. Afterwards something is off. Some paid orders are now cancelled, a few refunds are missing, and the agent’s summary says everything went to plan.

The question isn’t “what did the agent say it did”. It’s “which rows changed while it ran, and how do I put exactly those back without touching anyone else’s work”.

The four ways back

Restore a backup or a point in time. It works, and sometimes it’s the only way back. But it rewinds the whole database, so every customer order, payment and support note since that moment goes too. For an agent that made a few hundred bad writes in an hour, that’s a big price.

Reset a branch. If the agent worked on a database branch rather than production, this is the cleanest answer. A Neon branch is a copy-on-write clone of your data, created without load on the parent, and Neon can reset a branch to match its parent.33 Note that branches differ by provider: Supabase branches start without data from the main project by default, and merging runs a workflow that applies pending migrations.4 Either way, a branch only helps if the agent’s writes were on it. Agents that work on production data, a support agent or a billing agent, can’t use one.

Write compensating SQL by hand. Look at what changed, write the reverse. It is slow and easy to get wrong, and a statement log records the statements, not the old values.

Generate the reverse from the change log. Postgres already writes every committed row change, with the old values for tables set up for it, to the change log it uses for replication. The rows the agent changed, before and after, are sitting there. The rest of this post is that option.

What the change log already has

When Deedglass’s Postgres connector is set up, it turns on full row images for the tables you choose to watch and reads Postgres’s change log through a dedicated role and replication slot.2 Normally it uses that to check each action an agent sends through its rules. Observe mode is for agents that don’t go through Deedglass at all: it works beside any gate you already use, reading only Postgres’s change log through the deedglass_witness role and the slot deedglass connector postgres init creates.1

You open a session, let the agent work, close the session. Every transaction Postgres committed on the watched tables in that window is grouped into the session.

A watched session, end to end

This is a real run on a demo store database. The “agent” here is a stand-in script: one transaction that cancels every paid order, and one that deletes three refunds.

$ deedglass observe start --connector postgres --label "nightly cleanup agent"
◐ Watching postgres (public.customers, public.orders, public.refunds) · session obs_01m4x82br7s87t8ksx1804bj7j
  Next: let the agent work, then run: deedglass observe stop obs_01m4x82br7s87t8ksx1804bj7j

The script runs. Then:

$ deedglass observe stop obs_01m4x82br7s87t8ksx1804bj7j
✓ Session sealed · 2 transactions · 13:01:47–13:01:47
  public.orders   223 updated
  public.refunds  3 deleted
Deedglass caught this: one transaction changed 223 rows in public.orders.
Observed: what Postgres committed. Nothing was compared with what the agent said.

Two things to notice. The “caught” line fires because one transaction changed more rows in one table than the threshold you set (here 100). And the last line is deliberate: a watched session gets no level and no Matches or Doesn’t match verdict, because there was no claim from the agent to compare against.1 It is a record of what Postgres committed, nothing more.

For a command you can wrap, one line does all of it: deedglass observe run --connector postgres -- ./agent.sh opens the session, runs the command, and closes it when it exits.

The session page lists each transaction separately:

Session obs_01m4x82br7s87t8ksx1804bj7j · nightly cleanup agent · 13:01–13:01 · postgres · sealed
2 transactions · public.orders 223 updated · public.refunds 3 deleted
  record   deed_01m4x837… · sealed
  dtx_01m4x82bw78njne44prftvappv · 3 changes
  dtx_01m4x82bw78njne44prftvappt · 223 changes
Deedglass caught this: one transaction changed 223 rows in public.orders.
  Undo this session in one step (Pro): deedglass observe undo obs_01m4x82br7s87t8ksx1804bj7j
  Or print the undo SQL and run it yourself (free): deedglass observe show obs_01m4x82br7s87t8ksx1804bj7j --undo-sql
  Changes from any writer during this window appear here. Leave out anything that wasn't the agent before you undo.

The undo SQL

The free path prints SQL and never runs it.1 You read it, you run it. Here is the top of the file for this session:

-- Deedglass undo SQL for watched session obs_01m4x82br7s87t8ksx1804bj7j (connector postgres).
-- Written 2026-10-14 13:02 UTC from the row images Postgres committed. Deedglass doesn't run it; you do.
-- It puts back 226 rows: 223 updates, 3 inserts, newest change first, in one transaction.
-- Each step checks the row still holds what the session left. If any row changed since, the
-- step stops the script and Postgres rolls back everything: nothing is half undone.
-- Covered: the tables below, by primary key. Not covered: sequences (they don't move back),
-- unwatched tables, and work your triggers do when these statements run.
--   public.orders
--   public.refunds
-- Run it with: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f undo.sql

The deleted refunds come back as inserts with every original column. Each updated order is restored only if it still holds exactly what the session left:

-- put back id=3 in public.refunds (it was deleted)
INSERT INTO "public"."refunds" ("amount_cents", "created_at", "id", "order_id", "reason") VALUES ('8789'::integer, '2026-07-12 07:29:00+00'::timestamp with time zone, '3'::integer, '11'::integer, 'Arrived damaged'::text);
-- restore id=221 in public.orders (the WHERE clause is shortened here)
DO $dg$ BEGIN
  UPDATE "public"."orders" SET "status" = 'paid'::text, "updated_at" = '2026-08-16 01:59:00+00'::timestamp with time zone WHERE "id" = '221'::integer AND … AND pg_catalog.format('%s', "status") = 'cancelled' AND …;
  IF NOT FOUND THEN RAISE EXCEPTION USING MESSAGE = 'Deedglass undo stopped: id=221 in public.orders changed after the session. Nothing was undone.'; END IF;
END $dg$;

That guard is the important design choice. If a customer paid for order 221 again after the agent cancelled it, blindly restoring the old row would overwrite the customer’s change. Instead the whole script stops, and because it’s one transaction, nothing is half undone. You look at that row, decide, and run the rest.

The one-step undo (deedglass observe undo, a Pro feature) does the same thing through Deedglass’s rules: a person countersigns it, and the undo itself is checked against the change log.1

Who made each change

A watched session holds every transaction on the watched tables in its window, and as the session page says, changes from any writer during the window appear there, not only the agent’s.1 Before you undo, leave out any transaction that wasn’t the agent (deedglass observe exclude). For a busy production table, keep sessions short, or run the agent with a connection you can tell apart by time and table.

Row images for a watched session are kept for 30 days; after that the session keeps its sealed summary and says the frames expired, so the undo SQL can no longer be generated.1 By default a session’s undo also stops 7 days after the session is sealed (undo.max_age), so decide within the week.1

Undoing one action instead

When the agent’s writes go through Deedglass’s rules, each write is its own action, with its own undo. In the 301-row run, one UPDATE fired a trigger that changed 300 other orders. The undo for that one action restored all 301 rows after a person countersigned it, and the check on the undo came back Matches.2

The difference is precision. A session undo puts back a window of time. An action undo puts back exactly one call, with the request, the rule and the result attached.

What undo can’t do

Be honest with yourself about the edges before you rely on any undo:

  • Things outside the database. An email sent, a payment captured, a message posted. Nothing in Postgres can recall them. For those, the decision has to happen before the call: a rule or a person.
  • Sequences. Restored rows keep their old ids, but a sequence that moved forward stays forward.
  • Unwatched tables, and what triggers do during the undo. Watch every table the agent can reach, and read the script before running it on a table with triggers.
  • Rows changed since. By design, the script stops rather than overwrite them.

If you want to see an undo countersigned and checked on a real Postgres, without installing anything, try the playground. To see which of your agent’s tables are undo-ready today, run the free scan, npx deedglass scan; it has an Undo-ready column, and the grade is explained here.

Sources

  1. 1Deedglass docs: Observe mode beside another gate · docs.deedglass.com · checked 14 October 2026
  2. 2Deedglass docs: Undo a bad write · docs.deedglass.com · checked 14 October 2026
  3. 3Neon documentation: Branching · neon.com · checked 13 October 2026
  4. 4Supabase documentation: Branching · supabase.com · checked 13 October 2026

Published 14 October 2026, last checked 14 October 2026. If something here is out of date, write to hello@deedglass.com.

Try it on your own setup

npx deedglass scan

Grades only your own setup. Nothing is uploaded. The package opens with the public release.

Pricing