groups: compose shared attributes into members' cards
The read half of shared group attributes: migration 004 adds the
groups tables and widens the change log to a fourth action for the
fan-out entries the write half will record, and Contact composes
stored + inherited + birthday at every read. Nothing writes the group
tables yet, so FixtureData seeds a household in Ruby, its members
chosen outside the recorded exchange's hrefs.

Assisted-by: GLM-5.3 via pi
change oyozqrqumvxnotvpzxyvzkpomutvkmlk
commit 75a9b01b6c79e850f57071e5781f5570b87499c4
author Alpha Chen <alpha@kejadlen.dev>
date
parent uqrotzxw
diff --git a/db/migrations/004_groups.rb b/db/migrations/004_groups.rb
new file mode 100644
index 0000000..c4946da
--- /dev/null
+++ b/db/migrations/004_groups.rb
@@ -0,0 +1,73 @@
+# Groups: shared attributes composed into their members' cards at read
+# (docs/plans/2026-08-24-vcard-storage-and-groups.md). A group is
+# server-side state — membership is never exposed to the client — and
+# the properties it holds are verbatim content lines rather than parsed
+# structure, for the same reason the cards are stored as bytes: what
+# the group's author wrote is what a member's card carries, and a
+# property this server does not model rides through composition
+# untouched (RFC 6352 section 6.3.2.2).
+#
+# No timestamps on these tables, the birthdays' own reason: the rows
+# are current state, replaced wholesale by whatever authoring last
+# wrote them, and the change log — whose fan-out entries carry the
+# 'group' action below — is the history of a group's edits.
+#
+# `text: true` on every string column 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
+    # A group's own row: an id and the name the admin surfaces show.
+    # The name is for the author; a member's card never carries it.
+    create_table(:groups, strict: true) do
+      String :id, text: true, primary_key: true, null: false
+      String :name, text: true, null: false
+    end
+
+    # What a group contributes, as the content lines to compose into a
+    # member's card. `position` is the line's place in the group, which
+    # fixes the order the composition serves them in.
+    create_table(:group_properties, strict: true) do
+      foreign_key :group_id, :groups, type: String, text: true, null: false, on_delete: :cascade
+      Integer :position, null: false
+      String :line, text: true, null: false
+      primary_key [:group_id, :position]
+    end
+
+    # Membership. Deleting a card drops the membership — the group
+    # outlives the member — and deleting a group drops everything it
+    # had, members included.
+    create_table(:group_members, strict: true) do
+      foreign_key :group_id, :groups, type: String, text: true, null: false, on_delete: :cascade
+      foreign_key :card_id, :cards, type: String, text: true, null: false, on_delete: :cascade
+      primary_key [:group_id, :card_id]
+    end
+
+    # The change log's action grows its fourth value: group, the
+    # fan-out entry a group edit writes for each member whose served
+    # card the edit moved. Nothing writes it yet — that is the write
+    # half's task — but the constraint widens here, with the tables the
+    # action will name, because a schema and the actions it admits are
+    # one decision. The rebuild is 003's own move: SQLite cannot alter
+    # a CHECK constraint, so the table is created again beside the old
+    # one, rows copied with their sequences (every client's sync token
+    # state, copied rather than regenerated so no token skips a
+    # change), old dropped, new renamed. Nothing references this table,
+    # so the drop takes nothing with it.
+    create_table(:changes_new, strict: true) do
+      primary_key :sequence, type: Integer
+      String :card_id, text: true, null: false
+      String :action, text: true, null: false
+      String :etag, text: true
+      String :created_at, text: true, null: false, default: Sequel.lit(now)
+      constraint(:action_is_known, action: %w[put delete edit group])
+    end
+
+    run "INSERT INTO changes_new (sequence, card_id, action, etag, created_at) " \
+        "SELECT sequence, card_id, action, etag, created_at FROM changes"
+    drop_table(:changes)
+    rename_table(:changes_new, :changes)
+  end
+end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index c4de3f1..4a9415d 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -25,7 +25,10 @@ module ProTacts
   # card, never both, so the two facts are independent and #vcard
   # composes the served card from them. An unmodeled BDAY spelling is
   # the card's own — #properties shows it, and #birthday reads nil over
-  # it.
+  # it. The lines a contact inherits from its groups sit beside the
+  # birthday the same way: no stored card carries them, so they are
+  # store facts composed in at read
+  # (docs/plans/2026-08-24-vcard-storage-and-groups.md).
   #
   # The etag hashes the card that goes out, so it changes exactly when
   # what the client downloads changes. It is derived here and stored
@@ -49,6 +52,7 @@ module ProTacts
     # @rbs @id: String
     # @rbs @stored: VCard
     # @rbs @birthday: Birthday?
+    # @rbs @inherited: Array[String]
     # @rbs @vcard: VCard
     # @rbs @etag: String
 
@@ -87,20 +91,22 @@ module ProTacts
       :line,
     )
 
-    # A contact from its id, its stored card, and its birthday. The
-    # only way to make one: an etag that came from anywhere but the
-    # card in hand is an etag that can be wrong, and a birthday from
+    # A contact from its id, its stored card, its birthday, and the
+    # content lines it inherits from its groups. The only way to make
+    # one: an etag that came from anywhere but the card in hand is an
+    # etag that can be wrong, and a birthday or an inheritance from
     # anywhere but the store is wrong the same way — which is why
-    # `birthday:` is required and carries no default, since an
-    # optional nil would let a caller quietly get no birthday off a
-    # card that has one. Named `stored:` rather than `vcard:` because
-    # what #vcard returns is composed, and an argument that is not
-    # what the reader hands back is a trap.
-    #: (id: String, stored: VCard, birthday: Birthday?) -> Contact
-    def self.for(id:, stored:, birthday:)
+    # `birthday:` and `inherited:` are required and carry no defaults,
+    # since an optional nil would let a caller quietly get no birthday
+    # off a card that has one, and an omitted inheritance would serve a
+    # member its group's lines no longer reach. Named `stored:` rather
+    # than `vcard:` because what #vcard returns is composed, and an
+    # argument that is not what the reader hands back is a trap.
+    #: (id: String, stored: VCard, birthday: Birthday?, inherited: Array[String]) -> Contact
+    def self.for(id:, stored:, birthday:, inherited:)
       raise ArgumentError, "invalid contact id: #{id}" unless id.match?(ID_FORMAT)
 
-      new(id, stored, birthday)
+      new(id, stored, birthday, inherited)
     end
 
     #: (VCard vcard) -> String
@@ -108,11 +114,12 @@ module ProTacts
       %("#{Digest::SHA256.hexdigest(vcard.to_s)}")
     end
 
-    #: (String id, VCard stored, Birthday? birthday) -> void
-    def initialize(id, stored, birthday)
+    #: (String id, VCard stored, Birthday? birthday, Array[String] inherited) -> void
+    def initialize(id, stored, birthday, inherited)
       @id = id
       @stored = stored
       @birthday = birthday
+      @inherited = inherited
     end
 
     attr_reader :id
@@ -130,20 +137,24 @@ module ProTacts
     # #properties' job.
     attr_reader :birthday
 
-    # The card to serve: the stored one with the birthday composed back
-    # in, immediately before END:VCARD — the same insert over the same
-    # two inputs Store composed at each read path before, so the bytes
-    # a client downloads do not move. A birthday with no wire form, or
-    # none at all, serves the stored card exactly as it is. Composed on
-    # first ask and memoized, and the etag's derivation deferred with
-    # it: a caller that reads neither — and the listing paths read
-    # neither — pays for no composition of its own.
+    # The card to serve: the stored one with the inherited lines and
+    # the birthday composed back in, in that order, each immediately
+    # before END:VCARD — stored + inherited + birthday, the card a
+    # client downloads. A birthday with no wire form, or none at all,
+    # and an empty inheritance leave their halves as they lie, and an
+    # empty inheritance is #insert's own no-op — the same card object —
+    # so a contact in no group serves exactly the bytes it did before
+    # groups existed, etag included. Composed on first ask and
+    # memoized, and the etag's derivation deferred with it: a caller
+    # that reads neither — and the listing paths read neither — pays
+    # for no composition of its own.
     #: () -> VCard
     def vcard
       return @vcard if defined?(@vcard)
 
+      card = @stored.insert(@inherited)
       line = @birthday && @birthday.to_line
-      @vcard = line ? @stored.insert([line]) : @stored
+      @vcard = line ? card.insert([line]) : card
     end
 
     # The etag over #vcard's bytes, derived here on first ask and
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 2d03fe0..0ffe85f 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -24,15 +24,18 @@ module ProTacts
   # change-log entry have to land or fail together. The birthdays are
   # the third thing that cannot be rebuilt — a partial date has no
   # vCard 3.0 spelling, so it lives beside its card rather than in it
-  # (docs/plans/2026-08-31-partial-birthdays.md). Everything else is
-  # an index derived from the cards, and #rebuild_index will make it
-  # again from nothing.
+  # (docs/plans/2026-08-31-partial-birthdays.md). The groups are the
+  # fourth — membership and the lines a group contributes to its
+  # members' served cards, facts no stored card carries and so nothing
+  # can re-derive. Everything else is an index derived from the cards,
+  # and #rebuild_index will make it again from nothing.
   #
   # Every Contact this store hands out is composed, never the stored
   # card alone: a birthday is subtracted out of a card on the way in,
-  # and Contact composes it back in on read, so the vcard and the etag
-  # a caller sees — and the etag the change log records — describe the
-  # card a client downloads, not the bytes on disk.
+  # and Contact composes it back in on read, with the lines a contact
+  # inherits from its groups composed in beside it — so the vcard and
+  # the etag a caller sees, and the etag the change log records,
+  # describe the card a client downloads, not the bytes on disk.
   #
   # Sequel's transactions join one already open rather than failing on
   # SQLite's lack of nesting, which is what lets the group fan-out this
@@ -125,7 +128,11 @@ module ProTacts
     #: () -> Array[Contact]
     def contacts
       birthdays = birthdays_by_id
-      cards.order(:id).map { contact_from(it, birthdays[it.fetch(:id).to_s]) }
+      inherited = inherited_by_id
+      cards.order(:id).map {
+        id = it.fetch(:id).to_s
+        contact_from(it, birthdays[id], inherited.fetch(id, []))
+      }
     end
 
     # Every contact paired with its card's updated_at, newest first — the
@@ -135,9 +142,11 @@ module ProTacts
     #: () -> Array[RecentContact]
     def contacts_by_recency
       birthdays = birthdays_by_id
+      inherited = inherited_by_id
       cards.order(Sequel.desc(:updated_at)).map {
+        id = it.fetch(:id).to_s
         RecentContact.new(
-          contact: contact_from(it, birthdays[it.fetch(:id).to_s]),
+          contact: contact_from(it, birthdays[id], inherited.fetch(id, [])),
           updated_at: it.fetch(:updated_at).to_s,
         )
       }
@@ -178,9 +187,10 @@ module ProTacts
     # this reads the table rather than scanning cards for BDAY.
     #: (Integer limit, ?today: Date) -> Array[UpcomingBirthday]
     def upcoming_birthdays(limit, today: Date.today)
+      inherited = inherited_by_id
       birthdays
         .join(:cards, id: :card_id)
-        .map { upcoming_from(it, today) }
+        .map { upcoming_from(it, today, inherited.fetch(it.fetch(:card_id).to_s, [])) }
         .compact
         .sort_by { [it.occurs_on, it.contact.id] }
         .first(limit)
@@ -193,7 +203,7 @@ module ProTacts
     # nil.
     #: (String id) -> Contact?
     def contact(id)
-      contact_from(cards.where(id:).sole, birthday_of(id))
+      contact_from(cards.where(id:).sole, birthday_of(id), inherited_of(id))
     rescue Sequel::NoMatchingRow
       nil
     end
@@ -272,10 +282,15 @@ module ProTacts
           [nil, card]
         end
 
-      # The Contact this returns is the composed one, and the logged
-      # etag is its hash, so a client's token describes the card it
-      # downloads.
-      contact = Contact.for(id:, stored:, birthday:)
+      # The Contact this returns is the composed one — what the contact
+      # inherits included, read off membership as it stands at this
+      # write — and the logged etag is its hash, so a client's token
+      # describes the card it downloads. The submitted card is stored
+      # as it arrived: subtracting a group's lines back out of a
+      # member's submission is the write half's task
+      # (docs/plans/2026-08-24-vcard-storage-and-groups.md), so until it
+      # lands a member's rewrite carries the inherited lines within it.
+      contact = Contact.for(id:, stored:, birthday:, inherited: inherited_of(id))
       @database.transaction do
         cards
           .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
@@ -303,7 +318,7 @@ module ProTacts
     #: (String id, String vcard, birthday: Birthday?) -> Contact
     def rewrite(id, vcard, birthday:)
       card = VCard.new(vcard)
-      contact = Contact.for(id:, stored: card, birthday:)
+      contact = Contact.for(id:, stored: card, birthday:, inherited: inherited_of(id))
       @database.transaction do
         cards
           .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
@@ -376,6 +391,11 @@ module ProTacts
       @database[:birthdays]
     end
 
+    #: () -> Sequel::Dataset
+    def group_members
+      @database[:group_members]
+    end
+
     #: (String card_id, String action, String? etag) -> void
     def record(card_id, action, etag)
       change_log.insert(card_id:, action:, etag:)
@@ -501,14 +521,15 @@ module ProTacts
 
     # The birthday travels beside the card rather than inside it, and
     # Contact composes the served one — its vcard, its etag, everything
-    # a caller reads — so both describe what a client downloads rather
-    # than the bytes on disk.
-    #: (Hash[Symbol, untyped] row, Birthday? birthday) -> Contact
-    def contact_from(row, birthday)
+    # a caller reads — with the inherited lines beside it, so all
+    # describe what a client downloads rather than the bytes on disk.
+    #: (Hash[Symbol, untyped] row, Birthday? birthday, Array[String] inherited) -> Contact
+    def contact_from(row, birthday, inherited)
       Contact.for(
         id: row.fetch(:id).to_s,
         stored: VCard.new(row.fetch(:vcard).to_s),
         birthday:,
+        inherited:,
       )
     end
 
@@ -548,6 +569,42 @@ module ProTacts
       row && birthday_from(row)
     end
 
+    # The content lines a contact inherits — every property of every
+    # group it belongs to, nothing of a group that holds nothing, and
+    # [] for a contact in no group at all. Ordered by group id and then
+    # position, so the composed card is the same bytes every read.
+    #: (String id) -> Array[String]
+    def inherited_of(id)
+      inherited_rows.where(card_id: id).map { it.fetch(:line).to_s }
+    end
+
+    # Every contact's inheritance in one pass, keyed by card, for the
+    # listing reads that compose a whole collection — the same shape as
+    # birthdays_by_id, for the same reason. Only a contact with
+    # something to inherit appears; the absent key reads as [] at the
+    # caller, which is what it means.
+    #: () -> Hash[String, Array[String]]
+    def inherited_by_id
+      inherited_rows.all
+        .group_by { it.fetch(:card_id).to_s }
+        .transform_values { |rows| rows.map { it.fetch(:line).to_s } }
+    end
+
+    # The join both inherited reads walk: a membership to the property
+    # it inherits, qualified and ordered so the composition neither
+    # depends on what SQLite feels like returning nor trips over the
+    # group_id the two tables share.
+    #: () -> Sequel::Dataset
+    def inherited_rows
+      group_members
+        .join(:group_properties, group_id: :group_id)
+        .order(
+          Sequel[:group_members][:card_id],
+          Sequel[:group_properties][:group_id],
+          Sequel[:group_properties][:position],
+        )
+    end
+
     # A birthday row read as the model. The shape was validated on the
     # way in, so a row that no longer parses is corruption to raise on
     # rather than quietly drop.
@@ -559,15 +616,15 @@ module ProTacts
     # One joined birthday-and-card row as an UpcomingBirthday, or nil
     # for a shape that lands on no calendar day (see
     # #upcoming_birthdays).
-    #: (Hash[Symbol, untyped] row, Date today) -> UpcomingBirthday?
-    def upcoming_from(row, today)
+    #: (Hash[Symbol, untyped] row, Date today, Array[String] inherited) -> UpcomingBirthday?
+    def upcoming_from(row, today, inherited)
       birthday = birthday_from(row)
       month, day = birthday.month, birthday.day
       return if month.nil? || day.nil?
 
       candidate = date_in(today.year, month, day)
       UpcomingBirthday.new(
-        contact: contact_from(row, birthday),
+        contact: contact_from(row, birthday, inherited),
         occurs_on: candidate < today ? date_in(today.year + 1, month, day) : candidate,
       )
     end
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 7d7edad..4ec5f24 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -37,6 +37,37 @@ module FixtureData
     CARDS.glob("*.vcf").sort.to_h { [it.basename(".vcf").to_s, it.read] }
   end
 
+  # What the household shares: an address and a home number, the
+  # shape the groups work tests against
+  # (docs/plans/2026-08-24-vcard-storage-and-groups.md). Deliberately
+  # not the address any seed card already carries, so a test can tell
+  # a composed line from a stored one by its bytes.
+  HOUSEHOLD = [
+    "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
+    "TEL;TYPE=home:+44 20 5555 0100",
+  ].freeze #: Array[String]
+
+  # The groups the fixture book carries: one household, two members.
+  # Seeded in Ruby rather than a fixture file because the cards are the
+  # only evidence-shaped fixtures — a group is rows across three
+  # tables with no file format of its own — and the members are chosen
+  # to sit outside the recorded macOS exchange's hrefs, so the replay
+  # keeps serving that session the bytes it saw. Authoring groups is
+  # the admin UI's task; until it arrives nothing public writes these
+  # tables, so the rows land the way backdate's do, through the
+  # store's own database.
+  #: (ProTacts::Store store) -> void
+  def self.seed_groups(store)
+    database = store.instance_variable_get(:@database)
+    database[:groups].insert(id: "household", name: "Household")
+    HOUSEHOLD.each.with_index do |line, position|
+      database[:group_properties].insert(group_id: "household", position:, line:)
+    end
+    ["bday-complete", "bday-basic-noyear"].each do |id|
+      database[:group_members].insert(group_id: "household", card_id: id)
+    end
+  end
+
   # Builds the database and returns a store still open on it, for the
   # caller to hand to the app the way config.ru does.
   def self.install(directory)
@@ -49,6 +80,7 @@ module FixtureData
       store.put(id, card)
     end.keys
     backdate(store, ids)
+    seed_groups(store)
     store
   end
 
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index 79d5eb1..5913f18 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -26,7 +26,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+      <d:getetag>"78822ddd3e94790ad8132d452c65b9a9214b695f5db650b1882081696f04b080"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -35,7 +35,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-complete.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
+      <d:getetag>"ea55ff3f6cf660799cbc5b25f86d9e219e7c5b57aecb2a86f4c0af8687805861"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
index 875ad9c..c8d226e 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -16,7 +16,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+      <d:getetag>"78822ddd3e94790ad8132d452c65b9a9214b695f5db650b1882081696f04b080"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -25,7 +25,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-complete.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
+      <d:getetag>"ea55ff3f6cf660799cbc5b25f86d9e219e7c5b57aecb2a86f4c0af8687805861"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 2d8839b..36affd2 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -51,7 +51,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+      <d:getetag>"78822ddd3e94790ad8132d452c65b9a9214b695f5db650b1882081696f04b080"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -60,7 +60,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-complete.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
+      <d:getetag>"ea55ff3f6cf660799cbc5b25f86d9e219e7c5b57aecb2a86f4c0af8687805861"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index b1ca2ca..6d74793 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -9,10 +9,11 @@ class FormatTest < Minitest::Test
     "UID:ada\r\nEND:VCARD\r\n"
 
   # The production shapes: a modeled birthday passes a BDAY-free card
-  # beside a Birthday, and a fallback case passes a card carrying an
-  # unmodeled BDAY beside nil (see Contact).
-  def contact(vcard = CARD, id: "aiden", birthday: nil)
-    ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:)
+  # beside a Birthday, a fallback case passes a card carrying an
+  # unmodeled BDAY beside nil, and inheritance defaults to none (see
+  # Contact).
+  def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [])
+    ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:, inherited:)
   end
 
   # Initials come from the structured N property (given + family),
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index b888bac..469359e 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -20,11 +20,12 @@ class ContactTest < Minitest::Test
     "NOTE:Countess\\, mathematician.\r\nUID:ada\r\nEND:VCARD\r\n"
 
   # The production shapes (see the class comment): a modeled birthday
-  # passes a BDAY-free card beside a Birthday, and a fallback case
+  # passes a BDAY-free card beside a Birthday, a fallback case
   # passes a card carrying an unmodeled BDAY beside nil — no stored
-  # card ever carries a BDAY the model recomposes.
-  def contact(vcard = CARD, id: "aiden", birthday: nil)
-    ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:)
+  # card ever carries a BDAY the model recomposes — and inheritance
+  # defaults to none, the shape of every contact in no group.
+  def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [])
+    ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:, inherited:)
   end
 
   # The decode's refusal reports (see #photo); the transport this pins
@@ -264,6 +265,51 @@ class ContactTest < Minitest::Test
     assert_nil contact(unmodeled).birthday
   end
 
+  ## Inherited lines
+
+  # The group's lines land between the stored card and the composed
+  # birthday — stored + inherited + birthday — so the card a client
+  # downloads carries everything in one fixed order.
+  def test_the_served_card_composes_inherited_lines_before_the_birthday
+    birthday = ProTacts::Birthday.new(year: 1985, month: 12, day: 10)
+    composed = STRUCTURED.sub(
+      "END:VCARD\r\n",
+      "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom\r\n" \
+      "TEL;TYPE=home:+44 20 5555 0100\r\n" \
+      "BDAY:1985-12-10\r\nEND:VCARD\r\n",
+    )
+
+    assert_equal(
+      composed,
+      contact(
+        STRUCTURED,
+        birthday:,
+        inherited: [
+          "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
+          "TEL;TYPE=home:+44 20 5555 0100",
+        ],
+      ).vcard.to_s,
+    )
+  end
+
+  # No inheritance composes the same bytes and the same etag as before
+  # groups existed: an empty inheritance is insert's own no-op, so a
+  # contact in no group serves its stored card untouched.
+  def test_a_contact_in_no_group_serves_its_stored_bytes_and_etag
+    card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+
+    assert_equal card, contact(card, inherited: []).vcard.to_s
+    assert_equal ProTacts::Contact.etag_for(ProTacts::VCard.new(card)), contact(card).etag
+  end
+
+  # Inherited lines are card contents like any other, so the accessors
+  # read them: a group's number is a phone on the member's card.
+  def test_the_accessors_read_inherited_lines
+    card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+
+    assert_equal "+44 20 5555 0100", contact(card, inherited: ["TEL;TYPE=home:+44 20 5555 0100"]).phones.first&.value
+  end
+
   def test_unescapes_notes
     assert_equal "Countess, mathematician.", contact(STRUCTURED).notes
   end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 87881db..89667fa 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -936,6 +936,127 @@ class StoreTest < Minitest::Test
     end
   end
 
+  ## Groups
+
+  HOUSEHOLD_ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
+
+  # There is no write path for the group tables yet — authoring is the
+  # admin UI's task — so the tests arrange them the way the fixture
+  # seeder does, straight through the store's own database.
+  def add_group(store, id:, members:, lines:)
+    db = database(store)
+    db[:groups].insert(id:, name: id.capitalize)
+    lines.each.with_index { |line, position| db[:group_properties].insert(group_id: id, position:, line:) }
+    members.each { |card_id| db[:group_members].insert(group_id: id, card_id:) }
+  end
+
+  # A member's served card is the stored one with the group's lines
+  # composed in, and the birthday composed after them — every read,
+  # single or listed, hands out the same composed card.
+  def test_a_member_serves_the_group_lines_composed_in
+    born = AIDEN.sub("FN:Aiden\r\n", "FN:Aiden\r\nBDAY:1985-12-10\r\n")
+    composed = AIDEN.sub(
+      "END:VCARD\r\n",
+      "#{HOUSEHOLD_ADDRESS}\r\nTEL;TYPE=home:+44 20 5555 0100\r\nBDAY:1985-12-10\r\nEND:VCARD\r\n",
+    )
+
+    with_store({"aiden" => born, "znorth" => ZED}) do |store|
+      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, "TEL;TYPE=home:+44 20 5555 0100"])
+
+      assert_equal composed, store.contact("aiden").vcard.to_s
+      assert_equal composed, store.contacts.find { it.id == "aiden" }.vcard.to_s
+      assert_equal composed, store.contacts_by_recency.find { it.contact.id == "aiden" }.contact.vcard.to_s
+    end
+  end
+
+  # A contact in no group serves the stored bytes with the etag it
+  # always had — the byte-identity the whole fixture replay stands on.
+  def test_a_contact_in_no_group_serves_unchanged_bytes_and_etag
+    with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+      before = store.contact("aiden")
+      add_group(store, id: "household", members: ["znorth"], lines: [HOUSEHOLD_ADDRESS])
+
+      after = store.contact("aiden")
+      assert_equal AIDEN, after.vcard.to_s
+      assert_equal before.etag, after.etag
+    end
+  end
+
+  # Membership is the only lever: a contact that leaves the group
+  # serves its own bytes again, at the etag it had before any of the
+  # group's lines reached it.
+  def test_leaving_a_group_restores_the_stored_bytes
+    with_store({"aiden" => AIDEN}) do |store|
+      before = store.contact("aiden").etag
+      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      refute_equal before, store.contact("aiden").etag
+
+      database(store)[:group_members].where(card_id: "aiden").delete
+      assert_equal AIDEN, store.contact("aiden").vcard.to_s
+      assert_equal before, store.contact("aiden").etag
+    end
+  end
+
+  # Two groups' lines compose in group-id order, then position — the
+  # order is a fact of the schema, not of whichever join SQLite
+  # returns first, so the composed bytes never move between reads.
+  def test_two_groups_compose_in_a_fixed_order
+    with_store({"aiden" => AIDEN}) do |store|
+      add_group(store, id: "zedly", members: ["aiden"], lines: ["TEL;TYPE=home:+44 20 5555 0100"])
+      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+      composed = AIDEN.sub(
+        "END:VCARD\r\n",
+        "#{HOUSEHOLD_ADDRESS}\r\nTEL;TYPE=home:+44 20 5555 0100\r\nEND:VCARD\r\n",
+      )
+      assert_equal composed, store.contact("aiden").vcard.to_s
+    end
+  end
+
+  # The index projects the stored cards, and no stored card carries an
+  # inherited line: the group's address is in the served card and in
+  # no index row, and rebuilding the index leaves it that way.
+  def test_the_index_holds_nothing_the_group_composes_in
+    with_store({"aiden" => AIDEN}) do |store|
+      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+      assert_includes store.contact("aiden").vcard.to_s, HOUSEHOLD_ADDRESS
+      assert_includes indexed_names(store, "aiden"), "FN"
+      refute_includes indexed_names(store, "aiden"), "ADR"
+
+      store.rebuild_index
+      refute_includes indexed_names(store, "aiden"), "ADR"
+    end
+  end
+
+  # A PUT and an editor's save both hand back the composed contact —
+  # the etag each records in the change log is the composed card's, so
+  # a client's token describes what there is to download. The first
+  # entry is with_store's own seed put, logged before the group existed.
+  def test_writes_log_the_composed_etag_for_a_member
+    with_store({"aiden" => AIDEN}) do |store|
+      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+      put = store.put("aiden", AIDEN)
+      edit = store.rewrite("aiden", AIDEN, birthday: nil)
+      logged = database(store)[:changes].where(card_id: "aiden").order(:sequence).all
+
+      assert_equal [put.etag, edit.etag], logged.last(2).map { it[:etag] }
+      assert_includes put.vcard.to_s, HOUSEHOLD_ADDRESS
+    end
+  end
+
+  # The fourth action value exists for the fan-out a group edit will
+  # write; nothing writes it yet, and this pins that the log can hold
+  # one when it does.
+  def test_the_change_log_admits_the_group_action
+    with_store({"aiden" => AIDEN}) do |store|
+      database(store)[:changes].insert(card_id: "aiden", action: "group", etag: "\"x\"")
+
+      assert_equal "group", store.changes.last.action
+    end
+  end
+
   ## The migration
 
   # A database at the old action vocabulary — put and delete only — is