search: match each card's stored key, build only the hits
Every /search/results request built the whole book from the index and
parsed each contact to match it: about 400 ms at 1,000 cards whatever
the query. Each card's values are now folded into a search_keys row as
it is indexed, Admin::Search matches those rows, and only the contacts
it finds are built. A blank query and the dashboard read their ten
newest rows rather than the book. docs/plans/2026-10-17-search-keys.md
has the numbers and why not FTS5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQvcDxcd8ucY7iVaS6cJB8
change
commit 8c69a1a467d99649c6c323e5fbec1d73c1ccbe6c
author Claude <noreply@anthropic.com>
date
parent vxszuyrv
diff --git a/db/migrations/014_search_keys.rb b/db/migrations/014_search_keys.rb
new file mode 100644
index 0000000..5783d9c
--- /dev/null
+++ b/db/migrations/014_search_keys.rb
@@ -0,0 +1,38 @@
+# Each card's search key: its values folded the way the search compares
+# them, worked out when the card is written so that a search reads
+# these rows rather than building every contact to read them off
+# (docs/plans/2026-10-17-search-keys.md). Derived from the stored card
+# like card_properties, so Store#rebuild_index makes it again and a
+# card's delete cascades it away.
+#
+# Filled here through Admin::Search.key, the app's own rule, rather
+# than a copy spelled out against VCard the way 012 is: this is index,
+# not data, so what it must agree with is the search reading it today,
+# and a later change to what is keyed re-derives it the same way.
+#
+# `text: true` on every column because this is a STRICT table, which
+# refuses Sequel's plain String.
+require "pro_tacts/admin/search"
+require "pro_tacts/contact"
+require "pro_tacts/vcard"
+
+Sequel.migration do
+  up do
+    create_table(:search_keys, strict: true) do
+      foreign_key :card_id, :cards, type: String, text: true, primary_key: true, null: false, on_delete: :cascade
+      String :text, text: true, null: false
+      String :phones, text: true, null: false
+      String :names, text: true, null: false
+    end
+
+    self[:cards].order(:id).each do |row|
+      id = row.fetch(:id)
+      contact = ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(row.fetch(:vcard)), birthday: nil, inherited: [])
+      self[:search_keys].insert(card_id: id, **ProTacts::Admin::Search.key(contact).to_h)
+    end
+  end
+
+  down do
+    drop_table(:search_keys)
+  end
+end
diff --git a/docs/plans/2026-10-17-search-keys.md b/docs/plans/2026-10-17-search-keys.md
new file mode 100644
index 0000000..6bc5597
--- /dev/null
+++ b/docs/plans/2026-10-17-search-keys.md
@@ -0,0 +1,56 @@
+# Search keys
+
+2026-10-17. Every request the live search makes used to build the
+whole book. It now reads a short folded key for each card, matches
+those keys, and builds only the contacts it found.
+
+## What was slow
+
+Before, each request to `GET /search/results?q=` built every contact
+from the index (`Store#listed_by_id`). It then parsed each one and
+folded its values for `Admin::Search`. That cost grew with the size of
+the book and hardly depended on the query. The book was the 600-card
+seed from `2026-10-14-lists-read-the-index.md`, scaled to 1,000 cards,
+with requests sent in process:
+
+| Query | Before | After |
+|---|---|---|
+| `zzz` (no hits) | 510 ms | 18 ms |
+| `ada` (66 hits) | 380 ms | 48 ms |
+| `ad` (134 hits) | 420 ms | 67 ms |
+| `a` (every card) | 457 ms | 364 ms |
+| blank (10 recent) | 168 ms | 17 ms |
+
+The browser was never the cause, because the results always came from
+the server (`2026-09-30-live-search.md`). A query that matches every
+card still builds and renders every card. That is what an uncapped
+list costs, and a cap would be a separate decision.
+
+## Shape
+
+- `search_keys` holds one row per card: `text` (each text field
+  folded, one per line), `phones` (each number's digits) and `names`
+  (the name and nickname, for ranking). `Store#reindex` writes the row
+  next to the card's other index rows, and `#rebuild_index` derives it
+  again. Migration 014 fills it for the cards that are already stored.
+- `Admin::Search` is still the one place that decides a match. It
+  builds the key (`Search.key`) and reads it. The matching rules have
+  not changed.
+- A group's contribution is keyed at search time from its label and
+  from what it lends (`Group#reading`). It is never copied into its
+  members' keys, because then a rename or a change to a lent line
+  would have to rewrite every member's row. A group lends no name, so
+  a match on a group's label never ranks as a name hit.
+- A blank query and the dashboard read the 10 newest rows. They no
+  longer build the whole book in order to show 10 contacts.
+
+## Why not FTS5
+
+The trigram tokenizer handles substrings and, in the bundled SQLite
+3.53, folds diacritics, so "zoe" finds Zoë. It cannot match a word
+shorter than three characters, though, and every search starts with
+those keystrokes. Phones would still need their own digit column. The
+ranking would stay in Ruby, and group labels would have to be copied
+into every member's row. At family size, walking the stored keys
+already takes milliseconds. If the book ever grows enough that the
+walk becomes slow, these same columns can feed an FTS5 table.
diff --git a/lib/pro_tacts/admin/search.rb b/lib/pro_tacts/admin/search.rb
index 7ba2455..d4d1273 100644
--- a/lib/pro_tacts/admin/search.rb
+++ b/lib/pro_tacts/admin/search.rb
@@ -11,30 +11,64 @@ module ProTacts
     # compares digits alone, since a number is spelled with whatever
     # punctuation its writer liked.
     #
-    # Matching stays substring-generous rather than typo-tolerant or
-    # indexed: a family's book is small enough to walk whole, and SQLite
-    # FTS5 was weighed and set aside for it (ranger pnrqpyvk).
+    # Matching stays substring-generous rather than typo-tolerant: a
+    # family's book is small enough to walk whole, and SQLite FTS5 was
+    # weighed and set aside for it, twice (ranger pnrqpyvk, and
+    # docs/plans/2026-10-17-search-keys.md). What is walked is each
+    # contact's Key, folded when its card is written, rather than the
+    # contacts themselves.
     module Search
       # The punctuation a phone number is written with, which a word made
       # of nothing else (and at least one digit) is a number spelled.
       PHONE_WORD = /\A[\d().+-]*\d[\d().+-]*\z/ #: Regexp
       private_constant :PHONE_WORD
 
-      # The contacts the query matches, in the order a search is read:
-      # a hit on the name first — every word the start of a word of the
-      # name or nickname, "ada" or "love" for Ada Lovelace — then the
-      # hits anywhere else, recency keeping its order within each. The
-      # rows arrive in recency order, and partition keeps it. The groups
-      # arrive whole because a contact is found by the names of the
-      # groups it is in.
-      #: (Array[Store::RecentContact] rows, query: String, groups: Array[Group]) -> Array[Store::RecentContact]
+      # A contact's values folded for matching, worked out once when its
+      # card is written (Store#reindex) rather than on every search, so
+      # a search reads three short columns and builds only the contacts
+      # it finds. `text` is every text field (#text_fields) folded and
+      # one to a line, `phones` each number's digits, a space between,
+      # and `names` the name and nickname a name hit is read off. A word
+      # is split on whitespace, so it cannot span the separator between
+      # two values: a word found in the joined string is a word found in
+      # one of them.
+      #
+      # A group's values are keyed the same way, at search time, from
+      # what it lends (Group#reading) and its label: a contact is found
+      # by those too, and keying them into each member would leave every
+      # member's key to rewrite on a rename or a change to a lent line.
+      # @rbs skip
+      Key = Data.define(:text, :phones, :names)
+
+      # A contact's key, read off its own card alone: what its groups
+      # lend is matched as the group's (Key).
+      #: (Contact | ListedContact contact) -> Key
+      def self.key(contact)
+        Key.new(
+          text: text_fields(contact).map { Format.search_key(it) }.join("\n"),
+          phones: contact.phones.map { digits(it.value) }.join(" "),
+          names: [contact.name, contact.nickname].compact.map { Format.search_key(it) }.join(" "),
+        )
+      end
+
+      # The ids of the contacts the query matches, in the order a search
+      # is read: a hit on the name first — every word the start of a
+      # word of the name or nickname, "ada" or "love" for Ada Lovelace —
+      # then the hits anywhere else, recency keeping its order within
+      # each. The rows arrive in recency order, and partition keeps it.
+      # The groups arrive whole because a contact is found by the names
+      # of the groups it is in and by what they lend it.
+      #: (Array[Store::KeyedContact] rows, query: String, groups: Array[Group]) -> Array[String]
       def self.contacts(rows, query:, groups:)
         words = words(query)
-        labels = {} #: Hash[String, Array[String]]
-        groups.each { |group| group.members.each { (labels[it] ||= []) << group.label } }
-        matched = rows.select { |row| matches?(row.contact, words, labels.fetch(row.contact.id, [])) }
-        names, rest = matched.partition { name_hit?(it.contact, words) }
-        names + rest
+        lent = {} #: Hash[String, Array[Key]]
+        groups.each { |group|
+          key = group_key(group)
+          group.members.each { (lent[it] ||= []) << key }
+        }
+        matched = rows.select { |row| matches?([row.key, *lent.fetch(row.id, [])], words) }
+        names, rest = matched.partition { name_hit?(it.key, words) }
+        (names + rest).map(&:id)
       end
 
       # The groups whose names the query matches, every word somewhere
@@ -54,23 +88,31 @@ module ProTacts
       end
       private_class_method :words
 
-      #: (ListedContact contact, Array[String] words, Array[String] labels) -> bool
-      def self.matches?(contact, words, labels)
-        texts = text_fields(contact, labels).map { Format.search_key(it) }
-        phones = contact.phones.map { digits(it.value) }
+      # What a group adds to each member's key: the lines it lends, read
+      # as a card, and its label. A name it does not lend, so its key's
+      # names stay its own and no member's name hit comes from a group.
+      #: (Group group) -> Key
+      def self.group_key(group)
+        lent = key(group.reading)
+        lent.with(text: [lent.text, Format.search_key(group.label)].join("\n"))
+      end
+      private_class_method :group_key
+
+      #: (Array[Key] keys, Array[String] words) -> bool
+      def self.matches?(keys, words)
         words.all? { |word|
-          texts.any? { it.include?(word) } ||
-            (word.match?(PHONE_WORD) && phones.any? { it.include?(digits(word)) })
+          keys.any? { it.text.include?(word) } ||
+            (word.match?(PHONE_WORD) && keys.any? { it.phones.include?(digits(word)) })
         }
       end
       private_class_method :matches?
 
       # Every value of the contact a word is matched against as text:
-      # its names, organization, emails, addresses, notes, and groups.
-      # An address is its components run together, so "main st" finds
-      # a street.
-      #: (ListedContact contact, Array[String] labels) -> Array[String]
-      def self.text_fields(contact, labels)
+      # its names, organization, emails, addresses, and notes. An
+      # address is its components run together, so "main st" finds a
+      # street.
+      #: (Contact | ListedContact contact) -> Array[String]
+      def self.text_fields(contact)
         [
           contact.name,
           contact.nickname,
@@ -78,14 +120,13 @@ module ProTacts
           *contact.emails.map(&:value),
           *contact.addresses.map { [it.extended, it.street, it.locality, it.region, it.postal_code, it.country].compact.join(" ") },
           *contact.notes.map(&:value),
-          *labels,
         ].compact
       end
       private_class_method :text_fields
 
-      #: (ListedContact contact, Array[String] words) -> bool
-      def self.name_hit?(contact, words)
-        name_words = [contact.name, contact.nickname].compact.flat_map { Format.search_key(it).split }
+      #: (Key key, Array[String] words) -> bool
+      def self.name_hit?(key, words)
+        name_words = key.names.split
         words.all? { |word| name_words.any? { it.start_with?(word) } }
       end
       private_class_method :name_hit?
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 09aab1b..cb79d9d 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -4,6 +4,7 @@ require "json"
 require "pathname"
 require "sequel"
 
+require "pro_tacts/admin/search"
 require "pro_tacts/birthday"
 require "pro_tacts/card_diff"
 require "pro_tacts/change_id"
@@ -90,6 +91,12 @@ module ProTacts
     # @rbs skip
     RecentContact = Data.define(:contact, :updated_at)
 
+    # A contact's id beside the key a search matches it by
+    # (Admin::Search::Key), with no contact built: what a search walks
+    # the whole book as, building only the contacts it finds.
+    # @rbs skip
+    KeyedContact = Data.define(:id, :key)
+
     # A contact paired with the calendar day its birthday next lands
     # on — the read behind the admin's upcoming-birthdays list, where
     # the order is the coming year's, not the stored components'.
@@ -265,15 +272,30 @@ module ProTacts
     # Every contact as a list shows it, paired with its card's
     # updated_at, newest first — the read behind any "recently updated"
     # surface, ordered by the column SQLite already stamps rather than
-    # by id.
-    #: () -> Array[RecentContact]
-    def contacts_by_recency
-      listed = listed_by_id(nil)
-      cards.order(Sequel.desc(:updated_at)).select(:id, :updated_at).map {
+    # by id. The ids narrow it to the contacts a search found, and the
+    # limit to the newest few, and either way only those are built.
+    #: (?Array[String]? ids, ?limit: Integer?) -> Array[RecentContact]
+    def contacts_by_recency(ids = nil, limit: nil)
+      rows = (ids ? cards.where(id: ids) : cards).order(Sequel.desc(:updated_at)).select(:id, :updated_at)
+      rows = (limit ? rows.limit(limit) : rows).all
+      listed = listed_by_id(limit ? rows.map { it.fetch(:id).to_s } : ids)
+      rows.map {
         RecentContact.new(contact: listed.fetch(it.fetch(:id).to_s), updated_at: it.fetch(:updated_at).to_s)
       }
     end
 
+    # Every contact's search key, newest first like
+    # #contacts_by_recency, which a search hands the ids it finds back
+    # to (Web#search_results).
+    #: () -> Array[KeyedContact]
+    def keyed_by_recency
+      search_keys.join(:cards, id: :card_id).order(Sequel.desc(Sequel[:cards][:updated_at]))
+        .select(Sequel[:search_keys][:card_id], :text, :phones, :names).map {
+          key = Admin::Search::Key.new(text: it.fetch(:text).to_s, phones: it.fetch(:phones).to_s, names: it.fetch(:names).to_s)
+          KeyedContact.new(id: it.fetch(:card_id).to_s, key:)
+        }
+    end
+
     # The collection's content tag: one value that changes when any card
     # is added, removed, or changed and never otherwise, so a client
     # comparing two of them learns whether to resync — never what
@@ -584,6 +606,7 @@ module ProTacts
             # rather than moved — the deal #reindex always gives it. The
             # parameters follow their properties away on the cascade.
             card_properties.where(card_id: id).delete
+            search_keys.where(card_id: id).delete
             after = contact!(new_id)
             record(id, action: Action::DELETE, etag: nil, diff: CardDiff.between(before.vcard, nil))
             record(
@@ -911,6 +934,7 @@ module ProTacts
     def rebuild_index
       @database.transaction do
         card_properties.delete
+        search_keys.delete
         cards.order(:id).all.each do
           reindex(it.fetch(:id).to_s, VCard.new(it.fetch(:vcard).to_s))
         end
@@ -934,6 +958,11 @@ module ProTacts
       @database[:card_properties]
     end
 
+    #: () -> Sequel::Dataset
+    def search_keys
+      @database[:search_keys]
+    end
+
     #: () -> Sequel::Dataset
     def card_parameters
       @database[:card_parameters]
@@ -1177,12 +1206,14 @@ module ProTacts
     # way, and there is no version of this write that fixes a line the
     # parser cannot read. Takes the stored card — with the birthday
     # already subtracted — so the index never sees a BDAY no stored
-    # card carries.
+    # card carries. The card's search key goes with its rows, read off
+    # the stored card alone for the reason Admin::Search::Key gives.
     #: (String id, VCard vcard) -> void
     def reindex(id, vcard)
       # Before the parse, so that a card which has stopped parsing does
       # not keep the rows from when it did.
       card_properties.where(card_id: id).delete
+      search_keys.where(card_id: id).delete
 
       vcard.properties.each.with_index do |property, position|
         card_properties.insert(
@@ -1196,6 +1227,7 @@ module ProTacts
           card_parameters.insert(card_id: id, position:, name:, value:)
         end
       end
+      search_keys.insert(card_id: id, **Admin::Search.key(Contact.new(id:, stored: vcard, birthday: nil, inherited: [])).to_h)
     end
 
     # Applies whatever migrations the database has not seen. On one it is
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index d5a6110..a78fd74 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -134,7 +134,7 @@ module ProTacts
     def dashboard(notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
       Admin::Dashboard.call(
-        recent: store.contacts_by_recency,
+        recent: store.contacts_by_recency(limit: Admin::Dashboard::RECENT_LIMIT),
         upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
         login: @login,
         notice:,
diff --git a/lib/pro_tacts/web/search.rb b/lib/pro_tacts/web/search.rb
index 42f8ca4..a27e7b6 100644
--- a/lib/pro_tacts/web/search.rb
+++ b/lib/pro_tacts/web/search.rb
@@ -36,23 +36,33 @@ module ProTacts
 
     # A blank query answers the most recently updated contacts, the
     # dashboard's own list, so the search page is never empty before a
-    # word is typed.
+    # word is typed. A query walks every contact's search key and builds
+    # only the contacts it finds (docs/plans/2026-10-17-search-keys.md).
     #: (String query) -> Admin::SearchResults
     def search_results(query)
-      recent = span("search.contacts_by_recency") { |step|
-        store.contacts_by_recency.tap { step.set_attribute("rows", it.length) }
-      }
       if query.empty?
-        return Admin::SearchResults.new(contacts: recent.first(Admin::Dashboard::RECENT_LIMIT), groups: [], recent: true)
+        recent = span("search.contacts_by_recency") { |step|
+          store.contacts_by_recency(limit: Admin::Dashboard::RECENT_LIMIT).tap { step.set_attribute("rows", it.length) }
+        }
+        return Admin::SearchResults.new(contacts: recent, groups: [], recent: true)
       end
 
+      keyed = span("search.keyed_by_recency") { |step| store.keyed_by_recency.tap { step.set_attribute("rows", it.length) } }
       groups = span("search.all_groups") { |step| store.all_groups.tap { step.set_attribute("rows", it.length) } }
-      span("search.match") { |step|
-        contacts = Admin::Search.contacts(recent, query:, groups:)
-        step.set_attribute("words", query.split.length)
-        step.set_attribute("contacts", contacts.length)
-        Admin::SearchResults.new(contacts:, groups: Admin::Search.groups(groups, query:))
+      ids = span("search.match") { |step|
+        Admin::Search.contacts(keyed, query:, groups:).tap {
+          step.set_attribute("words", query.split.length)
+          step.set_attribute("contacts", it.length)
+        }
+      }
+      contacts = span("search.contacts_by_recency") { |step|
+        found = store.contacts_by_recency(ids).to_h {
+          [it.contact.id, it] #: [String, Store::RecentContact]
+        }
+        step.set_attribute("rows", found.length)
+        ids.map { found.fetch(it) }
       }
+      Admin::SearchResults.new(contacts:, groups: Admin::Search.groups(groups, query:))
     end
   end
 end
diff --git a/sig/pro_tacts/admin/search.rbs b/sig/pro_tacts/admin/search.rbs
new file mode 100644
index 0000000..e8dd9f9
--- /dev/null
+++ b/sig/pro_tacts/admin/search.rbs
@@ -0,0 +1,18 @@
+# Admin::Search::Key is a Data class, whose members the inline parser
+# cannot read (see sig/pro_tacts/contact.rbs). Search itself is a plain
+# module and carries its signatures inline.
+module ProTacts
+  module Admin
+    module Search
+      class Key
+        attr_reader text: String
+        attr_reader phones: String
+        attr_reader names: String
+
+        def self.new: (text: String, phones: String, names: String) -> instance
+        def with: (?text: String, ?phones: String, ?names: String) -> instance
+        def to_h: () -> { text: String, phones: String, names: String }
+      end
+    end
+  end
+end
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 1b23018..f501402 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -1,7 +1,7 @@
-# Change, GroupChange, RecentContact, UpcomingBirthday, GroupChoice, and
-# Snapshot are Data classes, whose members the inline parser cannot read
-# (see sig/pro_tacts/contact.rbs). Store itself is an ordinary class and
-# its types are inline in store.rb.
+# Change, GroupChange, RecentContact, KeyedContact, UpcomingBirthday,
+# GroupChoice, and Snapshot are Data classes, whose members the inline
+# parser cannot read (see sig/pro_tacts/contact.rbs). Store itself is an
+# ordinary class and its types are inline in store.rb.
 module ProTacts
   class Store
     class Change
@@ -32,6 +32,13 @@ module ProTacts
       def self.new: (contact: ListedContact, updated_at: String) -> instance
     end
 
+    class KeyedContact
+      attr_reader id: String
+      attr_reader key: Admin::Search::Key
+
+      def self.new: (id: String, key: Admin::Search::Key) -> instance
+    end
+
     class UpcomingBirthday
       attr_reader contact: ListedContact
       attr_reader occurs_on: Date
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 27d42e1..b3726f5 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -403,7 +403,7 @@ class AdminContactsPagesTest < Minitest::Test
       assert_equal 1, transactions.length
       transaction = transactions.first
       spans = transaction.spans.select { it[:op].start_with?("search.") }
-      assert_equal %w[search.contacts_by_recency search.all_groups search.match search.render], spans.map { it[:op] }
+      assert_equal %w[search.keyed_by_recency search.all_groups search.match search.contacts_by_recency search.render], spans.map { it[:op] }
       assert_equal({"otel.kind" => :internal, "words" => 1, "contacts" => 1}, spans.find { it[:op] == "search.match" }[:data])
       refute_includes JSON.generate(transaction.to_h), "lovelace"
     end
diff --git a/test/pro_tacts/admin/test_search.rb b/test/pro_tacts/admin/test_search.rb
index 5eab97d..ed75e70 100644
--- a/test/pro_tacts/admin/test_search.rb
+++ b/test/pro_tacts/admin/test_search.rb
@@ -7,17 +7,17 @@ require "pro_tacts/store"
 require "pro_tacts/vcard"
 
 class SearchTest < Minitest::Test
-  # A card from its extra lines, and its row in recency order: the
-  # rows arrive most recent first, the way Store#contacts_by_recency
-  # hands them over.
+  # A card from its extra lines, keyed as the store keys it: the rows
+  # arrive most recent first, the way Store#keyed_by_recency hands them
+  # over.
   def row(id, name, *lines)
     bytes = ["BEGIN:VCARD", "VERSION:3.0", "FN:#{name}", "UID:#{id}", *lines, "END:VCARD", ""].join("\r\n")
     contact = ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(bytes), birthday: nil, inherited: [])
-    ProTacts::Store::RecentContact.new(contact:, updated_at: Time.now)
+    ProTacts::Store::KeyedContact.new(id:, key: ProTacts::Admin::Search.key(contact))
   end
 
   def search(rows, query, groups: [])
-    ProTacts::Admin::Search.contacts(rows, query:, groups:).map { it.contact.id }
+    ProTacts::Admin::Search.contacts(rows, query:, groups:)
   end
 
   # Each word finds its own field: neither "ada" nor "london" is
@@ -62,6 +62,29 @@ class SearchTest < Minitest::Test
     assert_equal ["ada"], search([ada], "mathematicians", groups: [math])
   end
 
+  # What a group lends is matched as the group's, not keyed into the
+  # member: a lent address and a lent note find every member, and
+  # nobody outside the group.
+  def test_what_a_group_lends_finds_its_members
+    ada = row("ada", "Ada Lovelace")
+    grace = row("grace", "Grace Hopper")
+    home = ProTacts::Group.new(id: "home", name: "Home", label: "Home",
+                               lines: ["ADR:;;12 St James Sq;London;;;UK", "NOTE:Gate code 7946"], members: ["ada"])
+
+    assert_equal ["ada"], search([ada, grace], "james sq", groups: [home])
+    assert_equal ["ada"], search([ada, grace], "gate code", groups: [home])
+  end
+
+  # A group lends no name, so its label finds a member without making
+  # the find a name hit: Grace's own name still leads.
+  def test_a_group_label_is_not_a_name_hit
+    ada = row("ada", "Ada Lovelace")
+    grace = row("grace", "Grace Hopper")
+    graces = ProTacts::Group.new(id: "graces", name: "Graces", label: "Graces", lines: [], members: ["ada"])
+
+    assert_equal ["grace", "ada"], search([ada, grace], "grace", groups: [graces])
+  end
+
   # Charles is the more recent, and only his note says "ada"; Ada's
   # name does, so she leads anyway. Within a tier, recency stands:
   # the two notes keep the order they arrived in.
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index c39d604..60c8627 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -339,6 +339,18 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # The newest few, and the ones a search found, each still newest
+  # first: the two narrowings the search and the dashboard read.
+  def test_contacts_by_recency_narrows_to_a_limit_or_to_ids
+    with_store({"aiden" => AIDEN}) do |store|
+      sleep 0.002
+      store.put("znorth", vcard(ZED))
+
+      assert_equal %w[znorth], store.contacts_by_recency(limit: 1).map { it.contact.id }
+      assert_equal %w[aiden], store.contacts_by_recency(["aiden"]).map { it.contact.id }
+    end
+  end
+
   def test_contacts_by_recency_carries_the_stamp
     with_store({"aiden" => AIDEN}) do |store|
       recent = store.contacts_by_recency.fetch(0)
@@ -524,6 +536,7 @@ class StoreTest < Minitest::Test
       assert_equal [new_id], store.group(group).members
       assert_equal new_id, store.card_id_with_uid(new_id)
       assert database(store)[:card_properties].where(card_id: UUID).empty?
+      assert_equal [new_id], store.keyed_by_recency.map(&:id)
     end
   end
 
@@ -692,6 +705,31 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # A card's own values, folded, and nothing its groups lend: a lent
+  # address is matched as the group's (Admin::Search::Key).
+  def test_a_card_is_keyed_for_the_search_off_its_own_lines
+    card = AIDEN.sub("FN:Aiden\r\n", "FN:Áiden\r\nNICKNAME:Ade\r\nTEL:+1 (555) 123-4567\r\nNOTE:Plays chess\r\n")
+    with_store({"aiden" => card}) do |store|
+      group = store.create_group(name: "Booles")
+      store.add_member(group, "aiden")
+      store.set_group_lines(group, [HOUSEHOLD_ADDRESS])
+
+      key = store.keyed_by_recency.fetch(0).key
+      assert_equal "aiden\nade\nplays chess", key.text
+      assert_equal "15551234567", key.phones
+      assert_equal "aiden ade", key.names
+    end
+  end
+
+  def test_keys_are_listed_newest_first
+    with_store({"aiden" => AIDEN}) do |store|
+      sleep 0.002
+      store.put("znorth", vcard(ZED))
+
+      assert_equal %w[znorth aiden], store.keyed_by_recency.map(&:id)
+    end
+  end
+
   def test_replacing_a_card_replaces_its_index_rows
     with_store({"aiden" => AIDEN}) do |store|
       store.put("aiden", vcard(AIDEN.sub("FN:Aiden\r\n", "")))
@@ -2457,6 +2495,23 @@ class StoreTest < Minitest::Test
   # serves the group's joined note as one section, the shape every
   # write composes from here
   # (docs/plans/2026-09-25-one-note-per-contact.md).
+  # The cards already stored are keyed as the table lands, so a search
+  # finds them before any is written again.
+  def test_the_migration_keys_the_cards_already_stored
+    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: 13)
+        db[:cards].insert(id: "aiden", vcard: AIDEN)
+        db[:cards].insert(id: "znorth", vcard: ZED)
+      end
+
+      ProTacts::Store.connect(path) do |store|
+        assert_equal({"aiden" => "aiden", "znorth" => "zed"}, store.keyed_by_recency.to_h { [it.id, it.key.names] })
+      end
+    end
+  end
+
   def test_the_migration_joins_several_notes_into_one
     two_notes = AIDEN.sub("END:VCARD\r\n", "NOTE:First.\r\nNOTE:Second.\r\nEND:VCARD\r\n")
     Dir.mktmpdir do |dir|
@@ -2649,6 +2704,7 @@ class StoreTest < Minitest::Test
     [
       database(store)[:card_properties].order(:card_id, :position).all,
       database(store)[:card_parameters].order(:card_id, :position, :name, :value).all,
+      database(store)[:search_keys].order(:card_id).all,
     ]
   end
 
@@ -2662,5 +2718,6 @@ class StoreTest < Minitest::Test
   def wreck_the_index(store)
     database(store)[:card_parameters].delete
     database(store)[:card_properties].update(value: "wrong")
+    database(store)[:search_keys].delete
   end
 end