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)
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