MuninMunin
Sign inStart free
Home/Journal/Undo, for an author you can't watch.
Engineering · 10 min read

Undo, for an author you can't watch.

MCP's tool annotations are hints, and the specification says clients must treat them as untrusted. A hint asks the client to be careful. A required `ifVersion` in the input schema doesn't ask. Here is what a version row actually holds, what restore actually does, and why six CMS tools refuse to run without the number you last read.

A bright modern bedroom seen at eye level: a stack of twelve identical crisply folded white linen sheets squared up on a pale oak bench at the foot of the bed, and one sheet taken from the stack laid out flat and smooth across the mattress — every prior state kept, one of them put back.
Twelve states kept, so that one of them can come back.

The interesting question about an agent writing to your database was never whether it is allowed to. It is whether, three days later, you can find the sentence it changed, see what stood there before, and put that back.

Can you undo what an AI agent wrote?

Only if the write was recorded as a version rather than as an overwrite. In Munin every CMS entry and knowledge-base document keeps its full prior states, listed newest-first by cms_list_versions or kb_list_versions, and cms_restore_version rolls one back. Undo is not a feature of the client that called the tool. It is a property of the row.

That distinction is the whole post. An agent's write is not more dangerous than a human's because a model is careless — it is more dangerous because nobody was watching at the moment it happened. Nothing about the write itself is unusual. What is unusual is the absence of the person who would normally have noticed. That absence is the actual shift, and it moves the weight off supervision and onto the shape of the write.

What does a version row actually contain?

A complete snapshot, not a diff. Each row carries an id, the entry it belongs to, the version number, the status the entry had at that moment, the full data payload as it stood, and a timestamp. Nothing is reconstructed by replaying changes, so a restore cannot fail because an intermediate step was malformed.

Because an entry body is an ordered array of typed blocks rather than one long text field, the snapshot is structured all the way down — you get back the blocks and their keys, not a wall of markdown you then have to re-parse. This post's own neighbour in the Journal is a decent demonstration. If agents are the new users, MCP is HTTP. has been edited eleven times since June, and cms_list_versions returns eleven snapshots — every dek, every block, every rewritten heading, each one complete enough to restore on its own.

pythoncms_list_versions / cms_restore_version
# what the version list gives you back
[
  {
    "id":      "cev_hgv0jxkmhppxtlbwfe663i",
    "entryId": "cme_nvjisrqctcbkn36mwmr2mm",
    "version": 11,
    "status":  "published",
    "data":    { ... the entire entry as it stood ... },
    "createdAt": "..."
  },
  { "version": 10, ... },
  { "version":  9, ... }
]

# and the roll-back, which is itself a versioned write
cms_restore_version(
  entryId = "cme_nvjisrqctcbkn36mwmr2mm",
  version = 9,      # the state you want back
  ifVersion = 11    # the state you are replacing
)
# -> creates version 12, holding version 9's data

Read the last line of that snippet twice, because it is the part people get wrong when they build this themselves. Restore does not rewind. It appends. Rolling back to version 9 creates version 12 carrying version 9's data, and versions 10 and 11 stay exactly where they were.

The difference matters the first time a roll-back is itself the mistake. A rewind-style restore destroys the evidence of the thing you were trying to undo, which means the second undo has nothing to work from. An append-only chain means a bad restore is just another entry in the chain — and cms_restore_version requires an ifVersion of its own, so even the undo is bound to the state it thought it was fixing.

Why require a version token on an ordinary write?

Because two agents on one endpoint is now the normal case, not the edge case. ifVersion is required — not optional — on six CMS tools: cms_update_entry, cms_publish_entry, cms_unpublish_entry, cms_schedule_publish, cms_delete_entry, and cms_restore_version. A caller who has not read the current state cannot construct a legal call.

Most systems that ship optimistic concurrency put the version check on the approval step — the moment a human signs off — and leave ordinary edits to last-write-wins. That is the easier half. The Journal you are reading is edited by a scheduled agent that runs every morning, by me, and occasionally by a second agent doing a curation pass, and none of those three know about each other. Last-write-wins between them is not a race condition you hit occasionally. It is a race condition on a timer.

Note what cms_create_entry does not take. There is no ifVersion on a create, because there is no prior state to be wrong about. The token appears exactly where a caller could otherwise clobber something, which is the test for whether a required parameter is discipline or ceremony.

Two tokens on one write

kb_publish_curation_revision takes both ifCandidateVersion and ifDocumentVersion. The first binds the write to the proposed text that was reviewed; the second to the document it was diffed against. If either moved, nothing is written. Two tokens, because a revision is a claim about two objects — and a write that checks only one of them can still land on top of an edit nobody read.

A hint asks the client to be careful. A required parameter does not ask.The design rule underneath all of this

Are MCP tool annotations enough to make agent writes safe?

No, and the protocol says so itself. Tool annotations shipped in the 2025-03-26 revision as four booleans — readOnlyHint, destructiveHint, idempotentHint, openWorldHint — and the specification is explicit that clients must treat them as untrusted unless they come from a trusted server. Every property is a hint. A server can claim readOnlyHint: true and delete your files anyway.

Munin sets them, and they do useful work: they are what lets a host decide whether to put a confirmation dialog in front of a call. But the MCP maintainers' own March 2026 post on tool annotations draws the line precisely, in a sentence worth quoting because it is the cleanest statement of the problem I have read: "Hints inform decisions; contracts enforce them." The post goes on to say that when a proposal's value depends on the annotation being true, the right home for it is the authorization layer, the transport, or the runtime.

I would add a fourth home, and it is the cheapest of the four. Put it in the input schema. A required parameter is enforced by the thing that validates the request, which means it holds identically whether the caller is Claude Code, a cron job, a custom runner, or a script somebody wrote at 2am. The same specification advises clients to keep a human in the loop and to log tool usage for audit purposes — both stated as SHOULD, both addressed to the client. A SHOULD in someone else's codebase is as durable as their last deploy.

  • Version tokenThe integer you last read, handed back on the write. Used by cms_update_entry and kb_update_document. Cheap, because the version is already in the object you just fetched.
  • Two version tokenskb_publish_curation_revision binds to the proposed text and the target document at once. Necessary whenever a write is a claim about the relationship between two rows rather than about one.
  • Content fingerprintA digest of the whole draft rather than a counter. outreach_approve_proposal requires the proposal's draftFingerprint; crm_apply_merge_proposal requires its mergeFingerprint. The right choice when what must not change is a composition — recipient, subject, body, send time — and a version number would let a field move without moving the count.
  • Propose-only verbNo token at all, because the tool does not write the thing. crm_propose_merge files a row; the merge itself is a separate, separately-scoped call. What an agent cannot execute, it cannot execute against a stale read.

Those four are one idea wearing different clothes: the write carries proof of what the writer last saw. It took us a while to notice they were the same idea, and naming it changed how we review new tools. The question on any new mutating tool is no longer "who is allowed to call this" — scopes and audiences settle that — but "what does the caller have to have read first, and what does the call carry to prove it?"

The knowledge base got there before the CMS did. Its curation queue binds a publish to the exact text a reviewer read, and the discipline was obviously right the moment we had it in one module, which is why it spread to the rest.

How do you know which agent made a change?

By separating two questions that look like one. The version chain answers what the entry said at version 9. The decision records answer who chose it, when, and why. Munin keeps them apart deliberately, because a snapshot needs to be cheap enough to write on every edit, and a justification needs a place to put prose.

So the actor shows up on the decisions rather than on the snapshots. outreach_dismiss_proposal keeps the actor and timestamp on the proposal. outreach_withdraw_proposal requires a reason and records who withdrew it. outreach_revise_proposal records revisionCount, lastRevisedAt, lastRevisionReason, the revising actor, and revisedAfterReviewAt — a flag that exists solely to catch the case where somebody had already opened the draft for review before it changed under them. crm_set_contact_consent logs a CRM activity carrying the lawfulBasis and the source label. conv_strip_message_signature keeps the original body in metadata.preStripBody, so a cleanup pass is reversible too. Even analytics_revoke_tracker leaves the tracker row standing after the key stops working.

Every call resolves to an actor before it reaches any of that. ping is the least glamorous tool in the catalogue and it returns the resolved org id, the org name, and the actor type — which is how you find out, in one call, that the connector you thought was pointed at one tenant is pointed at another. It is the tool I use most often, and never for what it was built for.

What can't a version chain do for you?

Three things. It will not diff two versions for you — cms_list_versions returns complete snapshots, and the comparison is yours to make. It stops at deletion, because cms_delete_entry cascades to its own versions. And it is not a review: it proves a write landed on the state the caller read, not that the state was worth keeping. That last one is what the queues are for.

Take them in order, because each is a consequence of a choice rather than an accident.

The missing diff falls out of storing states rather than deltas. A snapshot restores exactly, in one call, with no replay, and comparing two of them is a problem you can solve in any language you like — whereas a stored delta that turns out to be wrong is a corrupted chain you cannot restore from at all. Given the choice between a comparison you write yourself and a history that might not load, we took the history.

Deletion ending the chain is why the delete verbs are the most tightly bound of the lot. cms_delete_entry cascades to its versions and kb_delete_document cascades to chunks and versions, so both require ifVersion — the most destructive call in the module is the one that most insists on knowing what the caller was looking at. And the genuinely irreversible operations in CRM and Outreach are not available to an agent as single calls at all. An agent can file a merge proposal. Applying it is a different tool, with a different scope, and it needs the fingerprint.

The third is the one worth building on. A version token and a review queue solve adjacent halves of the same problem: the token makes the write honest about what it saw, and the queue puts a person in front of the writes where seeing correctly is not enough. Cheap writes apply directly and expensive ones only propose — and the version token is what makes "apply directly" a defensible category rather than a shrug, because every one of those direct writes is still bound to a state somebody can go back to.

6
CMS tools that will not run without the version you last read
2
version tokens on a single knowledge-base revision publish
11
versions kept behind one published Journal entry
4
boolean hints in the MCP ToolAnnotations interface

Frequently asked questions

Can you roll back a change an AI agent made to your CMS? Yes, if the platform versions writes rather than overwriting them. In Munin, cms_list_versions returns every prior state of an entry newest-first, and cms_restore_version puts one back. The roll-back is itself a versioned write requiring its own ifVersion, so an undo cannot land on a state you had not read.

What is optimistic concurrency in an MCP tool? The caller hands back the version it last read, and the server refuses the write if the row has moved since. It is optimistic because nothing is locked — two agents can read the same entry at once, and the second one to write simply loses and has to re-read. On Munin's CMS write tools the token is a required parameter, not an optional one.

Are MCP tool annotations a security control? No. readOnlyHint and destructiveHint are hints, and the MCP specification requires clients to treat them as untrusted unless the server is trusted. They are good for driving confirmation prompts and bad for guarantees. Enforcement belongs in the authorization layer, the transport, the runtime — or, cheapest of all, in the tool's required parameters.

How do you know which agent changed a record? Through the decision records rather than the snapshots. Munin's proposal tools keep the acting party, the timestamp, and a reason: outreach_withdraw_proposal requires a reason and records the withdrawing actor, outreach_revise_proposal records the revision count and the reviser, and crm_set_contact_consent logs an activity with the lawful basis and source.

Does restoring an old version delete the newer ones? No. Restore appends. Rolling an entry back to version 9 creates version 12 holding version 9's data, and versions 10 and 11 remain in the chain. This is what makes a mistaken roll-back recoverable — the state you restored away from is still there.

Do you need a version token when creating a record? No, and Munin does not ask for one. cms_create_entry takes no ifVersion because there is no prior state a caller could be wrong about. The token belongs only on calls that replace, publish, schedule, restore, or delete something that already exists.

If you are building writes for an agent

  • Version the write instead of overwriting the row. Undo is a property of the data, not of the client that made the call.
  • Store snapshots rather than deltas. A snapshot restores in one call with no replay, and it cannot be corrupted by a bad intermediate step.
  • Make restore append rather than rewind, so a mistaken roll-back is recoverable and the chain never loses a step.
  • Put the version token in the input schema as a required parameter. ifVersion is required on six of Munin's CMS tools, so a caller who has not read the current state cannot construct a legal call.
  • Use a fingerprint instead of a counter when what must not change is a composition — recipient, subject, body, send time — rather than a single row.
  • Keep snapshots and decisions in separate places. The version chain records what the record said; the proposal records the actor, the timestamp, and the reason.
  • Treat MCP tool annotations as UX, not enforcement. The specification tells clients to distrust them, and every safety SHOULD in the spec is addressed to code you do not control.

Every tool named here is in the MCP reference with its full input schema, and the migration that creates the version table is in the repository.

An author you can't watch is fine. An author you can't undo isn't.