Inilah tool tulis yang didapat chat Claude untuk Notes di osFoundry, sesuai daftar di endpoint MCP publiknya. Data ini diambil lewat tools/list tanpa autentikasi pada 2026-11-06, lalu dipangkas ke field yang relevan di sini:
{"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"]}]}
Dua dari lima field wajib sama sekali tidak berhubungan dengan isi edit. expected_version membuat penulisan menjadi compare-and-swap: write hanya masuk kalau Note masih berada di versi yang dibaca agent. idempotency_key membuat pengiriman ulang tidak berbahaya. Write Note lainnya, create_note_tab dan add_note_source, juga mewajibkan pasangan yang sama. Read tidak: list_note_tabs, read_note_tab, read_sheet_range (paling banyak 100 kali 100 sel) dan list_note_attachments semuanya readOnlyHint: true di bawah osfoundry:read.
Di bawah ini apa yang didapat dari kontrak itu, diukur pada sebuah rebuild. Pengecekan versi baru separuhnya. Separuh lainnya adalah apa yang dilakukan agent setelah write-nya ditolak.
Kenapa write yang basi ditolak, bukan di-merge?
replace_text mengirim seluruh teks sebuah tab. Kalau Claude membaca versi 7, seseorang menyimpan versi 8, lalu Claude mengirim teksnya, server memegang dua dokumen utuh dan satu ancestor bersama yang sudah tidak ia simpan lagi. Server tidak bisa tahu apakah Claude memang ingin menghapus paragraf baru orang itu atau memang tidak pernah melihatnya. Last-writer-wins memilih jawaban yang salah tanpa suara. Menolak adalah satu-satunya respons yang tidak menebak.
Itulah optimistic concurrency. Tidak ada yang memegang lock selama model berpikir dua puluh detik. Setiap write menyebutkan versi yang menjadi dasarnya, dan server hanya melakukan swap kalau versi itu masih yang terbaru:
UPDATE notes SET version = :new WHERE id = :note AND version = :expected; -- 0 rows → conflict
Aplikasi desktop osFoundry melakukan hal yang sama dari sisi manusia. Di apps/web/src/screens/notes/context/NotesContext.tsx, penyimpanan Note adalah PATCH /notes/:id yang membawa expected_updated_at (revisi server terakhir yang dilihat editor) dan, setelah write dari Connected, sebuah expected_version yang disimpan connected-note-save-guard.ts di localStorage supaya tidak hilang saat reload. Kalau penyimpanan gagal, editor menyimpan konten orang itu sebagai draft lokal dan menampilkan error sinkronisasi. Kedua editor sama-sama melakukan conditional write.
Apa yang harus dilakukan agent saat write-nya ditolak?
Ada dua reaksi yang masuk akal, dan hanya satu yang benar-benar bekerja:
- Baca ulang tab, terapkan ulang perubahan yang dimaksud ke konten terbaru, lalu tulis dengan versi terbaru.
- Baca ulang versinya saja, lalu kirim ulang payload yang sama.
Cara kedua lolos pengecekan tapi tetap menimpa edit orang itu, karena payload-nya dihitung dari teks lama. Pengecekan tidak bisa melihat hal itu. Versi hanya membuktikan bahwa kamu sudah melihat nomor versi terbaru, bukan bahwa kamu memakai isinya.
Rebuild-nya
Tidak ada binary Claude yang berjalan di sini. notes_store.py mengimplementasikan list_note_tabs, read_note_tab, read_sheet_range, edit_note_tab (replace_text, sheet_set_range) dan create_note_tab di atas sqlite dalam mode WAL, dengan nama tool dan nama argumen yang sama seperti versi live. Setiap write berjalan di dalam BEGIN IMMEDIATE: cek tabel idempotency, bandingkan versi, terapkan, naikkan versi, dan simpan hasilnya, semuanya dalam satu transaksi. race.py menjalankan dua thread dengan koneksi masing-masing: "Claude" yang di-script dan seseorang yang di-script. Masing-masing melakukan read → think → write, 100 kali. Waktu berpikir diperkecil: 20–80 ms untuk Claude, 5–40 ms untuk orang itu, sehingga jendela read-ke-write milik agent lebih panjang, seperti pada model sungguhan. Setiap write menambahkan satu baris unik (claude-17, human-42), jadi setelahnya baris yang hilang adalah lost update yang bisa dihitung. Python 3.9.6, Apple M1, seed 1–5.
Jalur write-nya, tanpa urusan sqlite:
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
Dan loop agent-nya, dengan saklar policy:
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(...)
Percobaan yang kena conflict mendapat idempotency key baru. Key itu menamai satu write yang dimaksud di atas satu base. Setelah ditolak, write yang diterapkan ulang adalah write yang berbeda.
Berapa banyak update yang hilang tanpa pengecekan?
Lima run, 100 edit per editor per run, total 1,000 baris yang dimaksud per baris tabel:
| Pengaman write | Agent saat conflict | Baris hilang | Hilang: Claude / orang | Conflict: Claude / orang | Percobaan maks |
|---|---|---|---|---|---|
| tidak ada | — | 300 | 125 / 175 | 0 / 0 | 1 |
| versi Note | baca ulang, terapkan ulang | 0 | 0 / 0 | 219 / 133 | 2 |
| versi Note | baca ulang versi, kirim ulang teks lama | 304 | 117 / 187 | 213 / 136 | 3 |
Tanpa pengecekan, 30% dari semua edit lenyap, dan orang itu kehilangan lebih banyak daripada Claude. Waktu berpikir agent yang lebih panjang membuat write-nya didasarkan pada read yang lebih lama, jadi lebih banyak menimpa. Dengan pengecekan dan penerapan ulang, tidak ada yang hilang. 41–48% write Claude butuh percobaan kedua, tidak pernah yang ketiga. Rata-ratanya 1.41–1.48 percobaan untuk Claude dan 1.24–1.30 untuk orang itu. Setiap run memakan 9.6–10.5 s wall time apa pun mode-nya, karena waktu berpikir yang dominan.
Baris ketiga adalah yang perlu diingat. Server melaporkan lebih dari 300 conflict dan tidak melindungi apa pun. Pengecekan versi memaksa kamu membaca ulang. Ia tidak bisa memaksa kamu memakai apa yang kamu baca.
Apakah pengecekan itu ada biayanya?
bench.py mengukur waktu 2,000 panggilan edit_note_tab berurutan dengan replace_text 200 karakter, tiga kali pengulangan per mode. Median per write: 61.9–63.1 µs tanpa pengecekan, 57.0–58.8 µs dengan versi Note, 55.1–61.1 µs dengan versi tab. Perbandingannya hanya satu read ber-index di dalam transaksi yang memang sudah terbuka. Selisihnya di bawah noise antar-run. p99 (0.42–1.33 ms) adalah jitter fsync sqlite dan tidak mengikuti mode.
Kenapa versinya milik Note, dan apa konsekuensinya?
Deskripsi tool osFoundry mengikat satu versi ke seluruh Note. create_note_tab menyebut "Use the version from list_note_tabs", yaitu listing di level Note. Di jalur apply aplikasi desktop untuk edit Connected (connected-note-application.ts), expected_version dibandingkan dengan updated_at milik Note. Jadi seseorang yang mengetik di tab Sheet menggeser versi yang dipakai untuk mengecek write Claude ke tab teks.
Di rebuild, hal ini bisa diukur. Jalankan race.py tabs dengan Claude mengedit tab teks dan orang itu menulis sel di tab Sheet, 3 run, masing-masing 100 edit:
| Cakupan versi | Conflict Claude | Conflict orang | Hilang |
|---|---|---|---|
| Note | 130 dari 300 write (43%) | 87 dari 300 (29%) | 0 |
| Tab | 0 | 0 | 0 |
Ke-217 conflict itu semuanya palsu, karena kedua editor tidak pernah menyentuh tab yang sama. Biayanya masing-masing hanya satu read ulang, tidak lebih. Versi level Note memberi sesuatu yang tidak bisa diberikan versi tab: setiap write dicek terhadap seluruh isi Note, jadi agent yang membaca Sheet untuk menulis Brief tidak bisa menulis Brief berdasarkan nilai Sheet yang sudah berubah di tengah jalan. Versi level tab menghilangkan conflict palsu, tapi justru kehilangan jaminan lintas-tab itu. Untuk agent yang membaca satu tab dan menulis ke tab lain, versi yang kasar ini adalah default yang lebih aman.
Apa efek timestamp milidetik pada pengecekan versi?
Versi berbasis timestamp hanya bekerja kalau dua penyimpanan tidak mungkin menghasilkan string yang sama. race.py fast menurunkan waktu berpikir ke nol, 2,000 edit per editor, 5 run, lalu membandingkan versi yang dipotong ke milidetik dengan timestamp mikrodetik plus suffix 6 digit hex:
| Format versi | Baris hilang (dari 20,000) | Run dengan kehilangan |
|---|---|---|
| milidetik | 136 (0.68%) | 5 dari 5 |
| mikrodetik + suffix | 0 | 0 dari 5 |
Kalau dua write di-commit di milidetik yang sama, write kedua mendapat versi yang sama dengan yang pertama. Editor yang membaca sebelum write pertama lalu lolos pengecekan terhadap write kedua. osFoundry menyimpan versi Note dengan presisi timestamp PostgreSQL penuh. Kalau kamu membangun sendiri, pakai counter, atau timestamp dengan tie-breaker.
Kenapa create butuh key sendiri?
create_note_tab tidak mengganti apa pun, jadi pengecekan versi saja tidak bisa mencegah duplikat. Panggilan yang diputar ulang menjadi tab baru di versi baru. race.py create membuat 50 tab Sheet dengan 30% balasan dibuang, sehingga client melakukan retry seperti host setelah timeout:
| Panggilan terkirim | Tab dibuat | Duplikat | |
|---|---|---|---|
| tanpa key, 3 run | 71 / 61 / 63 | 71 / 61 / 63 | 21 / 11 / 13 |
| key sama saat retry | 71 / 61 / 63 | 50 / 50 / 50 | 0 |
Dengan key, retry mengembalikan hasil pertama, termasuk ID tab-nya, jadi edit_note_tab berikutnya dari agent menyasar tab yang memang ada. Itulah maksud baris "A repeated idempotency key returns the first tab" di deskripsi tool osFoundry.
Kalau kamu memasangnya sendiri
- Wajibkan
expected_versiondi setiap write tool, di dalam schema, bukan sebagai field opsional. Pengecekan opsional akan dilewati begitu model sedang terburu-buru. - Kembalikan conflict sebagai hasil tool biasa yang bisa ditindaklanjuti model, dan tulis di deskripsi tool apa yang harus dilakukan: baca ulang, lalu ulangi perubahannya. Instruksimu adalah satu-satunya tempat aturan "terapkan ulang, jangan cuma perbarui versi" bisa hidup.
- Tentukan cakupan versi dengan sengaja. Versi per dokumen memberi conflict palsu tapi melindungi read lintas-tab. Versi per tab menghindari conflict palsu dan melepas perlindungan itu.
- Buat versi yang bebas tabrakan: counter, atau timestamp presisi penuh dengan tie-breaker. Jangan pernah timestamp yang dibulatkan ke milidetik.
- Beri create dan append sebuah idempotency key yang menamai satu niat di atas satu base. Simpan key dan hasilnya di transaksi yang sama dengan write-nya.
- Lindungi editor manusia juga. Kalau pengecekan conflict hanya ada di sisi agent, tab browser yang basi tetap bisa menimpa agent.
Definisi singkat osFoundry tentang protokol di baliknya ada di entri glosarium MCP. Cara osFoundry menyimpan dan memulihkan kondisi Note sebelumnya dijelaskan di halaman Note versions.
Top comments (0)