books: name a login's book with a rake task
The group read sync:alpha@example.com, a login where a name should be;
rake book:name picks the name and renames the group, members logged for free.
Now that a deployment chooses it, the book no longer folds case —
sync:<name> matches as spelled, the unique index's own base.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change vsnynpwtxqluktupyrsvwyyostvotoyu
commit eef2e35e1ede90805c37c843cee1e16c0c156c94
author Alpha Chen <alpha@kejadlen.dev>
date
parent qmyznkrx
diff --git a/AGENTS.md b/AGENTS.md
index 21adb2f..169d735 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,6 +17,8 @@ rake fixtures         # Re-record response fixtures from current behavior
 rake fixtures:extract # A logged exchange's request as a fixture step
                       # (EXCHANGE=id STEP=test/fixtures/<recording>/NN-name)
 rake index:rebuild    # Derive the parsed index again from the stored cards
+rake book:name        # Name a login's book and rename its sync group
+                      # (LOGIN=login NAME=name; no NAME clears it)
 rake db:dump          # Write the cards, birthdays, and groups to data/dump
                       # (DUMP=path to move it)
 rake dev              # Dev server, seeded from test/fixtures/cards into a
diff --git a/README.md b/README.md
index e86d64e..d4d18d8 100644
--- a/README.md
+++ b/README.md
@@ -70,11 +70,14 @@ 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:<login>`,
-matched in any case. A card a client creates joins `sync:*`, so everyone
+matched exactly. `rake book:name LOGIN=... NAME=...` gives a login's
+book a name to use in place of the login, renaming its group to
+`sync:<name>`. A card a client creates joins `sync:*`, so everyone
 gets it until someone takes it out. See
 `docs/plans/2026-09-12-per-user-books.md`,
-`docs/plans/2026-09-15-client-creates-join-everyone.md`, and
-`docs/plans/2026-09-15-identity-from-one-header.md`.
+`docs/plans/2026-09-15-client-creates-join-everyone.md`,
+`docs/plans/2026-09-15-identity-from-one-header.md`, and
+`docs/plans/2026-09-16-book-names.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/db/migrations/009_book_names.rb b/db/migrations/009_book_names.rb
new file mode 100644
index 0000000..a236206
--- /dev/null
+++ b/db/migrations/009_book_names.rb
@@ -0,0 +1,18 @@
+# A login's book can go by a name other than the login, so the group
+# choosing it reads `sync:Alpha Chen` rather than
+# `sync:alpha@example.com` (docs/plans/2026-09-16-book-names.md).
+#
+# Both columns are exact bytes, the comparison Store#book makes, and a
+# name is one login's for 008's reason: two logins sharing it would
+# share a book.
+#
+# Like the groups, nothing can re-derive a row here. No timestamps, the
+# birthdays' reason: a row is current state, replaced wholesale.
+Sequel.migration do
+  change do
+    create_table(:book_names, strict: true) do
+      String :login, text: true, null: false, primary_key: true
+      String :name, text: true, null: false, unique: true
+    end
+  end
+end
diff --git a/docs/plans/2026-09-16-book-names.md b/docs/plans/2026-09-16-book-names.md
new file mode 100644
index 0000000..3b6e4be
--- /dev/null
+++ b/docs/plans/2026-09-16-book-names.md
@@ -0,0 +1,38 @@
+# Book names
+
+2026-09-16. A login's book can go by a name other than the login, so the
+group choosing it reads `sync:Alpha Chen` rather than
+`sync:alpha@example.com`. This is the setting
+`2026-09-15-identity-from-one-header.md` left for next, under "One value".
+
+## Where the name lives
+
+In the database, in `book_names` (db/migrations/009_book_names.rb), one
+row per login that has a name. A login without a row goes by itself.
+There is no screen for it: `rake book:name LOGIN=... NAME=...` sets one,
+and the same task with no `NAME` goes back to the login. Naming a book is
+done once per user per deployment, and an environment variable holding a
+map of logins would be a second place a group name is spelled.
+
+Names are unique, since two logins sharing a name would share a book,
+and `*` is refused as everyone's.
+
+## The group moves with the name
+
+`Store#name_book` renames the login's `sync:` group, if it has one, in
+the same transaction as the row, through `Store#rename_group`. The book
+keeps its cards, and every member is logged as moved, so a client syncing
+it refetches rather than missing anything.
+
+Renaming the group from the groups screen does not update the row. The
+book then names a group that is not there, the same as a login whose
+group was renamed before this.
+
+## Matched as spelled
+
+`Store#book` used to match `sync:<login>` in any case, because the name
+was a display name a user could recase at will. With a name that is the
+deployment's to choose, a login whose case reads badly gets a name
+instead, so the group name and the login are both compared as exact
+bytes, the comparison the unique index on group names already makes
+(`2026-09-15-group-names-are-unique.md`).
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index d943c5d..b8a696f 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -357,19 +357,46 @@ module ProTacts
     end
 
     # The cards one user's client syncs: every member of `sync:*` and of
-    # `sync:<login>`, each once (docs/plans/2026-09-12-per-user-books.md).
-    # The login matches in any case, folded here in Ruby because SQLite's
-    # lower() folds ASCII alone; the prefix only as #sync_name? reads it,
-    # since no other group's moves are logged.
+    # `sync:<name>`, each once (docs/plans/2026-09-12-per-user-books.md),
+    # where the name is the login's book name or the login itself
+    # (docs/plans/2026-09-16-book-names.md). Matched as spelled: a login
+    # whose case reads badly gets a name rather than a folded match.
     #: (String login) -> Set[String]
     def book(login)
-      own = "#{SYNC_PREFIX}#{login}"
-      ids = groups.where(Sequel.function(:glob, "#{SYNC_PREFIX}*", :name)).all
-        .select { |group| (label = group.fetch(:name).to_s) == EVERYONE || label.casecmp?(own) }
-        .map { it.fetch(:id) }
+      ids = groups.where(name: [EVERYONE, own_sync_name(login)]).select_map(:id)
       Set.new(group_members.where(group_id: ids).select_map(:card_id).map(&:to_s))
     end
 
+    # The name a login's book goes by in place of the login, or nil for
+    # the login itself.
+    #: (String login) -> String?
+    def book_name(login)
+      book_names.where(login:).sole.fetch(:name).to_s
+    rescue Sequel::NoMatchingRow
+      nil
+    end
+
+    # Sets the name a login's book goes by, or with nil or a blank goes
+    # back to the login. The login's `sync:` group, if it has one, is
+    # renamed in the same transaction, so the book keeps its cards and
+    # #rename_group logs every member for the clients syncing it. A name
+    # another login's book goes by raises on the unique index, and `*`
+    # is refused before the transaction, #put's reason for building a
+    # Contact outside one.
+    #: (String login, String? name) -> void
+    def name_book(login, name)
+      name = name.to_s.strip
+      name = nil if name.empty?
+      raise ArgumentError, "#{EVERYONE} is everyone's book, not #{login}'s" if name == "*"
+
+      @database.transaction do
+        group = groups.where(name: own_sync_name(login)).select_map(:id).first
+        book_names.where(login:).delete
+        book_names.insert(login:, name:) unless name.nil?
+        rename_group(group.to_s, name: own_sync_name(login)) unless group.nil?
+      end
+    end
+
     # The id of the card whose UID property holds this value, if one
     # does — the read behind the no-uid-conflict precondition (RFC 6352
     # section 6.3.2.1). It reads the index, so a card whose UID line
@@ -758,6 +785,11 @@ module ProTacts
       @database[:group_members]
     end
 
+    #: () -> Sequel::Dataset
+    def book_names
+      @database[:book_names]
+    end
+
     #: () -> Sequel::Dataset
     def group_properties
       @database[:group_properties]
@@ -868,6 +900,12 @@ module ProTacts
       create_group(name: EVERYONE)
     end
 
+    # The group name that puts cards in this login's book alone.
+    #: (String login) -> String
+    def own_sync_name(login)
+      "#{SYNC_PREFIX}#{book_name(login) || login}"
+    end
+
     #: (String? name) -> bool
     def sync_name?(name)
       name&.start_with?(SYNC_PREFIX) == true
diff --git a/tasks/book.rake b/tasks/book.rake
new file mode 100644
index 0000000..113bae2
--- /dev/null
+++ b/tasks/book.rake
@@ -0,0 +1,16 @@
+# The name a login's book goes by, in place of the login
+# (docs/plans/2026-09-16-book-names.md).
+
+namespace :book do
+  desc "Name LOGIN's book NAME, renaming its sync group (no NAME goes back to the login)"
+  task :name do
+    require "pro_tacts"
+    require "pro_tacts/store"
+
+    login = ENV.fetch("LOGIN")
+    ProTacts::Store.connect(ProTacts.config.database_path) do |store|
+      store.name_book(login, ENV.fetch("NAME", nil))
+      puts "#{login} syncs #{ProTacts::Store::SYNC_PREFIX}#{store.book_name(login) || login}"
+    end
+  end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 78f647a..baa3290 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1367,17 +1367,58 @@ class StoreTest < Minitest::Test
     end
   end
 
-  # The display name in any case, beyond ASCII too; the prefix only as
-  # spelled, since a group that is no `sync:` group puts nothing in a
-  # book.
-  def test_a_book_matches_the_display_name_in_any_case
+  # A case variant, or the prefix spelled another way, is some other
+  # group.
+  def test_a_book_matches_its_group_name_exactly
     with_store({"aiden" => AIDEN, "znorth" => ZED, "yuki" => YUKI}) do |store|
-      FixtureData.seed_group(store, name: "sync:alpha chen", members: ["aiden"])
-      FixtureData.seed_group(store, name: "SYNC:Alpha Chen", members: ["znorth"])
-      FixtureData.seed_group(store, name: "sync:zoë chen", members: ["yuki"])
+      FixtureData.seed_group(store, name: "sync:Alpha Chen", members: ["aiden"])
+      FixtureData.seed_group(store, name: "sync:alpha chen", members: ["znorth"])
+      FixtureData.seed_group(store, name: "SYNC:Alpha Chen", members: ["yuki"])
 
       assert_equal Set["aiden"], store.book("Alpha Chen")
-      assert_equal Set["yuki"], store.book("ZOË CHEN")
+    end
+  end
+
+  # A named book is the named group's, and the login's own group moves
+  # to the name with its members logged, so a client hears of nothing
+  # but the rename (docs/plans/2026-09-16-book-names.md).
+  def test_a_named_book_takes_its_group_along
+    with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+      id = FixtureData.seed_group(store, name: "sync:alpha@example.com", members: ["aiden"])
+
+      store.name_book("alpha@example.com", "Alpha Chen")
+
+      assert_equal "sync:Alpha Chen", store.group(id).name
+      assert_equal "Alpha Chen", store.book_name("alpha@example.com")
+      assert_equal Set["aiden"], store.book("alpha@example.com")
+      assert_equal %w[group put], store.changes_of("aiden").map(&:action)
+
+      store.name_book("alpha@example.com", " ")
+
+      assert_equal "sync:alpha@example.com", store.group(id).name
+      assert_nil store.book_name("alpha@example.com")
+      assert_equal Set["aiden"], store.book("alpha@example.com")
+    end
+  end
+
+  def test_a_book_is_named_before_its_group_exists
+    with_store({"aiden" => AIDEN}) do |store|
+      store.name_book("alpha@example.com", "Alpha Chen")
+      FixtureData.seed_group(store, name: "sync:Alpha Chen", members: ["aiden"])
+
+      assert_equal Set["aiden"], store.book("alpha@example.com")
+    end
+  end
+
+  # Another login's name would share a book, and `*` is everyone's.
+  def test_a_book_name_is_one_logins
+    with_store do |store|
+      store.name_book("alpha@example.com", "Alpha Chen")
+      store.name_book("alpha@example.com", "Alpha Chen")
+
+      assert_raises(Sequel::UniqueConstraintViolation) { store.name_book("zoe@example.com", "Alpha Chen") }
+      assert_raises(ArgumentError) { store.name_book("zoe@example.com", "*") }
+      assert_nil store.book_name("zoe@example.com")
     end
   end
 
diff --git a/test/tasks/test_book.rb b/test/tasks/test_book.rb
new file mode 100644
index 0000000..4f7cb9c
--- /dev/null
+++ b/test/tasks/test_book.rb
@@ -0,0 +1,43 @@
+require_relative "../test_helper"
+
+require "pathname"
+require "rake"
+require "tmpdir"
+
+require "pro_tacts/store"
+
+class BookTasksTest < Minitest::Test
+  def test_naming_a_book_renames_its_group
+    Dir.mktmpdir do |root|
+      root = Pathname.new(root)
+      ProTacts::Store.connect(root / "contacts.db") do |store|
+        id = store.create_group(name: "sync:alpha@example.com")
+
+        out, = run_task(root, "LOGIN" => "alpha@example.com", "NAME" => "Alpha Chen")
+
+        assert_equal "alpha@example.com syncs sync:Alpha Chen\n", out
+        assert_equal "sync:Alpha Chen", store.group(id).name
+      end
+    end
+  end
+
+  private
+
+  # Runs book:name in a Rake application of its own, with root as the
+  # data directory and env set for the run alone.
+  def run_task(root, env)
+    config = ProTacts.config
+    application = Rake.application
+    overridden = env.keys.to_h { [it, ENV.fetch(it, nil)] }
+
+    ProTacts.config = ProTacts::Config.new("PRO_TACTS_DATA_DIR" => root.to_s)
+    ENV.update(env)
+    Rake.application = Rake::Application.new
+    load (Pathname.new(__dir__).parent.parent / "tasks" / "book.rake").to_s
+    capture_io { Rake.application["book:name"].invoke }
+  ensure
+    ProTacts.config = config
+    Rake.application = application
+    ENV.update(overridden)
+  end
+end