store: a custom label reads as the type it was made from
A dom address comes home as item1.ADR with a label at its side;
the label now counts as its type, and gets struck alongside.

Assisted-by: Claude Opus 5 via Claude Code
change utqtmrkmnkxorovluzzlnwrrypktotow
commit 6e8dae86ed33cd7b1de2bcf5f9332a2bcb9dd6cd
author Alpha Chen <alpha@kejadlen.dev>
date
parent utmtooqt
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 24fdc46..5dd7669 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -152,9 +152,9 @@ module ProTacts
     # 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
+    # edited in a type Contacts cannot model comes back as an
+    # `item1.ADR` with its label beside it (see #kept_types?), 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").
@@ -940,8 +940,12 @@ module ProTacts
         # 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, parsed_line(row.line)) }
-        taken << strike(unaccounted, match) if match
+        match = candidates.find { unedited?(it, parsed_line(row.line), unaccounted) }
+        if match
+          label = label_of(match, unaccounted)
+          taken << strike(unaccounted, match)
+          taken << strike(unaccounted, label) if label
+        end
         match
       }
 
@@ -1048,31 +1052,48 @@ module ProTacts
     #
     # 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 unedited?(line, lent)
+    #: (VCard::Parser::Line line, VCard::Parser::Line lent, Array[VCard::Parser::Line] pool) -> bool
+    def unedited?(line, lent, pool)
       value = value_of(lent)
       return line.verbatim.chomp == lent.verbatim.chomp if value.nil?
 
-      value_of(line) == value && kept_types?(line, lent)
+      value_of(line) == value && kept_types?(line, lent, pool)
     end
 
     # Whether a submitted line carries the types the group lent it,
-    # across the two rewrites they survive: the values come back
+    # across the three 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). An untyped line
     # comes back untyped, so no types compares to no types and a
-    # member who labels one has edited it (docs/macos-contacts.md, "An
-    # address type the client cannot model becomes a custom label").
+    # member who labels one has edited it.
     #
-    # One shape this reads as an edit that nobody made: a 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. A group lending `ADR;TYPE=dom` therefore
-    # materializes into every member's card at their next sync — see
-    # the task "Read a custom label as the type it was made from".
-    #: (VCard::Parser::Line line, VCard::Parser::Line lent) -> bool
-    def kept_types?(line, lent)
-      types_of(line) == types_of(lent)
+    # The third: a type Contacts has no field for comes back moved,
+    # not kept — into a property group, `TYPE` parameter gone, the type
+    # the value of an `X-ABLabel` beside it — on an address nobody
+    # touched (docs/macos-contacts.md, "An address type the client
+    # cannot model becomes a custom label"). So that label counts as
+    # one of the line's types, and `ADR;TYPE=dom` coming back as
+    # `item1.ADR` with `item1.X-ABLabel:dom` is unedited.
+    #: (VCard::Parser::Line line, VCard::Parser::Line lent, Array[VCard::Parser::Line] pool) -> bool
+    def kept_types?(line, lent, pool)
+      label = label_of(line, pool)&.property
+      types = types_of(line)
+      types = (types + [label.text.downcase]).uniq.sort if label
+      types == types_of(lent)
+    end
+
+    # The `X-ABLabel` sharing a property group with `line`, the other
+    # half of the pair #kept_types? reads as one. Nil for an ungrouped
+    # line, or a group the pool holds no label for.
+    #: (VCard::Parser::Line line, Array[VCard::Parser::Line] pool) -> VCard::Parser::Line?
+    def label_of(line, pool)
+      group = line.property&.group
+      return if group.nil?
+
+      pool.find { |other|
+        property = other.property
+        !property.nil? && property.group&.casecmp?(group) == true && property.name.casecmp?("X-ABLABEL")
+      }
     end
 
     # What a line says, as the reading its property's value type calls
@@ -1151,12 +1172,13 @@ module ProTacts
 
     # 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.
+    # keeps what it lent. The known cause is an edit that lands in a
+    # type Contacts cannot model — a relabel to one, or a new value
+    # under one — 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?
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 761ef41..7d3cbdd 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1473,6 +1473,25 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # The label goes with the line it was made from: left behind, it
+  # would be a label in the member's card naming nothing.
+  def test_a_lent_type_the_client_made_a_label_of_comes_back_unedited
+    domestic = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "ADR;TYPE=dom:")
+    labeled = domestic.sub("ADR;TYPE=dom:", "item1.ADR:") + "\r\nitem1.X-ABLabel:dom"
+
+    with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+      group = FixtureData.seed_group(store, members: %w[aiden znorth], lines: [domestic])
+      served = store.contact("aiden").vcard.to_s
+
+      store.put("aiden", vcard(served.sub(domestic, labeled)))
+
+      assert_equal [domestic], lent_lines(store, group)
+      assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+      assert_equal ["put"], store.changes_of("znorth").map(&:action)
+      assert_empty sentry_messages
+    end
+  end
+
   # An edit still reads as one through the re-serialization it arrives
   # wrapped in: the value moved, and the spelling around it is what the
   # comparison sees past.