store: hold every group name to one group
Only the sync: names were spoken for; the rest could all collide,
and two groups named Booles put two identical tags side by side.
The index takes every name now, exact bytes, as 007 compared;
the dialog that creates by name can no longer make a pair.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change tzwvrkqzpkyvsurkrykqxvkzuowlyqru
commit 3ff46e8b549b7b1f716f250f659fc2e19da8f11b
author Alpha Chen <alpha@kejadlen.dev>
date
parent kvmqkkzu
diff --git a/db/migrations/008_group_names.rb b/db/migrations/008_group_names.rb
new file mode 100644
index 0000000..ef1764a
--- /dev/null
+++ b/db/migrations/008_group_names.rb
@@ -0,0 +1,35 @@
+# A name is one group's. 007 made that true of the `sync:` names alone,
+# because a client's create had to find exactly one group to join, and
+# left every other name repeatable as the author's to reuse. The rest
+# follow it now that a contact's groups dialog creates a group by name
+# (Web#apply_groups): two groups called Booles are two tags a member
+# cannot tell apart, and the save that made the second one meant the
+# first.
+#
+# Exact bytes, the comparison 007 made. SQLite's NOCASE folds ASCII
+# alone, so a folded index would refuse "booles" beside "Booles" and
+# admit "ZOË" beside "Zoë" — a rule no screen could state. The dialog
+# offers to create a name only when no group's lowercased label is it
+# (Admin::GroupDialog), so a case variant is a hand-made POST rather
+# than something the admin makes.
+#
+# Nameless groups are untouched, a unique index admitting any number of
+# NULLs: 005 made the column nullable so that nameless is one spelling
+# and a group without a name is displayed by its id.
+#
+# 007's index is dropped rather than left beside this one — the `sync:`
+# names are a subset of every name, and the same rule held twice is the
+# one that goes stale. `up` and `down` rather than `change`, because
+# Sequel's reverser knows add_index and not drop_index.
+Sequel.migration do
+  up do
+    drop_index :groups, :name, name: :groups_sync_name_is_unique
+    add_index :groups, :name, unique: true, name: :groups_name_is_unique
+  end
+
+  down do
+    drop_index :groups, :name, name: :groups_name_is_unique
+    add_index :groups, :name, unique: true, name: :groups_sync_name_is_unique,
+                              where: Sequel.function(:glob, "sync:*", :name)
+  end
+end
diff --git a/docs/plans/2026-09-15-group-names-are-unique.md b/docs/plans/2026-09-15-group-names-are-unique.md
new file mode 100644
index 0000000..122a8c2
--- /dev/null
+++ b/docs/plans/2026-09-15-group-names-are-unique.md
@@ -0,0 +1,22 @@
+# Group names are unique
+
+2026-09-15. This changes one rule in
+`2026-09-12-per-user-books.md`, "Selection rides on groups": every
+group's name is its own, not the `sync:` names alone.
+
+The groups dialog on a contact's card creates a group by the name typed
+into its filter, so a name is something a person writes rather than
+something they pick. Two groups called Booles put two identical tags on
+a card, and neither the tag nor the save that made the second one says
+which group is which. The refusal is the database's
+(`db/migrations/008_group_names.rb`), so it holds for the group
+editor's rename and the create route as well as for the dialog.
+
+Exact bytes rather than a case fold. SQLite's NOCASE folds ASCII alone,
+so a folded index would refuse "booles" beside "Booles" and admit "ZOË"
+beside "Zoë". The dialog offers to create only a name that no group's
+lowercased label already spells, so a case variant takes a hand-made
+POST.
+
+Nameless groups are unaffected: a unique index admits any number of
+NULLs, and a group with no name is displayed by its id.
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 6591eee..d936e6a 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -173,8 +173,8 @@ module ProTacts
 
     # The group names that choose what a client syncs: `sync:*` for
     # everyone, `sync:<display name>` for one user
-    # (docs/plans/2026-09-12-per-user-books.md). Unique among groups
-    # (db/migrations/007_sync_names.rb), where other names may repeat.
+    # (docs/plans/2026-09-12-per-user-books.md). Unique like every
+    # group's name (db/migrations/008_group_names.rb).
     SYNC_PREFIX = "sync:" #: String
     EVERYONE = "#{SYNC_PREFIX}*" #: String
 
@@ -563,8 +563,8 @@ module ProTacts
           groups.insert(id:, name:)
           return id
         rescue Sequel::UniqueConstraintViolation
-          # The id collided, or a `sync:` name did
-          # (db/migrations/007_sync_names.rb). Drawing again only helps
+          # The id collided, or the name did
+          # (db/migrations/008_group_names.rb). Drawing again only helps
           # the first; the second is the caller's to hear.
           raise if groups.where(id:).empty?
 
@@ -672,8 +672,8 @@ module ProTacts
 
     # A card's side of #edit_group: one transaction, leavers first and
     # joiners last for that method's reason. A name creates a group to
-    # join inside the same transaction, so a taken `sync:` name
-    # (#create_group) moves nothing.
+    # join inside the same transaction, so a taken name (#create_group)
+    # moves nothing.
     #: (String card_id, join: Array[String], leave: Array[String], ?create: String?) -> void
     def regroup(card_id, join:, leave:, create: nil)
       @database.transaction do
@@ -859,8 +859,8 @@ module ProTacts
     end
 
     # 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.
+    # a group's name is unique (db/migrations/008_group_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
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index f3913a6..1066d81 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -194,8 +194,8 @@ module ProTacts
       begin
         store.regroup(id, join: checked - was, leave: was - checked, create: (name unless name.empty?))
       rescue Sequel::UniqueConstraintViolation
-        # A taken `sync:` name (db/migrations/007_sync_names.rb), refused
-        # with the rest of the save in regroup's transaction.
+        # A taken name (db/migrations/008_group_names.rb), refused with
+        # the rest of the save in regroup's transaction.
         return contact_screen(contact, notice: "Another group is already named #{name}; nothing was saved.")
       end
       r.redirect "/contacts/#{id}", 303
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index 122b7ef..06880f9 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -23,8 +23,7 @@ module ProTacts
           id = store.create_group(name: name.empty? ? nil : name)
           r.redirect "/groups/#{id}/edit", 303
         rescue Sequel::UniqueConstraintViolation
-          # A taken `sync:` name, the one kind that must be unique
-          # (db/migrations/007_sync_names.rb).
+          # A taken name (db/migrations/008_group_names.rb).
           response["Content-Type"] = "text/html; charset=utf-8"
           Admin::GroupsIndex.call(groups: store.all_groups, notice: "Another group is already named #{name}.")
         end
@@ -80,8 +79,8 @@ module ProTacts
       begin
         store.edit_group(id, name: r.params["name"].to_s, lines: Admin::CardForm.group_lines(group, r.params), members:)
       rescue Sequel::UniqueConstraintViolation
-        # A taken `sync:` name (db/migrations/007_sync_names.rb). The save
-        # is one transaction, so nothing of it landed.
+        # A taken name (db/migrations/008_group_names.rb). The save is
+        # one transaction, so nothing of it landed.
         return group_edit_screen(group, notice: "Another group is already named #{r.params['name']}; nothing was saved.")
       end
       r.redirect "/groups/#{id}", 303
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 81d4f71..19c8273 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -445,17 +445,17 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
-  # A `sync:` name is the one kind that must be unique
-  # (db/migrations/007_sync_names.rb), and the refusal is the whole save.
-  def test_a_new_group_with_a_taken_sync_name_saves_nothing
+  # A name is one group's (db/migrations/008_group_names.rb), and the
+  # refusal is the whole save.
+  def test_a_new_group_under_a_taken_name_saves_nothing
     with_contacts({"ada" => ADA}) do |store|
       booles = FixtureData.seed_group(store, name: "Booles")
-      FixtureData.seed_group(store, name: "sync:alpha")
+      FixtureData.seed_group(store, name: "Clarks")
 
-      post "/contacts/ada/groups", groups: [booles], new: "sync:alpha"
+      post "/contacts/ada/groups", groups: [booles], new: "Clarks"
 
       assert_equal 200, last_response.status
-      assert_includes last_response.body, "Another group is already named sync:alpha; nothing was saved."
+      assert_includes last_response.body, "Another group is already named Clarks; nothing was saved."
       assert_empty store.groups_of("ada")
       assert_equal 2, store.all_groups.length
     end
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index d8ed38b..0ac91f0 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -92,15 +92,15 @@ class AdminGroupsPagesTest < Minitest::Test
     end
   end
 
-  # Only a `sync:` name has to be unique (db/migrations/007_sync_names.rb).
-  def test_a_create_under_a_taken_sync_name_is_refused
+  # A name is one group's (db/migrations/008_group_names.rb).
+  def test_a_create_under_a_taken_name_is_refused
     with_contacts({}) do |store|
-      store.create_group(name: "sync:Test User")
+      store.create_group(name: "Booles")
 
-      post "/groups", name: "sync:Test User"
+      post "/groups", name: "Booles"
 
       assert_equal 200, last_response.status
-      assert_includes last_response.body, "Another group is already named sync:Test User."
+      assert_includes last_response.body, "Another group is already named Booles."
       assert_equal 1, store.all_groups.size
     end
   end
@@ -227,16 +227,16 @@ class AdminGroupsPagesTest < Minitest::Test
     end
   end
 
-  def test_a_rename_to_a_taken_sync_name_is_refused_and_changes_nothing
+  def test_a_rename_to_a_taken_name_is_refused_and_changes_nothing
     with_contacts(BOOLES) do |store|
-      store.create_group(name: "sync:Test User")
+      store.create_group(name: "Clarks")
       id = household(store)
 
-      post "/groups/#{id}", version: store.group(id).version, name: "sync:Test User", note: "Gate code 1854.",
+      post "/groups/#{id}", version: store.group(id).version, name: "Clarks", note: "Gate code 1854.",
                             members: ["", "ada"]
 
       assert_equal 200, last_response.status
-      assert_includes last_response.body, "Another group is already named sync:Test User; nothing was saved."
+      assert_includes last_response.body, "Another group is already named Clarks; nothing was saved."
       assert_equal "Booles", store.group(id).name
       assert_equal %w[george mary], store.group(id).members
     end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index ea4f719..78f647a 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1446,18 +1446,24 @@ class StoreTest < Minitest::Test
     end
   end
 
-  # A client's create has to join exactly one group, so a `sync:` name
-  # is unique (db/migrations/007_sync_names.rb); any other may repeat.
-  def test_a_sync_name_is_unique_and_others_are_not
+  # A name is one group's (db/migrations/008_group_names.rb). Nameless
+  # groups are the exception, and a group keeping the name it has is no
+  # collision with itself — every group edit rewrites the name it found
+  # (#edit_group).
+  def test_a_name_is_one_groups_and_nameless_groups_are_any_number
     with_store do |store|
+      household = store.create_group(name: "Household")
       store.create_group(name: "sync:Alpha Chen")
-      store.create_group(name: "Household")
-      store.create_group(name: "Household")
-      other = store.create_group(name: "sync:Zoë Chen")
+      store.create_group
+      other = store.create_group
 
-      assert_raises(Sequel::UniqueConstraintViolation) { store.create_group(name: "sync:Alpha Chen") }
+      assert_raises(Sequel::UniqueConstraintViolation) { store.create_group(name: "Household") }
       assert_raises(Sequel::UniqueConstraintViolation) { store.rename_group(other, name: "sync:Alpha Chen") }
-      assert_equal "sync:Zoë Chen", store.group(other).name
+      assert_nil store.group(other).name
+
+      store.rename_group(household, name: "Household")
+
+      assert_equal "Household", store.group(household).name
     end
   end