admin: edit the contact's own card, not what a group lends it
The form served the household's address as if you owned the deed,
and a blank note row would've stamped the gate code on the member.
Now the rows come from the stored card, the lent ones left alone,
and a save that names a borrowed line matches nothing and writes none.

Assisted-by: Claude Opus 5 via Claude Code
change zolovsnkzzozlumnvupupxkswqnrpmqw
commit 1b6fdd680585a9837cd6fa365ed978eed0891d76
author Alpha Chen <alpha@kejadlen.dev>
date
parent omozuxym
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 176d50e..a6642d2 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -78,6 +78,7 @@ module ProTacts
       private_constant :ADDRESS_FIELDS
 
       # @rbs @contact: Contact
+      # @rbs @own: Contact
       # @rbs @notice: String?
       # @rbs @first: String?
       # @rbs @last: String?
@@ -85,6 +86,13 @@ module ProTacts
       #: (contact: Contact, ?notice: String?) -> void
       def initialize(contact:, notice: nil)
         @contact = contact
+        # The rows are the contact's own, never what a group lends it
+        # (Contact#own): a row here is a row the save may rewrite or
+        # remove, and neither is this screen's to do to a value the
+        # household owns — the details page is where an inherited row
+        # is read (Admin::ContactsShow). The etag below is still the
+        # composed contact's, the one a save is guarded against.
+        @own = contact.own
         @notice = notice
         # N's first two components (RFC 2426 section 3.1.2: family;
         # given) — the two fields the create dialog also asks for. The
@@ -157,7 +165,7 @@ module ProTacts
                   # the last value of a duplicated name — editing one
                   # of a pair of byte-identical rows means blanking
                   # one, saving, then editing the other.
-                  @contact.phones.each do |phone|
+                  @own.phones.each do |phone|
                     label(class: "field", data: {blank_removes: true}) do
                       span { phone.type || "phone" }
                       input(type: "tel", name: "phone[#{phone.line.digest}]",
@@ -165,19 +173,19 @@ module ProTacts
                     end
                   end
                   # An email row, the phone row's own shape over EMAIL.
-                  @contact.emails.each do |email|
+                  @own.emails.each do |email|
                     label(class: "field", data: {blank_removes: true}) do
                       span { email.type || "email" }
                       input(type: "email", name: "email[#{email.line.digest}]",
                             value: email.value, placeholder: "removed on save")
                     end
                   end
-                  @contact.addresses.each { address_row(it) }
+                  @own.addresses.each { address_row(it) }
                   birthday_row
                   added_rows
                   # The first NOTE the card carries: this row edits one,
                   # and a save writes one (Web#edited_card's replace).
-                  note = @contact.notes.first&.value
+                  note = @own.notes.first&.value
                   label(class: "field", data: {blank_removes: !!note}) do
                     span { "Note" }
                     textarea(name: "note", rows: 4,
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 0a84a5b..54ce75b 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -168,6 +168,23 @@ module ProTacts
       @vcard = line ? card.insert([line]) : card
     end
 
+    # The same contact with nothing its groups lend it: the stored
+    # card and the birthday, no inheritance. The editor's subject —
+    # a form rendered from the composed contact offers rows the member
+    # does not own, and saving one writes the group's value into the
+    # member's own card (Web#edited_card), which is the
+    # materialization the composition exists to avoid
+    # (docs/plans/2026-08-24-vcard-storage-and-groups.md). The etag is
+    # not this contact's: a screen guarding a save still carries the
+    # composed one, which is what a client downloads and what the
+    # store's change log records.
+    #: () -> Contact
+    def own
+      return @own if defined?(@own)
+
+      @own = self.class.for(id: @id, stored: @stored, birthday: @birthday, inherited: [])
+    end
+
     # The etag over #vcard's bytes, derived here on first ask and
     # memoized with the card it hashes.
     #: () -> String
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 06027a7..3cfa0f4 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -752,14 +752,21 @@ module ProTacts
       nickname = params["nickname"].to_s.strip
       note = params["note"].to_s.strip
 
+      # The rows are read off the contact's own card, the one the form
+      # rendered (Admin::ContactsEdit) and the one this save splices:
+      # a line a group lends is on neither, so no digest here can name
+      # one and no save can copy a group's value onto its member
+      # (Contact#own).
+      own = contact.own
+
       card = contact.stored
       card = card.replace("N", [n_line(card, first, last)])
       card = edited_fn(contact, card, first, last)
       card = card.replace("NICKNAME", text_lines("NICKNAME", nickname))
       card = card.replace("NOTE", text_lines("NOTE", note))
-      card = edited_phones(contact, card, params)
-      card = edited_emails(contact, card, params)
-      edited_addresses(contact, card, params)
+      card = edited_phones(own, card, params)
+      card = edited_emails(own, card, params)
+      edited_addresses(own, card, params)
     end
 
     # The phones' half of the surgical save: each row names its line
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 8990829..7a3e4dc 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -686,6 +686,59 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The editor renders the contact's own rows and none a group lends:
+  # a row here is a row the save may rewrite or remove, and neither is
+  # this screen's to do to a value the household owns. The details
+  # page is where an inherited row is read.
+  def test_the_edit_screen_omits_the_rows_a_group_lends
+    with_contacts({"ada" => ADA}) do |store|
+      add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+
+      get "/contacts/ada/edit"
+
+      assert_includes last_response.body, "12 Analytical Way"
+      refute_includes last_response.body, "7 Calculus Close"
+    end
+  end
+
+  # The note row is the collision's own case: a member with no note of
+  # its own inherits the household's, and a textarea prefilled from it
+  # would write the group's words onto the member the first time
+  # anyone saved.
+  def test_the_note_row_is_blank_over_a_note_only_the_group_lends
+    noteless = ADA.sub("NOTE:Countess of Lovelace.\r\n", "")
+
+    with_contacts({"ada" => noteless}) do |store|
+      add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+
+      get "/contacts/ada/edit"
+
+      assert_includes last_response.body, '<textarea name="note" rows="4"></textarea>'
+      refute_includes last_response.body, "Gate code 1854."
+    end
+  end
+
+  # A save reaches the contact's own lines and no others, so a request
+  # naming a lent line — the screen renders no such row, but a request
+  # is not the screen — matches nothing and writes nothing.
+  def test_a_save_naming_a_lent_line_touches_nothing
+    with_contacts({"ada" => ADA}) do |store|
+      add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+      contact = store.contact("ada")
+      lent = contact.addresses.find { contact.group_of(it.line) }
+
+      post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
+                               etag: contact.etag,
+                               address: {lent.line.digest => {"street" => "9 Elsewhere Lane"}}
+
+      assert_equal 303, last_response.status
+      after = store.contact("ada")
+      refute_includes after.stored.to_s, "9 Elsewhere Lane"
+      refute_includes after.stored.to_s, "7 Calculus Close"
+      assert_includes after.vcard.to_s, "7 Calculus Close"
+    end
+  end
+
   def test_the_edit_screen_of_an_unknown_contact_is_404
     with_contacts({}) { get "/contacts/nope/edit" }