edited contact: a served card PUT back stores what was stored
A client re-PUTting the card it downloaded changed the stored card
whenever a group lent a NOTE: the member's own NOTE came back
re-escaped, trimmed, stripped of parameters, deleted when blank, or
CRLF on an LF card, and grew by a section each sync when it held a
line reading as a lent header. And where the member's own card held a
line byte-equal to a lent one, the stored copy was the one struck, so
the lent copy moved into its place. Each such sync logged a change
and moved the etag.

A NOTE that reads as what the contact served now restores the stored
line as it was; an edit to a section keeps the member's line wherever
its text still reads the same; taken lines leave by identity, the
lent copy rather than the first; and composed NOTE lines take their
neighbour's terminator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
change
commit e1c714ae3b0a0b54af629177ca510b814da3a004
author Claude <noreply@anthropic.com>
date
parent aa7b9c3e
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 3080471..0b99194 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -460,7 +460,8 @@ module ProTacts
     # The one NOTE the served card carries, as a content line: the
     # member's own text and the lent notes as sections, written
     # through NoteSections and escaped the way the editors write a
-    # text value. Nil where nothing is lent — a member whose groups
+    # text value, with no terminator so it takes the one of the line it
+    # replaces and an LF card stays one (VCard#replace). Nil where nothing is lent — a member whose groups
     # hold no note serves its own NOTE's bytes as they lie, whatever
     # they say, the editor's whole safety.
     #
@@ -480,7 +481,7 @@ module ProTacts
       }
       member = @stored.lines.select { it.names?("NOTE") }
         .filter_map { it.property&.text }.join("\n\n")
-      "NOTE:#{VCard.escape(NoteSections.join(member, sections))}\r\n"
+      "NOTE:#{VCard.escape(NoteSections.join(member, sections))}"
     end
 
     # A property group to the `X-ABLabel` text anchoring it — the
diff --git a/lib/pro_tacts/edited_contact.rb b/lib/pro_tacts/edited_contact.rb
index 71c51b4..633803f 100644
--- a/lib/pro_tacts/edited_contact.rb
+++ b/lib/pro_tacts/edited_contact.rb
@@ -184,11 +184,17 @@ module ProTacts
     # 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).
+    # The taken lines leave by identity rather than through #extract
+    # and #insert, which would move a member's own lines of the same
+    # name to the card's end, or #substitute, which takes the first
+    # copy of identical bytes — the member's own, where its card
+    # carries what a group lends, since composition puts the lent copy
+    # last. Struck by identity, 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). The pool is the
+    # submission's own lines (#unaccounted_lines), so each taken line
+    # is one of them.
     #: (VCard vcard) -> [VCard, Array[GroupEdit]]
     def subtract_inherited(vcard)
       lent = @before&.inherited || [] #: Array[Contact::Inherited]
@@ -229,7 +235,7 @@ module ProTacts
 
         report_ambiguous_inherited_lines(ambiguous)
         report_unshareable_lines(unshareable)
-        struck = taken.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }
+        struck = VCard.new(vcard.lines.reject { |line| taken.any? { it.equal?(line) } }.map(&:verbatim).join)
       end
 
       card, note_edits = subtract_notes(struck, note_rows)
@@ -276,6 +282,9 @@ module ProTacts
         return [members_own(vcard, notes), []]
       end
       value = values.join("\n\n")
+      own = stored_before&.lines&.select { it.names?("NOTE") } || [] #: Array[VCard::Parser::Line]
+      return [vcard.replace("NOTE", own.map { it.verbatim.chomp }), []] if own.length <= 1 && value == served_note
+
       split = NoteSections.split(value, labels)
       if split.sections.empty?
         # The two-line shape's truncation (see the method comment).
@@ -316,12 +325,35 @@ module ProTacts
           )
         end
       }
-      if split.member.empty?
-        member = [] #: Array[String]
-      else
-        member = ["NOTE:#{VCard.escape(split.member)}\r\n"]
-      end
-      [vcard.replace("NOTE", member), edits]
+      [vcard.replace("NOTE", member_note(split.member, own, labels)), edits]
+    end
+
+    # What a no-op PUT sent back: the NOTE text the contact before this
+    # write served, which is what composition made of the member's own
+    # note and its groups' (Contact#composed_note). Read off the
+    # composed card rather than composed again, so the two cannot
+    # drift. Nil for a contact being created, which served nothing.
+    #: () -> String?
+    def served_note
+      @before&.vcard&.lines&.find { it.names?("NOTE") }&.property&.text
+    end
+
+    # The member's half of a split NOTE as the line to store. Where it
+    # still reads as the stored note did, the stored line stays byte
+    # for byte — its parameters, its escapes, the line breaks the
+    # split trims off — since rewriting it would change bytes nobody
+    # edited and move the etag on a sync that changed nothing else.
+    # Otherwise the text is escaped the way the editors write it, and
+    # empty text is no line. No terminator, so the line takes its
+    # neighbour's and an LF card stays one.
+    #: (String text, Array[VCard::Parser::Line] own, Array[String] labels) -> Array[String]
+    def member_note(text, own, labels)
+      line = own.fetch(0) if own.length == 1
+      stored = line&.property&.text
+      return [line.verbatim.chomp] if line && stored && NoteSections.split(stored, labels).member == text
+      return [] if text.empty?
+
+      ["NOTE:#{VCard.escape(text)}"]
     end
 
     # The submission's NOTE lines kept as the member's own: their
@@ -352,7 +384,7 @@ module ProTacts
     #: (VCard vcard, Array[VCard::Parser::Line] notes) -> VCard
     def joined_notes(vcard, notes)
       texts = notes.map { it.property&.text || it.verbatim.chomp }
-      vcard.replace("NOTE", ["NOTE:#{VCard.escape(texts.join("\n\n"))}\r\n"])
+      vcard.replace("NOTE", ["NOTE:#{VCard.escape(texts.join("\n\n"))}"])
     end
 
     # What a lent line that came back changed asks of its group: the
diff --git a/test/pro_tacts/test_edited_contact.rb b/test/pro_tacts/test_edited_contact.rb
index df3061f..847a309 100644
--- a/test/pro_tacts/test_edited_contact.rb
+++ b/test/pro_tacts/test_edited_contact.rb
@@ -1,5 +1,7 @@
 require_relative "../test_helper"
 
+require "hegel"
+
 require "pro_tacts/birthday"
 require "pro_tacts/contact"
 require "pro_tacts/edited_contact"
@@ -14,6 +16,7 @@ require "pro_tacts/vcard"
 class EditedContactTest < Minitest::Test
   include Sentry::TestHelper
   include SentryMessages
+  include Hegel::Syntax::Methods
 
   AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
   APRIL_12 = ProTacts::Birthday.new(year: 1985, month: 4, day: 12)
@@ -312,4 +315,68 @@ class EditedContactTest < Minitest::Test
       decompose(served, before: before(stored, inherited: [*LENT, *LENT_NOTE])),
     )
   end
+
+  ## The round trip
+
+  # Raw NOTE values heavy in what the round trip has to carry back:
+  # escapes and the separators a client may leave bare, blank lines and
+  # trailing line breaks the section split trims, and a line that reads
+  # as a lent section's header.
+  NOTE_PIECES = [
+    "a", "b", " ", ",", "\\,", ";", "\\;", "\\\\", "\\n", "\\N",
+    "\\nShared · Neighbours:\\n", "Shared · Dog walkers:",
+  ].freeze #: Array[String]
+
+  # A stored card for the round trips: the member's lines in any order
+  # and either terminator, its own NOTE in any spelling the pieces
+  # make, and its own ADR possibly byte-equal to the one a group lends.
+  def stored_card(tc, pieces)
+    note = tc.draw(optional(tuples(
+      sampled_from(["NOTE", "NOTE;LANGUAGE=en", "note"]),
+      arrays(sampled_from(pieces), max_size: 6).map(&:join),
+    ).map { |name, value| "#{name}:#{value}" }))
+    own = tc.draw(arrays(sampled_from([OWN_ADDRESS, HOUSEHOLD_ADDRESS, "TEL:+1 555 0100"]), max_size: 3))
+    lines = [*own, *note].shuffle(random: Random.new(tc.draw(integers(min_value: 0, max_value: 1_000))))
+    eol = tc.draw(sampled_from(["\r\n", "\n"]))
+    ["BEGIN:VCARD", "VERSION:3.0", "FN:Aiden", *lines, "UID:aiden", "END:VCARD"].map { "#{it}#{eol}" }.join
+  end
+
+  # Whatever the member's card holds and whatever its groups lend, the
+  # card Contact#vcard composes, PUT back untouched, stores the bytes it
+  # was composed from and asks nothing of any group: the no-op a
+  # syncing client makes on every pass, which must not move the etag or
+  # log a change (RFC 6352 section 6.3.2.3).
+  def test_a_served_card_put_back_stores_what_was_stored
+    lendable = [*LENT, *LENT_NOTE,
+                ProTacts::Contact::Inherited.new(group: DOGWALKERS, position: 2, line: "NOTE:Sunday mornings.")]
+    Hegel.test(test_cases: 1_000) do |tc|
+      stored = stored_card(tc, NOTE_PIECES)
+      inherited = tc.draw(sets(sampled_from(lendable), max_size: 3)).to_a.sort_by(&:position)
+      birthday = tc.draw(optional(just(APRIL_12)))
+      contact = before(stored, birthday:, inherited:)
+
+      assert_equal [stored, birthday, []], decompose(contact.vcard.to_s, before: contact)
+      assert_empty sentry_messages
+    end
+  end
+
+  # An edit of one group's section asks that group for the new text
+  # and leaves the member's own NOTE as it was stored, byte for byte:
+  # nobody edited it, so rewriting it would change what the member
+  # holds on a write about the group. Member text here holds no line
+  # that reads as a header, which would be the mangle instead.
+  def test_an_edited_section_leaves_the_members_note_as_stored
+    pieces = NOTE_PIECES.reject { it.include?("Shared") }
+    Hegel.test(test_cases: 1_000) do |tc|
+      stored = stored_card(tc, pieces)
+      text = tc.draw(from_regex("[a-z ,;]{0,12}[a-z]", fullmatch: true))
+      tc.assume(text != NOTE_TEXT)
+      lent = [*LENT_NOTE, *tc.draw(optional(just(LENT.fetch(0))))]
+      contact = before(stored, inherited: lent)
+      edited = ProTacts::Contact::Inherited.new(group: NEIGHBOURS, position: 1, line: "NOTE:#{ProTacts::VCard.escape(text)}")
+      submitted = before(stored, inherited: [edited, *lent.drop(1)]).vcard.to_s
+
+      assert_equal [stored, nil, [neighbour_edit(edited.line)]], decompose(submitted, before: contact)
+    end
+  end
 end