store, admin: groups get screens of their own
A group had rows but no door to walk through,
so /groups lists one, shows one, and edits it too.
Search finds it by name, a member's tag leads the way,
and each card a save moves is logged right away.

Assisted-by: Claude Opus 5 via Claude Code
change ykvvryrnrznutmovtmmozlvtppnktrqv
commit 75fe80c0465c93e6e18537abc4cbd42072ce89bb
author Alpha Chen <alpha@kejadlen.dev>
date
parent pvsztwwv
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 5a2125a..6fcea8d 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -66,7 +66,8 @@ module ProTacts
       # the save splices around. Field name to placeholder: a
       # component is not an attribute and earns no type column of its
       # own, so the placeholder is the label and the conventional
-      # order carries it.
+      # order carries it. Public because a group's address rows are
+      # these same rows (Admin::GroupsEdit).
       ADDRESS_FIELDS = [
         ["street", "street"],
         ["extended", "street 2"],
@@ -75,7 +76,6 @@ module ProTacts
         ["postal_code", "postal code"],
         ["country", "country"],
       ].freeze #: Array[[String, String]]
-      private_constant :ADDRESS_FIELDS
 
       # @rbs @contact: Contact
       # @rbs @own: Contact
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index ef4d7ac..e45173f 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -22,14 +22,23 @@ module ProTacts
 
       # @rbs @query: String
       # @rbs @upcoming: Array[Store::UpcomingBirthday]
+      # @rbs @labels: Hash[String, Array[String]]
+      # @rbs @groups: Array[Store::Group]
       # @rbs @rows: Array[Store::RecentContact]
       # @rbs @notice: String?
 
-      #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, ?notice: String?) -> void
-      def initialize(recent:, upcoming:, query:, notice: nil)
+      # The groups arrive whole because a search answers from both
+      # sides of the relationship: a group is findable by its own name,
+      # and a contact by the names of the groups it is in.
+      #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, groups: Array[Store::Group], ?notice: String?) -> void
+      def initialize(recent:, upcoming:, query:, groups:, notice: nil)
         @query = query.to_s.strip
         @upcoming = upcoming
         @notice = notice
+        @labels = {}
+        groups.each { |group| group.members.each { (@labels[it] ||= []) << group.label } }
+        q = @query.downcase
+        @groups = @query.empty? ? [] : groups.select { it.label.downcase.include?(q) }
         @rows = @query.empty? ? recent.first(RECENT_LIMIT) : recent.select { matches?(it, @query) }
       end
 
@@ -54,6 +63,7 @@ module ProTacts
                   end
                 end
               end
+              group_results if @groups.any?
             end
             render UpcomingBirthdays.new(upcoming: @upcoming)
           end
@@ -90,10 +100,30 @@ module ProTacts
         return true if row.contact.nickname&.downcase&.include?(q)
         return true if row.contact.phones.any? { it.value.downcase.include?(q) }
         return true if row.contact.emails.any? { it.value.downcase.include?(q) }
+        return true if @labels.fetch(row.contact.id, []).any? { it.downcase.include?(q) }
 
         false
       end
 
+      # The groups whose names match, under the contacts: a group is a
+      # record the search reaches as surely as a contact is
+      # (docs/DESIGN.md, "When adding something new").
+      #: () -> void
+      def group_results
+        div(class: "section-head") do
+          h2(class: "type-label") { "groups" }
+        end
+        ul(class: "card") do
+          @groups.each do |group|
+            li do
+              a(href: "/groups/#{group.id}") do
+                div(style: "flex: 1; min-width: 0; font-weight: 550;") { group.label }
+              end
+            end
+          end
+        end
+      end
+
       #: (Store::RecentContact row) -> void
       def render_row(row)
         li do
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 695e4c5..576fbfb 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -33,7 +33,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.
-      #: (Contact contact, Array[String] groups, Array[Store::Change] changes) -> void
+      #: (Contact contact, Array[Store::Group] groups, Array[Store::Change] changes) -> void
       def initialize(contact:, groups:, changes:)
         @contact = contact
         @groups = groups
@@ -138,7 +138,7 @@ module ProTacts
           row(email.type || "email", email.value, email.line)
         end
         @contact.addresses.each do |address|
-          row(address.type || "address", address_lines(address), address.line)
+          row(address.type || "address", Format.address_lines(address), address.line)
         end
         row("birthday", @birthday) if @birthday
         @contact.notes.each do |note|
@@ -151,18 +151,15 @@ module ProTacts
       # values are the group's rather than the contact's. Gloss' Tag
       # rather than the Badge the inherited rows carry — a badge marks
       # the row it sits on, and these are the row's whole value
-      # (docs/DESIGN.md, "Relationships are navigable").
-      #
-      # Tags, not links, for now: the design has every tag opening the
-      # record it names, and a group has no screen to open yet. They
-      # become links with the group card, which is also what gives a
-      # nameless group somewhere for its id to lead.
+      # (docs/DESIGN.md, "Relationships are navigable"). Every tag opens
+      # the group it names, the other half of the member tags on a
+      # group's own card (Admin::GroupsShow).
       #: () -> void
       def groups_row
         dt(class: "type-label") { "groups" }
         dd(class: "type-body-sm") do
           div(class: "tag-set") do
-            @groups.each { |group| span(class: "tag") { group } }
+            @groups.each { |group| a(href: "/groups/#{group.id}", class: "tag") { group.label } }
           end
         end
       end
@@ -189,18 +186,6 @@ module ProTacts
         end
       end
 
-      # ADR's components as two display lines — street, then everything
-      # after it — the same shape the design's own address rows settled
-      # on. Presentation: the components themselves are Contact's.
-      #: (Contact::Address address) -> Array[String]
-      def address_lines(address)
-        [
-          [address.extended, address.street].compact.join(" "),
-          [address.locality, address.region, address.postal_code].compact.join(", "),
-          address.country,
-        ].compact.reject { it.empty? }
-      end
-
       #: (String | Array[String] value) -> void
       def render_value(value)
         if value.is_a?(Array)
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index ec93e61..8e12aab 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -43,6 +43,19 @@ module ProTacts
         birthday&.to_s || property.value
       end
 
+      # ADR's components as display lines — street, then everything
+      # after it — the shape the design's own address rows settled on,
+      # and one shape for a contact's card and a group's alike.
+      # Presentation: the components themselves are Contact's.
+      #: (Contact::Address address) -> Array[String]
+      def self.address_lines(address)
+        [
+          [address.extended, address.street].compact.join(" "),
+          [address.locality, address.region, address.postal_code].compact.join(", "),
+          address.country,
+        ].compact.reject { it.empty? }
+      end
+
       # How much of an etag's hash is shown before it is cut. Twelve
       # hex digits is git's own longest short-hash, and this address
       # book will never hold enough cards for two to collide in a
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
new file mode 100644
index 0000000..7a3e3c4
--- /dev/null
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -0,0 +1,161 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/contacts_edit"
+require "pro_tacts/admin/layout"
+
+module ProTacts
+  module Admin
+    # GET /groups/:id/edit — a group's editor: Admin::ContactsEdit's
+    # shape over what a group holds, which is a name, the addresses and
+    # note it lends, and who it lends them to. The address rows are the
+    # contact editor's own — addressed by digest, blank removes
+    # (docs/plans/2026-09-05-web-card-editor.md) — and the save runs
+    # the contact editor's own splice over the group's lines
+    # (Web#apply_group_edit). A group's version rides along hidden, the
+    # snapshot guard a contact's etag is on its editor.
+    #
+    # Membership is checkboxes, native form state rather than script: a
+    # member is a checked tag, unchecking one removes it on save, and
+    # the add dialog lists every contact not yet in the group as an
+    # unchecked box that submits with the form it sits outside of (the
+    # `form` attribute) — the dialog being the view's one floated layer,
+    # docs/DESIGN.md's rule for adding. A hidden empty `members[]`
+    # makes the list present on every save of this form, so a POST that
+    # carries no list at all is one that says nothing about membership
+    # (#apply_edit's is-a-Hash posture) rather than one that empties it.
+    class GroupsEdit < Phlex::HTML
+      # @rbs @group: Store::Group
+      # @rbs @reading: Contact
+      # @rbs @members: Array[Contact]
+      # @rbs @others: Array[Contact]
+      # @rbs @notice: String?
+
+      #: (group: Store::Group, contacts: Array[Contact], ?notice: String?) -> void
+      def initialize(group:, contacts:, notice: nil)
+        @group = group
+        @reading = group.reading
+        @members, @others = contacts.partition { group.members.include?(it.id) }
+        @notice = notice
+      end
+
+      def view_template
+        render Layout.new(title: "Edit #{@group.label}", notice: @notice) do
+          div(class: "record") do
+            div(class: "record-nav") do
+              a(href: "/groups/#{@group.id}", class: "type-label") { "‹ #{@group.label}" }
+              if @others.any?
+                button(type: "button", data_size: "sm", popovertarget: "add-members") { "add members" }
+              end
+            end
+            div(class: "card") do
+              div(class: "card-body") do
+                form(id: "group-form", action: "/groups/#{@group.id}", method: "post", class: "field-stack") do
+                  input(type: "hidden", name: "version", value: @group.version)
+                  input(type: "hidden", name: "members[]", value: "")
+                  # The id as the blank's placeholder, because a group
+                  # with no name is shown as its id everywhere else.
+                  label(class: "field") do
+                    span { "Name" }
+                    input(type: "text", name: "name", value: @group.name,
+                          placeholder: @group.id, autofocus: true)
+                  end
+                  members_row if @members.any?
+                  @reading.addresses.each { |address| address_row(address) }
+                  added_address_row
+                  # The first NOTE, the contact editor's own rule: this
+                  # row edits one, and a save writes one.
+                  note = @reading.notes.first&.value
+                  label(class: "field", data: {blank_removes: !!note}) do
+                    span { "Note" }
+                    textarea(name: "note", rows: 4,
+                             placeholder: note ? "removed on save" : nil) { note.to_s }
+                  end
+                  div(class: "form-actions") do
+                    button(type: "submit", data: {variant: "primary"}) { "Save" }
+                    a(href: "/groups/#{@group.id}", class: "btn") { "Cancel" }
+                  end
+                end
+              end
+            end
+          end
+          add_members_dialog if @others.any?
+        end
+      end
+
+      private
+
+      #: () -> void
+      def members_row
+        div(class: "field") do
+          span { "Members" }
+          div(class: "tag-set") do
+            @members.each do |member|
+              label(class: "tag") do
+                input(type: "checkbox", name: "members[]", value: member.id, checked: true)
+                plain(member.name || member.id)
+              end
+            end
+          end
+        end
+      end
+
+      # ContactsEdit#address_row, over a group's line.
+      #: (Contact::Address address) -> void
+      def address_row(address)
+        div(class: "field", data: {blank_removes: address.po_box.nil?}) do
+          span { address.type || "address" }
+          div(class: "field-stack") do
+            ContactsEdit::ADDRESS_FIELDS.each do |component, label|
+              input(type: "text", name: "address[#{address.line.digest}][#{component}]",
+                    value: address.public_send(component), placeholder: label, aria_label: label)
+            end
+          end
+        end
+      end
+
+      # One address added per save, revealed on ask so no empty row
+      # stands in the form until one is wanted (ContactsEdit#added_rows'
+      # reason, and Alpine's). `display: contents` keeps both halves on
+      # the form's own grid.
+      #: () -> void
+      def added_address_row
+        div(x_data: "{ adding: false }", style: "display: contents;") do
+          template("x-if": "adding") do
+            div(class: "field") do
+              span { "address" }
+              div(class: "field-stack") do
+                ContactsEdit::ADDRESS_FIELDS.each_with_index do |(component, label), index|
+                  input(type: "text", name: "new_address[0][#{component}]", placeholder: label,
+                        aria_label: label, "x-init": index.zero? ? "$el.focus()" : nil)
+                end
+              end
+            end
+          end
+          div(class: "field", "x-show": "!adding") do
+            span
+            button(type: "button", data_size: "sm", "@click": "adding = true") { "add address" }
+          end
+        end
+      end
+
+      #: () -> void
+      def add_members_dialog
+        dialog(id: "add-members", popover: "auto") do
+          header { "Add members" }
+          div(class: "field-stack") do
+            @others.each do |contact|
+              label do
+                input(type: "checkbox", name: "members[]", value: contact.id, form: "group-form")
+                plain(contact.name || contact.id)
+              end
+            end
+          end
+          footer do
+            button(type: "button", popovertarget: "add-members",
+                   popovertargetaction: "hide") { "Done" }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/groups_index.rb b/lib/pro_tacts/admin/groups_index.rb
new file mode 100644
index 0000000..aaae82c
--- /dev/null
+++ b/lib/pro_tacts/admin/groups_index.rb
@@ -0,0 +1,78 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/layout"
+
+module ProTacts
+  module Admin
+    # GET /groups — every group and how many cards it lends to, with
+    # the create dialog riding along hidden the way the dashboard's
+    # does (see ContactDialog for why a popover). A group needs nothing
+    # to exist — no name, no lines, no members (db/migrations/
+    # 005_group_identity.rb) — so the dialog's one field is optional,
+    # and a create lands on the new group's editor, where everything
+    # it will hold is added.
+    class GroupsIndex < Phlex::HTML
+      # @rbs @groups: Array[Store::Group]
+      # @rbs @notice: String?
+
+      #: (groups: Array[Store::Group], ?notice: String?) -> void
+      def initialize(groups:, notice: nil)
+        @groups = groups
+        @notice = notice
+      end
+
+      def view_template
+        render Layout.new(title: "Groups", notice: @notice) do
+          section do
+            div(class: "section-head") do
+              h2(class: "type-label") { "groups" }
+              button(data_size: "sm", popovertarget: "new-group") { "add group" }
+            end
+            if @groups.empty?
+              p(class: "type-body-sm") { "No groups yet." }
+            else
+              ul(class: "card") do
+                @groups.each do |group|
+                  li do
+                    a(href: "/groups/#{group.id}") do
+                      div(style: "flex: 1; min-width: 0; font-weight: 550;") { group.label }
+                      span(class: "type-label") { members_label(group) }
+                    end
+                  end
+                end
+              end
+            end
+          end
+          new_group_dialog
+        end
+      end
+
+      private
+
+      #: (Store::Group group) -> String
+      def members_label(group)
+        count = group.members.length
+        count == 1 ? "1 member" : "#{count} members"
+      end
+
+      #: () -> void
+      def new_group_dialog
+        dialog(id: "new-group", popover: "auto") do
+          header { "New group" }
+          form(id: "new-group-form", action: "/groups", method: "post") do
+            label(class: "field") do
+              plain "Name"
+              input(type: "text", name: "name", autofocus: true)
+            end
+          end
+          footer do
+            button(type: "button", popovertarget: "new-group",
+                   popovertargetaction: "hide") { "Cancel" }
+            button(type: "submit", form: "new-group-form",
+                   data: {variant: "primary"}) { "Create" }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
new file mode 100644
index 0000000..9263137
--- /dev/null
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -0,0 +1,87 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/layout"
+
+module ProTacts
+  module Admin
+    # GET /groups/:id — a group's card. Groups are records, not labels
+    # (docs/DESIGN.md, "Relationships are navigable"), so this is a
+    # contact's card in every way that carries over: what the group
+    # lends on the same type-and-value grid, and an attribute with no
+    # data gets no row. Its members are tags that open their records,
+    # the other half of the tags on a member's own card.
+    class GroupsShow < Phlex::HTML
+      # @rbs @group: Store::Group
+      # @rbs @reading: Contact
+      # @rbs @members: Array[Contact]
+
+      #: (group: Store::Group, members: Array[Contact]) -> void
+      def initialize(group:, members:)
+        @group = group
+        @reading = group.reading
+        @members = members
+      end
+
+      def view_template
+        render Layout.new(title: @group.label) do
+          div(class: "record") do
+            div(class: "record-nav") do
+              a(href: "/groups", class: "type-label") { "‹ groups" }
+              a(href: "/groups/#{@group.id}/edit", class: "btn", data_size: "sm") { "edit" }
+            end
+            div(class: "card") do
+              div(class: "card-body") do
+                div(class: "detail-header") do
+                  h1(class: "type-h2", style: "margin: 0;") { @group.label }
+                end
+                # ContactsShow#has_data?'s reason: an empty grid would
+                # still take card-body's gap.
+                dl(class: "detail-grid") { rows } if has_data?
+              end
+            end
+          end
+        end
+      end
+
+      private
+
+      #: () -> bool
+      def has_data?
+        @members.any? || @reading.addresses.any? || @reading.notes.any?
+      end
+
+      # Members first, the order a contact's card puts its groups in:
+      # the relationship frames the rows under it.
+      #: () -> void
+      def rows
+        members_row if @members.any?
+        @reading.addresses.each do |address|
+          row(address.type || "address") do
+            Format.address_lines(address).each { |line| div { line } }
+          end
+        end
+        @reading.notes.each do |note|
+          row("notes") { div(style: "white-space: pre-wrap;") { note.value } }
+        end
+      end
+
+      #: () -> void
+      def members_row
+        row("members") do
+          div(class: "tag-set") do
+            @members.each do |member|
+              a(href: "/contacts/#{member.id}", class: "tag") { member.name || member.id }
+            end
+          end
+        end
+      end
+
+      #: (String type) { () -> void } -> void
+      def row(type, &)
+        dt(class: "type-label") { type }
+        dd(class: "type-body-sm", &)
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index a1e3eea..20d4c89 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -108,10 +108,13 @@ module ProTacts
             # Device setup belongs here because the dashboard is a
             # search over contacts and /setup is not a contact — a
             # header link would put it beside the search on every
-            # screen, at the weight the app's own name has.
+            # screen, at the weight the app's own name has. The groups
+            # list is here for the same weight: search finds a group by
+            # name, and this is where one is created.
             footer(class: "admin-footer") do
               span(class: "type-label") { ProTacts.config.version || "dev server" }
               span(class: "type-label") { "+debug" } if ProTacts.config.debug?
+              a(href: "/groups", class: "type-label") { "groups" }
               a(href: "/setup", class: "type-label") { "device setup" }
             end
             # A server-rendered toast, Gloss's [role=status] contract:
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index bfcf897..97349e8 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -87,6 +87,36 @@ module ProTacts
     # @rbs skip
     GroupEdit = Data.define(:group_id, :position, :line)
 
+    # 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
+    # label is the SQL one #group_label computes, so a tag and a
+    # heading can never disagree about what to call a nameless group.
+    # @rbs skip
+    Group = Data.define(:id, :name, :label, :lines, :members)
+
+    # Reopened rather than defined in the block above, for the reason
+    # CardDiff is.
+    class Group
+      # The group's lines read through the one model that knows how to
+      # read an address and a note — a Contact over a card made of
+      # those lines and nothing else — for the screens that render and
+      # edit them. It composes nothing and is never stored or served.
+      #: () -> Contact
+      def reading
+        Contact.new(id:, stored: VCard.new(lines.map { "#{it}\r\n" }.join), birthday: nil, inherited: [])
+      end
+
+      # The group's etag, for the editor's snapshot guard: a hash of
+      # everything the edit screen shows and the save writes, so a
+      # group edited since the page loaded refuses the stale save
+      # rather than reverting what changed (Web#apply_edit's rule).
+      # Nothing serves it, so nothing has to agree with it but the form.
+      #: () -> String
+      def version
+        Digest::SHA256.hexdigest([name, lines, members].to_json)
+      end
+    end
+
     # SQLite has no ON UPDATE, so the column default stamps a row on
     # insert and this stamps it again on the way past. Same expression as
     # the migration's, deliberately: the database keeps the clock, so two
@@ -131,6 +161,10 @@ module ProTacts
     # group cannot hold").
     SHAREABLE_NAMES = %w[ADR NOTE].freeze #: Array[String]
 
+    # A groups row's own columns, qualified for #group_label's reason:
+    # the label is written against the table by name.
+    GROUP_COLUMNS = [Sequel[:groups][:id], Sequel[:groups][:name]].freeze #: Array[untyped]
+
     # 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
@@ -273,22 +307,24 @@ module ProTacts
       nil
     end
 
-    # The groups a contact belongs to, by the label each is shown
-    # under, ordered by group id — a rename must not move a tag, the
+    # The groups a contact belongs to, whole — a tag names one and
+    # opens it — ordered by group id: a rename must not move a tag, the
     # same reason #inherited_rows orders by it. Membership is the whole
     # answer here, so a group that lends nothing appears too, where the
     # inherited reads have nothing of it to carry. Off the contact
     # 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) -> Array[String]
+    #: (String id) -> Array[Group]
     def groups_of(id)
-      group_members
-        .join(:groups, id: Sequel[:group_members][:group_id])
-        .where(card_id: id)
-        .order(Sequel[:groups][:id])
-        .select(group_label.as(:label))
-        .map { it.fetch(:label).to_s }
+      load_groups(
+        groups
+          .join(:group_members, group_id: :id)
+          .where(card_id: id)
+          .order(Sequel[:groups][:id])
+          .select(*GROUP_COLUMNS, group_label.as(:label))
+          .all,
+      )
     end
 
     # The id of the card whose UID property holds this value, if one
@@ -484,6 +520,83 @@ module ProTacts
       raise "no free group id in #{GROUP_ID_ATTEMPTS} draws: #{groups.count} groups already"
     end
 
+    # Every group, ordered by id — the order #groups_of and the
+    # composition both use, so a rename never moves one.
+    #: () -> Array[Group]
+    def all_groups
+      load_groups(groups.select(*GROUP_COLUMNS, group_label.as(:label)).order(:id).all)
+    end
+
+    # One group, or nil for an id nobody has: the admin's 404 path,
+    # #contact's own shape.
+    #: (String id) -> Group?
+    def group(id)
+      load_groups([groups.select(*GROUP_COLUMNS, group_label.as(:label)).where(id:).sole]).fetch(0)
+    rescue Sequel::NoMatchingRow
+      nil
+    end
+
+    # A group's name, or none — NULL being the one spelling of
+    # nameless (db/migrations/005_group_identity.rb), so a blank is
+    # stored as that rather than refused by the schema. A name is on no
+    # card, so no member's bytes move and nothing is logged.
+    #: (String id, name: String?) -> void
+    def rename_group(id, name:)
+      groups.where(id:).update(name: name.to_s.strip.empty? ? nil : name)
+    end
+
+    # Replaces what a group lends, wholesale, at positions from zero.
+    # Every member serves the new lines from here on, so every member
+    # whose served card moved is logged (#fan_out). A line outside what
+    # a group may hold is refused by the CHECK, which is a caller's bug
+    # — the editor only builds ADR and NOTE lines — and raises.
+    #: (String id, Array[String] lines) -> void
+    def set_group_lines(id, lines)
+      @database.transaction do
+        fan_out(member_ids([id])) do
+          group_properties.where(group_id: id).delete
+          lines.each.with_index do |line, position|
+            group_properties.insert(group_id: id, position:, line:)
+          end
+        end
+      end
+    end
+
+    # The editor's whole save, as one transaction: a failure part-way
+    # leaves the group as it was rather than half renamed. Leavers go
+    # first and joiners last, so no card is logged for lines it was
+    # about to stop or had yet to start serving.
+    #: (String id, name: String?, lines: Array[String], members: Array[String]) -> void
+    def edit_group(id, name:, lines:, members:)
+      @database.transaction do
+        current = member_ids([id])
+        (current - members).each { remove_member(id, it) }
+        rename_group(id, name:)
+        set_group_lines(id, lines)
+        (members - current).each { add_member(id, it) }
+      end
+    end
+
+    # A card joins a group, and starts serving what the group lends.
+    # Joining twice is joining once.
+    #: (String group_id, String card_id) -> void
+    def add_member(group_id, card_id)
+      @database.transaction do
+        fan_out([card_id]) { group_members.insert_conflict.insert(group_id:, card_id:) }
+      end
+    end
+
+    # A card leaves a group, and stops serving what the group lends —
+    # the one lever the model has for "everyone but this member"
+    # (docs/plans/2026-08-24-vcard-storage-and-groups.md, "Edits
+    # propagate to the group").
+    #: (String group_id, String card_id) -> void
+    def remove_member(group_id, card_id)
+      @database.transaction do
+        fan_out([card_id]) { group_members.where(group_id:, card_id:).delete }
+      end
+    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.
@@ -597,6 +710,10 @@ module ProTacts
     # writing, whose own entry already carries this composition
     # (docs/plans/2026-09-09-group-edits-propagate.md, "The fan-out").
     #
+    # Card ids rather than groups, because the cards a write can move
+    # are not always members yet: a card joining a group is read before
+    # the row that makes it one exists.
+    #
     # Inside whatever transaction the caller has open: Sequel joins one
     # rather than nesting, which is what lets this run from #put.
     #: (Array[String] cards, ?except: String?) { () -> void } -> void
@@ -628,6 +745,27 @@ module ProTacts
       end
     end
 
+    # Groups rows read whole: the lines and members of every group in
+    # `rows`, two reads however many groups there are.
+    #: (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 }
+      rows.map { |row|
+        id = row.fetch(:id).to_s
+        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: members.fetch(id, []).map { it.fetch(:card_id).to_s },
+        )
+      }
+    end
+
     # The cards belonging to any of these groups, each once — the
     # members a write to those groups can move.
     #: (Array[String] group_ids) -> Array[String]
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 2adc8ea..525bca3 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -14,6 +14,9 @@ require "pro_tacts/admin/contacts_edit"
 require "pro_tacts/admin/contacts_index"
 require "pro_tacts/admin/contacts_show"
 require "pro_tacts/admin/device_setup"
+require "pro_tacts/admin/groups_edit"
+require "pro_tacts/admin/groups_index"
+require "pro_tacts/admin/groups_show"
 require "pro_tacts/debug_logger"
 require "pro_tacts/contact"
 require "pro_tacts/profile"
@@ -190,6 +193,47 @@ module ProTacts
         end
       end
 
+      # The group screens, the contacts' own shape: the collection's
+      # GET and create, then a record's GET, its editor, and the POST
+      # that applies it. What the writes rest on is
+      # docs/plans/2026-09-09-group-edits-propagate.md. A create lands
+      # on the editor, a new group being nothing until something is
+      # added to it.
+      r.on "groups" do
+        r.is do
+          r.get do
+            response["Content-Type"] = "text/html; charset=utf-8"
+            Admin::GroupsIndex.call(groups: store.all_groups)
+          end
+
+          r.post do
+            name = r.params["name"].to_s.strip
+            id = store.create_group(name: name.empty? ? nil : name)
+            r.redirect "/groups/#{id}/edit", 303
+          end
+        end
+
+        r.on String do |id|
+          r.get "edit" do
+            group = store.group(id)
+            group_edit_screen(group) if group
+          end
+
+          r.post do
+            apply_group_edit(r, id)
+          end
+
+          r.get do
+            group = store.group(id)
+
+            if group
+              response["Content-Type"] = "text/html; charset=utf-8"
+              Admin::GroupsShow.call(group:, members: members_of(group))
+            end
+          end
+        end
+      end
+
       # The configuration profile, served so the device reading this can
       # provision itself: until this route existed the only renderer was
       # the Rakefile, so putting the account on a phone meant running
@@ -586,6 +630,7 @@ module ProTacts
         recent: store.contacts_by_recency,
         upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
         query:,
+        groups: store.all_groups,
         notice:,
       )
     end
@@ -778,6 +823,61 @@ module ProTacts
       r.redirect "/contacts/#{id}", 303
     end
 
+    # The group editor's POST, #apply_edit's shape over a group: the
+    # snapshot guard, then the contact editor's own address splice and
+    # note replace over the group's lines read as a card (Store::Group#reading),
+    # and one store write for the lot.
+    #: (untyped r, String id) -> String?
+    def apply_group_edit(r, id)
+      group = store.group(id)
+      return if group.nil?
+
+      if r.params["version"].to_s != group.version
+        return group_edit_screen(group, notice: "This group changed since the page loaded; nothing was saved.")
+      end
+
+      reading = group.reading
+      card = reading.stored.replace("NOTE", text_lines("NOTE", r.params["note"].to_s.strip))
+      card = edited_addresses(reading, card, r.params)
+      # Only ids that name a card: a membership row is a foreign key,
+      # and a doctored id is ordinary bad input rather than a 500. A
+      # POST carrying no list keeps the membership it found, the
+      # phones' is-a-Hash posture (Admin::GroupsEdit).
+      submitted = r.params["members"]
+      members =
+        if submitted.is_a?(Array)
+          submitted.map(&:to_s) & store.contacts.map(&:id)
+        else
+          group.members
+        end #: Array[String]
+
+      store.edit_group(id, name: r.params["name"].to_s, lines: group_lines(card), members:)
+      r.redirect "/groups/#{id}", 303
+    end
+
+    # The card a group save spliced, back into the lines a group holds:
+    # each unfolded and shorn of its terminator, the unit CardDiff
+    # records a write in and group_properties stores.
+    #: (VCard card) -> Array[String]
+    def group_lines(card)
+      card.lines.filter_map {
+        line = VCard::Parser.unfold(it.verbatim).sub(/(\r\n|[\r\n])\z/, "")
+        line unless line.empty?
+      }
+    end
+
+    #: (Store::Group group, ?notice: String) -> String
+    def group_edit_screen(group, notice: nil)
+      response["Content-Type"] = "text/html; charset=utf-8"
+      Admin::GroupsEdit.call(group:, contacts: store.contacts, notice:)
+    end
+
+    # A group's members as contacts, in the listing's own order.
+    #: (Store::Group group) -> Array[Contact]
+    def members_of(group)
+      store.contacts.select { group.members.include?(it.id) }
+    end
+
     #: (Contact contact, ?notice: String) -> String
     def edit_screen(contact, notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index edacc9a..7eb6ff9 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, groups: Array[String], changes: Array[Store::Change]) -> String
+      def self.call: (contact: Contact, groups: Array[Store::Group], changes: Array[Store::Change]) -> String
     end
 
     class ContactsEdit
@@ -15,7 +15,19 @@ module ProTacts
     end
 
     class ContactsIndex
-      def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, ?notice: String?) -> String
+      def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, groups: Array[Store::Group], ?notice: String?) -> String
+    end
+
+    class GroupsIndex
+      def self.call: (groups: Array[Store::Group], ?notice: String?) -> String
+    end
+
+    class GroupsShow
+      def self.call: (group: Store::Group, members: Array[Contact]) -> String
+    end
+
+    class GroupsEdit
+      def self.call: (group: Store::Group, contacts: Array[Contact], ?notice: String?) -> String
     end
 
     class DeviceSetup
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 7c53412..2d61a9f 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -37,6 +37,16 @@ module ProTacts
       def self.new: (group_id: String, position: Integer, label: String, line: String) -> instance
     end
 
+    class Group
+      attr_reader id: String
+      attr_reader name: String?
+      attr_reader label: String
+      attr_reader lines: Array[String]
+      attr_reader members: Array[String]
+
+      def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String]) -> instance
+    end
+
     class GroupEdit
       attr_reader group_id: String
       attr_reader position: Integer
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 541316a..53c1761 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -70,9 +70,12 @@ module FixtureData
   # only evidence-shaped fixtures — a group is rows across three
   # 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.
+  # its properties and members land the way backdate's rows do, through
+  # the store's own database rather than Store#set_group_lines and
+  # #add_member. Those log a `group` entry for every member they move,
+  # and the replay's sync tokens are read off that log — seeding
+  # through them would hand the recorded session a history it never
+  # saw.
   MEMBERS = %w[household-george household-mary household-alicia].freeze #: Array[String]
 
   # The seed's birthday shapes, read off the card ids rather than
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 45baab0..13e92cc 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -50,7 +50,8 @@ class AdminContactsPagesTest < Minitest::Test
   # 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.
+  # (test/fixture_data.rb), so the change log holds only what the test
+  # itself wrote.
   HOUSEHOLD = [
     "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
     "NOTE:Gate code 1854.",
@@ -333,16 +334,18 @@ class AdminContactsPagesTest < Minitest::Test
 
   # The card says what the contact belongs to, as tags: the group
   # lending it two rows and the group lending it nothing both appear,
-  # because the row is membership rather than what came of it.
+  # because the row is membership rather than what came of it. Every
+  # tag opens the group it names (docs/DESIGN.md, "Relationships are
+  # navigable").
   def test_show_tags_the_groups_a_contact_belongs_to
     with_contacts({"ada" => ADA}) do |store|
       named = add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
       nameless = add_group(store, members: ["ada"], lines: [])
 
       get "/contacts/ada"
-      tags = last_response.body.scan(%r{<span class="tag">([^<]*)</span>}).flatten
+      tags = last_response.body.scan(%r{<a href="/groups/([^"]*)" class="tag">([^<]*)</a>})
 
-      assert_equal({named => "Booles", nameless => nameless}.sort.map { it.last }, tags)
+      assert_equal({named => "Booles", nameless => nameless}.sort, tags)
       # The grid leads with what the contact belongs to, so the values
       # under it are read already knowing whose they might be.
       assert_equal "groups", last_response.body[%r{<dt class="type-label">([^<]*)<}, 1]
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
new file mode 100644
index 0000000..6dae39b
--- /dev/null
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -0,0 +1,248 @@
+require_relative "../../test_helper"
+
+require "pathname"
+require "rack/test"
+require "tmpdir"
+
+require "pro_tacts/store"
+require "pro_tacts/vcard"
+require "pro_tacts/web"
+
+# The group screens, exercised the way AdminContactsPagesTest exercises
+# a contact's: real requests through the Roda app, against a throwaway
+# store.
+class AdminGroupsPagesTest < Minitest::Test
+  include Rack::Test::Methods
+
+  def app
+    ProTacts::Web
+  end
+
+  def setup
+    header "Tailscale-User-Login", "test@example.com"
+  end
+
+  def card(id, name)
+    "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:#{name}\r\nUID:#{id}\r\nEND:VCARD\r\n"
+  end
+
+  ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
+  NOTE = "NOTE:Gate code 1854." #: String
+
+  def with_contacts(names)
+    Dir.mktmpdir do |dir|
+      original = ProTacts::Web.store
+
+      ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
+        ProTacts::Web.store = store
+        names.each { |id, name| store.put(id, ProTacts::VCard.new(card(id, name))) }
+        yield store
+      ensure
+        ProTacts::Web.store = original
+      end
+    end
+  end
+
+  def household(store, members: %w[george mary])
+    id = store.create_group(name: "Booles")
+    store.set_group_lines(id, [ADDRESS, NOTE])
+    members.each { store.add_member(id, it) }
+    id
+  end
+
+  BOOLES = {"george" => "George Boole", "mary" => "Mary Boole", "ada" => "Ada Lovelace"}.freeze
+
+  ## The list
+
+  def test_the_list_names_every_group_and_its_size
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+      nameless = store.create_group
+
+      get "/groups"
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, %(<a href="/groups/#{id}">)
+      assert_includes last_response.body, "Booles"
+      assert_includes last_response.body, "2 members"
+      assert_includes last_response.body, nameless
+    end
+  end
+
+  def test_an_empty_list_says_so
+    with_contacts({}) do
+      get "/groups"
+
+      assert_includes last_response.body, "No groups yet."
+    end
+  end
+
+  def test_the_footer_leads_to_the_groups
+    with_contacts({}) do
+      get "/"
+
+      assert_includes last_response.body, %(<a href="/groups" class="type-label">groups</a>)
+    end
+  end
+
+  ## Creating
+
+  # A new group is nothing until something is added to it, so the
+  # create lands on its editor.
+  def test_a_create_lands_on_the_new_groups_editor
+    with_contacts({}) do |store|
+      post "/groups", name: "Booles"
+
+      id = store.all_groups.fetch(0).id
+      assert_equal 303, last_response.status
+      assert_equal "/groups/#{id}/edit", URI(last_response["Location"]).path
+      assert_equal "Booles", store.group(id).name
+    end
+  end
+
+  def test_a_blank_create_makes_a_nameless_group
+    with_contacts({}) do |store|
+      post "/groups", name: "  "
+
+      assert_nil store.all_groups.fetch(0).name
+    end
+  end
+
+  ## The card
+
+  def test_the_card_shows_what_the_group_lends_and_to_whom
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+
+      get "/groups/#{id}"
+
+      body = last_response.body
+      assert_equal 200, last_response.status
+      assert_includes body, "7 Calculus Close"
+      assert_includes body, "Gate code 1854."
+      assert_includes body, %(<a href="/contacts/george" class="tag">George Boole</a>)
+      assert_includes body, %(<a href="/contacts/mary" class="tag">Mary Boole</a>)
+      refute_includes body, "Ada Lovelace"
+    end
+  end
+
+  def test_an_unknown_group_is_a_404
+    with_contacts({}) do
+      get "/groups/zzzz"
+      assert_equal 404, last_response.status
+
+      get "/groups/zzzz/edit"
+      assert_equal 404, last_response.status
+    end
+  end
+
+  ## The editor
+
+  def test_the_editor_checks_the_members_and_offers_everyone_else
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+
+      get "/groups/#{id}/edit"
+
+      body = last_response.body
+      assert_includes body, %(<input type="hidden" name="version" value="#{store.group(id).version}">)
+      assert_includes body, %(<input type="checkbox" name="members[]" value="george" checked>)
+      assert_includes body, %(<input type="checkbox" name="members[]" value="ada" form="group-form">)
+      assert_includes body, %(name="address[)
+    end
+  end
+
+  # The whole save: the name, a line, and the membership in one POST,
+  # and every card whose served bytes moved told so in the change log.
+  def test_a_save_writes_the_name_the_lines_and_the_members
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+      group = store.group(id)
+
+      post "/groups/#{id}", version: group.version, name: "The Booles", note: "Gate code 1912.",
+                            members: ["", "george", "ada"]
+
+      assert_equal 303, last_response.status
+      saved = store.group(id)
+      assert_equal "The Booles", saved.name
+      assert_equal [ADDRESS, "NOTE:Gate code 1912."], saved.lines
+      assert_equal %w[ada george], saved.members
+      assert_includes store.contact("ada").vcard.to_s, ADDRESS
+      refute_includes store.contact("mary").vcard.to_s, ADDRESS
+      assert_equal "group", store.changes_of("mary").first.action
+    end
+  end
+
+  def test_a_save_edits_and_adds_an_address
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+      group = store.group(id)
+      digest = group.reading.addresses.fetch(0).line.digest
+
+      post "/groups/#{id}", version: group.version, note: "Gate code 1854.", members: ["", "george", "mary"],
+                            address: {digest => {street: "8 Calculus Close", locality: "London",
+                                                 region: "England", postal_code: "NW1 1AB",
+                                                 country: "United Kingdom"}},
+                            new_address: {"0" => {street: "1 Long Road"}}
+
+      assert_equal [ADDRESS.sub("7 Calculus", "8 Calculus"), NOTE, "ADR:;;1 Long Road;;;;"],
+                   store.group(id).lines
+    end
+  end
+
+  # The snapshot guard, the contact editor's: a group changed since the
+  # page loaded refuses the stale save rather than reverting the change.
+  def test_a_stale_save_is_refused_and_changes_nothing
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+      stale = store.group(id).version
+      store.rename_group(id, name: "The Booles")
+
+      post "/groups/#{id}", version: stale, name: "Booles", members: [""]
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, "This group changed since the page loaded; nothing was saved."
+      assert_equal "The Booles", store.group(id).name
+      assert_equal %w[george mary], store.group(id).members
+    end
+  end
+
+  # No list at all is a POST that says nothing about membership; a
+  # doctored id names no card and is dropped rather than a 500.
+  def test_membership_moves_only_for_a_list_of_real_cards
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+
+      post "/groups/#{id}", version: store.group(id).version, note: "Gate code 1854."
+      assert_equal %w[george mary], store.group(id).members
+
+      post "/groups/#{id}", version: store.group(id).version, note: "Gate code 1854.",
+                            members: ["", "george", "nobody"]
+      assert_equal 303, last_response.status
+      assert_equal %w[george], store.group(id).members
+    end
+  end
+
+  ## Search
+
+  def test_search_finds_a_group_by_its_name
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+
+      get "/", q: "boole"
+
+      assert_includes last_response.body, %(<a href="/groups/#{id}">)
+    end
+  end
+
+  def test_search_finds_a_contact_by_its_groups_name
+    with_contacts({"george" => "George", "ada" => "Ada Lovelace"}) do |store|
+      household(store, members: ["george"])
+
+      get "/", q: "boole"
+
+      assert_includes last_response.body, %(<a href="/contacts/george">)
+      refute_includes last_response.body, %(<a href="/contacts/ada">)
+    end
+  end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 580c4e3..adb106e 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -996,9 +996,9 @@ class StoreTest < Minitest::Test
   HOUSEHOLD_NOTE = "NOTE:Gate code 1854." #: String
 
   # 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.
+  # id; its properties and its members land the way the fixture
+  # seeder's do, straight through the store's own database, so the
+  # change log holds only what the test itself wrote.
   # Hands back the id, which is the only way a caller learns it.
   def add_group(store, members:, lines:, name: nil)
     db = database(store)
@@ -1161,7 +1161,7 @@ class StoreTest < Minitest::Test
       add_group(store, name: "Neighbours", members: [], lines: [HOUSEHOLD_NOTE])
 
       expected = {named => "Booles", nameless => nameless}.sort.map { it.last }
-      assert_equal expected, store.groups_of("aiden")
+      assert_equal expected, store.groups_of("aiden").map(&:label)
       assert_empty store.groups_of("znorth")
     end
   end
@@ -1241,6 +1241,89 @@ class StoreTest < Minitest::Test
     end
   end
 
+  ## Authoring a group
+
+  def test_a_group_reads_whole
+    with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+      id = add_group(store, name: "Household", members: %w[znorth aiden], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
+
+      group = store.group(id)
+      assert_equal "Household", group.label
+      assert_equal [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE], group.lines
+      assert_equal %w[aiden znorth], group.members
+      assert_nil store.group("zzzz")
+    end
+  end
+
+  def test_every_group_is_listed_by_id_under_its_label
+    with_store do |store|
+      named = store.create_group(name: "Household")
+      nameless = store.create_group
+
+      assert_equal({named => "Household", nameless => nameless}.sort.to_h,
+                   store.all_groups.to_h { [it.id, it.label] })
+    end
+  end
+
+  # What a group lends is what every member serves, so setting it logs
+  # a `group` entry on each (docs/plans/2026-09-09-group-edits-propagate.md,
+  # "The fan-out").
+  def test_setting_a_groups_lines_reaches_every_member
+    with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+      id = add_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
+
+      store.set_group_lines(id, [EDITED_ADDRESS, HOUSEHOLD_NOTE])
+
+      %w[aiden znorth].each do |member|
+        assert_includes store.contact(member).vcard.to_s, EDITED_ADDRESS
+        change = store.changes_of(member).first
+        assert_equal "group", change.action
+        assert_equal store.contact(member).etag, change.etag
+        assert_equal [EDITED_ADDRESS, HOUSEHOLD_NOTE], change.diff.added
+      end
+    end
+  end
+
+  def test_a_joining_member_serves_the_group_and_is_logged
+    with_store({"aiden" => AIDEN}) do |store|
+      id = add_group(store, members: [], lines: [HOUSEHOLD_ADDRESS])
+
+      store.add_member(id, "aiden")
+      store.add_member(id, "aiden")
+
+      assert_includes store.contact("aiden").vcard.to_s, HOUSEHOLD_ADDRESS
+      assert_equal %w[group put], store.changes_of("aiden").map(&:action)
+      assert_equal [HOUSEHOLD_ADDRESS], store.changes_of("aiden").first.diff.added
+    end
+  end
+
+  def test_a_leaving_member_stops_serving_the_group_and_is_logged
+    with_store({"aiden" => AIDEN}) do |store|
+      id = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+      store.remove_member(id, "aiden")
+
+      assert_equal AIDEN, store.contact("aiden").vcard.to_s
+      assert_equal [HOUSEHOLD_ADDRESS], store.changes_of("aiden").first.diff.removed
+    end
+  end
+
+  # Nothing a client downloads moved, so nothing is logged: a group
+  # that lends nothing composes nothing into a new member, and a name
+  # is on no card.
+  def test_writes_that_move_no_served_card_log_nothing
+    with_store({"aiden" => AIDEN}) do |store|
+      id = add_group(store, members: [], lines: [])
+
+      store.add_member(id, "aiden")
+      store.rename_group(id, name: "Household")
+      store.rename_group(id, name: " ")
+
+      assert_equal ["put"], store.changes_of("aiden").map(&:action)
+      assert_equal id, store.group(id).label
+    end
+  end
+
   ## Subtracting what a group lends
 
   # The member's own address, deliberately unlike the group's so a
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index b77b66b..f1e5aee 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -759,10 +759,10 @@ class WebTest < Minitest::Test
     end
   end
 
-  # Puts a card in a group of its own that lends it one line. A group's
-  # properties and members have no write path yet — authoring is the
-  # admin UI's task — so they land through the store's own database,
-  # the way the fixture seeder's do (test/fixture_data.rb).
+  # Puts a card in a group of its own that lends it one line, through
+  # the store's own database the way the fixture seeder's do
+  # (test/fixture_data.rb), so the change log holds only what the test
+  # itself wrote.
   def lend(store, id, line)
     database = store.instance_variable_get(:@database)
     group = store.create_group(name: "Booles")