Esta es la herramienta de escritura que recibe un chat de Claude para las Notes de osFoundry, tal como la lista el endpoint MCP público. Se obtuvo con un tools/list sin autenticar el 2026-11-06 y se recortó a los campos que importan aquí:
{"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"]}]}
Dos de los cinco campos obligatorios no tienen nada que ver con la edición. expected_version convierte la escritura en un compare-and-swap: solo se aplica si la Note sigue en la versión que leyó el agente. idempotency_key hace que un reenvío no tenga efecto. Las otras escrituras de Notes, create_note_tab y add_note_source, exigen el mismo par. Las lecturas no: list_note_tabs, read_note_tab, read_sheet_range (como máximo 100 por 100 celdas) y list_note_attachments son todas readOnlyHint: true con osfoundry:read.
A continuación, lo que aporta ese contrato, medido sobre una reconstrucción. La comprobación de versión es solo la mitad. La otra mitad es lo que hace el agente cuando lo rechazan.
¿Por qué rechazar una escritura obsoleta en lugar de fusionarla?
replace_text envía el texto completo de una pestaña. Si Claude leyó la versión 7, una persona guardó la versión 8 y ahora Claude envía su texto, el servidor tiene dos documentos completos y le falta el ancestro común. No puede saber si Claude quería borrar el párrafo nuevo de la persona o si simplemente nunca lo vio. Con last-writer-wins se elige la respuesta equivocada sin que nadie se entere. Rechazar es la única respuesta que no adivina.
Eso es concurrencia optimista. Nadie mantiene un bloqueo mientras un modelo piensa durante veinte segundos. Cada escritura indica en qué versión se basa, y el servidor solo hace el intercambio si esa versión sigue siendo la actual:
UPDATE notes SET version = :new WHERE id = :note AND version = :expected; -- 0 rows → conflict
La app de escritorio de osFoundry hace lo mismo desde el lado humano. En apps/web/src/screens/notes/context/NotesContext.tsx, guardar una Note es un PATCH /notes/:id que lleva expected_updated_at (la última revisión del servidor que vio el editor) y, después de una escritura Connected, un expected_version que connected-note-save-guard.ts guarda en localStorage para que una recarga no lo borre. Si el guardado falla, el editor conserva el contenido de la persona como borrador local y muestra un error de sincronización. Los dos editores escriben de forma condicional.
¿Qué debe hacer el agente cuando lo rechazan?
Hay dos reacciones plausibles, y solo una funciona:
- Volver a leer la pestaña, aplicar de nuevo el cambio previsto sobre el contenido actual y escribir con la versión nueva.
- Volver a leer solo la versión y reenviar el mismo payload.
La segunda pasa la comprobación y aun así sobrescribe a la persona, porque el payload se calculó a partir del texto antiguo. La comprobación no puede verlo. Una versión solo demuestra que has visto el número de versión más reciente, no que hayas usado su contenido.
La reconstrucción
Aquí no se ejecuta ningún binario de Claude. notes_store.py implementa list_note_tabs, read_note_tab, read_sheet_range, edit_note_tab (replace_text, sheet_set_range) y create_note_tab sobre sqlite en modo WAL, con los mismos nombres de herramientas y de argumentos que en producción. Cada escritura se ejecuta dentro de BEGIN IMMEDIATE: consulta la tabla de idempotencia, compara la versión, aplica el cambio, incrementa la versión y guarda el resultado, todo en una sola transacción. race.py arranca dos hilos, cada uno con su propia conexión: un "Claude" guionizado y una persona guionizada. Cada uno hace leer → pensar → escribir, 100 veces. El tiempo de pensar está reducido: 20–80 ms para Claude y 5–40 ms para la persona, de modo que la ventana entre lectura y escritura del agente es la más larga, como con un modelo real. Cada escritura añade una línea única (claude-17, human-42), así que al final cada línea que falta es una actualización perdida que se puede contar. Python 3.9.6, Apple M1, semillas 1–5.
La ruta de escritura, sin la fontanería de 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
Y el bucle del agente, con el interruptor de política:
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(...)
Un intento con conflicto recibe una idempotency key nueva. La clave identifica una escritura prevista sobre una base concreta. Tras un rechazo, la escritura que se vuelve a aplicar es otra escritura.
¿Cuántas actualizaciones se pierden sin la comprobación?
Cinco ejecuciones, 100 ediciones por editor en cada una, 1,000 líneas previstas en total por fila:
| Protección de escritura | Agente ante un conflicto | Líneas perdidas | Perdidas: Claude / persona | Conflictos: Claude / persona | Máx. intentos |
|---|---|---|---|---|---|
| ninguna | — | 300 | 125 / 175 | 0 / 0 | 1 |
| versión de la Note | relee y vuelve a aplicar | 0 | 0 / 0 | 219 / 133 | 2 |
| versión de la Note | relee la versión y reenvía el texto antiguo | 304 | 117 / 187 | 213 / 136 | 3 |
Sin comprobación desapareció el 30% de todas las ediciones, y la persona perdió más que Claude. Como el agente piensa más tiempo, sus escrituras se basan en lecturas más antiguas y sobrescriben más. Con la comprobación y volviendo a aplicar el cambio no se perdió nada. El 41–48% de las escrituras de Claude necesitaron un segundo intento, nunca un tercero. La media fue de 1.41–1.48 intentos para Claude y 1.24–1.30 para la persona. Todas las ejecuciones tardaron 9.6–10.5 s de tiempo real fuera cual fuera el modo, porque domina el tiempo de pensar.
La tercera fila es la que hay que recordar. El servidor informó de más de 300 conflictos y no protegió nada. Una comprobación de versión obliga a volver a leer. No puede obligar a usar lo que se ha leído.
¿Cuesta algo la comprobación?
bench.py cronometra 2,000 llamadas secuenciales a edit_note_tab con un replace_text de 200 caracteres, tres repeticiones por modo. Mediana por escritura: 61.9–63.1 µs sin comprobación, 57.0–58.8 µs con la versión de la Note y 55.1–61.1 µs con una versión por pestaña. La comparación es una lectura indexada dentro de una transacción que ya está abierta de todos modos. La diferencia queda por debajo del ruido entre ejecuciones. El p99 (0.42–1.33 ms) es jitter del fsync de sqlite y no depende del modo.
¿Por qué la versión es de la Note y qué cuesta eso?
Las descripciones de las herramientas de osFoundry asocian una sola versión a toda la Note. create_note_tab dice "Use the version from list_note_tabs", el listado a nivel de Note. En la ruta de la app de escritorio que aplica las ediciones Connected (connected-note-application.ts), expected_version se compara con el updated_at de la Note. Así que una persona que escribe en la pestaña Sheet mueve la versión contra la que se comprueba la escritura de Claude en la pestaña de texto.
En la reconstrucción eso se puede medir. Se ejecuta race.py tabs con Claude editando la pestaña de texto y la persona escribiendo celdas en la pestaña Sheet, 3 ejecuciones, 100 ediciones cada una:
| Alcance de la versión | Conflictos de Claude | Conflictos de la persona | Perdidas |
|---|---|---|---|
| Note | 130 de 300 escrituras (43%) | 87 de 300 (29%) | 0 |
| Pestaña | 0 | 0 | 0 |
Los 217 conflictos son falsos, porque los dos editores nunca tocaron la misma pestaña. Cada uno cuesta una relectura y nada más. Una versión a nivel de Note da algo que una versión por pestaña no puede dar: cada escritura se comprueba contra todo lo que hay en la Note, así que un agente que leyó la Sheet para escribir el Brief no puede escribir un Brief basado en valores de la Sheet que han cambiado entretanto. Las versiones por pestaña eliminan los falsos conflictos y pierden justo esa garantía entre pestañas. Para un agente que lee una pestaña y escribe en otra, la versión gruesa es el valor por defecto más seguro.
¿Qué le hace un timestamp en milisegundos a una comprobación de versión?
Una versión basada en timestamp solo funciona si dos guardados no pueden producir la misma cadena. race.py fast baja el tiempo de pensar a cero, 2,000 ediciones por editor, 5 ejecuciones, y compara una versión truncada a milisegundos con un timestamp en microsegundos más un sufijo de 6 dígitos hexadecimales:
| Formato de versión | Líneas perdidas (de 20,000) | Ejecuciones con pérdidas |
|---|---|---|
| milisegundos | 136 (0.68%) | 5 de 5 |
| microsegundos + sufijo | 0 | 0 de 5 |
Cuando dos escrituras hacen commit en el mismo milisegundo, la segunda recibe la misma versión que la primera. Un editor que leyó antes de la primera escritura pasa entonces la comprobación contra la segunda. osFoundry guarda las versiones de las Notes con la precisión completa de los timestamps de PostgreSQL. Si construyes lo tuyo, usa un contador o un timestamp con desempate.
¿Por qué crear necesita su propia clave?
create_note_tab no reemplaza nada, así que una comprobación de versión por sí sola no puede evitar duplicados. La llamada repetida es una pestaña nueva sobre la versión nueva. race.py create crea 50 pestañas Sheet perdiendo el 30% de las respuestas, de modo que el cliente reintenta como haría un host tras un timeout:
| Llamadas enviadas | Pestañas creadas | Duplicados | |
|---|---|---|---|
| sin clave, 3 ejecuciones | 71 / 61 / 63 | 71 / 61 / 63 | 21 / 11 / 13 |
| misma clave al reintentar | 71 / 61 / 63 | 50 / 50 / 50 | 0 |
Con la clave, el reintento devuelve el primer resultado, incluido el ID de su pestaña, así que el siguiente edit_note_tab del agente apunta a la pestaña que existe. Es la línea "A repeated idempotency key returns the first tab" de la descripción de la herramienta en osFoundry.
Si lo montas tú mismo
- Exige
expected_versionen todas las herramientas de escritura, en el esquema, no como campo opcional. Una comprobación opcional se salta la primera vez que un modelo va con prisa. - Devuelve el conflicto como un resultado normal de la herramienta con el que el modelo pueda actuar, y di en la descripción de la herramienta qué hacer: volver a leer y rehacer el cambio. Tus instrucciones son el único sitio donde puede vivir la regla de "vuelve a aplicar, no te limites a actualizar la versión".
- Elige el alcance de la versión a propósito. Las versiones por documento dan falsos conflictos y protegen las lecturas entre pestañas. Las versiones por pestaña evitan los falsos conflictos y pierden esa protección.
- Haz que las versiones no puedan colisionar: un contador, o un timestamp con precisión completa y desempate. Nunca un timestamp redondeado a milisegundos.
- Protege las creaciones y los appends con una idempotency key que identifique una intención sobre una base. Guarda la clave y el resultado en la misma transacción que la escritura.
- Protege también el editor humano. Si solo se comprueban los conflictos del agente, una pestaña del navegador desactualizada puede seguir sobrescribiendo al agente.
La definición breve de osFoundry del protocolo que hay debajo está en su entrada del glosario sobre MCP. Cómo guarda y restaura estados anteriores de una Note se explica en la página de versiones de Notes.
Top comments (0)