dav: put a client's new card in everyone's book
A card a phone creates joins sync:*, where the whole family sees it through;
the admin takes it out, since membership is visible there, and editable too.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change ulpytkvusrykrsmxqssvpkzvstryummr
commit b26e4d17319844d1791d02d1f22e1e1575ab00f5
author Alpha Chen <alpha@kejadlen.dev>
date
parent kuknplqs
diff --git a/README.md b/README.md
index a40e657..fb13d65 100644
--- a/README.md
+++ b/README.md
@@ -68,8 +68,9 @@ 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 in any case. A card a
-client creates joins its writer's group. See
-`docs/plans/2026-09-12-per-user-books.md`.
+client creates joins `sync:*`, so everyone gets it until someone takes
+it out. See `docs/plans/2026-09-12-per-user-books.md` and
+`docs/plans/2026-09-15-client-creates-join-everyone.md`.
 
 DAV exchanges that go wrong — a status of 400 or more other than the 401
 above, a crash, or a request that reported to Sentry — are written whole
diff --git a/docs/plans/2026-09-15-client-creates-join-everyone.md b/docs/plans/2026-09-15-client-creates-join-everyone.md
new file mode 100644
index 0000000..85b9799
--- /dev/null
+++ b/docs/plans/2026-09-15-client-creates-join-everyone.md
@@ -0,0 +1,17 @@
+# Client creates join everyone's book
+
+2026-09-15. This changes one rule in
+`2026-09-12-per-user-books.md`, "The wire": a PUT that creates a card
+adds it to `sync:*`, not to the writer's `sync:<name>` group.
+
+The card still stays in the writer's book, because `sync:*` is in every
+book. It also reaches everyone else's, and that is the intent. A new
+contact is in plain sight for the family, and one that should be a
+single person's is taken out of `sync:*` in the admin UI, where
+membership is visible and editable. The writer's own group was neither:
+a phone never shows it, and nobody was told a group had been made.
+
+The create no longer needs the writer's name, so it no longer depends
+on how a `sync:<name>` group is spelled. Books still match the display
+name in any case (`Store#book`), but no write has to find the one group
+that a name means.
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 642aa8d..91df6eb 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -410,13 +410,12 @@ module ProTacts
     # construction the caller makes, is the assertion under that, and
     # the bind is the third line.
     #
-    # `sync_to` is the writing user's display name, for a client's
-    # write: a card it creates joins that user's `sync:` group, created
-    # on first use, or it would drop out of the collection it was
-    # written to (docs/plans/2026-09-12-per-user-books.md, "The wire").
+    # `client` marks a client's write: a card it creates joins `sync:*`,
+    # created on first use, or it would drop out of the collection it
+    # was written to (docs/plans/2026-09-15-client-creates-join-everyone.md).
     # A rewrite of a card that exists joins nothing.
-    #: (String id, VCard vcard, ?sync_to: String?) -> Contact
-    def put(id, vcard, sync_to: nil)
+    #: (String id, VCard vcard, ?client: bool) -> Contact
+    def put(id, vcard, client: false)
       # The birthday half of the split a write makes
       # (docs/plans/2026-09-11-every-birthday-in-the-model.md). A card
       # holds at most one BDAY, and one that reads as a birthday leaves
@@ -495,8 +494,8 @@ module ProTacts
         # before the log entry, which records the card composed with
         # whatever the group lends. The put's entry is the card's arrival
         # in the book, so the join writes none of its own.
-        if sync_to && own.nil?
-          group_members.insert(group_id: sync_group_id(sync_to), card_id: contact.id)
+        if client && own.nil?
+          group_members.insert(group_id: everyone_group_id, card_id: contact.id)
           contact = Contact.new(id:, stored:, birthday:, inherited: inherited_of(contact.id))
         end
         write_birthday(contact.id, birthday)
@@ -856,14 +855,14 @@ module ProTacts
       }
     end
 
-    # The id of one user's `sync:` group, created on first use. `sole`
-    # because a `sync:` name is unique (db/migrations/007_sync_names.rb),
-    # and no row is the ordinary answer for a user's first create.
-    #: (String name) -> String
-    def sync_group_id(name)
-      groups.where(name: "#{SYNC_PREFIX}#{name}").sole.fetch(:id).to_s
+    # The id of the `sync:*` group, created on first use. `sole` because
+    # a `sync:` name is unique (db/migrations/007_sync_names.rb), and no
+    # row is the ordinary answer for the first create.
+    #: () -> String
+    def everyone_group_id
+      groups.where(name: EVERYONE).sole.fetch(:id).to_s
     rescue Sequel::NoMatchingRow
-      create_group(name: "#{SYNC_PREFIX}#{name}")
+      create_group(name: EVERYONE)
     end
 
     #: (String? name) -> bool
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index d09ecdd..3a248bb 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -427,7 +427,7 @@ module ProTacts
       report_unreadable_lines(vcard)
       report_broken_assumptions(vcard)
 
-      stored = store.put(id, vcard, sync_to: @identity.name)
+      stored = store.put(id, vcard, client: true)
       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
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index ac7cb16..c58e9a7 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1373,22 +1373,22 @@ class StoreTest < Minitest::Test
     end
   end
 
-  # The put's own entry is the card's arrival in the book, so the join
+  # The put's own entry is the card's arrival in every book, so the join
   # logs nothing beside it.
-  def test_a_client_create_joins_the_writers_book
+  def test_a_client_create_joins_everyones_book
     with_store do |store|
-      store.put("aiden", vcard(AIDEN), sync_to: "Alpha Chen")
-      store.put("znorth", vcard(ZED), sync_to: "Alpha Chen")
+      store.put("aiden", vcard(AIDEN), client: true)
+      store.put("znorth", vcard(ZED), client: true)
 
-      assert_equal Set["aiden", "znorth"], store.book("Alpha Chen")
-      assert_equal ["sync:Alpha Chen"], store.all_groups.map(&:name)
+      assert_equal Set["aiden", "znorth"], store.book("Zoë Chen")
+      assert_equal ["sync:*"], store.all_groups.map(&:name)
       assert_equal ["put"], store.changes_of("aiden").map(&:action)
     end
   end
 
   def test_a_client_rewrite_joins_nothing
     with_store({"aiden" => AIDEN}) do |store|
-      store.put("aiden", vcard(AIDEN), sync_to: "Alpha Chen")
+      store.put("aiden", vcard(AIDEN), client: true)
 
       assert_empty store.book("Alpha Chen")
     end
@@ -1398,9 +1398,9 @@ class StoreTest < Minitest::Test
   # is the card as the book serves it.
   def test_a_client_create_is_logged_as_its_book_serves_it
     with_store do |store|
-      FixtureData.seed_group(store, name: "sync:Alpha Chen", lines: [HOUSEHOLD_NOTE])
+      FixtureData.seed_group(store, name: "sync:*", lines: [HOUSEHOLD_NOTE])
 
-      contact = store.put("aiden", vcard(AIDEN), sync_to: "Alpha Chen")
+      contact = store.put("aiden", vcard(AIDEN), client: true)
 
       assert_includes contact.vcard.to_s, HOUSEHOLD_NOTE
       assert_equal store.contact("aiden").etag, store.changes_of("aiden").first.etag
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 3a7fd67..494b913 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -1012,13 +1012,15 @@ class WebTest < Minitest::Test
     end
   end
 
-  # A card a client creates stays in the collection it was written to.
-  def test_a_put_create_joins_the_writers_book
+  # A card a client creates stays in the collection it was written to,
+  # and reaches everyone else's.
+  def test_a_put_create_joins_everyones_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")
+      assert_equal Set["new"], store.book("Zoë Chen")
     end
   end