store: a relabelled lent line is an edit to the group's line
A label is a value, not a spelling the client picked out of the air,
so home turned to work is an edit the group is owed a share of.
Types compare casefolded and shorn of the pref macOS adds,
and probe:types asks what it does to a type it never had.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index e93af1e..689bd23 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -607,10 +607,10 @@ module ProTacts
# 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 with the
- # same value is the group's, untouched, and it goes. One line of
- # that name left over is an edit to the shared value and none is a
- # deletion of it: both stay in the card as they arrived, because
+ # 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
@@ -632,12 +632,13 @@ module ProTacts
inherited.each do |lent|
lent_line = parsed_line(lent.line)
candidates = unaccounted.select { it.names?(property_name(lent.line)) }
- # Blind to equal values the way the editor's digests are blind
- # to identical bytes (VCard::Parser::Line#digest): where a
- # member's own card carries the value its group lends, which
- # copy this takes is undecidable and the values make it not
+ # 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 { same_value?(it, lent_line) }
+ match = candidates.find { unedited?(it, lent_line) }
if match
unaccounted.delete_at(
unaccounted.index(match) #: Integer
@@ -659,30 +660,52 @@ module ProTacts
VCard.new(line).lines.fetch(0)
end
- # Whether a submitted line says what the group lends, which is a
- # question about the values and not about the bytes: macOS
+ # Whether a submitted line still says what the group lends, which
+ # is a question about what it says and not about its bytes: macOS
# re-serializes every card it touches, so `ADR;TYPE=home` comes
- # back `ADR;type=HOME;type=pref` on an address nobody edited, and a
- # parameter the client does not model is dropped from a NOTE
+ # back `ADR;type=HOME;type=pref` on an address nobody edited
# (docs/macos-contacts.md, "The client rewrites every card it
# touches"). Comparing bytes reads every such line as an edit.
#
- # So the parameters are not compared at all — the client rewrites
- # them, and invents them where the server sent none (the same
- # doc's annotation probe answered a bare `ADR` with
- # `ADR;type=WORK`). The cost is that a submission changing only a
- # lent line's TYPE reads as untouched and is dropped, which is the
- # trade this evidence leaves: no parameter a client returns can be
- # told from one it made up.
+ # What a line says is its value and its types: 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 an edit is the
+ # group's to take (the plan's "Edits propagate to the group"). So a
+ # type that moved is not subtracted, and stays in the member's card
+ # where the propagation will find it.
+ #
+ # Every other parameter goes uncompared, being the half the client
+ # rewrites without being asked: it drops the ones it does not model
+ # — a `NOTE;LANGUAGE=en` comes back bare — and fills in defaults on
+ # the ones it does.
#
# A line that will not read has no value to compare and falls back
# to its bytes, which still recognize the line nobody touched.
#: (VCard::Parser::Line line, VCard::Parser::Line lent) -> bool
- def same_value?(line, lent)
+ def unedited?(line, lent)
value = value_of(lent)
return line.verbatim.chomp == lent.verbatim.chomp if value.nil?
- value_of(line) == value
+ value_of(line) == value && kept_types?(line, lent)
+ end
+
+ # Whether a submitted line carries the types the group lent it,
+ # across the two rewrites they survive: the values come back
+ # uppercased and `pref` filled in, so neither side's case counts
+ # and neither counts `pref` (types_of drops it).
+ #
+ # A lent line with no TYPE at all compares none: an untyped ADR
+ # already means `TYPE=intl,postal,parcel,work` by RFC 2426 section
+ # 3.2.1's default, so the `ADR;type=WORK` the annotation probe got
+ # back from a bare `ADR` is the client spelling that default out
+ # rather than a member choosing anything. What a client does to a
+ # type it does not model is the open question, and the one that
+ # decides whether this comparison can carry a propagating write:
+ # `rake probe:types` asks it.
+ #: (VCard::Parser::Line line, VCard::Parser::Line lent) -> bool
+ def kept_types?(line, lent)
+ types = types_of(lent)
+ types.empty? || types_of(line) == types
end
# What a line says, as the reading its property's value type calls
@@ -697,6 +720,24 @@ module ProTacts
STRUCTURED_VALUES.include?(property.name.upcase) ? property.components : [property.text]
end
+ # A line's TYPE values, as the set the round trip preserves:
+ # casefolded, `pref` dropped as the client's own addition, and
+ # sorted because `TYPE=home;TYPE=pref` and `TYPE=pref,home` are one
+ # thing (RFC 2426 section 3.2.1, which the parser reads into pairs
+ # either way). Empty for a line that would not read, which has no
+ # parameters to compare.
+ #: (VCard::Parser::Line line) -> Array[String]
+ def types_of(line)
+ property = line.property
+ return [] if property.nil?
+
+ property.parameters
+ .filter_map { |name, value| value.downcase if name.casecmp?("TYPE") }
+ .reject { it == "pref" }
+ .uniq
+ .sort
+ end
+
# The submission's lines that the member's own stored card does not
# already explain — one struck per stored line of the same bytes,
# so a card that stores one of something and submits two leaves one
diff --git a/tasks/probe.rake b/tasks/probe.rake
index 6eea939..aebb2a5 100644
--- a/tasks/probe.rake
+++ b/tasks/probe.rake
@@ -36,15 +36,17 @@ namespace :probe do
# The shared half of every probe's instructions: a successful PUT is
# not an unhandled request, so the debug log is the only place it
- # lands.
- def reading_the_result(id, name)
+ # lands. `edit` is what to change in the client: a probe that reads
+ # what the rewrite does to the rest of the card does not care, and
+ # one whose experiment is the edit itself says which to make.
+ def reading_the_result(id, name, edit: "any field, the edit itself does not matter")
<<~STEPS
1. The server needs PRO_TACTS_DEBUG=1 for the write to be logged —
a successful PUT is not an unhandled request, so log/unhandled
will not have it. Restart with `PRO_TACTS_DEBUG=1 rake dev` if
it is not set.
- 2. Resync the client, edit "#{name}" — any field, the edit itself
- does not matter — and let the write go through.
+ 2. Resync the client and let a write through, editing "#{name}":
+ #{edit}.
3. The PUT body lands in log/dev.log after a `>>` line, under
#{id}.vcf.
STEPS
@@ -123,6 +125,62 @@ namespace :probe do
END:VCARD
CARD
+ # What a round trip does to an address's TYPE, which decides what a
+ # relabelled shared address means (Store#kept_types?). A group's line
+ # is subtracted from a member's submission when the value and the
+ # types both survive, so a type the client rewrites unasked reads as
+ # an edit — and once edits propagate, one member's sync would rewrite
+ # the shared address for everyone.
+ #
+ # Three addresses, told apart by their streets, since values survive
+ # exactly:
+ #
+ # TYPE=home a type the client models, and the control: known to
+ # come back HOME with pref added
+ # TYPE=dom a type RFC 2426 section 3.2.1 lists and Contacts has
+ # no field for — the one that decides it
+ # (none) the RFC's own default, TYPE=intl,postal,parcel,work,
+ # against the annotation probe's bare ADR coming back
+ # typed WORK
+ TYPES_ID = "probe-types"
+ TYPES_CARD = <<~CARD.gsub("\n", "\r\n")
+ BEGIN:VCARD
+ VERSION:3.0
+ N:Boole;Types;;;
+ FN:Probe Types
+ ADR;TYPE=home:;;1 Modeled Way;London;England;NW1 1AB;United Kingdom
+ ADR;TYPE=dom:;;2 Unmodeled Way;London;England;NW1 1AB;United Kingdom
+ ADR:;;3 Untyped Way;London;England;NW1 1AB;United Kingdom
+ UID:#{TYPES_ID}
+ END:VCARD
+ CARD
+
+ desc "Seed the address type probe: what survives a relabel, and what the client relabels unasked"
+ task :types do
+ code = seed(TYPES_ID, TYPES_CARD)
+
+ puts <<~NEXT
+ Seeded #{TYPES_ID} (#{code}).
+
+ #{reading_the_result(TYPES_ID, "Probe Types", edit: %(relabel "1 Modeled Way" from Home to Work, and leave the other two addresses alone)).chomp}
+ 4. Read the three addresses that come back:
+ - 1 Modeled Way: did the relabel arrive as type=WORK, so a
+ member's relabel is a thing this server can see at all?
+ - 2 Unmodeled Way: did TYPE=dom survive on an address nobody
+ touched, or did the client replace it the way it corrects
+ TEL;TYPE=mobile to CELL? A replacement means an untouched
+ shared address reads as edited, which is what Store#kept_types?
+ cannot carry into a propagating write.
+ - 3 Untyped Way: which types did the client write where the
+ card carried none?
+ 5. Record what happened in docs/macos-contacts.md, under "The
+ client rewrites every card it touches".
+ 6. Then relabel one address to a custom label and read it again:
+ a custom label is an itemN.ADR with an X-ABLabel beside it,
+ two lines where the group lends one.
+ NEXT
+ end
+
desc "Seed the annotation probe card into a running dev server (PROBE_URL to override)"
task :annotations do
code = seed(PROBE_ID, PROBE_CARD)
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 0b44cc5..8bc59fd 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1262,8 +1262,48 @@ 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
+ 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])
+ 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_empty sentry_messages
+ end
+ end
+
+ # A group that lends a line with no type of its own compares none:
+ # the client writes a type where the server sent one and where it
+ # sent none alike, and a type nobody chose is not an edit anyone
+ # made (docs/macos-contacts.md, "An annotation survives only on its
+ # own line", where a bare ADR came back typed WORK).
+ def test_a_type_the_client_invents_is_not_an_edit
+ bare = "ADR:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom"
+
+ with_store({"aiden" => AIDEN}) do |store|
+ add_group(store, members: ["aiden"], lines: [bare])
+ served = store.contact("aiden").vcard.to_s
+
+ store.put("aiden", vcard(served.sub(bare, bare.sub("ADR:", "ADR;type=WORK;type=pref:"))))
+
+ assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+ assert_equal served, store.contact("aiden").vcard.to_s
+ assert_empty sentry_messages
+ end
+ end
+
# An edit still reads as one through the re-serialization it arrives
- # wrapped in: the value moved, and only the value is compared.
+ # wrapped in: the value moved, and the spelling around it is what the
+ # comparison sees past.
def test_an_edit_survives_the_reserialization_it_arrives_in
edited = RESERIALIZED_ADDRESS.sub("7 Calculus", "8 Calculus")