groups: a name is optional, and an id is minted
The empty string slipped past NOT NULL and badged a row with air;
now NULL is the one way to be nameless, and the id says who lends there.
Four letters, k to z — the alphabet jj mints its own ids inside;
all three tables rebuilt, since a dropped parent cascades the children aside.

Assisted-by: Claude Opus 5 via Claude Code
change upxsvvnrowrxoruvlxqqvkomomswvnyo
commit 177a6faa77a4c514be49136710cc59364ce67f52
author Alpha Chen <alpha@kejadlen.dev>
date
parent vnwpwtww
diff --git a/db/migrations/005_group_identity.rb b/db/migrations/005_group_identity.rb
new file mode 100644
index 0000000..182455b
--- /dev/null
+++ b/db/migrations/005_group_identity.rb
@@ -0,0 +1,82 @@
+# Group identity: a name a group may not have, and an id in the shape
+# jj spells a change id.
+#
+# The name is the author's label, and 004 made it required — which left
+# the empty string in as a second spelling of nameless, one that reads
+# on a member's page as an empty badge. Nullable is the honest column:
+# NULL is a group with no name, '' is refused, and a nameless group is
+# displayed by its id instead (Store#inherited_rows composes the
+# fallback into the read).
+#
+# Which makes the id something a person reads, so it is minted rather
+# than chosen: four reverse-hex letters, k through z, the alphabet jj
+# renders a change id in so that an id can never be mistaken for a hash
+# (Store#next_group_id). GLOB rather than LIKE, because GLOB takes the
+# character ranges this shape is written as and because its case
+# sensitivity is its own rather than a per-connection pragma's — 004
+# records the second reason at length. `glob(X, Y)` is `Y GLOB X`:
+# pattern first, SQLite's argument order for the function form.
+#
+# Both are CHECK constraints, and SQLite cannot alter one, so the table
+# is rebuilt the way 003 and 004 rebuilt changes. Unlike changes,
+# groups is referenced: with foreign keys on, DROP TABLE runs an
+# implicit DELETE FROM first, and the members and the properties would
+# cascade away behind it. So all three tables are rebuilt together —
+# created beside the old ones, rows copied, the children dropped before
+# the parent, then renamed into place, which is also what points the
+# children's foreign keys back at `groups`. Turning foreign keys off
+# around a single-table rebuild is not available here: that pragma is a
+# no-op inside a transaction, and a migration is one.
+#
+# A row whose id is not four letters in k-z stops the migration rather
+# than being rewritten into something that fits. Nothing public writes
+# these tables, so such a row was seeded by hand, and choosing its new
+# id is the author's call and not this file's.
+#
+# The shareable-line constraint is restated rather than referenced —
+# a migration is standalone, and 004 holds the reasoning for which
+# names it admits. `text: true` and locals over constants, for the
+# reasons 001 and 004 record.
+folded = Sequel.function(:upper, :line)
+shareable = %w[ADR NOTE].flat_map { |name|
+  [Sequel.like(folded, "#{name}:%"), Sequel.like(folded, "#{name};%")]
+}
+
+Sequel.migration do
+  change do
+    create_table(:groups_new, strict: true) do
+      String :id, text: true, primary_key: true, null: false
+      String :name, text: true
+      constraint(:id_is_a_change_id, Sequel.function(:glob, "[k-z][k-z][k-z][k-z]", :id))
+      constraint(:name_is_absent_or_given, Sequel.|({name: nil}, Sequel.~(name: "")))
+    end
+
+    create_table(:group_properties_new, strict: true) do
+      foreign_key :group_id, :groups_new, type: String, text: true, null: false, on_delete: :cascade
+      Integer :position, null: false
+      String :line, text: true, null: false
+      primary_key [:group_id, :position]
+      constraint(:line_is_shareable, Sequel.|(*shareable))
+    end
+
+    create_table(:group_members_new, strict: true) do
+      foreign_key :group_id, :groups_new, type: String, text: true, null: false, on_delete: :cascade
+      foreign_key :card_id, :cards, type: String, text: true, null: false, on_delete: :cascade
+      primary_key [:group_id, :card_id]
+    end
+
+    run "INSERT INTO groups_new (id, name) SELECT id, name FROM groups"
+    run "INSERT INTO group_properties_new (group_id, position, line) " \
+        "SELECT group_id, position, line FROM group_properties"
+    run "INSERT INTO group_members_new (group_id, card_id) " \
+        "SELECT group_id, card_id FROM group_members"
+
+    drop_table(:group_properties)
+    drop_table(:group_members)
+    drop_table(:groups)
+
+    rename_table(:groups_new, :groups)
+    rename_table(:group_properties_new, :group_properties)
+    rename_table(:group_members_new, :group_members)
+  end
+end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 37144f2..0e3fe98 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -82,11 +82,12 @@ module ProTacts
     # @rbs skip
     Photo = Data.define(:mime_type, :bytes)
 
-    # One line a group lends this contact, and the name of the group
-    # that lends it. The name rather than the group itself, because
-    # the name is the whole of what a card shows about it today and
-    # nothing here can navigate to a record that has no screen yet
-    # (docs/DESIGN.md, "Relationships are navigable").
+    # One line a group lends this contact, and what to call the group
+    # that lends it: its name, or its id where it has no name
+    # (Store#inherited_rows). A label rather than the group itself,
+    # because the label is the whole of what a card shows about it
+    # today and nothing here can navigate to a record that has no
+    # screen yet (docs/DESIGN.md, "Relationships are navigable").
     # @rbs skip
     Inherited = Data.define(:group, :line)
     # @rbs skip
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index d4d2188..8e7a514 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -1,5 +1,6 @@
 require "date"
 require "pathname"
+require "securerandom"
 require "sequel"
 require "sentry-ruby"
 
@@ -77,6 +78,19 @@ module ProTacts
     # ordinary, and SQLITE_BUSY straight back to the client is not.
     BUSY_TIMEOUT = 5_000 #: Integer
 
+    # Hex in the letters jj renders a change id with: 0 is z, f is k,
+    # and every digit lands in k-z, so an id is never a hash and never
+    # a word its author meant. `tr` maps the alphabets in one pass, in
+    # the order this pair is written.
+    REVERSE_HEX = "zyxwvutsrqponmlk" #: String
+
+    # How many ids a create draws before giving up. Four letters is one
+    # of 65,536 and an address book holds groups by the dozen, so a
+    # first collision is already unlucky and a run of eight is a broken
+    # generator rather than a run of bad draws — better to say so than
+    # to spin.
+    GROUP_ID_ATTEMPTS = 8 #: Integer
+
     # Sequel's migrations, run on open. They ship with the code rather
     # than with a deployment, so the path is relative to this file.
     # `__dir__` is nil only for code with no file behind it, which a
@@ -335,6 +349,31 @@ module ProTacts
       end
     end
 
+    # Creates a group and hands back the id it was given. The name is
+    # the author's label and optional; a group without one is displayed
+    # by its id (see #inherited_rows), and the empty string is refused
+    # by the schema rather than kept as a second spelling of nameless.
+    #
+    # The id is minted here rather than taken from the caller, for the
+    # shape db/migrations/005_group_identity.rb pins. The insert is
+    # what settles a collision, not a read before it: two creates
+    # drawing at once would both find the same id free, so a duplicate
+    # comes back as the primary key's own violation and the next
+    # attempt draws again.
+    #: (?name: String?) -> String
+    def create_group(name: nil)
+      GROUP_ID_ATTEMPTS.times do
+        id = next_group_id
+        begin
+          groups.insert(id:, name:)
+          return id
+        rescue Sequel::UniqueConstraintViolation
+          next
+        end
+      end
+      raise "no free group id in #{GROUP_ID_ATTEMPTS} draws: #{groups.count} groups already"
+    end
+
     # The change log from a sequence number on, oldest first: the window
     # a sync-collection report answers from (RFC 6578 section 3.2) — the
     # entries after the client's token are the changes it has not seen.
@@ -387,11 +426,25 @@ module ProTacts
       @database[:birthdays]
     end
 
+    #: () -> Sequel::Dataset
+    def groups
+      @database[:groups]
+    end
+
     #: () -> Sequel::Dataset
     def group_members
       @database[:group_members]
     end
 
+    # Four hex digits spelled in REVERSE_HEX. SecureRandom rather than
+    # rand: the draws have to be independent of anything a caller can
+    # observe or seed, and two bytes of it is exactly the four digits
+    # the id is wide.
+    #: () -> String
+    def next_group_id
+      SecureRandom.hex(2).tr("0-9a-f", REVERSE_HEX)
+    end
+
     #: (String card_id, String action, String? etag) -> void
     def record(card_id, action, etag)
       change_log.insert(card_id:, action:, etag:)
@@ -594,6 +647,12 @@ module ProTacts
     # share. Ordered by the group's id rather than its name, because a
     # rename must not move a member's lines and change every etag in
     # the group.
+    #
+    # A group with no name is lent under its id instead, coalesced here
+    # rather than at the surfaces: a mark on an inherited row names the
+    # group it came from, and a group with no name still has to be
+    # named as some one group among several
+    # (db/migrations/005_group_identity.rb).
     #: () -> Sequel::Dataset
     def inherited_rows
       group_members
@@ -601,7 +660,7 @@ module ProTacts
         .join(:groups, id: Sequel[:group_members][:group_id])
         .select(
           Sequel[:group_members][:card_id],
-          Sequel[:groups][:name].as(:group_name),
+          Sequel.function(:coalesce, Sequel[:groups][:name], Sequel[:groups][:id]).as(:group_name),
           Sequel[:group_properties][:line],
         )
         .order(
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index c3f102e..01356c7 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -32,6 +32,10 @@ module Sequel
   # "recently updated" reads sort by.
   def self.desc: (Symbol name) -> untyped
 
+  # A SQL function call: `coalesce` over a group's name and its id, in
+  # the read that lends an unnamed group's lines under something.
+  def self.function: (Symbol name, *untyped arguments) -> untyped
+
   class Database
     def []: (Symbol table) -> Dataset
 
@@ -155,4 +159,9 @@ module Sequel
 
   class CheckConstraintViolation < ConstraintViolation
   end
+
+  # What an insert onto a taken primary key raises: the answer a group
+  # id's draw waits for rather than asking first.
+  class UniqueConstraintViolation < ConstraintViolation
+  end
 end
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index a0d6025..15c0c49 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -48,31 +48,55 @@ module FixtureData
     "NOTE:Gate code 1854. The dog is friendly\\, the goose is not.",
   ].freeze #: Array[String]
 
-  # The groups the fixture book carries: one household, the three
-  # household-* cards in it. Those cards are a family — one surname, a
-  # mobile and an email each, and no address or note of their own — so
-  # everything the admin UI shows on them in those two rows is the
-  # group's, and removing a member visibly takes it away. They sit
-  # outside the recorded macOS exchange's hrefs, so the replay keeps
-  # serving that session the bytes it saw.
+  # The groups the fixture book carries. The first is a household, the
+  # three household-* cards in it. Those cards are a family — one
+  # surname, a mobile and an email each, and no address or note of
+  # their own — so everything the admin UI shows on them in those two
+  # rows is the group's, and removing a member visibly takes it away.
+  # They sit outside the recorded macOS exchange's hrefs, so the replay
+  # keeps serving that session the bytes it saw.
+  #
+  # The second is the nameless case: every bday-* card in one group
+  # with no name and nothing to lend
+  # (db/migrations/005_group_identity.rb). It moves no member's bytes —
+  # a group that holds nothing composes nothing, so the replay is
+  # untouched by it — and what it is here for is the surfaces that show
+  # a group rather than a card, which have a group with no name to
+  # render as its id (docs/DESIGN.md, "Groups are first-class
+  # records").
   #
   # Seeded in Ruby rather than a fixture file because the cards are the
   # only evidence-shaped fixtures — a group is rows across three
-  # tables with no file format of its own. Authoring groups is the
-  # admin UI's task; until it arrives nothing public writes these
-  # tables, so the rows land the way backdate's do, through the
-  # store's own database.
+  # tables with no file format of its own. The group itself is
+  # Store#create_group's, which mints the id no caller gets to choose;
+  # its properties and members have no write path yet — authoring is
+  # the admin UI's task — so those rows land the way backdate's do,
+  # through the store's own database.
   MEMBERS = %w[household-george household-mary household-alicia].freeze #: Array[String]
 
+  # The seed's birthday shapes, read off the card ids rather than
+  # listed again, so a shape added to test/fixtures/cards joins the
+  # group by being named like its neighbours.
+  #: () -> Array[String]
+  def self.birthday_cards
+    cards.keys.grep(/\Abday-/)
+  end
+
   #: (ProTacts::Store store) -> void
   def self.seed_groups(store)
     database = store.instance_variable_get(:@database)
-    database[:groups].insert(id: "household", name: "Boole household")
+
+    household = store.create_group(name: "Boole household")
     HOUSEHOLD.each.with_index do |line, position|
-      database[:group_properties].insert(group_id: "household", position:, line:)
+      database[:group_properties].insert(group_id: household, position:, line:)
     end
     MEMBERS.each do |id|
-      database[:group_members].insert(group_id: "household", card_id: id)
+      database[:group_members].insert(group_id: household, card_id: id)
+    end
+
+    birthdays = store.create_group
+    birthday_cards.each do |id|
+      database[:group_members].insert(group_id: birthdays, card_id: id)
     end
   end
 
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 2b428e1..7a59b71 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -46,23 +46,25 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
-  # A household's lines, and the seeding a group takes: authoring is
-  # still the admin UI's task, so the rows land through the store's
-  # own database the way FixtureData's do (test/fixture_data.rb).
+  # A household's lines, and the seeding a group takes: a created
+  # group for the row that mints an id, and its properties and members
+  # through the store's own database the way FixtureData's do
+  # (test/fixture_data.rb), authoring being still the admin UI's task.
   HOUSEHOLD = [
     "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
     "NOTE:Gate code 1854.",
   ].freeze #: Array[String]
 
-  def add_group(store, name:, members:, lines:)
+  def add_group(store, members:, lines:, name: nil)
     database = store.instance_variable_get(:@database)
-    database[:groups].insert(id: "household", name:)
+    group_id = store.create_group(name:)
     lines.each.with_index do |line, position|
-      database[:group_properties].insert(group_id: "household", position:, line:)
+      database[:group_properties].insert(group_id:, position:, line:)
     end
     members.each do |card_id|
-      database[:group_members].insert(group_id: "household", card_id:)
+      database[:group_members].insert(group_id:, card_id:)
     end
+    group_id
   end
 
   def test_index_lists_recently_updated_contacts
@@ -328,6 +330,20 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # A group with no name marks its rows with its id, which is what the
+  # mark is for: an empty badge would say a row came from somewhere and
+  # then not say where.
+  def test_show_marks_a_nameless_groups_rows_with_its_id
+    with_contacts({"ada" => ADA}) do |store|
+      id = add_group(store, members: ["ada"], lines: HOUSEHOLD)
+
+      get "/contacts/ada"
+      marks = last_response.body.scan(%r{<span class="badge">([^<]*)</span>})
+
+      assert_equal [[id], [id]], marks
+    end
+  end
+
   # A contact in no group is marked nowhere: the badge is provenance,
   # not decoration, so a card with nothing lent renders none.
   def test_show_marks_nothing_on_a_contact_in_no_group
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index bc35e01..4283fad 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -934,18 +934,21 @@ class StoreTest < Minitest::Test
   HOUSEHOLD_ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
   HOUSEHOLD_NOTE = "NOTE:Gate code 1854." #: String
 
-  # There is no write path for the group tables yet — authoring is the
-  # admin UI's task — so the tests arrange them the way the fixture
-  # seeder does, straight through the store's own database.
-  def add_group(store, id:, members:, lines:)
+  # A group's own row is Store#create_group's, which is what mints the
+  # id; its properties and its members have no write path yet —
+  # authoring is the admin UI's task — so those land the way the
+  # fixture seeder's do, straight through the store's own database.
+  # Hands back the id, which is the only way a caller learns it.
+  def add_group(store, members:, lines:, name: nil)
     db = database(store)
-    db[:groups].insert(id:, name: id.capitalize)
+    id = store.create_group(name:)
     lines.each.with_index do |line, position|
       db[:group_properties].insert(group_id: id, position:, line:)
     end
     members.each do |card_id|
       db[:group_members].insert(group_id: id, card_id:)
     end
+    id
   end
 
   # A member's served card is the stored one with the group's lines
@@ -959,7 +962,7 @@ class StoreTest < Minitest::Test
     )
 
     with_store({"aiden" => born, "znorth" => ZED}) do |store|
-      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
+      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
 
       assert_equal composed, store.contact("aiden").vcard.to_s
       assert_equal composed, store.contacts.find { it.id == "aiden" }.vcard.to_s
@@ -972,7 +975,7 @@ class StoreTest < Minitest::Test
   def test_a_contact_in_no_group_serves_unchanged_bytes_and_etag
     with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
       before = store.contact("aiden")
-      add_group(store, id: "household", members: ["znorth"], lines: [HOUSEHOLD_ADDRESS])
+      add_group(store, members: ["znorth"], lines: [HOUSEHOLD_ADDRESS])
 
       after = store.contact("aiden")
       assert_equal AIDEN, after.vcard.to_s
@@ -986,7 +989,7 @@ class StoreTest < Minitest::Test
   def test_leaving_a_group_restores_the_stored_bytes
     with_store({"aiden" => AIDEN}) do |store|
       before = store.contact("aiden").etag
-      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       refute_equal before, store.contact("aiden").etag
 
       database(store)[:group_members].where(card_id: "aiden").delete
@@ -997,16 +1000,19 @@ class StoreTest < Minitest::Test
 
   # Two groups' lines compose in group-id order, then position — the
   # order is a fact of the schema, not of whichever join SQLite
-  # returns first, so the composed bytes never move between reads.
+  # returns first, so the composed bytes never move between reads. The
+  # ids are minted rather than chosen, so the expectation is sorted by
+  # them: which group leads is the ids' business, that the id decides
+  # it rather than the creation order is this test's.
   def test_two_groups_compose_in_a_fixed_order
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, id: "zedly", members: ["aiden"], lines: [HOUSEHOLD_NOTE])
-      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      lent = {
+        add_group(store, members: ["aiden"], lines: [HOUSEHOLD_NOTE]) => HOUSEHOLD_NOTE,
+        add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS]) => HOUSEHOLD_ADDRESS,
+      }
 
-      composed = AIDEN.sub(
-        "END:VCARD\r\n",
-        "#{HOUSEHOLD_ADDRESS}\r\n#{HOUSEHOLD_NOTE}\r\nEND:VCARD\r\n",
-      )
+      first, second = lent.sort.map { it.last }
+      composed = AIDEN.sub("END:VCARD\r\n", "#{first}\r\n#{second}\r\nEND:VCARD\r\n")
       assert_equal composed, store.contact("aiden").vcard.to_s
     end
   end
@@ -1016,7 +1022,7 @@ class StoreTest < Minitest::Test
   # no index row, and rebuilding the index leaves it that way.
   def test_the_index_holds_nothing_the_group_composes_in
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
 
       assert_includes store.contact("aiden").vcard.to_s, HOUSEHOLD_ADDRESS
       assert_includes indexed_names(store, "aiden"), "FN"
@@ -1033,7 +1039,7 @@ class StoreTest < Minitest::Test
   # entry is with_store's own seed put, logged before the group existed.
   def test_writes_log_the_composed_etag_for_a_member
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
 
       put = store.put("aiden", AIDEN)
       edit = store.rewrite("aiden", AIDEN, birthday: nil)
@@ -1049,7 +1055,7 @@ class StoreTest < Minitest::Test
   # collection — a screen marks a row as the household's from either.
   def test_an_inherited_line_carries_its_groups_name
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      add_group(store, name: "Household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
 
       listed = store.contacts.find { it.id == "aiden" }
       [store.contact("aiden"), listed].each do |contact|
@@ -1058,6 +1064,20 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # A group with no name lends its lines under its id, on both reads —
+  # a mark that says which group a row came from, where the name would
+  # otherwise leave the row marked with nothing at all.
+  def test_a_nameless_groups_lines_are_marked_with_its_id
+    with_store({"aiden" => AIDEN}) do |store|
+      id = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+      listed = store.contacts.find { it.id == "aiden" }
+      [store.contact("aiden"), listed].each do |contact|
+        assert_equal id, contact.group_of(contact.addresses.fetch(0).line)
+      end
+    end
+  end
+
   # The schema is the only gate on what a group may hold until
   # authoring exists, so what it admits is worth pinning: an address
   # or a note, bare or parameterized, in whatever case the author
@@ -1067,22 +1087,59 @@ class StoreTest < Minitest::Test
   def test_a_group_holds_only_addresses_and_notes
     with_store({"aiden" => AIDEN}) do |store|
       properties = database(store)[:group_properties]
-      database(store)[:groups].insert(id: "household", name: "Household")
+      group_id = store.create_group(name: "Household")
 
       admitted = [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE, "ADR:;;1 Long Road;;;;", "note:lowercase is the same name"]
       admitted.each.with_index do |line, position|
-        properties.insert(group_id: "household", position:, line:)
+        properties.insert(group_id:, position:, line:)
       end
       assert_equal admitted, properties.order(:position).map { it.fetch(:line) }
 
       ["TEL;TYPE=home:+44 20 5555 0100", "EMAIL:boole@example.com", "item1.ADR:;;1 Long Road;;;;"].each do |line|
         assert_raises(Sequel::ConstraintViolation) do
-          properties.insert(group_id: "household", position: admitted.size, line:)
+          properties.insert(group_id:, position: admitted.size, line:)
         end
       end
     end
   end
 
+  # An id is minted, never chosen, in the shape jj spells a change id:
+  # four letters from k to z, drawn independently, so that two creates
+  # in a row are two groups.
+  def test_a_created_group_takes_a_change_id
+    with_store do |store|
+      ids = Array.new(8) { store.create_group }
+
+      ids.each { assert_match(/\A[k-z]{4}\z/, it) }
+      assert_equal ids.size, ids.uniq.size
+    end
+  end
+
+  # The schema is the gate on the id's shape, the way it is the gate on
+  # what a group may hold: nothing else stops a hand-written row from
+  # taking a word, a hash, or a change id's letters in the wrong case.
+  def test_the_schema_refuses_an_id_that_is_not_a_change_id
+    with_store do |store|
+      ["household", "abcd", "kxs", "kxsvv", "KXSV", "kx s"].each do |id|
+        assert_raises(Sequel::ConstraintViolation) do
+          database(store)[:groups].insert(id:, name: "Household")
+        end
+      end
+    end
+  end
+
+  # A group may have no name — NULL, the one spelling of it. The empty
+  # string is the other spelling the column would otherwise admit, and
+  # it renders as a mark with nothing in it, so the schema refuses it.
+  def test_a_group_may_be_nameless_but_not_named_nothing
+    with_store do |store|
+      id = store.create_group
+
+      assert_nil database(store)[:groups].where(id:).sole.fetch(:name)
+      assert_raises(Sequel::ConstraintViolation) { store.create_group(name: "") }
+    end
+  end
+
   # The fourth action value exists for the fan-out a group edit will
   # write; nothing writes it yet, and this pins that the log can hold
   # one when it does.
@@ -1124,6 +1181,38 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # The group rebuild carries all three tables, and the hazard it is
+  # written around is this one: dropping the old groups table with
+  # foreign keys on runs an implicit DELETE FROM, and the members and
+  # the properties would cascade away behind it. So the members and
+  # the properties are asserted across the upgrade, and both cascades
+  # asserted still to fire on the far side — a rebuild that forgot to
+  # redeclare them would pass the first half of this alone.
+  def test_the_migration_carries_a_group_with_its_members_and_properties
+    Dir.mktmpdir do |dir|
+      path = Pathname.new(dir) / "contacts.db"
+      Sequel.connect("sqlite://#{path}") do |db|
+        Sequel::Migrator.run(db, ProTacts::Store::MIGRATIONS.to_s, target: 4)
+        db[:cards].insert(id: "aiden", vcard: AIDEN)
+        db[:groups].insert(id: "nous", name: "Boole household")
+        db[:group_properties].insert(group_id: "nous", position: 0, line: HOUSEHOLD_ADDRESS)
+        db[:group_members].insert(group_id: "nous", card_id: "aiden")
+      end
+
+      ProTacts::Store.connect(path) do |store|
+        contact = store.contact("aiden")
+        assert_includes contact.vcard.to_s, HOUSEHOLD_ADDRESS
+        assert_equal "Boole household", contact.group_of(contact.addresses.fetch(0).line)
+
+        database(store)[:cards].where(id: "aiden").delete
+        assert_empty database(store)[:group_members].all
+
+        database(store)[:groups].where(id: "nous").delete
+        assert_empty database(store)[:group_properties].all
+      end
+    end
+  end
+
   # A database at the old shape — BDAY in the card, no birthdays table
   # — is carried over by the same subtraction a write makes: modeled
   # forms move, unmodeled forms stay put.