contact: hold the card, not the bytes
Every caller had a VCard in hand to make those bytes from, and Contact
built a second one back from them on each structured read; #insert
returns a card now so the first survives the round trip.

Assisted-by: Claude Opus 5 via Claude Code
change srooowzkvmlqrvrxykzroukqmrlosulw
commit 702f540268613f7dcc31de495f85e9255016040e
author Alpha Chen <alpha@kejadlen.dev>
date
parent lozlyzun
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index b31ec49..01d4b02 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -5,12 +5,17 @@ require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
 
 module ProTacts
-  # One contact: its id, the vCard bytes served for it, the etag over
-  # those bytes, and the structured read of them — the one model of a
-  # contact (docs/plans/2026-09-01-contact-is-the-model.md). The id is
-  # the vCard's UID and the name in its href (see
+  # One contact: its id, the card served for it, and the etag over that
+  # card's bytes — the one model of a contact
+  # (docs/plans/2026-09-01-contact-is-the-model.md). The id is the
+  # vCard's UID and the name in its href (see
   # docs/plans/2026-01-12-carddav-architecture.md).
   #
+  # A VCard rather than the bytes, because a contact that held bytes
+  # would have to make a card out of them to answer anything, and every
+  # caller handing one over already had a card in hand to make the bytes
+  # from. One card is built per contact and every read is asked of it.
+  #
   # The etag hashes the card that goes out, so it changes exactly when
   # what the client downloads changes. It is derived here and stored
   # nowhere: a contact read back from the store hashes its card the same
@@ -20,22 +25,19 @@ module ProTacts
   # both the ETag header and getetag properties carry.
   #
   # The structured accessors are that idea applied to the card's
-  # contents: everything derives from the bytes, lazily, on the first
-  # structured read. The CardDAV paths never take one — they answer in
-  # bytes — and ctag and the listing reads build a contact per row
-  # without reading structure, so deferring the parse keeps every
-  # serving path as cheap as it was without the accessors. A regular
-  # class rather than a Data one because the memoized parse needs an
-  # ivar and Data freezes its instances.
+  # contents: everything derives from the bytes, and the card defers the
+  # walk that reads them until something asks. The CardDAV paths never
+  # ask — they answer in bytes — so the serving paths pay for no parse
+  # they do not use, and that laziness is VCard's to keep rather than
+  # this class's to arrange.
   #
   # The accessors are deliberately shallow, like the parser: a card can
   # carry properties no accessor knows, and CardDAV still serves every
   # one. #properties is the read for those.
   class Contact
     # @rbs @id: String
-    # @rbs @vcard: String
+    # @rbs @vcard: VCard
     # @rbs @etag: String
-    # @rbs @properties: Array[VCard::Parser::Property]
 
     # Ids end up in paths and arrive from client-supplied hrefs, so an id
     # outside this charset cannot be served.
@@ -67,19 +69,19 @@ module ProTacts
     # A contact from its id and its card. The only way to make one: an
     # etag that came from anywhere but the card in hand is an etag that
     # can be wrong.
-    #: (id: String, vcard: String) -> Contact
+    #: (id: String, vcard: VCard) -> Contact
     def self.for(id:, vcard:)
       raise ArgumentError, "invalid contact id: #{id}" unless id.match?(ID_FORMAT)
 
       new(id, vcard, etag_for(vcard))
     end
 
-    #: (String vcard) -> String
+    #: (VCard vcard) -> String
     def self.etag_for(vcard)
-      %("#{Digest::SHA256.hexdigest(vcard)}")
+      %("#{Digest::SHA256.hexdigest(vcard.to_s)}")
     end
 
-    #: (String id, String vcard, String etag) -> void
+    #: (String id, VCard vcard, String etag) -> void
     def initialize(id, vcard, etag)
       @id = id
       @vcard = vcard
@@ -92,19 +94,14 @@ module ProTacts
 
     attr_reader :etag
 
-    # The card's properties, parsed once and memoized — the substrate
-    # the typed accessors sit on, and the read for what none of them
-    # models. Every line that read, and no complaint about one that did
-    # not: a contact is served from its bytes, so a line this parser
-    # cannot read costs an accessor its answer and costs the contact
-    # nothing. There is no repair to make and nobody to make it, which
-    # is why nothing here looks for one.
+    # The card's properties — the substrate the typed accessors sit on,
+    # and the read for what none of them models. Every line that read,
+    # and no complaint about one that did not: a contact is served from
+    # its bytes, so a line this parser cannot read costs an accessor its
+    # answer and costs the contact nothing. There is no repair to make
+    # and nobody to make it, which is why nothing here looks for one.
     #: () -> Array[VCard::Parser::Property]
-    def properties
-      return @properties if defined?(@properties)
-
-      @properties = VCard.new(@vcard).properties
-    end
+    def properties = @vcard.properties
 
     # FN's value (RFC 2426 section 3.1.1), in text form.
     #: () -> String?
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 85a7c9b..bf26539 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -226,8 +226,9 @@ module ProTacts
     # binary-flagged byte above 7 bits raises at the bind, and bytes
     # that are not UTF-8 at all stop at the insert, which SQLite refuses
     # to store as text. The body — the one binary input — is relabelled
-    # where it is read, in write_card, and refused at the card itself
-    # before that: VCard raises on bytes that are not text.
+    # and judged in the same breath where it is read, in write_card, so
+    # nothing that is not text gets this far; VCard's own raise is the
+    # assertion under that, and the bind is the third line.
     #: (String id, String vcard) -> Contact
     def put(id, vcard)
       # The birthday half of the split a write makes, one arm per shape
@@ -254,12 +255,12 @@ module ProTacts
           report_unrecognized_bday_lines([line])
           property = bday_of(line)
           birthday = property && Birthday.from_property(property)
-          [birthday, birthday ? rest.to_s : vcard]
+          [birthday, birthday ? rest : card]
         in [[], _]
           # The rewrite arm: carry the unrendered lines out of the
           # stored card, report the unrecognized ones' loss, and keep
           # whatever an unseen model row holds.
-          carried, lost = carried_and_lost_bday_lines(stored_vcard(id))
+          carried, lost = carried_and_lost_bday_lines(stored_card(id))
           kept = existing && !existing.served? ? existing : nil
           report_lost_bday_lines(lost)
           [kept, card.insert(carried)]
@@ -267,7 +268,7 @@ module ProTacts
           # More than one BDAY: cardinality-broken data, kept verbatim
           # and reported like any other unrecognized line.
           report_unrecognized_bday_lines(lines)
-          [nil, vcard]
+          [nil, card]
         end
 
       # The Contact this returns is the composed one, and the logged
@@ -277,7 +278,7 @@ module ProTacts
       @database.transaction do
         cards
           .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
-          .insert(id: contact.id, vcard: stored)
+          .insert(id: contact.id, vcard: stored.to_s)
         write_birthday(contact.id, birthday)
         record(contact.id, "put", contact.etag)
         reindex(contact.id, stored)
@@ -314,7 +315,7 @@ module ProTacts
     def rebuild_index
       @database.transaction do
         card_properties.delete
-        cards.order(:id).all.each { reindex(it.fetch(:id).to_s, it.fetch(:vcard).to_s) }
+        cards.order(:id).all.each { reindex(it.fetch(:id).to_s, VCard.new(it.fetch(:vcard).to_s)) }
       end
     end
 
@@ -358,13 +359,13 @@ module ProTacts
     # parser cannot read. Takes the stored card — with the birthday
     # already subtracted — so the index never sees a BDAY no stored
     # card carries.
-    #: (String id, String vcard) -> void
-    def reindex(id, vcard)
+    #: (String id, VCard card) -> void
+    def reindex(id, card)
       # Before the parse, so that a card which has stopped parsing does
       # not keep the rows from when it did.
       card_properties.where(card_id: id).delete
 
-      properties_of(vcard).each.with_index do |property, position|
+      card.properties.each.with_index do |property, position|
         card_properties.insert(
           card_id: id,
           position:,
@@ -378,12 +379,6 @@ module ProTacts
       end
     end
 
-    # A card's properties: every line of it that read.
-    #: (String vcard) -> Array[VCard::Parser::Property]
-    def properties_of(vcard)
-      VCard.new(vcard).properties
-    end
-
     # Applies whatever migrations the database has not seen. On one it is
     # already current with, this is a read of Sequel's schema_info table
     # and nothing else, which is cheap enough to do on every open and
@@ -396,10 +391,10 @@ module ProTacts
     # 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.
-    #: (String id) -> String?
-    def stored_vcard(id)
+    #: (String id) -> VCard?
+    def stored_card(id)
       row = cards.where(id:).first
-      row && row.fetch(:vcard).to_s
+      row && VCard.new(row.fetch(:vcard).to_s)
     end
 
     # The BDAY a decision may be made of: the line's property, when it
@@ -420,11 +415,11 @@ module ProTacts
     # it drops unwitnessed, which no client renders and no whitelist
     # recognizes. Lines a client rendered are in neither pile: their
     # absence is a deletion the rewrite already honors.
-    #: (String? vcard) -> [Array[String], Array[VCard::Parser::Line]]
-    def carried_and_lost_bday_lines(vcard)
-      return [[], []] if vcard.nil?
+    #: (VCard? card) -> [Array[String], Array[VCard::Parser::Line]]
+    def carried_and_lost_bday_lines(card)
+      return [[], []] if card.nil?
 
-      bdays, = VCard.new(vcard).extract("BDAY")
+      bdays, = card.extract("BDAY")
       carried, rest = bdays.partition { |line|
         property = bday_of(line)
         property && Birthday.unrendered_value?(property.value)
@@ -482,19 +477,19 @@ module ProTacts
     def contact_from(row, birthday)
       Contact.for(
         id: row.fetch(:id).to_s,
-        vcard: with_birthday(row.fetch(:vcard).to_s, birthday),
+        vcard: with_birthday(VCard.new(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
+    #: (VCard card, Birthday? birthday) -> VCard
     def with_birthday(card, birthday)
       line = birthday && birthday.to_line
       return card if line.nil?
 
-      VCard.new(card).insert([line])
+      card.insert([line])
     end
 
     # The model's half of a write: hold the birthday the card gave up,
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index a2dd24a..da4d9ec 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -164,18 +164,22 @@ module ProTacts
     # 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.
-    #: (Array[String] lines) -> String
+    #
+    # 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
+    # re-judge, and one that wants the bytes asks #to_s for them.
+    #: (Array[String] lines) -> VCard
     def insert(lines)
-      return @bytes if lines.empty?
+      return self 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
+        VCard.new(physical.join)
       else
-        @bytes + lines.map { it.end_with?("\n") ? it : it + "\r\n" }.join
+        VCard.new(@bytes + lines.map { it.end_with?("\n") ? it : it + "\r\n" }.join)
       end
     end
 
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index a6f8f51..a458215 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -374,7 +374,7 @@ module ProTacts
               # RFC 7232 section 2.3; must match the getetag reported for
               # this contact in PROPFIND and REPORT.
               response["ETag"] = contact.etag
-              contact.vcard
+              contact.vcard.to_s
             end
           end
 
@@ -498,7 +498,7 @@ module ProTacts
       # PUT that carried one stores a different card than it was handed
       # and the client refetches; a card with nothing to subtract still
       # stores octet for octet and still gets the tag.
-      response["ETag"] = stored.etag if stored.vcard == vcard
+      response["ETag"] = stored.etag if stored.vcard.to_s == vcard
 
       # A returned "" would land in the body and pin text/html and
       # content-length onto the 204, which a bodyless status must not
@@ -620,7 +620,7 @@ module ProTacts
           <d:propstat>
             <d:prop>
               <d:getetag>#{contact.etag}</d:getetag>
-              <card:address-data>#{xml_escape(contact.vcard.chomp)}</card:address-data>
+              <card:address-data>#{xml_escape(contact.vcard.to_s.chomp)}</card:address-data>
             </d:prop>
             <d:status>HTTP/1.1 200 OK</d:status>
           </d:propstat>
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index 1d184f1..7115cf5 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -2,13 +2,14 @@ require_relative "../../test_helper"
 
 require "pro_tacts/admin/format"
 require "pro_tacts/contact"
+require "pro_tacts/vcard"
 
 class FormatTest < Minitest::Test
   CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
     "BDAY:1985-12-10\r\nUID:ada\r\nEND:VCARD\r\n"
 
   def contact(vcard = CARD, id: "aiden")
-    ProTacts::Contact.for(id:, vcard:)
+    ProTacts::Contact.for(id:, vcard: ProTacts::VCard.new(vcard))
   end
 
   # Initials come from the structured N property (given + family),
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index a076379..a4004c1 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -3,6 +3,7 @@ require_relative "../test_helper"
 require "digest"
 
 require "pro_tacts/contact"
+require "pro_tacts/vcard"
 
 class ContactTest < Minitest::Test
   CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nEND:VCARD\r\n"
@@ -13,35 +14,30 @@ class ContactTest < Minitest::Test
     "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n" \
     "BDAY:1985-12-10\r\nNOTE:Countess\\, mathematician.\r\nUID:ada\r\nEND:VCARD\r\n"
 
-  def contact(vcard)
-    ProTacts::Contact.for(id: "aiden", vcard:)
+  def contact(vcard = CARD, id: "aiden")
+    ProTacts::Contact.for(id:, vcard: ProTacts::VCard.new(vcard))
   end
 
   def test_the_etag_hashes_the_card
-    contact = ProTacts::Contact.for(id: "aiden", vcard: CARD)
-
     assert_equal %("#{Digest::SHA256.hexdigest(CARD)}"), contact.etag
   end
 
   def test_the_etag_moves_with_the_card
-    etag = ProTacts::Contact.for(id: "aiden", vcard: CARD).etag
+    etag = contact.etag
 
-    assert_equal etag, ProTacts::Contact.for(id: "aiden", vcard: CARD).etag
-    refute_equal etag, ProTacts::Contact.for(id: "aiden", vcard: CARD.sub("Aiden", "Aiden Smith")).etag
+    assert_equal etag, contact.etag
+    refute_equal etag, contact(CARD.sub("Aiden", "Aiden Smith")).etag
   end
 
   # The same card under two ids is two contacts with the same etag: the
   # etag is about the bytes a client downloads, and the id is not in them.
   def test_the_etag_ignores_the_id
-    assert_equal(
-      ProTacts::Contact.for(id: "aiden", vcard: CARD).etag,
-      ProTacts::Contact.for(id: "znorth", vcard: CARD).etag,
-    )
+    assert_equal contact(id: "aiden").etag, contact(id: "znorth").etag
   end
 
   def test_ids_outside_the_href_charset_raise
     ["John Smith", "../etc/passwd", "a/b", ""].each do |id|
-      error = assert_raises(ArgumentError) { ProTacts::Contact.for(id:, vcard: CARD) }
+      error = assert_raises(ArgumentError) { contact(id:) }
 
       assert_equal "invalid contact id: #{id}", error.message
     end
@@ -49,7 +45,7 @@ class ContactTest < Minitest::Test
 
   def test_uuids_and_slugs_are_ids
     %w[AB12C345-6789-0DEF-1234-567890ABCDEF aiden znorth_2].each do |id|
-      assert_equal id, ProTacts::Contact.for(id:, vcard: CARD).id
+      assert_equal id, contact(id:).id
     end
   end
 
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 6a7e221..0be1d93 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -43,7 +43,7 @@ class StoreTest < Minitest::Test
 
   def test_a_stored_card_comes_back_byte_for_byte
     with_store({"aiden" => AIDEN}) do |store|
-      assert_equal AIDEN, store.contact("aiden").vcard
+      assert_equal AIDEN, store.contact("aiden").vcard.to_s
     end
   end
 
@@ -92,7 +92,7 @@ class StoreTest < Minitest::Test
       updated = AIDEN.sub("Aiden", "Aiden Smith")
       store.put("aiden", updated)
 
-      assert_equal [updated], store.contacts.map { it.vcard }
+      assert_equal [updated], store.contacts.map { it.vcard.to_s }
     end
   end
 
@@ -129,7 +129,7 @@ class StoreTest < Minitest::Test
       ProTacts::Store.connect(path) { it.put("aiden", AIDEN) }
 
       ProTacts::Store.connect(path) do |store|
-        assert_equal AIDEN, store.contact("aiden").vcard
+        assert_equal AIDEN, store.contact("aiden").vcard.to_s
       end
     end
   end
@@ -149,7 +149,7 @@ class StoreTest < Minitest::Test
 
       # Pure ASCII carries no such byte, so a binary-flagged id still
       # finds its row.
-      assert_equal AIDEN, store.contact("aiden".b).vcard
+      assert_equal AIDEN, store.contact("aiden".b).vcard.to_s
     end
   end
 
@@ -438,7 +438,7 @@ class StoreTest < Minitest::Test
   # nothing to the index and cost the index nothing else.
   def test_an_unparseable_card_is_still_stored_and_served
     with_store({"broken" => "this is not a vCard\r\n"}) do |store|
-      assert_equal "this is not a vCard\r\n", store.contact("broken").vcard
+      assert_equal "this is not a vCard\r\n", store.contact("broken").vcard.to_s
       assert_empty indexed_names(store, "broken")
     end
   end
@@ -450,7 +450,7 @@ class StoreTest < Minitest::Test
     unreadable = AIDEN.sub("FN:Aiden\r\n", "FN:Aiden\r\nTEL;HOME:+1-555-1234\r\n")
     with_store({"aiden" => unreadable}) do |store|
       assert_equal %w[BEGIN VERSION FN UID END], indexed_names(store, "aiden")
-      assert_equal unreadable, store.contact("aiden").vcard
+      assert_equal unreadable, store.contact("aiden").vcard.to_s
     end
   end
 
@@ -465,7 +465,7 @@ class StoreTest < Minitest::Test
   def test_a_birthday_is_stored_beside_the_card_and_served_within_it
     with_store({"aiden" => AIDEN_BORN}) do |store|
       assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
-      assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), store.contact("aiden").vcard
+      assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), store.contact("aiden").vcard.to_s
     end
   end
 
@@ -506,7 +506,7 @@ class StoreTest < Minitest::Test
       store.put("aiden", AIDEN)
 
       assert_nil birthday_row(store, "aiden")
-      assert_equal AIDEN, store.contact("aiden").vcard
+      assert_equal AIDEN, store.contact("aiden").vcard.to_s
     end
   end
 
@@ -520,7 +520,7 @@ class StoreTest < Minitest::Test
       store.put("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
 
       assert_equal ProTacts::Birthday.new(year: 1985), birthday_row(store, "aiden")
-      assert_equal AIDEN.sub("FN:Aiden", "FN:Aiden Smith"), store.contact("aiden").vcard
+      assert_equal AIDEN.sub("FN:Aiden", "FN:Aiden Smith"), store.contact("aiden").vcard.to_s
     end
   end
 
@@ -538,7 +538,7 @@ class StoreTest < Minitest::Test
       with_store({"aiden" => AIDEN_BORN}) do |store|
         store.put("aiden", unmodeled)
 
-        assert_equal unmodeled, store.contact("aiden").vcard, bday
+        assert_equal unmodeled, store.contact("aiden").vcard.to_s, bday
         assert_nil birthday_row(store, "aiden"), bday
       end
     end
@@ -565,7 +565,7 @@ class StoreTest < Minitest::Test
       store.put("aiden", shared)
       messages = sentry_messages
 
-      assert_equal shared, store.contact("aiden").vcard
+      assert_equal shared, store.contact("aiden").vcard.to_s
       assert_nil birthday_row(store, "aiden")
       assert_empty messages
     end
@@ -578,7 +578,7 @@ class StoreTest < Minitest::Test
 
         store.put("aiden", edited)
 
-        assert_equal edited.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n"), store.contact("aiden").vcard, line
+        assert_equal edited.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n"), store.contact("aiden").vcard.to_s, line
         assert_nil birthday_row(store, "aiden"), line
       end
     end
@@ -592,7 +592,7 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n")}) do |store|
       store.put("aiden", AIDEN)
 
-      assert_equal AIDEN, store.contact("aiden").vcard
+      assert_equal AIDEN, store.contact("aiden").vcard.to_s
       assert_nil birthday_row(store, "aiden")
     end
   end
@@ -608,7 +608,7 @@ class StoreTest < Minitest::Test
       store.put("aiden", edited)
       messages = sentry_messages
 
-      assert_equal edited, store.contact("aiden").vcard
+      assert_equal edited, store.contact("aiden").vcard.to_s
       assert_equal 1, messages.length
     end
   end
@@ -622,7 +622,7 @@ class StoreTest < Minitest::Test
       store.put("aiden", born)
 
       assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), birthday_row(store, "aiden")
-      assert_equal born, store.contact("aiden").vcard
+      assert_equal born, store.contact("aiden").vcard.to_s
     end
   end
 
@@ -822,16 +822,16 @@ class StoreTest < Minitest::Test
       ProTacts::Store.connect(path) do |store|
         assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
         assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), birthday_row(store, "aiden")
-        assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), store.contact("aiden").vcard
+        assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), store.contact("aiden").vcard.to_s
 
         # Unmodeled: byte-identical, no birthday row beside it.
-        assert_equal ZED.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n"), store.contact("znorth").vcard
+        assert_equal ZED.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n"), store.contact("znorth").vcard.to_s
         assert_nil birthday_row(store, "znorth")
 
         # A BDAY sharing its line's bytes stays put: the line moves as
         # one or not at all, where the first property alone would have
         # moved the birthday and dropped the NOTE unwitnessed.
-        assert_equal shared, store.contact("xavi").vcard
+        assert_equal shared, store.contact("xavi").vcard.to_s
         assert_nil birthday_row(store, "xavi")
       end
     end
diff --git a/test/pro_tacts/vcard/test_lines.rb b/test/pro_tacts/vcard/test_lines.rb
index 2125757..f98e5e1 100644
--- a/test/pro_tacts/vcard/test_lines.rb
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -146,7 +146,7 @@ class VCardLinesTest < Minitest::Test
   ## insert
 
   def test_insert_takes_the_end_lines_terminator_for_a_bare_line
-    inserted = VCARD.new(CARD).insert(["BDAY:1985-04\r\n"])
+    inserted = VCARD.new(CARD).insert(["BDAY:1985-04\r\n"]).to_s
 
     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")
@@ -159,17 +159,17 @@ class VCardLinesTest < Minitest::Test
     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])
+      VCARD.new(CARD).insert([folded]).to_s
     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"])
+      VCARD.new("BEGIN:VCARD\nEND:VCARD\n").insert(["NOTE:x\n"]).to_s
   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"])
+      VCARD.new("not a card\r\n").insert(["BDAY:1985-04\r\n"]).to_s
   end
 
   def test_insert_with_nothing_leaves_the_card_alone
-    assert_equal CARD, VCARD.new(CARD).insert([])
+    assert_equal CARD, VCARD.new(CARD).insert([]).to_s
   end
 end