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
| Capability | v1 | v2 |
|---|---|---|
| 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 batch | 100 | 200 |
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=tombstonesAuthorization: Bearer <token>Picora-Sync-Version: 2For 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=tombstonesThe 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=tombstonesreturns 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_bothThe 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
| Limit | Value (Trial / Pro / Pro+) |
|---|---|
| Pending branches per document | 5 |
| Pending branches per user (global) | 100 |
| Max single branch size | 10 MB / 20 MB / 50 MB |
| Auto-discard pending branches after | 30 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
| Date | Behavior |
|---|---|
| 2026-08-22 | v0.35 ships. v2 is opt-in via header; v1 unchanged. |
| 2026-09-01 | v1 responses include Sunset: 2027-03-01 and Deprecation: true. |
| 2027-02-15 | Final email reminder to known v1 callers. |
| 2027-03-01 | v1 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
| HTTP | Code | When |
|---|---|---|
| 400 | INVALID_SYNC_VERSION | Header value is non-numeric or 0 |
| 400 | SYNC_VERSION_UNSUPPORTED | Header declares v3+ (server’s max is 2) |
| 400 | INVALID_CURSOR | Cursor decode failed / wrong shape |
| 409 | SYNC_CONFLICT | sourceHash mismatch in default mode |
| 409 | SYNC_VERSION_MISMATCH | Header v2 + body looks like v1 (missing sourceHash) |
| 410 | SYNC_V1_SUNSET | After 2027-03-01, v1 endpoints |
| 410 | TOMBSTONE_CURSOR_EXPIRED | since cursor older than tombstone retention |
| 422 | SYNC_HASH_REQUIRED | v2 update/move/rename missing sourceHash |
| 422 | CONFLICT_BRANCH_LIMIT_* | Per-doc / per-user / size limit hit |
| 422 | INVALID_OP_BATCH_SIZE | More than 200 ops in one sync request |
Migrating an existing v1 client (checklist)
- Update your sync URL and add
Picora-Sync-Version: 2. - Add a content hash function and pass
sourceHashonupdate/move/rename. - Cache the cursor returned by
manifestand pass it assincenext time. - Process the
tombstonesarray — delete locally, optionally calltombstone_ackfor telemetry. - Decide your conflict policy: stick with the default 409, or use
?conflictResolution=preserve_bothif your UX can show pending branches. - 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.