Protocole de synchronisation
Cette page est le contrat d'interopérabilité destiné à quiconque implémente un service de synchronisation pour OHarness. Elle décrit ce que le client envoie et le comportement dont il dépend ; elle n'impose pas le stockage JSONL utilisé par @oharness/sync.
Deux protocoles indépendants partagent une même origine :
- La synchronisation des sessions reproduit les transcriptions, les révisions d'artefacts et les métadonnées de session via un flux en ajout seul.
- La synchronisation Memory réconcilie des notes Markdown via un manifeste et un compare-and-swap. Elle est facultative. Un serveur peut implémenter la synchronisation des sessions sans implémenter Memory.
L'URL de base ne contient ni chemin, ni query, ni identifiants, ni fragment. Les clients de production n'acceptent que HTTPS ; le HTTP simple est accepté pour le développement sur localhost, 127.0.0.1 et [::1]. Les exemples ci-dessous utilisent https://sync.example.com.
Sur cette page
- [Cible de compatibilité](#compatibility-target)
- [Authentification et locataires](#authentication-and-tenancy)
- [Modèle de données de la synchronisation des sessions](#session-sync-data-model)
- [Endpoints de session](#session-endpoints)
- [Réponses d'erreur des sessions](#session-error-responses)
- [Synchronisation Memory](#memory-sync)
- [Configurer un client OHarness](#configuring-an-oharness-client)
- [Liste de contrôle d'implémentation](#implementation-checklist)
- [Ce qui n'est pas exigé](#non-requirements)
Cible de compatibilité
Ce document décrit la version 1 du protocole, telle qu'utilisée par OHarness 0.5.x. Il n'existe actuellement ni endpoint de capacités ni négociation de contenu. Gardez /v1/ rétrocompatible : ajoutez des champs facultatifs, ne changez pas le sens d'un champ existant et ne rendez pas obligatoire un champ auparavant facultatif.
Les champs de requête inconnus doivent être ignorés pour la synchronisation des sessions. Memory est plus strict et les rejette avec 400 {"code":"unknown_field"} ; les nouveaux clients s'appuient sur cette réponse pour détecter un ancien serveur Memory et retenter un snapshot de projet sans l'optimisation have.
Tous les horodatages sont en millisecondes Unix. Tous les corps sont en JSON. Les réponses réussies utilisent Content-Type: application/json ; les clients ne dépendent d'aucun espacement JSON ni d'aucun ordre de propriétés particulier.
Authentification et locataires
Toutes les routes sauf GET /v1/health exigent :
Authorization: Bearer <token>
Un token valide se résout en exactement un identifiant de principal stable. Le serveur doit déduire le propriétaire du token vérifié, jamais d'un identifiant d'utilisateur fourni dans une requête. Un token de session peut en outre contenir des identifiants d'équipe.
Sans team, les requêtes de session visent le flux personnel du principal. Avec un team, elles visent le flux de cette équipe, uniquement après vérification de l'appartenance. Une équipe inconnue ou non autorisée renvoie 403 ; il ne faut pas se rabattre en silence sur le flux personnel.
L'authentification n'est volontairement pas normalisée au-delà du transport Bearer. Le serveur fourni accepte des tokens opaques émis localement, des JWT HS256 et des JWT vérifiés via JWKS. Une autre implémentation peut utiliser une base de données ou un autre vérificateur de tokens, tant que la sémantique observable du principal et des équipes reste la même.
Memory applique une vérification d'identité plus stricte, décrite plus bas. En particulier, un token opaque émis par oharness-sync token suffit pour la synchronisation des sessions, mais le client Memory actuel d'OHarness utilise le token de son compte Cloud.
Modèle de données de la synchronisation des sessions
Enregistrement
Un enregistrement est un élément immuable d'une session. Aujourd'hui, il contient normalement un enregistrement de transcription ou une enveloppe de révision d'artefact, mais le serveur doit traiter body comme du JSON opaque.
{
"session": "20260914_abcd",
"id": "stable-record-id",
"seq": 12,
"device": "device-id",
"at": 1789344000000,
"body": { "opaque": "to the server" }
}
| Champ | Type | Signification |
|---|---|---|
session | string | Session contenant l'enregistrement |
id | string | Identifiant stable utilisé pour dédoublonner les nouvelles tentatives |
seq | number | Séquence de Lamport à l'intérieur de la session |
device | string | Appareil qui a créé l'enregistrement |
at | number | Date de création en millisecondes Unix |
body | tout JSON | Charge utile stockée et restituée sans modification |
La clé de dédoublonnage est (session, id). Repousser cette clé ne doit pas ajouter un second événement, même si une réponse précédente a été perdue. N'analysez pas, ne normalisez pas et ne réécrivez pas body.
Métadonnées de session
{
"id": "20260914_abcd",
"gen": 1,
"updatedAt": 1789344000000,
"body": { "opaque": "session metadata" }
}
body est opaque. deleted: true est un tombstone et n'a normalement pas de corps. Les métadonnées suivent la règle « le dernier qui écrit gagne » selon ce tuple, par priorité décroissante :
- Le
genle plus grand l'emporte. Une compaction ou une réécriture incrémente la génération. - Au sein d'une même génération, le
updatedAtle plus grand l'emporte. - Un tuple égal est déjà détenu et n'ajoute aucun événement.
La mise à jour gagnante, tombstone compris, est ajoutée au flux pour que les appareils qui ont déjà lu une valeur plus ancienne puissent observer le changement.
Curseur
Un curseur est un entier positif ou nul émis par le serveur. Il identifie une position dans un flux (owner, project). Le client le traite comme opaque : il le stocke, le renvoie comme since et s'attend seulement à ce qu'il ne recule jamais.
L'implémentation JSONL fournie utilise un numéro d'événement incrémental. Une implémentation sur base de données peut utiliser une séquence. Un curseur n'est pas tenu d'être un décalage en octets, un horodatage ou le seq d'un enregistrement.
Les curseurs appartiennent à un seul flux et ne doivent pas être réutilisés entre utilisateurs, équipes ou projets. Le stockage des flux est un état durable. Si un serveur perd un flux alors qu'un client conserve son curseur, le client peut rester définitivement en avance sur le nouveau flux ; le protocole v1 n'a pas de négociation de réinitialisation du curseur.
Endpoints de session
GET /v1/health
Endpoint de disponibilité sans authentification.
HTTP/1.1 200 OK
Content-Type: application/json
{"ok":true}
Cela prouve que le processus et la route sont joignables. Cela ne prouve pas qu'un token fonctionne, que le stockage persistant est monté ni que Memory est configuré.
GET /v1/projects
Liste les projets qui ont un flux pour le propriétaire sélectionné.
GET /v1/projects
GET /v1/projects?team=platform
Réponse :
{"projects":["project-a","project-b"]}
Renvoyez un tableau vide lorsque le propriétaire n'a aucun flux. Les identifiants produits par les clients OHarness actuels correspondent à [A-Za-z0-9_-]{1,128} et doivent être renvoyés sans modification. Un serveur peut rejeter une autre entrée ou l'encoder de façon réversible ; ne concaténez jamais un identifiant non fiable dans un chemin du système de fichiers.
Le miroir par projet actuel n'appelle pas cette route dans sa boucle push/pull normale, mais elle fait partie du client public et est requise pour une implémentation complète et pour la découverte des projets entre appareils.
POST /v1/push
Ajoute de façon atomique les nouveaux enregistrements et les métadonnées de session gagnantes.
{
"project": "project-a",
"team": "platform",
"records": [
{
"session": "20260914_abcd",
"id": "record-12",
"seq": 12,
"device": "laptop",
"at": 1789344000000,
"body": { "role": "user", "content": "hello" }
}
],
"sessions": [
{
"id": "20260914_abcd",
"gen": 0,
"updatedAt": 1789344000000,
"body": { "id": "20260914_abcd", "title": "Example" }
}
]
}
team, records et sessions sont facultatifs. Des tableaux absents valent des tableaux vides. Seul project est obligatoire. Les anciennes déclarations de types comprenaient un device au niveau supérieur ; le client OHarness ne l'envoie pas et les serveurs compatibles ne doivent pas l'exiger. L'identité de l'appareil figure sur chaque enregistrement.
Réponse :
{"cursor":42,"accepted":2}
cursor est la fin actuelle du flux après ce push. accepted compte les nouveaux événements d'enregistrement plus les événements de métadonnées de session gagnants. Les enregistrements en double et les métadonnées de session périmées ou égales ne comptent pas.
La requête entière peut être retentée. Validez-la avant de valider la moindre entrée. N'acceptez pas de lot partiel : renvoyez une erreur pour toute la requête si une entrée est mal formée ou si une limite est dépassée.
Limites utilisées par le client actuel et le serveur fourni :
| Limite | Valeur |
|---|---|
| Enregistrements par push | 500 |
| Entrées de métadonnées de session par push | 500 |
| Corps de requête sérialisé | 8 Mio |
Les deux limites de tableaux sont indépendantes : une requête peut contenir 500 enregistrements et 500 entrées de session.
GET /v1/pull
Lit les événements du flux après un curseur.
GET /v1/pull?project=project-a&since=20&limit=500&wait=30&team=platform
| Paramètre | Obligatoire | Signification |
|---|---|---|
project | oui | Identifiant de projet opaque |
since | oui en usage normal | Renvoie les événements strictement postérieurs à ce curseur ; 0 part du début |
limit | non | Taille de page, bornée à 1..500 ; 500 par défaut |
wait | non | Secondes de long polling, bornées à 0..30 ; 0 par défaut |
team | non | Flux de l'équipe au lieu du flux du principal |
Réponse :
{
"cursor": 22,
"records": [],
"sessions": [],
"more": false
}
Renvoyez les événements dans l'ordre du flux et sans les champs réservés au serveur. Le curseur correspond au dernier événement renvoyé. S'il n'y a pas de nouvel événement, il reste égal à since. Ne définissez more: true que lorsqu'une autre page est déjà disponible ; le client relance alors immédiatement un pull.
Lorsque wait > 0 et que le flux est à jour, maintenez la requête jusqu'à l'ajout d'un événement ou l'expiration de l'attente. Un délai expiré est une réponse vide réussie, pas une erreur HTTP. La déconnexion d'un long polling ne doit pas modifier l'état du flux.
Réponses d'erreur des sessions
Les erreurs de session sont des objets JSON contenant une courte chaîne error lisible par un humain :
{"error":"missing or bad token"}
Utilisez les codes d'état habituels :
| Code | Quand |
|---|---|
400 | JSON invalide, projet manquant, curseur invalide ou entrée mal formée |
401 | Token Bearer absent, expiré ou invalide ; incluez WWW-Authenticate: Bearer |
403 | Le principal authentifié n'est pas membre de l'équipe demandée |
413 | Le nombre d'octets de la requête ou le nombre d'entrées dépasse la limite |
404 | Route inconnue |
500 | Échec inattendu du stockage ; ne prétendez pas qu'un push partiel a réussi |
Le miroir retente avec un délai croissant les erreurs réseau et les défaillances ordinaires du serveur. Un 401 arrête ce miroir jusqu'à ce qu'il soit redémarré avec un identifiant utilisable.
Synchronisation Memory
La synchronisation Memory est facultative et distincte du flux de sessions en ajout seul. Les trois routes sont des requêtes POST authentifiées avec Content-Type: application/json :
/v1/memory/identity/v1/memory/snapshot/v1/memory/commit
Le client actuel est uniquement natif. Une requête portant un en-tête Origin est rejetée avec 403 {"code":"native_client_required"}.
Identité Memory
Chaque requête contient expectedPrincipal, tiré du compte Cloud décodé localement, qui sert d'ancrage du consentement. Le serveur doit valider le token Bearer de façon indépendante et exiger que le principal vérifié lui corresponde. N'autorisez jamais un préfixe d'objets sur la seule base de expectedPrincipal.
Requête :
{"expectedPrincipal":"user-id"}
Réponse :
{
"principal": "user-id",
"quota": {
"maxNotes": 5000,
"maxChanges": 500,
"maxNoteBytes": 131072,
"tombstoneDays": 90
}
}
L'implémentation fournie valide les tokens via GET /auth/v1/user de Supabase, mais une implémentation compatible peut utiliser un autre service de comptes. Elle doit rejeter les comptes anonymes, supprimés, expirés ou invalides pour toute autre raison, et renvoyer le même identifiant de principal stable que celui attendu par le client.
Portée et format des notes
La portée projet s'exprime par project, sans propriété scope :
{"project":"project-a"}
La portée utilisateur a scope: "user" et ne doit pas contenir project :
{"scope":"user"}
Le protocole exige les deux portées. L'adaptateur R2 fourni actuellement a une lacune connue dans sa liste d'autorisation de clés pour le préfixe utilisateur ; cela ne change pas le contrat réseau pour les implémentations indépendantes.
Une note se présente ainsi :
{
"id": "borderless-cards-a91",
"summary": "Pricing cards use fills, not borders",
"body": "Use whitespace and background fills.",
"tags": ["design"],
"type": "feedback",
"createdAt": 1789344000000,
"updatedAt": 1789345000000
}
type est facultatif et, s'il est présent, vaut user, feedback, project ou reference. updatedAt est une métadonnée facultative, volontairement exclue du hash du contenu.
Les limites de validation font partie du contrat réseau :
| Champ | Contrainte |
|---|---|
| Identifiants de principal, de projet et de note | [A-Za-z0-9_-]{1,128} ; rejetez aussi __proto__, constructor et prototype |
summary | Sans espaces superflus, une seule ligne, 1 à 500 caractères |
body | Chaîne sans espaces superflus, au plus le maxNoteBytes annoncé en UTF-8 |
tags | 30 au maximum ; chacun de 1 à 64 caractères, sans espace, virgule ni saut de ligne |
createdAt | Entier sûr positif ou nul |
updatedAt | Entier sûr positif ou nul facultatif |
Les identifiants des notes actives sont uniques sans tenir compte de la casse. C'est obligatoire, car un serveur ne doit pas créer un état que les systèmes de fichiers clients courants, insensibles à la casse, ne peuvent pas représenter.
Le hash SHA-256 d'une note, en hexadécimal minuscule, est calculé sur du JSON UTF-8 avec ce tableau positionnel exact :
JSON.stringify([id, summary, body, tags, createdAt])
Si et seulement si type est présent, ajoutez-le :
JSON.stringify([id, summary, body, tags, createdAt, type])
Les implémentations doivent reproduire le JSON.stringify de JavaScript pour ces valeurs. Cette exception de compatibilité préserve les hashs calculés avant l'apparition des types de notes.
POST /v1/memory/snapshot
Requête de projet :
{
"expectedPrincipal": "user-id",
"project": "project-a",
"knownEtag": "optional-previous-etag",
"have": {
"borderless-cards-a91": "64-lowercase-hex-characters"
}
}
Pour la portée utilisateur, remplacez project par "scope":"user".
knownEtag permet un chemin rapide quand rien n'a changé. have associe des identifiants de notes aux hashs que le client détient déjà ; le serveur peut omettre le corps de ces notes sans les lire dans le stockage d'objets. Rejetez une table have de plus de maxNotes * 2 + 1000 clés.
Réponse inchangée :
{
"principal": "user-id",
"etag": "same-etag",
"unchanged": true,
"quota": {
"maxNotes": 5000,
"maxChanges": 500,
"maxNoteBytes": 131072,
"tombstoneDays": 90
}
}
Réponse complète ou incrémentale :
{
"principal": "user-id",
"etag": "current-etag-or-null",
"entries": [
{
"id": "borderless-cards-a91",
"hash": "64-lowercase-hex-characters",
"note": { "id": "borderless-cards-a91", "summary": "...", "body": "...", "tags": [], "createdAt": 1789344000000 }
},
{ "id": "already-held", "hash": "64-lowercase-hex-characters" },
{ "id": "deleted-note", "hash": null }
],
"quota": {
"maxNotes": 5000,
"maxChanges": 500,
"maxNoteBytes": 131072,
"tombstoneDays": 90
}
}
Une entrée active ne peut omettre note que si have[id] est égal à son hash. Un tombstone a hash: null et pas de note. etag vaut null lorsqu'aucun manifeste n'existe.
POST /v1/memory/commit
{
"expectedPrincipal": "user-id",
"project": "project-a",
"etag": "etag-from-snapshot-or-null",
"changes": [
{
"id": "borderless-cards-a91",
"hash": "64-lowercase-hex-characters",
"note": { "id": "borderless-cards-a91", "summary": "...", "body": "...", "tags": [], "createdAt": 1789344000000 }
},
{ "id": "deleted-note", "hash": null }
],
"have": {}
}
Pour la portée utilisateur, remplacez project par "scope":"user". Une création ou une mise à jour doit contenir une note dont l'identifiant et le hash calculé correspondent à la modification. Une suppression a hash: null et pas de note. changes doit contenir entre 1 et le maxChanges annoncé d'entrées, sans identifiants en double.
N'appliquez le commit que si etag identifie toujours le manifeste actuel. En cas d'ETag périmé, renvoyez 409 {"code":"revision_conflict"} et laissez le manifeste publié inchangé. Écrivez les nouvelles révisions immuables des notes avant de publier le nouveau manifeste, et publiez le manifeste de façon atomique. Un écrivain concurrent perdant peut laisser des objets immuables inaccessibles, à nettoyer plus tard par le ramasse-miettes, mais aucun état partiel ne doit devenir visible via le manifeste. La réponse a la même forme qu'un snapshot réussi, avec le nouvel ETag et les entrées actuelles.
Ne supprimez pas les suppressions immédiatement. Conservez les tombstones pendant la durée annoncée pour que les clients hors ligne puissent les observer. Abaisser un quota ne doit pas bloquer un principal qui le dépasse : refusez la croissance au-delà de maxNotes, mais continuez d'autoriser les suppressions.
Erreurs et limites de Memory
Les erreurs Memory utilisent un code stable, lisible par une machine :
{"code":"revision_conflict"}
Les codes utilisés par l'implémentation actuelle comprennent :
| Code d'état | Codes |
|---|---|
400 | invalid_body, unknown_field, invalid_project, invalid_scope, invalid_have, invalid_changes, invalid_note, hash_mismatch, id_case_conflict |
401 | sign_in_required |
403 | native_client_required, account_mismatch |
409 | revision_conflict |
413 | memory_quota_exceeded |
415 | json_required |
429 | rate_limited |
502 | auth_unavailable, invalid_manifest, missing_memory_object, memory_integrity_error, memory_sync_failed |
503 | auth_not_configured, r2_not_configured |
Les valeurs par défaut fournies sont de 5 000 notes actives par portée, 500 modifications par commit, 128 Kio par corps de note, 4 Mio par requête, 16 Mio de données d'entrées sérialisées par réponse et 90 jours pour les tombstones. Seules les quatre valeurs sur lesquelles le client peut agir, présentées dans quota, sont annoncées sur le réseau. Un client applique aussi ses propres plafonds de sécurité stricts : augmenter les quotas du serveur ne garantit donc pas qu'un client acceptera une réponse arbitrairement volumineuse.
Configurer un client OHarness
Placez la destination dans la configuration de l'utilisateur, jamais dans celle d'un dépôt :
{
"sync": {
"url": "https://sync.example.com",
"token": "opaque-session-token",
"team": "optional-team",
"enabled": true
}
}
Le fichier est $OHARNESS_HOME/config.json lorsque OHARNESS_HOME est défini, $XDG_CONFIG_HOME/oharness/config.json sous Linux lorsque XDG est défini, et ~/.oharness/config.json sinon. Redémarrez OHarness après avoir changé la destination.
L'ordre de priorité actuel est :
sync.url in user config > OHARNESS_SYNC_URL > https://sync.oharness.ai
sync.token in user config > OHARNESS_SYNC_TOKEN > Cloud account token
Par conséquent, OHARNESS_SYNC_URL ne remplace la valeur par défaut codée en dur que si la configuration utilisateur n'a pas de sync.url. OHARNESS_SYNC=0 désactive la synchronisation des sessions, quels que soient la destination ou l'identifiant.
Les fichiers oharness.config.json et .oharness.json d'un dépôt ne peuvent pas définir sync.url, sync.token ni sync.team ; OHarness ignore ces valeurs. Un dépôt peut seulement désactiver la synchronisation ou ajouter des exclusions. Cela empêche un dépôt cloné de rediriger les transcriptions d'un utilisateur.
La même URL de base résolue sert pour Memory, mais le client Memory actuel utilise l'identifiant du compte Cloud, et non sync.token ou OHARNESS_SYNC_TOKEN. Pour auto-héberger Memory, la connexion du client et le vérificateur d'identité du serveur Memory doivent donc s'accorder sur le même principal.
Liste de contrôle d'implémentation
Un service compatible doit satisfaire tous ces comportements :
- La vérification de santé fonctionne sans token ; toutes les routes de données refusent par défaut sans token.
- Deux principaux qui utilisent le même identifiant de projet ne peuvent pas voir les données l'un de l'autre.
- Les flux personnels et d'équipe sont distincts ; les équipes non autorisées renvoient
403. - Retenter un push ne duplique ni les enregistrements ni les métadonnées de session périmées.
- Le pull est ordonné, paginé, à curseur monotone et sûr en long polling.
- Les corps de session traversent un push/pull sans modification au niveau de la valeur JSON.
- Les suppressions de sessions et de notes Memory atteignent un second appareil hors ligne.
- Un ETag Memory périmé ne publie aucune modification du manifeste.
- Les clés d'objets Memory utilisent toujours le principal vérifié par le serveur.
- Les hashs non concordants, les collisions d'identifiants de notes ne différant que par la casse et les requêtes trop volumineuses sont rejetés avant la publication d'un manifeste.
- L'état persistant des flux et des manifestes survit aux redémarrages du processus et aux déploiements.
Les tests de sessions du dépôt dans test/sync.test.ts et les tests Memory dans script/sync-memory.test.ts sont des exemples exécutables du contrat. Ils instancient actuellement le serveur fourni directement au lieu d'accepter une URL distante : une autre implémentation doit donc porter les mêmes cas ou fournir un petit adaptateur avant de revendiquer la compatibilité.
Pour un premier test de fumée à distance, émettez un token jetable et substituez sa valeur ci-dessous. N'utilisez pas de token de compte de production dans un historique de shell :
export SYNC_URL=https://sync.example.com
export SYNC_TOKEN=test-token
curl "$SYNC_URL/v1/health"
curl -X POST "$SYNC_URL/v1/push" \
-H "Authorization: Bearer $SYNC_TOKEN" \
-H "Content-Type: application/json" \
--data '{"project":"conformance","records":[{"session":"s1","id":"r1","seq":1,"device":"test","at":1,"body":{"value":"unchanged"}}],"sessions":[]}'
curl "$SYNC_URL/v1/pull?project=conformance&since=0&limit=500" \
-H "Authorization: Bearer $SYNC_TOKEN"
Poussez le même corps une seconde fois et vérifiez que accepted vaut 0 ; puis faites un pull depuis le curseur renvoyé et vérifiez que les tableaux sont vides et que le curseur n'a pas changé. Ces vérifications établissent la joignabilité et l'idempotence de base, pas l'isolation des locataires ni la sûreté du CAS en concurrence.
Ce qui n'est pas exigé
Un serveur compatible n'a pas besoin :
- d'exécuter un modèle, d'exécuter des outils ou de comprendre les formats de messages des transcriptions ;
- d'utiliser JSONL, R2, Supabase ni une base de données particulière ;
- de fournir l'inscription, la connexion, l'émission de tokens ou une application navigateur ;
- d'implémenter Memory pour implémenter la synchronisation des sessions ;
- d'exposer CORS. Les clients actuels sont des clients natifs Node/Electron.
Garder ces préoccupations hors du protocole, c'est ce qui permet à un petit service auto-hébergé et à un déploiement multi-locataire de parler le même format réseau.