Voici l'outil d'écriture qu'un chat Claude reçoit pour les Notes d'osFoundry, tel que l'endpoint MCP public le liste. Il a été récupéré avec un tools/list non authentifié le 2026-11-06, puis réduit aux champs qui comptent ici :
{"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"]}]} Deux des cinq champs obligatoires ne concernent pas du tout la modification. expected_version transforme l'écriture en compare-and-swap : elle ne passe que si la Note est toujours à la version que l'agent a lue. idempotency_key rend un renvoi sans effet. Les autres écritures sur les Notes, create_note_tab et add_note_source, exigent la même paire. Pas les lectures : list_note_tabs, read_note_tab, read_sheet_range (au maximum 100 par 100 cellules) et list_note_attachments sont toutes readOnlyHint: true sous osfoundry:read.
La suite montre ce que ce contrat apporte, mesuré sur une reconstruction. La vérification de version n'en est que la moitié. L'autre moitié, c'est ce que fait l'agent une fois rejeté.
Pourquoi rejeter une écriture périmée au lieu de la fusionner ?
replace_text envoie le texte complet d'un onglet. Si Claude a lu la version 7, qu'une personne a enregistré la version 8 et que Claude envoie maintenant son texte, le serveur se retrouve avec deux documents complets et sans l'ancêtre commun. Il ne peut pas savoir si Claude voulait supprimer le nouveau paragraphe de la personne ou s'il ne l'a simplement jamais vu. Le last-writer-wins choisit la mauvaise réponse sans rien dire. Rejeter est la seule réponse qui ne devine pas.
C'est de la concurrence optimiste. Personne ne garde un verrou pendant qu'un modèle réfléchit vingt secondes. Chaque écriture indique la version sur laquelle elle se base, et le serveur ne fait l'échange que si cette version est toujours la version courante :
UPDATE notes SET version = :new WHERE id = :note AND version = :expected; -- 0 rows → conflict L'application de bureau d'osFoundry fait la même chose côté humain. Dans apps/web/src/screens/notes/context/NotesContext.tsx, l'enregistrement d'une Note est un PATCH /notes/:id qui porte expected_updated_at (la dernière révision serveur vue par l'éditeur) et, après une écriture Connected, un expected_version que connected-note-save-guard.ts conserve dans localStorage pour qu'un rechargement ne l'efface pas. Quand l'enregistrement échoue, l'éditeur garde le contenu de la personne comme brouillon local et affiche une erreur de synchronisation. Les deux éditeurs font des écritures conditionnelles.
Que doit faire l'agent quand il est rejeté ?
Il y a deux réactions plausibles, et une seule fonctionne :
- Relire l'onglet, réappliquer la modification prévue sur le contenu à jour et écrire avec la nouvelle version.
- Relire uniquement la version et renvoyer le même payload.
La deuxième passe la vérification et écrase quand même la personne, parce que le payload a été calculé à partir de l'ancien texte. La vérification ne peut pas le voir. Une version prouve seulement que vous avez vu le dernier numéro de version, pas que vous avez utilisé son contenu.
La reconstruction
Aucun binaire Claude ne tourne ici. notes_store.py implémente list_note_tabs, read_note_tab, read_sheet_range, edit_note_tab (replace_text, sheet_set_range) et create_note_tab sur sqlite en mode WAL, avec les mêmes noms d'outils et d'arguments qu'en production. Chaque écriture s'exécute dans un BEGIN IMMEDIATE : elle consulte la table d'idempotence, compare la version, applique la modification, incrémente la version et stocke le résultat, le tout dans une seule transaction. race.py lance deux threads, chacun avec sa propre connexion : un « Claude » scripté et une personne scriptée. Chacun fait lecture → réflexion → écriture, 100 fois. Le temps de réflexion est réduit : 20–80 ms pour Claude, 5–40 ms pour la personne, de sorte que la fenêtre entre lecture et écriture de l'agent est la plus longue, comme avec un vrai modèle. Chaque écriture ajoute une ligne unique (claude-17, human-42) : à la fin, chaque ligne manquante est une mise à jour perdue que l'on peut compter. Python 3.9.6, Apple M1, seeds 1–5.
Le chemin d'écriture, sans la plomberie 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 Et la boucle de l'agent, avec le choix de stratégie :
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(...) Une tentative en conflit reçoit une nouvelle idempotency key. La clé désigne une écriture prévue sur une base donnée. Après un rejet, l'écriture réappliquée est une autre écriture.
Combien de mises à jour sont perdues sans la vérification ?
Cinq exécutions, 100 modifications par éditeur et par exécution, 1,000 lignes prévues au total par ligne du tableau :
| Protection en écriture | Agent en cas de conflit | Lignes perdues | Perdues : Claude / personne | Conflits : Claude / personne | Tentatives max. |
|---|---|---|---|---|---|
| aucune | — | 300 | 125 / 175 | 0 / 0 | 1 |
| version de la Note | relit, réapplique | 0 | 0 / 0 | 219 / 133 | 2 |
| version de la Note | relit la version, renvoie l'ancien texte | 304 | 117 / 187 | 213 / 136 | 3 |
Sans vérification, 30% de toutes les modifications ont disparu, et la personne en a perdu plus que Claude. Comme l'agent réfléchit plus longtemps, ses écritures reposent sur des lectures plus anciennes et écrasent donc davantage. Avec la vérification et la réapplication, rien n'a été perdu. 41–48% des écritures de Claude ont eu besoin d'une deuxième tentative, jamais d'une troisième. La moyenne était de 1.41–1.48 tentatives pour Claude et de 1.24–1.30 pour la personne. Chaque exécution a pris 9.6–10.5 s en temps réel, quel que soit le mode, parce que le temps de réflexion domine.
C'est la troisième ligne qu'il faut retenir. Le serveur a signalé plus de 300 conflits et n'a rien protégé. Une vérification de version oblige à relire. Elle ne peut pas obliger à utiliser ce qu'on a lu.
La vérification coûte-t-elle quelque chose ?
bench.py chronomètre 2,000 appels séquentiels à edit_note_tab avec un replace_text de 200 caractères, trois répétitions par mode. Médiane par écriture : 61.9–63.1 µs sans vérification, 57.0–58.8 µs avec la version de la Note, 55.1–61.1 µs avec une version par onglet. La comparaison est une lecture indexée dans une transaction qui est de toute façon ouverte. L'écart reste sous le bruit d'une exécution à l'autre. Le p99 (0.42–1.33 ms) correspond au jitter du fsync de sqlite et ne suit pas le mode.
Pourquoi la version est-elle celle de la Note, et qu'est-ce que ça coûte ?
Les descriptions des outils d'osFoundry rattachent une seule version à toute la Note. create_note_tab indique « Use the version from list_note_tabs », c'est-à-dire le listing au niveau de la Note. Dans le chemin de l'application de bureau qui applique les modifications Connected (connected-note-application.ts), expected_version est comparé au updated_at de la Note. Une personne qui tape dans l'onglet Sheet fait donc bouger la version contre laquelle l'écriture de Claude dans l'onglet texte est vérifiée.
Dans la reconstruction, cela se mesure. On lance race.py tabs avec Claude qui modifie l'onglet texte et la personne qui écrit des cellules dans l'onglet Sheet, 3 exécutions, 100 modifications chacune :
| Portée de la version | Conflits de Claude | Conflits de la personne | Perdues |
|---|---|---|---|
| Note | 130 sur 300 écritures (43%) | 87 sur 300 (29%) | 0 |
| Onglet | 0 | 0 | 0 |
Ces 217 conflits sont tous faux, puisque les deux éditeurs n'ont jamais touché au même onglet. Chacun coûte une relecture, rien de plus. Une version au niveau de la Note apporte quelque chose qu'une version par onglet ne peut pas apporter : chaque écriture est vérifiée par rapport à tout le contenu de la Note. Un agent qui a lu la Sheet pour écrire le Brief ne peut donc pas écrire un Brief fondé sur des valeurs de la Sheet qui ont changé entre-temps. Les versions par onglet suppriment les faux conflits et perdent précisément cette garantie entre onglets. Pour un agent qui lit un onglet et en écrit un autre, la version grossière est le choix par défaut le plus sûr.
Que fait un timestamp à la milliseconde à une vérification de version ?
Une version fondée sur un timestamp ne fonctionne que si deux enregistrements ne peuvent pas produire la même chaîne. race.py fast ramène le temps de réflexion à zéro, 2,000 modifications par éditeur, 5 exécutions, et compare une version tronquée à la milliseconde avec un timestamp à la microseconde suivi d'un suffixe de 6 chiffres hexadécimaux :
| Format de version | Lignes perdues (sur 20,000) | Exécutions avec perte |
|---|---|---|
| millisecondes | 136 (0.68%) | 5 sur 5 |
| microsecondes + suffixe | 0 | 0 sur 5 |
Quand deux écritures font leur commit dans la même milliseconde, la seconde reçoit la même version que la première. Un éditeur qui a lu avant la première écriture passe alors la vérification face à la seconde. osFoundry conserve les versions des Notes avec la précision complète des timestamps PostgreSQL. Si vous construisez votre propre système, utilisez un compteur, ou un timestamp avec un départage.
Pourquoi la création a-t-elle besoin de sa propre clé ?
create_note_tab ne remplace rien, donc une vérification de version seule ne peut pas empêcher les doublons. L'appel rejoué crée un nouvel onglet sur la nouvelle version. race.py create crée 50 onglets Sheet en perdant 30% des réponses, si bien que le client réessaie comme le ferait un hôte après un timeout :
| Appels envoyés | Onglets créés | Doublons | |
|---|---|---|---|
| sans clé, 3 exécutions | 71 / 61 / 63 | 71 / 61 / 63 | 21 / 11 / 13 |
| même clé au retry | 71 / 61 / 63 | 50 / 50 / 50 | 0 |
Avec la clé, le retry renvoie le premier résultat, y compris l'ID de son onglet, donc le edit_note_tab suivant de l'agent vise l'onglet qui existe vraiment. C'est la ligne « A repeated idempotency key returns the first tab » de la description de l'outil chez osFoundry.
Si vous le câblez vous-même
- Rendez
expected_versionobligatoire sur chaque outil d'écriture, dans le schéma, pas comme champ optionnel. Une vérification optionnelle saute dès la première fois qu'un modèle est pressé. - Renvoyez un conflit comme un résultat d'outil normal sur lequel le modèle peut agir, et indiquez dans la description de l'outil ce qu'il faut faire : relire, puis refaire la modification. Vos instructions sont le seul endroit où la règle « réappliquer, pas seulement rafraîchir la version » peut exister.
- Choisissez la portée de la version délibérément. Des versions par document donnent de faux conflits et protègent les lectures entre onglets. Des versions par onglet évitent les faux conflits et perdent cette protection.
- Faites en sorte que les versions ne puissent pas entrer en collision : un compteur, ou un timestamp en pleine précision avec un départage. Jamais un timestamp arrondi à la milliseconde.
- Protégez les créations et les ajouts avec une idempotency key qui désigne une seule intention sur une seule base. Stockez la clé et le résultat dans la même transaction que l'écriture.
- Protégez aussi l'éditeur humain. Si seuls les conflits de l'agent sont vérifiés, un onglet de navigateur périmé peut encore écraser l'agent.
La définition courte du protocole sous-jacent par osFoundry se trouve dans son entrée de glossaire sur MCP. La façon dont il conserve et restaure les états antérieurs d'une Note est décrite sur la page des versions de Notes.
