Protocolo de sincronização

Esta página é o contrato de interoperabilidade para quem for implementar um serviço de sincronização para o OHarness. Ela descreve o que o cliente envia e o comportamento de que ele depende; ela não exige o armazenamento JSONL usado pelo @oharness/sync.

Há dois protocolos independentes sob uma mesma origem:

  • A sincronização de sessões espelha transcrições, revisões de artefatos e metadados de sessão por meio de um feed somente de acréscimo.
  • A sincronização de memória reconcilia notas em Markdown por meio de um manifesto e de compare-and-swap. Ela é opcional. Um servidor pode implementar a sincronização de sessões sem implementar a memória.

A URL base não contém caminho, query, credenciais nem fragmento. Os clientes de produção só aceitam HTTPS; HTTP simples é aceito para desenvolvimento em localhost, 127.0.0.1 e [::1]. Os exemplos abaixo usam https://sync.example.com.

Nesta página

  • [Alvo de compatibilidade](#compatibility-target)
  • [Autenticação e tenancy](#authentication-and-tenancy)
  • [Modelo de dados da sincronização de sessões](#session-sync-data-model)
  • [Endpoints de sessão](#session-endpoints)
  • [Respostas de erro de sessão](#session-error-responses)
  • [Sincronização de memória](#memory-sync)
  • [Configurar um cliente do OHarness](#configuring-an-oharness-client)
  • [Checklist de implementação](#implementation-checklist)
  • [O que não é exigido](#non-requirements)

Alvo de compatibilidade

Este documento descreve a versão 1 do protocolo, usada pelo OHarness 0.5.x. No momento não há endpoint de capacidades nem negociação de conteúdo. Mantenha o /v1/ retrocompatível: adicione campos opcionais, não mude o significado de um campo existente e não torne obrigatório um campo que antes era opcional.

Campos de requisição desconhecidos devem ser ignorados na sincronização de sessões. A memória é mais rígida e os rejeita com 400 {"code":"unknown_field"}; os clientes novos usam essa resposta para detectar um servidor de memória mais antigo e repetir um snapshot de projeto sem a otimização have.

Todos os timestamps são em milissegundos Unix. Todos os corpos são JSON. As respostas de sucesso usam Content-Type: application/json; os clientes não dependem de nenhum espaçamento ou ordem de propriedades específicos no JSON.

Autenticação e tenancy

Toda rota, exceto GET /v1/health, exige:

Authorization: Bearer <token>

Um token válido resolve para exatamente um id de principal estável. O servidor precisa derivar o dono do token verificado, nunca de um id de usuário enviado em uma requisição. Um token de sessão pode, além disso, conter ids de equipe.

Sem team, as requisições de sessão se referem ao feed pessoal do principal. Com um team, elas se referem ao feed daquela equipe só depois de verificada a participação. Uma equipe desconhecida ou não autorizada retorna 403; ela não pode recorrer em silêncio ao feed pessoal.

A autenticação deliberadamente não é padronizada além do transporte Bearer. O servidor incluído aceita tokens opacos emitidos localmente, JWTs HS256 e JWTs verificados por JWKS. Outra implementação pode usar um banco de dados ou outro verificador de tokens, desde que a semântica observável de principal e de equipe seja a mesma.

A memória tem uma verificação de identidade mais forte, descrita abaixo. Em particular, um token opaco emitido por oharness-sync token basta para a sincronização de sessões, mas o cliente de memória atual do OHarness usa o token da conta do Cloud.

Modelo de dados da sincronização de sessões

Registro

Um registro é um item imutável em uma sessão. Hoje ele normalmente contém um registro de transcrição ou um envelope de revisão de artefato, mas o servidor precisa tratar body como JSON opaco.

{
  "session": "20260914_abcd",
  "id": "stable-record-id",
  "seq": 12,
  "device": "device-id",
  "at": 1789344000000,
  "body": { "opaque": "to the server" }
}
CampoTipoSignificado
sessionstringSessão que contém o registro
idstringId estável usado para deduplicar novas tentativas
seqnumberSequência de Lamport dentro da sessão
devicestringDispositivo que criou o registro
atnumberMomento de criação em milissegundos Unix
bodyqualquer JSONPayload guardado e devolvido sem alterações

A chave de deduplicação é (session, id). Reenviar essa chave não pode acrescentar um segundo evento, mesmo que uma resposta anterior tenha se perdido. Não interprete, normalize nem reescreva body.

Metadados de sessão

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

body é opaco. deleted: true é um tombstone e normalmente não tem corpo. Nos metadados, a última escrita vence, usando esta tupla em ordem decrescente de prioridade:

  1. O gen maior vence. Uma compactação ou reescrita incrementa a geração.
  2. Dentro de uma mesma geração, o updatedAt maior vence.
  3. Uma tupla igual já está guardada e não acrescenta nenhum evento.

A atualização vencedora, incluindo um tombstone, é acrescentada ao feed para que os dispositivos que já leram um valor mais antigo possam observar a mudança.

Cursor

Um cursor é um inteiro não negativo emitido pelo servidor. Ele identifica uma posição em um feed (owner, project). O cliente o trata como opaco: guarda-o, envia-o de volta como since e só espera que ele nunca ande para trás.

A implementação JSONL incluída usa um número de evento incremental. Uma implementação com banco de dados pode usar uma sequência. Um cursor não precisa ser um deslocamento em bytes, um timestamp nem o seq de um registro.

Os cursores pertencem a um feed e não podem ser reutilizados entre usuários, equipes ou projetos. O armazenamento dos feeds é estado durável. Se um servidor perder um feed enquanto um cliente mantém o cursor dele, o cliente pode ficar permanentemente à frente do novo feed; o protocolo v1 não tem handshake de reinício de cursor.

Endpoints de sessão

GET /v1/health

Endpoint de disponibilidade sem autenticação.

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

{"ok":true}

Isso prova que o processo e a rota estão acessíveis. Não prova que um token funciona, que o armazenamento persistente está montado nem que a memória está configurada.

GET /v1/projects

Lista os projetos que têm um feed para o dono selecionado.

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

Resposta:

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

Retorne um array vazio quando o dono não tiver feeds. Os IDs gerados pelos clientes atuais do OHarness seguem [A-Za-z0-9_-]{1,128} e devem ser retornados sem alterações. Um servidor pode rejeitar ou codificar de forma reversível outras entradas; nunca concatene um id não confiável em um caminho do sistema de arquivos.

O espelho por projeto atual não chama esta rota no seu loop normal de push/pull, mas ela faz parte do cliente público e é necessária para uma implementação completa e para a descoberta de projetos entre dispositivos.

POST /v1/push

Acrescenta atomicamente novos registros e os metadados de sessão vencedores.

{
  "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 e sessions são opcionais. Arrays ausentes significam arrays vazios. No mínimo project é obrigatório. Declarações de tipo mais antigas incluíam um device no nível superior; o cliente do OHarness não o envia, e servidores compatíveis não podem exigi-lo. A identidade do dispositivo fica em cada registro.

Resposta:

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

cursor é o fim atual do feed depois deste push. accepted conta os novos eventos de registro mais os eventos vencedores de metadados de sessão. Registros duplicados e metadados de sessão desatualizados/iguais não contam.

A requisição inteira pode ser repetida. Valide-a antes de efetivar qualquer entrada. Não aceite um lote parcial: retorne um erro para a requisição inteira se alguma entrada estiver malformada ou algum limite for ultrapassado.

Limites usados pelo cliente atual e pelo servidor incluído:

LimiteValor
Registros em um push500
Entradas de metadados de sessão em um push500
Corpo da requisição serializado8 MiB

Os dois limites de array são independentes: uma requisição pode conter 500 registros e 500 entradas de sessão.

GET /v1/pull

Lê os eventos do feed depois de um cursor.

GET /v1/pull?project=project-a&since=20&limit=500&wait=30&team=platform
QueryObrigatórioSignificado
projectsimId de projeto opaco
sincesim, no uso normalRetorna eventos estritamente posteriores a este cursor; 0 começa do início
limitnãoTamanho da página, limitado a 1..500; padrão 500
waitnãoSegundos de long polling, limitado a 0..30; padrão 0
teamnãoFeed da equipe em vez do feed do principal

Resposta:

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

Retorne os eventos na ordem do feed e sem campos exclusivos do servidor. O cursor é o último evento retornado. Se não houver eventos novos, ele continua igual a since. Defina more: true só quando outra página já estiver disponível; o cliente então faz um novo pull imediatamente.

Quando wait > 0 e o feed estiver em dia, segure a requisição até que um evento seja acrescentado ou a espera expire. Um timeout é uma resposta vazia de sucesso, não um erro HTTP. Desconectar um long poll não pode mudar o estado do feed.

Respostas de erro de sessão

Os erros de sessão são objetos JSON com uma string error curta e legível:

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

Use os códigos de status convencionais:

StatusQuando
400JSON inválido, projeto ausente, cursor inválido ou entrada malformada
401Bearer token ausente, expirado ou inválido; inclua WWW-Authenticate: Bearer
403O principal autenticado não é membro da equipe solicitada
413Os bytes da requisição ou o número de entradas ultrapassam o limite
404Rota desconhecida
500Falha inesperada de armazenamento; não afirme que um push parcial teve sucesso

O espelho repete erros de rede e falhas comuns do servidor com backoff. Um 401 para esse espelho até que ele seja reiniciado com uma credencial utilizável.

Sincronização de memória

A sincronização de memória é opcional e separada do feed de sessões somente de acréscimo. As três rotas são requisições POST autenticadas com Content-Type: application/json:

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

O cliente atual é só nativo. Uma requisição com cabeçalho Origin é rejeitada com 403 {"code":"native_client_required"}.

Identidade da memória

Toda requisição leva expectedPrincipal, tirado da conta do Cloud decodificada localmente, como uma fixação de consentimento. O servidor precisa validar o Bearer token de forma independente e exigir que o principal verificado corresponda a ele. Nunca autorize um prefixo de objeto só com base em expectedPrincipal.

Requisição:

{"expectedPrincipal":"user-id"}

Resposta:

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

A implementação incluída valida os tokens pelo GET /auth/v1/user do Supabase, mas uma implementação compatível pode usar outro serviço de contas. Ela precisa rejeitar contas anônimas, excluídas, expiradas ou inválidas de qualquer outra forma e retornar o mesmo id de principal estável que o cliente espera.

Escopo e formato das notas

O escopo de projeto é expresso por project, sem a propriedade scope:

{"project":"project-a"}

O escopo de usuário tem scope: "user" e não pode conter project:

{"scope":"user"}

O protocolo exige os dois escopos. O adaptador R2 incluído atualmente tem uma lacuna conhecida na lista de chaves permitidas para o prefixo de usuário; isso não muda o contrato do protocolo para implementações independentes.

Uma nota é:

{
  "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 é opcional e, quando presente, é um de user, feedback, project ou reference. updatedAt é um metadado opcional e fica deliberadamente fora do hash de conteúdo.

Os limites de validação fazem parte do contrato do protocolo:

CampoRestrição
Ids de principal, de projeto e de nota[A-Za-z0-9_-]{1,128}; rejeite também __proto__, constructor e prototype
summaryAparado, uma linha, 1–500 caracteres
bodyString aparada, no máximo o maxNoteBytes anunciado em UTF-8
tagsNo máximo 30; cada uma com 1–64 caracteres, sem espaço em branco, vírgula ou quebra de linha
createdAtInteiro seguro não negativo
updatedAtInteiro seguro não negativo opcional

Os ids de notas vivas são únicos sem diferenciar maiúsculas de minúsculas. Isso é necessário porque um servidor não pode criar um estado que os sistemas de arquivos comuns de clientes, que não diferenciam maiúsculas de minúsculas, não conseguem representar.

O hash SHA-256 da nota, em hexadecimal minúsculo, é calculado sobre JSON em UTF-8 com exatamente este array posicional:

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

Quando, e somente quando, type estiver presente, acrescente-o:

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

As implementações precisam reproduzir o JSON.stringify do JavaScript para esses valores. A exceção de compatibilidade preserva os hashes gerados antes da existência dos tipos de nota.

POST /v1/memory/snapshot

Requisição de projeto:

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

Para o escopo de usuário, substitua project por "scope":"user".

knownEtag permite um caminho rápido quando nada mudou. have mapeia ids de notas para os hashes que o cliente já tem; o servidor pode omitir os corpos dessas notas sem lê-los do armazenamento de objetos. Rejeite um mapa have com mais de maxNotes * 2 + 1000 chaves.

Resposta sem alterações:

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

Resposta completa ou incremental:

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

Uma entrada viva só pode omitir note quando have[id] for igual ao hash dela. Um tombstone tem hash: null e nenhuma nota. etag é null quando não existe manifesto.

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

Para o escopo de usuário, substitua project por "scope":"user". Uma criação/atualização precisa levar uma nota cujo id e hash calculado correspondam à alteração. Uma exclusão tem hash: null e nenhum note. changes precisa conter entre 1 e o maxChanges anunciado de entradas, sem ids duplicados.

Aplique o commit só quando etag ainda identificar o manifesto atual. Com um ETag desatualizado, retorne 409 {"code":"revision_conflict"} e deixe o manifesto publicado sem alterações. Grave as novas revisões imutáveis das notas antes de publicar o novo manifesto, e publique o manifesto atomicamente. Um escritor concorrente que perde pode deixar objetos imutáveis inacessíveis para uma coleta de lixo posterior, mas nenhum estado parcial pode ficar visível pelo manifesto. A resposta tem o mesmo formato de um snapshot bem-sucedido, com o novo ETag e as entradas atuais.

Não descarte as exclusões imediatamente. Mantenha os tombstones durante a janela anunciada para que clientes offline possam observá-los. Reduzir uma cota não pode prender um principal acima dela: rejeite o crescimento acima de maxNotes, mas continue permitindo exclusões.

Erros e limites da memória

Os erros de memória usam um code estável e legível por máquina:

{"code":"revision_conflict"}

Os códigos usados pela implementação atual incluem:

StatusCódigos
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

Os padrões incluídos são 5000 notas vivas por escopo, 500 alterações por commit, 128 KiB por corpo de nota, 4 MiB por requisição, 16 MiB de dados de entradas serializados por resposta e 90 dias para os tombstones. Só os quatro valores sobre os quais o cliente pode agir, mostrados em quota, são anunciados no protocolo. Um cliente também aplica os próprios tetos de segurança rígidos, então aumentar as cotas do servidor não garante que um cliente vá aceitar uma resposta arbitrariamente grande.

Configurar um cliente do OHarness

Coloque o destino na configuração do usuário, nunca na configuração de um repositório:

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

O arquivo é $OHARNESS_HOME/config.json quando OHARNESS_HOME está definido, $XDG_CONFIG_HOME/oharness/config.json no Linux quando o XDG está definido e ~/.oharness/config.json nos demais casos. Reinicie o OHarness depois de mudar o destino.

A precedência atual é:

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

Consequentemente, OHARNESS_SYNC_URL só substitui o padrão fixo no código quando a configuração do usuário não tem sync.url. OHARNESS_SYNC=0 desativa a sincronização de sessões independentemente do destino ou da credencial.

Os arquivos oharness.config.json e .oharness.json de um repositório não podem definir sync.url, sync.token nem sync.team; o OHarness descarta esses valores. Um repositório só pode desligar a sincronização ou adicionar exclusões. Isso impede que um repositório clonado redirecione as transcrições de um usuário.

A mesma URL base resolvida é usada para a memória, mas o cliente de memória atual usa a credencial da conta do Cloud, e não sync.token nem OHARNESS_SYNC_TOKEN. Para auto-hospedar a memória, portanto, o login do cliente e o verificador de identidade do servidor de memória precisam concordar sobre o mesmo principal.

Checklist de implementação

Um serviço compatível deve passar em todos estes comportamentos:

  • O health funciona sem token; toda rota de dados falha fechado sem um.
  • Dois principais usando o mesmo id de projeto não conseguem ver os dados um do outro.
  • Os feeds pessoais e de equipe são distintos; equipes não autorizadas retornam 403.
  • Repetir um push não duplica registros nem metadados de sessão desatualizados.
  • O pull é ordenado, paginado, com cursor monotônico e seguro para long polling.
  • Os corpos de sessão sobrevivem a um push/pull sem alterações no nível do valor JSON.
  • As exclusões de sessão e as exclusões de memória chegam a um segundo dispositivo offline.
  • Um ETag de memória desatualizado não publica nenhuma alteração no manifesto.
  • As chaves de objeto da memória sempre usam o principal verificado pelo servidor.
  • Divergências de hash, colisões de ids de nota que diferem só em maiúsculas e minúsculas e requisições grandes demais são rejeitadas antes de publicar um manifesto.
  • O estado persistente dos feeds e do manifesto sobrevive a reinícios do processo e a deploys.

Os testes de sessão do repositório em test/sync.test.ts e os testes de memória em script/sync-memory.test.ts são exemplos executáveis do contrato. Hoje eles instanciam o servidor incluído diretamente, em vez de aceitar uma URL remota, então outra implementação deve portar os mesmos casos ou fornecer um pequeno adaptador antes de declarar compatibilidade.

Para um primeiro teste de fumaça remoto, emita um token descartável e substitua o valor dele abaixo. Não use um token de conta de produção no histórico do 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"

Envie o mesmo corpo uma segunda vez e verifique se accepted é 0; depois faça um pull a partir do cursor retornado e verifique se os arrays estão vazios e o cursor não mudou. Essas verificações estabelecem a acessibilidade e a idempotência básica, não o isolamento entre tenants nem a segurança do CAS concorrente.

O que não é exigido

Um servidor compatível não precisa:

  • rodar um modelo, executar ferramentas nem entender os formatos de mensagem das transcrições;
  • usar JSONL, R2, Supabase ou qualquer banco de dados específico;
  • oferecer cadastro, login, emissão de tokens nem um aplicativo de navegador;
  • implementar a memória para implementar a sincronização de sessões;
  • expor CORS. Os clientes atuais são clientes nativos Node/Electron.

Manter essas questões fora do protocolo é o que permite que um pequeno serviço auto-hospedado e um deploy multi-tenant falem o mesmo formato de protocolo.