Pair by collection and UID with the address as a cache, never rewrite a remote UID, and re-pair a re-added connection instead of duplicating it #7

Closed
opened 2026-09-30 20:30:02 +00:00 by tiagoagueda · 0 comments
Owner

The suggestions for contacts, events and tasks all say that an item is "paired" and its data synced "using its URI". Today the plugin pairs cards with contacts and events with interviews through the core's SyncLink, and knows a twin by its address (remote_href) alone. This issue settles the key for #8, #11 and #12 alike.

Reading. "Its URI" is read here as the item's address, its href. Two other readings are possible: the item's UID, which in vCard 4 is itself a URI by default (RFC 6350 §6.7.6), or a link back to Postulo written into the item. The choice is the first decision below. The standards:

  • An address "is entirely arbitrary" (RFC 4791 §5.3.2), and a moved or renamed item is reported as a removal plus a new member (RFC 6578 §3.5.1–3.5.2).
  • The UID travels with the content and is unique within its collection (RFC 6352 §5.1, RFC 4791 §4.1). A server must refuse a PUT that changes an item's UID or reuses one already there (no-uid-conflict, RFC 6352 §6.3.2.1, RFC 4791 §5.3.2.1), with a 403 or a 409 (RFC 4791 §1.3).

What is wrong today

Read from the code; none of it has been reproduced on a real server.

  • The UID is written and never read. link.uid is set (src/postulo_dav/sync.py:451,545,582), but a twin is found only by its href (:458, :589). A card whose address changes has its link flagged remote_gone and, in the same run, is adopted again as a second contact (:527-550). The plugin never clears remote_gone.
  • An adopted card gets its UID rewritten. card_for() always writes UID:contact-{pk}-{owner}@postulo (:136), so the first push of a card adopted from the phone (:503, after an edit here) replaces its UID. A conforming server refuses, which is not the 412 caught at :509, so every run from then on fails before the interviews. A lenient server lets the identity change. Which servers do which is unverified.
  • A re-added connection duplicates or fails, on every run. Deleting a connection deletes its links (on_delete=CASCADE in the core). The next one pushes every contact as a new card, under the UID the old one gave it, and adopts every card already there as a new contact (:437-455, :527-550).
    • A server that enforces UID uniqueness refuses the first such push, uncaught (:442), and the run fails; contacts the old connection had adopted may already have a second card by then.
    • One that does not ends with every card and contact twice.
    • Interviews meet a 412 on <interview.uid>.ics (If-None-Match: *, :576-577) that nothing catches.
  • No test covers any of this. The fake server in tests/test_dav.py does not enforce no-uid-conflict, and no test re-adds a connection.

What the plugin should do

  1. A pairing is a collection plus a UID. The last href and ETag are a cache.
  2. Never change a remote UID: every push writes the one stored on the link. An event made elsewhere keeps its own through #11.
  3. Before calling a missing twin gone, look for its UID in the chosen collections of that kind, never the whole home: among the unpaired items the run reads anyway, or with addressbook-query and match-type="equals" (RFC 6352 §8.6, §10.5.4) or calendar-query (RFC 4791 §7.8), comparing exactly, since CalDAV's text-match matches substrings (§9.7.5). If found, the link follows it, remote_gone is cleared, and a note says so.
  4. Before pushing an unpaired record or adopting an unpaired item, match by UID; Postulo's own are recognisable (contact-{pk}-{owner}@postulo, an interview's uid). A match is paired, and today's conflict rule picks the winner (:472, :611). A pair the person undid by hand is not matched again.
  5. A refusal or a 412 on one item is a note, never a failed run, as the SyncPlugin contract asks (src/postulo/plugins/base.py in the core).
  6. WebDAV files have no UID, and DAV:resource-id (RFC 5842) is optional, so #5 keys them by path. Choosing collections by href is #13.

Decisions

  • What "using its URI" keys on: (a) the href alone, as today; (b) the collection plus the UID, the href a cache; (c) a link back to Postulo written into the item. Recommended: (b). Reads and writes still go to the href, but it may change and the UID may not. (c) writes into the person's own cards and events, which their clients may drop (unverified).
  • Trusting a Postulo UID. After an export and an import, its pk can name a different contact. Recommended: pair on it only when the name matches too; a miss only makes a duplicate.
  • Cards adopted earlier under their own UIDs, whose links are gone: (a) adopt them again; (b) pair a card with the one unpaired contact sharing its email address; (c) leave them for postulo/postulo#327. Recommended: (b), then (c). Whether unmatched cards are adopted at all is for #8.

Needs from the core

Points 2 to 5 work before either lands, on the uid the link already stores and the one configured collection.

New notes go through uv run postulo-messages extract; fr-fr and pt-pt are filled on the commit (the test gates them), the 23 EU catalogues before a tag (uv run pytest -m release). New surface names mean repinning the core (pyproject.toml dev group pins 1d6cdd7e0) and extending tests/test_surface.py.

Done when

  • The fake server refuses a PUT that changes or reuses a UID, and each bug above has a test.
  • An adopted card keeps its UID when edited here.
  • A card moved to a new address stays paired, with no duplicate contact.
  • Deleting and re-adding a connection changes no counts, and the run succeeds.

From the list of DAV plugin improvements of 2026-09-30.

The suggestions for contacts, events and tasks all say that an item is "paired" and its data synced "using its URI". Today the plugin pairs cards with contacts and events with interviews through the core's `SyncLink`, and knows a twin by its address (`remote_href`) alone. This issue settles the key for #8, #11 and #12 alike. **Reading.** "Its URI" is read here as the item's address, its href. Two other readings are possible: the item's `UID`, which in vCard 4 is itself a URI by default (RFC 6350 §6.7.6), or a link back to Postulo written into the item. The choice is the first decision below. The standards: - An address "is entirely arbitrary" (RFC 4791 §5.3.2), and a moved or renamed item is reported as a removal plus a new member (RFC 6578 §3.5.1–3.5.2). - The `UID` travels with the content and is unique within its collection (RFC 6352 §5.1, RFC 4791 §4.1). A server must refuse a `PUT` that changes an item's `UID` or reuses one already there (`no-uid-conflict`, RFC 6352 §6.3.2.1, RFC 4791 §5.3.2.1), with a 403 or a 409 (RFC 4791 §1.3). ## What is wrong today Read from the code; none of it has been reproduced on a real server. - **The UID is written and never read.** `link.uid` is set (`src/postulo_dav/sync.py:451,545,582`), but a twin is found only by its href (`:458`, `:589`). A card whose address changes has its link flagged `remote_gone` and, in the same run, is adopted again as a second contact (`:527-550`). The plugin never clears `remote_gone`. - **An adopted card gets its UID rewritten.** `card_for()` always writes `UID:contact-{pk}-{owner}@postulo` (`:136`), so the first push of a card adopted from the phone (`:503`, after an edit here) replaces its UID. A conforming server refuses, which is not the 412 caught at `:509`, so every run from then on fails before the interviews. A lenient server lets the identity change. Which servers do which is unverified. - **A re-added connection duplicates or fails, on every run.** Deleting a connection deletes its links (`on_delete=CASCADE` in the core). The next one pushes every contact as a new card, under the UID the old one gave it, and adopts every card already there as a new contact (`:437-455`, `:527-550`). - A server that enforces UID uniqueness refuses the first such push, uncaught (`:442`), and the run fails; contacts the old connection had adopted may already have a second card by then. - One that does not ends with every card and contact twice. - Interviews meet a 412 on `<interview.uid>.ics` (`If-None-Match: *`, `:576-577`) that nothing catches. - **No test covers any of this.** The fake server in `tests/test_dav.py` does not enforce `no-uid-conflict`, and no test re-adds a connection. ## What the plugin should do 1. A pairing is a collection plus a `UID`. The last href and ETag are a cache. 2. Never change a remote `UID`: every push writes the one stored on the link. An event made elsewhere keeps its own through #11. 3. Before calling a missing twin gone, look for its `UID` in the chosen collections of that kind, never the whole home: among the unpaired items the run reads anyway, or with `addressbook-query` and `match-type="equals"` (RFC 6352 §8.6, §10.5.4) or `calendar-query` (RFC 4791 §7.8), comparing exactly, since CalDAV's `text-match` matches substrings (§9.7.5). If found, the link follows it, `remote_gone` is cleared, and a note says so. 4. Before pushing an unpaired record or adopting an unpaired item, match by `UID`; Postulo's own are recognisable (`contact-{pk}-{owner}@postulo`, an interview's `uid`). A match is paired, and today's conflict rule picks the winner (`:472`, `:611`). A pair the person undid by hand is not matched again. 5. A refusal or a 412 on one item is a note, never a failed run, as the `SyncPlugin` contract asks (`src/postulo/plugins/base.py` in the core). 6. WebDAV files have no `UID`, and `DAV:resource-id` (RFC 5842) is optional, so #5 keys them by path. Choosing collections by href is #13. ## Decisions - **What "using its URI" keys on:** (a) the href alone, as today; (b) the collection plus the `UID`, the href a cache; (c) a link back to Postulo written into the item. Recommended: (b). Reads and writes still go to the href, but it may change and the UID may not. (c) writes into the person's own cards and events, which their clients may drop (unverified). - **Trusting a Postulo UID.** After an export and an import, its `pk` can name a different contact. Recommended: pair on it only when the name matches too; a miss only makes a duplicate. - **Cards adopted earlier under their own UIDs**, whose links are gone: (a) adopt them again; (b) pair a card with the one unpaired contact sharing its email address; (c) leave them for postulo/postulo#327. Recommended: (b), then (c). Whether unmatched cards are adopted at all is for #8. ## Needs from the core - postulo/postulo#326: the collection on the link, `for_remote`, an `unbind` that stays undone, and several twins per record for #12. - postulo/postulo#327: pairing and unpairing by hand. Points 2 to 5 work before either lands, on the `uid` the link already stores and the one configured collection. New notes go through `uv run postulo-messages extract`; fr-fr and pt-pt are filled on the commit (the test gates them), the 23 EU catalogues before a tag (`uv run pytest -m release`). New surface names mean repinning the core (`pyproject.toml` dev group pins `1d6cdd7e0`) and extending `tests/test_surface.py`. ## Done when - The fake server refuses a `PUT` that changes or reuses a UID, and each bug above has a test. - An adopted card keeps its UID when edited here. - A card moved to a new address stays paired, with no duplicate contact. - Deleting and re-adding a connection changes no counts, and the run succeeds. From the list of DAV plugin improvements of 2026-09-30.
Sign in to join this conversation.
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
Postulo/postulo-dav#7
No description provided.