sync: shared notes in the feed, revocations, and text pushes from an edit share
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 54s

The change feed answers `?shares=1` with every note the caller can see, each
saying how it is held, plus a `revoked` list of notes that left them. Granting
a share moves the note past the recipient's cursor; ending one, or the owner
deleting the note, leaves a revocation on the same cursor. A recipient at edit
may push the note's text, and nothing else. Protocol 6, feature `shares`;
opt-in, so the floor stays at 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:25:13 -04:00
co-authored by Claude Opus 5.5
parent 2e2d8667dd
commit 75928c7afd
10 changed files with 407 additions and 46 deletions
+40
View File
@@ -64,6 +64,9 @@ syncs everything else.
- v5 (#5168): attachments became a sync entity — an upload route keyed by the
client's id, and `attachment`/`preview` deletes in push. Additive, so it is
the `attachment_sync` feature and the floor stays.
- v6 (#5175): shared notes — see "Shared notes" below. Opt-in per request
(`changes?shares=1`), so it is the `shares` feature and the floor stays: a v5
client never asks and gets exactly its own notes.
- **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
@@ -196,6 +199,37 @@ boundaries, so nothing between `cursor` and the next pull is skipped. Loop while
Note attachment metadata carries `{id, url, mime, size, sha256}`.
### Shared notes (`shares`, v6)
With `?shares=1` the feed carries every note the caller can **see**: their own, plus
those shared with them directly or through a group. Each note then also says how
it is held:
```json
{ "permission": "owner" | "edit" | "view",
"shared": true,
"shared_by": { "id": "...", "display_name": "..." } }
```
`shared_by` is null on the caller's own notes, and `shared` says whether they have
shared it with anyone. A note shared with the caller comes with `labels: []`:
labels are personal, and a recipient files nothing under the owner's tags.
Granting or changing a share moves the note to a new revision (without touching
`updated_at`, so last-write-wins is unaffected), which is how it passes a
recipient's cursor. Ending a share — or the owner purging the note — adds the note
to the recipient's `revoked` list:
```json
{ "notes": [ ... ], "labels": [ ... ], "revoked": ["<note id>", ...],
"cursor": 51, "has_more": false }
```
A client deletes its copy of each revoked note. Revocations draw from the same
sequence and page on the same cursor as notes and labels (three streams now; the
smallest full boundary wins). Sharing with the person again deletes their
revocation, so a device that never saw it is never told to drop a note it has.
## Push — `POST /api/sync/push`
Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
@@ -233,6 +267,12 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
the same as one that never existed. Removals are explicit rather than "the
note's current attachment set" on purpose: push runs before pull, so a set
would delete an attachment another device added that this one has not seen.
- **A note someone else owns** (`shares`): a recipient at `edit` may push an upsert
and only its `body` is applied, under the same last-write-wins — every other
field is ignored, since pinning, archiving, reminders, labels and deleting stay
the owner's. A `view` recipient's change, or any delete, is `rejected` ("only its
owner can change that"); a note the caller can't see at all answers the generic
"cannot apply".
### Conflict resolution — last-write-wins + history