Record what macOS Contacts sends on write
The storage record committed earlier today assumed the client returns
untouched properties as it received them; it rewrites every card it
touches, which makes the group-attribute diff semantic rather than
byte-wise.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/AGENTS.md b/AGENTS.md
index c7a7cbb..1b65355 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -77,6 +77,11 @@ before regenerating.
under a C locale otherwise. Watch for em dashes in comments.
- `RUBYOPT=--enable-frozen-string-literal` is set in `.ramekin/config.kdl`.
String literals are frozen; mutating one raises.
+- `supported_http_methods` in `config/puma.rb` *replaces* Puma's default
+ method list rather than extending it. Any method the app answers must be
+ named there or Puma returns 501 from the HTTP parser, before Rack runs —
+ so the request never reaches the app and `UnhandledRequests` cannot
+ capture it. Adding a route is two files, not one.
- Application code reads configuration through `ProTacts.config` only; add
a method to `config.rb` rather than reaching for `ENV`. The Rakefile is
outside that rule and reads `PRO_TACTS_HOSTNAME` directly.
diff --git a/docs/macos-contacts.md b/docs/macos-contacts.md
index cdae80c..06b0576 100644
--- a/docs/macos-contacts.md
+++ b/docs/macos-contacts.md
@@ -130,6 +130,77 @@ 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.
+
+## 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`.
+
+## 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.
+
+## 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.
+
## One address book per account
Through at least macOS 10.10, Contacts binds one address book per account
diff --git a/docs/plans/2026-08-24-corrections-from-the-first-write.md b/docs/plans/2026-08-24-corrections-from-the-first-write.md
new file mode 100644
index 0000000..57b6ae8
--- /dev/null
+++ b/docs/plans/2026-08-24-corrections-from-the-first-write.md
@@ -0,0 +1,96 @@
+# Corrections from the first real write
+
+2026-08-24, later the same day. macOS Contacts wrote to this server for
+the first time, and the captures in `log/unhandled` contradict three
+things in `2026-08-24-vcard-storage-and-groups.md`. That record stands
+as written; this one says what the first real card changed.
+
+The client behavior itself is in `../macos-contacts.md`. This covers
+only what it means for the storage design.
+
+## Diffing a submitted card has to be semantic
+
+The classification rule says to recompute what was last served and diff
+the submitted card against it, treating each property that moved as an
+edit. That silently assumed the client returns untouched properties in
+the form they were sent. It does not:
+
+```
+ADR;TYPE=home -> ADR;type=HOME;type=pref
+```
+
+Same address, same seven components, byte-for-byte different. Contacts
+lowercases parameter names, uppercases values, fills in defaults, and
+reorders properties on every card it touches.
+
+A byte-wise diff therefore reports every property as modified, including
+the ones a group injected. Under the rule that an edit to a shared value
+updates the group, that turns every save of any member's card into a
+group rewrite fanned out to every other member — a phantom edit,
+repeated on each sync, from a card nobody changed.
+
+The comparison must normalize before it decides anything: parameter name
+case folded, parameter values treated as an unordered set, the repeated
+`type=` spelling and the comma-separated list treated as equivalent
+(RFC 2426 section 3.3.1 permits both), property order ignored, and text
+values compared after unescaping. Only a difference that survives all of
+that is an edit.
+
+This does not change the subtract-and-compose model. It changes what
+counts as equality inside it, which is the part the model rests on.
+
+## BDAY is semantic loss, not syntactic drift
+
+The storage record uses `BDAY` as its example of the cheap mismatch —
+meaning preserved, bytes different. That holds for a birthday with a
+year. It is wrong for one without:
+
+```
+BDAY;X-APPLE-OMIT-YEAR=1604:1604-01-01
+```
+
+The fact that the year is unknown lives entirely in a non-standard
+parameter, with 1604 as a sentinel in both halves. Parse that into a
+date type and the parameter is gone, the sentinel becomes real, and
+re-rendering gives the contact a birthday in 1604. A card with a known
+year is a plain `BDAY:1900-01-01`, so the two are not even the same
+shape.
+
+The correction is to the category, not the conclusion. This is a
+stronger argument for storing the document than the one the record
+makes: the loss needs no `X-` property to demonstrate, only a birthday
+with no year.
+
+## The strong-ETag path is reachable after all
+
+The record treats round-trip drift as costing the strong ETag on PUT,
+and argues the refetch is cheap. Rereading RFC 6352 section 6.3.2.3, the
+comparison it specifies is between what the server **stores** and what
+was **submitted** — not between what the server previously served and
+what came back. Storing the submitted card verbatim makes those equal by
+construction, so a strong ETag is allowed on every ordinary PUT.
+
+Drift between a card we rendered and the card Contacts returns costs
+nothing. It is paid once, when the KDL-rendered cards are replaced by
+Apple-shaped ones, and never again.
+
+The exception is unchanged and is now the only one: a member of a group
+has inherited properties subtracted before storage, so stored is
+deliberately not equal to submitted, and those responses must omit the
+strong ETag.
+
+## Still unknown
+
+Whether Contacts handles an ETag-less PUT response gracefully cannot be
+answered until PUT is implemented, since the server currently 404s and
+that path never runs. It belongs to the PUT task.
+
+Whether the client preserves a parameter of this server's own invention
+is untested — it emits its own non-standard parameters freely, but that
+is not the same question. It matters less now that the marker parameter
+is a hint rather than the mechanism.
+
+No `X-ABADR`, `X-APPLE-SUBLOCALITY`, `X-ABLabel`, or `item1.` grouping
+appeared on any captured card. The storage record says macOS leans on
+those heavily, which overstates what a card without custom labels
+carries. `PRODID`, `REV`, and `NOTE` alone are enough to make its point.