store: a member's edit to a lent line is the group's
Edit what a group lends and the group takes the line;
each card whose bytes moved is logged, so a sync token keeps time.
Delete it and it leaves the household as a whole,
and BEGIN IMMEDIATE has a second writer wait for control.

Assisted-by: Claude Opus 5 via Claude Code
change pvsztwwvuqxqlruxyzxswnzmkxwsyzux
commit b2426e9954fa88c0765a0394218b75395416ca74
author Alpha Chen <alpha@kejadlen.dev>
date
parent trvrrmxy
diff --git a/docs/plans/2026-09-09-group-edits-propagate.md b/docs/plans/2026-09-09-group-edits-propagate.md
new file mode 100644
index 0000000..d1e835d
--- /dev/null
+++ b/docs/plans/2026-09-09-group-edits-propagate.md
@@ -0,0 +1,125 @@
+# A member's edit is the group's edit
+
+2026-09-09. Task rzn, the third of the four
+docs/plans/2026-08-24-vcard-storage-and-groups.md's group model needs.
+The classification that task's second stage built starts acting: an
+edited inherited line rewrites the line its group lends, a deleted one
+takes that line away from the group, and both reach every other member
+through the change log.
+
+Until now `Store#subtract_inherited` could tell the four shapes apart
+and moved a byte for only one of them. An edit or a deletion stayed in
+the member's own card, deliberately, so that the edit would not be lost
+while the propagation was unwritten. That is what changes here.
+
+## The open question, decided
+
+The 2026-08-24 plan left one thing open: whether deleting an inherited
+property should propagate the way an edit does, or be suppressed for
+that member alone through a tombstone table.
+
+It propagates. The plan's own model has no per-member overrides —
+"a member either inherits or does not, and membership is the only
+lever" — and a suppression row is a per-member override wearing a
+different name. A negative one is still one.
+
+The blast radius that made the question worth asking is real: an edit
+fixes a typo for everyone, and a delete takes the household's address
+off three cards from a phone that offers no confirmation. What answers
+it is not a second mechanism but the admin UI (task opy) — a group is a
+record with a history, and its lines are recoverable from the change
+log's diffs, which record the lines each write moved.
+
+## What a propagated edit stores
+
+The submitted line's own bytes, not the group's header with a new value
+spliced into it.
+
+The editor's surgical save does the opposite for a phone or an email:
+it keeps `VCard.header_of` and swaps the value, because the form models
+no parameters and rebuilding the header would drop the three TYPE
+parameters macOS writes. A propagated edit cannot use that rule,
+because a relabel is one of the edits it must carry. `Store#kept_types?`
+reads `ADR;TYPE=home` coming back as `ADR;TYPE=work` as an edit on
+purpose — "a member who relabels a lent address from home to work
+edited the shared line as surely as one who changed a digit of it" —
+and re-rendering that under the group's old header would propagate the
+value and silently discard the label.
+
+So the group takes the line as the client spelled it, which is also the
+rule the group's rows already follow: what the author wrote is what a
+member's card carries, down to the spelling this server would not have
+chosen (db/migrations/004_groups.rb).
+
+## The line a group cannot hold
+
+`group_properties` admits `ADR` and `NOTE`, bare or parameterized, and
+refuses a grouped spelling — `item1.ADR:` starts with neither `ADR:`
+nor `ADR;`. That constraint now stands between a client and a 500.
+
+The case is not hypothetical, and `Store#kept_types?` already documents
+it: an address type Contacts has no field for comes back as an
+`X-ABLabel` on a property group, taking the `TYPE` parameter with it,
+on an address nobody touched. That reads as an edit, and propagating it
+would put `item1.ADR:...` into a table whose CHECK refuses it — an
+exception on an ordinary sync.
+
+A candidate line the group cannot hold is therefore not propagated. It
+stays in the member's own card, which is exactly where the old
+behaviour left every edit, and reports through Sentry the way an
+ambiguous attribution does. `Store::SHAREABLE_NAMES` is a second copy
+of the CHECK's list and says so where it is defined: the constraint
+stays the authority, and this is the pre-check that keeps a client's
+relabel from being a crash rather than a refusal.
+
+## The fan-out
+
+A group edit moves bytes on cards nobody wrote to. Each of those
+members needs a change-log entry or its client will never refetch —
+"a client's sync token silently skips whatever the log missed".
+
+The entry is the `group` action migration 004 widened the CHECK for,
+carrying the member's newly composed etag and a `CardDiff` of the lines
+the group edit moved on that member's served card. One per member whose
+composed bytes actually moved, which is narrower than one per member of
+the group: a rename moves nothing, a member joining a group that lends
+nothing gains nothing, and an entry for either would spend every
+client's next poll on a card that reads identically.
+
+The writing member gets none. Its own `put` entry already carries the
+composition, which is why `#put` applies the group's rows first and
+composes its `Contact` afterwards — the etag a client's token is handed
+has to be the hash of the card that write leaves behind, group line
+included.
+
+All of it inside the one transaction the member's own write already
+opens. Sequel joins an open transaction rather than nesting it, which
+is what lets the fan-out run from inside `#put` at all.
+
+## Two edits, one shared value
+
+`If-Match` protects the member's card and not the shared state behind
+it: Alice's conditional passes while Bob's card changes underneath him.
+Bob's etag moves and his client resyncs, which is correct. Two
+simultaneous edits to one shared address are the case that is not —
+both conditionals pass and the group takes the last writer.
+
+The transaction has to serialize for the last writer to be a writer at
+all. SQLite's default deferred transaction takes no write lock until
+its first write, so two of these that each read the group's rows and
+then write have one fail outright with `SQLITE_BUSY`: the busy handler
+declines to retry a lock upgrade, because a retry there could deadlock.
+`BEGIN IMMEDIATE` takes the lock at the start, which is what makes the
+`BUSY_TIMEOUT` this store already sets apply to it. `Store#initialize`
+sets Sequel's `transaction_mode` to `:immediate` for every transaction
+the store opens; the writes here are all short, and a reader is
+unaffected under WAL.
+
+## Where the editor stands
+
+`Admin::ContactsEdit` renders its rows off `Contact#own` and splices
+`Contact#stored`, so no save it makes can copy a group's value onto its
+member. The nickname was the one field reading the composed contact,
+and now reads `own` like the rest. Nothing depends on which properties
+a group may lend: widening `SHAREABLE_NAMES` is a schema decision, not
+a hunt through the views.
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index f2132fa..5a2125a 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -146,10 +146,14 @@ module ProTacts
                   # when it does not, and a box that started blank
                   # cannot lose anything — "removed on save" in it
                   # would be a false alarm.
-                  label(class: "field", data: {blank_removes: !!@contact.nickname}) do
+                  # Off the contact's own card like every row below, so
+                  # no row here depends on which properties a group may
+                  # lend (docs/plans/2026-09-09-group-edits-propagate.md,
+                  # "Where the editor stands").
+                  label(class: "field", data: {blank_removes: !!@own.nickname}) do
                     span { "Nickname" }
-                    input(type: "text", name: "nickname", value: @contact.nickname,
-                          placeholder: @contact.nickname ? "removed on save" : nil)
+                    input(type: "text", name: "nickname", value: @own.nickname,
+                          placeholder: @own.nickname ? "removed on save" : nil)
                   end
                   # A phone row edits its value and nothing else: the
                   # TYPE parameters ride in the line's own header,
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 87d6005..bfcf897 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -69,6 +69,24 @@ module ProTacts
     # @rbs skip
     UpcomingBirthday = Data.define(:contact, :occurs_on)
 
+    # One line a group lends one card, with the row it is lent from —
+    # the write path's reading of what #inherited_of answers as the
+    # model's. A member's edit has to reach the group_properties row
+    # the line came from, and the label a screen marks the row with
+    # cannot name it: two groups may share a name, and a nameless one
+    # is labelled by its id. The signature is in
+    # sig/pro_tacts/store.rbs with Store's own.
+    # @rbs skip
+    Lent = Data.define(:group_id, :position, :label, :line)
+
+    # What one classified line asks of the group it came from: the row
+    # to write, and the line that replaces it — or nil to remove the
+    # row, which is what a member's deletion of a shared line means
+    # (docs/plans/2026-09-09-group-edits-propagate.md, "The open
+    # question, decided").
+    # @rbs skip
+    GroupEdit = Data.define(:group_id, :position, :line)
+
     # SQLite has no ON UPDATE, so the column default stamps a row on
     # insert and this stamps it again on the way past. Same expression as
     # the migration's, deliberately: the database keeps the clock, so two
@@ -100,6 +118,19 @@ module ProTacts
     # no value types — and this is the one caller that compares values.
     STRUCTURED_VALUES = %w[ADR].freeze #: Array[String]
 
+    # The property names a group may lend, which is a second copy of
+    # the CHECK in db/migrations/004_groups.rb and says so here. The
+    # constraint stays the authority; this is the pre-check a
+    # propagated edit passes first, because the line a member's client
+    # hands back is not always one the group can hold — an address
+    # type Contacts cannot model comes back as an `item1.ADR` with its
+    # label beside it (see #kept_types?), which reads as an edit and
+    # which the CHECK refuses. Without this the refusal would be an
+    # exception on an ordinary sync
+    # (docs/plans/2026-09-09-group-edits-propagate.md, "The line a
+    # group cannot hold").
+    SHAREABLE_NAMES = %w[ADR NOTE].freeze #: Array[String]
+
     # Sequel's migrations, run on open. They ship with the code rather
     # than with a deployment, so the path is relative to this file.
     # `__dir__` is nil only for code with no file behind it, which a
@@ -136,6 +167,17 @@ module ProTacts
       # pragma: Sequel turns them on for every SQLite connection it
       # opens, which is what makes the index's cascades fire.
       @database.run("PRAGMA journal_mode = WAL")
+      # BEGIN IMMEDIATE for every transaction, so that the writes which
+      # read before they write serialize instead of one of them dying.
+      # A deferred transaction — SQLite's default — takes no write lock
+      # until its first write, and the busy handler declines to retry
+      # that upgrade because retrying could deadlock, so the second of
+      # two concurrent group edits gets SQLITE_BUSY straight back
+      # however long BUSY_TIMEOUT is. Taking the lock at BEGIN is what
+      # puts the wait back under the timeout
+      # (docs/plans/2026-09-09-group-edits-propagate.md, "Two edits,
+      # one shared value"). Readers are unaffected: WAL is on above.
+      @database.transaction_mode = :immediate
       # lib/sequel/extensions/sole.rb, for the reads that mean one row.
       @database.extension(:sole)
       migrate
@@ -344,23 +386,38 @@ module ProTacts
       # back, 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.
-      inherited = inherited_of(id)
+      lent = lent_of(id)
+      inherited = inheritance(lent)
       # The card this write replaces, composed from the parts already
       # read here rather than through #contact, which would read all
       # three again. Membership cannot move during a put, so the
       # inheritance either side of it is the one read above.
       before = own && Contact.new(id:, stored: own, birthday: existing, inherited:)
-      stored = subtract_inherited(stored, inherited, own)
+      stored, edits = subtract_inherited(stored, lent, own)
+      # Built before the transaction as well as inside it, because its
+      # constructor is where an id that could not be served is refused
+      # and a refusal from inside a transaction reaches the caller
+      # wearing Sequel's own error class. Composition is lazy, so the
+      # one that gets replaced below cost nothing to make.
       contact = Contact.new(id:, stored:, birthday:, inherited:)
       @database.transaction do
+        # The groups move first, so that everything composed below is
+        # the card this write leaves behind: the etag the change log
+        # records is what a client downloading this member now gets,
+        # group line included. The fan-out brackets the edits with a
+        # read of every other member either side of them.
+        unless edits.empty?
+          fan_out(member_ids(edits.map(&:group_id).uniq), except: id) { apply_group_edits(edits) }
+          contact = Contact.new(id:, stored:, birthday:, inherited: inheritance(lent_of(id)))
+        end
         cards
           .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
           .insert(id: contact.id, vcard: stored.to_s)
         write_birthday(contact.id, birthday)
         record(contact.id, "put", contact.etag, CardDiff.between(before&.vcard, contact.vcard))
         reindex(contact.id, stored)
+        contact
       end
-      contact
     end
 
     # The store's own editor changed a contact, against #put's "a
@@ -502,6 +559,11 @@ module ProTacts
       @database[:group_members]
     end
 
+    #: () -> Sequel::Dataset
+    def group_properties
+      @database[:group_properties]
+    end
+
     # Four hex digits spelled in REVERSE_HEX. SecureRandom rather than
     # rand: the draws have to be independent of anything a caller can
     # observe or seed, and two bytes of it is exactly the four digits
@@ -520,6 +582,73 @@ module ProTacts
       change_log.insert(card_id:, action:, etag:, diff: diff.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. A group edit
+    # moves bytes on cards nobody wrote to, and a client is told a card
+    # changed only by the log — a sync token silently skips whatever
+    # the log missed (see the class comment).
+    #
+    # An entry only where the bytes actually moved, which is narrower
+    # than one per member: a rename moves nothing and a group that
+    # lends nothing lends nothing to a new member either, and an entry
+    # for either would spend every client's next poll re-fetching a
+    # card that reads the same. `except` is the member doing the
+    # writing, whose own entry already carries this composition
+    # (docs/plans/2026-09-09-group-edits-propagate.md, "The fan-out").
+    #
+    # Inside whatever transaction the caller has open: Sequel joins one
+    # rather than nesting, which is what lets this run from #put.
+    #: (Array[String] cards, ?except: String?) { () -> void } -> void
+    def fan_out(cards, except: nil)
+      ids = cards - [except].compact
+      before = ids.to_h {
+        [it, composed(it)] #: [String, Contact]
+      }
+      yield
+      ids.each do |id|
+        was = before.fetch(id)
+        now = composed(id)
+        next if was.vcard.to_s == now.vcard.to_s
+
+        record(id, "group", now.etag, CardDiff.between(was.vcard, now.vcard))
+      end
+    end
+
+    # The group's rows as the classification asked for them: a line
+    # rewritten in place, or the row removed where a member deleted
+    # what it lent. A removal leaves a gap in the positions, which
+    # costs nothing — position orders a group's lines and is not
+    # counted.
+    #: (Array[GroupEdit] edits) -> void
+    def apply_group_edits(edits)
+      edits.each do |edit|
+        row = group_properties.where(group_id: edit.group_id, position: edit.position)
+        edit.line ? row.update(line: edit.line) : row.delete
+      end
+    end
+
+    # The cards belonging to any of these groups, each once — the
+    # members a write to those groups can move.
+    #: (Array[String] group_ids) -> Array[String]
+    def member_ids(group_ids)
+      group_members
+        .where(group_id: group_ids)
+        .distinct
+        .order(:card_id)
+        .select(:card_id)
+        .map { it.fetch(:card_id).to_s }
+    end
+
+    # A member's card as it composes right now. #contact's read without
+    # the rescue it makes: a group_members row names a card by foreign
+    # key, so no row here is the corruption `sole` exists to raise on
+    # rather than the ordinary miss an href off the wire is.
+    #: (String id) -> Contact
+    def composed(id)
+      contact_from(cards.where(id:).sole, birthday_of(id), inherited_of(id))
+    end
+
     # Replaces a card's rows in the index. Rebuilt wholesale rather than
     # diffed because it is derived data and replacing it is the cheaper
     # correct thing. Indexes the lines that read and says nothing about
@@ -634,51 +763,124 @@ module ProTacts
     # materialize the group's lines into its own card, and a later edit
     # to the group would reach nobody.
     #
-    # Each lent line is classified against the submission, and only one
-    # of the four shapes moves a byte. The line coming back saying what
-    # the group lends is the group's, untouched, and it goes. One line
-    # of that name left over is an edit to the shared line and none is
-    # a deletion of it: both stay in the card as they arrived, because
-    # propagating either is the next task's (the plan's "Edits
-    # propagate to the group"), and storing the edit is what keeps it
-    # from being lost meanwhile. More than one left over is a line this
-    # cannot attribute at all, and reports rather than guesses.
+    # Each lent line is classified against the submission, and three of
+    # the four shapes act. The line coming back saying what the group
+    # lends is the group's, untouched, and it goes. One line of that
+    # name left over is an edit to the shared value: it goes too, and
+    # the group's row takes its bytes, so the member does not end up
+    # carrying its own copy beside the one composition puts back. None
+    # left over is a deletion, and the group's row goes with it
+    # (docs/plans/2026-09-09-group-edits-propagate.md). More than one
+    # left over is a line this cannot attribute at all, and reports
+    # rather than guesses.
+    #
+    # Two passes, because one lent line must not be attributed a line
+    # another lent line would have matched exactly. A member of two
+    # groups that each lend an address, editing one of them, submits
+    # one edited address and one untouched: walked in a single pass,
+    # whichever group came first would read the other's untouched line
+    # as its own edit and the other would read a deletion. Every
+    # untouched line is struck first, and only what is left is
+    # attributed.
     #
     # #substitute rather than #extract and #insert, which would move a
     # member's own lines of the same name to the card's end: the lines
     # this leaves keep their positions, so a submission that was the
     # served card round-trips to the bytes it was composed from and the
     # PUT can answer with a strong etag (RFC 6352 section 6.3.2.3).
-    #: (VCard vcard, Array[Contact::Inherited] inherited, VCard? own) -> VCard
-    def subtract_inherited(vcard, inherited, own)
-      return vcard if inherited.empty?
+    #: (VCard vcard, Array[Lent] lent, VCard? own) -> [VCard, Array[GroupEdit]]
+    def subtract_inherited(vcard, lent, own)
+      return [vcard, []] if lent.empty?
 
       unaccounted = unaccounted_lines(vcard, own)
-      untouched = [] #: Array[VCard::Parser::Line]
-      ambiguous = 0
+      taken = [] #: Array[VCard::Parser::Line]
 
-      inherited.each do |lent|
-        lent_line = parsed_line(lent.line)
-        candidates = unaccounted.select { it.names?(property_name(lent.line)) }
+      moved = lent.reject { |row|
+        candidates = unaccounted.select { it.names?(property_name(row.line)) }
         # Blind to lines saying the same thing the way the editor's
         # digests are blind to identical bytes
         # (VCard::Parser::Line#digest): where a member's own card
         # carries what its group lends, which copy this takes is
         # undecidable and their saying the same thing makes it not
         # matter.
-        match = candidates.find { unedited?(it, lent_line) }
-        if match
-          unaccounted.delete_at(
-            unaccounted.index(match) #: Integer
-          )
-          untouched << match
-        elsif candidates.length > 1
-          ambiguous += 1
+        match = candidates.find { unedited?(it, parsed_line(row.line)) }
+        taken << strike(unaccounted, match) if match
+        match
+      }
+
+      edits = [] #: Array[GroupEdit]
+      ambiguous = 0
+      unshareable = 0
+      moved.each do |row|
+        case classify(row, unaccounted, taken)
+        in GroupEdit => edit then edits << edit
+        in :ambiguous then ambiguous += 1
+        in :unshareable then unshareable += 1
         end
       end
 
       report_ambiguous_inherited_lines(ambiguous)
-      untouched.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }
+      report_unshareable_lines(unshareable)
+      [taken.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }, edits]
+    end
+
+    # What a lent line that came back changed asks of its group: the
+    # edit the one candidate of its name is, or the deletion no
+    # candidate at all is. The two refusals ask nothing — more than one
+    # candidate cannot be attributed, and a candidate the group could
+    # not hold must not be tried (#shareable?) — and both leave the
+    # line in the member's own card, where an edit at least is not
+    # lost.
+    #
+    # An edit's line is struck from the pool and carried by the group
+    # from here on, so `taken` grows the way it does for an untouched
+    # one.
+    #: (Lent row, Array[VCard::Parser::Line] unaccounted, Array[VCard::Parser::Line] taken) -> (GroupEdit | :ambiguous | :unshareable)
+    def classify(row, unaccounted, taken)
+      candidates = unaccounted.select { it.names?(property_name(row.line)) }
+      return GroupEdit.new(group_id: row.group_id, position: row.position, line: nil) if candidates.empty?
+      return :ambiguous unless candidates.length == 1
+
+      edited = candidates.fetch(0)
+      return :unshareable unless shareable?(edited)
+
+      taken << strike(unaccounted, edited)
+      GroupEdit.new(group_id: row.group_id, position: row.position, line: content_line(edited))
+    end
+
+    # The line out of the pool, so no second lent line is attributed
+    # it. `index` rather than `delete`, which would take every copy of
+    # a line a card carries twice.
+    #: (Array[VCard::Parser::Line] unaccounted, VCard::Parser::Line line) -> VCard::Parser::Line
+    def strike(unaccounted, line)
+      unaccounted.delete_at(
+        unaccounted.index(line) #: Integer
+      )
+      line
+    end
+
+    # A line as a group holds one: unfolded and shorn of its
+    # terminator, the same unit CardDiff records a write in and the
+    # shape db/migrations/004_groups.rb's own rows are written in.
+    # The bytes are the client's, not a re-render under the group's old
+    # header — a relabel is one of the edits this carries, and
+    # rebuilding the header would propagate the value and drop the
+    # label (the plan's "What a propagated edit stores").
+    #: (VCard::Parser::Line line) -> String
+    def content_line(line)
+      VCard::Parser.unfold(line.verbatim).sub(/(\r\n|[\r\n])\z/, "")
+    end
+
+    # Whether a group could hold this line: one of the names
+    # SHAREABLE_NAMES admits, and no property group in front of it —
+    # the CHECK's two clauses, read off the parse rather than matched
+    # as bytes. A line that would not read is not one to share.
+    #: (VCard::Parser::Line line) -> bool
+    def shareable?(line)
+      property = line.property
+      return false if property.nil?
+
+      property.group.nil? && SHAREABLE_NAMES.include?(property.name.upcase)
     end
 
     # A lent line as the parser reads it: one logical line, the group
@@ -810,6 +1012,24 @@ module ProTacts
       )
     end
 
+    # The refusal report: a member edited a shared line into a shape no
+    # group may hold, so the edit stays on the member and the group
+    # keeps what it lent. The known cause is a type Contacts cannot
+    # model, which comes back as a property group with an `X-ABLabel`
+    # beside it (#kept_types?), and the news is worth having because
+    # that member now serves the line twice — its own copy and the
+    # group's — until someone reconciles them. No card content, the
+    # ambiguity report's own line.
+    #: (Integer count) -> void
+    def report_unshareable_lines(count)
+      return if count.zero?
+
+      Sentry.capture_message(
+        "a submitted card edited #{count} inherited line(s) into a shape no group may hold",
+        level: :warning,
+      )
+    end
+
     # The loss report, the rewrite's half of the arrival one: a stored
     # BDAY no client renders and no whitelist recognizes is about to be
     # dropped, and nobody would know.
@@ -880,7 +1100,15 @@ module ProTacts
     # composed card is the same bytes every read.
     #: (String id) -> Array[Contact::Inherited]
     def inherited_of(id)
-      inherited_rows.where(card_id: id).map { inherited_from(it) }
+      inheritance(lent_of(id))
+    end
+
+    # The same read as it stands for a write: the rows a member's edit
+    # has to reach back to, before #inheritance drops them to what the
+    # model wants (see Lent).
+    #: (String id) -> Array[Lent]
+    def lent_of(id)
+      inherited_rows.where(card_id: id).map { lent_from(it) }
     end
 
     # Every contact's inheritance in one pass, keyed by card, for the
@@ -892,7 +1120,15 @@ module ProTacts
     def inherited_by_id
       inherited_rows.all
         .group_by { it.fetch(:card_id).to_s }
-        .transform_values { |rows| rows.map { inherited_from(it) } }
+        .transform_values { |rows| inheritance(rows.map { lent_from(it) }) }
+    end
+
+    # What a model asks of a group's rows: the label to mark a row
+    # with and the line itself. Which group_properties row lent it is
+    # the write path's business and stops here (Contact#group_of).
+    #: (Array[Lent] lent) -> Array[Contact::Inherited]
+    def inheritance(lent)
+      lent.map { Contact::Inherited.new(group: it.label, line: it.line) }
     end
 
     # What to call a group on a screen: its name, or its id where it
@@ -924,6 +1160,8 @@ module ProTacts
         .join(:groups, id: Sequel[:group_members][:group_id])
         .select(
           Sequel[:group_members][:card_id],
+          Sequel[:group_properties][:group_id],
+          Sequel[:group_properties][:position],
           group_label.as(:group_name),
           Sequel[:group_properties][:line],
         )
@@ -934,9 +1172,14 @@ module ProTacts
         )
     end
 
-    #: (Hash[Symbol, untyped] row) -> Contact::Inherited
-    def inherited_from(row)
-      Contact::Inherited.new(group: row.fetch(:group_name).to_s, line: row.fetch(:line).to_s)
+    #: (Hash[Symbol, untyped] row) -> Lent
+    def lent_from(row)
+      Lent.new(
+        group_id: row.fetch(:group_id).to_s,
+        position: row.fetch(:position).to_i,
+        label: row.fetch(:group_name).to_s,
+        line: row.fetch(:line).to_s,
+      )
     end
 
     # A birthday row read as the model. The shape was validated on the
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index 01356c7..ef3d266 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -57,6 +57,10 @@ module Sequel
     # inside a write is still one atomic unit.
     def transaction: [T] () { () -> T } -> T
 
+    # SQLite's own: which BEGIN every transaction opens with. Nil,
+    # :deferred, :immediate, or :exclusive.
+    def transaction_mode=: (Symbol? mode) -> void
+
     def create_table: (Symbol name, **untyped options) { () [self: Schema::CreateTableGenerator] -> void } -> void
 
     def disconnect: () -> void
@@ -82,6 +86,7 @@ module Sequel
 
     def where: (*untyped conditions) -> Dataset
     def order: (*untyped columns) -> Dataset
+    def distinct: (*untyped columns) -> Dataset
     # The columns a joined dataset comes back with, named rather than
     # merged by whichever table's column won. Signed here so it reads
     # as the query builder's rather than Enumerable's own #select.
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 1eb38f8..7c53412 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -27,5 +27,22 @@ module ProTacts
 
       def self.new: (contact: Contact, occurs_on: Date) -> instance
     end
+
+    class Lent
+      attr_reader group_id: String
+      attr_reader position: Integer
+      attr_reader label: String
+      attr_reader line: String
+
+      def self.new: (group_id: String, position: Integer, label: String, line: String) -> instance
+    end
+
+    class GroupEdit
+      attr_reader group_id: String
+      attr_reader position: Integer
+      attr_reader line: String?
+
+      def self.new: (group_id: String, position: Integer, line: String?) -> instance
+    end
   end
 end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index e253148..580c4e3 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1012,6 +1012,12 @@ class StoreTest < Minitest::Test
     id
   end
 
+  # What a group lends now, in the order it lends it — the rows a
+  # propagated edit rewrites.
+  def lent_lines(store, group)
+    database(store)[:group_properties].where(group_id: group).order(:position).map { it[:line] }
+  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.
@@ -1098,11 +1104,15 @@ class StoreTest < Minitest::Test
   # 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.
+  #
+  # The PUT submits the served card, as a client does: a submission
+  # missing the lent line is that member deleting it, which is a
+  # different test's subject.
   def test_writes_log_the_composed_etag_for_a_member
     with_store({"aiden" => AIDEN}) do |store|
       add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
 
-      put = store.put("aiden", vcard(AIDEN))
+      put = store.put("aiden", vcard(store.contact("aiden").vcard.to_s))
       edit = store.rewrite("aiden", vcard(AIDEN), birthday: nil)
       logged = database(store)[:changes].where(card_id: "aiden").order(:sequence).all
 
@@ -1218,9 +1228,8 @@ class StoreTest < Minitest::Test
     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.
+  # The fourth action value is the fan-out's (Store#fan_out); this pins
+  # that the schema admits it, apart from anything the store writes.
   def test_the_change_log_admits_the_group_action
     with_store({"aiden" => AIDEN}) do |store|
       database(store)[:changes]
@@ -1303,21 +1312,22 @@ class StoreTest < Minitest::Test
     end
   end
 
-  # An edit to a shared value is detected and left where it landed:
-  # the member's card keeps it, the group keeps its own, and the
-  # member serves both until the propagation task takes the edit to
-  # the group (the plan's "Edits propagate to the group").
-  def test_an_edited_lent_line_stays_in_the_members_own_card
+  # An edit to a shared value is an edit to the group's line: the row
+  # takes the new bytes, the member stores none of them, and the served
+  # card carries one copy rather than the member's beside the group's
+  # (docs/plans/2026-09-09-group-edits-propagate.md).
+  def test_an_edited_lent_line_becomes_the_groups_line
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      group = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, EDITED_ADDRESS)))
 
-      assert_includes card_row(store, "aiden").fetch(:vcard), EDITED_ADDRESS
+      assert_equal [EDITED_ADDRESS], lent_lines(store, group)
+      assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
       composed = store.contact("aiden").vcard.to_s
       assert_includes composed, EDITED_ADDRESS
-      assert_includes composed, HOUSEHOLD_ADDRESS
+      refute_includes composed, HOUSEHOLD_ADDRESS
       assert_empty sentry_messages
     end
   end
@@ -1355,21 +1365,23 @@ class StoreTest < Minitest::Test
     end
   end
 
-  # Relabelling a shared address is an edit to the shared line, so it
-  # is left in the member's card for the propagation to take to the
-  # group rather than subtracted as the group's own (the plan's "Edits
-  # propagate to the group").
-  def test_a_relabeled_lent_line_reads_as_an_edit
+  # Relabelling a shared address is an edit to the shared line, and the
+  # label travels with the value: the group takes the line as the
+  # client spelled it, because re-rendering under the group's old
+  # header would propagate the address and drop the relabel (the plan's
+  # "What a propagated edit stores").
+  def test_a_relabeled_lent_line_relabels_the_groups_line
     relabeled = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "ADR;type=WORK;type=pref:")
 
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      group = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, relabeled)))
 
-      assert_includes card_row(store, "aiden").fetch(:vcard), relabeled
-      assert_includes store.contact("aiden").vcard.to_s, HOUSEHOLD_ADDRESS
+      assert_equal [relabeled], lent_lines(store, group)
+      assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+      assert_includes store.contact("aiden").vcard.to_s, relabeled
       assert_empty sentry_messages
     end
   end
@@ -1393,17 +1405,18 @@ class StoreTest < Minitest::Test
   # Which is what makes labelling one an edit like any other relabel:
   # the type it arrives with is the member's, nobody else having put
   # one there.
-  def test_a_label_on_an_untyped_lent_line_reads_as_an_edit
+  def test_a_label_on_an_untyped_lent_line_labels_the_groups_line
     labeled = UNTYPED_ADDRESS.sub("ADR:", "ADR;type=HOME;type=pref:")
 
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [UNTYPED_ADDRESS])
+      group = add_group(store, members: ["aiden"], lines: [UNTYPED_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(UNTYPED_ADDRESS, labeled)))
 
-      assert_includes card_row(store, "aiden").fetch(:vcard), labeled
-      assert_includes store.contact("aiden").vcard.to_s, UNTYPED_ADDRESS
+      assert_equal [labeled], lent_lines(store, group)
+      assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+      assert_includes store.contact("aiden").vcard.to_s, labeled
       assert_empty sentry_messages
     end
   end
@@ -1415,30 +1428,159 @@ class StoreTest < Minitest::Test
     edited = RESERIALIZED_ADDRESS.sub("7 Calculus", "8 Calculus")
 
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      group = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, edited)))
 
-      assert_includes card_row(store, "aiden").fetch(:vcard), edited
-      assert_includes store.contact("aiden").vcard.to_s, HOUSEHOLD_ADDRESS
+      assert_equal [edited], lent_lines(store, group)
+      assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+      assert_includes store.contact("aiden").vcard.to_s, edited
       assert_empty sentry_messages
     end
   end
 
-  # A deletion is detected and takes nothing with it: the group still
-  # lends the line, so the next read composes it back in. Whether a
-  # delete should reach the group at all is the plan's open question,
-  # and nothing here decides it.
-  def test_a_deleted_lent_line_comes_straight_back
+  # A deletion leaves the group: the row goes, so the next read
+  # composes nothing back in and the member serves what it stores.
+  # Consistent with an edit, and the plan's open question decided —
+  # a member either inherits or does not, negative overrides included
+  # (docs/plans/2026-09-09-group-edits-propagate.md, "The open
+  # question, decided").
+  def test_a_deleted_lent_line_leaves_the_group
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      group = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub("#{HOUSEHOLD_ADDRESS}\r\n", "")))
 
+      assert_empty lent_lines(store, group)
+      assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+      assert_equal AIDEN, store.contact("aiden").vcard.to_s
+      assert_empty sentry_messages
+    end
+  end
+
+  ## The fan-out
+
+  # The whole point of propagating: one member's edit is what every
+  # other member serves, and each of them is told so. The writer gets
+  # no `group` entry — its own `put` entry already carries the
+  # composition (docs/plans/2026-09-09-group-edits-propagate.md, "The
+  # fan-out").
+  def test_an_edit_reaches_every_other_member
+    with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+      add_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
+      served = store.contact("aiden").vcard.to_s
+
+      store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, EDITED_ADDRESS)))
+
+      assert_includes store.contact("znorth").vcard.to_s, EDITED_ADDRESS
+      fanned = store.changes_of("znorth").first
+      assert_equal "group", fanned.action
+      assert_equal store.contact("znorth").etag, fanned.etag
+      assert_equal [EDITED_ADDRESS], fanned.diff.added
+      assert_equal [HOUSEHOLD_ADDRESS], fanned.diff.removed
+      assert_equal "put", store.changes_of("aiden").first.action
+    end
+  end
+
+  # A deletion fans out the same way, its diff the line every other
+  # member just lost.
+  def test_a_deletion_reaches_every_other_member
+    with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+      add_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
+      served = store.contact("aiden").vcard.to_s
+
+      store.put("aiden", vcard(served.sub("#{HOUSEHOLD_ADDRESS}\r\n", "")))
+
+      assert_equal ZED, store.contact("znorth").vcard.to_s
+      fanned = store.changes_of("znorth").first
+      assert_equal "group", fanned.action
+      assert_equal [HOUSEHOLD_ADDRESS], fanned.diff.removed
+      assert_empty fanned.diff.added
+    end
+  end
+
+  # A member returning what it downloaded moves no group line, so
+  # nobody else is told anything: an entry per member of the group
+  # rather than per member the edit moved would have every client
+  # re-fetch a card that reads the same.
+  def test_an_unchanged_round_trip_fans_out_nothing
+    with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+      add_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
+
+      store.put("aiden", vcard(store.contact("aiden").vcard.to_s))
+
+      assert_equal ["put"], store.changes_of("znorth").map(&:action)
+    end
+  end
+
+  # Both halves land together or neither does: the group's row is
+  # rewritten inside the transaction the member's own write opens, so a
+  # log that will not take an entry leaves the group holding what it
+  # lent. The card row and the group row are two tables, and a client's
+  # sync token counts on the entry that ties them.
+  def test_a_propagated_edit_rolls_back_with_the_members_write
+    Dir.mktmpdir do |dir|
+      path = Pathname.new(dir) / "contacts.db"
+      group = ProTacts::Store.connect(path) do |store|
+        store.put("aiden", vcard(AIDEN))
+        add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      end
+
+      ProTacts::Store.connect(path) do |store|
+        served = store.contact("aiden").vcard.to_s
+        submitted = vcard(served.sub(HOUSEHOLD_ADDRESS, EDITED_ADDRESS))
+
+        assert_raises(RuntimeError) { FailingLog.new(database(store)).put("aiden", submitted) }
+        assert_equal [HOUSEHOLD_ADDRESS], lent_lines(store, group)
+        assert_equal served, store.contact("aiden").vcard.to_s
+      end
+    end
+  end
+
+  ## What propagation refuses
+
+  # An edit into a shape no group may hold stays on the member: an
+  # address type Contacts cannot model comes back as a property group
+  # with its label beside it, which reads as an edit and which
+  # group_properties refuses. Propagating it would be an exception on
+  # an ordinary sync, so the member keeps the line — serving it twice,
+  # which is what the report is for
+  # (docs/plans/2026-09-09-group-edits-propagate.md, "The line a group
+  # cannot hold").
+  def test_an_edit_the_group_cannot_hold_stays_on_the_member
+    labeled = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "item1.ADR:") + "\r\nitem1.X-ABLabel:dom"
+
+    with_store({"aiden" => AIDEN}) do |store|
+      group = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      served = store.contact("aiden").vcard.to_s
+
+      store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, labeled)))
+
+      assert_equal [HOUSEHOLD_ADDRESS], lent_lines(store, group)
+      assert_includes card_row(store, "aiden").fetch(:vcard), labeled
+      assert_equal 1, sentry_messages.length
+    end
+  end
+
+  # One group's untouched line must not be attributed to another
+  # group's lent line. A member of two groups that each lend an
+  # address, editing one of them, submits one edited line and one
+  # untouched: attributed in a single pass, whichever group came first
+  # would take the other's line as its own edit and the other would
+  # read a deletion.
+  def test_a_lent_line_is_not_attributed_anothers_untouched_line
+    with_store({"aiden" => AIDEN}) do |store|
+      household = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      other = add_group(store, members: ["aiden"], lines: [OWN_ADDRESS])
+      served = store.contact("aiden").vcard.to_s
+
+      store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, EDITED_ADDRESS)))
+
+      assert_equal [EDITED_ADDRESS], lent_lines(store, household)
+      assert_equal [OWN_ADDRESS], lent_lines(store, other)
       assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
-      assert_equal served, store.contact("aiden").vcard.to_s
       assert_empty sentry_messages
     end
   end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 0db6f8e..b77b66b 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -362,6 +362,29 @@ class WebTest < Minitest::Test
     end
   end
 
+  # And where the member edited the line its group lends: the group
+  # takes the edit and composes it back in, so the resource serves the
+  # submitted bytes again and the tag is honest
+  # (docs/plans/2026-09-09-group-edits-propagate.md).
+  def test_a_members_edit_to_a_lent_line_keeps_the_strong_etag
+    address = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom"
+
+    with_contacts({"aiden" => "Aiden"}) do |store|
+      lend(store, "aiden", address)
+
+      get "/dav/addressbook/aiden.vcf"
+      edited = last_response.body.sub("7 Calculus", "8 Calculus")
+
+      put_request "aiden", edited, "CONTENT_TYPE" => VCARD
+
+      assert_equal 204, last_response.status
+      assert_equal %("#{Digest::SHA256.hexdigest(edited)}"), last_response["ETag"]
+
+      get "/dav/addressbook/aiden.vcf"
+      assert_equal edited, last_response.body
+    end
+  end
+
   # The same omission for the rewrite that carries a birthday across:
   # the stored card is the submission plus the BDAY line macOS dropped,
   # so the answer cannot claim the tag — the client refetches, and the