groups: keep a change log
The cards' log is the sync backbone — its sequence is the ctag and
the sync token's state — so group entries live in their own table
rather than bumping the ctag on a rename that moves no card's
bytes. docs/plans/2026-09-23-group-change-log.md holds the design.

Assisted-by: GLM-5.3 via pi
Task: rvv (Keep a change log for groups)
change lwopmrlswqrpsmowyklmpqxszkxtlumv
commit 8c21c24eac3b439c497f9e6bf528b58a4aa2053a
author Alpha Chen <alpha@kejadlen.dev>
date
parent xwwzpyos
diff --git a/AGENTS.md b/AGENTS.md
index 02cf823..805a29a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -76,7 +76,8 @@ lib/pro_tacts/
 │                   # contacts the book has, and writing them
 ├── admin/          # The Phlex views those screens render, plus
 │                   # card_form.rb, which reads a form back into a card
-├── store.rb        # Sequel over SQLite: cards, change log, derived index
+├── store.rb        # Sequel over SQLite: cards, the change log, the
+│                   # group change log, and the derived index
 ├── contact.rb      # the one model of a contact: id, vCard, etag,
 │                   # and the structured accessors over the card
 ├── birthday.rb     # A partial date, the one fact no card can hold
@@ -206,19 +207,24 @@ not a fixture; edit a `.vcf` to change what the replay serves.
   configuration as it loads, since the exchange log is given its path at
   class-definition time. Set it afterwards and the failed exchanges land
   in `log/` instead of a tmpdir.
-- Only five things in the database cannot be rebuilt: the cards, the
-  change log, the birthdays, the groups, and the books — a partial
-  date has no vCard 3.0 spelling, so the birthday lives beside its
-  card rather than in it (docs/plans/2026-08-31-partial-birthdays.md);
+- Only six things in the database cannot be rebuilt: the cards, the
+  change log, the birthdays, the groups, the books, and the group
+  change log — a partial date has no vCard 3.0 spelling, so the
+  birthday lives beside its card rather than in it
+  (docs/plans/2026-08-31-partial-birthdays.md);
   a group's membership and the lines it lends are facts no stored card
-  carries (docs/plans/2026-08-24-vcard-storage-and-groups.md); and
+  carries (docs/plans/2026-08-24-vcard-storage-and-groups.md);
   only the `books` row says whose book a name is for
-  (docs/plans/2026-09-19-books-in-the-dump.md). Everything else
+  (docs/plans/2026-09-19-books-in-the-dump.md); and the group change
+  log's detail is a fact about a write, gone the moment it lands
+  (docs/plans/2026-09-23-group-change-log.md). Everything else
   is a projection of the cards that `rake index:rebuild` will make
   again, so no repair is ever needed for it. A write that touches a
   card must land its change-log entry in the same transaction,
   because a client's sync token silently skips whatever the log
-  missed.
+  missed — and a write that touches a group must land its
+  group-change-log entry there too, because a history with a hole in
+  it is silently wrong the same way.
 - A read whose filter is meant to identify one row uses `sole`, not
   `first` — `first` answers with one of several rather than saying the
   filter was too loose. `sole` raises either way it is wrong:
diff --git a/db/migrations/011_group_change_log.rb b/db/migrations/011_group_change_log.rb
new file mode 100644
index 0000000..d5ff6c0
--- /dev/null
+++ b/db/migrations/011_group_change_log.rb
@@ -0,0 +1,36 @@
+# A change log for groups, the cards' own (001) beside it: one entry
+# per store primitive that moves a group, its own sequence and
+# nothing synced from it — the cards' sequence is the ctag and the
+# sync token's state, and a group write changes no card's bytes that
+# the fan-out has not already logged (docs/plans/2026-09-23-group-
+# change-log.md, "Why its own table").
+#
+# `detail` is a JSON object shaped by its action — the name a create
+# or rename left, the moved lines a `lines` write carried in
+# CardDiff's spelling, the card a join or leave moved, and a delete's
+# tombstone: the whole group, which the entry is thereafter the only
+# record of. One value rather than a table of facts, CardDiff's own
+# reason: displayed beside its entry, never queried.
+#
+# `group_id` is an id and not a foreign key, the card log's own
+# reason: a tombstone has to outlive the group it is about.
+# AUTOINCREMENT for that reason too: the sequence is the log's order,
+# and a number reused after a delete would misorder the history.
+#
+# `text: true` on the string columns because these are STRICT tables,
+# which refuse Sequel's plain String. A local rather than a constant,
+# for the reason 001 records.
+now = "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
+
+Sequel.migration do
+  change do
+    create_table(:group_changes, strict: true) do
+      primary_key :sequence, type: Integer
+      String :group_id, text: true, null: false
+      String :action, text: true, null: false
+      String :detail, text: true, null: false
+      String :created_at, text: true, null: false, default: Sequel.lit(now)
+      constraint(:action_is_known, action: %w[create rename lines join leave delete])
+    end
+  end
+end
diff --git a/docs/plans/2026-09-23-group-change-log.md b/docs/plans/2026-09-23-group-change-log.md
new file mode 100644
index 0000000..bfecbef
--- /dev/null
+++ b/docs/plans/2026-09-23-group-change-log.md
@@ -0,0 +1,97 @@
+# A change log for groups
+
+2026-09-23. Groups get the history contacts have had since the change
+log grew its diff: a `group_changes` table, one entry per write that
+moved a group, rendered on the group's card the way the contact's
+page renders its own.
+
+## Why its own table
+
+The cards' log (`changes`) is load-bearing for sync: its sequence is
+the ctag and the number a sync token carries, and a sync-collection
+report answers its entries as cards (RFC 6578 section 3). A group
+entry in it would move the ctag on writes that change no card's bytes
+— a rename moves nothing a client downloads — and an entry no card
+backs would reach the report with nothing to answer it as. So a
+second table, its own sequence, nothing synced from it.
+
+What a group write does to cards is already logged where it belongs:
+the fan-out writes a `group` entry for every member whose served
+bytes moved, and for every card a `sync:` membership moved between
+books. The new log is the other side of the same events — what
+happened to the group itself — and the two record different facts
+about one write, the way the etag and the diff in one card entry do.
+
+## One entry per primitive, and only where something moved
+
+Every group write path runs through six store primitives —
+`create_group`, `rename_group`, `set_group_lines`, `add_member`,
+`remove_member`, `delete_group` — including the composites
+(`edit_group`, `edit_members`, `regroup`, `name_book`) and the
+import's writes. Logging at the primitives logs everything once and
+needs no composite-detail shape: an editor save that renames, moves a
+line, and swaps membership reads in the history as exactly those
+moves, in the order the transaction made them.
+
+An entry is written only where the stored state moved. A rename to
+the same name, lines rewritten identical, joining twice, leaving a
+group never joined: no entry. This is deliberately the opposite of
+the cards' log, which moves on a rewrite that stores identical bytes
+— that bargain exists so a sync token never misses a change, and
+nothing syncs from this log. Here a no-op entry would be a request
+log, not a history. Both directions of the invariant hold: nothing
+logged that didn't happen, nothing that happened left unlogged —
+including a pure reorder of a group's lines, which the multiset diff
+reports as an empty diff but which is a real entry, because the
+lending order moved every member's composed card.
+
+The actions are `create`, `rename`, `lines`, `join`, `leave`,
+`delete` — the CHECK constraint's own list, as `Store::Action` is the
+cards' four.
+
+## What an entry carries
+
+No etag: a group serves nothing, so there is no download for an etag
+to describe (`Group#version` is a form guard, derived from what the
+editor shows). What an entry carries instead is a `detail`, a JSON
+object shaped by its action:
+
+- `create`: the name it was created under, null for nameless.
+- `rename`: `was` and `name`, either null — null being the one
+  spelling of nameless (db/migrations/005_group_identity.rb).
+- `lines`: the moved lines in the CardDiff spelling, `added` and
+  `removed` — the same multiset difference, over the logical lines a
+  group already stores its lines as (`CardDiff.between_lines`).
+- `join`/`leave`: the card that moved.
+- `delete`: a tombstone carrying the whole group — its name, its
+  lines, its members — the card tombstone's own bargain: the entry is
+  the only record left of what was here, the rows being gone.
+
+The detail is action-shaped rather than a fixed column set because
+the facts differ per action the way they do not for cards, where one
+diff shape covers every write. It is stored as one JSON value for
+CardDiff's own reason: displayed beside its entry, never queried.
+
+`group_id` is an id and not a foreign key, the card log's reason: a
+tombstone has to outlive the group it is about.
+
+## The page
+
+The group's card gets the contact page's third card, in its own
+shape: a collapsed `details` under the members list — the history is
+read after the thing that has one, and the members are part of what
+the group is. The action sits in the type column; the moment and the
+detail in the value column, the contact page's grid. A `lines` entry
+renders the contact page's removed-then-added diff rows, which is
+why the elide machinery moved from `ContactsShow` to `Format`: two
+views now render diff lines, and a group can lend a NOTE as long as
+any.
+
+A join or a leave names the card that moved, linked to its page and
+carrying its name, the members list's own bargain — the log is for a
+person, and a person reads names. A card since deleted renders its
+id plain, the tombstone's text being all that's left.
+
+The log is left out of the dump with the cards' own
+(docs/plans/2026-09-12-database-dump.md): what moved between
+snapshots is the dump repository's own history.
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 8e8808f..9463312 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -17,13 +17,6 @@ module ProTacts
     # keeps its mono type label for now. Revisit once Gloss or this
     # project settles on how icons ship.
     class ContactsShow < Phlex::HTML
-      # A value — a property's or a parameter's — at or under this many
-      # octets renders whole. Anything longer is a wall of folded or
-      # unbroken octets (an inline PHOTO payload, a memoji's binary
-      # plist parameter, a novel-length NOTE) and renders as its count.
-      ELIDE_ABOVE = 120 #: Integer
-      private_constant :ELIDE_ABOVE
-
       # The groups come from the store beside the contact rather than
       # off it: a contact's card carries what its groups lend it, never
       # which groups those are (Store#groups_of). Required, and not
@@ -145,69 +138,17 @@ module ProTacts
           # Named rather than `it`: Phlex yields the component to an
           # element's block, so an inner `it` is this view, not the
           # line.
-          diff.removed.each { |line| div(class: "diff-removed") { "-#{elide_text(line)}" } }
-          diff.added.each { |line| div(class: "diff-added") { "+#{elide_text(line)}" } }
+          diff.removed.each { |line| div(class: "diff-removed") { "-#{Format.elide_text(line)}" } }
+          diff.added.each { |line| div(class: "diff-added") { "+#{Format.elide_text(line)}" } }
         end
       end
 
-      # A diff's line elided the way the raw card's are. It arrives as
-      # text rather than as a card's line, so it is read back into one
-      # to reach #elide's rebuild — worth the parse: the first entry of
-      # a card with a picture is the whole card, and a base64 payload
-      # is exactly the wall ELIDE_ABOVE exists for. A line the parser
-      # cannot read still elides, by the byte surgery #elide falls back
-      # to; a line the terminator was stripped from keeps none, so the
-      # rebuilt one sheds the CRLF #elide gives it.
-      #: (String text) -> String
-      def elide_text(text)
-        line = VCard::Parser.lines(text).first
-        line ? elide(line).chomp : text
-      end
-
       # The stored card for display: byte for byte, except a value long
       # enough to be a wall. The count says exactly what is there; what
       # it stands in for is not readable text.
       #: () -> String
       def raw_card
-        @contact.vcard.lines.map { elide(it) }.join
-      end
-
-      # One line of the display card: verbatim when nothing in it runs
-      # past ELIDE_ABOVE; rebuilt from its parse when something does,
-      # each long value standing in as its count. The rebuild is the
-      # parser's spelling rather than the bytes' — an elided line is a
-      # rendering — and the line keeps its own terminator so the
-      # card's line-break convention survives.
-      #: (VCard::Parser::Line line) -> String
-      def elide(line)
-        property = line.property
-        return elide_unparsed(line.verbatim) if property.nil?
-
-        long = property.parameters.any? { |_, value| value.bytesize > ELIDE_ABOVE } ||
-          property.value.bytesize > ELIDE_ABOVE
-        return line.verbatim unless long
-
-        header = property.group ? "#{property.group}.#{property.name}" : property.name
-        params = property.parameters.map { |name, value| "#{name}=#{count_octets(value)}" }
-        terminator = line.verbatim[/\r?\n\z/] || "\r\n"
-        "#{[header, *params].join(';')}:#{count_octets(property.value)}#{terminator}"
-      end
-
-      #: (String value) -> String
-      def count_octets(value)
-        value.bytesize > ELIDE_ABOVE ? "[#{value.bytesize} octets elided]" : value
-      end
-
-      # A line that would not parse has no structure to rebuild from,
-      # so its value is elided by surgery on the bytes: everything up
-      # to the first colon stands, and the rest is the count.
-      #: (String verbatim) -> String
-      def elide_unparsed(verbatim)
-        prefix, value = verbatim.split(":", 2)
-        return verbatim if value.nil?
-
-        terminator = value.slice!(/\r?\n\z/) || ""
-        "#{prefix}:#{count_octets(value)}#{terminator}"
+        @contact.vcard.lines.map { Format.elide_line(it) }.join
       end
     end
   end
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index b216b33..e13ed5f 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -1,6 +1,7 @@
 require "time"
 
 require "pro_tacts/birthday"
+require "pro_tacts/vcard/parser"
 
 module ProTacts
   module Admin
@@ -89,6 +90,65 @@ module ProTacts
         types.empty? ? kind : types.join(", ")
       end
 
+      # A value — a property's or a parameter's — at or under this many
+      # octets renders whole. Anything longer is a wall of folded or
+      # unbroken octets (an inline PHOTO payload, a memoji's binary
+      # plist parameter, a novel-length NOTE) and renders as its count.
+      # The count says exactly what is there; what it stands in for is
+      # not readable text.
+      ELIDE_ABOVE = 120 #: Integer
+
+      # A diff's line or a card's, elided: text in, for the lines a
+      # change log holds, a parsed line in for the ones a card does
+      # (#elide_line). Two views render one of these — a contact's raw
+      # card and a group's lent lines — which is why the machinery
+      # lives here rather than in either view.
+      #: (String text) -> String
+      def self.elide_text(text)
+        line = VCard::Parser.lines(text).first
+        line ? elide_line(line).chomp : text
+      end
+
+      # One line of the display card: verbatim when nothing in it runs
+      # past ELIDE_ABOVE; rebuilt from its parse when something does,
+      # each long value standing in as its count. The rebuild is the
+      # parser's spelling rather than the bytes' — an elided line is a
+      # rendering — and the line keeps its own terminator so the
+      # card's line-break convention survives.
+      #: (VCard::Parser::Line line) -> String
+      def self.elide_line(line)
+        property = line.property
+        return elide_unparsed(line.verbatim) if property.nil?
+
+        long = property.parameters.any? { |_, value| value.bytesize > ELIDE_ABOVE } ||
+          property.value.bytesize > ELIDE_ABOVE
+        return line.verbatim unless long
+
+        header = property.group ? "#{property.group}.#{property.name}" : property.name
+        params = property.parameters.map { |name, value| "#{name}=#{count_octets(value)}" }
+        terminator = line.verbatim[/\r?\n\z/] || "\r\n"
+        "#{[header, *params].join(';')}:#{count_octets(property.value)}#{terminator}"
+      end
+
+      # A line that would not parse has no structure to rebuild from,
+      # so its value is elided by surgery on the bytes: everything up
+      # to the first colon stands, and the rest is the count.
+      #: (String verbatim) -> String
+      def self.elide_unparsed(verbatim)
+        prefix, value = verbatim.split(":", 2)
+        return verbatim if value.nil?
+
+        terminator = value.slice!(/\r?\n\z/) || ""
+        "#{prefix}:#{count_octets(value)}#{terminator}"
+      end
+      private_class_method :elide_unparsed
+
+      #: (String value) -> String
+      def self.count_octets(value)
+        value.bytesize > ELIDE_ABOVE ? "[#{value.bytesize} octets elided]" : value
+      end
+      private_class_method :count_octets
+
       # 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_show.rb b/lib/pro_tacts/admin/groups_show.rb
index afa68bd..f893a24 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -15,17 +15,26 @@ module ProTacts
     # type-and-value grid, and an attribute with no data gets no row.
     # The members are what a group is, so they are a list rather than a
     # row: /contacts' rows, narrowed to this group and in its order
-    # (Format.sort_key).
+    # (Format.sort_key). Under them the group's history, the change
+    # log the contact page renders in this page's own shape. The whole
+    # book comes in beside the members (Store#contacts) because that
+    # log names the cards a join or a leave moved, and a name is what
+    # a person reads; the members list is the narrowing of it.
     class GroupsShow < Phlex::HTML
       # @rbs @group: Store::Group
       # @rbs @reading: Contact
+      # @rbs @contacts: Array[Contact]
       # @rbs @members: Array[Contact]
+      # @rbs @changes: Array[Store::GroupChange]
 
-      #: (group: Store::Group, members: Array[Contact]) -> void
-      def initialize(group:, members:)
+      #: (group: Store::Group, contacts: Array[Contact], changes: Array[Store::GroupChange]) -> void
+      def initialize(group:, contacts:, changes:)
         @group = group
+        @contacts = contacts
+        @changes = changes
         @reading = group.reading
-        @members = members.sort_by { [Format.sort_key(it), it.id] }
+        @members = contacts.select { group.members.include?(it.id) }
+          .sort_by { [Format.sort_key(it), it.id] }
       end
 
       def view_template
@@ -47,6 +56,7 @@ module ProTacts
             end
           end
           members_list
+          change_log
         end
       end
 
@@ -102,6 +112,89 @@ module ProTacts
         dt(class: "type-label") { type }
         dd(class: "type-body-sm", &)
       end
+
+      # What this group has done, under the members it holds: the
+      # group's own log (Store#group_changes_of) in the contact page's
+      # change-log shape — collapsed, because the record is about the
+      # group and a history is about the writes.
+      #: () -> void
+      def change_log
+        div(class: "card") do
+          div(class: "card-body") do
+            details do
+              summary(class: "type-label") { "change log" }
+              dl(class: "detail-grid change-log") { change_rows }
+            end
+          end
+        end
+      end
+
+      # One row per entry, on the record's own grid — the contact
+      # page's layout, its action in the type column and its moment
+      # and detail in the value column. No etag beside the moment: a
+      # group serves nothing, so no download exists for an etag to
+      # describe. A `delete` entry has no arm — it would mean a page
+      # rendered for a group that is gone, which is a 404 before any
+      # of this runs.
+      #: () -> void
+      def change_rows
+        @changes.each do |change|
+          dt(class: "type-label") { change.action }
+          dd(class: "type-body-sm") do
+            div { Format.stamp(change.created_at) }
+            case change.action
+            when Store::GroupAction::CREATE then div { change.detail.fetch("name") || @group.id }
+            when Store::GroupAction::RENAME then div { rename_detail(change.detail) }
+            when Store::GroupAction::LINES then diff_lines(change.detail)
+            when Store::GroupAction::JOIN, Store::GroupAction::LEAVE then member_link(change.detail)
+            else raise "no arm for #{change.action}"
+            end
+          end
+        end
+      end
+
+      # A rename's `was → name`, a null spelled as the group's id —
+      # the label rule (Store#group_label), said of the names a group
+      # no longer or not yet carries. The arrow, because the pair is
+      # one fact about one moment and not two rows of it.
+      #: (Hash[String, untyped] detail) -> String
+      def rename_detail(detail)
+        "#{detail.fetch("was") || @group.id} → #{detail.fetch("name") || @group.id}"
+      end
+
+      # A `lines` entry's moved lines, the contact page's own diff
+      # rows: removed then added, the sign and not the color saying
+      # which, each line elided the way a card's are. An empty diff is
+      # a pure reorder — a real entry whose lines all stayed — so it
+      # renders nothing rather than an empty block.
+      #: (Hash[String, untyped] detail) -> void
+      def diff_lines(detail)
+        added = detail.fetch("added") #: Array[String]
+        removed = detail.fetch("removed") #: Array[String]
+        return if added.empty? && removed.empty?
+
+        div(class: "diff type-mono") do
+          removed.each { |line| div(class: "diff-removed") { "-#{Format.elide_text(line)}" } }
+          added.each { |line| div(class: "diff-added") { "+#{Format.elide_text(line)}" } }
+        end
+      end
+
+      # The card a join or a leave moved, linked to its page and
+      # carrying its name — the members list's own bargain, the log
+      # being for a person and a person reading names. A card since
+      # deleted renders its id plain, the tombstone's text being all
+      # that's left; a card that never was one is dropped by the save
+      # (Web#apply_group_edit), so it cannot reach the log.
+      #: (Hash[String, untyped] detail) -> void
+      def member_link(detail)
+        id = detail.fetch("card").to_s
+        contact = @contacts.find { it.id == id }
+        if contact
+          a(href: "/contacts/#{id}") { Format.name_label(contact) }
+        else
+          div { id }
+        end
+      end
     end
   end
 end
diff --git a/lib/pro_tacts/card_diff.rb b/lib/pro_tacts/card_diff.rb
index e4580b6..9928c00 100644
--- a/lib/pro_tacts/card_diff.rb
+++ b/lib/pro_tacts/card_diff.rb
@@ -43,6 +43,17 @@ module ProTacts
       new(added: without(new_lines, old_lines), removed: without(old_lines, new_lines))
     end
 
+    # The same write over an array of logical lines rather than a
+    # card — a group's own (Store#set_group_lines), which are stored
+    # as the unit this diffs, with no terminator to shed and no fold
+    # to unfold. The cards' path is #between's; this one exists so
+    # the group log's detail is the same multiset difference by the
+    # same code rather than a second spelling of it.
+    #: (Array[String] before, Array[String] after) -> CardDiff
+    def self.between_lines(before, after)
+      new(added: without(after, before), removed: without(before, after))
+    end
+
     # The stored spelling: a JSON object, so a line goes in and comes
     # back byte for byte whatever it holds. A diff is displayed beside
     # the entry it belongs to and never queried, so the column is one
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index aa194d2..c160b59 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -1,4 +1,5 @@
 require "date"
+require "json"
 require "pathname"
 require "sequel"
 require "sentry-ruby"
@@ -17,14 +18,16 @@ module ProTacts
   # The transactional store: one SQLite database holding each contact's
   # card exactly as it was submitted.
   #
-  # Five kinds of state live here and they are not equally precious.
-  # Only the cards, the change log, the birthdays, the groups, and
-  # the books cannot be rebuilt, and each is argued where it was
-  # decided: the first two in docs/plans/2026-08-25-sqlite-schema.md,
+  # Six kinds of state live here and they are not equally precious.
+  # Only the cards, the change log, the birthdays, the groups, the
+  # books, and the group change log cannot be rebuilt, and each is
+  # argued where it was decided: the first two in
+  # docs/plans/2026-08-25-sqlite-schema.md,
   # "What the tables are for", the birthdays in
   # docs/plans/2026-08-31-partial-birthdays.md, the groups in
   # docs/plans/2026-08-24-vcard-storage-and-groups.md, the books in
-  # docs/plans/2026-09-19-books-in-the-dump.md. Everything else
+  # docs/plans/2026-09-19-books-in-the-dump.md, the group change log
+  # in docs/plans/2026-09-23-group-change-log.md. Everything else
   # is an index derived from the cards, and #rebuild_index will make
   # it again from nothing.
   #
@@ -57,6 +60,27 @@ module ProTacts
       GROUP = "group" #: String
     end
 
+    # One entry in the group change log: what happened to a group and
+    # at which moment, with a detail shaped by the action — the name a
+    # create or rename left, the lines a `lines` write moved in
+    # CardDiff's spelling, the card a join or leave moved, and a
+    # delete's tombstone carrying the whole group. The signature is in
+    # sig/pro_tacts/store.rbs with Store's own.
+    # @rbs skip
+    GroupChange = Data.define(:sequence, :group_id, :action, :detail, :created_at)
+
+    # What an entry says happened to a group. The six the schema
+    # admits and no seventh — the CHECK constraint's own list
+    # (db/migrations/011_group_change_log.rb), Action's own rule.
+    module GroupAction
+      CREATE = "create" #: String
+      RENAME = "rename" #: String
+      LINES = "lines" #: String
+      JOIN = "join" #: String
+      LEAVE = "leave" #: String
+      DELETE = "delete" #: String
+    end
+
     # A contact paired with when its card last changed. Contact itself
     # carries no timestamp — it is derived from the card alone, see its
     # own comment — so a surface that sorts or displays recency (the
@@ -643,6 +667,8 @@ module ProTacts
     # the author's label and optional; a group without one is displayed
     # by its id (see #inherited_rows), and the empty string is refused
     # by the schema rather than kept as a second spelling of nameless.
+    # Logged in the group's own log as every group write is, the
+    # create's detail carrying the name it was created under.
     #
     # The id is minted here rather than taken from the caller, for the
     # shape db/migrations/005_group_identity.rb pins. The insert is
@@ -655,7 +681,10 @@ module ProTacts
       GROUP_ID_ATTEMPTS.times do
         id = ChangeId.mint(4)
         begin
-          groups.insert(id:, name:)
+          @database.transaction do
+            groups.insert(id:, name:)
+            record_group_change(id, action: GroupAction::CREATE, detail: {"name" => name})
+          end
           return id
         rescue Sequel::UniqueConstraintViolation
           # The id collided, or the name did
@@ -710,6 +739,10 @@ module ProTacts
     # card, so no member's bytes move, but a `sync:` name is what puts a
     # card in a book: a rename into, out of, or between them moves every
     # member between books and logs each one (#fan_out's `moved`).
+    # Logged in the group's own log only where the name moved, the log
+    # recording what happened rather than that a save was asked
+    # (docs/plans/2026-09-23-group-change-log.md, "One entry per
+    # primitive").
     #: (String id, name: String?) -> void
     def rename_group(id, name:)
       name = nil if name.to_s.strip.empty?
@@ -721,23 +754,40 @@ module ProTacts
         else
           groups.where(id:).update(name:)
         end
+        record_group_change(id, action: GroupAction::RENAME, detail: {"was" => was, "name" => name}) if was != name
       end
     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.
+    # whose served card moved is logged (#fan_out), and the group's own
+    # log carries the lines the write moved in CardDiff's spelling. A
+    # pure reorder is an entry with an empty diff — the multiset
+    # difference cannot see it, but the lending order moved every
+    # member's composed card, so it is a real write. A rewrite that
+    # stores the same lines in the same order writes no entry, this
+    # log's own bargain against the cards' (the plan's "One entry per
+    # primitive"). 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
+        was = group_properties.where(group_id: id).order(:position).select_map(:line)
+        diff = CardDiff.between_lines(was, lines)
         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
+        if was != lines
+          record_group_change(
+            id,
+            action: GroupAction::LINES,
+            detail: {"added" => diff.added, "removed" => diff.removed},
+          )
+        end
       end
     end
 
@@ -769,15 +819,18 @@ module ProTacts
     end
 
     # A card joins a group, and starts serving what the group lends.
-    # Joining twice is joining once. Joining a `sync:` group puts the
-    # card in a book, which is logged whether or not its bytes moved.
+    # Joining twice is joining once, and only the join that moved
+    # membership is logged in the group's own log. Joining a `sync:`
+    # group puts the card in a book, which the cards' own log carries
+    # whether or not its bytes moved.
     #: (String group_id, String card_id) -> void
     def add_member(group_id, card_id)
       @database.transaction do
-        joining = sync_group?(group_id) && !member?(group_id, card_id)
-        fan_out([card_id], moved: joining ? [card_id] : []) {
+        joining = !member?(group_id, card_id)
+        fan_out([card_id], moved: joining && sync_group?(group_id) ? [card_id] : []) {
           group_members.insert_conflict.insert(group_id:, card_id:)
         }
+        record_group_change(group_id, action: GroupAction::JOIN, detail: {"card" => card_id}) if joining
       end
     end
 
@@ -785,14 +838,17 @@ module ProTacts
     # 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"). Leaving a `sync:` group takes the card
-    # out of a book, logged the way joining one is.
+    # out of a book, logged the way joining one is, and only the leave
+    # that moved membership reaches the group's own log — leaving a
+    # group never joined is nothing, not news.
     #: (String group_id, String card_id) -> void
     def remove_member(group_id, card_id)
       @database.transaction do
-        leaving = sync_group?(group_id) && member?(group_id, card_id)
-        fan_out([card_id], moved: leaving ? [card_id] : []) {
+        leaving = member?(group_id, card_id)
+        fan_out([card_id], moved: leaving && sync_group?(group_id) ? [card_id] : []) {
           group_members.where(group_id:, card_id:).delete
         }
+        record_group_change(group_id, action: GroupAction::LEAVE, detail: {"card" => card_id}) if leaving
       end
     end
 
@@ -801,10 +857,13 @@ module ProTacts
     # with it (db/migrations/004_groups.rb). Members are logged as
     # leavers are (#remove_member's reason) — a `sync:` group's
     # whatever their bytes did, a book's deletion taking its cards out
-    # of it. Everyone's book is refused, #name_book's reason: every
-    # card a client created joined it, so deleting it would tell every
-    # client to drop every card it ever synced. A group nobody has is
-    # the ordinary miss, #delete's shape.
+    # of it. The group's own log closes with a tombstone carrying the
+    # whole group, the card tombstone's bargain: the entry is the only
+    # record left of what was here, the rows being gone. Everyone's
+    # book is refused, #name_book's reason: every card a client created
+    # joined it, so deleting it would tell every client to drop every
+    # card it ever synced. A group nobody has is the ordinary miss,
+    # #delete's shape.
     #: (String id) -> bool
     def delete_group(id)
       # A plain `first` read, #stored_card's reason: id is the primary
@@ -822,6 +881,12 @@ module ProTacts
 
       @database.transaction do
         members = member_ids([id])
+        lines = group_properties.where(group_id: id).order(:position).select_map(:line)
+        record_group_change(
+          id,
+          action: GroupAction::DELETE,
+          detail: {"name" => row.fetch(:name), "lines" => lines, "members" => members},
+        )
         fan_out(members, moved: sync_name?(name) ? members : []) {
           groups.where(id:).delete
         }
@@ -865,6 +930,17 @@ module ProTacts
       change_log.where(card_id: id).order(Sequel.desc(:sequence)).map { change_from(it) }
     end
 
+    # One group's entries, newest first — the history the group's page
+    # renders, #changes_of's own order for its own reason: a record
+    # someone reads, where the last thing that happened is what they
+    # came for. A group id and not a foreign key (see the migration),
+    # so an id whose group is gone still answers with the history that
+    # ends in its tombstone.
+    #: (String id) -> Array[GroupChange]
+    def group_changes_of(id)
+      group_changes.where(group_id: id).order(Sequel.desc(:sequence)).map { group_change_from(it) }
+    end
+
     # Drops the index and derives it again from the stored cards alone.
     # Always safe to run: nothing is authoritative here, so if the
     # projection ever disagrees with the cards, this is the repair. Raw
@@ -927,6 +1003,11 @@ module ProTacts
       @database[:group_properties]
     end
 
+    #: () -> Sequel::Dataset
+    def group_changes
+      @database[:group_changes]
+    end
+
     # A card written where one may already stand. The stamp is set
     # again on the way past because SQLite has no ON UPDATE and the
     # column default only fires on insert (NOW).
@@ -946,6 +1027,14 @@ module ProTacts
       change_log.insert(card_id:, action:, etag:, diff: diff.to_json)
     end
 
+    # The group log's half of #record. The detail arrives already
+    # built, action-shaped, for #record's own reason: only the writer
+    # knows what its write moved.
+    #: (String group_id, action: String, detail: Hash[String, untyped]) -> void
+    def record_group_change(group_id, action:, detail:)
+      group_changes.insert(group_id:, action:, detail: detail.to_json)
+    end
+
     # The block's group writes, bracketed by a read of every member
     # they could move: what each served before and what each serves
     # after, and a `group` entry wherever the two differ. Why an entry
@@ -1641,5 +1730,17 @@ module ProTacts
         created_at: row.fetch(:created_at).to_s,
       )
     end
+
+    #: (Hash[Symbol, untyped] row) -> GroupChange
+    def group_change_from(row)
+      detail = JSON.parse(row.fetch(:detail).to_s) #: Hash[String, untyped]
+      GroupChange.new(
+        sequence: row.fetch(:sequence).to_i,
+        group_id: row.fetch(:group_id).to_s,
+        action: row.fetch(:action).to_s,
+        detail:,
+        created_at: row.fetch(:created_at).to_s,
+      )
+    end
   end
 end
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index d261cdd..cabf95b 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -60,7 +60,11 @@ module ProTacts
 
           if group
             response["Content-Type"] = "text/html; charset=utf-8"
-            Admin::GroupsShow.call(group:, members: members_of(group))
+            Admin::GroupsShow.call(
+              group:,
+              contacts: store.contacts,
+              changes: store.group_changes_of(id),
+            )
           end
         end
       end
@@ -131,11 +135,5 @@ module ProTacts
       response["Content-Type"] = "text/html; charset=utf-8"
       Admin::GroupsMembers.call(group:, contacts: store.contacts)
     end
-
-    # A group's members as contacts; the page puts them in order.
-    #: (Store::Group group) -> Array[Contact]
-    def members_of(group)
-      store.contacts.select { group.members.include?(it.id) }
-    end
   end
 end
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 73448a9..677e2b7 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -30,7 +30,7 @@ module ProTacts
     end
 
     class GroupsShow
-      def self.call: (group: Store::Group, members: Array[Contact]) -> String
+      def self.call: (group: Store::Group, contacts: Array[Contact], changes: Array[Store::GroupChange]) -> String
     end
 
     class GroupsEdit
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 3e52e5e..a538db0 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -14,6 +14,16 @@ module ProTacts
       def self.new: (sequence: Integer, card_id: String, action: String, etag: String?, diff: CardDiff, created_at: String) -> instance
     end
 
+    class GroupChange
+      attr_reader sequence: Integer
+      attr_reader group_id: String
+      attr_reader action: String
+      attr_reader detail: Hash[String, untyped]
+      attr_reader created_at: String
+
+      def self.new: (sequence: Integer, group_id: String, action: String, detail: Hash[String, untyped], created_at: String) -> instance
+    end
+
     class RecentContact
       attr_reader contact: Contact
       attr_reader updated_at: String
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index aa9da9b..4bb38b4 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -156,8 +156,12 @@ module FixtureData
   end
 
   # A group, seeded without a change-log entry for any member it moves
-  # (see MEMBERS for why), and its id. Tests seed theirs here too, so
-  # the change log they assert on holds only what the test wrote.
+  # (see MEMBERS for why). The group's own log — which nothing replays
+  # or syncs from — keeps the create's entry, which is true: the group
+  # really was created, and the fixtures' lines and members land as
+  # though the log had not been invented yet. Tests seed theirs here
+  # too, so the cards' change log they assert on holds only what the
+  # test wrote.
   #: (ProTacts::Store store, ?name: String?, ?lines: Array[String], ?members: Array[String]) -> String
   def self.seed_group(store, name: nil, lines: [], members: [])
     group_id = store.create_group(name:)
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 3526fe9..a371a60 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -139,14 +139,16 @@ class AdminGroupsPagesTest < Minitest::Test
   end
 
   # The members list the way /contacts does (Format.sort_key), not in
-  # the store's id order. Seeded so the two disagree.
+  # the store's id order. Seeded so the two disagree. The scan takes
+  # the list's own rows — an li's link, not the change log's, which
+  # names the same cards for other reasons.
   def test_the_members_list_in_name_order
     with_contacts(BOOLES.merge("zz" => "Alice Aardvark")) do |store|
       id = household(store, members: %w[mary zz george])
 
       get "/groups/#{id}"
 
-      assert_equal %w[zz george mary], last_response.body.scan(%r{<a href="/contacts/([^"]+)">}).flatten
+      assert_equal %w[zz george mary], last_response.body.scan(%r{<li><a href="/contacts/([^"]+)">}).flatten
     end
   end
 
@@ -162,15 +164,16 @@ class AdminGroupsPagesTest < Minitest::Test
     end
   end
 
-  # A group that lends nothing renders no grid, so its name is not
-  # held above an empty one (.detail-header in admin.css).
+  # A group that lends nothing renders no record grid, so its name is
+  # not held above an empty one (.detail-header in admin.css) — the
+  # change log's own grid below does not count, being another card's.
   def test_a_card_that_lends_nothing_has_no_grid
     with_contacts(BOOLES) do |store|
       id = store.create_group(name: "Booles")
 
       get "/groups/#{id}"
 
-      refute_includes last_response.body, "detail-grid"
+      refute_includes last_response.body, %(<dl class="detail-grid">)
     end
   end
 
@@ -421,6 +424,55 @@ class AdminGroupsPagesTest < Minitest::Test
     end
   end
 
+  ## The change log
+
+  # The contact page's third card in this page's own shape: the
+  # writes that made the group, under the members it holds — the
+  # lines entry as the contact page's diff rows, the joins named and
+  # linked the way the members list names them.
+  def test_the_card_shows_the_groups_change_log
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+
+      get "/groups/#{id}"
+
+      body = last_response.body
+      assert_equal 200, last_response.status
+      assert_includes body, "change log"
+      assert_includes body, "+#{ADDRESS}"
+      assert_includes body, "+#{NOTE}"
+      assert_match %r{<a href="/contacts/mary">Mary Boole</a>}, body
+      assert_match %r{<a href="/contacts/george">George Boole</a>}, body
+    end
+  end
+
+  def test_the_change_log_marks_a_rename_and_a_leave
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+      store.rename_group(id, name: "The Booles")
+      store.remove_member(id, "mary")
+
+      get "/groups/#{id}"
+
+      body = last_response.body
+      assert_includes body, "Booles → The Booles"
+      assert_match %r{<a href="/contacts/mary">Mary Boole</a>}, body
+    end
+  end
+
+  # The tombstone's text: a join's card since deleted has nothing
+  # left to link to, so its id is the row.
+  def test_the_change_log_names_a_gone_card_by_its_id
+    with_contacts(BOOLES) do |store|
+      id = household(store, members: ["ada"])
+      store.delete("ada")
+
+      get "/groups/#{id}"
+
+      assert_includes last_response.body, "<div>ada</div>"
+    end
+  end
+
   ## Search
 
   def test_search_finds_a_group_by_its_name
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index fa1ceee..ad5913a 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1492,6 +1492,114 @@ class StoreTest < Minitest::Test
     end
   end
 
+  ## The group change log
+
+  # Every primitive, in the order a hand would write them, ending in
+  # a delete whose tombstone carries the whole group — the card
+  # tombstone's bargain, the entry being the only record left of what
+  # was here.
+  def test_every_group_write_is_logged
+    with_store({"aiden" => AIDEN}) do |store|
+      id = store.create_group(name: "Booles")
+      store.set_group_lines(id, [HOUSEHOLD_ADDRESS])
+      store.add_member(id, "aiden")
+      store.rename_group(id, name: "The Booles")
+      store.remove_member(id, "aiden")
+      store.delete_group(id)
+
+      entries = store.group_changes_of(id)
+      assert_equal %w[delete leave rename join lines create], entries.map(&:action)
+      assert_equal({"name" => "Booles"}, entries.fetch(5).detail)
+      assert_equal [HOUSEHOLD_ADDRESS], entries.fetch(4).detail.fetch("added")
+      assert_equal [], entries.fetch(4).detail.fetch("removed")
+      assert_equal "aiden", entries.fetch(3).detail.fetch("card")
+      assert_equal({"was" => "Booles", "name" => "The Booles"}, entries.fetch(2).detail)
+      assert_equal "aiden", entries.fetch(1).detail.fetch("card")
+      assert_equal(
+        {"name" => "The Booles", "lines" => [HOUSEHOLD_ADDRESS], "members" => []},
+        entries.fetch(0).detail,
+      )
+    end
+  end
+
+  def test_a_group_change_is_stamped_when_it_is_logged
+    with_store do |store|
+      store.create_group
+      assert_match TIMESTAMP, store.group_changes_of(store.all_groups.fetch(0).id).fetch(0).created_at
+    end
+  end
+
+  # The log records what happened, not that a save was asked
+  # (docs/plans/2026-09-23-group-change-log.md, "One entry per
+  # primitive") — the cards' log's own opposite bargain, nothing
+  # syncing from this one to miss a change.
+  def test_writes_that_move_nothing_of_the_group_log_nothing
+    with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+      id = store.create_group(name: "Booles")
+      store.set_group_lines(id, [HOUSEHOLD_ADDRESS])
+      store.add_member(id, "aiden")
+      logged = store.group_changes_of(id).length
+
+      store.rename_group(id, name: "Booles")
+      store.set_group_lines(id, [HOUSEHOLD_ADDRESS])
+      store.add_member(id, "aiden")
+      store.remove_member(id, "znorth")
+      store.edit_group(id, name: "Booles", lines: [HOUSEHOLD_ADDRESS], members: ["aiden"])
+
+      assert_equal logged, store.group_changes_of(id).length
+    end
+  end
+
+  # A pure reorder moved every member's composed card — the fan-out
+  # logs them — so it is a real entry, though the multiset difference
+  # sees no line move at all.
+  def test_a_reorder_of_a_groups_lines_is_an_entry_with_an_empty_diff
+    with_store({"aiden" => AIDEN}) do |store|
+      id = FixtureData.seed_group(store, lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
+
+      store.set_group_lines(id, [HOUSEHOLD_NOTE, HOUSEHOLD_ADDRESS])
+
+      entry = store.group_changes_of(id).first
+      assert_equal "lines", entry.action
+      assert_equal [], entry.detail.fetch("added")
+      assert_equal [], entry.detail.fetch("removed")
+    end
+  end
+
+  # The composites log as the primitives they run, in the order the
+  # transaction made them — an editor save read in the history as
+  # exactly the moves it made.
+  def test_an_edit_groups_save_logs_each_move_in_the_order_it_made_them
+    with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+      id = FixtureData.seed_group(store, name: "Booles", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+      store.edit_group(id, name: "The Booles", lines: [HOUSEHOLD_NOTE], members: ["znorth"])
+
+      assert_equal %w[join lines rename leave create], store.group_changes_of(id).map(&:action)
+    end
+  end
+
+  def test_a_rename_to_nameless_is_logged_with_a_null_name
+    with_store do |store|
+      id = store.create_group(name: "Booles")
+
+      store.rename_group(id, name: " ")
+
+      assert_equal({"was" => "Booles", "name" => nil}, store.group_changes_of(id).first.detail)
+    end
+  end
+
+  # A group id and not a foreign key, the card log's reason: the
+  # history outlives the group and ends in its tombstone.
+  def test_a_deleted_groups_changes_still_read
+    with_store do |store|
+      id = store.create_group(name: "Booles")
+      store.delete_group(id)
+
+      assert_equal %w[delete create], store.group_changes_of(id).map(&:action)
+    end
+  end
+
   ## Books
 
   YUKI = AIDEN.sub("FN:Aiden", "FN:Yuki").sub("UID:aiden", "UID:yuki") #: String