Synchronisatieprotocol

Deze pagina is het interoperabiliteitscontract voor iedereen die een synchronisatiedienst voor OHarness implementeert. Ze beschrijft wat de client stuurt en op welk gedrag hij rekent; de JSONL-opslag die @oharness/sync gebruikt, is geen vereiste.

Onder één origin draaien twee onafhankelijke protocollen:

  • Sessiesynchronisatie spiegelt transcripties, artefactrevisies en sessiemetadata via een append-only feed.
  • Geheugensynchronisatie brengt Markdown-notities in overeenstemming via een manifest en compare-and-swap. Die is optioneel. Een server kan sessiesynchronisatie implementeren zonder geheugen te implementeren.

De basis-URL bevat geen pad, query, inloggegevens of fragment. Clients in productie accepteren alleen HTTPS; gewone HTTP wordt geaccepteerd voor ontwikkeling op localhost, 127.0.0.1 en [::1]. De voorbeelden hieronder gebruiken https://sync.example.com.

Op deze pagina

  • [Compatibiliteitsdoel](#compatibility-target)
  • [Authenticatie en tenancy](#authentication-and-tenancy)
  • [Datamodel van sessiesynchronisatie](#session-sync-data-model)
  • [Sessie-endpoints](#session-endpoints)
  • [Foutantwoorden bij sessies](#session-error-responses)
  • [Geheugensynchronisatie](#memory-sync)
  • [Een OHarness-client configureren](#configuring-an-oharness-client)
  • [Checklist voor implementaties](#implementation-checklist)
  • [Wat niet vereist is](#non-requirements)

Compatibiliteitsdoel

Dit document beschrijft protocolversie 1, zoals gebruikt door OHarness 0.5.x. Er is op dit moment geen capabilities-endpoint en geen content negotiation. Houd /v1/ achterwaarts compatibel: voeg optionele velden toe, verander de betekenis van een bestaand veld niet en maak een eerder optioneel veld niet verplicht.

Onbekende velden in verzoeken horen bij sessiesynchronisatie te worden genegeerd. Geheugen is strenger en weigert ze met 400 {"code":"unknown_field"}; nieuwe clients gebruiken dat antwoord om een oudere geheugenserver te herkennen en een projectsnapshot opnieuw te proberen zonder de have-optimalisatie.

Alle tijdstempels zijn Unix-milliseconden. Alle body's zijn JSON. Geslaagde antwoorden gebruiken Content-Type: application/json; clients rekenen niet op bepaalde witruimte of volgorde van eigenschappen in de JSON.

Authenticatie en tenancy

Elke route behalve GET /v1/health vereist:

Authorization: Bearer <token>

Een geldig token leidt naar precies één stabiele principal-id. De server moet de eigenaar afleiden uit het geverifieerde token, nooit uit een gebruikers-id die in een verzoek wordt meegestuurd. Een sessietoken kan daarnaast team-id's bevatten.

Zonder team richten sessieverzoeken zich op de persoonlijke feed van de principal. Met een team richten ze zich op de feed van dat team, pas nadat het lidmaatschap is gecontroleerd. Een onbekend of niet-geautoriseerd team geeft 403; er mag niet stilletjes worden teruggevallen op de persoonlijke feed.

Authenticatie is bewust niet verder gestandaardiseerd dan Bearer-transport. De meegeleverde server accepteert lokaal uitgegeven ondoorzichtige tokens, HS256-JWT's en JWT's die via JWKS worden geverifieerd. Een andere implementatie mag een database of een andere tokenverifier gebruiken, zolang de waarneembare semantiek van principal en team gelijk blijft.

Geheugen heeft een strengere identiteitscontrole, die hieronder wordt beschreven. In het bijzonder: een ondoorzichtig token van oharness-sync token is genoeg voor sessiesynchronisatie, maar de huidige geheugenclient van OHarness gebruikt zijn Cloud-accounttoken.

Datamodel van sessiesynchronisatie

Record

Een record is één onveranderlijk item in een sessie. Op dit moment bevat het meestal een transcriptierecord of een envelop met een artefactrevisie, maar de server moet body als ondoorzichtige JSON behandelen.

{
  "session": "20260914_abcd",
  "id": "stable-record-id",
  "seq": 12,
  "device": "device-id",
  "at": 1789344000000,
  "body": { "opaque": "to the server" }
}
VeldTypeBetekenis
sessionstringSessie waartoe het record behoort
idstringStabiele id voor ontdubbeling bij nieuwe pogingen
seqnumberLamport-volgnummer binnen de sessie
devicestringApparaat dat het record heeft aangemaakt
atnumberAanmaaktijd in Unix-milliseconden
bodywillekeurige JSONPayload die ongewijzigd wordt opgeslagen en teruggegeven

De ontdubbelingssleutel is (session, id). Die sleutel opnieuw pushen mag geen tweede event toevoegen, ook niet als een eerder antwoord verloren is gegaan. Parse, normaliseer of herschrijf body niet.

Sessiemetadata

{
  "id": "20260914_abcd",
  "gen": 1,
  "updatedAt": 1789344000000,
  "body": { "opaque": "session metadata" }
}

body is ondoorzichtig. deleted: true is een tombstone en heeft normaal geen body. Metadata is last-writer-wins op basis van dit tupel, in aflopende prioriteit:

  1. Een hogere gen wint. Een compactie of herschrijving verhoogt de generatie.
  2. Binnen één generatie wint een hogere updatedAt.
  3. Een gelijk tupel is al aanwezig en voegt geen event toe.

De winnende update, ook een tombstone, wordt aan de feed toegevoegd, zodat apparaten die al een oudere waarde hebben gelezen de wijziging kunnen zien.

Cursor

Een cursor is een niet-negatief geheel getal dat de server uitgeeft. Hij identificeert een positie in één feed (owner, project). De client behandelt hem als ondoorzichtig: hij slaat hem op, stuurt hem terug als since en verwacht alleen dat hij nooit achteruit gaat.

De meegeleverde JSONL-implementatie gebruikt een oplopend eventnummer. Een database-implementatie kan een sequentie gebruiken. Een cursor hoeft geen byte-offset, tijdstempel of record-seq te zijn.

Cursors horen bij één feed en mogen niet worden hergebruikt tussen gebruikers, teams of projecten. Feedopslag is duurzame status. Raakt een server een feed kwijt terwijl een client zijn cursor bewaart, dan kan de client permanent vóór de nieuwe feed blijven lopen; protocol v1 heeft geen handshake om de cursor te resetten.

Sessie-endpoints

GET /v1/health

Liveness-endpoint zonder authenticatie.

HTTP/1.1 200 OK
Content-Type: application/json

{"ok":true}

Dit bewijst dat het proces en de route bereikbaar zijn. Het bewijst niet dat een token werkt, dat duurzame opslag is gemount of dat geheugen is geconfigureerd.

GET /v1/projects

Somt de projecten op die een feed hebben voor de gekozen eigenaar.

GET /v1/projects
GET /v1/projects?team=platform

Antwoord:

{"projects":["project-a","project-b"]}

Geef een lege array terug als de eigenaar geen feeds heeft. ID's die huidige OHarness-clients aanmaken, voldoen aan [A-Za-z0-9_-]{1,128} en horen ongewijzigd te worden teruggegeven. Een server mag andere invoer weigeren of omkeerbaar coderen; plak nooit een onbetrouwbare id aan een bestandssysteempad.

De huidige spiegel per project roept deze route niet aan in zijn normale push/pull-lus, maar ze maakt deel uit van de publieke client en is vereist voor een volledige implementatie en voor het vinden van projecten over apparaten heen.

POST /v1/push

Voegt atomair nieuwe records en winnende sessiemetadata toe.

{
  "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 en sessions zijn optioneel. Ontbrekende arrays gelden als lege arrays. Minimaal project is verplicht. Oudere typedeclaraties bevatten een device op het hoogste niveau; de OHarness-client stuurt die niet en compatibele servers mogen hem niet vereisen. De apparaatidentiteit staat op elk record.

Antwoord:

{"cursor":42,"accepted":2}

cursor is het huidige einde van de feed na deze push. accepted telt nieuwe record-events plus winnende events voor sessiemetadata. Dubbele records en verouderde of gelijke sessiemetadata tellen niet mee.

Het hele verzoek kan opnieuw worden geprobeerd. Valideer het voordat je ook maar één vermelding vastlegt. Accepteer geen gedeeltelijke batch: geef voor het hele verzoek een fout terug als een vermelding misvormd is of een limiet wordt overschreden.

Limieten die de huidige client en de meegeleverde server gebruiken:

LimietWaarde
Records in één push500
Vermeldingen met sessiemetadata in één push500
Geserialiseerde request-body8 MiB

De twee arraylimieten staan los van elkaar: één verzoek mag 500 records en 500 sessievermeldingen bevatten.

GET /v1/pull

Leest feed-events na een cursor.

GET /v1/pull?project=project-a&since=20&limit=500&wait=30&team=platform
QueryVerplichtBetekenis
projectjaOndoorzichtige project-id
sinceja, bij normaal gebruikGeeft events strikt na deze cursor terug; 0 begint bij het begin
limitneePaginagrootte, begrensd tot 1..500; standaard 500
waitneeSeconden long polling, begrensd tot 0..30; standaard 0
teamneeTeamfeed in plaats van de feed van de principal

Antwoord:

{
  "cursor": 22,
  "records": [],
  "sessions": [],
  "more": false
}

Geef events terug in de volgorde van de feed en zonder velden die alleen voor de server zijn. De cursor is het laatste teruggegeven event. Zijn er geen nieuwe events, dan blijft hij gelijk aan since. Zet more: true alleen als er al een volgende pagina beschikbaar is; de client pullt dan meteen opnieuw.

Is wait > 0 en is de feed bijgewerkt, houd het verzoek dan vast tot er een event wordt toegevoegd of de wachttijd verloopt. Een time-out is een geslaagd leeg antwoord, geen HTTP-fout. Een long poll afbreken mag de status van de feed niet veranderen.

Foutantwoorden bij sessies

Sessiefouten zijn JSON-objecten met een korte, voor mensen leesbare error-string:

{"error":"missing or bad token"}

Gebruik gangbare statuscodes:

StatusWanneer
400Ongeldige JSON, ontbrekend project, ongeldige cursor of misvormde vermelding
401Ontbrekend, verlopen of ongeldig Bearer-token; voeg WWW-Authenticate: Bearer toe
403De geauthenticeerde principal is geen lid van het gevraagde team
413Het aantal bytes of vermeldingen in het verzoek overschrijdt de limiet
404Onbekende route
500Onverwachte opslagfout; beweer niet dat een gedeeltelijke push is geslaagd

De spiegel probeert netwerkfouten en gewone serverfouten opnieuw met backoff. Een 401 stopt die spiegel tot hij opnieuw wordt gestart met een bruikbare credential.

Geheugensynchronisatie

Geheugensynchronisatie is optioneel en staat los van de append-only sessiefeed. Alle drie de routes zijn geauthenticeerde POST-verzoeken met Content-Type: application/json:

  • /v1/memory/identity
  • /v1/memory/snapshot
  • /v1/memory/commit

De huidige client is alleen native. Een verzoek met een Origin-header wordt geweigerd met 403 {"code":"native_client_required"}.

Geheugenidentiteit

Elk verzoek bevat expectedPrincipal, afkomstig uit het lokaal gedecodeerde Cloud-account, als vastgepinde toestemming. De server moet het Bearer-token zelfstandig valideren en eisen dat de geverifieerde principal ermee overeenkomt. Autoriseer nooit een objectprefix op basis van alleen expectedPrincipal.

Verzoek:

{"expectedPrincipal":"user-id"}

Antwoord:

{
  "principal": "user-id",
  "quota": {
    "maxNotes": 5000,
    "maxChanges": 500,
    "maxNoteBytes": 131072,
    "tombstoneDays": 90
  }
}

De meegeleverde implementatie valideert tokens via Supabase GET /auth/v1/user, maar een compatibele implementatie mag een andere accountdienst gebruiken. Ze moet anonieme, verwijderde, verlopen of anderszins ongeldige accounts weigeren en dezelfde stabiele principal-id teruggeven die de client verwacht.

Bereik en notitieformaat

Projectbereik wordt uitgedrukt met project zonder scope-eigenschap:

{"project":"project-a"}

Gebruikersbereik heeft scope: "user" en mag geen project bevatten:

{"scope":"user"}

Het protocol vereist beide bereiken. De huidige meegeleverde R2-adapter heeft een bekend gat in de sleutel-allowlist voor het gebruikersprefix; dat verandert niets aan het contract op de lijn voor onafhankelijke implementaties.

Een notitie ziet er zo uit:

{
  "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 is optioneel en is, als het aanwezig is, een van user, feedback, project of reference. updatedAt is optionele metadata en wordt bewust buiten de inhoudshash gehouden.

Validatielimieten maken deel uit van het contract op de lijn:

VeldBeperking
Principal-, project- en notitie-id's[A-Za-z0-9_-]{1,128}; weiger ook __proto__, constructor en prototype
summaryIngekort, één regel, 1–500 tekens
bodyIngekorte string, maximaal de aangekondigde maxNoteBytes in UTF-8
tagsMaximaal 30; elk 1–64 tekens zonder witruimte, komma of regeleinde
createdAtNiet-negatief veilig geheel getal
updatedAtOptioneel niet-negatief veilig geheel getal

Live notitie-id's zijn uniek zonder onderscheid tussen hoofd- en kleine letters. Dat is vereist omdat een server geen status mag aanmaken die gangbare hoofdletterongevoelige bestandssystemen van clients niet kunnen weergeven.

De notitiehash, SHA-256 in kleine hexadecimale letters, wordt berekend over UTF-8-JSON met precies deze positionele array:

JSON.stringify([id, summary, body, tags, createdAt])

Alleen als type aanwezig is, voeg je het toe:

JSON.stringify([id, summary, body, tags, createdAt, type])

Implementaties moeten JavaScript-JSON.stringify voor deze waarden exact nabootsen. De compatibiliteitsuitzondering behoudt hashes die zijn gemaakt voordat notitietypen bestonden.

POST /v1/memory/snapshot

Projectverzoek:

{
  "expectedPrincipal": "user-id",
  "project": "project-a",
  "knownEtag": "optional-previous-etag",
  "have": {
    "borderless-cards-a91": "64-lowercase-hex-characters"
  }
}

Vervang voor gebruikersbereik project door "scope":"user".

knownEtag maakt een snel pad mogelijk als er niets is veranderd. have koppelt notitie-id's aan hashes die de client al heeft; de server mag die notitie-inhoud weglaten zonder hem uit de objectopslag te lezen. Weiger een have-map met meer dan maxNotes * 2 + 1000 sleutels.

Antwoord als er niets is veranderd:

{
  "principal": "user-id",
  "etag": "same-etag",
  "unchanged": true,
  "quota": {
    "maxNotes": 5000,
    "maxChanges": 500,
    "maxNoteBytes": 131072,
    "tombstoneDays": 90
  }
}

Volledig of incrementeel antwoord:

{
  "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
  }
}

Een live vermelding mag note alleen weglaten als have[id] gelijk is aan zijn hash. Een tombstone heeft hash: null en geen notitie. etag is null als er geen manifest bestaat.

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": {}
}

Vervang voor gebruikersbereik project door "scope":"user". Een aanmaak of update moet een notitie bevatten waarvan de id en de berekende hash overeenkomen met de wijziging. Een verwijdering heeft hash: null en geen note. changes moet tussen 1 en de aangekondigde maxChanges vermeldingen bevatten, zonder dubbele id's.

Pas de commit alleen toe als etag nog steeds het huidige manifest identificeert. Bij een verouderde ETag geef je 409 {"code":"revision_conflict"} terug en laat je het gepubliceerde manifest ongewijzigd. Schrijf nieuwe onveranderlijke notitierevisies voordat je het nieuwe manifest publiceert, en publiceer het manifest atomair. Een gelijktijdige schrijver die verliest, kan onbereikbare onveranderlijke objecten achterlaten voor latere garbage collection, maar via het manifest mag geen gedeeltelijke status zichtbaar worden. Het antwoord heeft dezelfde vorm als een geslaagde snapshot, met de nieuwe ETag en de huidige vermeldingen.

Gooi verwijderingen niet meteen weg. Bewaar tombstones gedurende het aangekondigde venster, zodat offline clients ze kunnen zien. Een quotum verlagen mag een principal die erboven zit niet vastzetten: weiger groei boven maxNotes, maar sta verwijderingen toe.

Geheugenfouten en limieten

Geheugenfouten gebruiken een stabiele, machineleesbare code:

{"code":"revision_conflict"}

Codes die de huidige implementatie gebruikt, zijn onder andere:

StatusCodes
400invalid_body, unknown_field, invalid_project, invalid_scope, invalid_have, invalid_changes, invalid_note, hash_mismatch, id_case_conflict
401sign_in_required
403native_client_required, account_mismatch
409revision_conflict
413memory_quota_exceeded
415json_required
429rate_limited
502auth_unavailable, invalid_manifest, missing_memory_object, memory_integrity_error, memory_sync_failed
503auth_not_configured, r2_not_configured

De meegeleverde standaarden zijn 5000 live notities per bereik, 500 wijzigingen per commit, 128 KiB per notitie-inhoud, 4 MiB per verzoek, 16 MiB aan geserialiseerde vermeldingsdata per antwoord en 90 dagen voor tombstones. Alleen de vier waarden in quota waar de client iets mee kan, worden op de lijn aangekondigd. Een client hanteert daarnaast eigen harde veiligheidsplafonds, dus hogere serverquota garanderen niet dat een client een willekeurig groot antwoord accepteert.

Een OHarness-client configureren

Zet de bestemming in de configuratie van de gebruiker, nooit in de configuratie van een repository:

{
  "sync": {
    "url": "https://sync.example.com",
    "token": "opaque-session-token",
    "team": "optional-team",
    "enabled": true
  }
}

Het bestand is $OHARNESS_HOME/config.json als OHARNESS_HOME is gezet, $XDG_CONFIG_HOME/oharness/config.json op Linux als XDG is gezet, en anders ~/.oharness/config.json. Herstart OHarness nadat je de bestemming hebt gewijzigd.

De huidige voorrangsvolgorde is:

sync.url in user config > OHARNESS_SYNC_URL > https://sync.oharness.ai
sync.token in user config > OHARNESS_SYNC_TOKEN > Cloud account token

Daardoor vervangt OHARNESS_SYNC_URL de vaste standaard alleen als de gebruikersconfiguratie geen sync.url heeft. OHARNESS_SYNC=0 zet sessiesynchronisatie uit, ongeacht de bestemming of de credential.

De bestanden oharness.config.json en .oharness.json in een repository kunnen sync.url, sync.token of sync.team niet instellen; OHarness gooit die waarden weg. Een repository kan synchronisatie alleen uitzetten of uitsluitingen toevoegen. Zo kan een gekloonde repository de transcripties van een gebruiker niet ergens anders heen leiden.

Dezelfde bepaalde basis-URL wordt gebruikt voor geheugen, maar de huidige geheugenclient gebruikt de credential van het Cloud-account, niet sync.token of OHARNESS_SYNC_TOKEN. Om geheugen zelf te hosten, moeten de login van de client en de identiteitsverifier van de geheugenserver het dus eens zijn over dezelfde principal.

Checklist voor implementaties

Een compatibele dienst hoort al deze gedragingen te doorstaan:

  • Health werkt zonder token; elke dataroute faalt gesloten zonder token.
  • Twee principals met dezelfde project-id kunnen elkaars data niet zien.
  • Persoonlijke feeds en teamfeeds zijn gescheiden; niet-geautoriseerde teams geven 403.
  • Een push opnieuw proberen dupliceert geen records of verouderde sessiemetadata.
  • Pull is geordend, gepagineerd, cursor-monotoon en veilig voor long polling.
  • Sessie-body's komen op JSON-waardeniveau ongewijzigd door een push/pull.
  • Verwijderingen van sessies en van geheugen bereiken een tweede apparaat dat offline was.
  • Een verouderde geheugen-ETag publiceert geen wijziging aan het manifest.
  • Sleutels van geheugenobjecten gebruiken altijd de door de server geverifieerde principal.
  • Hash-mismatches, botsingen tussen notitie-id's die alleen in hoofdletters verschillen en te grote verzoeken worden geweigerd voordat een manifest wordt gepubliceerd.
  • De duurzame status van feed en manifest overleeft herstarts van het proces en deploys.

De sessietests van de repository in test/sync.test.ts en de geheugentests in script/sync-memory.test.ts zijn uitvoerbare voorbeelden van het contract. Ze instantiëren op dit moment de meegeleverde server direct in plaats van een URL op afstand te accepteren, dus een andere implementatie hoort dezelfde gevallen over te zetten of een kleine adapter te leveren voordat ze compatibiliteit claimt.

Geef voor een eerste smoke test op afstand een wegwerptoken uit en vul de waarde hieronder in. Gebruik geen accounttoken uit productie in een shellgeschiedenis:

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"

Push dezelfde body een tweede keer en controleer dat accepted 0 is; pull daarna vanaf de teruggegeven cursor en controleer dat de arrays leeg zijn en de cursor ongewijzigd is. Deze controles stellen bereikbaarheid en basale idempotentie vast, geen tenantisolatie of veiligheid van gelijktijdige CAS.

Wat niet vereist is

Een compatibele server hoeft niet:

  • een model te draaien, tools uit te voeren of berichtformaten van transcripties te begrijpen;
  • JSONL, R2, Supabase of een bepaalde database te gebruiken;
  • registratie, login, uitgifte van tokens of een browserapplicatie te bieden;
  • geheugen te implementeren om sessiesynchronisatie te implementeren;
  • CORS beschikbaar te stellen. De huidige clients zijn native Node/Electron-clients.

Door deze zaken buiten het protocol te houden, kunnen een kleine zelfgehoste dienst en een multi-tenant-deployment hetzelfde protocol op de lijn spreken.