listed group: a Group subclass carrying its listed members
Group goes back to main's shape, with no optional listed_members.
The reads that tag groups (Store#listed_groups_of, #all_listed_groups)
return a ListedGroup, so a tag's type says its members are there and
GroupTag no longer checks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191V6gdWnYUcU5DvJBAWoMj
change
commit 887fa1ce4c7714e2f43f6e78fb652fe67bf6a96f
author Claude <noreply@anthropic.com>
date
parent d23aa030
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index 8131250..f387c4d 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -31,10 +31,10 @@ module ProTacts
     # save lands on the group it made rather than back on the walk.
     class ContactCard < Phlex::HTML
       # @rbs @contact: Contact
-      # @rbs @groups: Array[Group]
+      # @rbs @groups: Array[ListedGroup]
       # @rbs @walk: bool
 
-      #: (contact: Contact, groups: Array[Group], ?walk: bool) -> void
+      #: (contact: Contact, groups: Array[ListedGroup], ?walk: bool) -> void
       def initialize(contact:, groups:, walk: false)
         @contact = contact
         @groups = groups
@@ -188,7 +188,7 @@ module ProTacts
       end
 
       # A lent row's group as the groups row read it, with its members.
-      #: (Group lent) -> Group
+      #: (Group lent) -> ListedGroup
       def listed(lent) = @groups.find { it.id == lent.id } || raise(ArgumentError, "group #{lent.id} lends a row but is not among the contact's groups")
 
       #: (String | Array[String] value) -> void
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index ee9979e..c06c123 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -20,9 +20,9 @@ module ProTacts
     class ContactsIndex < Phlex::HTML
       # @rbs @rows: Array[ListedContact]
       # @rbs @login: String
-      # @rbs @groups_of: Hash[String, Array[Group]]
+      # @rbs @groups_of: Hash[String, Array[ListedGroup]]
 
-      #: (contacts: Array[ListedContact], groups: Array[Group], login: String) -> void
+      #: (contacts: Array[ListedContact], groups: Array[ListedGroup], login: String) -> void
       def initialize(contacts:, groups:, login:)
         @login = login
         @rows = contacts.sort
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 718ab60..dbaf51c 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -18,7 +18,7 @@ module ProTacts
       # one: a card with no entries is a card whose history was lost,
       # and an empty default would render that as an ordinary quiet
       # record. Every group is the groups dialog's (GroupDialog).
-      #: (Contact contact, String login, Array[Group] groups, Array[Store::GroupChoice] all_groups, Array[Store::Change] changes, ?notice: String?) -> void
+      #: (Contact contact, String login, Array[ListedGroup] groups, Array[Store::GroupChoice] all_groups, Array[Store::Change] changes, ?notice: String?) -> void
       def initialize(contact:, login:, groups:, all_groups:, changes:, notice: nil)
         @contact = contact
         @login = login
diff --git a/lib/pro_tacts/admin/group_tag.rb b/lib/pro_tacts/admin/group_tag.rb
index f877335..509b3f6 100644
--- a/lib/pro_tacts/admin/group_tag.rb
+++ b/lib/pro_tacts/admin/group_tag.rb
@@ -12,17 +12,15 @@ module ProTacts
     class GroupTag < Phlex::HTML
       SHOWN = 5 #: Integer
 
-      # @rbs @group: Group
-      # @rbs @members: Array[ListedContact]
+      # @rbs @group: ListedGroup
 
-      #: (group: Group) -> void
+      #: (group: ListedGroup) -> void
       def initialize(group:)
         @group = group
-        @members = group.listed_members || raise(ArgumentError, "group #{group.id} was read without its members listed")
       end
 
       def view_template
-        shown, rest = @members.partition.with_index { |_, index| index < SHOWN }
+        shown, rest = @group.listed_members.partition.with_index { |_, index| index < SHOWN }
         span do
           a(href: "/groups/#{@group.id}", class: "tag") do
             render Icon.new(:image, size: 12) if @group.photo
diff --git a/lib/pro_tacts/admin/import_saved.rb b/lib/pro_tacts/admin/import_saved.rb
index 3082f03..282ae1b 100644
--- a/lib/pro_tacts/admin/import_saved.rb
+++ b/lib/pro_tacts/admin/import_saved.rb
@@ -19,7 +19,7 @@ module ProTacts
     class ImportSaved < Phlex::HTML
       # @rbs @contact: Contact
       # @rbs @login: String
-      # @rbs @groups: Array[Group]
+      # @rbs @groups: Array[ListedGroup]
       # @rbs @all_groups: Array[Store::GroupChoice]
       # @rbs @aside: Phlex::HTML
       # @rbs @sidebar: Phlex::HTML
@@ -27,7 +27,7 @@ module ProTacts
       # @rbs @land: String
       # @rbs @notice: String?
 
-      #: (contact: Contact, login: String, groups: Array[Group], all_groups: Array[Store::GroupChoice], aside: Phlex::HTML, sidebar: Phlex::HTML, edit: String, land: String, ?notice: String?) -> void
+      #: (contact: Contact, login: String, groups: Array[ListedGroup], all_groups: Array[Store::GroupChoice], aside: Phlex::HTML, sidebar: Phlex::HTML, edit: String, land: String, ?notice: String?) -> void
       def initialize(contact:, login:, groups:, all_groups:, aside:, sidebar:, edit:, land:, notice: nil)
         @contact = contact
         @login = login
diff --git a/lib/pro_tacts/group.rb b/lib/pro_tacts/group.rb
index d55df22..2b4116c 100644
--- a/lib/pro_tacts/group.rb
+++ b/lib/pro_tacts/group.rb
@@ -8,22 +8,21 @@ module ProTacts
   # A group as the admin screens read one: its own row, the lines it
   # lends in the order it lends them, its members' card ids, and
   # whether it has a photo (docs/plans/2026-10-12-group-photos.md).
-  # Only the reads that ask fill in `listed_members`, so a group
-  # composed into a card for a client reads none. The label is the SQL one Store#group_label computes, so a tag and a
+  # The label is the SQL one Store#group_label computes, so a tag and a
   # heading can never disagree about what to call a nameless group.
   #
   # The signature lives in sig/pro_tacts/group.rbs, this being a Data
   # class.
   # @rbs skip
-  Group = Data.define(:id, :name, :label, :lines, :members, :photo, :listed_members)
+  Group = Data.define(:id, :name, :label, :lines, :members, :photo)
 
   # Reopened rather than defined in the block above, for the reason
   # CardDiff is.
   class Group
     # A photo is the exception, not the rule, so a group built without
     # saying is one without.
-    #: (?photo: bool, ?listed_members: Array[ListedContact]?, **untyped) -> void
-    def initialize(photo: false, listed_members: nil, **)
+    #: (?photo: bool, **untyped) -> void
+    def initialize(photo: false, **)
       super
     end
 
diff --git a/lib/pro_tacts/listed_group.rb b/lib/pro_tacts/listed_group.rb
new file mode 100644
index 0000000..0e984ff
--- /dev/null
+++ b/lib/pro_tacts/listed_group.rb
@@ -0,0 +1,19 @@
+require "pro_tacts/group"
+require "pro_tacts/listed_contact"
+
+module ProTacts
+  # A group read with its members as a list shows them, in Contact#<=>'s
+  # order, for the screens that tag groups (Admin::GroupTag). A group
+  # composed into a card for a client is a plain Group, and reads none.
+  #
+  # Signed in sig/pro_tacts/listed_group.rbs, for Group's reason.
+  # @rbs skip
+  class ListedGroup < Group
+    def initialize(listed_members:, **)
+      @listed_members = listed_members
+      super(**)
+    end
+
+    attr_reader :listed_members
+  end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 141b3dc..da09d3a 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -12,6 +12,7 @@ require "pro_tacts/contact"
 require "pro_tacts/edited_contact"
 require "pro_tacts/group"
 require "pro_tacts/listed_contact"
+require "pro_tacts/listed_group"
 require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
 
@@ -360,17 +361,20 @@ module ProTacts
     # rather than on it: what a group lends is in the card and belongs
     # to the model of one, and which groups a contact is in is a
     # relationship the card never carries.
-    #: (String id, ?listed_members: bool) -> Array[Group]
-    def groups_of(id, listed_members: false)
-      rows = labeled_groups
+    #: (String id) -> Array[Group]
+    def groups_of(id)
+      load_groups(labeled_groups
         .join(:group_members, group_id: :id)
         .where(card_id: id)
         .order(Sequel[:groups][:id])
-        .all
-      return load_groups(rows) unless listed_members
+        .all)
+    end
 
-      members = group_members.where(group_id: rows.map { it.fetch(:id) }).select_map(:card_id).map(&:to_s).uniq
-      load_groups(rows, listing: listed_contacts(members))
+    # #groups_of with each group's members listed, for a contact's tags.
+    #: (String id) -> Array[ListedGroup]
+    def listed_groups_of(id)
+      groups = groups_of(id)
+      list_members(groups, listed_contacts(groups.flat_map(&:members).uniq))
     end
 
     # The cards one user's client syncs: every member of `sync:*` and of
@@ -663,12 +667,18 @@ module ProTacts
     end
 
     # Every group, ordered by id — the order #groups_of and the
-    # composition both use, so a rename never moves one. A `listing`
-    # the caller already read lists each group's members out of it,
-    # since `sync:*` would otherwise read the whole book again.
-    #: (?listing: Array[ListedContact]?) -> Array[Group]
-    def all_groups(listing: nil)
-      load_groups(labeled_groups.order(:id).all, listing:)
+    # composition both use, so a rename never moves one.
+    #: () -> Array[Group]
+    def all_groups
+      load_groups(labeled_groups.order(:id).all)
+    end
+
+    # #all_groups with each group's members listed out of a `listing`
+    # the caller already read, since `sync:*` would otherwise read the
+    # whole book again.
+    #: (Array[ListedContact] listing) -> Array[ListedGroup]
+    def all_listed_groups(listing)
+      list_members(all_groups, listing)
     end
 
     # Every group as a picker needs it: the row, its label and how many
@@ -1128,29 +1138,39 @@ module ProTacts
 
     # Groups rows read whole: the lines and members of every group in
     # `rows`, and which have a photo, three reads however many groups
-    # there are. The photo read takes ids, never bytes. A `listing` is
-    # sorted once here rather than by every tag that shows a group.
-    #: (Array[Hash[Symbol, untyped]] rows, ?listing: Array[ListedContact]?) -> Array[Group]
-    def load_groups(rows, listing: nil)
+    # there are. The photo read takes ids, never bytes.
+    #: (Array[Hash[Symbol, untyped]] rows) -> Array[Group]
+    def load_groups(rows)
       ids = rows.map { it.fetch(:id).to_s }
       lines = group_properties.where(group_id: ids).order(:group_id, :position).all
         .group_by { it.fetch(:group_id).to_s }
       members = group_members.where(group_id: ids).order(:card_id).all
         .group_by { it.fetch(:group_id).to_s }
       photos = group_photos.where(group_id: ids).select_map(:group_id).map(&:to_s)
-      sorted = listing&.sort
       rows.map { |row|
         id = row.fetch(:id).to_s
-        member_ids = members.fetch(id, []).map { it.fetch(:card_id).to_s }
-        joined = member_ids.to_set
         Group.new(
           id:,
           name: row.fetch(:name)&.to_s,
           label: row.fetch(:label).to_s,
           lines: lines.fetch(id, []).map { it.fetch(:line).to_s },
-          members: member_ids,
+          members: members.fetch(id, []).map { it.fetch(:card_id).to_s },
           photo: photos.include?(id),
-          listed_members: sorted&.select { joined.include?(it.id) },
+        )
+      }
+    end
+
+    # The listing is sorted once here rather than by every tag that
+    # shows a group.
+    #: (Array[Group] groups, Array[ListedContact] listing) -> Array[ListedGroup]
+    def list_members(groups, listing)
+      sorted = listing.sort
+      groups.map { |group|
+        joined = group.members.to_set
+        ListedGroup.new(
+          id: group.id, name: group.name, label: group.label, lines: group.lines,
+          members: group.members, photo: group.photo,
+          listed_members: sorted.select { joined.include?(it.id) },
         )
       }
     end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 46dbaf1..91c61e0 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -25,7 +25,7 @@ module ProTacts
         r.get do
           response["Content-Type"] = "text/html; charset=utf-8"
           contacts = store.listed_contacts
-          Admin::ContactsIndex.call(contacts:, groups: store.all_groups(listing: contacts), login: @login)
+          Admin::ContactsIndex.call(contacts:, groups: store.all_listed_groups(contacts), login: @login)
         end
 
         # The browser's create, from the dashboard's dialog: POST is
@@ -296,7 +296,7 @@ module ProTacts
       Admin::ContactsShow.call(
         contact:,
         login: @login,
-        groups: store.groups_of(contact.id, listed_members: true),
+        groups: store.listed_groups_of(contact.id),
         all_groups: store.group_choices,
         changes: store.changes_of(contact.id),
         notice:,
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 8d5f38f..45deeba 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -215,7 +215,7 @@ module ProTacts
         Admin::ImportSaved.call(
           contact:,
           login: @login,
-          groups: store.groups_of(contact.id, listed_members: true),
+          groups: store.listed_groups_of(contact.id),
           all_groups: store.group_choices,
           aside: Admin::ImportOriginal.new(card: original, dropped:),
           sidebar:,
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 9f3199c..2b5a1c4 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -7,7 +7,7 @@
 module ProTacts
   module Admin
     class ContactsShow
-      def self.call: (contact: Contact, login: String, groups: Array[Group], all_groups: Array[Store::GroupChoice], changes: Array[Store::Change], ?notice: String?) -> String
+      def self.call: (contact: Contact, login: String, groups: Array[ListedGroup], all_groups: Array[Store::GroupChoice], changes: Array[Store::Change], ?notice: String?) -> String
     end
 
     # `aside` is a Phlex component, which has no type here: phlex
@@ -33,7 +33,7 @@ module ProTacts
     end
 
     class ContactsIndex
-      def self.call: (contacts: Array[ListedContact], groups: Array[Group], login: String) -> String
+      def self.call: (contacts: Array[ListedContact], groups: Array[ListedGroup], login: String) -> String
     end
 
     class GroupsIndex
@@ -83,7 +83,7 @@ module ProTacts
     end
 
     class ImportSaved
-      def self.call: (contact: Contact, login: String, groups: Array[Group], all_groups: Array[Store::GroupChoice], aside: untyped, sidebar: untyped, edit: String, land: String, ?notice: String?) -> String
+      def self.call: (contact: Contact, login: String, groups: Array[ListedGroup], all_groups: Array[Store::GroupChoice], aside: untyped, sidebar: untyped, edit: String, land: String, ?notice: String?) -> String
     end
 
     # Its name_label is the import's list rows' (Web#import_row), and
diff --git a/sig/pro_tacts/group.rbs b/sig/pro_tacts/group.rbs
index 8cb6d8a..7c5ebc1 100644
--- a/sig/pro_tacts/group.rbs
+++ b/sig/pro_tacts/group.rbs
@@ -10,8 +10,7 @@ module ProTacts
     attr_reader lines: Array[String]
     attr_reader members: Array[String]
     attr_reader photo: bool
-    attr_reader listed_members: Array[ListedContact]?
 
-    def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String], ?photo: bool, ?listed_members: Array[ListedContact]?) -> instance
+    def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String], ?photo: bool) -> instance
   end
 end
diff --git a/sig/pro_tacts/listed_group.rbs b/sig/pro_tacts/listed_group.rbs
new file mode 100644
index 0000000..3c3ac58
--- /dev/null
+++ b/sig/pro_tacts/listed_group.rbs
@@ -0,0 +1,11 @@
+module ProTacts
+  class ListedGroup < Group
+    @listed_members: Array[ListedContact]
+
+    attr_reader listed_members: Array[ListedContact]
+
+    def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String], ?photo: bool, listed_members: Array[ListedContact]) -> instance
+
+    def initialize: (listed_members: Array[ListedContact], **untyped) -> void
+  end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 5ec0610..506a2b5 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1376,10 +1376,10 @@ class StoreTest < Minitest::Test
     with_store({"znorth" => ZED, "aiden" => AIDEN}) do |store|
       FixtureData.seed_group(store, name: "Booles", members: ["znorth", "aiden"])
 
-      assert_equal [%w[aiden znorth]], store.groups_of("znorth", listed_members: true).map { it.listed_members.to_a.map(&:id) }
-      assert_equal [%w[aiden znorth]], store.all_groups(listing: store.listed_contacts).map { it.listed_members.to_a.map(&:id) }
-      assert_nil store.groups_of("znorth").fetch(0).listed_members
-      assert_nil store.all_groups.fetch(0).listed_members
+      assert_equal [%w[aiden znorth]], store.listed_groups_of("znorth").map { it.listed_members.map(&:id) }
+      assert_equal [%w[aiden znorth]], store.all_listed_groups(store.listed_contacts).map { it.listed_members.map(&:id) }
+      assert_instance_of ProTacts::Group, store.groups_of("znorth").fetch(0)
+      assert_instance_of ProTacts::Group, store.all_groups.fetch(0)
     end
   end