db: the dump writes the book names
The dump holds what the store cannot rebuild, and a book name is that:
nothing re-derives a book_names row, and no card carries one. A login
whose name is lost goes back to syncing sync:<login>, leaving the
sync:<name> group the dump does hold pointing at nobody's book.

book_names.yml sits beside birthdays.yml, keyed by login, written every
dump so a deployment that has named no book says so rather than leaving
it to a missing file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SB9PbfzjHsGjApxNNt7Qj5
change
commit 67bd63c7120292dc76e58dde7bdea4636c96dd5b
author Claude <noreply@anthropic.com>
date
parent plltmkrs
diff --git a/AGENTS.md b/AGENTS.md
index 7b7db2f..7ffccef 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,9 +21,9 @@ rake contacts:reid    # Move contacts whose ids are not server-minted
                       # onto minted ones, rewriting each card's UID
 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
-                      # and commit them to the repository there
-                      # (DUMP=path to move it)
+rake db:dump          # Write the cards, birthdays, groups, and book
+                      # names to data/dump and commit them to the
+                      # repository there (DUMP=path to move it)
 rake dev              # Dev server, seeded from test/fixtures/cards into a
                       # throwaway tmpdir on every start, reloading on change
                       # (needs fd and entr)
diff --git a/README.md b/README.md
index e27bdcd..10a768b 100644
--- a/README.md
+++ b/README.md
@@ -38,10 +38,10 @@ the birthdays — the two things in it that cannot be rebuilt, the
 birthdays because a partial date has no vCard 3.0 spelling — and an
 index of parsed properties, which can: `rake index:rebuild` derives
 that again from the stored cards alone. `rake db:dump` writes the
-cards, birthdays, and groups out as plain files, into `data/dump`
-unless `DUMP` names another directory, and commits them to that
-directory's own repository, so a snapshot is something to revert to
-(`docs/plans/2026-09-12-database-dump.md`,
+cards, birthdays, groups, and book names out as plain files, into
+`data/dump` unless `DUMP` names another directory, and commits them to
+that directory's own repository, so a snapshot is something to revert
+to (`docs/plans/2026-09-12-database-dump.md`,
 `docs/plans/2026-09-20-the-dump-commits.md`).
 
 A group holds attributes its members' cards carry: an address or a note
diff --git a/docs/plans/2026-09-19-book-names-in-the-dump.md b/docs/plans/2026-09-19-book-names-in-the-dump.md
new file mode 100644
index 0000000..e94f3f5
--- /dev/null
+++ b/docs/plans/2026-09-19-book-names-in-the-dump.md
@@ -0,0 +1,45 @@
+# Book names in the dump
+
+2026-09-19. `rake db:dump` writes `book_names.yml` beside the cards,
+the birthdays, and the groups: every login that has a book name, and
+the name it goes by.
+
+## Why it belongs there
+
+The dump holds what the store cannot rebuild
+(`2026-09-12-database-dump.md`), and a book name is exactly that. Nothing
+re-derives a `book_names` row — the migration says so in as many words
+(db/migrations/009_book_names.rb) — and it is not carried by any card, so
+it went out of a dump the way the birthdays would have.
+
+Losing one is not a cosmetic loss either. A login whose name is gone
+goes back to syncing `sync:<login>`, and the `sync:<name>` group the dump
+does hold is then a group nobody's book points at
+(`2026-09-16-book-names.md`, "The group moves with the name"). The row
+is what ties a login to its group, and it was the part not written
+down.
+
+## What it looks like
+
+| Path | Holds |
+|---|---|
+| `book_names.yml` | The name each login's book goes by, keyed by login |
+
+One file rather than a directory, the birthdays' shape: a row is a login
+and a string, and there is one per user of the deployment. Written every
+dump, `--- {}` for a deployment that has named no book, so the file is
+the answer rather than its absence being one.
+
+Logins are ordered in `Store#all_book_names` rather than by the task, the
+birthdays' arrangement reversed. `Store#birthdays_by_id` is shared with
+the listing reads, which do not care, so `tasks/db.rake` sorts what it
+writes; nothing but the dump reads all the book names, so the order the
+file needs lives with the read.
+
+## Still not read back
+
+Nothing reads a dump back, this no more than the rest. A restore would
+need an importer, and `Store#name_book` is the writer it would call —
+which renames the `sync:` group as it goes, so an importer replaying
+`book_names.yml` after the groups would rename what it had just restored.
+The order is the importer's problem to solve when one is written.
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 5257991..9afa6ba 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -88,10 +88,10 @@ module ProTacts
     GroupEdit = Data.define(:group_id, :position, :line)
 
     # Everything the store cannot rebuild, read at one moment: the
-    # stored cards by id, the birthdays by card id, and the groups.
-    # What `rake db:dump` writes (tasks/db.rake).
+    # stored cards by id, the birthdays by card id, the groups, and the
+    # book names by login. What `rake db:dump` writes (tasks/db.rake).
     # @rbs skip
-    Snapshot = Data.define(:cards, :birthdays, :groups)
+    Snapshot = Data.define(:cards, :birthdays, :groups, :book_names)
 
     # A group as the admin screens read one: its own row, the lines it
     # lends in the order it lends them, and its members' card ids. The
@@ -268,6 +268,7 @@ module ProTacts
           },
           birthdays: birthdays_by_id,
           groups: all_groups,
+          book_names: all_book_names,
         )
       end
     end
@@ -1450,6 +1451,18 @@ module ProTacts
       end
     end
 
+    # Every book name, keyed by login, for the dump — the one read that
+    # wants them all (docs/plans/2026-09-19-book-names-in-the-dump.md).
+    # Ordered here rather than by the caller, the birthdays' arrangement
+    # reversed: nothing else reads this, so the order a dumped file
+    # needs belongs with the read.
+    #: () -> Hash[String, String]
+    def all_book_names
+      book_names.order(:login).all.to_h {
+        [it.fetch(:login).to_s, it.fetch(:name).to_s] #: [String, String]
+      }
+    end
+
     # Every birthday, keyed by card, for the listing reads that compose
     # a whole collection in one pass.
     #: () -> Hash[String, Birthday]
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index a84ff25..7a4fb89 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -50,8 +50,9 @@ module ProTacts
       attr_reader cards: Hash[String, String]
       attr_reader birthdays: Hash[String, Birthday]
       attr_reader groups: Array[Group]
+      attr_reader book_names: Hash[String, String]
 
-      def self.new: (cards: Hash[String, String], birthdays: Hash[String, Birthday], groups: Array[Group]) -> instance
+      def self.new: (cards: Hash[String, String], birthdays: Hash[String, Birthday], groups: Array[Group], book_names: Hash[String, String]) -> instance
     end
 
     class GroupEdit
diff --git a/tasks/console.rake b/tasks/console.rake
index da377cc..cc02874 100644
--- a/tasks/console.rake
+++ b/tasks/console.rake
@@ -6,7 +6,7 @@
 #
 # Before the prompt, the dump is taken, and taking it is committing it
 # (tasks/db.rake), so a session starts from a recorded snapshot of the
-# three things the store cannot rebuild and a mistake at the prompt is
+# four things the store cannot rebuild and a mistake at the prompt is
 # a revert in the dump's repository rather than a restore from backup.
 # db:dump raising is what keeps the console from opening on a session
 # its safety net never armed.
diff --git a/tasks/db.rake b/tasks/db.rake
index c55a15e..a4872e5 100644
--- a/tasks/db.rake
+++ b/tasks/db.rake
@@ -1,6 +1,7 @@
 # A readable copy of what the store cannot rebuild: each stored card as
-# its bytes, the birthdays, and the groups, split the way the store
-# splits them (docs/plans/2026-09-12-database-dump.md). cards/ and
+# its bytes, the birthdays, the groups, and the book names, split the
+# way the store splits them (docs/plans/2026-09-12-database-dump.md,
+# docs/plans/2026-09-19-book-names-in-the-dump.md). cards/ and
 # groups/ are made to hold exactly what the store holds, so a dump kept
 # in version control shows deletions too; anything else in the
 # directory is left alone.
@@ -23,7 +24,7 @@ directory cards.to_s
 directory groups.to_s
 
 namespace :db do
-  desc "Dump the cards, birthdays, and groups into #{dump} and commit them there (DUMP=path to move it)"
+  desc "Dump the cards, birthdays, groups, and book names into #{dump} and commit them there (DUMP=path to move it)"
   task dump: [cards.to_s, groups.to_s] do
     require "yaml"
     require "pro_tacts/store"
@@ -42,6 +43,8 @@ namespace :db do
       dump_directory(groups, snapshot.groups.to_h { |group|
         ["#{group.id}.yml", YAML.dump({"name" => group.name, "lines" => group.lines, "members" => group.members})]
       })
+
+      dump_file(dump / "book_names.yml", YAML.dump(snapshot.book_names))
     end
     commit_dump(dump)
     puts "dumped #{database} into #{dump}"
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index ebd5a71..a35b989 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -2130,12 +2130,14 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN_BORN}) do |store|
       group = store.create_group(name: "Booles")
       store.add_member(group, "aiden")
+      store.name_book("alpha@example.com", "Alpha Chen")
 
       snapshot = store.snapshot
 
       assert_equal({"aiden" => AIDEN}, snapshot.cards)
       assert_equal({"aiden" => ProTacts::Birthday.new(year: 1985, month: 4, day: 12)}, snapshot.birthdays)
       assert_equal [["Booles", ["aiden"]]], snapshot.groups.map { [it.name, it.members] }
+      assert_equal({"alpha@example.com" => "Alpha Chen"}, snapshot.book_names)
     end
   end
 
diff --git a/test/tasks/test_db.rb b/test/tasks/test_db.rb
index 4addc84..432008f 100644
--- a/test/tasks/test_db.rb
+++ b/test/tasks/test_db.rb
@@ -12,15 +12,17 @@ require "pro_tacts/vcard"
 class DbTasksTest < Minitest::Test
   AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
 
-  # The stored card as its bytes, and the two things beside it that no
-  # card carries: the birthday, in a shape no client is sent, and the
-  # group with what it lends and who it lends to.
+  # The stored card as its bytes, and the three things beside it that no
+  # card carries: the birthday, in a shape no client is sent, the group
+  # with what it lends and who it lends to, and the name a login's book
+  # goes by.
   def test_the_dump_writes_what_the_store_cannot_rebuild
     with_store do |store, root|
       store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04\r\nEND:VCARD\r\n")))
       group = store.create_group(name: "Booles")
       store.set_group_lines(group, ["NOTE:a household"])
       store.add_member(group, "aiden")
+      store.name_book("alpha@example.com", "Alpha Chen")
 
       dump(root)
 
@@ -28,6 +30,19 @@ class DbTasksTest < Minitest::Test
       assert_equal({"aiden" => {"year" => 1985, "month" => 4}}, YAML.safe_load((root / "dump" / "birthdays.yml").read))
       assert_equal({"name" => "Booles", "lines" => ["NOTE:a household"], "members" => ["aiden"]},
         YAML.safe_load((root / "dump" / "groups" / "#{group}.yml").read))
+      assert_equal({"alpha@example.com" => "Alpha Chen"}, YAML.safe_load((root / "dump" / "book_names.yml").read))
+    end
+  end
+
+  # A deployment that has named no book still gets the file, so the
+  # dump says there are none rather than leaving it to a missing file.
+  def test_a_store_that_has_named_no_book_dumps_the_file_anyway
+    with_store do |store, root|
+      store.put("aiden", vcard(AIDEN))
+
+      dump(root)
+
+      assert_equal({}, YAML.safe_load((root / "dump" / "book_names.yml").read))
     end
   end