Skip to content

KB Sync — v1 vs v2

The Picora KB sync protocol got an upgrade in v0.35.0 (released 2026-08-22). This page explains what changed, why, and how to migrate your client. The v1 protocol continues to work — sunset is 2027-03-01.

TL;DR

Capabilityv1v2
Full manifest fetch
Incremental manifest (?since=<cursor>)
Tombstones (cross-client delete propagation)✅ (include=tombstones)
Optimistic locking (sourceHash enforced)⚠️ optional✅ required on update/move/rename
Conflict-preserving sync (preserve_both)
Sync ops max per batch100200

Opting into v2

Send the Picora-Sync-Version: 2 request header on /v1/kbs/:id/manifest and /v1/kbs/:id/sync. That’s it — same URLs, same body shape (with sourceHash now required where listed above).

GET /v1/kbs/V1StGXR8_Z5jdHi6B-myT/manifest?since=eyJ0cyI6...&include=tombstones
Authorization: Bearer <token>
Picora-Sync-Version: 2

For environments that can’t set custom headers, use ?syncVersion=2 query parameter instead. The header takes precedence when both are set.

Cursor-based incremental manifest

The big win in v2 is incremental sync. v1 always returns up to 1000 docs / 5 MB at once; large KBs paginate. v2 lets a client pass the cursor it received last time and get only the changes since:

GET /v1/kbs/<id>/manifest?since=<cursor>&include=tombstones

The cursor is base64url-encoded {"ts": "2026-08-23T10:00:00.000Z", "id": "doc_abc..."}. The server uses (updated_at ASC, id ASC) ordering with (updated_at, id) > cursor semantics, so same-millisecond batch commits don’t lose or duplicate rows.

First sync: omit since. The server returns the full set in pages, just like v1.

Tombstones

Deleting a doc on one client used to invisible to others — they’d keep showing the doc until they did a full re-sync. v2 fixes this with tombstones:

  • Every delete (soft or hard) writes a row to kb_doc_tombstones.
  • v2 manifest with include=tombstones returns tombstones since the cursor.
  • Tombstones are kept for 30 days (Trial / Pro) or 90 days (Pro+).
{
"tombstones": [
{
"docId": "old_doc_xyz",
"relativePath": "drafts/deprecated.md",
"sourceHash": "fa7c…",
"deletedAt": "2026-08-22T08:00:00.000Z"
}
]
}

When the client sees a tombstone, it should delete the matching local file. Optionally call op: "tombstone_ack" in the next sync to confirm — Picora uses these acks for analytics, not correctness.

Long-offline clients

If a client returns after 30+ days offline (Pro+: 90+ days), some tombstones may have been pruned. The server detects since < tombstoneRetentionBoundary and responds with:

{
"tombstoneCursorExpired": true,
"retentionDays": 30,
"hint": "Re-sync from scratch."
}

The client should drop its local cursor and call manifest without since to get a fresh baseline.

Required sourceHash

In v1, update ops could omit sourceHash and the server would happily overwrite with last-write-wins. v2 makes the hash mandatory on update, move, and rename:

{
"ops": [
{ "op": "update", "docId": "...", "content": "...", "sourceHash": "abc123…" }
]
}

Missing it returns 422 SYNC_HASH_REQUIRED. Computing the hash is straightforward:

const sha256 = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(content))
const hex = Array.from(new Uint8Array(sha256))
.map(b => b.toString(16).padStart(2, '0')).join('')

preserve_both conflict resolution

When the server’s current sourceHash differs from the client’s sourceHash, the default behavior is 409 SYNC_CONFLICT — the op is rejected, master is unchanged. Pass ?conflictResolution=preserve_both to keep both versions:

POST /v1/kbs/<id>/sync?conflictResolution=preserve_both

The server does not overwrite master. Instead it writes a row to kb_doc_conflict_branches and returns:

{
"results": [
{ "opIndex": 0, "status": "applied", "docId": "..." },
{
"opIndex": 1,
"status": "conflict_branch_created",
"docId": "doc_b",
"branchId": "br_xK9...",
"currentMasterHash": "xyz",
"currentMasterUpdatedAt": "2026-08-23T..."
}
]
}

The user resolves branches later in picora-center → KB → Conflicts:

  • Adopt branch → branch becomes master (POST /v1/kbs/:id/conflicts/:branchId/accept)
  • Discard branch → permanent (DELETE /v1/kbs/:id/conflicts/:branchId)

Branch quotas

LimitValue (Trial / Pro / Pro+)
Pending branches per document5
Pending branches per user (global)100
Max single branch size10 MB / 20 MB / 50 MB
Auto-discard pending branches after30 days (email user 7 days before)

Picora allows up to 20% short-term quota overage when writing a branch — your master + branches together can briefly exceed plan limits while the user decides. After 14 days, unresolved branches are force-discarded to release the overage.

Sunset of v1

DateBehavior
2026-08-22v0.35 ships. v2 is opt-in via header; v1 unchanged.
2026-09-01v1 responses include Sunset: 2027-03-01 and Deprecation: true.
2027-02-15Final email reminder to known v1 callers.
2027-03-01v1 endpoints return 410 SYNC_V1_SUNSET with link to this guide.

If you maintain a third-party client and need help migrating, file an issue at github.com/picora.

Error reference

HTTPCodeWhen
400INVALID_SYNC_VERSIONHeader value is non-numeric or 0
400SYNC_VERSION_UNSUPPORTEDHeader declares v3+ (server’s max is 2)
400INVALID_CURSORCursor decode failed / wrong shape
409SYNC_CONFLICTsourceHash mismatch in default mode
409SYNC_VERSION_MISMATCHHeader v2 + body looks like v1 (missing sourceHash)
410SYNC_V1_SUNSETAfter 2027-03-01, v1 endpoints
410TOMBSTONE_CURSOR_EXPIREDsince cursor older than tombstone retention
422SYNC_HASH_REQUIREDv2 update/move/rename missing sourceHash
422CONFLICT_BRANCH_LIMIT_*Per-doc / per-user / size limit hit
422INVALID_OP_BATCH_SIZEMore than 200 ops in one sync request

Migrating an existing v1 client (checklist)

  1. Update your sync URL and add Picora-Sync-Version: 2.
  2. Add a content hash function and pass sourceHash on update / move / rename.
  3. Cache the cursor returned by manifest and pass it as since next time.
  4. Process the tombstones array — delete locally, optionally call tombstone_ack for telemetry.
  5. Decide your conflict policy: stick with the default 409, or use ?conflictResolution=preserve_both if your UX can show pending branches.
  6. Handle the new error codes (SYNC_HASH_REQUIRED, TOMBSTONE_CURSOR_EXPIRED).

A reference implementation lives in @picora/sdk@0.2.0 (TBD); check @picora/sdk reference once available.