store: compare the types on an untyped lent line too
The probe says the client invents nothing where the card left types out,
so the special case that ignored them had nothing to be about.
A type it has no field for comes back a label on a group instead —
recorded in the doc, with the fix left to the task ahead.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/docs/macos-contacts.md b/docs/macos-contacts.md
index 48b21a6..f7dc4d1 100644
--- a/docs/macos-contacts.md
+++ b/docs/macos-contacts.md
@@ -187,6 +187,36 @@ The practical consequence is that any comparison between a card the server
sent and the card that comes back has to be semantic. Comparing bytes
reports every untouched property as modified. Verified 2026-08-24.
+## An address type the client cannot model becomes a custom label
+
+`rake probe:types` sends three addresses and asks for one relabel — 1
+Modeled Way from Home to Work — to answer what a round trip does to `TYPE`:
+
+| Sent | Came back |
+|---|---|
+| `ADR;TYPE=home` (relabelled to Work) | `ADR;type=WORK;type=pref` |
+| `ADR;TYPE=dom` (untouched) | `item1.ADR` + `item1.X-ABLabel:dom` |
+| `ADR:` (untouched, no type) | `ADR:` |
+
+A relabel is legible. The type the user picked comes back as the type
+value, so a server can tell Home from Work, and a comparison that ignored
+parameters could not.
+
+A type the client has no field for is not dropped — it is moved. `dom` is
+RFC 2426 section 3.2.1's own value and Contacts offers no Domestic label, so
+the client made one: the address moved into a property group and the type
+became the `X-ABLabel` beside it. That happened on an address nobody edited.
+Two lines came back where one was sent, and the `TYPE` parameter is gone
+from both.
+
+An address with no type comes back with none, so nothing is invented. That
+corrects a reading of the annotation probe below, whose bare-looking `ADR`
+was sent as `item9.ADR;TYPE=work` — the `type=WORK` it came back with was
+that type surviving the strip of its group prefix, not a type made up to
+fill a gap.
+
+Verified 2026-09-09 against macOS 26.5.1 (AddressBookCore/2732.600.11).
+
## An annotation survives only on its own line
A server that wants to mark a line — to say which group lent an address,
@@ -199,7 +229,7 @@ answers all of them:
| Standalone property | `X-PT-GROUP:kxsv` | `X-PT-GROUP:kxsv` |
| Parameter on `ADR` | `ADR;TYPE=home;X-PT-GROUP=kxsv:` | `ADR;type=HOME;type=pref:` |
| Parameter on `NOTE` | `NOTE;X-PT-GROUP=kxsv:` | `NOTE:` |
-| Companion in a bare group | `item9.ADR` + `item9.X-PT-GROUP` | `ADR;type=WORK:` + `item9.X-PT-GROUP` |
+| Companion in a bare group | `item9.ADR;TYPE=work` + `item9.X-PT-GROUP` | `ADR;type=WORK:` + `item9.X-PT-GROUP` |
| Companion beside `X-ABLabel` | `item8.TEL` + `item8.X-ABLabel` + `item8.X-PT-GROUP` | `item1.TEL` + `item1.X-ABLabel` + `item8.X-PT-GROUP` |
A property of its own survives untouched, lowercase value included. A
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 689bd23..cbd0468 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -692,20 +692,20 @@ module ProTacts
# 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).
+ # 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").
#
- # 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.
+ # 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 = types_of(lent)
- types.empty? || types_of(line) == types
+ types_of(line) == types_of(lent)
end
# What a line says, as the reading its property's value type calls
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 8bc59fd..bf452e5 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1157,6 +1157,9 @@ class StoreTest < Minitest::Test
# rewrites every card it touches" and "An annotation survives only on
# its own line").
RESERIALIZED_ADDRESS = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "ADR;type=HOME;type=pref:") #: String
+ # The same address lent with no type on it, which is a shape the
+ # round trip leaves alone.
+ UNTYPED_ADDRESS = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "ADR:") #: String
TAGGED_NOTE = HOUSEHOLD_NOTE.sub("NOTE:", "NOTE;LANGUAGE=en:") #: String
# The round trip the whole composition rests on: a member PUTs back
@@ -1281,19 +1284,15 @@ class StoreTest < Minitest::Test
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"
-
+ # An untyped line comes back untyped: the client invents no type to
+ # fill the gap (docs/macos-contacts.md, "An address type the client
+ # cannot model becomes a custom label").
+ def test_an_untyped_lent_line_comes_back_untyped
with_store({"aiden" => AIDEN}) do |store|
- add_group(store, members: ["aiden"], lines: [bare])
+ add_group(store, members: ["aiden"], lines: [UNTYPED_ADDRESS])
served = store.contact("aiden").vcard.to_s
- store.put("aiden", vcard(served.sub(bare, bare.sub("ADR:", "ADR;type=WORK;type=pref:"))))
+ store.put("aiden", vcard(served))
assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
assert_equal served, store.contact("aiden").vcard.to_s
@@ -1301,6 +1300,24 @@ class StoreTest < Minitest::Test
end
end
+ # 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
+ labeled = UNTYPED_ADDRESS.sub("ADR:", "ADR;type=HOME;type=pref:")
+
+ with_store({"aiden" => AIDEN}) do |store|
+ 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_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.