vcard: the card as the post-parsed abstraction
Parsing was scattered — web parse_card, Store properties_of, and
Birthday parse_line, each rescuing ParseError on its own — and the
birthday line surgery lived in the model. VCard now reads the bytes
once and owns every question about either half; Birthday is the
partial date and nothing more; Store keeps the policy.

Assisted-by: GLM-5.3 via pi
change srszmnznmzmqsyznuyvxvosurxuykwts
commit e8e2738d6f265e7a825a851d0416e40ea8fa6486
author Alpha Chen <alpha@kejadlen.dev>
date
parent okrxkszz
diff --git a/db/migrations/002_birthdays.rb b/db/migrations/002_birthdays.rb
index 44fd9a2..617785d 100644
--- a/db/migrations/002_birthdays.rb
+++ b/db/migrations/002_birthdays.rb
@@ -7,6 +7,7 @@
 # which refuse Sequel's plain String. No timestamp: a birthday changes
 # with its card's write and the card's stamp is the write's stamp.
 require "pro_tacts/birthday"
+require "pro_tacts/vcard"
 
 Sequel.migration do
   change do
@@ -35,17 +36,29 @@ Sequel.migration do
     # birthday-carrying card once, by design rather than by accident.
     # The change log is not written: as far as any client can tell, no
     # contact changed, and the stamps are left alone to say the same.
+    # Spelled out against VCard and Birthday directly rather than
+    # through a Store helper, so what this migration does cannot drift
+    # with the app's internals; its test pins the behavior.
     self[:cards].order(:id).each do |row|
-      split = ProTacts::Birthday.subtract(row.fetch(:vcard))
-      next if split.birthday.nil?
+      # The same move a write makes, one arm per shape the stored
+      # card's BDAY lines take — only the modeled single line moves.
+      case ProTacts::VCard.new(row.fetch(:vcard)).partition { it.names?("BDAY") }
+      in [[line], others]
+        property = line.property
+        birthday = property && ProTacts::Birthday.from_property(property)
+        next if birthday.nil?
 
-      self[:cards].where(id: row.fetch(:id)).update(vcard: split.card)
-      self[:birthdays].insert(
-        card_id: row.fetch(:id),
-        year: split.birthday.year,
-        month: split.birthday.month,
-        day: split.birthday.day,
-      )
+        self[:cards].where(id: row.fetch(:id)).update(vcard: others.map(&:verbatim).join)
+        self[:birthdays].insert(
+          card_id: row.fetch(:id),
+          year: birthday.year,
+          month: birthday.month,
+          day: birthday.day,
+        )
+      in [[], _] | [_, _]
+        # Nothing to move, or lines the model cannot recompose: the
+        # card stands as stored.
+      end
     end
   end
 end
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index 9ac906f..4f60289 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -18,9 +18,9 @@ module ProTacts
   # with all three components required, and RFC 2426 section 3.1.5 builds
   # BDAY on it. So a birthday is database state that no stored card
   # carries, composed into the served card on read and subtracted out of
-  # a submitted one on write — the same split a group's shared attributes
-  # take (docs/plans/2026-08-24-vcard-storage-and-groups.md), and the
-  # full reasoning is docs/plans/2026-08-31-partial-birthdays.md.
+  # a submitted one on write — Store owns that surgery, over the line
+  # rules VCard provides and the property-level reading below; the full
+  # reasoning is docs/plans/2026-08-31-partial-birthdays.md.
   #
   # The signature lives in sig/pro_tacts/birthday.rbs: a Data class has
   # no constant super class for the inline syntax to read.
@@ -42,27 +42,6 @@ module ProTacts
     OMIT_YEAR_DATE = /\A1604-(\d{2})-(\d{2})\z/ #: Regexp
     OMIT_YEAR = "1604" #: String
 
-    # A content line naming BDAY, optionally group-prefixed the way
-    # macOS labels properties (`item1.BDAY`). A group's first physical
-    # line is all that is matched; continuations travel with it.
-    BDAY_LINE = /\A(?:[A-Za-z0-9-]+\.)?BDAY[;:]/i #: Regexp
-
-    # The anchor compose inserts before, so the property lands inside
-    # the envelope however bare the card is.
-    END_LINE = /\AEND:VCARD/i #: Regexp
-
-    # A physical line that continues the one above it (RFC 2426 section
-    # 2.6 folding).
-    CONTINUATION = /\A[ \t]/ #: Regexp
-
-    # What subtract found in a card: the card to store, the birthday it
-    # gave up, and whether a BDAY line is still in the card because no
-    # modeled spelling would recompose it — the probe forms ride on
-    # exactly that, served as stored.
-    #
-    # The signature lives in sig/pro_tacts/birthday.rbs with the class's.
-    Split = Data.define(:card, :birthday, :bday_kept)
-
     # The only constructor, so no birthday exists in a shape the grammar
     # rejects: one `in` clause per shape RFC 6350 section 4.3.1 admits,
     # each component's range standing where the component stands, so the
@@ -93,7 +72,7 @@ module ProTacts
     # The two wire forms a client has been verified to accept: a plain
     # full date, and Apple's 1604 sentinel for a month and day. The other
     # four shapes have no verified spelling and reach no client — this
-    # returns nil for them and compose leaves the card alone.
+    # returns nil for them and no card grows a BDAY on their behalf.
     def to_line
       if year && month && day
         format("BDAY:%04d-%02d-%02d", year, month, day)
@@ -112,7 +91,7 @@ module ProTacts
     # Reads one parsed BDAY property into the model, or nil for every
     # spelling the model does not recompose — which keeps it in the card
     # verbatim rather than losing it (RFC 6352 section 6.3.2.2). Only the
-    # two forms compose emits are accepted, and only bare: any extra
+    # two forms #to_line emits are accepted, and only bare: any extra
     # parameter travels with the line, so a line carrying one is left
     # whole. A grouped `item1.BDAY` likewise stays, because its label
     # pairing belongs to the card.
@@ -140,84 +119,5 @@ module ProTacts
       name, value = property.parameters.fetch(0)
       name.casecmp?("X-APPLE-OMIT-YEAR") && value == OMIT_YEAR
     end
-
-    # Splits a submitted card into what gets stored and the birthday it
-    # carried. One BDAY line in a modeled spelling moves into the model
-    # and out of the card; any other BDAY — the vCard 4.0 forms, a
-    # foreign sentinel, more than one — is data this model cannot
-    # recompose and stays in the card byte for byte, with the
-    # model emptied so compose never adds a second BDAY beside it.
-    def self.subtract(card)
-      # Bytes that are not the UTF-8 the store's contract promises are
-      # never parsed — they pass through untouched to the bind that
-      # refuses them, which is where the contract is enforced.
-      unless card.valid_encoding?
-        return Split.new(card:, birthday: nil, bday_kept: false)
-      end
-
-      bday_groups, other_groups = line_groups(card).partition { it.first.match?(BDAY_LINE) }
-
-      birthday = nil
-      if bday_groups.length == 1
-        property = parse_line(bday_groups.fetch(0).join)
-        birthday = property && from_property(property)
-      end
-
-      extracted = !birthday.nil?
-      Split.new(
-        card: extracted ? other_groups.join : card,
-        birthday:,
-        bday_kept: !extracted && !bday_groups.empty?,
-      )
-    end
-
-    # Puts a birthday into a stored card, immediately before END:VCARD,
-    # as the inverse of subtract. A birthday with no wire form, or none
-    # at all, leaves the card exactly as it is. The inserted line takes
-    # the END line's own terminator so a card stays single-convention;
-    # with no END to anchor to there is no envelope worth respecting, and
-    # the line is appended with CRLF.
-    def self.compose(card, birthday)
-      line = birthday && birthday.to_line
-      return card if line.nil?
-
-      lines = physical_lines(card)
-      index = lines.rindex { it.match?(END_LINE) }
-      if index
-        lines.insert(index, line + terminator_of(lines.fetch(index)))
-        lines.join
-      else
-        card + line + "\r\n"
-      end
-    end
-
-    # One content line read as a property, or nil when it does not read
-    # as one. The parser unfolds what it is handed, so a folded BDAY
-    # arrives here as the single logical line it is.
-    def self.parse_line(text)
-      VCard::Parser.parse(text).fetch(0, nil)
-    rescue VCard::ParseError
-      nil
-    end
-
-    # Physical lines with their terminators attached, so surgery on them
-    # cannot lose or normalize a line break.
-    def self.physical_lines(card)
-      card.split(/(?<=\n)/, -1)
-    end
-
-    # Logical lines: a physical line and the continuations folded under
-    # it, kept together because a BDAY and its fold are one property.
-    # Slicing before each non-continuation, rather than chunking, is
-    # what attaches a continuation to the line above it.
-    def self.line_groups(card)
-      physical_lines(card).slice_when { |_line, next_line| !next_line.match?(CONTINUATION) }.to_a
-    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.
-    def self.terminator_of(line)
-      line[/\r?\n\z/] || "\r\n"
-    end
   end
 end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 5ab9674..2d64d98 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -95,10 +95,12 @@ module ProTacts
     # The card's properties, parsed once and memoized — the substrate
     # the typed accessors sit on, and the read for what none of them
     # models. Lets VCard::ParseError raise rather than returning an
-    # empty card: PUT checks VCard.card? before the store accepts a
-    # card, so one reaching this unparseable is a bug or a corrupt row,
-    # and Sentry (already wired into every request, see ProTacts::Web)
-    # is where that should surface, not a silently blank screen.
+    # empty card: PUT checks the card's envelope before the store
+    # accepts it, so one reaching this unparseable is a bug or a
+    # corrupt row, and Sentry (already wired into every request, see
+    # ProTacts::Web) is where that should surface, not a silently
+    # blank screen. VCard.new tolerates the same bytes for the paths
+    # that serve them; this model of a served contact does not.
     #: () -> Array[VCard::Property]
     def properties
       return @properties if defined?(@properties)
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 45ae0f6..7bac3f6 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -207,36 +207,42 @@ module ProTacts
     # where it is read, in write_card.
     #: (String id, String vcard) -> Contact
     def put(id, vcard)
-      split = Birthday.subtract(vcard)
+      # The birthday half of the split a write makes, one arm per shape
+      # a submitted card's BDAY lines can take. One line that reads as
+      # a modeled birthday moves out of the card and into the model;
+      # any other BDAY — the vCard 4.0 forms, a foreign sentinel, a
+      # line that will not parse, more than one — is data the model
+      # cannot recompose and stays in the card byte for byte, with the
+      # model emptied so nothing composes a second BDAY beside it
+      # (RFC 6352 section 6.3.2.2). No BDAY at all is nothing to split:
+      # the birthday to carry is whatever an unseen model row already
+      # holds, and a served one was the user's deletion. Bytes that are
+      # not UTF-8 raise at the card before any of this runs — a PUT
+      # has already answered them with a 412 by then.
       existing = birthday_of(id)
-      birthday =
-        if split.bday_kept
-          nil
-        elsif split.birthday
-          split.birthday
-        elsif existing && !existing.served?
-          existing
-        else
-          nil
+      birthday, stored =
+        case VCard.new(vcard).partition { it.names?("BDAY") }
+        in [[line], others]
+          property = line.property
+          birthday = property && Birthday.from_property(property)
+          [birthday, birthday ? others.map(&:verbatim).join : vcard]
+        in [[], _]
+          [existing && !existing.served? ? existing : nil, vcard]
+        in [_, _]
+          [nil, vcard]
         end
 
-      contact = Contact.for(id:, vcard: Birthday.compose(split.card, birthday))
+      # The Contact this returns is the composed one, and the logged
+      # etag is its hash, so a client's token describes the card it
+      # downloads.
+      contact = Contact.for(id:, vcard: with_birthday(stored, birthday))
       @database.transaction do
         cards
           .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
-          .insert(id: contact.id, vcard: split.card)
-        if birthday
-          birthdays
-            .insert_conflict(
-              target: :card_id,
-              update: {year: Sequel[:excluded][:year], month: Sequel[:excluded][:month], day: Sequel[:excluded][:day]},
-            )
-            .insert(card_id: contact.id, year: birthday.year, month: birthday.month, day: birthday.day)
-        else
-          birthdays.where(card_id: contact.id).delete
-        end
+          .insert(id: contact.id, vcard: stored)
+        write_birthday(contact.id, birthday)
         record(contact.id, "put", contact.etag)
-        reindex(contact.id, split.card)
+        reindex(contact.id, stored)
       end
       contact
     end
@@ -343,9 +349,7 @@ module ProTacts
     # A card's properties, or nil when the bytes are not a vCard.
     #: (String vcard) -> Array[VCard::Property]?
     def properties_of(vcard)
-      VCard::Parser.parse(vcard)
-    rescue VCard::ParseError
-      nil
+      VCard.new(vcard).properties
     end
 
     # Applies whatever migrations the database has not seen. On one it is
@@ -365,10 +369,39 @@ module ProTacts
     def contact_from(row, birthday)
       Contact.for(
         id: row.fetch(:id).to_s,
-        vcard: Birthday.compose(row.fetch(:vcard).to_s, birthday),
+        vcard: with_birthday(row.fetch(:vcard).to_s, birthday),
       )
     end
 
+    # The card to serve: the stored card with its birthday composed
+    # back in, immediately before END:VCARD. A birthday with no wire
+    # form, or none at all, leaves the card exactly as it is.
+    #: (String card, Birthday? birthday) -> String
+    def with_birthday(card, birthday)
+      line = birthday && birthday.to_line
+      return card if line.nil?
+
+      VCard.new(card).insert([line])
+    end
+
+    # The model's half of a write: hold the birthday the card gave up,
+    # or empty the model — a BDAY that stayed in the card must not grow
+    # a composed twin beside it, and a deletion must take the row with
+    # it.
+    #: (String id, Birthday? birthday) -> void
+    def write_birthday(id, birthday)
+      if birthday
+        birthdays
+          .insert_conflict(
+            target: :card_id,
+            update: {year: Sequel[:excluded][:year], month: Sequel[:excluded][:month], day: Sequel[:excluded][:day]},
+          )
+          .insert(card_id: id, year: birthday.year, month: birthday.month, day: birthday.day)
+      else
+        birthdays.where(card_id: id).delete
+      end
+    end
+
     # Every birthday, keyed by card, for the listing reads that compose
     # a whole collection in one pass.
     #: () -> Hash[String, Birthday]
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 836e57e..4e7d18c 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -1,9 +1,21 @@
-
 module ProTacts
-  # vCard 3.0 (RFC 2426): the line-level rules a card obeys, and the
-  # shape a parsed one takes. VCard::Parser reads a card into those;
-  # escape and fold write one back out.
-  module VCard
+  # A card, read once: the parsed properties beside the exact bytes
+  # they came from, so a caller asks questions of either without
+  # re-parsing, and moves a property without a byte around it moving
+  # too. A card is Enumerable over its logical lines — each one parsed
+  # beside its verbatim bytes (#each and Line) — so the structured
+  # questions are select and reject and count, and #insert puts lines
+  # back in. vCard 3.0 (RFC 2426): the line-level rules a card obeys
+  # are here whichever half is being asked — Parser reads, escape and
+  # fold write. The line walk is lazy for the same reason Contact's
+  # parse is: the byte-moving paths never read structure.
+  # The signature lives in sig/pro_tacts/vcard.rbs: the generic
+  # Enumerable include is beyond the inline parser.
+  # @rbs skip
+  class VCard
+    include Enumerable
+
+
     # Folded lines must not exceed 75 octets, excluding the line break
     # (RFC 2426 section 2.6). The octet count, not character count, is
     # what matters: a continuation must never split a multibyte
@@ -27,6 +39,14 @@ module ProTacts
     # `(?:\r\n|[\r\n])[ \t]` rather than `\r\n` or `[\r\n][ \t]`.
     FOLD = /#{LINE_BREAK}[ \t]/ #: Regexp
 
+    # A physical line that continues the one above it (RFC 2426 section
+    # 2.6 folding).
+    CONTINUATION = /\A[ \t]/ #: Regexp
+
+    # The line inserted lines go immediately before, so a property
+    # lands inside the envelope however bare the card is.
+    END_LINE = /\AEND:VCARD/i #: Regexp
+
     # A card whose bytes are not a vCard at all. Callers are expected to
     # carry on serving the card: failing to parse costs an index entry,
     # not the contact.
@@ -46,39 +66,34 @@ module ProTacts
     #
     # The signature lives in sig/pro_tacts/vcard.rbs: a Data class has no
     # constant super class for the inline syntax to read.
-    # @rbs skip
     Property = Data.define(:group, :name, :parameters, :value)
 
-    # Whether parsed properties carry the envelope RFC 2426 section 4
-    # requires of a card: BEGIN:VCARD first, END:VCARD last, and a
-    # VERSION in between. Deciding that is deliberately not the
-    # parser's job — it reads lines without judging the card — so it
-    # lives here, where a caller that has to decide (a PUT) can ask.
-    # The VERSION's value is not judged: any version is stored verbatim,
-    # and this only decides whether there is a card at all.
-    #: (Array[Property] properties) -> bool
-    def self.card?(properties)
-      return false if properties.empty?
-
-      first, last = properties.first, properties.last
-      first.name.casecmp?("BEGIN") && first.value.casecmp?("VCARD") &&
-        last.name.casecmp?("END") && last.value.casecmp?("VCARD") &&
-        properties.any? { it.name.casecmp?("VERSION") }
-    end
+    # One logical line of a card, parsed beside the exact bytes that
+    # carried it: `property` is the parsed form — nil when the line
+    # will not read as one, which is a fact about the line, not an
+    # error — and `verbatim` is the line itself, folds and terminator
+    # included, so what moves between cards moves unchanged.
+    #
+    # The signature lives in sig/pro_tacts/vcard.rbs with Property's.
+    Line = Data.define(:property, :verbatim)
 
-    # The value of the card's UID property, if it carries one. Names
-    # compare without case, as the index's NOCASE collation already
-    # assumes for them.
-    #: (Array[Property] properties) -> String?
-    def self.uid(properties)
-      properties.find { it.name.casecmp?("UID") }&.value
+    # Reopened rather than defined in the block above: steep reads a
+    # define block's self as this class's, not the constant's, so the
+    # method could not live there.
+    class Line
+      # Whether this line names the property, a group prefix
+      # (`item1.BDAY`) counting as naming it. Asked of the line rather
+      # than the property because a line that will not parse still has
+      # a name in it.
+      def names?(name)
+        verbatim.match?(/\A(?:[A-Za-z0-9-]+\.)?#{Regexp.escape(name)}[;:]/i)
+      end
     end
 
     # Text values escape backslash, the component separator, and the
     # sub-component separator (RFC 2426 section 2.4.2); CRLF and CR are
     # normalized to the `\n` escape because a raw line break would end
     # the property line.
-    #: (String text) -> String
     def self.escape(text)
       text.gsub(/\r\n|\r/, "\n").gsub(/[\\;,\n]/) { TEXT_ESCAPES.fetch(it) }
     end
@@ -89,7 +104,6 @@ module ProTacts
     # out unparsed — but a view that displays a structured value (an
     # admin screen, not this server's CardDAV responses) has to undo the
     # escaping or show the reader literal backslashes.
-    #: (String text) -> String
     def self.unescape(text)
       text.gsub(/\\[\\;,n]/) { it == "\\n" ? "\n" : it[1..].to_s }
     end
@@ -97,7 +111,6 @@ module ProTacts
     # Splits a structured value's ";"-delimited components (RFC 2426
     # section 3.2.1, e.g. ADR and N) without breaking on an escaped
     # "\;", and unescapes each component in the same pass.
-    #: (String value) -> Array[String]
     def self.split_components(value)
       value.split(/(?<!\\);/, -1).map { unescape(it) }
     end
@@ -106,7 +119,6 @@ module ProTacts
     # octets, each continuation starting with a single space (RFC 2426
     # section 2.6). The walk is character-wise so a multibyte character
     # is never split mid-sequence.
-    #: (String line) -> String
     def self.fold(line)
       return line if line.bytesize <= LINE_LIMIT
 
@@ -124,13 +136,138 @@ module ProTacts
     end
 
     # Fold's inverse, and the first thing a parse does: RFC 2426 section
-    # 2.6 has a content line unfolded before it is read. It runs over the
-    # whole card rather than token by token because a fold can land
+    # 2.6 has a content line unfolded before it is read. It runs over
+    # the whole card rather than token by token because a fold can land
     # anywhere in a line — Contacts folds at 75 octets without regard for
     # what it splits — so there is no boundary to do it at.
-    #: (String card) -> String
     def self.unfold(card)
       card.gsub(FOLD, "")
     end
+
+    # A card is made of UTF-8 text, and refuses to be made of anything
+    # else: bytes that are not valid UTF-8 raise here, at the boundary,
+    # rather than leaking an ArgumentError out of whatever regex first
+    # trips over them. A PUT gates this with its own 412 first, and the
+    # store's bind holds the same line again below the card.
+    def initialize(bytes)
+      raise ArgumentError, "not valid UTF-8: a card is text" unless bytes.valid_encoding?
+
+      @bytes = bytes
+    end
+
+    def bytes
+      @bytes
+    end
+
+    # The card's properties, parsed once on the first structured read.
+    # Nil when the bytes are not a vCard — a caller is expected to carry
+    # on serving the bytes (ParseError's rule, held one level up), and
+    # #parseable? is the question to ask about it.
+    def properties
+      return @properties if defined?(@properties)
+
+      @properties = Parser.parse(@bytes)
+    rescue ParseError
+      @properties = nil
+    end
+
+    def parseable?
+      !properties.nil?
+    end
+
+    # Whether the parsed properties carry the envelope RFC 2426 section
+    # 4 requires of a card: BEGIN:VCARD first, END:VCARD last, and a
+    # VERSION in between. Deciding that is deliberately not the
+    # parser's job — it reads lines without judging the card — so it
+    # lives here, where a caller that has to decide (a PUT) can ask.
+    # The VERSION's value is not judged: any version is stored verbatim,
+    # and this only decides whether there is a card at all.
+    def card?
+      parsed = properties
+      return false if parsed.nil? || parsed.empty?
+
+      first, last = parsed.first, parsed.last
+      first.name.casecmp?("BEGIN") && first.value.casecmp?("VCARD") &&
+        last.name.casecmp?("END") && last.value.casecmp?("VCARD") &&
+        parsed.any? { it.name.casecmp?("VERSION") }
+    end
+
+    # The value of the card's UID property, if it carries one. Names
+    # compare without case, as the index's NOCASE collation already
+    # assumes for them.
+    def uid
+      parsed = properties
+      parsed && parsed.find { it.name.casecmp?("UID") }&.value
+    end
+
+    # Each logical line of the card, parsed beside its verbatim bytes —
+    # the one enumeration every structured question about a card is
+    # asked over. A continuation travels with its line, terminators
+    # attached, so no line's bytes are lost or normalized on the way
+    # through (RFC 2426 section 2.6 folding).
+    def each(&block)
+      lines.each(&block)
+    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.
+    def insert(lines)
+      return @bytes if lines.empty?
+
+      physical = physical_lines
+      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 })
+        physical.join
+      else
+        @bytes + lines.map { it.end_with?("\n") ? it : it + "\r\n" }.join
+      end
+    end
+
+    private
+
+    # One line group read as a property, or nil when it does not read
+    # as one. The parser unfolds what it is handed, so a folded group
+    # arrives as the single logical line it is.
+    def parse(group)
+      Parser.parse(group.join).fetch(0, nil)
+    rescue ParseError
+      nil
+    end
+
+    # The card's logical lines, walked once on the first question
+    # asked over them. The terminatorless trailing group the byte-level
+    # split leaves behind is not a line — dropping it loses nothing,
+    # an empty group joining to nothing.
+    def lines
+      return @lines if defined?(@lines)
+
+      groups = line_groups.reject { |group| group == [""] }
+      @lines = groups.map { |group| Line.new(property: parse(group), verbatim: group.join) }
+    end
+
+    # Physical lines with their terminators attached, so surgery on them
+    # cannot lose or normalize a line break.
+    def physical_lines
+      @bytes.split(/(?<=\n)/, -1)
+    end
+
+    # Logical lines: a physical line and the continuations folded under
+    # it, kept together because a property and its fold are one unit.
+    # Slicing before each non-continuation, rather than chunking, is
+    # what attaches a continuation to the line above it.
+    def line_groups
+      physical_lines.slice_when { |_line, next_line| !next_line.match?(CONTINUATION) }.to_a
+    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.
+    def terminator_of(line)
+      line[/\r?\n\z/] || "\r\n"
+    end
   end
 end
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 96f8335..958c614 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -4,7 +4,7 @@ require "strscan"
 require "pro_tacts/vcard"
 
 module ProTacts
-  module VCard
+  class VCard
     # Reads a stored card into VCard::Property values.
     #
     # Deliberately shallow: a property is a name, its parameters, and the
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index fdf9ed5..71d25a7 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -448,8 +448,8 @@ module ProTacts
       # client could be told what was wrong with the body.
       return precondition("valid-address-data") unless vcard.valid_encoding?
 
-      properties = parse_card(vcard)
-      return precondition("valid-address-data") unless properties && VCard.card?(properties)
+      card = VCard.new(vcard)
+      return precondition("valid-address-data") unless card.card?
 
       # CARDDAV:no-uid-conflict: the submitted UID must not belong to a
       # different resource, and a mapped URI must not be overwritten by
@@ -458,7 +458,7 @@ module ProTacts
       # UID naming the resource being written, and no other card claims
       # it. The href in the body is the SHOULD that section attaches to
       # the first clause — report where the UID already lives.
-      uid = VCard.uid(properties)
+      uid = card.uid
       owner = uid && store.card_id_with_uid(uid)
       return precondition("no-uid-conflict", owner) if uid != id || owner && owner != id
 
@@ -491,16 +491,6 @@ module ProTacts
       nil
     end
 
-    # A parse that answers nil rather than raising when the bytes are
-    # not a card — the same tolerance Store#properties_of gives the
-    # index, at the point where a caller must decide.
-    #: (String vcard) -> Array[VCard::Property]?
-    def parse_card(vcard)
-      VCard::Parser.parse(vcard)
-    rescue VCard::ParseError
-      nil
-    end
-
     # A 412 whose body names the CardDAV precondition that failed, in
     # the DAV:error form RFC 4918 section 16 defines; the element names
     # are RFC 6352 section 6.3.2.1's, qualified by the card: namespace
diff --git a/sig/pro_tacts/birthday.rbs b/sig/pro_tacts/birthday.rbs
index f6b7e99..a73636a 100644
--- a/sig/pro_tacts/birthday.rbs
+++ b/sig/pro_tacts/birthday.rbs
@@ -1,14 +1,11 @@
-# Birthday and Birthday::Split are Data classes, whose members the
-# inline parser cannot read (see sig/pro_tacts/contact.rbs). Both are
-# marked `@rbs skip` in birthday.rb and signed here instead.
+# Birthday is a Data class, whose members the inline parser cannot
+# read (see sig/pro_tacts/contact.rbs). It is marked `@rbs skip` in
+# birthday.rb and signed here instead.
 module ProTacts
   class Birthday
     FULL_DATE: Regexp
     OMIT_YEAR_DATE: Regexp
     OMIT_YEAR: String
-    BDAY_LINE: Regexp
-    END_LINE: Regexp
-    CONTINUATION: Regexp
 
     attr_reader year: Integer?
     attr_reader month: Integer?
@@ -23,25 +20,5 @@ module ProTacts
     def self.from_property: (VCard::Property property) -> Birthday?
 
     def self.apple_no_year?: (VCard::Property property) -> bool
-
-    def self.subtract: (String card) -> Split
-
-    def self.compose: (String card, Birthday? birthday) -> String
-
-    def self.parse_line: (String text) -> VCard::Property?
-
-    def self.physical_lines: (String card) -> Array[String]
-
-    def self.line_groups: (String card) -> Array[Array[String]]
-
-    def self.terminator_of: (String line) -> String
-
-    class Split
-      attr_reader card: String
-      attr_reader birthday: Birthday?
-      attr_reader bday_kept: bool
-
-      def self.new: (card: String, birthday: Birthday?, bday_kept: bool) -> instance
-    end
   end
 end
diff --git a/sig/pro_tacts/vcard.rbs b/sig/pro_tacts/vcard.rbs
index b1482a9..2d5ad8e 100644
--- a/sig/pro_tacts/vcard.rbs
+++ b/sig/pro_tacts/vcard.rbs
@@ -1,9 +1,59 @@
-# Property is a Data class, whose members the inline parser cannot read
-# (see sig/pro_tacts/contact.rbs). Everything else about vcard.rb is
-# inline: its methods are singleton methods rather than
-# `module_function`, which is a shape RBS reads without help.
+# VCard is signed here rather than inline: its Enumerable include is
+# generic, which the inline parser refuses (the rule
+# docs/plans/2026-08-20-type-checking.md records). Property and Line
+# are Data classes, which the inline syntax cannot read either.
 module ProTacts
-  module VCard
+  class VCard
+    include Enumerable[Line]
+
+    LINE_LIMIT: Integer
+    TEXT_ESCAPES: Hash[String, String]
+    LINE_BREAK: Regexp
+    FOLD: Regexp
+    CONTINUATION: Regexp
+    END_LINE: Regexp
+
+    class ParseError < StandardError
+    end
+
+    def initialize: (String bytes) -> void
+
+    def bytes: () -> String
+
+    def properties: () -> Array[Property]?
+
+    def parseable?: () -> bool
+
+    def card?: () -> bool
+
+    def uid: () -> String?
+
+    def each: () { (Line) -> void } -> Array[Line]
+
+    def insert: (Array[String] lines) -> String
+
+    def self.escape: (String text) -> String
+
+    def self.unescape: (String text) -> String
+
+    def self.split_components: (String value) -> Array[String]
+
+    def self.fold: (String line) -> String
+
+    def self.unfold: (String card) -> String
+
+    private
+
+    def parse: (Array[String] group) -> Property?
+
+    def lines: () -> Array[Line]
+
+    def physical_lines: () -> Array[String]
+
+    def line_groups: () -> Array[Array[String]]
+
+    def terminator_of: (String line) -> String
+
     class Property
       attr_reader group: String?
       attr_reader name: String
@@ -12,5 +62,14 @@ module ProTacts
 
       def self.new: (group: String?, name: String, parameters: Array[[String, String]], value: String) -> instance
     end
+
+    class Line
+      attr_reader property: Property?
+      attr_reader verbatim: String
+
+      def self.new: (property: Property?, verbatim: String) -> instance
+
+      def names?: (String name) -> bool
+    end
   end
 end
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index 1bf1ec5..b256673 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -1,6 +1,7 @@
 require_relative "../test_helper"
 
 require "pro_tacts/birthday"
+require "pro_tacts/vcard"
 
 class BirthdayTest < Minitest::Test
   # The six shapes RFC 6350 section 4.3.1 admits, one assertion per
@@ -20,7 +21,7 @@ class BirthdayTest < Minitest::Test
     error = assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 1985, day: 12) }
     assert_match "not a shape", error.message
 
-    assert_raises(ArgumentError) { ProTacts::Birthday.new }
+    assert_raises(ArgumentError) { ProTacts::Birthday.new() }
   end
 
   def test_components_out_of_range_are_refused
@@ -60,145 +61,58 @@ class BirthdayTest < Minitest::Test
     end
   end
 
-  ## subtract
+  ## from_property
 
-  CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada\r\nUID:ada\r\nEND:VCARD\r\n"
-
-  def test_a_full_date_moves_into_the_model_and_out_of_the_card
-    split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\nBDAY:1985-04-12\r\n"))
+  # A parsed property, the shape a read out of a card takes.
+  def property(value, parameters: [], group: nil)
+    ProTacts::VCard::Property.new(group:, name: "BDAY", parameters:, value:)
+  end
 
-    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), split.birthday
-    assert_equal CARD, split.card
-    refute split.bday_kept
+  def test_a_full_date_reads_into_the_model
+    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12),
+      ProTacts::Birthday.from_property(property("1985-04-12"))
   end
 
   # docs/macos-contacts.md, "Birthdays without a year": the verified
   # Apple form, with 1604 standing in for the year in both halves.
-  def test_the_apple_no_year_form_moves_into_the_model
-    split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\nBDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\n"))
+  def test_the_apple_no_year_form_reads_into_the_model
+    sentinel = property("1604-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"]])
 
-    assert_equal ProTacts::Birthday.new(month: 4, day: 12), split.birthday
-    assert_equal CARD, split.card
+    assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_property(sentinel)
   end
 
   # RFC 2426 section 3.1.5 lets BDAY carry a date and time; the time is
   # the one thing ingest deliberately loses, because a birthday is a
   # date — the design record's decision, not an oversight.
   def test_a_date_time_loses_its_time_on_the_way_in
-    ["BDAY:1985-04-12T23:10:00Z", "BDAY:1985-04-12T08:30:00-06:00"].each do |line|
-      split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\n#{line}\r\n"))
-
-      assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), split.birthday, line
-      assert_equal CARD, split.card, line
+    ["1985-04-12T23:10:00Z", "1985-04-12T08:30:00-06:00"].each do |value|
+      assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12),
+        ProTacts::Birthday.from_property(property(value)), value
     end
   end
 
   def test_names_and_parameters_match_without_case
-    split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\nbday;x-apple-omit-year=1604:1604-04-12\r\n"))
+    sentinel = property("1604-04-12", parameters: [["x-apple-omit-year", "1604"]])
 
-    assert_equal ProTacts::Birthday.new(month: 4, day: 12), split.birthday
+    assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_property(sentinel)
   end
 
-  def test_a_folded_bday_is_one_property
-    split = ProTacts::Birthday.subtract(CARD.sub("UID:ada\r\n", "BDAY:1985-04-\r\n 12\r\nUID:ada\r\n"))
-
-    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), split.birthday
-    assert_equal CARD, split.card
+  # A component out of range is unmodeled, not exceptional: from_property
+  # reads nil and the line stays wherever it was.
+  def test_an_out_of_range_date_reads_as_nil
+    assert_nil ProTacts::Birthday.from_property(property("1985-13-40"))
   end
 
-  # Everything the model cannot recompose stays in the card byte for
-  # byte (RFC 6352 section 6.3.2.2), and the model is emptied so compose
-  # never adds a second BDAY beside it. The probe cards ride on this.
-  def test_the_unmodeled_spellings_stay_verbatim
-    unmodeled = [
-      "BDAY:--0412",       # vCard 4.0 basic, month-day
-      "BDAY:--04-12",      # vCard 4.0 extended, month-day
-      "BDAY:1985",         # year alone
-      "BDAY:1985-04",      # year and month
-      "BDAY:---12",        # day alone
-      "BDAY:19850412",     # undashed full date, legal 3.0
-      "BDAY:1985-4-12",    # unpadded, not the grammar's shape
-      "BDAY;VALUE=DATE:1985-04-12",
-      "BDAY;X-APPLE-OMIT-YEAR=1605:1605-04-12", # a foreign sentinel
-      "BDAY;X-APPLE-OMIT-YEAR=1604;X-OTHER=1:1604-04-12", # parameters compose would drop
-      "item1.BDAY:1985-04-12", # a grouped property's label pairing belongs to the card
-      "BDAY:",             # nothing to read
-      "BDAY:1985-13-40",   # components out of range
-    ]
-
-    unmodeled.each do |line|
-      card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\n#{line}\r\n")
-      split = ProTacts::Birthday.subtract(card)
-
-      assert_nil split.birthday, line
-      assert_equal card, split.card, line
-      assert split.bday_kept, line
+  # Everything the model cannot recompose reads as nil, keeping it in
+  # the card verbatim (RFC 6352 section 6.3.2.2): extra parameters
+  # travel with their line, and a grouped property's label pairing
+  # belongs to the card.
+  def test_what_the_model_cannot_recompose_reads_as_nil
+    [property("--0412"), property("1985-04"), property("19850412"),
+      property("1985-04-12", parameters: [["X-OTHER", "1"]]),
+      property("1604-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"], ["X-OTHER", "1"]]),
+      property("1985-04-12", group: "item1")].each do |unmodeled|
+      assert_nil ProTacts::Birthday.from_property(unmodeled), unmodeled.value
     end
   end
-
-  def test_more_than_one_bday_stays_verbatim
-    card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nBDAY:1985-04-12\r\nBDAY:1986-04-12\r\n")
-    split = ProTacts::Birthday.subtract(card)
-
-    assert_nil split.birthday
-    assert_equal card, split.card
-    assert split.bday_kept
-  end
-
-  def test_a_card_without_a_bday_is_untouched
-    split = ProTacts::Birthday.subtract(CARD)
-
-    assert_nil split.birthday
-    assert_equal CARD, split.card
-    refute split.bday_kept
-  end
-
-  # Bytes that are not the UTF-8 the store's contract promises are not
-  # this model's business: they pass through so the bind that enforces
-  # the contract is what refuses them.
-  def test_bytes_that_are_not_utf_8_pass_through_unparsed
-    invalid = "BDAY:\xFF\xFE\r\n".dup.force_encoding(Encoding::UTF_8)
-    split = ProTacts::Birthday.subtract(invalid)
-
-    assert_equal invalid, split.card
-    assert_nil split.birthday
-    refute split.bday_kept
-  end
-
-  ## compose
-
-  def test_compose_puts_the_birthday_before_the_end
-    composed = ProTacts::Birthday.compose(CARD, ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
-
-    assert_equal CARD.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), composed
-  end
-
-  def test_compose_takes_the_end_lines_own_terminator
-    card = "BEGIN:VCARD\nUID:ada\nEND:VCARD\n"
-    composed = ProTacts::Birthday.compose(card, ProTacts::Birthday.new(month: 4, day: 12))
-
-    assert_equal "BEGIN:VCARD\nUID:ada\nBDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\nEND:VCARD\n", composed
-  end
-
-  def test_compose_leaves_a_card_alone_when_there_is_nothing_to_say
-    assert_equal CARD, ProTacts::Birthday.compose(CARD, nil)
-    assert_equal CARD, ProTacts::Birthday.compose(CARD, ProTacts::Birthday.new(year: 1985))
-  end
-
-  def test_compose_appends_when_there_is_no_end_to_anchor_to
-    assert_equal "not a card\r\nBDAY:1985-04-12\r\n",
-      ProTacts::Birthday.compose("not a card\r\n", ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
-  end
-
-  ## the pair of them
-
-  # subtract and compose are inverses on the model and on every line
-  # but the BDAY's own place in the card, which compose chooses.
-  def test_compose_undoes_subtract
-    birthday = ProTacts::Birthday.new(month: 4, day: 12)
-    split = ProTacts::Birthday.subtract(CARD.sub("END:VCARD\r\n", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\nEND:VCARD\r\n"))
-
-    assert_equal birthday, split.birthday
-    assert_equal birthday, ProTacts::Birthday.subtract(ProTacts::Birthday.compose(split.card, birthday)).birthday
-  end
 end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 88be821..f7c874e 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -233,15 +233,17 @@ class StoreTest < Minitest::Test
   # not UTF-8 at all is refused rather than stored: better than serving
   # it back as `text/vcard; charset=utf-8` while it is no such thing.
   # Relabelled first, as the route would — a request this bad never
-  # reaches the store, and this pins the last line that catches it.
+  # reaches the store, and this pins the last line that catches it: the
+  # card itself, before any walk or bind could meet the bytes.
   def test_a_card_that_is_not_utf_8_is_refused
     with_store do |store|
       invalid = "BEGIN:VCARD\r\nFN:\xFF\xFE\r\nEND:VCARD\r\n".dup.force_encoding(Encoding::UTF_8)
 
-      assert_raises(Sequel::DatabaseError) do
+      error = assert_raises(ArgumentError) do
         store.put("bad", invalid)
       end
 
+      assert_match "not valid UTF-8", error.message
       assert_empty store.contacts
     end
   end
@@ -507,14 +509,34 @@ class StoreTest < Minitest::Test
   # A BDAY the model cannot recompose stays in the card verbatim and
   # empties the model: the card's own line speaks for itself, and
   # compose must never add a second one beside it.
+  # A BDAY the model cannot recompose stays in the card verbatim and
+  # empties the model: the card's own line speaks for itself, and
+  # nothing composes a second one beside it. A fold travels with its
+  # line, byte for byte.
   def test_an_unmodeled_bday_stays_in_the_card_and_empties_the_model
-    unmodeled = AIDEN.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n")
+    ["BDAY:--0412", "BDAY:1985-\r\n 04\r\n", "BDAY:1985-04-12\r\nBDAY:1986-04-12\r\n"].each do |bday|
+      unmodeled = AIDEN.sub("END:VCARD\r\n", "#{bday}END:VCARD\r\n")
 
-    with_store({"aiden" => AIDEN_BORN}) do |store|
-      store.put("aiden", unmodeled)
+      with_store({"aiden" => AIDEN_BORN}) do |store|
+        store.put("aiden", unmodeled)
 
-      assert_equal unmodeled, store.contact("aiden").vcard
-      assert_nil birthday_row(store, "aiden")
+        assert_equal unmodeled, store.contact("aiden").vcard, bday
+        assert_nil birthday_row(store, "aiden"), bday
+      end
+    end
+  end
+
+  # The card is made of text and says so itself: bytes that are not
+  # UTF-8 raise at the card, before any walk — the web's PUT has
+  # already answered them with a 412 by the time one reaches a caller
+  # this direct.
+  def test_put_of_bytes_that_are_not_text_raises_at_the_card
+    invalid = "BEGIN:VCARD\r\nFN:\xFF\r\nEND:VCARD\r\n".dup.force_encoding(Encoding::UTF_8)
+
+    with_store({}) do |store|
+      error = assert_raises(ArgumentError) { store.put("aiden", invalid) }
+
+      assert_match "not valid UTF-8", error.message
     end
   end
 
diff --git a/test/pro_tacts/vcard/test_lines.rb b/test/pro_tacts/vcard/test_lines.rb
new file mode 100644
index 0000000..ee9e40b
--- /dev/null
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -0,0 +1,133 @@
+require_relative "../../test_helper"
+
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
+class VCardLinesTest < Minitest::Test
+  VCARD = ProTacts::VCard
+  CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada\r\nUID:ada\r\nEND:VCARD\r\n"
+
+  ## the card, read once
+
+  def test_a_card_reads_its_properties_beside_its_bytes
+    card = VCARD.new(CARD)
+
+    assert_equal CARD, card.bytes
+    assert card.parseable?
+    assert_equal %w[BEGIN VERSION FN UID END], card.properties.map(&:name)
+  end
+
+  # The parse is lazy, so moving bytes never pays for reading structure.
+  def test_the_parse_happens_only_when_read
+    card = VCARD.new("this is not a vCard\r\n")
+
+    assert_equal "this is not a vCard\r\n", card.bytes
+    refute card.parseable?
+    assert_nil card.properties
+    refute card.card?
+    assert_nil card.uid
+  end
+
+  # A card is made of UTF-8 text and refuses to be made of anything
+  # else — at the boundary, where the blame lands on the input rather
+  # than on whatever regex would have tripped over the bytes later.
+  def test_bytes_that_are_not_text_are_refused
+    invalid = "FN:\xFF\r\n".dup.force_encoding(Encoding::UTF_8)
+
+    error = assert_raises(ArgumentError) { VCARD.new(invalid) }
+
+    assert_match "not valid UTF-8", error.message
+  end
+
+  # The envelope RFC 2426 section 4 requires, the one judgment a PUT
+  # needs and the parser deliberately does not make.
+  def test_card_judges_the_envelope
+    assert VCARD.new(CARD).card?
+    refute VCARD.new(CARD.sub("VERSION:3.0\r\n", "")).card?
+    refute VCARD.new(CARD.sub("END:VCARD\r\n", "")).card?
+    refute VCARD.new("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:ada\r\nEND:VCARD\r\n" + "FN:Ada\r\n").card?
+  end
+
+  def test_uid_reads_without_case
+    assert_equal "ada", VCARD.new(CARD).uid
+    assert_nil VCARD.new(CARD.sub("UID:ada\r\n", "")).uid
+  end
+
+  ## the enumeration
+
+  def test_each_yields_the_cards_lines_parsed_beside_verbatim
+    lines = VCARD.new(CARD).to_a
+
+    assert_equal %w[BEGIN VERSION FN UID END], lines.map { it.property&.name }
+    assert_equal CARD, lines.map(&:verbatim).join
+  end
+
+  def test_partition_splits_a_card_without_losing_a_byte
+    born = CARD.sub("UID:ada\r\n", "UID:ada\r\nBDAY:1985-04-12\r\n")
+    bdays, others = VCARD.new(born).partition { it.names?("BDAY") }
+
+    assert_equal 1, bdays.length
+    assert_equal "1985-04-12", bdays.fetch(0).property&.value
+    assert_equal "BDAY:1985-04-12\r\n", bdays.fetch(0).verbatim
+    assert_equal CARD, others.map(&:verbatim).join
+  end
+
+  # A fold travels with its line, byte for byte, and parses as the one
+  # logical line it is.
+  def test_a_fold_is_one_line
+    folded = CARD.sub("UID:ada\r\n", "UID:ada\r\nBDAY:1985-04-\r\n 12\r\n")
+    bday = VCARD.new(folded).find { it.names?("BDAY") }
+
+    assert_equal "1985-04-12", bday&.property&.value
+    assert_equal "BDAY:1985-04-\r\n 12\r\n", bday&.verbatim
+  end
+
+  # A group prefix counts as naming the property; anything else does
+  # not, and a line that will not parse is a fact about the line
+  # (property nil), not an error.
+  def test_names_judges_lines_not_properties
+    grouped = CARD.sub("UID:ada\r\n", "UID:ada\r\nitem1.BDAY:1985-04-12\r\n")
+
+    assert VCARD.new(grouped).any? { it.names?("BDAY") }
+
+    lookalike = "NOTE:BDAY:1985\r\nX-BDAY:1\r\n"
+
+    refute VCARD.new(lookalike).any? { it.names?("BDAY") }
+
+    unparsed = VCARD.new("BDAY;=;:\r\n").first
+
+    assert_nil unparsed.property
+    assert unparsed.names?("BDAY")
+    assert_equal "BDAY;=;:\r\n", unparsed.verbatim
+  end
+
+  ## insert
+
+  def test_insert_takes_the_end_lines_terminator_for_a_bare_line
+    inserted = VCARD.new(CARD).insert(["BDAY:1985-04\r\n"])
+
+    assert_equal CARD.sub("END:VCARD\r\n", "BDAY:1985-04\r\nEND:VCARD\r\n"), inserted
+    assert_operator inserted.index("BDAY"), :<, inserted.index("END:VCARD")
+  end
+
+  # A bare line takes the END line's own terminator so a card stays
+  # single-convention; a terminated line keeps its own, folds and all,
+  # and must not grow a second one.
+  def test_insert_leaves_a_terminated_fold_its_own
+    folded = "BDAY:1985-\r\n 04\r\n"
+
+    assert_equal CARD.sub("END:VCARD\r\n", "BDAY:1985-\r\n 04\r\nEND:VCARD\r\n"),
+      VCARD.new(CARD).insert([folded])
+    assert_equal "BEGIN:VCARD\nEND:VCARD\n".sub("END:VCARD\n", "NOTE:x\nEND:VCARD\n"),
+      VCARD.new("BEGIN:VCARD\nEND:VCARD\n").insert(["NOTE:x\n"])
+  end
+
+  def test_insert_appends_with_crlf_when_there_is_no_end
+    assert_equal "not a card\r\nBDAY:1985-04\r\n",
+      VCARD.new("not a card\r\n").insert(["BDAY:1985-04\r\n"])
+  end
+
+  def test_insert_with_nothing_leaves_the_card_alone
+    assert_equal CARD, VCARD.new(CARD).insert([])
+  end
+end