store: mint group ids through ChangeId
The k-to-z mint moved out of Store and takes a length instead of four;
it draws with alphanumeric, so an odd width spells as well as it did before.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change kzmmrmwpmnwrmzpumwyowroryqnknpzu
commit bd2ab23ce71734b9476633ef5dd80ec16ee341bf
author Alpha Chen <alpha@kejadlen.dev>
date
parent zqoyrsss
diff --git a/lib/pro_tacts/change_id.rb b/lib/pro_tacts/change_id.rb
new file mode 100644
index 0000000..7e07e6d
--- /dev/null
+++ b/lib/pro_tacts/change_id.rb
@@ -0,0 +1,18 @@
+require "securerandom"
+
+module ProTacts
+  # Ids in the shape jj spells a change id: letters from k to z, so an id
+  # is never a hash and never a word its author meant. Minted rather
+  # than chosen, which is what lets a person read one
+  # (db/migrations/005_group_identity.rb).
+  module ChangeId
+    ALPHABET = "klmnopqrstuvwxyz".chars.freeze #: Array[String]
+
+    # SecureRandom rather than rand: the draws have to be independent of
+    # anything a caller can observe or seed.
+    #: (Integer length) -> String
+    def self.mint(length)
+      SecureRandom.alphanumeric(length, chars: ALPHABET)
+    end
+  end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 0b35349..696bbde 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -1,12 +1,12 @@
 require "date"
 require "pathname"
-require "securerandom"
 require "sequel"
 require "sentry-ruby"
 
 require "pro_tacts/birthday"
 require "pro_tacts/birthday_line"
 require "pro_tacts/card_diff"
+require "pro_tacts/change_id"
 require "pro_tacts/contact"
 require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
@@ -134,12 +134,6 @@ 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
@@ -588,7 +582,7 @@ module ProTacts
     #: (?name: String?) -> String
     def create_group(name: nil)
       GROUP_ID_ATTEMPTS.times do
-        id = next_group_id
+        id = ChangeId.mint(4)
         begin
           groups.insert(id:, name:)
           return id
@@ -798,15 +792,6 @@ module ProTacts
       @database[:group_properties]
     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
-
     # The diff comes in already computed rather than being taken here
     # off a `before` and an `after`: only the caller knows which two
     # cards its write was between, and a delete's `after` is nothing at
diff --git a/test/pro_tacts/test_change_id.rb b/test/pro_tacts/test_change_id.rb
new file mode 100644
index 0000000..07f443c
--- /dev/null
+++ b/test/pro_tacts/test_change_id.rb
@@ -0,0 +1,15 @@
+require_relative "../test_helper"
+
+require "pro_tacts/change_id"
+
+class ChangeIdTest < Minitest::Test
+  def test_an_id_is_as_many_letters_from_k_to_z_as_asked_for
+    [1, 4, 7, 32].each do |length|
+      assert_match(/\A[k-z]{#{length}}\z/, ProTacts::ChangeId.mint(length))
+    end
+  end
+
+  def test_the_draws_cover_the_whole_alphabet
+    assert_equal ProTacts::ChangeId::ALPHABET.sort, ProTacts::ChangeId.mint(1_000).chars.uniq.sort
+  end
+end