This is the write tool a Claude chat gets for osFoundry Notes, as the public MCP endpoint lists it. It was fetched with an unauthenticated tools/list on 2026-11-06 and trimmed to the fields that matter here:
{"name": "edit_note_tab",
"description": "Use an exact tab ID and version from read_note_tab. Use replace_text for Note/Markdown, slide_add or slides_add_outline for Slides, sheet_set_range for Sheet, or replace_tab_data for supported content tabs. File and Source attachments cannot be edited through this tool. A repeated idempotency key returns the first result.",
"inputSchema": {
"required": ["note_id", "tab_id", "expected_version", "idempotency_key", "operation"],
"properties": {
"operation": {"enum": ["replace_text", "replace_tab_data", "slide_add",
"slides_add_outline", "sheet_set_range"]},
"sheet_id": {"type": "string"}, "start_row": {"type": "integer"},
"start_col": {"type": "integer"}, "values": {"type": "array", "items": {"type": "array"}}}},
"annotations": {"readOnlyHint": false, "destructiveHint": true},
"securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]}
Two of the five required fields aren't about the edit at all. expected_version makes the write a compare-and-swap: it lands only if the Note is still at the version the agent read. idempotency_key makes a resend harmless. The other Note writes, create_note_tab and add_note_source, require the same pair. Reads don't: list_note_tabs, read_note_tab, read_sheet_range (at most 100 by 100 cells) and list_note_attachments are all readOnlyHint: true under osfoundry:read.
Below is what that contract buys, measured on a rebuild. A version check is only half of it. The other half is what the agent does after it's rejected.
Why reject a stale write instead of merging it?
replace_text sends the whole text of a tab. If Claude read version 7, a person saved version 8, and Claude now sends its text, the server holds two full documents and one common ancestor it no longer has. It can't tell whether Claude meant to delete the person's new paragraph or simply never saw it. Last-writer-wins picks the wrong answer quietly. Rejecting is the only response that doesn't guess.
That is optimistic concurrency. No one holds a lock while a model thinks for twenty seconds. Each write states the version it was based on, and the server swaps only if that's still current:
UPDATE notes SET version = :new WHERE id = :note AND version = :expected; -- 0 rows → conflict
osFoundry's desktop app does the same thing from the human side. In apps/web/src/screens/notes/context/NotesContext.tsx, the Note save is a PATCH /notes/:id that carries expected_updated_at (the last server revision the editor saw) and, after a Connected write, an expected_version that connected-note-save-guard.ts keeps in localStorage so a reload can't erase it. When a save fails, the editor keeps the person's content as a local draft and shows a sync error. Both editors are conditional writers.
What should the agent do when it's rejected?
There are two plausible reactions, and only one of them works:
- Re-read the tab, re-apply the intended change to the fresh content, and write with the fresh version.
- Re-read only the version and resend the same payload.
The second one passes the check and still overwrites the person, because the payload was computed from the old text. The check can't see that. A version only proves you've seen the latest version number, not that you used its content.
The rebuild
No Claude binary runs here. notes_store.py implements list_note_tabs, read_note_tab, read_sheet_range, edit_note_tab (replace_text, sheet_set_range) and create_note_tab on sqlite in WAL mode, with the live tool names and argument names. Every write runs inside BEGIN IMMEDIATE, checks the idempotency table, compares the version, applies, bumps the version and stores the result, all in one transaction. race.py starts two threads with their own connections: a scripted "Claude" and a scripted person. Each does read → think → write, 100 times. Think time is scaled down: 20–80 ms for Claude, 5–40 ms for the person, so the agent's read-to-write window is the longer one, as with a real model. Each write appends a unique line (claude-17, human-42), so afterwards a missing line is a lost update you can count. Python 3.9.6, Apple M1, seeds 1–5.
The write path, without the sqlite plumbing:
def _guarded(self, note_id, tab_id, expected_version, idempotency_key, apply):
db.execute("BEGIN IMMEDIATE")
hit = db.execute("SELECT result FROM idem WHERE key=?", (idempotency_key,)).fetchone()
if hit:
return {**json.loads(hit[0]), "replayed": True}
current = tab_version if self.check == "tab" else note_version
if self.check != "none" and expected_version != current:
return {"error": "version_conflict", "current_version": current}
result = apply(db) # replace_text / sheet_set_range / new tab
# bump version, store (key, result), COMMIT
And the agent's loop, with the policy switch:
r = s.edit_note_tab("note-1", tab, tab_now["version"], uuid.uuid4().hex, **payload)
while "error" in r:
fresh = s.read_note_tab("note-1", tab)
if policy == "reapply":
payload["text"] = fresh["data"] + "\n" + line # redo the change on what's there now
tab_now = fresh # refresh_version keeps the old text
r = s.edit_note_tab(...)
A conflicted attempt gets a new idempotency key. The key names one intended write on one base. After a rejection, the re-applied write is a different write.
How many updates are lost without the check?
Five runs, 100 edits per editor per run, 1,000 intended lines in total per row:
| Write guard | Agent on conflict | Lost lines | Lost: Claude / person | Conflicts: Claude / person | Max tries |
|---|---|---|---|---|---|
| none | — | 300 | 125 / 175 | 0 / 0 | 1 |
| Note version | re-read, re-apply | 0 | 0 / 0 | 219 / 133 | 2 |
| Note version | re-read version, resend old text | 304 | 117 / 187 | 213 / 136 | 3 |
Without a check, 30% of all edits vanished, and the person lost more than Claude did. The agent's longer think time means its writes are based on older reads, so they overwrite more. With the check and a re-apply, nothing was lost. 41–48% of Claude's writes needed a second attempt, never a third. The mean was 1.41–1.48 tries for Claude and 1.24–1.30 for the person. Every run took 9.6–10.5 s of wall time whatever the mode, because think time dominates.
The third row is the one to remember. The server reported over 300 conflicts and protected nothing. A version check enforces that you read again. It can't enforce that you used what you read.
Does the check cost anything?
bench.py times 2,000 sequential edit_note_tab calls with a 200-character replace_text, three repetitions per mode. Median per write: 61.9–63.1 µs with no check, 57.0–58.8 µs with the Note version, 55.1–61.1 µs with a tab version. The comparison is one indexed read inside a transaction that's open anyway. The difference is below run-to-run noise. The p99 (0.42–1.33 ms) is sqlite fsync jitter and doesn't track the mode.
Why is the version the Note's, and what does that cost?
osFoundry's tool descriptions tie one version to the whole Note. create_note_tab says "Use the version from list_note_tabs", the Note-level listing. In the desktop app's apply path for Connected edits (connected-note-application.ts), expected_version is compared with the Note's updated_at. So a person typing in the Sheet tab moves the version that Claude's text-tab write is checked against.
In the rebuild that's measurable. Run race.py tabs with Claude editing the text tab and the person writing cells in the Sheet tab, 3 runs, 100 edits each:
| Version scope | Claude conflicts | Person conflicts | Lost |
|---|---|---|---|
| Note | 130 of 300 writes (43%) | 87 of 300 (29%) | 0 |
| Tab | 0 | 0 | 0 |
Every one of those 217 conflicts is a false one, since the two editors never touched the same tab. They cost a re-read each and nothing else. A Note-level version buys something a tab version can't: a write is checked against everything in the Note, so an agent that read the Sheet to write the Brief can't write a Brief based on Sheet values that changed in the meantime. Tab-level versions remove the false conflicts and lose exactly that cross-tab guarantee. For an agent that reads one tab and writes another, the coarse version is the safer default.
What does a millisecond timestamp do to a version check?
A timestamp version only works if two saves can't produce the same string. race.py fast drops the think time to zero, 2,000 edits per editor, 5 runs, and compares a version cut to milliseconds with a microsecond timestamp plus a 6-hex-digit suffix:
| Version format | Lost lines (of 20,000) | Runs with a loss |
|---|---|---|
| milliseconds | 136 (0.68%) | 5 of 5 |
| microseconds + suffix | 0 | 0 of 5 |
When two writes commit in the same millisecond, the second gets the same version as the first. An editor that read before the first write then passes the check against the second. osFoundry keeps Note versions at full PostgreSQL timestamp precision. If you build your own, use a counter, or a timestamp with a tie-breaker.
Why does create need a key of its own?
create_note_tab doesn't replace anything, so a version check alone can't stop duplicates. The replayed call is a new tab on the new version. race.py create makes 50 Sheet tabs with 30% of the replies dropped, so the client retries as a host would after a timeout:
| Calls sent | Tabs created | Duplicates | |
|---|---|---|---|
| no key, 3 runs | 71 / 61 / 63 | 71 / 61 / 63 | 21 / 11 / 13 |
| same key on retry | 71 / 61 / 63 | 50 / 50 / 50 | 0 |
With the key, the retry returns the first result, including its tab ID, so the agent's next edit_note_tab targets the tab that exists. That's the "A repeated idempotency key returns the first tab" line in osFoundry's tool description.
If you wire this up yourself
- Require
expected_versionon every write tool, in the schema, not as an optional field. An optional check gets skipped the first time a model is in a hurry. - Return a conflict as a normal tool result the model can act on, and say in the tool description what to do: read again, then redo the change. Your instructions are the only place the "re-apply, don't just refresh the version" rule can live.
- Pick the version's scope on purpose. Per-document versions give false conflicts and protect cross-tab reads. Per-tab versions avoid the false conflicts and drop that protection.
- Make versions collision-free: a counter, or a timestamp at full precision with a tie-breaker. Never a timestamp rounded to milliseconds.
- Key creates and appends with an idempotency key that names one intent on one base. Store the key and the result in the same transaction as the write.
- Guard the human editor too. Conflict checks on the agent alone still let a stale browser tab overwrite the agent.
osFoundry's short definition of the protocol underneath is in its MCP glossary entry. How it keeps and restores earlier states of a Note is on the Note versions page.
Top comments (0)