feat(sharing,sync): shared address books with owner and rights; sync_contacts over sync-collection (v0.4.0) #40

Merged
jlxq0 merged 3 commits from feat/t_8eb101f3-shared-sync into main 2026-10-24 05:06:42 +00:00
Owner

Card t_8eb101f3 (Julian approved shared address books and incremental sync directly; per-agent identity, CalDAV, dependency migrations and any tool that changes other users' access are out of scope). Follows v0.3.0 (#39).

Why

Address books other accounts share with the caller were listed with no owner or rights, discovery broke on more than one addressbook-home-set href, and a client that wanted to stay current had to re-page the whole book.

What

  • Shared address books (read side). list_address_books returns the caller's own books and the ones shared with them, each with owner, shared, rights (owner, read-write, read, custom, none, classified from the server's current-user-privilege-set as Stalwart spells it) and the raw privileges; own books first. Every home-set href is read, so discovery works with assisted discovery off. Contact tools take shared hrefs unchanged; Stalwart's ACL decides, a refused write is a 403 that now names the rights field, and nothing changes. No share-management tool, on purpose: this server acts as whoever presented the bearer, and an agent holding the owner's session must not be able to widen access. The unmerged feat/delegate-sharing branch (#33) carried both halves; only its read side is ported here (reworked onto the quick-xml parser, no new dependency).
  • sync_contacts(addressbook_href, sync_token, limit, version) (src/contact_sync.rs): RFC 6578 sync-collection, etags only, bodies via addressbook-multiget per page. Without a token: every card (initial pass). With one: cards added or changed, hrefs deleted, up to limit (max 100) entries per call, complete, changes, initial, missing. The token is opaque, bound to the book and refused before any request when not issued here or issued for another book, with instructions to start over; a report the server truncates (507 number-of-matches-within-limits) is refused the same way, never paged. Stalwart's own limit is not sent: read from its source, a limited report is paged by offset = limit * seq over a set that moves under concurrent edits and a limited pass is stamped with its last page's state, so a low-id card changed mid-pass is lost; a limited initial report is a truncated list under a whole-book token. Here the next pass is anchored to the token of the pass's first report, so a change made during a multi-page pass is never lost; it may be delivered twice, which applying by href absorbs.
  • Fixed: the two wrong schema descriptions (version, given_name); concatenated home-set hrefs in discovery.
  • src/fake_stalwart.rs (tests only): discovery, home listing with owner/privileges, read-only books (403 on PUT/DELETE), a change log and sync-collection with Stalwart's token shape and quirks (unknown token answered as initial; sync_max_results → 507; the collection's own response).
  • Docs: README, CHANGELOG 0.4.0, docs/sharing-and-sync.md (contract, no-loss argument, permission boundaries, client loop), AGENTS.md scope and seven pitfalls (stale tool schema on old mounts; never send limit on a Stalwart sync; unknown token = initial report; tombstones have no propstat; AddItems has no privilege spelling; no share tool here; the DAV change log is not purged by default).

Evidence

  • 237 tests (211 before): 250-card initial pass in three bounded pages then an empty delta; a delta with additions, an update (new ETag), a tombstone and a delete-then-recreate; edits ahead of, behind and within the page between every page of a three-page delta with the mirror equal to the server after the next pass; token for another book or tampered refused before any request (request log empty); truncated report refused with guidance; member deleted between report and fetch is missing now and a tombstone next; another account's book refused without ending the session or leaking; @/%40 spellings; the unknown-token-is-initial Stalwart behaviour pinned; listing with owner/rights/order; assisted discovery off with three home hrefs listing each book once; encoded owner href; writes to a read-only share refused by the server with no PUT/DELETE recorded and the ETag unchanged; parser units for owner, privileges, response-level status, truncation and sync-token; rights table; schema shapes.
  • 24 mutations, each killed by at least one named test: snapshot taken from the last page, book check disabled, tombstones dropped, truncated report paged, after inclusive, initial constant, missing constant, status read from propstat, read-write requiring write, shared constant, first home only, books not deduped, collection counted as a member, all not owner; after the review: sibling tombstone counted as a wrong book, sibling tombstone delivered, probe skipped, probe on every page, behind-the-token check off, listing truncation ignored, secondary-home error propagated, secondary home not evicted, homes not deduped, sync bodies matched by raw href.
  • fmt, clippy pedantic on 1.93 and 1.99, cargo test --locked, cargo audit, cargo deny check, gitleaks: clean.
  • Independent review before this PR (fresh agent, read-only, against the diff and the Stalwart source at aa47d8e): round 1 MUST-FIX on one item, fixed in c12234b: Stalwart reports the account's deleted paths for every address book in one book's sync-collection report, and the first draft counted a sibling's tombstone as a member outside the book, refusing a quiet delta with a false "href does not name the book" error (the fake did not emulate it; 232 tests were green either way). Also from round 1: every pass now starts with a PROPFIND probe of the href, because Stalwart answers a sync-collection on an unshared book inside an account that shares another one with an empty 207 rather than a refusal (read from the source; to be confirmed live in AC4); list_contacts refuses a listing Stalwart cut at max-results instead of returning it as the whole book; a revoked secondary home no longer fails the caller's own listing for the cache hour; a report whose Stalwart token id is behind the id asked about is refused with resync guidance; seven nits. Round 2 (confirmation): PASS, every finding closed, two wording nits fixed in ebce484. The reviewer's unverified items for AC4: the 404-vs-403 shape of the probe on an unreadable book, and highest_change_id monotonicity across a cache rebuild. Follow-up outside this card: search_contacts and upcoming_birthdays go through the same max-results cut silently (pre-existing).

Live proof (AC4) follows Clark's rollout, with fixture permissions Clark provisions on a disposable account: initial sync, create/update/delete and continued sync on a read-write share, read-only refusal, revoke and denied readback, conditional cleanup and absence; no real contact is written.

🤖 Generated with Claude Code

https://claude.ai/code/session_016ZfeS2nAr54h4afQ8ShfVE

Card t_8eb101f3 (Julian approved shared address books and incremental sync directly; per-agent identity, CalDAV, dependency migrations and any tool that changes other users' access are out of scope). Follows v0.3.0 (#39). ## Why Address books other accounts share with the caller were listed with no owner or rights, discovery broke on more than one `addressbook-home-set` href, and a client that wanted to stay current had to re-page the whole book. ## What - **Shared address books (read side).** `list_address_books` returns the caller's own books and the ones shared with them, each with `owner`, `shared`, `rights` (`owner`, `read-write`, `read`, `custom`, `none`, classified from the server's `current-user-privilege-set` as Stalwart spells it) and the raw `privileges`; own books first. Every home-set href is read, so discovery works with assisted discovery off. Contact tools take shared hrefs unchanged; Stalwart's ACL decides, a refused write is a 403 that now names the `rights` field, and nothing changes. **No share-management tool**, on purpose: this server acts as whoever presented the bearer, and an agent holding the owner's session must not be able to widen access. The unmerged `feat/delegate-sharing` branch (#33) carried both halves; only its read side is ported here (reworked onto the quick-xml parser, no new dependency). - **`sync_contacts(addressbook_href, sync_token, limit, version)`** (`src/contact_sync.rs`): RFC 6578 `sync-collection`, etags only, bodies via `addressbook-multiget` per page. Without a token: every card (initial pass). With one: cards added or changed, hrefs deleted, up to `limit` (max 100) entries per call, `complete`, `changes`, `initial`, `missing`. The token is opaque, bound to the book and refused before any request when not issued here or issued for another book, with instructions to start over; a report the server truncates (507 `number-of-matches-within-limits`) is refused the same way, never paged. **Stalwart's own `limit` is not sent**: read from its source, a limited report is paged by `offset = limit * seq` over a set that moves under concurrent edits and a limited pass is stamped with its *last* page's state, so a low-id card changed mid-pass is lost; a limited initial report is a truncated list under a whole-book token. Here the next pass is anchored to the token of the pass's *first* report, so a change made during a multi-page pass is never lost; it may be delivered twice, which applying by href absorbs. - Fixed: the two wrong schema descriptions (`version`, `given_name`); concatenated home-set hrefs in discovery. - `src/fake_stalwart.rs` (tests only): discovery, home listing with owner/privileges, read-only books (403 on PUT/DELETE), a change log and `sync-collection` with Stalwart's token shape and quirks (unknown token answered as initial; `sync_max_results` → 507; the collection's own response). - Docs: README, CHANGELOG 0.4.0, `docs/sharing-and-sync.md` (contract, no-loss argument, permission boundaries, client loop), AGENTS.md scope and seven pitfalls (stale tool schema on old mounts; never send `limit` on a Stalwart sync; unknown token = initial report; tombstones have no `propstat`; `AddItems` has no privilege spelling; no share tool here; the DAV change log is not purged by default). ## Evidence - 237 tests (211 before): 250-card initial pass in three bounded pages then an empty delta; a delta with additions, an update (new ETag), a tombstone and a delete-then-recreate; edits ahead of, behind and within the page between every page of a three-page delta with the mirror equal to the server after the next pass; token for another book or tampered refused before any request (request log empty); truncated report refused with guidance; member deleted between report and fetch is `missing` now and a tombstone next; another account's book refused without ending the session or leaking; `@`/`%40` spellings; the unknown-token-is-initial Stalwart behaviour pinned; listing with owner/rights/order; assisted discovery off with three home hrefs listing each book once; encoded owner href; writes to a read-only share refused by the server with no PUT/DELETE recorded and the ETag unchanged; parser units for owner, privileges, response-level status, truncation and `sync-token`; rights table; schema shapes. - 24 mutations, each killed by at least one named test: snapshot taken from the last page, book check disabled, tombstones dropped, truncated report paged, `after` inclusive, `initial` constant, `missing` constant, status read from `propstat`, read-write requiring `write`, `shared` constant, first home only, books not deduped, collection counted as a member, `all` not owner; after the review: sibling tombstone counted as a wrong book, sibling tombstone delivered, probe skipped, probe on every page, behind-the-token check off, listing truncation ignored, secondary-home error propagated, secondary home not evicted, homes not deduped, sync bodies matched by raw href. - fmt, clippy pedantic on 1.93 and 1.99, `cargo test --locked`, `cargo audit`, `cargo deny check`, gitleaks: clean. - Independent review before this PR (fresh agent, read-only, against the diff and the Stalwart source at `aa47d8e`): round 1 **MUST-FIX** on one item, fixed in c12234b: Stalwart reports the account's deleted paths for *every* address book in one book's `sync-collection` report, and the first draft counted a sibling's tombstone as a member outside the book, refusing a quiet delta with a false "href does not name the book" error (the fake did not emulate it; 232 tests were green either way). Also from round 1: every pass now starts with a `PROPFIND` probe of the href, because Stalwart answers a `sync-collection` on an unshared book inside an account that shares another one with an empty 207 rather than a refusal (read from the source; to be confirmed live in AC4); `list_contacts` refuses a listing Stalwart cut at `max-results` instead of returning it as the whole book; a revoked secondary home no longer fails the caller's own listing for the cache hour; a report whose Stalwart token id is behind the id asked about is refused with resync guidance; seven nits. Round 2 (confirmation): **PASS**, every finding closed, two wording nits fixed in ebce484. The reviewer's unverified items for AC4: the 404-vs-403 shape of the probe on an unreadable book, and `highest_change_id` monotonicity across a cache rebuild. Follow-up outside this card: `search_contacts` and `upcoming_birthdays` go through the same `max-results` cut silently (pre-existing). Live proof (AC4) follows Clark's rollout, with fixture permissions Clark provisions on a disposable account: initial sync, create/update/delete and continued sync on a read-write share, read-only refusal, revoke and denied readback, conditional cleanup and absence; no real contact is written. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016ZfeS2nAr54h4afQ8ShfVE
list_address_books now returns the books other accounts shared with the
caller beside their own, each with owner, shared, rights (classified from
the server's current-user-privilege-set as Stalwart spells it) and the raw
privileges, and reads every addressbook-home-set href (assisted discovery
off). No share-management tool, on purpose: this server acts as whoever
presented the bearer. A 403 now names the rights field.

sync_contacts(addressbook_href, sync_token, limit, version) reports the
cards added or changed and the hrefs deleted since a token, paged
client-side from one RFC 6578 sync-collection report per pass and anchored
to the pass's first report, so a change made during a multi-page pass is
never lost (Stalwart's own limit paging can lose one). Tokens are opaque,
bound to the book, and refused before any request when not issued here;
a truncated report is refused with resync guidance, never paged.

Also fixes the concatenated home-set hrefs in discovery and the two wrong
schema descriptions (version, given_name), and records the Stalwart
sync-collection and privilege-set findings in AGENTS.md and
docs/sharing-and-sync.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZfeS2nAr54h4afQ8ShfVE
Stalwart reports the account's deleted paths for every address book in one
book's sync-collection report. The first draft counted such a tombstone as a
member outside the book and refused a quiet delta with "the href does not
name the book"; only a present member outside the book says that now, and
the fake emulates the account-wide tombstones (reviewer M1).

Every pass starts with a PROPFIND Depth: 0 of the href: Stalwart answers a
sync-collection on a book the caller may not read, inside an account that
shares another with them, with an empty report and a token, which would read
as "complete, nothing changed"; the PROPFIND is a 404, and a home or other
collection is refused instead of paged (reviewer's unchecked item, N2).

A listing Stalwart cut at max-results is refused instead of returned as the
whole book, and the sync's truncation message no longer points at
list_contacts (S1). A secondary home the server no longer grants is skipped
and the cached discovery dropped, so a revoked share cannot fail the
caller's own listing for an hour (S2). A report whose Stalwart token id is
below the id it was asked about is refused with resync guidance (S3). The
fake's module doc states its fidelity limits (S4); homes are read once (N6);
rights pins for write-acl and bind (N3); the decoded-path match in sync is
exercised in the mismatch direction (N4); docs reworded for `write` (N5) and
the undo advice names the owner (N7); `id` documented as non-unique (N1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZfeS2nAr54h4afQ8ShfVE
docs(agents): review round 2: fix the direction word and the unparseable-token sentence in the sync pitfalls
All checks were successful
CI / cargo (pull_request) Successful in 2m28s
CI / docker (pull_request) Successful in 1m16s
ebce484e49
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZfeS2nAr54h4afQ8ShfVE
jlxq0 merged commit a3e993bd27 into main 2026-10-24 05:06:42 +00:00
Sign in to join this conversation.
No reviewers
No labels
waiting-on-julian
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jlxq0/carddav-mcp!40
No description provided.