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
# Importing from macOS Contacts

2026-09-16. Contacts on this Mac move into a pro-tacts host in three
steps, each a rake task: build a plan from the Mac, execute it against a
host, and remove the originals from the Mac. Monica gets an importer of
its own later, and the two share everything but the first and last
steps.

## A plan is what will happen, and how far it got

A plan is a directory under `data/imports/`, which is ignored, since it
holds every contact in full:

| Path | Holds |
|---|---|
| `plan.yml` | The source, when it was built, the group, and each contact's minted id beside its source id; then the host and each contact's status |
| `cards/<id>.yml` | The contact's fields, the groups it belongs to, and the source it was read from, for a person to edit before `execute` |

The builder writes the cards once, and no code rewrites them. A person can: a card file is the plan's one open question, read as
it stands when `execute` carries it.

`plan.yml` also records how far `execute` and `remove` have carried the
plan, saved after every step. A contact's status goes from none to
`landed`, `imported`, and `removed`. Each save writes a new file and renames
it over the old one, so a run that dies mid-save leaves the plan as the
step before left it. `execute` records the host before its first write
and refuses a `HOST` other than the one recorded, so one plan cannot
land on two servers.

Companies are not imported. A contact whose `contactType` is
`organization` is left out of the plan, so `remove` never touches it
either.

A plan can cover part of the book, so an import can be tried a few
contacts at a time: `rake import:macos:plan LIMIT=n` plans the first n
people, companies skipped. First means Contacts.app's own list order,
`CNContactSortOrderUserDefault`; the fetch request's default is
`CNContactSortOrderNone`, which promises no order at all
(`CNContactFetchRequest.h`). Plans keep no record of one another. The
next plan's first n are new because `remove` took the last batch off the
Mac, so a contact that landed and was not removed
lands again, as a second card, from the next plan that includes it. Each
plan is its own group.

Every card gets a freshly minted id, which is also its `UID`: twelve
letters from `ChangeId.mint(12)`, the width the web create mints. A
source's own identifier is never reused on the server; the plan carries
it beside the minted id, and that pairing is how `remove` finds the
original.

## Execute is the same for every source

`rake import:execute HOST=host` lands the oldest plan in `data/imports`
with a contact still to land or still to be put in its groups, and
`PLAN=dir` names another. Plans are carried in the order they were
built, so the one to take further is the oldest not taken there yet —
the rule `remove` picks by too. It does the following, recording each
step in the plan:

1. Read every card not yet imported, and stop before any write if one
   will not read.
2. PUT each card to `/dav/addressbook/<id>.vcf` with `If-None-Match: *`.
3. Put each card in the groups its file names through
   `POST /contacts/<id>/groups`, creating any that are missing through
   `POST /groups`.

A rerun skips every step the plan records. The plan alone is not
enough, because a run can die after a write lands and before the plan
records it, so each step also accepts its own earlier success from the
host. Skipping
first keeps a rerun from collecting refusals, each of which warns Sentry
(`RefusalAlerts`).

- A bare 412 to the PUT is `If-None-Match` failing, and at 48 bits a
  minted id is taken only by this plan's own earlier PUT, so the card
  counts as landed. A 412 with a body is the card refused.
- A group is looked up by name in `GET /api/groups`, every group's id
  and name as JSON, before it is created, so a rerun reuses the group an
  earlier run made.
- Joining a group twice is joining once (`Store#add_member`).
- A PUT that creates a card makes it a member of `sync:*`
  (`2026-09-15-client-creates-join-everyone.md`). The request that sets
  a card's groups therefore lists `sync:*` among the groups it was
  already in, so a card whose file does not name `sync:*` is taken back
  out of it.

## A card is a form filled in

A card file holds what the web editor would: a first and last name, a
nickname, a birthday, phone numbers, email addresses, addresses, a note,
and whether to carry the picture, plus the names of the groups it
belongs to. Under `source` it also holds what the reader gave — the
identifier, the vCard, the note, and every fetched key — so the fields
and the original are read together, and `remove` compares the Mac's
contact against the same file. The plan writes `sync:*` and its own
`import-<timestamp>` group into every card, ahead of any the builder
chose.

```yaml
first: Ada
last: Lovelace
birthday: '1815-12-10'
phones:
- "+12532189075"
emails:
- ada@example.com
addresses:
- street: 12 Marylebone Rd
  locality: London
  postal_code: NW1 5LS
note: An analyst.
photo: true
groups:
- sync:*
- import-20260916T180412Z
source:
  identifier: A:ABPerson
  vcard: "BEGIN:VCARD\r\n…"
  note: An analyst.
  contact:
    imageData: /9j/…
```

`Import::Card` turns the fields into a contact through the web's own
code: `CardForm.new_card`, then `CardForm.contact_card` with each phone,
email, and address as an add row. Types do not survive, because the form
has no field for them, so a number lands as a bare `TEL` and a home
address as a bare `ADR`. The birthday rides beside the card rather than
in it, which is where the store keeps one
(`2026-09-11-every-birthday-in-the-model.md`); `Contact` composes the
`BDAY` line back in, and the PUT hands it to the store, which takes it
out again.

The picture is the one field with no value of its own: `photo` says
whether to carry what `source` holds, and the line is written from
`imageData`, or from `thumbnailImageData` when the framework gave no
full image, with the TYPE its magic bytes name, the way Contact#photo
reads one back. A `photo: true` the source cannot supply is refused.

An address holds the editor's own parts — extended, street, locality,
region, postal code, country — and no post office box, which no screen
shows either.

A file that will not read as a card is refused, naming the file: a
missing or unknown key, a name with neither half, or a value YAML reads
as something other than text. An unquoted `no` is false and an unquoted
`+12532189075` is a number, and either would otherwise import as
something no one typed.

`HOST` is a base URL, such as `https://contacts` or the dev server's
`http://localhost:9292`.

These are the routes a client and the admin UI already use, so the
server gains nothing for this. The host's proxy supplies the identity,
and a PUT that creates a card also puts it in the writer's `sync:` group
(`2026-09-12-per-user-books.md`). `HOST` has no default and is not
`PRO_TACTS_HOSTNAME`, which names the host a profile points at and is
usually a different one.

## Reading the Mac

`script/macos-contacts.swift` reads through Contacts.framework, and TCC
grants access to whatever terminal runs it.

It reads the iCloud account alone, card by card. This Mac's Contacts
holds four accounts: iCloud (443 cards), Monica (1,679), and two
pro-tacts accounts, the deployment and the dev server. By default the
framework merges cards linked across accounts into one contact under an
identifier no card has; 344 of the 1,801 contacts it returned were
merges. AppleScript knows no such identifier, so a merge's note cannot
be read, and a delete could not name it. Reading the iCloud container
with `unifyResults` off gives each card under the identifier AppleScript
uses, and keeps Monica's cards for the Monica importer and pro-tacts
from importing itself. The reader still fails on any card AppleScript
does not list.
 It serializes each contact
with `CNContactVCardSerialization`, which leaves out three things on this
Mac with no error for any of them (measured 2026-09-16 over 1,799
contacts, macOS 26):

- `NOTE`. The framework strips `CNContactNoteKey` from the fetch of a
  process without Apple's notes entitlement: `isKeyAvailable` is false,
  and reading `note` raises `CNPropertyNotFetchedException`. AppleScript's
  `note of person` returns the note, so notes come from there.
- `PHOTO`. The image key comes back available, and still no card carries
  a photo, so the builder writes `PHOTO` from `imageData`. That is the
  signal to believe: across the 360 people the iCloud container holds
  unmerged, `imageDataAvailable` and `imageData` agree on every one
  (measured 2026-09-19). The gap recorded here before — 392 against 193
  — came of measuring the unified, all-account fetch instead, where 185
  of 1,770 contacts claim an image and supply none. Each of those 185
  has a unified identifier no container claims and a distinct
  `thumbnailImageData` of 723 to 333,967 bytes, and thumbnails without
  images are Monica's: 1,673 of its 1,680 cards, against 8 in the other
  three accounts. Two of those 8 are iCloud's, and one imported as
  `photo: false` over a 68,996-byte JPEG thumbnail, so the builder
  falls back to `thumbnailImageData` when `imageData` is nil.
- `UID`. No card has one, so the builder adds the minted id.

The reader asks for an explicit list of keys and fails if any comes back
unavailable, so the next silent strip stops the read rather than a
contact losing a field.

What a card keeps of its source is the serialized vCard, every fetched
key with image data in base64, and the AppleScript note.

## The builder knows only what it was taught

The macOS builder decides each line through a `case` on the property
name, whose `else` records the line as unknown, then fills the card's
fields from the lines it kept. The envelope is dropped, since the web's
create writes its own; a `VERSION` other than 3.0 is unknown. An Apple `PRODID` is
dropped, since Contacts writes its own on every save. The note and the image
data, which the vCard leaves out, are unknown until a branch takes them
too. Each branch
names the forms it accepts, a form being the group, name, and parameters
spelled exactly as the source wrote them, and a line in any other form is
unknown under that form. Once every contact in the batch is read, any unknown fails
the build with every unknown name, its count, and a few distinct values
it held, each beside its source id and cut short when long, and
no plan is written. A small batch needs only the branches its own
contacts use.

The `case` starts empty. A property earns a branch when a build refuses
it, and the branch is written with that property in view, so nothing is
imported on the strength of a guess about what it holds. Parameter
values are part of the form, so `TYPE=IPHONE` is refused until a branch
names it. Two spellings are read as one: `item1.` and `item2.`, since
the number counts a card's groups rather than saying anything about the
line, and a trailing `type=pref`, which ranks a line rather than naming
a kind — `Contact#types_of` drops it for that reason, and a form is
listed without it.

Some properties are read and dropped rather than refused, this address
book having no field for any of them: `IMPP`, `URL`, `X-SOCIALPROFILE`,
`X-APPLE-SUBADMINISTRATIVEAREA`, and `X-AIM`. `X-ABLabel` is dropped for
a related reason — it labels whichever line shares its `item` group, and
every line that takes one is itself dropped or reads the label. A card's
`source` still holds every one of their lines.

`X-ABRELATEDNAMES` has no field either and is not dropped: each related
name is written under the note as "<label>: <name>", the label being the
`X-ABLabel` of its group, unwrapped from Apple's `_$!<Spouse>!$_`. A
spouse's name is worth more in the note than nowhere.

A property whose value has a shape is checked against it: `TEL` takes a
`+` and digits, `BDAY` a whole date, `EMAIL` an address with an `@`, and
`ADR` the seven components RFC 2426 section 3.2.1 gives it, with the post
office box empty.

A field holds less than a line can, so a line that would lose something
on the way into one is unknown too. The name comes from the one `N`,
and is refused when `N` holds more than a family and given name, or when
the one `FN` is not those two joined with a space, the way the web
editor writes it.

## Removing the originals

`rake import:macos:remove` deletes, through `CNSaveRequest`, the contacts
the oldest landed plan imported, and `PLAN=dir` names another. The host
is the one that plan recorded rather than a `HOST`, the contacts to take
off this Mac being the ones already carried somewhere. For each one it
first asks that host's card browser, `GET /contacts/<id>`, whether it
still has the card — the DAV collection serves the asking user's book
alone, and a card in no `sync:` group is on the host and in no book —
then reads the contact again through
`macos-contacts.swift show` and compares it to the card's `source`, note
included, and keeps it, printing why, if either check
fails. A contact edited on the Mac since the plan is kept, and so
is one whose card is no longer on the host. A contact already gone from
the Mac counts as removed, so a rerun is safe for the same reason
`execute`'s is. The deletes go in one run of the script, since each one
starts it again, but in one `CNSaveRequest` each: the save is all or
nothing, and the store refuses some contacts it will hand over quite
happily, so a batch would lose every other delete to that one. The
script prints per identifier whether the delete took, a refused one is
kept and printed like any other kept contact, and the statuses are
recorded after: a run that dies between the two finds those contacts
gone next time and records them then.

A refusal is `NSCocoaErrorDomain` 134092 out of `store.execute`, with
`CoreData: error: Unhandled error occurred during faulting` on stderr,
and no key set avoids it — identifier alone fails the same way the full
fetch does. What makes a contact refusable is not known; the one that
turned this up was the only one of its plan carrying both a photo and
`X-ABRELATEDNAMES`. Deleting it in Contacts.app is the way out.

The comparison is exact because CNContact exposes no modification date.
A contact Contacts.app touched on its own will be kept, which is the
safe direction to be wrong in.