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:
- Message 41 creates experience #7 with the title “Developer”.
- Message 42 renames it to “Senior Developer”.
- 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.