Sync protocol
The sync protocol is a simple incremental pull plus a conflict-aware push. The server is the source of truth; the client carries a per-device cursor and reconciles based on UpdatedAt timestamps.
Endpoints
Section titled “Endpoints”| Method | Path | Purpose |
|---|---|---|
GET |
/api/sync/pull?since={ISO8601} |
Incremental pull of records updated since the given timestamp |
POST |
/api/sync/push |
Push client changes; receive server-assigned IDs and conflicts |
GET |
/api/sync/status |
Current sync cursor, last successful sync, queue depth |
GET |
/api/sync/events |
Audit trail of recent sync activity |
GET |
/api/sync/quota |
Current usage vs. caps |
All require a JWT. The sync rate-limit policy (30 req/min per user) is applied to push and pull.
The client sends its last-known sync timestamp and receives a SyncPullResponse:
{ "templates": [...], "providers": [...], "sessions": [...], "memories": [...], "todos": [...], "settings": { ... }, "serverTime": "2026-05-03T12:34:56Z"}The client persists serverTime as its new cursor. If since is omitted (first sync), the response contains the full set of records the user owns.
The managedPersonas channel
Section titled “The managedPersonas channel”The response also carries managedPersonas, which delivers managed personas to every member of an assigned group:
{ "managedPersonas": { "personas": [...], "recentlyRemoved": [...] }}It behaves unlike every other channel, and deliberately does not share their { upserted, deleted } shape:
- Replace-all, not merge.
personasis the complete set the caller may currently see. A client that merged it would keep a persona after its group lost access, because unassignment carries no tombstone at all. Absence is the signal — which is also what makes the channel self-healing for a client that was offline past any retention window. - Pull-only. There is no push path. A managed persona id arriving on
/api/sync/pushis quarantined rather than applied, so a client cannot edit or resurrect one. - Plaintext. Rows carry no
encryptedPayloadorwrappedDek, even for an E2EE-enabled account. A client must not route them through its decrypt path or count them in decrypt-error stats. isManagedis alwaystrueon the wire, so provenance survives a merge into the client’s local persona store instead of being inferred from which channel the row arrived on.
recentlyRemoved is a courtesy for the UI: it is what lets a client tell a user by name that the persona they had selected was withdrawn. A soft-deleted persona rides that list for 90 days and is then dropped from the projection — past that window, removal is conveyed by absence, which is the mechanism anyway.
The clientPolicy channel
Section titled “The clientPolicy channel”The same catalog block carries clientPolicy, the enterprise-policy document an admin published to the caller’s group:
{ "clientPolicy": { "document": "{\"enforce\":{\"assistantFileToolsEnabled\":false}}", "updatedAt": "2026-08-19T09:14:02Z" }}Server-side the document is a catalog row and the group holds only a reference to it, so one document can serve many groups. None of that reaches the wire: the pull carries the resolved document and no policy id or name, and a group pointing at a deleted policy resolves to {} — never to an omitted key.
It shares the managed-persona channel’s rules — replace-all, pull-only, plaintext — with two of its own:
- The document is a string, not parsed sections. It is stored and forwarded byte for byte, because the desktop client owns the settings schema and needs to know which keys are present, not merely which values parse. Its own conventions also differ from the rest of the wire: enum values are spelled as strings there and as integers everywhere else.
{}is an authoritative answer, not a missing one: it means this group has no policy, and a client must drop whatever it cached. Keeping the cache is only correct when the key is absent, which is what the catalog fast-skip below produces.
Catalog gating
Section titled “Catalog gating”Both catalog channels are loaded with no SyncedAt filter — since has no effect on them — and are skipped wholesale when the client echoes a catalogVersion equal to the server’s current one. A skipped catalog omits both keys entirely, which is the signal to keep the local copies.
The catalogVersion token folds in the caller’s group. Treat it as opaque — it is not monotonic, and two of them must never be compared. Because the group is folded in, moving one user between groups invalidates that user’s view alone rather than every client on the instance.
A group’s own id does not change when it is re-pointed at another client policy, so that one write bumps the global catalog counter instead. Editing a policy in the catalog does the same. Every other change to a group — a rename, a quota, a token limit — deliberately does not: it is invisible to both catalog channels, and bumping would make every client on the instance pay a no-op catalog pull.
The client batches local changes into a SyncPushRequest and sends them in one shot:
{ "templates": [...], "providers": [...], "sessions": [...], "memories": [...], "todos": [...], "settings": { ... }}The server applies validation (16 unique rules — max field lengths, required fields, format checks), then walks the request:
- Quota check —
QuotaService.CheckQuotaAsyncaborts the entire push with409 quota_exceededif any cap would be violated. - Conflict resolution —
ConflictResolvercompares serverUpdatedAtwith the client-provided value:- Default: last-write-wins (later timestamp wins).
- Server-wins: applied to auth-sensitive data.
- Persist — successful records are written inside a single EF transaction.
The SyncPushResponse contains:
- Server-assigned IDs for new records.
- A
conflictsarray describing per-record decisions (so the client can update its local view). - A new
serverTimecursor.
Validation failures
Section titled “Validation failures”A push that fails validation returns 400 with an errors array; nothing is persisted. The client should surface the errors and retry after the user fixes them.
Cursors
Section titled “Cursors”SyncCursor rows are per-device, not per-user. This lets a single user run multiple devices that move at independent speeds without one device’s late writes pulling another back. The DeviceId is read from the X-Pia-Device-Id header, populated by the desktop client.