fixtures: an iOS session, where it differs from macOS
macOS writes CardDAV C:, the phone writes it D:,
so a parser reading prefixes has something it cannot see.
Six steps of difference: three headers it omits,
an item-grouped card, and the verb no macOS log admits.

Assisted-by: Claude Opus 5 via Claude Code
change ylwsvlvmmmvxokpsopptxoqslztlvkmx
commit 631be3b8c9843bdd54b85e45329a600043fa57db
author Alpha Chen <alpha@kejadlen.dev>
date
parent otoxtops
diff --git a/AGENTS.md b/AGENTS.md
index d2dc76f..9438b8e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,13 +50,16 @@ docs/rfcs/          # Vendored spec texts the code cites
 docs/plans/         # Dated design records
 test/fixtures/cards/            # Seed cards for the test database
 test/fixtures/macos-exchange/   # A real client session, replayed
+test/fixtures/ios-exchange/     # Where a second client differs from it
 ```
 
 ## Fixtures
 
-`test/fixtures/macos-exchange/` replays the one confirmed-working macOS
-Contacts session. Within each step directory the two files have opposite
-standing:
+`test/fixtures/macos-exchange/` replays the confirmed-working macOS
+Contacts session, and `test/fixtures/ios-exchange/` the steps where iOS
+sends something macOS does not — a namespace prefix spelled differently,
+a header it omits, a verb macOS never sends. Within each step directory
+the two files have opposite standing:
 
 - `request` files are evidence of what a real client sent. Edit them only
   when a new recorded session supersedes this one.
diff --git a/Rakefile b/Rakefile
index a320a76..5e3a432 100644
--- a/Rakefile
+++ b/Rakefile
@@ -39,7 +39,7 @@ task :dev do
   end
 end
 
-desc "Regenerate macOS exchange response fixtures from current responses"
+desc "Regenerate the recorded exchange response fixtures from current responses"
 task :fixtures do
   # Mirrors test/test_helper.rb, which cannot be required here without
   # minitest/autorun running its at_exit hook inside rake. Its own
@@ -54,8 +54,16 @@ task :fixtures do
     require "pro_tacts/web"
     require_relative "test/fixture_data"
     require_relative "test/pro_tacts/exchange_fixtures"
-    ProTacts::Web.store = FixtureData.install(data_dir)
-    ExchangeFixtures.record_responses(ProTacts::Web)
+
+    # A store per recording, seeded fresh: the iOS session ends by
+    # deleting a card, and the recording after it must not start from
+    # what that one left behind.
+    ExchangeFixtures.all.each do |recording|
+      store = FixtureData.install(data_dir / recording.directory.basename)
+      ProTacts::Web.store = store
+      recording.record_responses(ProTacts::Web)
+      store.close
+    end
   end
 end
 
diff --git a/test/fixtures/ios-exchange/01-propfind-principal/request b/test/fixtures/ios-exchange/01-propfind-principal/request
new file mode 100644
index 0000000..90cc46b
--- /dev/null
+++ b/test/fixtures/ios-exchange/01-propfind-principal/request
@@ -0,0 +1,19 @@
+PROPFIND /dav/principal/ HTTP/1.1
+Brief: t
+Content-Type: text/xml
+Depth: 0
+Prefer: return=minimal
+
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+  <A:prop>
+    <D:addressbook-home-set xmlns:D="urn:ietf:params:xml:ns:carddav"/>
+    <D:directory-gateway xmlns:D="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>
diff --git a/test/fixtures/ios-exchange/01-propfind-principal/response b/test/fixtures/ios-exchange/01-propfind-principal/response
new file mode 100644
index 0000000..72a8a59
--- /dev/null
+++ b/test/fixtures/ios-exchange/01-propfind-principal/response
@@ -0,0 +1,17 @@
+207
+Content-Type: text/xml
+
+<?xml version="1.0" encoding="UTF-8"?>
+<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+  <d:response>
+    <d:href>/dav/principal/</d:href>
+    <d:propstat>
+      <d:prop>
+        <card:addressbook-home-set>
+          <d:href>/dav/addressbook/</d:href>
+        </card:addressbook-home-set>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+</d:multistatus>
diff --git a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/request b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/request
new file mode 100644
index 0000000..999f62a
--- /dev/null
+++ b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/request
@@ -0,0 +1,13 @@
+PROPFIND /dav/addressbook/ HTTP/1.1
+Brief: t
+Content-Type: text/xml
+Depth: 0
+Prefer: return=minimal
+
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+  <A:prop>
+    <C:getctag xmlns:C="http://calendarserver.org/ns/"/>
+    <A:sync-token/>
+  </A:prop>
+</A:propfind>
diff --git a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
new file mode 100644
index 0000000..2dbbd28
--- /dev/null
+++ b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
@@ -0,0 +1,33 @@
+207
+Content-Type: text/xml
+
+<?xml version="1.0" encoding="UTF-8"?>
+<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
+  <d:response>
+  <d:href>/dav/addressbook/</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:resourcetype>
+        <d:collection/>
+        <card:addressbook/>
+      </d:resourcetype>
+      <d:supported-report-set>
+        <d:supported-report>
+          <d:report><d:sync-collection/></d:report>
+        </d:supported-report>
+      </d:supported-report-set>
+      <cs:getctag>18</cs:getctag>
+      <d:sync-token>http://pro-tacts/sync/18</d:sync-token>
+      <d:current-user-privilege-set>
+        <d:privilege><d:read/></d:privilege>
+        <d:privilege><d:write/></d:privilege>
+        <d:privilege><d:bind/></d:privilege>
+        <d:privilege><d:unbind/></d:privilege>
+      </d:current-user-privilege-set>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+
+  
+</d:multistatus>
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/request b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/request
new file mode 100644
index 0000000..3785690
--- /dev/null
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/request
@@ -0,0 +1,30 @@
+PROPFIND /dav/addressbook/ HTTP/1.1
+Brief: t
+Content-Type: text/xml
+Depth: 1
+Prefer: return=minimal
+
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+  <A:prop>
+    <A:add-member/>
+    <E:bulk-requests xmlns:E="http://me.com/_namespace/"/>
+    <A:current-user-privilege-set/>
+    <A:displayname/>
+    <E:guardian-restricted xmlns:E="http://me.com/_namespace/"/>
+    <D:max-image-size xmlns:D="urn:ietf:params:xml:ns:carddav"/>
+    <D:max-resource-size xmlns:D="urn:ietf:params:xml:ns:carddav"/>
+    <C:me-card xmlns:C="http://calendarserver.org/ns/"/>
+    <A:owner/>
+    <C:push-transports xmlns:C="http://calendarserver.org/ns/"/>
+    <C:pushkey xmlns:C="http://calendarserver.org/ns/"/>
+    <F:quota-available xmlns:F="urn:mobileme:davservices"/>
+    <A:quota-available-bytes/>
+    <F:quota-used xmlns:F="urn:mobileme:davservices"/>
+    <A:quota-used-bytes/>
+    <A:resource-id/>
+    <A:resourcetype/>
+    <A:supported-report-set/>
+    <A:sync-token/>
+  </A:prop>
+</A:propfind>
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
new file mode 100644
index 0000000..aee624a
--- /dev/null
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -0,0 +1,195 @@
+207
+Content-Type: text/xml
+
+<?xml version="1.0" encoding="UTF-8"?>
+<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
+  <d:response>
+  <d:href>/dav/addressbook/</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:resourcetype>
+        <d:collection/>
+        <card:addressbook/>
+      </d:resourcetype>
+      <d:supported-report-set>
+        <d:supported-report>
+          <d:report><d:sync-collection/></d:report>
+        </d:supported-report>
+      </d:supported-report-set>
+      <cs:getctag>18</cs:getctag>
+      <d:sync-token>http://pro-tacts/sync/18</d:sync-token>
+      <d:current-user-privilege-set>
+        <d:privilege><d:read/></d:privilege>
+        <d:privilege><d:write/></d:privilege>
+        <d:privilege><d:bind/></d:privilege>
+        <d:privilege><d:unbind/></d:privilege>
+      </d:current-user-privilege-set>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+
+  <d:response>
+  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/D0504597-CF12-4AF6-9894-43724366D39D.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"6ce139775700c82011936b1958b6e081d62f27c711931804ecac2a1e5bb6e8f9"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/bday-complete.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/bday-datetime.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"e05100ccdcc77884ab0cd415544df3be9caaed41b37cf6f15b1675462e92bc14"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/bday-day-only.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"25ef184d775a0c7551be4cf5397292a36982d89565aa33e314d65bc0aae0299c"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"303991514fc3eb60721a39f014cf533a407039f558b4dfc5556bfc31c35173e9"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/bday-month-only.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"6aafe94f0d0857d91ee3a8bba7a49f9ceb758270cb5a2b4d35e7f48026494881"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/bday-omit-year.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"fe423fff45d51c9c49cc25d667a9fd2ee3f9f53dbb2be12acad15f1088b5c2a0"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/bday-year-month.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"aa31020a69a1123e0c3bfefa0cf34a8ecf8c58e2f4af6f293c62920a06a26357"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/bday-year-only.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"05e62746a7c24908a89e3ea9f4333a4033ed18d7398b1ff1c1ad192850924313"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/emoji.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"f2de8c9cbbc34e304510ff6f076ed3275e620802bc15731550b5d43b0d0ae994"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/household-alicia.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/household-george.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/household-mary.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/memoji.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"bb0734ee418c5bb60047503ea7675ccb46151d8e0084af665ba628bfc4acd804"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/oqmvpzooywwmtwwx.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"e63c84519663074659f6cd80ba7c4de31fe7c72c87736cbf630619bb0448d1cd"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+<d:response>
+  <d:href>/dav/addressbook/photo.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"9bd93308c95dc178599a5102bdc6f194f8cb02f838262f421fbe7e2d7c1ab281"</d:getetag>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+
+</d:multistatus>
diff --git a/test/fixtures/ios-exchange/04-report-multiget/request b/test/fixtures/ios-exchange/04-report-multiget/request
new file mode 100644
index 0000000..dfae428
--- /dev/null
+++ b/test/fixtures/ios-exchange/04-report-multiget/request
@@ -0,0 +1,11 @@
+REPORT /dav/addressbook/ HTTP/1.1
+Content-Type: text/xml
+
+<?xml version="1.0" encoding="UTF-8"?>
+<D:addressbook-multiget xmlns:D="urn:ietf:params:xml:ns:carddav">
+  <A:prop xmlns:A="DAV:">
+    <A:getetag/>
+    <D:address-data/>
+  </A:prop>
+  <A:href xmlns:A="DAV:">/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</A:href>
+</D:addressbook-multiget>
diff --git a/test/fixtures/ios-exchange/04-report-multiget/response b/test/fixtures/ios-exchange/04-report-multiget/response
new file mode 100644
index 0000000..a023f27
--- /dev/null
+++ b/test/fixtures/ios-exchange/04-report-multiget/response
@@ -0,0 +1,22 @@
+207
+Content-Type: text/xml
+
+<?xml version="1.0" encoding="UTF-8"?>
+<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+  <d:response>
+  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
+      <card:address-data>BEGIN:VCARD
+VERSION:3.0
+N:Contact;Test;;;
+FN:Test Contact
+UID:AB12C345-6789-0DEF-1234-567890ABCDEF
+END:VCARD</card:address-data>
+    </d:prop>
+    <d:status>HTTP/1.1 200 OK</d:status>
+  </d:propstat>
+</d:response>
+
+</d:multistatus>
diff --git a/test/fixtures/ios-exchange/05-put-contact-edit/request b/test/fixtures/ios-exchange/05-put-contact-edit/request
new file mode 100644
index 0000000..f2385f4
--- /dev/null
+++ b/test/fixtures/ios-exchange/05-put-contact-edit/request
@@ -0,0 +1,18 @@
+PUT /dav/addressbook/oqmvpzooywwmtwwx.vcf HTTP/1.1
+Content-Type: text/vcard; charset=utf-8
+If-Match: "e63c84519663074659f6cd80ba7c4de31fe7c72c87736cbf630619bb0448d1cd"
+
+BEGIN:VCARD
+VERSION:3.0
+PRODID:-//Apple Inc.//iOS 26.6.1//EN
+N:Lovelace;Ada;;;
+FN:Ada Lovelace
+NICKNAME:Enchantress of Numbers
+item1.EMAIL;type=INTERNET;type=pref:ada@example.com
+item2.TEL;type=pref:+1-555-0100
+item2.X-ABLabel:mobile
+item3.ADR;type=HOME;type=pref:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom
+NOTE:Test\n\nHi from iOS!
+REV:2026-09-08T22:47:52Z
+UID:oqmvpzooywwmtwwx
+END:VCARD
diff --git a/test/fixtures/ios-exchange/05-put-contact-edit/response b/test/fixtures/ios-exchange/05-put-contact-edit/response
new file mode 100644
index 0000000..7efa1ee
--- /dev/null
+++ b/test/fixtures/ios-exchange/05-put-contact-edit/response
@@ -0,0 +1,3 @@
+204
+ETag: "a1c21552c744c7d8735074f7cb3bcb053eb1e07f1e381e07f1ede57322af949a"
+
diff --git a/test/fixtures/ios-exchange/06-delete-contact/request b/test/fixtures/ios-exchange/06-delete-contact/request
new file mode 100644
index 0000000..1682fa2
--- /dev/null
+++ b/test/fixtures/ios-exchange/06-delete-contact/request
@@ -0,0 +1 @@
+DELETE /dav/addressbook/oqmvpzooywwmtwwx.vcf HTTP/1.1
diff --git a/test/fixtures/ios-exchange/06-delete-contact/response b/test/fixtures/ios-exchange/06-delete-contact/response
new file mode 100644
index 0000000..e291fbe
--- /dev/null
+++ b/test/fixtures/ios-exchange/06-delete-contact/response
@@ -0,0 +1,2 @@
+204
+
diff --git a/test/fixtures/ios-exchange/README.md b/test/fixtures/ios-exchange/README.md
new file mode 100644
index 0000000..529fb8b
--- /dev/null
+++ b/test/fixtures/ios-exchange/README.md
@@ -0,0 +1,45 @@
+# iOS exchange fixtures
+
+Request-level recordings of what iOS Contacts (26.6.1, dataaccessd/1.0)
+has sent to pro-tacts, from the session of 2026-09-08 — the first run
+against a phone, after `/setup` installed the configuration profile.
+The file format is `../macos-exchange/README.md`'s, and so is the rule
+that `request` files are evidence and `response` files are a snapshot
+`rake fixtures` rewrites.
+
+This directory is not a second baseline. It holds only the steps where
+iOS sends something macOS does not, so the whole of it is differences:
+
+| Step | What differs from macOS |
+|---|---|
+| 01-propfind-principal | Namespace prefixes: `D:` is CardDAV and `C:` is CalendarServer, the reverse of fixture 04's |
+| 02-propfind-addressbook-ctag | `C:` is CalendarServer, where fixture 05 uses `D:` |
+| 03-propfind-addressbook-bootstrap | `D:` is CardDAV, `E:` is me.com, `F:` is mobileme; fixture 09 spells the same three `B:`, `D:`, and `E:` |
+| 04-report-multiget | `D:` is CardDAV, and the request carries no `Brief`, `Depth`, or `Prefer` — all three of which macOS sends on the same report (fixture 07) |
+| 05-put-contact-edit | The card is grouped: `item1.EMAIL`, `item2.TEL` with its `X-ABLabel`, `item3.ADR`. macOS sends the same three properties ungrouped, and an `X-ADDRESSING-GRAMMAR` iOS omits |
+| 06-delete-contact | DELETE, which macOS has never sent |
+
+`D:` meaning CardDAV in one client and CalendarServer in another is the
+reason these steps are worth replaying: a prefix is arbitrary, and code
+that reads one as a name breaks on the client that spells it
+differently. Nothing here should ever be "fixed" to match macOS.
+
+The steps run in order against one store, so 05 edits the card 06 then
+removes.
+
+## Provenance
+
+Two things were taken from the capture rather than left as recorded:
+
+- Step 04 asked for all nineteen seed cards. It is recorded with the
+  one href fixture 07 uses, so the two multigets differ only in the
+  prefix and the missing headers — which is the whole point of keeping
+  it.
+- The bodies are read back from `log/dev.log`, which logs a request
+  line by line, so a body's final newline is not recoverable from it.
+  Step 05's card ends at `END:VCARD`, the same shortfall the macOS PUT
+  steps carry.
+
+Step 05's `If-Match` needed no rewrite, unlike the macOS PUT steps':
+`rake dev` seeds from `test/fixtures/cards`, so the etag the phone sent
+back is the one the replay's seed card hashes to.
diff --git a/test/pro_tacts/exchange_fixtures.rb b/test/pro_tacts/exchange_fixtures.rb
index f2317ee..5c511f4 100644
--- a/test/pro_tacts/exchange_fixtures.rb
+++ b/test/pro_tacts/exchange_fixtures.rb
@@ -1,90 +1,102 @@
-
 require "pathname"
 require "rack/test"
 
 require "pro_tacts/tailscale_auth"
 
-# Loads and replays the recorded macOS Contacts exchange in
-# test/fixtures/macos-exchange. See that directory's README for provenance
-# and the request/response file format.
-module ExchangeFixtures
-  DIRECTORY = Pathname.new(__dir__).parent / "fixtures" / "macos-exchange"
-
+# One recorded client exchange: a directory of numbered steps, replayed
+# in order. See each directory's README for provenance and the
+# request/response file format.
+#
+# Two clients are recorded, and they are not peers. MACOS is the whole
+# working session, the baseline every response change is measured
+# against. IOS holds only the steps where iOS sends something macOS
+# never did — a different namespace prefix, a header it omits, a verb
+# macOS has no equivalent for — so the second replay stays small and
+# every step in it is a difference someone can name.
+class ExchangeFixtures < Data.define(:directory)
   Step = Data.define(:name, :method, :path, :headers, :body)
   Response = Data.define(:status, :headers, :body)
 
+  FIXTURES = Pathname.new(__dir__).parent / "fixtures"
+
   # Response headers worth snapshotting; everything else (Content-Length,
   # Date, server) is plumbing.
   RESPONSE_HEADERS = %w[Content-Type ETag DAV Allow Location].freeze
 
-  class << self
-    def steps
-      Dir.children(DIRECTORY)
-        .select { (DIRECTORY / it).directory? }
-        .sort
-        .map { |name| Step.new(name:, **parse_request(name)) }
-    end
+  # Stands in for the Tailscale-User-Login the recorded sessions carried.
+  # It was stripped from the request files because it names a real tailnet
+  # user; the app now refuses requests without one, so the replay has to
+  # put an identity back.
+  REPLAY_LOGIN = "replay@example.com"
 
-    def parse_request(name)
-      request_line, header_lines, body = split_message(read(name, "request"))
-      method, path, = request_line.split(" ")
-      { method:, path:, headers: parse_headers(header_lines), body: }
-    end
+  # Every recording, for the callers that run all of them.
+  def self.all
+    [MACOS, IOS]
+  end
 
-    def recorded_response(step)
-      status_line, header_lines, body = split_message(read(step.name, "response"))
-      Response.new(status: status_line.to_i, headers: parse_headers(header_lines), body:)
-    end
+  def steps
+    Dir.children(directory)
+      .select { (directory / it).directory? }
+      .sort
+      .map { |name| Step.new(name:, **parse_request(name)) }
+  end
 
-    # Stands in for the Tailscale-User-Login the recorded session carried.
-    # It was stripped from the request files because it names a real tailnet
-    # user; the app now refuses requests without one, so the replay has to
-    # put an identity back.
-    REPLAY_LOGIN = "replay@example.com"
-
-    # Rack env for the recorded headers: Content-Type and Depth are the only
-    # ones the app reads, but replaying all of them keeps the fidelity.
-    def env_for(step)
-      step.headers.to_h { |name, value|
-        key = name == "Content-Type" ? "CONTENT_TYPE" : "HTTP_#{name.tr('-', '_').upcase}"
-        [key, value]
-      }.merge(ProTacts::TailscaleAuth::LOGIN_HEADER => REPLAY_LOGIN)
-    end
+  def parse_request(name)
+    request_line, header_lines, body = split_message(read(name, "request"))
+    method, path, = request_line.split(" ")
+    { method:, path:, headers: parse_headers(header_lines), body: }
+  end
 
-    # Replays every recorded request against the app and rewrites the
-    # response fixtures. Rake task "fixtures"; run it after deliberately
-    # changing responses, and review the diff.
-    def record_responses(app)
-      session = Rack::Test::Session.new(Rack::MockSession.new(app))
-      steps.each do |step|
-        session.request(step.path, { method: step.method, input: step.body }.merge(env_for(step)))
-        write_response(step, session.last_response)
-      end
-    end
+  def recorded_response(step)
+    status_line, header_lines, body = split_message(read(step.name, "response"))
+    Response.new(status: status_line.to_i, headers: parse_headers(header_lines), body:)
+  end
 
-    private
+  # Rack env for the recorded headers: Content-Type and Depth are the only
+  # ones the app reads, but replaying all of them keeps the fidelity.
+  def env_for(step)
+    step.headers.to_h { |name, value|
+      key = name == "Content-Type" ? "CONTENT_TYPE" : "HTTP_#{name.tr('-', '_').upcase}"
+      [key, value]
+    }.merge(ProTacts::TailscaleAuth::LOGIN_HEADER => REPLAY_LOGIN)
+  end
 
-    def read(name, file)
-      File.read(DIRECTORY / name / file)
+  # Replays every recorded request against the app and rewrites the
+  # response fixtures. Rake task "fixtures"; run it after deliberately
+  # changing responses, and review the diff.
+  def record_responses(app)
+    session = Rack::Test::Session.new(Rack::MockSession.new(app))
+    steps.each do |step|
+      session.request(step.path, { method: step.method, input: step.body }.merge(env_for(step)))
+      write_response(step, session.last_response)
     end
+  end
 
-    def split_message(raw)
-      head, body = raw.split("\n\n", 2)
-      lines = head.split("\n")
-      [lines.first, lines.drop(1), body.to_s.chomp]
-    end
+  private
 
-    def parse_headers(lines)
-      lines.to_h { |line| line.split(": ", 2) }
-    end
+  def read(name, file)
+    File.read(directory / name / file)
+  end
 
-    def write_response(step, response)
-      headers = RESPONSE_HEADERS
-        .filter_map { |name| response[name] && "#{name}: #{response[name]}" }
-      content = ([response.status.to_s] + headers).join("\n") + "\n\n"
-      body = response.body.chomp
-      content = "#{content}#{body}\n" unless body.empty?
-      File.write(DIRECTORY / step.name / "response", content)
-    end
+  def split_message(raw)
+    head, body = raw.split("\n\n", 2)
+    lines = head.split("\n")
+    [lines.first, lines.drop(1), body.to_s.chomp]
+  end
+
+  def parse_headers(lines)
+    lines.to_h { |line| line.split(": ", 2) }
+  end
+
+  def write_response(step, response)
+    headers = RESPONSE_HEADERS
+      .filter_map { |name| response[name] && "#{name}: #{response[name]}" }
+    content = ([response.status.to_s] + headers).join("\n") + "\n\n"
+    body = response.body.chomp
+    content = "#{content}#{body}\n" unless body.empty?
+    File.write(directory / step.name / "response", content)
   end
 end
+
+ExchangeFixtures::MACOS = ExchangeFixtures.new(ExchangeFixtures::FIXTURES / "macos-exchange")
+ExchangeFixtures::IOS = ExchangeFixtures.new(ExchangeFixtures::FIXTURES / "ios-exchange")
diff --git a/test/pro_tacts/test_exchanges.rb b/test/pro_tacts/test_exchanges.rb
new file mode 100644
index 0000000..05eed77
--- /dev/null
+++ b/test/pro_tacts/test_exchanges.rb
@@ -0,0 +1,64 @@
+require_relative "../test_helper"
+require_relative "../fixture_data"
+require "pathname"
+require "rack/test"
+
+require_relative "exchange_fixtures"
+require "pro_tacts/web"
+
+# Replays each recorded client session against the app and compares
+# every response, byte for byte, with the recorded baseline. A failure
+# means the responses drifted from what a working client session saw;
+# either fix the drift or, if the change was deliberate, regenerate with
+# `rake fixtures`.
+#
+# The steps of one recording run in sorted order against one freshly
+# seeded store, in a single test: the session is a conversation, and a
+# step that writes has to be able to change what the steps after it see.
+# A failure stops that replay at the first divergent step — the steps
+# after it would be measuring a state the real session never produced.
+#
+# The two recordings are independent conversations and get a store each,
+# so what the iOS session deletes is still there for the macOS one.
+class ExchangesTest < Minitest::Test
+  include Rack::Test::Methods
+
+  def app
+    ProTacts::Web
+  end
+
+  def test_the_recorded_macos_session_replays_in_order
+    replay(ExchangeFixtures::MACOS)
+  end
+
+  def test_the_recorded_ios_session_replays_in_order
+    replay(ExchangeFixtures::IOS)
+  end
+
+  private
+
+  def replay(recording)
+    # A store of its own so the writes a session may make never touch the
+    # one the rest of the suite reads, built fresh so a replay never depends
+    # on what a previous run left behind.
+    directory = Pathname.new(__dir__).parent.parent / "tmp" / recording.directory.basename
+    store = FixtureData.install(directory)
+    original = ProTacts::Web.store
+    ProTacts::Web.store = store
+
+    recording.steps.each do |step|
+      request step.path, { method: step.method, input: step.body }.merge(recording.env_for(step))
+
+      expected = recording.recorded_response(step)
+
+      assert_equal expected.status, last_response.status, "#{step.name}: status"
+      expected.headers.each do |name, value|
+        assert_equal value, last_response[name], "#{step.name}: #{name}"
+      end
+      assert_equal expected.body, last_response.body.chomp, "#{step.name}: body"
+    end
+  ensure
+    ProTacts::Web.store = original
+    store&.close
+  end
+end
diff --git a/test/pro_tacts/test_macos_exchange.rb b/test/pro_tacts/test_macos_exchange.rb
deleted file mode 100644
index 4a53c18..0000000
--- a/test/pro_tacts/test_macos_exchange.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-require_relative "../test_helper"
-require_relative "../fixture_data"
-require "pathname"
-require "rack/test"
-
-require_relative "exchange_fixtures"
-require "pro_tacts/web"
-
-# Replays the recorded macOS Contacts session against the app and compares
-# each response, byte for byte, with the recorded baseline. A failure means
-# the responses drifted from what the working client session saw; either fix
-# the drift or, if the change was deliberate, regenerate with `rake fixtures`.
-#
-# The steps run in sorted order against one freshly seeded store, in a
-# single test: the session is a conversation, and a step that writes has to
-# be able to change what the steps after it see. A failure stops the replay
-# at the first divergent step — the steps after it would be measuring a
-# state the real session never produced.
-class MacosExchangeTest < Minitest::Test
-  include Rack::Test::Methods
-
-  def app
-    ProTacts::Web
-  end
-
-  def test_the_recorded_session_replays_in_order
-    # A store of its own so the writes the session may make never touch the
-    # one the rest of the suite reads, built fresh so a replay never depends
-    # on what a previous run left behind.
-    directory = Pathname.new(__dir__).parent.parent / "tmp" / "test-exchange"
-    store = FixtureData.install(directory)
-    original = ProTacts::Web.store
-    ProTacts::Web.store = store
-
-    ExchangeFixtures.steps.each do |step|
-      request step.path, { method: step.method, input: step.body }.merge(ExchangeFixtures.env_for(step))
-
-      expected = ExchangeFixtures.recorded_response(step)
-
-      assert_equal expected.status, last_response.status, "#{step.name}: status"
-      expected.headers.each do |name, value|
-        assert_equal value, last_response[name], "#{step.name}: #{name}"
-      end
-      assert_equal expected.body, last_response.body.chomp, "#{step.name}: body"
-    end
-  ensure
-    ProTacts::Web.store = original
-    store&.close
-  end
-end