Sync protocol
This page is the interoperability contract for anyone implementing a sync service for OHarness. It describes what the client sends and the behaviour it depends on; it does not require the JSONL storage used by @oharness/sync.
There are two independent protocols under one origin:
- Session sync mirrors transcripts, artifact revisions and session metadata through an append-only feed.
- Memory sync reconciles Markdown notes through a manifest and compare-and-swap. It is optional. A server may implement session sync without implementing Memory.
The base URL contains no path, query, credentials or fragment. Production clients accept HTTPS only; plain HTTP is accepted for localhost, 127.0.0.1 and [::1] development. Examples below use https://sync.example.com.
On this page
- [Compatibility target](#compatibility-target)
- [Authentication and tenancy](#authentication-and-tenancy)
- [Session sync data model](#session-sync-data-model)
- [Session endpoints](#session-endpoints)
- [Session error responses](#session-error-responses)
- [Memory sync](#memory-sync)
- [Configuring an OHarness client](#configuring-an-oharness-client)
- [Implementation checklist](#implementation-checklist)
- [Non-requirements](#non-requirements)
Compatibility target
This document describes protocol version 1, as used by OHarness 0.5.x. There is currently no capabilities endpoint or content negotiation. Keep /v1/ backwards compatible: add optional fields, do not change the meaning of an existing field, and do not make a formerly optional field required.
Unknown request fields should be ignored for session sync. Memory is stricter and rejects them with 400 {"code":"unknown_field"}; this response is used by new clients to detect an older Memory server and retry a project snapshot without the have optimization.
All timestamps are Unix milliseconds. All bodies are JSON. Successful responses use Content-Type: application/json; clients do not depend on any particular JSON whitespace or property order.
Authentication and tenancy
Every route except GET /v1/health requires:
Authorization: Bearer <token>
A valid token resolves to exactly one stable principal id. The server must derive the owner from the verified token, never from a user id supplied in a request. A session token may additionally hold team ids.
With no team, session requests address the principal's personal feed. With a team, they address that team's feed only after membership is checked. An unknown or unauthorized team returns 403; it must not silently fall back to the personal feed.
Authentication is deliberately not standardized beyond Bearer transport. The bundled server accepts locally issued opaque tokens, HS256 JWTs and JWTs verified through JWKS. Another implementation may use a database or a different token verifier as long as the observable principal and team semantics are the same.
Memory has a stronger identity check described below. In particular, an opaque token issued by oharness-sync token is sufficient for session sync but the current OHarness Memory client uses its Cloud account token.
Session sync data model
Record
A record is one immutable item in a session. Today it normally contains a transcript record or an artifact-revision envelope, but the server must treat body as opaque JSON.
{
"session": "20260914_abcd",
"id": "stable-record-id",
"seq": 12,
"device": "device-id",
"at": 1789344000000,
"body": { "opaque": "to the server" }
}
| Field | Type | Meaning |
|---|---|---|
session | string | Session containing the record |
id | string | Stable id used for retry deduplication |
seq | number | Lamport sequence inside the session |
device | string | Device that created the record |
at | number | Creation time in Unix milliseconds |
body | any JSON | Payload stored and returned unchanged |
The deduplication key is (session, id). Re-pushing that key must not append a second event, even if a previous response was lost. Do not parse, normalize or rewrite body.
Session metadata
{
"id": "20260914_abcd",
"gen": 1,
"updatedAt": 1789344000000,
"body": { "opaque": "session metadata" }
}
body is opaque. deleted: true is a tombstone and normally has no body. Metadata is last-writer-wins using this tuple, in descending priority:
- Greater
genwins. A compaction or rewrite increments the generation. - Within one generation, greater
updatedAtwins. - An equal tuple is already held and adds no event.
The winning update, including a tombstone, is appended to the feed so devices that already read an older value can observe the change.
Cursor
A cursor is a non-negative integer issued by the server. It identifies a position in one (owner, project) feed. The client treats it as opaque: it stores it, sends it back as since, and only expects it never to move backwards.
The bundled JSONL implementation uses an incrementing event number. A database implementation may use a sequence. A cursor is not required to be a byte offset, timestamp or record seq.
Cursors belong to one feed and must not be reused across users, teams or projects. Feed storage is durable state. If a server loses a feed while a client retains its cursor, the client can remain permanently ahead of the new feed; protocol v1 has no cursor-reset handshake.
Session endpoints
GET /v1/health
Unauthenticated liveness endpoint.
HTTP/1.1 200 OK
Content-Type: application/json
{"ok":true}
This proves that the process and route are reachable. It does not prove that a token works, that persistent storage is mounted, or that Memory is configured.
GET /v1/projects
Lists projects that have a feed for the selected owner.
GET /v1/projects
GET /v1/projects?team=platform
Response:
{"projects":["project-a","project-b"]}
Return an empty array when the owner has no feeds. IDs produced by current OHarness clients match [A-Za-z0-9_-]{1,128} and should be returned unchanged. A server may reject or reversibly encode other input; never concatenate an untrusted id into a filesystem path.
The current per-project mirror does not call this route during its normal push/pull loop, but it is part of the public client and is required for a complete implementation and cross-device project discovery.
POST /v1/push
Atomically appends new records and winning session metadata.
{
"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 and sessions are optional. Missing arrays mean empty arrays. At least project is required. Older type declarations included a top-level device; the OHarness client does not send it and compatible servers must not require it. Device identity lives on each record.
Response:
{"cursor":42,"accepted":2}
cursor is the current end of the feed after this push. accepted counts new record events plus winning session-metadata events. Duplicated records and stale/equal session metadata do not count.
The whole request is retriable. Validate it before committing any entry. Do not accept a partial batch: return an error for the whole request if any entry is malformed or a limit is exceeded.
Limits used by the current client and bundled server:
| Limit | Value |
|---|---|
| Records in one push | 500 |
| Session metadata entries in one push | 500 |
| Serialized request body | 8 MiB |
The two array limits are independent: one request may contain 500 records and 500 session entries.
GET /v1/pull
Reads feed events after a cursor.
GET /v1/pull?project=project-a&since=20&limit=500&wait=30&team=platform
| Query | Required | Meaning |
|---|---|---|
project | yes | Opaque project id |
since | yes in normal use | Return events strictly after this cursor; 0 starts at the beginning |
limit | no | Page size, clamped to 1..500; default 500 |
wait | no | Long-poll seconds, clamped to 0..30; default 0 |
team | no | Team feed instead of the principal's feed |
Response:
{
"cursor": 22,
"records": [],
"sessions": [],
"more": false
}
Return events in feed order and without server-only fields. The cursor is the last event returned. If there are no new events it remains equal to since. Set more: true only when another page is already available; the client then pulls again immediately.
When wait > 0 and the feed is caught up, hold the request until an event is appended or the wait expires. A timeout is a successful empty response, not an HTTP error. Disconnecting a long poll must not change feed state.
Session error responses
Session errors are JSON objects with a short human-readable error string:
{"error":"missing or bad token"}
Use conventional status codes:
| Status | When |
|---|---|
400 | Invalid JSON, missing project, invalid cursor or malformed entry |
401 | Missing, expired or invalid Bearer token; include WWW-Authenticate: Bearer |
403 | Authenticated principal is not a member of the requested team |
413 | Request bytes or entry count exceeds the limit |
404 | Unknown route |
500 | Unexpected storage failure; do not claim a partial push succeeded |
The mirror retries network errors and ordinary server failures with backoff. A 401 stops that mirror until it is restarted with a usable credential.
Memory sync
Memory sync is optional and separate from the append-only session feed. All three routes are authenticated POST requests with Content-Type: application/json:
/v1/memory/identity/v1/memory/snapshot/v1/memory/commit
The current client is native-only. A request carrying an Origin header is rejected with 403 {"code":"native_client_required"}.
Memory identity
Every request carries expectedPrincipal, taken from the locally decoded Cloud account as a consent pin. The server must independently validate the Bearer token and require the verified principal to match it. Never authorize an object prefix from expectedPrincipal alone.
Request:
{"expectedPrincipal":"user-id"}
Response:
{
"principal": "user-id",
"quota": {
"maxNotes": 5000,
"maxChanges": 500,
"maxNoteBytes": 131072,
"tombstoneDays": 90
}
}
The bundled implementation validates tokens through Supabase GET /auth/v1/user, but a compatible implementation may use another account service. It must reject anonymous, deleted, expired or otherwise invalid accounts and return the same stable principal id that the client expects.
Scope and note format
Project scope is expressed by project with no scope property:
{"project":"project-a"}
User scope has scope: "user" and must not contain project:
{"scope":"user"}
The protocol requires both scopes. The current bundled R2 adapter has a known key-allowlist gap for the user prefix; this does not change the wire contract for independent implementations.
A note is:
{
"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 optional and, when present, is one of user, feedback, project or reference. updatedAt is optional metadata and is deliberately excluded from the content hash.
Validation limits are part of the wire contract:
| Field | Constraint |
|---|---|
| Principal, project and note ids | [A-Za-z0-9_-]{1,128}; also reject __proto__, constructor and prototype |
summary | Trimmed, one line, 1–500 characters |
body | Trimmed string, at most the announced maxNoteBytes in UTF-8 |
tags | At most 30; each is 1–64 characters with no whitespace, comma or newline |
createdAt | Non-negative safe integer |
updatedAt | Optional non-negative safe integer |
Live note ids are unique case-insensitively. This is required because a server must not create a state that common case-insensitive client filesystems cannot represent.
The lowercase hexadecimal SHA-256 note hash is computed over UTF-8 JSON with this exact positional array:
JSON.stringify([id, summary, body, tags, createdAt])
When and only when type is present, append it:
JSON.stringify([id, summary, body, tags, createdAt, type])
Implementations must reproduce JavaScript JSON.stringify for these values. The compatibility exception preserves hashes made before note types existed.
POST /v1/memory/snapshot
Project request:
{
"expectedPrincipal": "user-id",
"project": "project-a",
"knownEtag": "optional-previous-etag",
"have": {
"borderless-cards-a91": "64-lowercase-hex-characters"
}
}
For user scope, replace project with "scope":"user".
knownEtag allows an unchanged fast path. have maps note ids to hashes the client already holds; the server may omit those note bodies without reading them from object storage. Reject a have map with more than maxNotes * 2 + 1000 keys.
Unchanged response:
{
"principal": "user-id",
"etag": "same-etag",
"unchanged": true,
"quota": {
"maxNotes": 5000,
"maxChanges": 500,
"maxNoteBytes": 131072,
"tombstoneDays": 90
}
}
Full or incremental response:
{
"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
}
}
A live entry may omit note only when have[id] equals its hash. A tombstone has hash: null and no note. etag is null when no manifest exists.
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": {}
}
For user scope, replace project with "scope":"user". A create/update must carry a note whose id and computed hash match the change. A deletion has hash: null and no note. changes must contain between 1 and the announced maxChanges entries, with no duplicate ids.
Apply the commit only when etag still identifies the current manifest. On a stale ETag return 409 {"code":"revision_conflict"} and leave the published manifest unchanged. Write new immutable note revisions before publishing the new manifest, and publish the manifest atomically. A losing concurrent writer may leave unreachable immutable objects for later garbage collection, but no partial state may become visible through the manifest. The response has the same shape as a successful snapshot, with the new ETag and current entries.
Do not discard deletions immediately. Retain tombstones for the announced window so offline clients can observe them. Lowering a quota must not trap a principal above it: reject growth over maxNotes, but continue to allow deletions.
Memory errors and limits
Memory errors use a stable machine-readable code:
{"code":"revision_conflict"}
Codes used by the current implementation include:
| Status | 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 |
The bundled defaults are 5000 live notes per scope, 500 changes per commit, 128 KiB per note body, 4 MiB per request, 16 MiB of serialized entry data per response, and 90 days for tombstones. Only the four client-actionable values shown in quota are announced on the wire. A client also applies its own hard safety ceilings, so raising server quotas does not guarantee that a client will accept an arbitrarily large response.
Configuring an OHarness client
Put the destination in the user's config, never in a repository's config:
{
"sync": {
"url": "https://sync.example.com",
"token": "opaque-session-token",
"team": "optional-team",
"enabled": true
}
}
The file is $OHARNESS_HOME/config.json when OHARNESS_HOME is set, $XDG_CONFIG_HOME/oharness/config.json on Linux when XDG is set, and ~/.oharness/config.json otherwise. Restart OHarness after changing the destination.
The current precedence is:
sync.url in user config > OHARNESS_SYNC_URL > https://sync.oharness.ai
sync.token in user config > OHARNESS_SYNC_TOKEN > Cloud account token
Consequently, OHARNESS_SYNC_URL only replaces the hard-coded default when the user config has no sync.url. OHARNESS_SYNC=0 disables session sync regardless of the destination or credential.
Repository oharness.config.json and .oharness.json files cannot set sync.url, sync.token or sync.team; OHarness discards those values. A repository may only turn sync off or add exclusions. This prevents a cloned repository from redirecting a user's transcripts.
The same resolved base URL is used for Memory, but the current Memory client uses the Cloud account credential, not sync.token or OHARNESS_SYNC_TOKEN. To self-host Memory, the client login and the Memory server's identity verifier must therefore agree on the same principal.
Implementation checklist
A compatible service should pass all of these behaviours:
- Health works without a token; every data route fails closed without one.
- Two principals using the same project id cannot see each other's data.
- Personal and team feeds are distinct; unauthorized teams return
403. - Retrying a push does not duplicate records or stale session metadata.
- Pull is ordered, paged, cursor-monotonic and safe to long-poll.
- Session bodies survive a push/pull unchanged at the JSON value level.
- Session deletions and Memory deletions reach an offline second device.
- A stale Memory ETag publishes no change to the manifest.
- Memory object keys always use the server-verified principal.
- Hash mismatches, case-only note-id collisions and oversized requests are rejected before publishing a manifest.
- Persistent feed and manifest state survives process restarts and deploys.
The repository's session tests in test/sync.test.ts and Memory tests in script/sync-memory.test.ts are executable examples of the contract. They currently instantiate the bundled server directly rather than accepting a remote URL, so another implementation should port the same cases or provide a small adapter before claiming compatibility.
For a first remote smoke test, issue a disposable token and substitute its value below. Do not use a production account token in a shell history:
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 the same body a second time and verify accepted is 0; then pull from the returned cursor and verify the arrays are empty and the cursor is unchanged. These checks establish reachability and basic idempotence, not tenant isolation or concurrent CAS safety.
Non-requirements
A compatible server does not need to:
- run a model, execute tools or understand transcript message formats;
- use JSONL, R2, Supabase or any particular database;
- provide registration, login, token issuance or a browser application;
- implement Memory in order to implement session sync;
- expose CORS. The current clients are native Node/Electron clients.
Keeping these concerns outside the protocol is what lets a small self-hosted service and a multi-tenant deployment speak the same wire format.