M10.6: client↔server sync protocol handshake (task 1995)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 42s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m34s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 42s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m34s
Version the sync WIRE PROTOCOL separately from either program's release
version, so a self-hosted server and the desktop app can sit on different
releases and still work out whether they can talk.
Each side declares two numbers — what it speaks, and the oldest counterpart
it accepts. Either side can therefore mark a change breaking without the
other shipping in step, which is the whole point: no app↔server lockstep.
Server advertises on the existing public /api/config (a client must be able
to ask "can I talk to you?" before it holds a device token, or even has an
account): sync_protocol_version, min_client_protocol_version, sync_features.
sync_features exists because a version number can only say newer/older. An
ADDITIVE change earns a capability name instead of a minimum bump, so a
newer client meeting an older server drops that one feature and syncs the
rest, rather than refusing. Raising a minimum is reserved for genuinely
breaking changes — it's the switch that hard-blocks the other side.
Client half is pure decision logic (sync/compat.rs), no I/O, so every branch
is unit-testable — there's no live-server lane in CI. Three outcomes: ok /
degraded{unavailable} / incompatible{reason, client_must_update}. The last
names which side can fix it, so the message is actionable. A server that
predates the handshake sends no protocol fields at all; that reads as
"update the server", deliberately not as a parse error, which would look to
the user like they mistyped the URL.
normalize_base_url defaults a bare host to https://, never http:// —
silently downgrading would put a long-lived device token on the wire in
cleartext because someone omitted five characters. Plain HTTP on a trusted
LAN stays supported; the user types http:// and thereby chooses it.
Transport (the actual fetch) lands next, separately: it needs an HTTP/TLS
stack, and that's a real risk to the Windows cross-compile lane, so it gets
its own CI run to bisect against rather than riding along with this.
No UI here by design — the link/settings surface it feeds is M10.7's, per
this task's own sequencing.
Policy documented in docs/sync.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
This commit is contained in:
@@ -13,6 +13,67 @@ All sync endpoints live under `/api/sync`. Everything is **owner-scoped** and
|
||||
> operator on deploy, not in CI. Pure logic (LWW comparator, paging cursor,
|
||||
> token hashing) is unit-tested.
|
||||
|
||||
## Protocol versioning — the compatibility handshake
|
||||
|
||||
Clients and servers update on their own schedules; a self-hosted server can sit on
|
||||
an older release than the desktop app for months. So the wire protocol is
|
||||
versioned **separately from either program's release version**, and each side
|
||||
declares two numbers: what it speaks, and the oldest counterpart it accepts.
|
||||
|
||||
| | server (`src/thoughtsync/sync.py`) | client (`desktop/src-tauri/src/sync/compat.rs`) |
|
||||
|---|---|---|
|
||||
| speaks | `SYNC_PROTOCOL_VERSION` | `CLIENT_PROTOCOL_VERSION` |
|
||||
| accepts down to | `MIN_CLIENT_PROTOCOL_VERSION` | `MIN_SERVER_PROTOCOL_VERSION` |
|
||||
|
||||
The server publishes its half on the **public, unauthenticated** `GET /api/config`
|
||||
— a client must be able to ask "can I talk to you?" before it holds a device
|
||||
token, or even has an account:
|
||||
|
||||
```json
|
||||
{ "site_name": "...", "version": "0.1.0",
|
||||
"sync_protocol_version": 1,
|
||||
"min_client_protocol_version": 1,
|
||||
"sync_features": ["notes", "labels", "attachments", "tombstones", "revisions"] }
|
||||
```
|
||||
|
||||
The client identifies itself on every request with
|
||||
`X-ThoughtSync-Client: thoughtsync-desktop/<app version>` and
|
||||
`X-ThoughtSync-Protocol: <n>`.
|
||||
|
||||
### `sync_features` — why versions alone aren't enough
|
||||
|
||||
A version number can only say "newer" or "older". `sync_features` names
|
||||
capabilities, so a client tests for the one it needs instead of inferring it from
|
||||
a number. That is what keeps an **additive** change from forcing a lockstep
|
||||
upgrade: a newer client meeting an older server drops the missing feature and
|
||||
syncs everything else.
|
||||
|
||||
### The policy
|
||||
|
||||
- **Any wire change** → bump `SYNC_PROTOCOL_VERSION`.
|
||||
- **Additive change** (a new field, a new capability) → add a `sync_features`
|
||||
name. Do **not** raise a minimum. Old clients keep working.
|
||||
- **Breaking change only** → raise `MIN_CLIENT_PROTOCOL_VERSION` (or the client's
|
||||
`MIN_SERVER_PROTOCOL_VERSION`). This is the switch that hard-blocks the other
|
||||
side, so it is the one to be stingy with.
|
||||
- Never gate behavior on the *release* version (`version`) — it's for display.
|
||||
|
||||
### The three outcomes
|
||||
|
||||
The client evaluates the advertisement (`compat::evaluate`) and gets exactly one
|
||||
of:
|
||||
|
||||
- **ok** — full parity; sync everything.
|
||||
- **degraded** — safe to sync, but named capabilities are unavailable here; the UI
|
||||
says which.
|
||||
- **incompatible** — do not sync. Carries `client_must_update` so the message can
|
||||
point at the side that can actually fix it, rather than just saying
|
||||
"incompatible".
|
||||
|
||||
A server that predates this handshake sends no protocol fields at all. That is
|
||||
treated as **incompatible (update the server)** — deliberately not as a parse
|
||||
error, which would look to the user like they mistyped the URL.
|
||||
|
||||
## Authentication — device bearer tokens
|
||||
|
||||
Native clients authenticate with a long-lived **device token**, not a session
|
||||
|
||||
Reference in New Issue
Block a user