contact: a phone, email or address row carries its property
CardForm re-read each row's property off its line and raised on a nil,
though Contact#rows builds a row only from a line that read. The rows
now carry that reading, so the save splices it with no check left to
make.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kqo9sKAi4DnKCQkrtYoAwM
change
commit cd322ea2ede8f37c466b09286baaad8695035038
author Claude <noreply@anthropic.com>
date
parent 7637f42e
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 728ccf6..0eb56e6 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -181,17 +181,13 @@ module ProTacts
         edits = params[field]
         if edits
           rows.each do |row|
-            # These rows read from lines that did (Contact#phones), so
-            # a nil is a broken assumption.
-            property = row.line.property or raise "a #{field} row read from a line that will not read"
-
             submitted = edits[row.line.digest]
             next if submitted.nil? || submitted.to_s.strip == row.value.strip
 
             value = submitted.to_s.strip
             vcard = vcard.substitute(
               row.line.digest,
-              value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"],
+              value.empty? ? [] : ["#{VCard.header_of(row.property)}#{VCard.escape(value)}"],
             )
           end
         end
@@ -256,7 +252,7 @@ module ProTacts
       # header.
       #: (Contact::Address? address, Hash[String, untyped] submitted) -> String?
       def self.address_line(address, submitted)
-        property = address && address.line.property
+        property = address&.property
         if property.nil?
           components = [""]
         else
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 296d241..9f22592 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -63,14 +63,17 @@ module ProTacts
     # (docs/plans/2026-09-18-phone-labels.md, "Reading"), every
     # shape's `line` is the parsed line it was read from, the address
     # a save names that row by
-    # (docs/plans/2026-09-05-web-card-editor.md), and `lent_by` the
+    # (docs/plans/2026-09-05-web-card-editor.md), a repeatable row's
+    # `property` that line's reading, which #rows only builds a row
+    # from, so a save splicing it never meets a line that would not
+    # read, and `lent_by` the
     # group lending the row, nil where the card's own (#lent_at).
     # Data classes, whose members the inline syntax cannot read; the
     # signatures live in sig/pro_tacts/contact.rbs.
     # @rbs skip
-    Phone = Data.define(:value, :label, :types, :line, :lent_by)
+    Phone = Data.define(:value, :label, :types, :line, :property, :lent_by)
     # @rbs skip
-    Email = Data.define(:value, :types, :line, :lent_by)
+    Email = Data.define(:value, :types, :line, :property, :lent_by)
     # @rbs skip
     Note = Data.define(:value, :line)
     # @rbs skip
@@ -95,6 +98,7 @@ module ProTacts
       :country,
       :types,
       :line,
+      :property,
       :lent_by,
     )
 
@@ -371,6 +375,7 @@ module ProTacts
           label: group && labels[group.downcase],
           types: property.types - %w[voice],
           line:,
+          property:,
           lent_by:,
         ) if value
       }
@@ -382,7 +387,7 @@ module ProTacts
         value = text_of(property)
         # RFC 2426 section 3.3.2: `internet` is the format every address
         # has by default, so it names none.
-        Email.new(value:, types: property.types - %w[internet], line:, lent_by:) if value
+        Email.new(value:, types: property.types - %w[internet], line:, property:, lent_by:) if value
       }
     end
 
@@ -548,15 +553,16 @@ module ProTacts
     end
 
     # ADR's components, with a value that is blank throughout reading
-    # as no address at all. Carries the line it was read from, the
-    # other provenance-carrying shapes' own address for a save.
+    # as no address at all. Carries the line it was read from and its
+    # reading, the other provenance-carrying shapes' own address for a
+    # save.
     #: (VCard::Parser::Property property, line: VCard::Parser::Line, lent_by: Group?) -> Address?
     def address_of(property, line:, lent_by:)
       components = components_of(property)
       return if components.none?
 
       po_box, extended, street, locality, region, postal_code, country = components
-      Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, types: property.types, line:, lent_by:)
+      Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, types: property.types, line:, property:, lent_by:)
     end
 
   end
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 93bd045..2af6dc6 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -10,18 +10,20 @@ module ProTacts
       attr_reader label: String?
       attr_reader types: Array[String]
       attr_reader line: VCard::Parser::Line
+      attr_reader property: VCard::Parser::Property
       attr_reader lent_by: Group?
 
-      def self.new: (value: String, label: String?, types: Array[String], line: VCard::Parser::Line, lent_by: Group?) -> instance
+      def self.new: (value: String, label: String?, types: Array[String], line: VCard::Parser::Line, property: VCard::Parser::Property, lent_by: Group?) -> instance
     end
 
     class Email
       attr_reader value: String
       attr_reader types: Array[String]
       attr_reader line: VCard::Parser::Line
+      attr_reader property: VCard::Parser::Property
       attr_reader lent_by: Group?
 
-      def self.new: (value: String, types: Array[String], line: VCard::Parser::Line, lent_by: Group?) -> instance
+      def self.new: (value: String, types: Array[String], line: VCard::Parser::Line, property: VCard::Parser::Property, lent_by: Group?) -> instance
     end
 
     class Note
@@ -56,6 +58,7 @@ module ProTacts
       attr_reader country: String?
       attr_reader types: Array[String]
       attr_reader line: VCard::Parser::Line
+      attr_reader property: VCard::Parser::Property
       attr_reader lent_by: Group?
 
       def self.new: (
@@ -68,6 +71,7 @@ module ProTacts
         country: String?,
         types: Array[String],
         line: VCard::Parser::Line,
+        property: VCard::Parser::Property,
         lent_by: Group?
       ) -> instance
     end