How to Add Soft Delete and Undo to an AI Built App
Undo, recycle bin and audit history are three features, not one. The deleted_at implementation, the four traps AI builders hit, and the hard delete you still owe.
A user deletes a client record. Attached to it are two hundred invoices, four years of notes and a payment history. They meant to delete a duplicate. To add soft delete and undo to an AI built app properly, you mark rows as deleted instead of removing them, hide them from every read path, and give the user a window to change their mind. The implementation is a single column and a lot of discipline about queries, and the traps are consistent enough to list.
First, a distinction that saves you from building the wrong thing.
These are three separate features
Undo is a short window, seconds to a minute, surfaced right where the action happened. It answers 'that was a misclick'. It needs no new screen and no permissions model, just a toast with a button and a delay before anything irreversible runs.
Soft delete is a recycle bin measured in days or months. It answers 'we deleted that in March and now we need it'. It needs a restore screen, a retention policy and a decision about who can see the bin.
Audit history is a record of who did what, and it answers 'who deleted this'. It is not a recovery mechanism, and building it is a different job entirely, covered in adding an audit log to an AI built app.
Most apps need undo everywhere and soft delete on the two or three entities that hurt to lose. Applying soft delete to every table in the schema is how you end up with a database full of tombstones and queries nobody trusts.
The implementation
A nullable timestamp, not a boolean. The boolean version tells you something was deleted. The timestamp tells you when, which is what every retention policy, every restore screen and every support conversation actually needs.
alter table clients
add column deleted_at timestamptz,
add column deleted_by uuid references users(id);
-- partial index: the live rows stay fast, tombstones stay out of it
create index clients_live_idx on clients (workspace_id)
where deleted_at is null;
-- a view is the cheapest way to stop forgetting the filter
create view clients_live as
select * from clients where deleted_at is null;That view is doing more work than it looks like. The single most common soft delete bug is a query somewhere that forgets the filter, and deleted records reappear in a report, an export or a dropdown months later. Make the filtered view the default thing your code reads, so forgetting the filter requires actively choosing the raw table.
For undo, do not delete on the click at all. Mark the row, show the toast, and let the restore be a null update. Sixty seconds later nothing more happens, because the row is already in the exact state it needs to be in.
Four traps that catch AI generated implementations
Ask an AI builder for soft delete and you will reliably get the column and the filter, and reliably not get these.
Unique constraints keep counting the dead. A user deletes the project 'Website Redesign' and cannot create a new one with that name, because the unique index still sees the tombstone. Fix it with a partial unique index that only covers live rows: unique (workspace_id, name) where deleted_at is null.
Children outlive parents. Delete a client and its invoices stay visible in the invoice list, now pointing at a record the UI refuses to show. Decide per relationship whether deletion cascades to a soft delete of children or is blocked while children exist, and write it down. Cascading soft delete needs a marker so restore knows which children it deleted and which were already gone.
Counts and aggregates drift. Dashboards, usage limits and billing totals written against the raw table start including deleted rows. Anything that counts is a place to check, and it is the failure users notice last and trust least.
The bin has no permissions. If a workspace member can delete a record, that does not mean every member should be able to browse and restore everything the team ever deleted. Treat the bin as its own permission, as covered in role based permissions for an AI built app.
Trap one is the one users report as a bug within the first week. Trap three is the one that costs you money quietly.
You still owe a real delete
Soft delete alone does not satisfy a deletion request. If your app holds personal data on people in the EU or UK, the right to erasure under Article 17 of the GDPR means data actually leaves your systems, not that a flag gets set. A row marked deleted_at is still stored, still readable by your team, and still in every backup.
So build two paths and name them differently in your code. The user-facing delete is soft and reversible. A purge is hard, permanent, and triggered by a retention job or an erasure request. Keep the second one boring and well tested, because it is the one you run under pressure.
A retention job that hard deletes anything past its window, say ninety days in the bin, keeps the table honest and gives you a defensible answer when someone asks how long you keep deleted data. Pick a number, publish it, and let the job enforce it.
Why this matters more in an AI built app
Agents and automations delete things. A cleanup script written from an ambiguous instruction, a bulk action approved without a close read, a migration that removes what it thinks are orphans. When the actor is not a person clicking a button, the misclick model of undo does not apply and the recycle bin becomes the actual safety net.
That is worth designing for deliberately, and it is the difference between an incident and a bad afternoon. The wider version of this problem, when the deletion happened straight against production, is covered in what to do when an AI coding agent deletes production data.
Frequently asked questions
Should I use a deleted_at timestamp or an is_deleted boolean?
Timestamp. It costs the same to store and answers questions the boolean cannot: how long has this been in the bin, what should the retention job collect, what did the table look like last Tuesday. Adding deleted_by alongside it makes support conversations short.
How long should items stay in the recycle bin?
Thirty days suits consumer apps, ninety suits business tools where someone notices at quarter end. What matters more than the number is that it is enforced by a job and stated somewhere the user can read it.
Does soft delete slow the database down?
Not meaningfully, if your indexes are partial and exclude deleted rows. It becomes a problem when tombstones accumulate for years with no retention job, which is an argument for the purge path rather than against soft delete. Sizing considerations are in how to choose a database for an AI built app.
How do I add this to an app that is already live?
Add the column and the view first and change nothing else, so every existing query keeps working. Then move read paths onto the view one at a time, and only switch the delete endpoint to a soft delete once the reads are migrated. Doing it in the other order makes deleted records visible everywhere for however long the migration takes. Specifying that sequencing explicitly is the difference between a clean change and a messy one, as in how to build an app with AI.
How did this land?
About the author

Developer Advocate
Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.


