Engineering
Undo for AI actions: recording every change with its reverse
A confirm dialog stops most mistakes with an AI assistant, not all of them. So next to the changes ours makes there is an Undo, and it is not a history feature bolted on afterwards. Each reversible change records its own reverse at the moment it happens, so it can be taken back in one tap.
The short version
- Almost every change the assistant makes is recorded with its own reverse, so it can be undone in one tap.
- We compute the reverse at the moment of the write, while we still hold the before-state, and store it beside the action.
- Undo is atomic and single-winner: only one attempt can take an action back, so a double tap or a second device cannot undo twice.
- A multi-step plan is undone in reverse order, because later steps can depend on earlier ones.
- A few changes have no safe reverse, like received stock that may already be part-sold, so they carry no undo and send you to the real screen to adjust by hand.
A confirm dialog stops most mistakes. It does not stop all of them. An owner in a hurry can confirm the wrong proposal, or confirm the right one and change their mind a second later. So next to almost every action the assistant takes, there is an Undo. Not a general history feature bolted on afterwards, but a reverse that is recorded at the same instant as the change.
The naive version of undo is to snapshot the whole table before every change and diff your way back. That does not scale, and it undoes things the owner never meant to touch. We wanted something smaller and exact. For this one action, what is the single change that puts the world back.
An action and its reverse are a pair
A change is only really complete when you also know how to take it back. When the assistant creates a product, the reverse is to archive that product. When it raises a price from 30 to 45, the reverse is to set the price back to 30. When it records a sale of two units, the reverse is to put those two units back on the shelf. Each of those reverses is itself one of the ordinary operations the system already supports, so undo is not a special power. It is just another write.
The important detail is when the reverse is worked out. We compute it at write time, while the before-state is still in front of us. Reversing a price change needs the old price, and the old price is gone the moment the new one lands. So we capture it on the way in and store it with the action.
// Recorded at the moment of the write, inside the same transaction.
type ActionLog = {
id: string;
orgId: string; // the tenant this action belongs to
forward: Proposal; // what the assistant did
reverse: Proposal; // the single change that undoes it
status: "applied" | "undone";
createdAt: Date;
};
// A price change stores the old value in its reverse.
const entry: ActionLog = {
id, orgId,
forward: { op: "update_service", id: svc.id, priceCents: 4500 },
reverse: { op: "update_service", id: svc.id, priceCents: oldPriceCents },
status: "applied",
createdAt: new Date(),
};Writing the action and its effect together matters. The forward change and its log entry go in the same transaction, so we never end up with a change that has no recorded reverse, or a log entry for a write that did not land. If either half fails, both roll back.
Undo has to have exactly one winner
Undo looks simple until two of them race. The owner taps Undo, the network is slow, they tap again. Or they undo on their phone while the same action is still on screen on the front desk tablet. If both attempts ran, a price could be reversed twice, or stock removed twice, and now the reverse has caused its own problem.
The guard is a single status flip that only one transaction can win. Undo does not start by applying the reverse. It starts by trying to claim the action, moving it from applied to undone in one conditional update. Only the update that finds the row still applied succeeds. The loser finds nothing to claim and stops. The reverse runs once, for the winner, inside the same transaction as the claim.
-- Claim the action. Only one caller can move it out of 'applied'.
UPDATE action_log
SET status = 'undone'
WHERE id = :id
AND org_id = :orgId
AND status = 'applied'
RETURNING reverse;
-- No row returned means someone already undid it. Do nothing.If that update returns a row, this caller owns the undo and applies the reverse it just read. If it returns nothing, the action was already undone, and the correct response is to do nothing and say so. Undo becomes safe to attempt more than once, because only the first attempt ever does any work.
Multi-step plans unwind in reverse
Some requests are not one action. Set up a new service, attach a consent form to it, and require a deposit is three writes in one plan. The assistant records each step as its own action, linked to the plan. Undoing the plan means undoing each step, in the opposite order to the way they were done.
Reverse order is not a nicety, it is correctness. The deposit rule points at the service, and the consent form is attached to the service. If you removed the service first, the steps that depend on it would have nothing to reverse cleanly. So undo walks the plan's actions from last to first, applying each stored reverse in turn, the same way you take off the layers you put on.
async function undoPlan(planId: string, orgId: string) {
const steps = await loadPlanActions(planId, orgId); // in apply order
for (const step of steps.reverse()) { // undo last first
const claimed = await claimUndo(step.id, orgId); // single-winner
if (claimed) await apply(claimed.reverse, orgId);
}
}When there is no safe reverse
Not every change has a reverse we can trust, and offering one anyway would be worse than leaving it out. Receiving stock is the clearest case. The moment new units land they can be sold, counted or moved, so the on-hand number is no longer the one the assistant set. Subtracting the units it added could push the count below what is really on the shelf and leave the owner chasing a figure that was never wrong.
So a single stock receipt carries no undo at all. The assistant tells you what it received and points you at the Products screen, where you set the real number yourself. The rule underneath is that a reverse is only offered when it is exact. A price we changed has an exact reverse, the old price. A product we created has an exact reverse, archiving it. A consumable count the rest of the business may already have touched does not, so we do not pretend otherwise. An undo you can trust everywhere it appears is worth more than one that appears everywhere and is sometimes wrong.
An action you cannot take back is a decision you have to get right the first time. Recording the reverse gives everyone a second chance.
None of this is exotic. It is a log with two operations per row, a conditional update that picks one winner, and a loop that walks backwards. Put together, it means almost everything the assistant does carries its own way home, and the few changes that cannot be reversed cleanly say so rather than guess. That is what makes it comfortable to let it act at all.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs