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