1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
# Apple Contacts as a CardDAV client

What the Contacts clients on macOS and iOS do, where they deviate from the
RFCs, and which failures look like protocol bugs but are not. Most of this
is macOS, distilled from sabre/dav's [client notes][sabre-osx] and from
traffic captures against the servers in `servers/`; sections that cover
iOS say so.

[sabre-osx]: https://sabre.io/dav/clients/osx-addressbook/

Scope: client behavior only. For the specifications themselves, see
`docs/rfcs/README.md`; for the responses the server sends, `docs/carddav.md`. For request and response shapes, see
`2026-01-12-carddav-reference.md`.

## Turn on the client's debug log first

macOS 10.8 and later will log its own CardDAV traffic once asked:

```sh
defaults write com.apple.addressbook.carddavplugin EnableDebug -bool YES
defaults write com.apple.addressbook.carddavplugin LogConnectionDetails -bool YES
```

Logs land in `~/Library/Logs/CardDAVPlugin`. This is faster than guessing
from server logs, because the client makes decisions (giving up, downgrading,
refusing a redirect) that never reach the server at all.

## The account setup path

The fastest path is a configuration profile: `rake profile:install` (with
`PRO_TACTS_HOSTNAME` set) downloads `carddav.mobileconfig` from that host's
`/setup`, removes the installed profiles pointing at the same host, opens
the download, and opens System Settings on the Profiles pane (via the
`x-apple.systempreferences:` deep link) — the profiles CLI no longer
supports installs, so the profile lands there as pending until you click
Install. That click is the whole manual step. `rake profile:remove` runs
the removal half alone via `profiles remove`.

The download goes through the identity gate like any other request, so it
works against a deployment whose proxy writes the header and not against a
server with nothing in front of it. For one of those, fetch it with the
header set by hand: `curl -H "Remote-User: you" -o carddav.mobileconfig
https://host/setup/carddav.mobileconfig`.

The profile carries the hostname, the requester's login, a placeholder
password, and SSL — `CardDAVPrincipalURL` is deliberately omitted so the
account gets an empty Server Path, exercising discovery. Every render gets a
fresh identifier and UUIDs, so each install provisions a cold account with
no cached sync state — that is deliberate for the experiment loop, and
`rake profile:install` sweeping the old profiles before it is what keeps it
cold. `profile:remove` finds them by scanning `profiles list` output for
the pro-tacts prefix and a digest of `PRO_TACTS_HOSTNAME` (which it needs
set too), so a dev sweep leaves a profile pointing at the deployment alone
and prints the identifiers it left behind. Apple's device-management
reference marks the CardDAV payload as allowing manual install, so no MDM is
involved.

The manual alternative, for cross-checking when the profile path misbehaves:
In System Settings, add the account under Internet Accounts, Add Other
Account, CardDAV account, with Account Type set to Manual. Manual matters:
automatic setup runs its own discovery and fails in ways that are harder to
read.

The Server Address field takes a bare hostname, no scheme. Reports of
working Monica setups differ on what belongs in Server Path — some use the
principal URL, some the address book collection, some leave it empty and let
`/.well-known/carddav` do the work. Leaving it empty is the case worth
supporting, since it is the only one that exercises discovery.

## The `.well-known` redirect is the most common failure

Nearly every "macOS can't see my contacts" report against Monica traces back
to `/.well-known/carddav`, not to CardDAV. The client will not follow a
redirect that downgrades HTTPS to HTTP, so a reverse proxy that terminates
TLS and then redirects using the request's own scheme sends the client to an
`http://` URL, which it silently drops.

Redirect to an absolute `https://` URL. Do not rely on the proxy passing the
original scheme through, and do not chain redirects.

## Discovery stops where the properties stop

macOS issues a `PROPFIND` with `Depth: 0` against the configured URL asking
for this set:

```xml
<A:propfind xmlns:A="DAV:">
  <A:prop>
    <B:addressbook-home-set xmlns:B="urn:ietf:params:xml:ns:carddav"/>
    <B:directory-gateway xmlns:B="urn:ietf:params:xml:ns:carddav"/>
    <A:displayname/>
    <C:email-address-set xmlns:C="http://calendarserver.org/ns/"/>
    <A:principal-collection-set/>
    <A:principal-URL/>
    <A:resource-id/>
    <A:supported-report-set/>
  </A:prop>
</A:propfind>
```

If the response comes back with `supported-report-set` filled in but no
`addressbook-home-set`, the client stops. It does not retry at a higher
`Depth`, and it does not walk the tree looking for the address book. This is
what [sabre-io/dav#1315][sabre-1315] documents: a well-formed 207 that
happens to answer at the collection instead of the principal reads to macOS
as "there is nothing here."

[sabre-1315]: https://github.com/sabre-io/dav/issues/1315

The practical rule is that whatever URL the account points at must answer
that `PROPFIND` with a usable `addressbook-home-set`.

## `getctag` gates everything after discovery

The client requires the proprietary `{http://calendarserver.org/ns/}getctag`
property on the address book collection. Without it, the client completes
discovery and then never requests a single vCard, which presents as an
account that connects successfully and stays empty.

## The collection must claim to be an address book

Discovery is not the only gate. After it, the client sends a `Depth: 1`
`PROPFIND` against the address book asking a long list of properties
(`resourcetype`, `supported-report-set`, `sync-token`, quotas, push — see
fixture `09-propfind-addressbook-bootstrap`). If the answer carries `getctag`
and `sync-token` but the collection's `resourcetype` does not include
`card:addressbook`, the client retries the `PROPFIND` once and then stops —
no `REPORT`, no card requests, an empty account. Verified by minimization
rounds 1–2 on 2026-08-17.

## Warm sync gates on the collection advertising its reports

An incremental sync (`sync-collection` REPORT with a stored token) only
happens if the collection's `PROPFIND` answer advertises `sync-collection`
in `supported-report-set`, per RFC 3253 report discovery. Without the
advertisement the client does not fall back or rescan — it simply polls,
sees the ctag, and never fetches, which presents as changes silently not
arriving. A cold account does not hit this: first sync uses the etag
listing plus `multiget`, which needs no advertisement. Verified by
minimization rounds 3b/3c on 2026-08-18.

## Writes are gated on the advertised privilege set

The client asks for `DAV:current-user-privilege-set` on the collection in
every `Depth: 1` poll, and attempts no write at all while the server omits
it. Adding it — `read`, `write`, `bind`, `unbind` (RFC 3744 sections 3.1,
3.2, 3.9, 3.10) — produced a `PUT` within a second of the first response
that carried it, from an edit made four hours earlier.

The `Allow` header is not what decides this. Contacts sends `OPTIONS` to
the principal, never to the address book collection, so it never learns
which methods the collection accepts. Verified 2026-08-24.

## Pending writes queue indefinitely and retry on their own

An edit made while the server refuses writes is not lost. `REV` is stamped
at edit time and the client replays that same body for hours, retrying
every few minutes, byte-identical apart from a refreshed `REV`. An
experiment round that fails for server-side reasons does not need the edit
redone — fix the server and the queued write arrives by itself.

## Advertising only `read` stops the sync

Leaving the privilege set out lets Contacts read and holds back its
writes. Advertising `read` alone does not do the same thing: the client
reads nothing at all.

With `current-user-privilege-set` listing only `DAV:read`, a fresh
install of the account ran discovery twice. Each round ended in a
`Depth: 1` PROPFIND of the collection whose 207 listed every card and its
etag. Then the client stopped. It sent no `getctag` poll, no
`sync-collection`, and no GET, and Contacts showed the account with no
cards in it.

Restoring `read`, `write`, `bind`, and `unbind` did not wake that
account; it sent nothing for the next six minutes. A second install with
the full set did sync. After the same discovery, the client asked for
`getctag` and `sync-token`, listed etags, and fetched all 20 cards within
15 seconds of the install.

Neither form makes Contacts show a read-only address book. With no
privilege set, the cards sync and an edit is accepted locally but never
sent. With `read` alone, there are no cards to edit. Verified 2026-09-10,
macOS 26.5.1 (AddressBookCore/2732.600.11).

## What a write looks like on the wire

Creates carry `If-None-Match: *` (RFC 6352 section 6.3.2) and a
client-minted UUID in both the request URI and the card's `UID`. The
16-character id scheme in `plans/2026-01-12-carddav-architecture.md`
therefore governs only cards this server creates.

Updates carry `If-Match` with the server's strong etag, so conditional
requests work and an etag derived from the rendered card is a usable basis
for them. Both use `Content-Type: text/vcard; charset=utf-8`.

## A PUT answered without an ETag costs one GET

A card with a birthday is stored without its `BDAY`
(`plans/2026-08-31-partial-birthdays.md`), so what the server keeps is not
octet-equal to what was sent, and RFC 6352 section 6.3.2.3 promises a
strong ETag in the PUT response only when it is. Contacts takes the
omission in stride. Editing a card with a complete birthday produced:

```
PUT bday-complete.vcf    ->  204, no ETag
REPORT sync-collection   ->  the card's new etag
GET bday-complete.vcf    ->  200, 195 bytes
```

The GET came 108 ms after the PUT and took the server 3 ms. The two other
writes in the same session were answered with an ETag and got the same
`REPORT` but no GET. So the cost is one request per save, the size of the
card: nothing for a bare card, up to a photo's size for one with a picture
(see "Profile pictures"). Verified 2026-09-10, macOS 26.5.1
(AddressBookCore/2732.600.11).

A weak ETag — the one answer section 6.3.2.3 leaves open where it forbids
a strong one — is treated the same as none. A PUT answered `204` with
`ETag: W/"…"` got the same `REPORT` and the same `GET`, 87 ms after the
PUT. The refetch is what Contacts costs for a card it re-serializes;
nothing the spec permits saves it. Verified 2026-09-24, macOS 27.0 (26A428)
(AddressBookCore/2765.100.1.1.1).

## The client rewrites every card it touches

A card served by pro-tacts and edited in Contacts does not come back in the
form it was sent. Values survive exactly; serialization does not:

```
TEL;TYPE=mobile      ->  TEL;type=CELL;type=VOICE;type=pref
EMAIL                ->  EMAIL;type=INTERNET;type=pref
ADR;TYPE=home        ->  ADR;type=HOME;type=pref
(TEL before EMAIL)   ->  (EMAIL before TEL)
                     ->  PRODID and REV added
```

Parameter names are lowercased, values uppercased, defaults filled in, and
properties reordered. The phone number, address, and all seven `ADR`
components return byte-identical, including the two leading empty ones.

`TYPE=mobile` is not a valid value — RFC 2426 section 3.3.1 lists home,
msg, work, pref, voice, fax, and cell — so the rewrite to `CELL` is the
client correcting the server. The repeated `type=` spelling is sanctioned
by that same section, which allows either a parameter list or a value list.

The practical consequence is that any comparison between a card the server
sent and the card that comes back has to be semantic. Comparing bytes
reports every untouched property as modified. Verified 2026-08-24.

## An address type the client cannot model becomes a custom label

A probe card of three addresses, with one relabel asked for — 1 Modeled
Way from Home to Work — answers what a round trip does to `TYPE`:

| Sent | Came back |
|---|---|
| `ADR;TYPE=home` (relabelled to Work) | `ADR;type=WORK;type=pref` |
| `ADR;TYPE=dom` (untouched) | `item1.ADR` + `item1.X-ABLabel:dom` |
| `ADR:` (untouched, no type) | `ADR:` |

A relabel is legible. The type the user picked comes back as the type
value, so a server can tell Home from Work, and a comparison that ignored
parameters could not.

A type the client has no field for is not dropped — it is moved. `dom` is
RFC 2426 section 3.2.1's own value and Contacts offers no Domestic label, so
the client made one: the address moved into a property group and the type
became the `X-ABLabel` beside it. That happened on an address nobody edited.
Two lines came back where one was sent, and the `TYPE` parameter is gone
from both.

An address with no type comes back with none, so nothing is invented. That
corrects a reading of the annotation probe below, whose bare-looking `ADR`
was sent as `item9.ADR;TYPE=work` — the `type=WORK` it came back with was
that type surviving the strip of its group prefix, not a type made up to
fill a gap.

Verified 2026-09-09 against macOS 26.5.1 (AddressBookCore/2732.600.11).

## An annotation survives only on its own line

A server that wants to mark a line — to say which group lent an address,
say — gets one form that works and four that do not. A probe card
carrying all five, and one client edit, answers all of them:

| Form | Sent | Came back |
|---|---|---|
| Standalone property | `X-PT-GROUP:kxsv` | `X-PT-GROUP:kxsv` |
| Parameter on `ADR` | `ADR;TYPE=home;X-PT-GROUP=kxsv:` | `ADR;type=HOME;type=pref:` |
| Parameter on `NOTE` | `NOTE;X-PT-GROUP=kxsv:` | `NOTE:` |
| Companion in a bare group | `item9.ADR;TYPE=work` + `item9.X-PT-GROUP` | `ADR;type=WORK:` + `item9.X-PT-GROUP` |
| Companion beside `X-ABLabel` | `item8.TEL` + `item8.X-ABLabel` + `item8.X-PT-GROUP` | `item1.TEL` + `item1.X-ABLabel` + `item8.X-PT-GROUP` |

A property of its own survives untouched, lowercase value included. A
parameter the client does not model is dropped, on `NOTE` as well as on
the properties whose parameters it rewrites.

The two companion results are worse than losing the annotation. Both
`X-PT-GROUP` lines came back, and both came back pointing at nothing: the
client stripped `item9.` off the address it was grouped with, and
renumbered `item8.TEL` and its `X-ABLabel` to `item1` while leaving the
third line of that group behind.

That probe chose its own numbering — `item8` and `item9`, picked to dodge
collisions — so it could not tell a broken binding from numbering the
client disliked. A second card settles it with two groups spelled the
way a client spells its own, dense and in document order, each anchored
by an `X-ABLabel`:

| Sent | Came back |
|---|---|
| `item1.TEL` + `item1.X-ABLabel` | `item2.TEL` + `item2.X-ABLabel` |
| `item3.EMAIL` + `item3.X-ABLabel` | `item1.EMAIL` + `item1.X-ABLabel` |
| `item1.X-PT-GROUP:kxsv` | `item1.X-PT-GROUP:kxsv` |
| `item3.X-PT-GROUP:mnop` | `item3.X-PT-GROUP:mnop` |

The client reordered `EMAIL` above `TEL`, renumbered both groups densely
in that new order, and left both markers on the numbers they arrived
with. Neither followed the group it was sent with, and the damage is
worse than orphaning: `item1.X-PT-GROUP` went out naming the phone and
came back naming the email address. A companion does not detach, it
re-attaches to whatever property the renumbering leaves on its number.

Unknown properties keep their `itemN` prefix exactly as sent while
modeled ones are renumbered, so the two numbering systems are
independent. No card is safe from this: reordering alone moves a group
out from under its marker even when the numbering was dense and in order
to begin with. Line-level provenance cannot rest on a companion.

What a card-level property can carry is a fact about the card as a whole
— which version of a group's lines it was composed from, for instance.
What it cannot do is name a line, which is what attribution needs.
Verified 2026-09-09 against macOS 26.5.1.

One thing the second probe caught in passing: the card went out with
`N:Boole;Renumber;;;` and `FN:Probe Renumber`, and came back with
`FN:Renumber Boole`. The client recomputes `FN` from `N` where the two
disagree, so a composed `FN` is not stable across a round trip.

iOS gets to the same place by a different route. The standalone
property survives and both parameters are dropped, as on macOS. Every
companion comes back with its `itemN.` prefix stripped: `item9.X-PT-GROUP`
and `item8.X-PT-GROUP` return as bare `X-PT-GROUP` lines at the end of
the card, and so do both markers from the renumbering probe. iOS reorders
and renumbers the modeled groups the way macOS does, puts every address
in a group whether or not it has a label, and recomputes `FN` from `N`
too. A companion on iOS detaches instead of re-attaching to the wrong
line, which makes the marker harmless but still leaves it unable to name
the line it came with. Verified 2026-09-11 against iOS 26.6.2
(dataaccessd/1.0).

## Profile pictures

A contact with a picture carries it in the card, inline — `PHOTO` with
`ENCODING=b;TYPE=JPEG` and a base64 payload (RFC 2426 section 3.1.4's
second form), never a URI. A real photo runs the card to 216–338 KB
because the client does not downscale before sending; a memoji or emoji
sits around 31–43 KB; a bare card is ~500 bytes. Photos are the sizing
case; the rendered kinds are not.

Three kinds have been captured, and the properties do not name all of
them — `X-IMAGETYPE` carries `PHOTO` or `MONOGRAM`, and an emoji is
labeled `MONOGRAM` too. That label is Apple's own name for a contact's
initials on a colored background, so an emoji and a monogram plausibly
arrive in one shape, the distinction living in the image bytes alone;
no monogram has been captured to confirm it. A memoji is the one kind
with its own property — `VND-63-MEMOJI-DETAILS`, a base64 binary plist,
which runs the parameter section to 1,683 octets on a single physical
line.

The picture trio as a photo arrives:

```
X-IMAGEHASH:RKZaNYCmuYERD3AfbLtrIw==
X-IMAGETYPE:PHOTO
PHOTO;X-ABCROP-RECTANGLE=ABClipRect_1&38&0&769&768&RKZaNYCmuYERD3AfbLtrIw==;ENCODING=b;TYPE=JPEG:
 <76 octets of base64>
 ...
```

- `X-IMAGETYPE:PHOTO` sits directly above `PHOTO`, and the token
  `X-ABCROP-RECTANGLE` ends with is `X-IMAGEHASH`'s value.
- The crop rectangle can be a real crop — the photo's origin is (38,0);
  the rendered kinds carry whole-image crops.

RFC 2426 section 2.6's 75-octet fold is a SHOULD the client meets only
after the colon: the payload is folded — a space, then 76 octets a
continuation, 4,378 continuation lines for the largest photo — while
the parameter section is not folded at all. A parser that assumes a
folded-line ceiling, or that splits parameters before unfolding, breaks
on the memoji card.

One more arrival shape, observed once: after the server's state was
reset under the client (a dev database rebuilt from seeds), its
re-pushed cards carried the picture bare — `PHOTO;ENCODING=b;TYPE=JPEG:`
with no crop, no hash, no type line, the same image it had sent with
the full trio minutes before. A bare PHOTO is therefore a real shape,
not malformed input. Observed 2026-09-04.

Where the bytes land: warm sync asks only for etags, so polling stays
~4 KB whatever the book holds. A cold sync — the etag listing plus one
multiget with a bare `<C:address-data/>`, no property filter — ships
every card whole, so one photo card makes that one response ~340 KB.
Serving one contact, by GET or multiget, is the card's own size.
Measured 2026-09-04 against a 13-contact book with one photo card:
the multiget builds in ~2 ms and the PUT lands in ~17 ms, so the
response size is the whole of the cost — the bytes are the floor, since
the client needs the picture.

Captured 2026-08-25 and 2026-09-04, macOS 26.5.1
(AddressBookCore/2732.600.11). One fixture card per kind — `photo`,
`emoji`, `memoji` — is seeded in test/fixtures/cards/ from the
2026-09-04 session (pictures set deliberately on synthetic contacts,
ids rewritten); the 2026-08-25 captures remain unpromoted, being
unprompted and possibly real images, with their shapes built
synthetically in test/photo_card.rb.

## Birthdays without a year

A birthday entered as a month and day carries Apple's own parameter, using
1604 as a sentinel in both halves:

```
BDAY;X-APPLE-OMIT-YEAR=1604:1604-01-01
```

A birthday with a real year is a plain `BDAY:1900-01-01`. Parsing either
into a date type loses the distinction and re-renders the no-year case as a
birthday in 1604, so the parameter has to survive storage rather than be
interpreted.

A card the server supplies can spell the same month and day the way RFC 6350
section 4.3.1 does, and Contacts reads both `BDAY:--0412` and
`BDAY:--04-12` as April 12. Neither spelling survives an edit: the client
writes the sentinel back in place of whatever it received. Serving a reduced
form therefore changes what the client displays, never what it stores.
Contacts also renders the sentinel on a card it did not write, which its own
round trip could not establish. Verified 2026-09-01.

## A birthday the client cannot render is dropped from the card

Contacts displays two of the six birthday shapes RFC 6350 section 4.3.1
admits: a full date, and a month with a day. It shows nothing for the rest,
and the next card it writes no longer carries the property at all:

```
BDAY:1985-04-12      ->  BDAY:1985-04-12
BDAY:--0412          ->  BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12
BDAY:--04-12         ->  BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12
BDAY:1985-04         ->  (dropped)
BDAY:1985            ->  (dropped)
BDAY:---12           ->  (dropped)
```

Editing an unrelated field triggers this; the three losses above came from
adding a note. The client does not preserve what it cannot display, so a
server that stores the submitted card as authoritative loses the birthday
permanently. Only the year and month, year alone, and day alone shapes were
tested; month alone (`BDAY:--04`) has a seed card but no observation yet.
Verified 2026-09-01, macOS 26.5.1 (AddressBookCore/2732.600.11).

## iOS writes a birthday it cannot hold as a date it made up

The birthday seed cards, served to iOS 26.6.2 and each saved after an
unrelated edit, came back like this:

| Served | iOS showed | iOS wrote back |
|---|---|---|
| `BDAY:1987-06-21` | the full date | `BDAY;value=date:1987-06-21` |
| `BDAY:1992-09-03` | the full date | `BDAY;value=date:1992-09-03` |
| `BDAY;X-APPLE-OMIT-YEAR=1604:1604-01-15` | January 15 | `BDAY;value=date:1604-01-15` |
| `BDAY:--0308` | March 8 | `BDAY;value=date:1604-03-08` |
| `BDAY:--11-27` | Jan 27, 2 | `BDAY;value=date:0002-01-27` |
| `BDAY:---04` | nothing | `BDAY;value=date:0002-11-26` |
| `BDAY:1998-10` | nothing | (dropped) |
| `BDAY:1972` | nothing | (dropped) |
| `BDAY:--07` | not checked | (dropped) |

It displays what macOS displays except for one spelling, and writes back
worse. The 1604 sentinel keeps its year but loses `X-APPLE-OMIT-YEAR`,
so nothing marks it as yearless any more. `--11-27` reads as January 27
of the year 2 and goes back that way. A day-only birthday iOS never
displayed comes back as a year-2 date it made up.

Every birthday it writes spells out `value=date`, the default value type
of RFC 2426 section 3.1.5, so the parameter adds nothing to the line. The
carry across a rewrite (`plans/2026-09-01-birthdays-across-a-rewrite.md`)
kept the three shapes iOS dropped; the made-up dates replaced what was
stored. Verified 2026-09-11 against iOS 26.6.2 (dataaccessd/1.0).

## Only the last of two notes survives

A probe card with two `NOTE` lines, the shape a group member with a note
of its own is served in. RFC 6350 section 6.7.2 allows any number of
them; Contacts keeps one. The Note field shows only the last, and the
card it writes back after an unrelated edit carries only that one:

```
NOTE:Own note: gate code 1854.           ->  (dropped)
NOTE:Lent note: bins go out on Tuesday.  ->  NOTE:Lent note: bins go out on Tuesday.
```

Nothing is joined, and the first note is lost the way an unrenderable
birthday is. Composition appends a group's lines after the member's own
(`ProTacts::Contact`), so for a member that has both, the note that
survives is the group's and the one lost is the member's. Verified
2026-09-10, macOS 26.5.1 (AddressBookCore/2732.600.11).

## Categories survive a round trip but are never shown

A probe card with two `CATEGORIES` lines (RFC 2426 section 3.6.1), one
of them a comma list. Contacts shows neither anywhere on the card, and
keeps both anyway. The card it writes back after an unrelated edit
carries both, values byte-identical and the list unsplit, in reverse
order:

```
Sent:       CATEGORIES:family,school run
            CATEGORIES:neighbours
Came back:  CATEGORIES:neighbours
            CATEGORIES:family,school run
```

A tag can therefore ride in the stored card through a macOS edit, but
someone using Contacts can neither see nor change it; only this server's
own UI can. Verified 2026-09-10, macOS 26.5.1 (AddressBookCore/2732.600.11).

## One address book per account

Through at least macOS 10.10, Contacts binds one address book per account
and hides the rest. Later versions are better, but a design that assumes one
collection per account avoids the question entirely. This suits pro-tacts,
where a user has one address book anyway.

## vCard 3.0, not 4.0

Apple clients emit and expect vCard 3.0 ([RFC 2426][rfc2426]) with
`PRODID:-//Apple Inc.//macOS .../EN`. RFC 6352 requires address book
collections to support 3.0 and makes 4.0 optional, so serving 3.0 is
compliant, not a compromise.

[rfc2426]: https://datatracker.ietf.org/doc/html/rfc2426

## Smaller traps

- Usernames containing `@` are not percent-encoded by older clients, so
  email-address usernames break. Avoid them.
- The `me-card` property (`http://calendarserver.org/ns/`, set on the
  address book home) is expected; sabre notes crashes when it is missing.
- Older clients wanted the server at the domain root, and sometimes needed
  an explicit port. Worth remembering only if something inexplicable shows
  up on an old machine.
- One report describes the client probing ports 8443 and 8843 unprompted,
  and making an unauthenticated request before retrying with credentials.
  Expect a 401 round trip on every request.

## iOS Contacts syncs against the same minimal set

The response set above was cut down against macOS alone, and iOS
Contacts (`dataaccessd/1.0`) needs nothing it leaves out. On 2026-09-11,
iOS 26.6.2 discovered the account on the dev server and synced it. Its
first multiget asked for all 20 cards and got 20 back, two cards seeded
afterwards reached the phone on a later poll, and every response was a
200 or 207, with nothing landing in the exchange log. An earlier session,
iOS 26.6.1 on 2026-09-08, also edited a card and deleted one.

iOS differs from macOS on the wire rather than in what it needs:
namespace prefixes spelled differently, a multiget without `Brief`,
`Depth`, or `Prefer`, grouped properties in a PUT, and a DELETE macOS
has never sent. `test/fixtures/ios-exchange/README.md` records each
difference as a fixture the test suite replays.

## Reference implementations to compare against

`docs/carddav.md`'s "Reference implementations" section covers what `servers/`
runs and which of them to trust. What it is for is this: when pro-tacts
and a working server disagree, the diff between two recordings of the
same client action is the fastest way to find out why.