Twin CV
Engineering

How We Made Every AI Edit to a CV Undoable

An agent that writes to eight tables needs an undo people can trust. A journal of inverse operations, replayed newest-first inside one transaction, and the parts that still don't round-trip.

How We Made Every AI Edit to a CV Undoable

Twin CV's assistant doesn't just suggest text. It writes. It creates experience entries, renames skills, deletes a certificate, rewrites your summary, and every one of those is a real row in Postgres: eight CV sections, eight tables. Once a language model can write to someone's CV, undo stops being a nice-to-have. People only hand over the pen if they can take it back.

You can't get undo by asking the model. Its output isn't deterministic, and “put it back the way it was” is exactly the kind of instruction a model will cheerfully narrate without performing. So undo lives entirely below the model: every write the agent makes leaves a journal entry that knows how to reverse it. This post covers that journal, the order it is replayed in, the transaction around it, and the parts that still don't round-trip.

What undo has to mean
The requirement

What undo has to mean

The product surface is small. In the chat panel, every message you sent has an Edit button, and editing offers two choices: Edit text only or Edit and roll back changes. The second shows a preview (“2 skill(s) will be deleted”, “Your profile will revert”) and, once you confirm, reverts every CV change the assistant made from that message onward, then reruns the conversation from your edited text.

That one control pins down the engineering:

  • The unit of undo is a user message, not a tool call. One message can trigger several writes; they go back together.
  • Undo goes to a point: rolling back to message 41 also undoes 42, 43 and everything after.
  • The preview must be exact. It can't promise anything the restore won't do.
  • A failed restore must leave nothing half-done.
The journal

One row per write

Every mutating tool (create_skills, update_experiences, delete_certificates and the rest) appends to a plain journal table:

chat_checkpoints
  chat_id      → chats.id          ON DELETE CASCADE
  message_id   → chat_messages.id  the user message that caused it
  seq          int     monotonic per message_id
  tool_name    text
  entity_type  enum    profile | skill | experience | user_language
                       | education | certificate | cv_project | cv_extra
  operation    enum    create | update | delete
  entity_ids   jsonb   ids touched
  payload      jsonb   create → {}
                       update → {"before": [...], "after": [...]}
                       delete → {"rows": [full row snapshots]}
  created_at   timestamptz

The payload holds exactly what the inverse needs. A create needs only the new ids, because undoing it is a delete. An update stores the rows as they were before (and after, for auditing). A delete stores the full row, because the only way back from a delete is an insert. entity_ids is JSONB rather than int[] so a future UUID-keyed table can join the journal without a migration.

Tools don't insert journal rows themselves; they call a small writer. Here is the delete path for skills, lightly trimmed:

async def _exec_delete_skills(ctx, payload):
    ids = payload.normalized_ids()
    rows = await fetch_user_skills(ctx.db, ctx.user.id, ids)
    snapshots = [snapshot_skill(r) for r in rows]  # before the delete
    for r in rows:
        await ctx.db.delete(r)
    await ctx.db.flush()
    ctx.writer.record_delete(
        tool_name="delete_skills",
        entity_type=CheckpointEntityType.skill,
        rows=snapshots,
    )
    return {"deleted_skill_ids": [s["id"] for s in snapshots]}

Snapshots are explicit per-table field lists serialized to JSON-safe values: datetimes become UTC ISO strings, enums become their values. On the way back, a small decoder looks up each column's Python type and turns ISO strings back into date, datetime or time.

The restore

Inverse operations, newest first

Restoring is three branches, one per operation:

async def _apply_inverse(db, cp):
    model = _ENTITY_MODELS[cp.entity_type.value]
    if cp.operation == CREATE:
        await db.execute(delete(model).where(model.id.in_(cp.entity_ids)))
    elif cp.operation == UPDATE:
        for row in cp.payload["before"]:
            values = {k: decode(model, k, v)
                      for k, v in row.items() if k != "id"}
            await db.execute(update(model)
                             .where(model.id == row["id"])
                             .values(**values))
    elif cp.operation == DELETE:
        for row in cp.payload["rows"]:
            await db.execute(insert(model).values(
                **{k: decode(model, k, v) for k, v in row.items()}))

The interesting part is the order. The rows to replay are read with:

SELECT * FROM chat_checkpoints
 WHERE chat_id = :chat_id AND message_id >= :target
 ORDER BY message_id DESC, seq DESC;

Strictly last in, first out. Consider three messages touching one experience entry:

  1. Message 41 creates experience #7 with the title “Developer”.
  2. Message 42 renames it to “Senior Developer”.
  3. Message 43 deletes it.

Rolling back to 41 must re-insert #7 as it was just before the delete (“Senior Developer”), then set the title back to “Developer”, then delete the row that 41 created. Replay in any other order and it breaks quietly: the update runs before the row exists, an UPDATE that matches nothing is a no-op, and the final state is wrong without a single error. LIFO is the only order in which every inverse runs against exactly the state its forward operation left behind.

Two details make it hold. A deleted row comes back with its original primary key; the sequence never hands that id out again, so the insert can't collide, and every older journal entry that mentions #7 still points at the right row. And seq is per message, computed at flush time as max(seq) + 1, which gives a total order inside a message even when one message triggers five tool calls. The chat holds a lock while a turn runs, so two turns never race for the same seq.

Atomicity

One transaction, all or nothing

The edit endpoint does the whole rewind in a single database transaction:

async with session_factory() as db:          # one transaction
    if payload.rollback_state:
        # every inverse, newest first, then delete the replayed rows
        await apply_restore(db, chat_id=chat.id,
                            target_message_id=message_id)
    else:
        # text-only edit: drop the journal for the vanishing messages
        await checkpoints.delete_after(db, chat.id, message_id)
    await messages.delete_after(db, chat.id, message_id)
    await messages.replace_content(db, message_id, payload.content)
    chat.agent_state = None                   # the agent's half-done draft
    run = await runs.create(db, chat_id=chat.id,
                            user_message_id=message_id)
    await db.commit()

If any inverse fails (a constraint, a decoding error, anything), the exception escapes before commit(), the session rolls back, and the CV, the journal and the conversation are exactly as they were. The user sees an error, not a CV that is half old and half new. Before any of this, the endpoint answers 409 agent_busy if a turn is running and takes the chat lock, so the agent can't write into a CV while it is being rewound.

A text-only edit deletes the journal entries without replaying them. Their messages are about to disappear, and a later rollback must never try to undo writes from a conversation that no longer exists. Either way, the edited message then starts a fresh agent run, streamed over the same SSE channel as any other message.

Show the undo before doing it
The preview

Show the undo before doing it

The confirmation dialog reads the same journal, forwards this time, and groups it by table and operation:

GET /v1/chat/me/messages/{id}/rollback-preview

{"items": [
   {"entity_type": "profile",
    "operation": "update", "count": 1},
   {"entity_type": "skill",
    "operation": "create", "count": 2}],
 "total_operations": 2}

The panel phrases each line as what will happen, not what happened: a create of two skills becomes “2 skill(s) will be deleted”. Because the preview and the restore read the same rows with the same filter, the dialog can't describe something the restore won't do, which matters most when you're asking someone to press a destructive button.

The write path

Commit the write and its undo together

A checkpoint is only useful if it exists exactly when its write does. A write without a journal row can't be undone; a journal row without its write would “undo” something that never happened. So both go through one unit of work per tool call:

@asynccontextmanager
async def tool_context():
    async with session_factory() as db:
        user = await users.get(db, principal_id)
        writer = CheckpointWriter(chat_id=chat_id, message_id=message_id)
        try:
            yield ToolContext(db=db, user=user, writer=writer)
        except Exception:
            await db.rollback()     # the write and its journal row vanish
            raise
        else:
            await writer.flush(db)  # journal rows, same transaction
            await db.commit()

This boundary has moved once, and the move is a real trade-off. The first version of the agent ran all of a turn's writes in one transaction: a separate worker model executed the edits, then either called final_response (commit and flush the journal) or report_blocker (roll back and discard it). All or nothing per turn. That was neat until the agent became a single loop that can take up to 25 model round-trips in one turn, and a transaction held open across all of them would sit idle, holding locks, for minutes.

So today each tool call is its own transaction. The code comment states the cost plainly: a turn that dies halfway keeps the writes it already committed. For a CV builder that's the right trade. Half an entry saved beats nothing, and the journal still undoes the whole message, because every one of those per-call commits carries its checkpoint rows under the same message_id.

Limits

What doesn't round-trip

A journal of row-level inverses is honest about rows and nothing else. The edges we know about:

  • Files. When the agent removes a profile photo, the column change is journaled and the file is deleted afterwards. Undo restores the column; it can't resurrect the file. The code calls it the same trade-off as any soft delete of binary data, and that's what it is: the photo has to be uploaded again.
  • Edits made by hand. Only the agent's writes are journaled. If you change a row yourself in the CV editor after the agent touched it, rolling back past the agent's change writes its before snapshot over your edit, and a row the agent created disappears even if you've polished it since. The journal records what the agent changed, not who changed a row last.
  • Time. The journal doesn't live forever: an admin cleanup endpoint deletes checkpoints older than a configurable TTL, 30 days by default. After a purge you can still edit an older message; there's just nothing left to roll back.
  • Guests. Quick CV, the no-account mode, has no edit-and-roll-back control. It's part of the full chat panel.
Two things we got wrong first
Lessons

Two things we got wrong first

Telling the model about the rewind. An earlier version added a system note to the conversation after an edit, saying in effect that the user had edited and rolled back. The model did what models do with context: it talked about it, and replies opened by narrating the rewind. Now nothing is injected. An edit is a logical rewind: later messages are deleted, the target's text is replaced, and the model sees the conversation as if the new text had always been there.

Forgetting the agent's own state. The agent keeps a small per-conversation record of unfinished work: the entry it is halfway through collecting. After a rollback, that draft can describe an entry whose rows were just deleted, and the agent would then try to finish something that no longer exists. The edit endpoint now clears it in the same transaction. Rule of thumb: anything derived from the timeline has to be rewound with the timeline.

The engine is covered by integration tests against a real Postgres (create, update and delete, each written through the journal and rolled back), because the bugs a journal can have are the silent kind.

Watch the agent write
Try it

Watch the agent write

If you're building an agent that writes to user data, the pattern is portable: journal every write with enough to invert it, commit the journal row in the same transaction as the write, replay newest-first inside one transaction, and build the confirmation UI from the same rows. None of it involves the model, which is the point.

You can watch Twin CV's assistant build a CV from a conversation without an account in Quick CV; edit-and-roll-back is in the chat panel once you sign up. The companion post covers the other half of trusting an agent with writes: stopping it from claiming changes it never made.

What this looks like from the user's side: building a CV by chat, step by step.

FAQ

Questions about undo

A visitor asks a question at a help desk and the twin robot answers it
Why not snapshot the whole CV after every message?

It would work, and restoring would be simpler. But restoring a whole-CV snapshot also rewrites every row the agent never touched, including your own edits. A per-write journal touches only what the agent changed, and it already knows what that was, which is exactly what the confirmation dialog needs to show.

Does typing “undo” in the chat roll things back?

No. Undo is a control in the panel (Edit on an earlier message, then Edit and roll back changes), not something the model is asked to do. That keeps it deterministic.

What happens if the restore fails halfway?

Nothing is applied. The inverse operations, the journal cleanup, the message truncation and the new agent run are all in one transaction, and any error rolls the whole thing back.

Can I use rollback in Quick CV?

No. Quick CV is the no-account guest mode; the edit-and-roll-back control is in the chat panel after you sign up, and signing up keeps the CV and chat you started as a guest.

Related reads

Stopping an LLM From Narrating Changes It Never Made
November 8, 2026

Stopping an LLM From Narrating Changes It Never Made

Your twin is one conversation away.

Start free with Quick CV

Stay in the loop

Occasional product news and practical career tips — only when we have something worth your time.