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.
Additions in v0.68.0
Three request-frequency / cost optimizations landed for high-traffic clients (e.g. Moraya Web). None break v2 — they are additive.
op: 'move' — rename / move a path, keeping docId stable
Previously a rename meant “create new + delete old”, which changed the docId and broke server-side references, share links, and history. The new move op renames in place:
// POST /v1/kbs/:id/sync (one op inside the ops[] array){ "op": "move", "fromPath": "notes/idea.md", "toPath": "archive/idea.md", "baseUpdatedAt": "2026-06-01T10:00:00.000Z" }- Pure path change — no
content, nosourceHash(content is unchanged;baseUpdatedAtis the optimistic-lock check, same asupsert/delete). docIdis preserved — the applied item returns the sameid, withrelativePath = toPathandfromPathechoed back.- The server also writes a tombstone at
fromPath, so incremental / path-keyed clients drop the old path locally and pick up the new one.
Applied item shape:
{ "op": "move", "id": "doc_abc...", "relativePath": "archive/idea.md", "fromPath": "notes/idea.md", "updatedAt": "...", "sourceHash": "..." }New conflict reasons: MOVE_SOURCE_MISSING (no active doc at fromPath) and MOVE_TARGET_EXISTS (toPath already occupied). REMOTE_NEWER / REMOTE_DELETED / BASE_MISSING apply as usual.
Conditional manifest — ETag / If-None-Match → 304
GET /v1/kbs/:id/manifest now returns a weak ETag computed from the KB’s high-water-mark plus your request parameters (since / cursor / limit / protocol version). Send it back as If-None-Match; if nothing changed, you get an empty 304 Not Modified:
GET /v1/kbs/<id>/manifest?since=<iso>Authorization: Bearer <token>If-None-Match: W/"3f9c1a2b4d5e6f708192a3b4c5d6e7f8"
→ 304 Not Modified (KB unchanged; near-zero cost for "no changes" polling)Because the ETag is bound to your exact query, two clients with different since values never share a cache entry.
Batch content fetch — POST /v1/docs/raw:batch
Fetch up to 50 documents’ raw Markdown in a single round-trip, collapsing cold-start preview from N requests to 1:
// POST /v1/docs/raw:batch (Bearer optional — own + public with a token, public-only without){ "ids": ["doc_a...", "doc_b...", "doc_c..."] }
// 200{ "success": true, "data": { "docs": [{ "id": "doc_a...", "content": "# ...", "updatedAt": "...", "sourceHash": "..." }], "failed": [{ "id": "doc_c...", "reason": "NOT_FOUND" }]}}docs[] preserves input order. Not-found / unauthorized / soft-deleted ids fold into failed[] as NOT_FOUND (existence is not leaked); a content-read failure is CONTENT_FETCH_FAILED. Read-only — no upload quota consumed.
Incremental ?since= is already available
The cursor / ?since=<ISO> incremental manifest described above has shipped since v2 — a client that stores the response serverTime and passes it as the next since only receives updatedAt/deletedAt > since entries plus tombstones. No new endpoint is needed for incremental sync; combine it with the ETag above to make “no changes” polling free.
Recommended client loop (fastest “did it change, then pull only the delta”):
- First load —
GET /v1/kbs/:id/manifest(nosince) → full active manifest, paginated (limit≤ 1000, follownextCursor). Persist the responseserverTimeas your local watermark. - On each sync / focus —
GET /v1/kbs/:id/manifest?since=<watermark>withIf-None-Match: <last ETag>:304 Not Modified→ nothing changed, stop (near-zero cost).200withitems[](+tombstones[]underPicora-Sync-Version: 2) → these are only the docs changed since your watermark. Fetch their bodies withPOST /v1/docs/raw:batchorGET /v1/kbs/:id/raw?path=. Advance your watermark to the newserverTime.
This is the whole “check-then-delta” flow in one endpoint — the since param + ETag together do both the change-detection and the incremental fetch. Don’t poll GET /v1/docs?kbId= for sync (it has no since filter; it’s for browse UIs, not sync clients).
Coarse per-KB signal:
GET /v1/kbsreturns each KB’supdatedAt, which bumps on any child-doc add / content-replace / delete and in-KB rename (rename fixed in v0.74.x). Use it only as a cheap “which KBs to check” hint; the manifest ETag/watermark remains the authoritative per-doc delta source.
Note (
GET /v1/kbs/:id/manifestrequires an active plan): the manifest/sync endpoints are plan-gated (see the plan-gate policy). Browsing an existing KB’s document list (GET /v1/docs?kbId=) stays available after a plan expires, but incremental sync via the manifest requires an active subscription.
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.