Claude Killed a Player's Character and There Was No Undo Button

Claude killed a player's character and there was no undo
So here's the situation. Multiplayer D&D session. Someone's fighter drops to zero HP. The AI narrates it dramatically, rolls death saves, fails them, fires the modify_creature tool with addConditions: ['dead']. The fighter is now permanently dead. Campaign state: mutated. Database: committed. Player: unhappy.
Turns out the AI misread the combat round. It was supposed to be a different creature taking the damage. But the tool call had already fired. The database had already committed. There was no button to un-ring that bell.
I'd been staring at this as the only HIGH-severity entry in our security debt log for months. OWASP calls it LLM06: Excessive Agency. The framework's prevention guidance is three pillars: audit log, human-in-the-loop, least privilege. We had pillar three (game rules already block players from modifying each other's gear). Pillars one and two? Nothing. Just vibes and a prayer that the model got it right.
Tonight we fixed it. Here's how.
The shape of the problem
The AI has 15 write-capable tools. Damage creatures, rest the party, advance to the next chapter, activate a dungeon, end the adventure entirely. All of them dispatch through a single function called executeBaseTool. Every tool call goes through one hook point. (Turns out past-me made a decision that current-me really appreciated.)
Not all tools are equally dangerous. move_token moves a token on the battle map - trivially un-moveable. end_adventure marks the campaign permanently completed, unlocks achievements, ends the active episode. Very much a "are you absolutely sure" button.
I ranked them by blast radius. Critical: end_adventure, start_combat, modify_creature. High: chapter progress, resource consumption, ending combat. Medium: turn advancement, fog reveals, memory writes. Low: token moves.
Write-ahead log. That's it. That's the trick.
The core idea is stolen directly from how databases handle rollback. Before you mutate, write a row to an audit table capturing the pre-state. If the mutation succeeds, mark the row as committed. If you later want to undo, read the pre-state from the row and put things back.
The schema is small:
CREATE TABLE ai_tool_invocations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id uuid NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
interaction_id uuid REFERENCES interactions(id) ON DELETE SET NULL,
tool_name text NOT NULL,
tool_tier text NOT NULL, -- critical | high | medium | low
input jsonb NOT NULL,
result jsonb,
status text NOT NULL DEFAULT 'pending',
-- pending → committed | failed | reverted
revert_snapshot jsonb, -- tool-specific pre-state
broadcast_events jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamp NOT NULL DEFAULT now(),
reverted_at timestamp,
reverted_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
revert_reason text
);The revert_snapshot column is the interesting part. Each tool has its own idea of "pre-state." For modify_creature, it's the target's HP and conditions and death save counters before the damage landed. For end_adventure, it's the campaign's completion state plus the set of achievement IDs that existed before the tool ran. For rest, it's every resource row for the character plus the IDs of every resource_event that pre-existed (so we know which ones the tool added).
All per-tool, all JSONB, no foreign keys. If the related row gets deleted later, the snapshot is still there. (JSONB gets a lot of grief for being schemaless. It's perfect for this.)
Wrapping the executor - one place, not fifteen
Because every tool dispatches through executeBaseTool, I only had to instrument one function. The wrapper records a pending row BEFORE the tool runs, then marks it committed or failed after. If the audit write itself fails, the tool refuses to run. Fail-closed on the safety net.
export async function executeBaseTool(toolName, rawInput, campaignId, ...) {
const shouldAudit = hasRevertHandler(toolName) && !!userId;
let invocationId: string | undefined;
if (shouldAudit) {
try {
// Snapshot capture runs BEFORE the mutation
const rec = await recordInvocation({ toolName, campaignId, userId, input: rawInput });
invocationId = rec.invocationId;
} catch (err) {
// WAL write failed - refuse to run. No audit, no mutation.
return { result: { error: 'Internal audit-log failure. Please retry.' } };
}
}
try {
const result = await executor(toolName, rawInput, campaignId, ...);
if (invocationId) {
const isErr = typeof result.result?.error === 'string';
isErr
? await failInvocation(invocationId, new Error(result.result.error))
: await commitInvocation({ invocationId, result: result.result });
}
return result;
} catch (err) {
if (invocationId) await failInvocation(invocationId, err);
throw err;
}
}The snapshot capture is fail-open: if reading pre-state throws, we still audit the tool call, just without undo coverage. The WAL row insert itself is fail-closed: if we can't write the audit row, the tool refuses to mutate state. You'd rather miss a turn than mutate without a paper trail.
The permission model nobody asks about until it matters
Who's allowed to undo? I settled on Option B: the original actor (the player who took the turn), OR the campaign owner. Anyone else gets a 403 and a security log entry. This matters in multiplayer. You don't want your paladin friend undoing your rogue's stealth roll because they didn't like the narration.
There's a subtle detail here. If you try to undo an invocation that belongs to a different campaign (wrong path param), you get a 404, not 403. Returning "forbidden" would leak that the invocation exists somewhere. 404 keeps cross-campaign probing impossible.
Idempotency and the double-click problem
Users will click Undo twice. They will open the dialog, confirm, then click confirm again because the network took a second. They will refresh the page between the click and the response landing. The revert endpoint has to be idempotent.
Inside revertInvocation, I wrap the whole thing in a transaction and acquire .for('update') on the WAL row. Second call hits the lock, waits for the first to finish, then reads the row - which is now in status = 'reverted' - and returns an "already reverted" skip reason without re-running the handler.
return await db.transaction(async (tx) => {
const [row] = await tx
.select()
.from(aiToolInvocations)
.where(eq(aiToolInvocations.id, invocationId))
.for('update')
.limit(1);
if (row.status === 'reverted') {
return { reverted: false, skipReason: 'already_reverted' };
}
if (row.status !== 'committed') {
return { reverted: false, skipReason: 'not_committed' };
}
const handler = getRevertHandler(row.toolName);
const details = await handler.revert({ ...row, snapshot: row.revertSnapshot });
await tx.update(aiToolInvocations).set({ status: 'reverted', ... });
return { reverted: true, details };
});Row-level locking means two concurrent undo requests serialize on the database. No special coordination logic needed on the API server. (Which is nice because the existing per-campaign action lock is already single-instance and I wasn't about to build two locking systems.)
Compensating broadcasts - you can't un-send a WebSocket event
WebSockets don't have retraction semantics. When the server sends a combat_state_update after a modify_creature tool call, every connected client updates its combat HP in-place. If you later undo the tool, you can't un-send the broadcast.
What you CAN do is emit a compensating event. The undo endpoint fires { type: "interaction_reverted", invocationId, toolName } to every client, tagged with source 'system' (not 'ai_tool_execution', so audit trails can distinguish the revert from the original). Clients handle it by removing the matching player and GM messages from the chat feed. The underlying state has already been restored server-side, so the next refetch pulls the correct HP.
The source tagging is actually a separate security control we shipped last audit - every broadcast carries provenance so clients can style AI-originated events differently from user-originated ones. Turned out to be extremely useful here. (Past-me did another solid.)
The frontend is almost boring
After all that backend work, the UI is 50 lines. An Undo button mounts on the most recent GM turn. Clicking opens a confirm dialog that lists the tool names that'll be reverted. On confirm, POST to the undo endpoint, close the dialog, let the WebSocket compensating broadcast rewind the feed.
I originally planned to gate visibility on a permission check in the client ("is this user the original actor or the campaign owner?"). Skipped it. The server already does this check, and the failure mode is a 403 with a clear error message. Over-gating in the client means two places to keep in sync. One source of truth is better than two mostly-correct copies.
What I deliberately did NOT build
Pre-commit confirmation for high-stakes tools. Like a "this will permanently end the campaign, are you sure?" modal that pops up DURING streaming for the critical-tier tools. This is the OWASP pillar-two win. But our AI response streams through Server-Sent Events, and the generator function can't pause mid-stream to wait for user input without a significant state-machine rewrite on both server and client. It's also gated on moving our single-instance action lock to Redis, which is its own ticket.
So for now: you can't stop a bad tool call. You CAN undo it the moment it lands. That's good enough for v1.
Pattern-based auto-confirmation. "This modify_creature is reducing HP by more than 50%, so force a prompt." The OWASP literature and Anthropic's own research both warn that 93% of permission prompts get approved blindly. Adding more prompts doesn't help - it just trains users to ignore them. I'd rather have a good undo button than a bad approval gate.
Cross-turn revert propagation. If a turn's tool output became evidence for a later turn's decision, undoing the earlier turn doesn't touch the later one. You undo turn N, but turn N+1 is still there referencing the old state. Documented limitation. The button says "undo the last GM turn" for a reason.
Before and after
The OWASP LLM06 scorecard for this system, before and after:
| Pillar | Before | After |
|---|---|---|
| Audit log of tool calls | None | Every write-capable call, indexed + queryable |
| Reversibility of mutations | None | Per-tool revert handlers for all 14 writes |
| Pre-commit confirmation for critical | None | Deferred - needs SSE refactor |
| Least privilege on downstream | Already had it | Unchanged |
| OWASP LLM06 coverage | 1 of 3 pillars | 2 of 3 pillars |
One HIGH-severity security finding closed. Zero new dependencies added. 38 new tests. Three sequential PRs (v5.2.0-rc1 → rc2 → v5.2.0). Each one independently revertable if I needed to roll back.
What this unlocks
The WAL table is also a gold mine for debugging model behavior. Every tool call Claude has ever made, with its input, its output, the model version, and the pre-state it mutated. You can now answer questions like "did our latest model upgrade change how often end_adventure gets called?" and "what's the hallucinated-damage rate per model version?" Those queries were literally impossible last week.
And when we do get around to the pre-commit confirmation piece, the infrastructure is ready. The WAL already captures what would be mutated. A confirmation UX is just adding a pause-the-stream protocol on top of the same capture step. (Future-me will thank present-me. Or curse me. We'll see.)
How much of your AI agent's damage is actually reversible right now, and how much are you just hoping the model gets it right?
