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
change stqzzukuxzrqplwqynrvnulklsxrovku
commit f488d70acd50b71ccb04abd54a17683cb9e5dd12
author Alpha Chen <alpha@kejadlen.dev>
date
parent ooklxzzx
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")