web: serve each user their own book
Each DAV route serves the requester's book, and a card outside it is a 404;
tokens name the book they came from, so step 08's old token gets a 410, not the delta it got before.

Assisted-by: Claude Opus 5 via Claude Code
change wxmqulnpoymklwnynqpmtumtnnvnytwy
commit b0cc25b02eb4a5d5f8028e634096dbee2bb116f9
author Alpha Chen <alpha@kejadlen.dev>
date
parent vxpnwrsr
diff --git a/README.md b/README.md
index f9081e9..5ea1f28 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,11 @@ while the app is reachable through serve alone — bind it to localhost.
 Tailscale documents two cases that carry no identity and so cannot get in:
 Funnel traffic, which is public, and traffic from tagged devices.
 
+Each user syncs a book of their own rather than every card: the members
+of the group `sync:*`, which everyone gets, and of `sync:<name>`, where
+the name is the user's Tailscale display name. A card a client creates
+joins its writer's group. See `docs/plans/2026-09-12-per-user-books.md`.
+
 Requests the server cannot answer — a 404, a refused report, or a crash —
 are kept under `log/unhandled`, one directory per distinct request, in the
 same layout as `test/fixtures/macos-exchange`. A client asking for
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index d86253f..0d84ffb 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -1,4 +1,5 @@
 
+require "digest"
 require "pathname"
 require "securerandom"
 
@@ -30,6 +31,7 @@ module ProTacts
     # @rbs @contacts: Array[Contact]?
     # @rbs @ctag: String?
     # @rbs @identity: TailscaleAuth::Identity
+    # @rbs @book: Set[String]?
 
     # The vendored Gloss CSS and the admin app's own stylesheet (see
     # docs/DESIGN.md); relative to this file rather than $0 for the same
@@ -494,9 +496,13 @@ module ProTacts
               # The warm-sync ask is etag-only; a changed etag sends the
               # client back through multiget, so no address-data here.
               #
-              # The token carries the change log's sequence (see
-              # #sync_token), and the delta is the log after it: one
-              # response per member, the net of everything it did in the
+              # The token carries the change log's sequence and whose book
+              # it was issued for (see #sync_token), and the delta is the
+              # log after the sequence, answered from the requester's book
+              # as it is now: a card that left it answers as removed
+              # (section 3.5.2), and so does one that never was in it,
+              # which the log cannot tell apart. One response per
+              # member, the net of everything it did in the
               # window — a member put twice since the token answers once,
               # at its current etag, the multiple-changes case section 3.5
               # allows one response for. A member whose net is a removal
@@ -514,7 +520,7 @@ module ProTacts
                 # No token is the initial sync: every member, changed
                 # (section 3.4).
                 multistatus(contacts.map { etag_response(it) }, sync_token:)
-              elsif (sequence = token[%r{\Ahttp://pro-tacts/sync/(\d+)\z}, 1]) &&
+              elsif (sequence = token[%r{\Ahttp://pro-tacts/sync/(\d+)/#{book_digest}\z}, 1]) &&
                   sequence.to_i <= store.latest_sequence
                 net = store.changes(after: sequence.to_i).map { it.card_id }.uniq
                 responses = net.map { |id|
@@ -523,8 +529,9 @@ module ProTacts
                 }
                 multistatus(responses, sync_token:)
               else
-                # A token this server never issued, or one naming a state
-                # past the present: the section's DAV:valid-sync-token
+                # A token this server never issued, one issued for another
+                # book, or one naming a state past the present: the
+                # section's DAV:valid-sync-token
                 # precondition, marshalled per RFC 4918 section 16. 410
                 # rather than 403 because its fallback is a full resync —
                 # the request will not always fail, and the state the
@@ -581,7 +588,7 @@ module ProTacts
           # Read on its own rather than through the collection: serving
           # one href has no reason to load every other contact first.
           r.get String do |filename|
-            contact = store.contact(filename.delete_suffix(".vcf"))
+            contact = member(filename.delete_suffix(".vcf"))
 
             if contact
               response["Content-Type"] = "text/vcard; charset=utf-8"
@@ -622,9 +629,25 @@ module ProTacts
     # Read once per request — Roda builds a fresh app instance for each
     # one — from the store the whole process shares. Reading a family
     # address book per request is cheap and can never serve a stale one.
+    #
+    # The requester's book and nothing else: every route under /dav serves
+    # that collection (docs/plans/2026-09-12-per-user-books.md).
     #: () -> Array[Contact]
     def contacts
-      @contacts ||= store.contacts
+      @contacts ||= store.contacts.select { book.include?(it.id) }
+    end
+
+    #: () -> Set[String]
+    def book
+      @book ||= store.book(@identity.name)
+    end
+
+    # One member of the requester's book, or nil for a card outside it
+    # as for one that does not exist: neither is a member of this
+    # collection.
+    #: (String id) -> Contact?
+    def member(id)
+      store.contact(id) if book.include?(id)
     end
 
     #: () -> String
@@ -662,10 +685,20 @@ module ProTacts
 
     # Sync tokens are opaque to the client (RFC 6578 section 3); the URI
     # form is conventional. Built on the ctag so a client polling either
-    # one sees changes at the same points.
+    # one sees changes at the same points, and on the requester's name so
+    # that a token is refused by any other book, the one a rename leaves
+    # a user with included (docs/plans/2026-09-12-per-user-books.md, "The
+    # wire").
     #: () -> String
     def sync_token
-      "http://pro-tacts/sync/#{ctag}"
+      "http://pro-tacts/sync/#{ctag}/#{book_digest}"
+    end
+
+    # The name half of a sync token: the first 16 hex digits of the
+    # SHA-256 of the requester's display name.
+    #: () -> String
+    def book_digest
+      Digest::SHA256.hexdigest(@identity.name)[0, 16].to_s
     end
 
     #: () -> Store
@@ -682,6 +715,11 @@ module ProTacts
     # CARDDAV:valid-address-data even when its If-Match is stale too.
     #: (String id) -> String?
     def write_card(id)
+      # A card outside the requester's book is no member of this
+      # collection (#member), and its id cannot be created here either,
+      # being taken: the 404 the GET of it gets.
+      return if !book.include?(id) && store.contact(id)
+
       # CARDDAV:supported-address-data (RFC 6352 section 6.3.2.1): what
       # arrived must be a vCard, and text/vcard is the one media type
       # this server stores. Asked before the body is read because it is
@@ -739,7 +777,7 @@ module ProTacts
       report_unreadable_lines(vcard)
       report_broken_assumptions(vcard)
 
-      stored = store.put(id, vcard)
+      stored = store.put(id, vcard, sync_to: @identity.name)
       response.status = existing ? 204 : 201
       # A strong ETag belongs on the answer only when what the resource
       # now serves is the submitted bytes, octet for octet — the one
@@ -767,8 +805,9 @@ module ProTacts
       # An unmapped URI is a 404, not a silent success: RFC 4918 section
       # 9.6 gives DELETE no idempotent status, and a client deleting what
       # it believes exists is owed the disagreement. Falls through to the
-      # not_found handler the way the GET and PUT of a bad id do.
-      existing = store.contact(id)
+      # not_found handler the way the GET and PUT of a bad id do. A card
+      # outside the requester's book is unmapped here too (#member).
+      existing = member(id)
       return unless existing
 
       # The lost-update conditional, RFC 7232 section 3.1, on the same
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 2aab554..4881d5b 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -86,10 +86,14 @@ module FixtureData
     cards.keys.grep(/\Abday-/)
   end
 
+  # The third is `sync:*` holding every card, so every user's book is
+  # the whole seed — the replay's included, whose recorded sessions saw
+  # every card (docs/plans/2026-09-12-per-user-books.md).
   #: (ProTacts::Store store) -> void
   def self.seed_groups(store)
     seed_group(store, name: "Booles", lines: HOUSEHOLD, members: MEMBERS)
     seed_group(store, members: birthday_cards)
+    seed_group(store, name: ProTacts::Store::EVERYONE, members: cards.keys)
   end
 
   # A group, seeded without a change-log entry for any member it moves
diff --git a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
index 2dbbd28..b7d86f1 100644
--- a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
+++ b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
@@ -17,7 +17,7 @@ Content-Type: text/xml
         </d:supported-report>
       </d:supported-report-set>
       <cs:getctag>18</cs:getctag>
-      <d:sync-token>http://pro-tacts/sync/18</d:sync-token>
+      <d:sync-token>http://pro-tacts/sync/18/c8dae6371b33b8d6</d:sync-token>
       <d:current-user-privilege-set>
         <d:privilege><d:read/></d:privilege>
         <d:privilege><d:write/></d:privilege>
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index 567431e..c949537 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -17,7 +17,7 @@ Content-Type: text/xml
         </d:supported-report>
       </d:supported-report-set>
       <cs:getctag>18</cs:getctag>
-      <d:sync-token>http://pro-tacts/sync/18</d:sync-token>
+      <d:sync-token>http://pro-tacts/sync/18/c8dae6371b33b8d6</d:sync-token>
       <d:current-user-privilege-set>
         <d:privilege><d:read/></d:privilege>
         <d:privilege><d:write/></d:privilege>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index 2dbbd28..b7d86f1 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -17,7 +17,7 @@ Content-Type: text/xml
         </d:supported-report>
       </d:supported-report-set>
       <cs:getctag>18</cs:getctag>
-      <d:sync-token>http://pro-tacts/sync/18</d:sync-token>
+      <d:sync-token>http://pro-tacts/sync/18/c8dae6371b33b8d6</d:sync-token>
       <d:current-user-privilege-set>
         <d:privilege><d:read/></d:privilege>
         <d:privilege><d:write/></d:privilege>
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
index f6c4ddb..55e4357 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -1,161 +1,7 @@
-207
+410
 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/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>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</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>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</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>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</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>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</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>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</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>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</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:sync-token>http://pro-tacts/sync/18</d:sync-token>
-
-</d:multistatus>
+<d:error xmlns:d="DAV:">
+  <d:valid-sync-token/>
+</d:error>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 567431e..c949537 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -17,7 +17,7 @@ Content-Type: text/xml
         </d:supported-report>
       </d:supported-report-set>
       <cs:getctag>18</cs:getctag>
-      <d:sync-token>http://pro-tacts/sync/18</d:sync-token>
+      <d:sync-token>http://pro-tacts/sync/18/c8dae6371b33b8d6</d:sync-token>
       <d:current-user-privilege-set>
         <d:privilege><d:read/></d:privilege>
         <d:privilege><d:write/></d:privilege>
diff --git a/test/fixtures/macos-exchange/README.md b/test/fixtures/macos-exchange/README.md
index b639728..8bdd6ad 100644
--- a/test/fixtures/macos-exchange/README.md
+++ b/test/fixtures/macos-exchange/README.md
@@ -27,6 +27,14 @@ behavior: regenerate them with `rake fixtures` after changing responses
 deliberately, and review the diff — accidental drift is exactly what the
 comparison test exists to catch.
 
+## The sync step
+
+Step 08's request carries `http://pro-tacts/sync/1`, a token from before
+tokens named the book they were issued for
+(`docs/plans/2026-09-12-per-user-books.md`). The replay answers it with
+410 and `DAV:valid-sync-token` rather than the 207 delta the recorded
+session received. What macOS does after a 410 has not been recorded.
+
 ## The PUT steps
 
 Both are the same edit to the same contact. Step 10 is the edit, which
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 7605e4b..dc7030d 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -768,8 +768,20 @@ class WebTest < Minitest::Test
     text.gsub("&lt;", "<").gsub("&gt;", ">").gsub("&amp;", "&")
   end
 
-  # Contacts by id and name, each a bare card.
-  def with_contacts(names, &) = super(names.to_h { |id, name| [id, card(id, name)] }, &)
+  # Contacts by id and name, each a bare card, and every one in `sync:*`
+  # so the requester's book is all of them. Seeded without a change-log
+  # entry, so the sequences the sync tests read are the puts' alone.
+  def with_contacts(names, &block)
+    super(names.to_h { |id, name| [id, card(id, name)] }) do |store|
+      FixtureData.seed_group(store, name: ProTacts::Store::EVERYONE, members: names.keys)
+      block.call(store)
+    end
+  end
+
+  # A sync token as the route issues one to a user (Web#sync_token).
+  def issued_token(sequence, name: "Test User")
+    "http://pro-tacts/sync/#{sequence}/#{Digest::SHA256.hexdigest(name)[0, 16]}"
+  end
 
   def etag_only_propfind
     <<~XML
@@ -868,7 +880,7 @@ class WebTest < Minitest::Test
   # here.
   def test_sync_collection_reports_the_delta_since_the_token
     with_contacts({"aiden" => "Aiden"}) do |store|
-      request "/dav/addressbook/", method: "REPORT", input: sync_collection("http://pro-tacts/sync/0")
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(0))
 
       assert_equal 207, last_response.status
       assert_includes last_response.body, "/dav/addressbook/aiden.vcf"
@@ -898,14 +910,14 @@ class WebTest < Minitest::Test
       assert_equal 207, last_response.status
       assert_includes last_response.body, "/dav/addressbook/aiden.vcf"
       assert_includes last_response.body, "/dav/addressbook/znorth.vcf"
-      assert_includes last_response.body, "<d:sync-token>http://pro-tacts/sync/2</d:sync-token>"
+      assert_includes last_response.body, "<d:sync-token>#{issued_token(2)}</d:sync-token>"
     end
   end
 
   # RFC 6578 section 3.2 spells a removal href and 404, no propstat.
   def test_sync_collection_reports_a_removal_as_404
     with_contacts({"aiden" => "Aiden", "znorth" => "Znorth"}) do |store|
-      request "/dav/addressbook/", method: "REPORT", input: sync_collection("http://pro-tacts/sync/2")
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(2))
       token = last_response.body[%r{<d:sync-token>(.+)</d:sync-token>}, 1]
       store.delete("znorth")
 
@@ -924,7 +936,7 @@ class WebTest < Minitest::Test
   # section 3.2, marshalled per RFC 4918 section 16.
   def test_sync_collection_refuses_a_token_it_never_issued
     with_contacts({"aiden" => "Aiden"}) do
-      request "/dav/addressbook/", method: "REPORT", input: sync_collection("http://pro-tacts/sync/9")
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(9))
 
       assert_equal 410, last_response.status
       assert_includes last_response.body, "<d:valid-sync-token/>"
@@ -935,6 +947,94 @@ class WebTest < Minitest::Test
     end
   end
 
+  # A token names the book it was issued for, so a user whose name
+  # changed resyncs rather than taking a delta against another book —
+  # and a token from before tokens named one is refused the same way.
+  def test_sync_collection_refuses_a_token_from_another_book
+    with_contacts({"aiden" => "Aiden"}) do
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(1, name: "Zoë Chen"))
+      assert_equal 410, last_response.status
+
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection("http://pro-tacts/sync/1")
+      assert_equal 410, last_response.status
+
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(1))
+      assert_equal 207, last_response.status
+    end
+  end
+
+  ## Books
+
+  # A card in nobody's book is no member of this collection, to any
+  # method (docs/plans/2026-09-12-per-user-books.md, "The wire").
+  def test_a_card_outside_the_book_is_not_a_member
+    with_contacts({"aiden" => "Aiden"}) do |store|
+      store.put("znorth", ProTacts::VCard.new(card("znorth", "Zed")))
+
+      request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
+      assert_includes last_response.body, "aiden.vcf"
+      refute_includes last_response.body, "znorth"
+
+      get "/dav/addressbook/znorth.vcf"
+      assert_equal 404, last_response.status
+
+      request "/dav/addressbook/", method: "REPORT", input: multiget("znorth")
+      assert_includes last_response.body, "HTTP/1.1 404 Not Found"
+      refute_includes last_response.body, "FN:Zed"
+
+      put_request "znorth", card("znorth", "Zed Smith"), "CONTENT_TYPE" => VCARD
+      assert_equal 404, last_response.status
+
+      delete "/dav/addressbook/znorth.vcf"
+      assert_equal 404, last_response.status
+      assert_includes store.contact("znorth").vcard.to_s, "FN:Zed\r\n"
+    end
+  end
+
+  def test_each_user_syncs_their_own_book
+    with_contacts({"aiden" => "Aiden"}) do |store|
+      store.put("znorth", ProTacts::VCard.new(card("znorth", "Zed")))
+      FixtureData.seed_group(store, name: "sync:Zoë Chen", members: ["znorth"])
+
+      request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
+      refute_includes last_response.body, "znorth"
+
+      header "Tailscale-User-Name", "=?utf-8?q?Zo=C3=AB_Chen?="
+      request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
+      assert_includes last_response.body, "aiden.vcf"
+      assert_includes last_response.body, "znorth.vcf"
+    end
+  end
+
+  # A card a client creates stays in the collection it was written to.
+  def test_a_put_create_joins_the_writers_book
+    with_contacts({}) do |store|
+      put_request "new", card("new", "New"), "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+      assert_equal 201, last_response.status
+      assert_equal Set["new"], store.book("Test User")
+    end
+  end
+
+  # RFC 6578 section 3.5.2: a card whose mapping left the collection is
+  # reported removed, which a card leaving the book is.
+  def test_a_card_leaving_the_book_reaches_a_syncing_client_as_a_removal
+    with_contacts({"aiden" => "Aiden", "znorth" => "Zed"}) do |store|
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection("")
+      token = last_response.body[%r{<d:sync-token>([^<]+)</d:sync-token>}, 1]
+      everyone = store.all_groups.find { it.name == ProTacts::Store::EVERYONE }.id
+
+      store.remove_member(everyone, "znorth")
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(token)
+
+      body = last_response.body
+      assert_equal 207, last_response.status
+      assert_includes body, "<d:href>/dav/addressbook/znorth.vcf</d:href>"
+      assert_includes body, "HTTP/1.1 404 Not Found"
+      refute_includes body, "aiden"
+    end
+  end
+
   # RFC 6352 section 8.6 defines addressbook-query, which this server does
   # not implement. Before the DAV:supported-report precondition was enforced
   # it fell through to the multiget branch, found no hrefs, and answered with