lib: let the code carry what several comments were carrying
Every seam below was a piece of reasoning written out more than once,
because the code under it was written more than once. The duplicate
explanations go with the duplicate code.

- Sequel::Sole#sole_or_nil: the read where no row is the ordinary
  answer, said once instead of at five rescues.
- VCard#terminate: the line-break convention rule, which #replace,
  #substitute, and both arms of #insert each restated.
- VCard::Parser.physical_lines: one split, where VCard kept a private
  copy of it with a copy of its comment.
- VCard::Parser::Line#content: the form a group stores a line in,
  which Store and CardForm each spelled out and each explained.
- VCard::Parser::Property#types: the TYPE reading, with `pref` and its
  reason in one place rather than in Store and Contact both.
- VCard#property: the case-insensitive lookup seven callers wrote out.
- Store#split_birthday and #upsert_card: two blocks lifted out of #put,
  the second of which #rewrite also carried.
- Web#etag_matches?: If-Match and If-None-Match ask one question; they
  had two identical methods and two comments for it.
- Web#member_id and #no_content: the member-URI shape and the bodyless
  204, each explained at both of the routes that had it.
- CardForm.edited_values and ContactsEdit#value_row: the email walk and
  the email row were the phone ones copied, and said so in a comment.

Behavior is unchanged but for #delete_group, which no longer swallows a
Sequel::NoMatchingRow raised from inside its transaction — the
membership's foreign key leaves none to raise, and swallowing one would
hide an error rather than answer a miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WWFQaLD58uLFedh3Dvfb2C
change
commit f5107bd90fdce311a505c36605a86eebe84948eb
author Claude <noreply@anthropic.com>
date
parent 417a51bb
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index e248f77..1d95117 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -65,8 +65,8 @@ module ProTacts
         vcard = edited_fn(contact, vcard, first, middle, last)
         vcard = vcard.replace("NICKNAME", text_lines("NICKNAME", nickname))
         vcard = vcard.replace("NOTE", text_lines("NOTE", note))
-        vcard = edited_phones(own, vcard, params)
-        vcard = edited_emails(own, vcard, params)
+        vcard = edited_values(own.phones, "phone", "TEL", vcard, params)
+        vcard = edited_values(own.emails, "email", "EMAIL", vcard, params)
         edited_addresses(own, vcard, params)
       end
 
@@ -81,7 +81,7 @@ module ProTacts
         card = reading.stored.replace("NOTE", text_lines("NOTE", params["note"].to_s.strip))
         card = edited_addresses(reading, card, params)
         card.lines.filter_map {
-          line = VCard::Parser.unfold(it.verbatim).sub(/(\r\n|[\r\n])\z/, "")
+          line = it.content
           line unless line.empty?
         }
       end
@@ -106,81 +106,56 @@ module ProTacts
         Birthday.new(year:, month:, day:)
       end
 
-      # The phones' half of the surgical save: each row names its line
+      # The single-value rows' half of the surgical save, walked once
+      # for the phones and once for the emails: each row names its line
       # by digest, so a row the request leaves out is a line the save
       # never mentions, and an unchanged row is skipped — the line keeps
       # its own bytes by construction, not by careful re-rendering. A
       # blank row removes its line; a changed row swaps the value under
       # the line's own header (VCard.header_of), where the TYPE
-      # parameters no field models ride. The addresses come from the
+      # parameters no field models ride. The rows come from the
       # contact, never the request, so a doctored digest names nothing
       # and a missing one touches nothing.
-      #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
-      def self.edited_phones(contact, vcard, params)
-        rows = params["phone"]
-        if rows.is_a?(Hash)
-          contact.phones.each do |phone|
+      #
+      # `field` is the form's name for the kind and `name` the property
+      # an added row is written as. The rows the add dialog reveals
+      # (Admin::ContactsEdit) land as bare lines before END:VCARD, and a
+      # blank one inserts nothing — inserting absence is a no-op, the
+      # plan's rule for new rows, and the reason the rows nobody typed
+      # in cost nothing whether they were on screen or not. Array()
+      # because the field is a list and a request carrying one value
+      # is still a list of one.
+      #: (Array[Contact::Phone | Contact::Email] rows, String field, String name, VCard vcard, Hash[String, untyped] params) -> VCard
+      def self.edited_values(rows, field, name, vcard, params)
+        edits = params[field]
+        if edits.is_a?(Hash)
+          rows.each do |row|
             # The property is nil only for a line that would not read,
-            # and phones read from lines that did; the guard is the
+            # and these rows read from lines that did; the guard is the
             # type's honesty, not a reachable case (n_line's own shape).
-            property = phone.line.property
-            next if property.nil?
-
-            submitted = rows[phone.line.digest]
-            next if submitted.nil? || submitted.to_s.strip == phone.value
-
-            value = submitted.to_s.strip
-            vcard = vcard.substitute(
-              phone.line.digest,
-              value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"]
-            )
-          end
-        end
-
-        # The rows the add dialog reveals (Admin::ContactsEdit): each
-        # value lands as a bare TEL before END:VCARD, and a blank one
-        # inserts nothing — inserting absence is a no-op, the plan's
-        # rule for new rows, and the reason the rows nobody typed in
-        # cost nothing whether they were on screen or not. Array()
-        # because the field is a list and a request carrying one value
-        # is still a list of one.
-        submitted = Array(params["new_phone"]) #: Array[untyped]
-        added = submitted.filter_map {
-          value = it.to_s.strip
-          "TEL:#{VCard.escape(value)}\r\n" unless value.empty?
-        } #: Array[String]
-        vcard.insert(added)
-      end
-
-      # #edited_phones' walk, over EMAIL.
-      #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
-      def self.edited_emails(contact, vcard, params)
-        rows = params["email"]
-        if rows.is_a?(Hash)
-          contact.emails.each do |email|
-            property = email.line.property
+            property = row.line.property
             next if property.nil?
 
-            submitted = rows[email.line.digest]
-            next if submitted.nil? || submitted.to_s.strip == email.value
+            submitted = edits[row.line.digest]
+            next if submitted.nil? || submitted.to_s.strip == row.value
 
             value = submitted.to_s.strip
             vcard = vcard.substitute(
-              email.line.digest,
+              row.line.digest,
               value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"],
             )
           end
         end
 
-        submitted = Array(params["new_email"]) #: Array[untyped]
+        submitted = Array(params["new_#{field}"]) #: Array[untyped]
         added = submitted.filter_map {
           value = it.to_s.strip
-          "EMAIL:#{VCard.escape(value)}\r\n" unless value.empty?
+          "#{name}:#{VCard.escape(value)}\r\n" unless value.empty?
         } #: Array[String]
         vcard.insert(added)
       end
 
-      # #edited_phones' walk, over a row that is six fields rather than
+      # #edited_values' walk, over a row that is six fields rather than
       # one (Admin::ContactsEdit). Removal is the reader's own rule
       # (Contact#address_of): a row blank throughout, po box included,
       # removes the line, where a partially blanked one keeps it —
@@ -287,7 +262,7 @@ module ProTacts
       #: (Contact contact, VCard vcard, String first, String middle, String last) -> VCard
       def self.edited_fn(contact, vcard, first, middle, last)
         family, given, additional = contact.name_components || []
-        named = vcard.properties.any? { it.name.casecmp?("FN") }
+        named = !vcard.property("FN").nil?
         return vcard if named && first == given.to_s && middle == additional.to_s && last == family.to_s
 
         vcard.replace("FN", ["FN:#{VCard.escape(display_name(first, middle, last))}\r\n"])
@@ -315,7 +290,7 @@ module ProTacts
       # empties a whole-N writer would leave.
       #: (VCard vcard, String first, String middle, String last) -> String
       def self.n_line(vcard, first, middle, last)
-        property = vcard.properties.find { it.name.casecmp?("N") }
+        property = vcard.property("N")
         components = [] #: Array[String]
         readings = [] #: Array[String]
         if property
@@ -332,7 +307,7 @@ module ProTacts
         "N:#{components.join(";")}\r\n"
       end
 
-      private_class_method :edited_phones, :edited_emails, :edited_addresses, :address_unchanged?,
+      private_class_method :edited_values, :edited_addresses, :address_unchanged?,
                            :address_line, :birthday_component, :text_lines, :edited_fn, :display_name,
                            :n_line
     end
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 34ae5ab..b54af9e 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -173,32 +173,13 @@ module ProTacts
                     input(type: "text", name: "nickname", value: @own.nickname,
                           placeholder: @own.nickname ? "removed on save" : nil)
                   end
-                  # A phone row edits its value and nothing else: the
-                  # TYPE parameters ride in the line's own header,
-                  # which the save keeps (VCard.header_of) — a header
-                  # rebuilt from form fields would drop the parameters
-                  # no field models, and macOS writes three TYPE
-                  # parameters on one TEL. The digest in the field's
-                  # name is the row's address, and a blank value
-                  # removes the line. Identical duplicate lines share a
-                  # digest and therefore a field name, and Rack keeps
-                  # the last value of a duplicated name — editing one
-                  # of a pair of byte-identical rows means blanking
-                  # one, saving, then editing the other.
                   @own.phones.each do |phone|
-                    label(class: "field", data: {blank_removes: true}) do
-                      span { Format.type_label(phone.label, phone.types, "phone") }
-                      input(type: "tel", name: "phone[#{phone.line.digest}]",
-                            value: phone.value, placeholder: "removed on save")
-                    end
+                    value_row(Format.type_label(phone.label, phone.types, "phone"),
+                              type: "tel", name: "phone[#{phone.line.digest}]", value: phone.value)
                   end
-                  # An email row, the phone row's own shape over EMAIL.
                   @own.emails.each do |email|
-                    label(class: "field", data: {blank_removes: true}) do
-                      span { Format.type_label(nil, email.types, "email") }
-                      input(type: "email", name: "email[#{email.line.digest}]",
-                            value: email.value, placeholder: "removed on save")
-                    end
+                    value_row(Format.type_label(nil, email.types, "email"),
+                              type: "email", name: "email[#{email.line.digest}]", value: email.value)
                   end
                   @own.addresses.each do
                     address_row(it)
@@ -241,7 +222,7 @@ module ProTacts
       # scaffold docs/DESIGN.md refuses, and a trailing one left over
       # from a row already filled is the same scaffold arriving late.
       # A blank added row is a no-op rather than a removal
-      # (CardForm.edited_phones), so these rows wear no removal state —
+      # (CardForm.edited_values), so these rows wear no removal state —
       # the class comment's exemption.
       # That verb — make a row, now, without a round trip — is what
       # CSS could not do and what Alpine is here for (see Layout).
@@ -301,6 +282,26 @@ module ProTacts
         end
       end
 
+      # A standing single-value row — a phone, an email — editing its
+      # value and nothing else: the TYPE parameters ride in the line's
+      # own header, which the save keeps (VCard.header_of), where a
+      # header rebuilt from form fields would drop the parameters no
+      # field models, and macOS writes three TYPE parameters on one
+      # TEL. The digest in the field's name is the row's address, and
+      # a blank value removes the line — the state is unconditional,
+      # a row standing only over a line there is to lose. Identical
+      # duplicate lines share a digest and therefore a field name, and
+      # Rack keeps the last value of a duplicated name — editing one of
+      # a pair of byte-identical rows means blanking one, saving, then
+      # editing the other.
+      #: (String caption, type: String, name: String, value: String) -> void
+      def value_row(caption, type:, name:, value:)
+        label(class: "field", data: {blank_removes: true}) do
+          span { caption }
+          input(type:, name:, value:, placeholder: "removed on save")
+        end
+      end
+
       # The one row that saves to the model rather than the card's
       # bytes, so the prefill is Contact#birthday and not a reading
       # off the card — why, and why every shape the grammar admits is
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index e17dcc6..764f771 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -238,7 +238,7 @@ module ProTacts
     # repeats into one issue.
     #: () -> Photo?
     def photo
-      property = properties.find { it.name.casecmp?("PHOTO") }
+      property = vcard.property("PHOTO")
       return if property.nil?
 
       begin
@@ -268,7 +268,7 @@ module ProTacts
     # FN's value (RFC 2426 section 3.1.1), in text form.
     #: () -> String?
     def name
-      text_of(properties.find { it.name.casecmp?("FN") })
+      text_of(vcard.property("FN"))
     end
 
     # N's components (RFC 2426 section 3.1.2: family; given; additional;
@@ -278,7 +278,7 @@ module ProTacts
     # free-text FN.
     #: () -> Array[String?]?
     def name_components
-      property = properties.find { it.name.casecmp?("N") }
+      property = vcard.property("N")
       property && components_of(property)
     end
 
@@ -286,7 +286,7 @@ module ProTacts
     # whole value, as the card spells it, when it comma-lists several.
     #: () -> String?
     def nickname
-      text_of(properties.find { it.name.casecmp?("NICKNAME") })
+      text_of(vcard.property("NICKNAME"))
     end
 
     #: () -> Array[Phone]
@@ -300,7 +300,7 @@ module ProTacts
         Phone.new(
           value:,
           label: group && labels[group],
-          types: types_of(property, except: %w[voice]),
+          types: property.types - %w[voice],
           line:,
         ) if value
       }
@@ -312,7 +312,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: types_of(property, except: %w[internet]), line:) if value
+        Email.new(value:, types: property.types - %w[internet], line:) if value
       }
     end
 
@@ -437,20 +437,8 @@ module ProTacts
       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: types_of(property), line:)
+      Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, types: property.types, line:)
     end
 
-    # Every TYPE value (RFC 2426 section 3.3.1) that names a kind of
-    # line. `pref` ranks a line instead, and the client adds it on its
-    # own (docs/apple-contacts.md, "The client rewrites every card it
-    # touches"); `except` holds a property's own values that name no
-    # kind either. Downcased: the spelling is the card's, and a screen
-    # shows one.
-    #: (VCard::Parser::Property property, ?except: Array[String]) -> Array[String]
-    def types_of(property, except: [])
-      property.parameters
-        .filter_map { |name, value| value.downcase if name.casecmp?("TYPE") }
-        .uniq - ["pref", *except]
-    end
   end
 end
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index 8139ae6..a7b2485 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -313,7 +313,8 @@ module ProTacts
       # `item2.` are one spelling, since the number counts a card's
       # groups rather than saying anything about the line, and a
       # trailing `type=pref` is left off, ranking a line rather than
-      # naming a kind (Contact#types_of drops it for the same reason).
+      # naming a kind (VCard::Parser::Property#types drops it for the
+      # same reason).
       FORMS = {
         "FN" => ["FN"],
         "N" => ["N"],
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 9cf96d0..7b8f34a 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -348,15 +348,12 @@ module ProTacts
     end
 
     # `sole` rather than `first`: the id is the primary key, so a second
-    # row is a corruption and not a choice to make quietly. It raises on
-    # no row too, which here is the ordinary answer for an href nobody
-    # has — the 404 path — so that one is caught and turned back into
-    # nil.
+    # row is a corruption and not a choice to make quietly. No row is
+    # the ordinary answer for an href nobody has — the 404 path.
     #: (String id) -> Contact?
     def contact(id)
-      contact_from(cards.where(id:).sole, birthday_of(id), inherited_of(id))
-    rescue Sequel::NoMatchingRow
-      nil
+      row = cards.where(id:).sole_or_nil
+      row && contact_from(row, birthday_of(id), inherited_of(id))
     end
 
     # The groups a contact belongs to, whole — a tag names one and
@@ -401,9 +398,8 @@ module ProTacts
     # is set either way.
     #: (String login) -> String
     def book_name(login)
-      books.where(login:).sole.fetch(:name).to_s
-    rescue Sequel::NoMatchingRow
-      login
+      row = books.where(login:).sole_or_nil
+      row ? row.fetch(:name).to_s : login
     end
 
     # Sets the name a login's book goes by, or with nil or a blank goes
@@ -436,9 +432,8 @@ module ProTacts
     # cards sharing a UID is a corruption to raise on, not a choice.
     #: (String uid) -> String?
     def card_id_with_uid(uid)
-      card_properties.where(name: "UID", value: uid).sole.fetch(:card_id).to_s
-    rescue Sequel::NoMatchingRow
-      nil
+      row = card_properties.where(name: "UID", value: uid).sole_or_nil
+      row && row.fetch(:card_id).to_s
     end
 
     # Stores a card and everything that has to move with it: the
@@ -452,7 +447,7 @@ module ProTacts
     # it back in (docs/plans/2026-08-31-partial-birthdays.md).
     #
     # A card rather than its bytes, so the reading a caller already
-    # made is the one the split below decides from: a PUT has asked
+    # made is the one #split_birthday decides from: a PUT has asked
     # whether the bytes are a card at all and whose UID they carry
     # before it gets here (Web#write_card), and taking the card it
     # asked those of leaves the walk behind them made once.
@@ -473,43 +468,14 @@ module ProTacts
     # A rewrite of a card that exists joins nothing.
     #: (String id, VCard vcard, ?client: bool) -> Contact
     def put(id, vcard, client: false)
-      # The birthday half of the split a write makes
-      # (docs/plans/2026-09-11-every-birthday-in-the-model.md). A card
-      # holds at most one BDAY, and one that reads as a birthday leaves
-      # the card for the model. Any other stays in the card byte for
-      # byte, reported, with the model emptied so nothing composes a
-      # second BDAY beside it (RFC 6352 section 6.3.2.2).
       existing = birthday_of(id)
-      # The card as it stands before this write, read once for the two
-      # halves that need it: the no-BDAY arm below, which reports what
-      # it drops, and the subtraction after it, which accounts for the
-      # member's own lines before attributing any to a group.
+      # The card as it stands before this write, read once for the
+      # three things that want it: the birthday split, which reports
+      # what a rewrite drops; the subtraction after it, which accounts
+      # for the member's own lines before attributing any to a group;
+      # and the card the change log records this write as replacing.
       own = stored_card(id)
-      bdays, rest = vcard.extract("BDAY")
-      birthday, stored =
-        if bdays.empty?
-          # No BDAY: a client that was sent the birthday removed it, and
-          # one that was never sent it cannot have. A BDAY line left in
-          # the stored card goes with the rewrite, which is worth saying.
-          report_lost_bday_lines(own)
-          [existing && !existing.served? ? existing : nil, vcard]
-        elsif bdays.length > 1
-          report_many_bday_lines(bdays.length)
-          [nil, vcard]
-        else
-          line = BirthdayLine.read(bdays.fetch(0))
-          case line
-          when BirthdayLine::Modeled
-            [line.birthday, rest]
-          when BirthdayLine::Unrecognized
-            report_unrecognized_bday_line
-            [nil, vcard]
-          when BirthdayLine::Unreadable
-            [nil, vcard]
-          else
-            raise "no arm for #{line.class}"
-          end
-        end
+      birthday, stored = split_birthday(vcard, existing, own)
 
       # The other half of the split, the same shape as the birthday's:
       # what the groups lend comes back out of the submission before it
@@ -544,9 +510,7 @@ module ProTacts
           fan_out(member_ids(edits.map(&:group_id).uniq), except: id) { apply_group_edits(edits) }
           contact = Contact.new(id:, stored:, birthday:, inherited: inheritance(lent_of(id)))
         end
-        cards
-          .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
-          .insert(id: contact.id, vcard: stored.to_s)
+        upsert_card(contact.id, stored)
         # After the card, which the membership names by foreign key, and
         # before the log entry, which records the card composed with
         # whatever the group lends. The put's entry is the card's arrival
@@ -577,9 +541,7 @@ module ProTacts
       before = contact(id)
       contact = Contact.new(id:, stored: vcard, birthday:, inherited: inherited_of(id))
       @database.transaction do
-        cards
-          .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
-          .insert(id: contact.id, vcard: vcard.to_s)
+        upsert_card(contact.id, vcard)
         write_birthday(contact.id, birthday)
         record(contact.id, "edit", contact.etag, CardDiff.between(before&.vcard, contact.vcard))
         reindex(contact.id, vcard)
@@ -705,9 +667,8 @@ module ProTacts
     # #contact's own shape.
     #: (String id) -> Group?
     def group(id)
-      load_groups([groups.select(*GROUP_COLUMNS, group_label.as(:label)).where(id:).sole]).fetch(0)
-    rescue Sequel::NoMatchingRow
-      nil
+      row = groups.select(*GROUP_COLUMNS, group_label.as(:label)).where(id:).sole_or_nil
+      row && load_groups([row]).fetch(0)
     end
 
     # A group's name, or none — NULL being the one spelling of
@@ -801,7 +762,10 @@ module ProTacts
     # the ordinary miss, #delete's shape.
     #: (String id) -> bool
     def delete_group(id)
-      name = groups.where(id:).sole.fetch(:name).to_s
+      row = groups.where(id:).sole_or_nil
+      return false if row.nil?
+
+      name = row.fetch(:name).to_s
       # Refused before the transaction, #name_book's reason for the
       # same refusal: Sequel wraps what a rollback raises.
       raise EveryonesBookName, "#{EVERYONE} is everyone's book, not a group to delete" if name == EVERYONE
@@ -813,8 +777,6 @@ module ProTacts
         }
         true
       end
-    rescue Sequel::NoMatchingRow
-      false
     end
 
     # A card's side of #edit_group: one transaction, leavers first and
@@ -915,6 +877,16 @@ module ProTacts
       @database[:group_properties]
     end
 
+    # A card written where one may already stand. The stamp is set
+    # again on the way past because SQLite has no ON UPDATE and the
+    # column default only fires on insert (NOW).
+    #: (String id, VCard vcard) -> void
+    def upsert_card(id, vcard)
+      cards
+        .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
+        .insert(id:, vcard: vcard.to_s)
+    end
+
     # The diff comes in already computed rather than being taken here
     # off a `before` and an `after`: only the caller knows which two
     # cards its write was between, and a delete's `after` is nothing at
@@ -1001,14 +973,13 @@ module ProTacts
       }
     end
 
-    # The id of the `sync:*` group, created on first use. `sole` because
-    # a group's name is unique (db/migrations/008_group_names.rb), and
-    # no row is the ordinary answer for the first create.
+    # The id of the `sync:*` group, created on first use. One row
+    # because a group's name is unique
+    # (db/migrations/008_group_names.rb).
     #: () -> String
     def everyone_group_id
-      groups.where(name: EVERYONE).sole.fetch(:id).to_s
-    rescue Sequel::NoMatchingRow
-      create_group(name: EVERYONE)
+      row = groups.where(name: EVERYONE).sole_or_nil
+      row ? row.fetch(:id).to_s : create_group(name: EVERYONE)
     end
 
     # The group name that puts cards in this login's book alone.
@@ -1090,6 +1061,48 @@ module ProTacts
       Sequel::Migrator.run(@database, MIGRATIONS.to_s)
     end
 
+    # The birthday half of the split a write makes
+    # (docs/plans/2026-09-11-every-birthday-in-the-model.md): the
+    # model's new birthday, and the card to store beside it. A card
+    # holds at most one BDAY, and one that reads as a birthday leaves
+    # the card for the model. Any other stays in the card byte for
+    # byte, reported, with the model emptied so nothing composes a
+    # second BDAY beside it (RFC 6352 section 6.3.2.2).
+    #
+    # No BDAY is a client that was sent the birthday removing it, or
+    # one that was never sent it and so cannot have. A BDAY line left
+    # in the stored card goes with the rewrite, which is worth saying.
+    #
+    # case/when rather than case/in, BirthdayLine's own rule: Steep
+    # does not check the bodies of case/in branches, and these are the
+    # ones worth checking — which is what the last arm is for.
+    #: (VCard vcard, Birthday? existing, VCard? own) -> [Birthday?, VCard]
+    def split_birthday(vcard, existing, own)
+      bdays, rest = vcard.extract("BDAY")
+      if bdays.empty?
+        report_lost_bday_lines(own)
+        return [existing && !existing.served? ? existing : nil, vcard]
+      end
+
+      if bdays.length > 1
+        report_many_bday_lines(bdays.length)
+        return [nil, vcard]
+      end
+
+      line = BirthdayLine.read(bdays.fetch(0))
+      case line
+      when BirthdayLine::Modeled
+        [line.birthday, rest]
+      when BirthdayLine::Unrecognized
+        report_unrecognized_bday_line
+        [nil, vcard]
+      when BirthdayLine::Unreadable
+        [nil, vcard]
+      else
+        raise "no arm for #{line.class}"
+      end
+    end
+
     # The card currently stored for an id, or nil for a card being
     # created. A plain `first` read: the primary key leaves `sole`
     # nothing to catch, and no row is the ordinary answer.
@@ -1211,7 +1224,7 @@ module ProTacts
       return :unshareable unless shareable?(edited)
 
       taken << strike(unaccounted, edited)
-      GroupEdit.new(group_id: row.group_id, position: row.position, line: content_line(edited))
+      GroupEdit.new(group_id: row.group_id, position: row.position, line: edited.content)
     end
 
     # The line out of the pool, so no second lent line is attributed
@@ -1225,18 +1238,6 @@ module ProTacts
       line
     end
 
-    # A line as a group holds one: unfolded and shorn of its
-    # terminator, the same unit CardDiff records a write in and the
-    # shape db/migrations/004_groups.rb's own rows are written in.
-    # The bytes are the client's, not a re-render under the group's old
-    # header — a relabel is one of the edits this carries, and
-    # rebuilding the header would propagate the value and drop the
-    # label (the plan's "What a propagated edit stores").
-    #: (VCard::Parser::Line line) -> String
-    def content_line(line)
-      VCard::Parser.unfold(line.verbatim).sub(/(\r\n|[\r\n])\z/, "")
-    end
-
     # Whether a group could hold this line: one of the names
     # SHAREABLE_NAMES admits, and no property group in front of it —
     # the CHECK's two clauses, read off the parse rather than matched
@@ -1333,22 +1334,17 @@ module ProTacts
       STRUCTURED_VALUES.include?(property.name.upcase) ? property.components : [property.text]
     end
 
-    # A line's TYPE values, as the set the round trip preserves:
-    # casefolded, `pref` dropped as the client's own addition, and
-    # sorted because `TYPE=home;TYPE=pref` and `TYPE=pref,home` are one
-    # thing (RFC 2426 section 3.2.1, which the parser reads into pairs
-    # either way). Empty for a line that would not read, which has no
+    # A line's TYPE values as the set the round trip preserves, sorted
+    # because `TYPE=home;TYPE=pref` and `TYPE=pref,home` are one thing
+    # (RFC 2426 section 3.2.1, which the parser reads into pairs either
+    # way). Empty for a line that would not read, which has no
     # parameters to compare.
     #: (VCard::Parser::Line line) -> Array[String]
     def types_of(line)
       property = line.property
       return [] if property.nil?
 
-      property.parameters
-        .filter_map { |name, value| value.downcase if name.casecmp?("TYPE") }
-        .reject { it == "pref" }
-        .uniq
-        .sort
+      property.types.sort
     end
 
     # The submission's lines that the member's own stored card does not
@@ -1383,8 +1379,7 @@ module ProTacts
     # attribute — two lines of that name arrived that the member's card
     # does not explain, and calling either the edit would be a guess.
     # The card is stored as it arrived and the line is news, the same
-    # bargain report_unrecognized_bday_line makes, and carries no card
-    # content for the same reason (config.ru).
+    # bargain report_unrecognized_bday_line makes.
     #: (Integer count) -> void
     def report_ambiguous_inherited_lines(count)
       return if count.zero?
@@ -1402,8 +1397,7 @@ module ProTacts
     # 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.
+    # copy and the group's — until someone reconciles them.
     #: (Integer count) -> void
     def report_unshareable_lines(count)
       return if count.zero?
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index d648b9a..64a953c 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -144,7 +144,15 @@ module ProTacts
     # already assumes for them.
     #: () -> String?
     def uid
-      properties.find { it.name.casecmp?("UID") }&.value
+      property("UID")&.value
+    end
+
+    # The first property naming `name`, or nil where the card carries
+    # none — the read every caller after a property a card holds one of
+    # makes. Names match without case (RFC 2426 section 4).
+    #: (String name) -> Parser::Property?
+    def property(name)
+      properties.find { it.name.casecmp?(name) }
     end
 
     # The lines naming `name`, and the card without them: the split
@@ -189,15 +197,10 @@ module ProTacts
       first = all.index { it.names?(name) }
       return insert(lines) if first.nil?
 
-      # The replaced line's own terminator, so a card written in one
-      # line-break convention stays single-convention; a terminated
-      # replacement line keeps its own, folds and all.
-      terminator = terminator_of(all.fetch(first).verbatim)
-      replacements = lines.map { it.end_with?("\n") ? it : it + terminator }
       kept = all.reject { it.names?(name) }.map(&:verbatim)
       # `first` still names the splice point: the lines before it are
       # untouched, and every line it counted past was a named one.
-      kept.insert(first, *replacements)
+      kept.insert(first, *terminate(lines, all.fetch(first).verbatim))
       VCard.new(kept.join)
     end
 
@@ -210,28 +213,23 @@ module ProTacts
     # the snapshot guard makes a miss news, not an ordinary case. A
     # card carrying the same line twice is addressed as one row by
     # both copies (they hash alike), and this substitutes the first;
-    # the digests cannot tell identical bytes apart. A replacement
-    # line with no terminator of its own takes the substituted line's,
-    # #replace's own convention rule.
+    # the digests cannot tell identical bytes apart.
     #: (String digest, Array[String] lines) -> VCard
     def substitute(digest, lines)
       all = self.lines
       index = all.index { it.digest == digest }
       raise KeyError, "no line of this card hashes to #{digest}" if index.nil?
 
-      terminator = terminator_of(all.fetch(index).verbatim)
-      replacements = lines.map { it.end_with?("\n") ? it : it + terminator }
+      replacements = terminate(lines, all.fetch(index).verbatim)
       kept = all.map(&:verbatim)
       kept.delete_at(index)
       kept.insert(index, *replacements)
       VCard.new(kept.join)
     end
 
-    # Lines into the card immediately before END:VCARD. A line with no
-    # terminator of its own takes the END line's, so a card stays
-    # single-convention; a terminated line keeps its own, folds and
-    # all. With no END to anchor to there is no envelope worth
-    # respecting, and the lines are appended with CRLF.
+    # Lines into the card immediately before END:VCARD. With no END to
+    # anchor to there is no envelope worth respecting, and the lines
+    # are appended to the end.
     #
     # A card back, like #extract's remainder: text with text put into it
     # is still text, so there is nothing here for a caller to re-read or
@@ -240,31 +238,25 @@ module ProTacts
     def insert(lines)
       return self if lines.empty?
 
-      physical = physical_lines
+      physical = Parser.physical_lines(@bytes)
       index = physical.rindex { it.match?(END_LINE) }
-      if index
-        terminator = terminator_of(physical.fetch(index))
-        physical.insert(index, *lines.map { it.end_with?("\n") ? it : it + terminator })
-        VCard.new(physical.join)
-      else
-        VCard.new(@bytes + lines.map { it.end_with?("\n") ? it : it + "\r\n" }.join)
-      end
+      return VCard.new(@bytes + terminate(lines).join) if index.nil?
+
+      physical.insert(index, *terminate(lines, physical.fetch(index)))
+      VCard.new(physical.join)
     end
 
     private
 
-    # Physical lines with their terminators attached, so surgery on them
-    # cannot lose or normalize a line break.
-    #: () -> Array[String]
-    def physical_lines
-      @bytes.split(/(?<=\n)/, -1)
-    end
-
-    # The line break a line ends with, for the line inserted beside it
-    # to match. CRLF when it has none, being the grammar's own.
-    #: (String line) -> String
-    def terminator_of(line)
-      line[/\r?\n\z/] || "\r\n"
+    # The given lines, each with a terminator on it that has none,
+    # taken from the line it lands beside — so a card written in one
+    # line-break convention stays single-convention. A line that came
+    # with its own keeps it, folds and all, and with no neighbour to
+    # match the terminator is CRLF, the grammar's own.
+    #: (Array[String] lines, ?String neighbour) -> Array[String]
+    def terminate(lines, neighbour = "\r\n")
+      terminator = neighbour[/\r?\n\z/] || "\r\n"
+      lines.map { it.end_with?("\n") ? it : it + terminator }
     end
   end
 end
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 9c75bf1..22f7e1e 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -99,6 +99,19 @@ module ProTacts
             .filter_map { |parameter, value| value if parameter.casecmp?(name) }
             .first
         end
+
+        # The TYPE values naming a kind of line (RFC 2426 section
+        # 3.3.1), casefolded because the spelling is the card's and
+        # every reader wants one. `pref` is not one of them: it ranks a
+        # line rather than naming a kind, and the client adds it on its
+        # own (docs/apple-contacts.md, "The client rewrites every card
+        # it touches").
+        #: () -> Array[String]
+        def types
+          parameters
+            .filter_map { |name, value| value.downcase if name.casecmp?("TYPE") }
+            .uniq - ["pref"]
+        end
       end
 
       # One logical line of a card, parsed beside the exact bytes that
@@ -150,6 +163,18 @@ module ProTacts
         # the one pair of lines no address can tell apart.
         #: () -> String
         def digest = Digest::SHA256.hexdigest(verbatim)
+
+        # The line as a group holds one: unfolded and shorn of its
+        # terminator, the unit CardDiff records a write in and the
+        # shape db/migrations/004_groups.rb's own rows are written in.
+        # The bytes are the ones that arrived, not a re-render under an
+        # old header — a relabel is one of the edits a member's save
+        # carries, and rebuilding the header would propagate the value
+        # and drop the label
+        # (docs/plans/2026-09-09-group-edits-propagate.md, "What a
+        # propagated edit stores").
+        #: () -> String
+        def content = Parser.unfold(verbatim).sub(/#{LINE_BREAK}\z/, "")
       end
 
       # The content line, RFC 2426 section 4:
@@ -242,14 +267,15 @@ module ProTacts
           .reject { it == "" }
       end
 
-      # Physical lines with their terminators attached, so the split
-      # cannot lose or normalize a line break.
+      # Physical lines with their terminators attached, so no split of
+      # a card can lose or normalize a line break. Public for VCard,
+      # whose surgery on a card's bytes wants the same split.
       #: (String card) -> Array[String]
       def self.physical_lines(card)
         card.split(/(?<=\n)/, -1)
       end
 
-      private_class_method :line_of, :logical_lines, :physical_lines
+      private_class_method :line_of, :logical_lines
 
       #: (String logical_line) -> void
       def initialize(logical_line)
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index 1cd17b9..ff18c45 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -255,7 +255,8 @@ module ProTacts
         # Read on its own rather than through the collection: serving
         # one href has no reason to load every other contact first.
         r.get String do |filename|
-          contact = member(filename.delete_suffix(".vcf"))
+          id = member_id(filename)
+          contact = id && member(id)
 
           if contact
             response["Content-Type"] = "text/vcard; charset=utf-8"
@@ -273,19 +274,16 @@ module ProTacts
         # carrying the strong etag it was last served — see
         # docs/apple-contacts.md, "What a write looks like on the wire".
         r.put String do |filename|
-          id = filename.delete_suffix(".vcf")
-          # A last segment that is not this server's <id>.vcf shape
-          # cannot address a resource here, created or read — the
-          # same fall-through-to-404 the GET handler gives it.
-          write_card(id) if id.match?(Contact::ID_FORMAT)
+          id = member_id(filename)
+          write_card(id) if id
         end
 
         # DELETE removes the card at the member URI (RFC 4918 section
         # 9.6) — the DAV:unbind privilege the collection advertises,
         # and what iOS sends when a contact is deleted on the phone.
         r.delete String do |filename|
-          id = filename.delete_suffix(".vcf")
-          remove_card(id) if id.match?(Contact::ID_FORMAT)
+          id = member_id(filename)
+          remove_card(id) if id
         end
       end
     end
@@ -333,6 +331,16 @@ module ProTacts
       store.contact(id) if book.include?(id)
     end
 
+    # The contact id a member URI's last segment names, or nil for a
+    # segment that is not this server's <id>.vcf shape and so addresses
+    # no resource here, created or read — every route under the
+    # collection falling through to the same 404.
+    #: (String filename) -> String?
+    def member_id(filename)
+      id = filename.delete_suffix(".vcf")
+      id if id.match?(Contact::ID_FORMAT)
+    end
+
     #: () -> String
     def ctag
       @ctag ||= store.ctag
@@ -419,10 +427,12 @@ module ProTacts
       # macOS sends If-Match on updates and If-None-Match: * on creates;
       # a PUT carrying neither is unconditional and allowed to proceed.
       if_match = request.env["HTTP_IF_MATCH"]
-      return plain_412 if if_match && !if_match_satisfied?(if_match, existing)
+      return plain_412 if if_match && !etag_matches?(if_match, existing)
 
+      # The create — If-None-Match: * against an unmapped URI — is the
+      # case CardDAV clients send (RFC 6352 section 6.3.2).
       if_none_match = request.env["HTTP_IF_NONE_MATCH"]
-      return plain_412 if if_none_match && if_none_match_failed?(if_none_match, existing)
+      return plain_412 if if_none_match && etag_matches?(if_none_match, existing)
 
       report_unreadable_lines(vcard)
       report_broken_assumptions(vcard)
@@ -442,9 +452,7 @@ module ProTacts
       # it sent, and the client refetches.
       response["ETag"] = stored.etag if stored.vcard.to_s == vcard.to_s
 
-      # A returned "" would land in the body and pin text/html and
-      # content-length onto the 204, which a bodyless status must not
-      # carry (Rack 3's lint rejects both); nil leaves it bodyless.
+      # Bodyless either way, #no_content's reason.
       nil
     end
 
@@ -469,15 +477,21 @@ module ProTacts
       # because ignoring the header is the lost update it exists to
       # refuse, and the PUT's own check is right here to reuse.
       if_match = request.env["HTTP_IF_MATCH"]
-      return plain_412 if if_match && !if_match_satisfied?(if_match, existing)
+      return plain_412 if if_match && !etag_matches?(if_match, existing)
 
       # The change-log entry Store#delete leaves in the same transaction
       # is what a syncing client is told: sync-collection answers a
       # removed member as href plus 404 (DavXml::DAV#missing).
       store.delete(id)
-      response.status = 204
+      no_content
+    end
 
-      # Bodyless for the 204, write_card's reason.
+    # A 204, bodyless: a returned "" would land in the body and pin
+    # text/html and content-length onto a status that must carry
+    # neither (Rack 3's lint rejects both), where nil leaves it empty.
+    #: () -> nil
+    def no_content
+      response.status = 204
       nil
     end
 
@@ -547,22 +561,13 @@ module ProTacts
       ""
     end
 
-    # RFC 7232 section 3.1: If-Match passes when the current etag is one
-    # of those listed, or, for `*`, when there is a current
-    # representation at all.
-    #: (String header, Contact? existing) -> bool
-    def if_match_satisfied?(header, existing)
-      return !existing.nil? if header.strip == "*"
-
-      !existing.nil? && header.split(",").map(&:strip).include?(existing.etag)
-    end
-
-    # RFC 7232 section 3.2: If-None-Match fails a non-GET request when
-    # the current etag is one of those listed, or, for `*`, whenever the
-    # resource exists. The create — If-None-Match: * against an unmapped
-    # URI — is the case CardDAV clients send (RFC 6352 section 6.3.2).
+    # Whether the current representation is one the header names: an
+    # etag in its list, or, for `*`, any representation at all. The one
+    # question both conditionals ask, each reading the answer its own
+    # way — If-Match passes on a match (RFC 7232 section 3.1) and
+    # If-None-Match fails on one (section 3.2).
     #: (String header, Contact? existing) -> bool
-    def if_none_match_failed?(header, existing)
+    def etag_matches?(header, existing)
       return !existing.nil? if header.strip == "*"
 
       !existing.nil? && header.split(",").map(&:strip).include?(existing.etag)
diff --git a/lib/sequel/extensions/sole.rb b/lib/sequel/extensions/sole.rb
index 0fc9066..7390e05 100644
--- a/lib/sequel/extensions/sole.rb
+++ b/lib/sequel/extensions/sole.rb
@@ -40,6 +40,18 @@ module Sequel
 
       rows.fetch(0)
     end
+
+    # The same read where no row is the ordinary answer — an href
+    # nobody has, a login whose book was never named — so the caller
+    # says so once here rather than spelling the rescue out again.
+    # Only the empty case: more rows than the filter claimed is still
+    # the broken assumption #sole exists to raise on.
+    #: () -> Hash[Symbol, untyped]?
+    def sole_or_nil
+      sole
+    rescue Sequel::NoMatchingRow
+      nil
+    end
   end
 
   Dataset.register_extension(:sole, Sole)