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>
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