store, web: the write takes the card the route already read
The envelope, the birthday split, the rows the index kept —
three cards over one submission, and three walks the parser stepped.
One card now: the route reads it, the store decides from what it's handed,
and the raise for bytes that are not text is tested where it landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvMYjgmPgf7wa8nzH6DJwW
change
commit 856bfb6c2d584cbfa437a74745de9b0e4461c96f
author Claude <noreply@anthropic.com>
date
parent okolnknq
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 2420534..88556a6 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -264,16 +264,23 @@ module ProTacts
     # no stored card carries the model's BDAY and every read composes
     # 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
+    # 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.
+    #
     # The strings are UTF-8 by contract, and the adapter holds the store
     # to it: the sqlite3 gem encodes every bound value to UTF-8, so a
     # 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
     # 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)
+    # nothing that is not text gets this far; VCard's own raise, at the
+    # construction the caller makes, is the assertion under that, and
+    # the bind is the third line.
+    #: (String id, VCard card) -> Contact
+    def put(id, card)
       # 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;
@@ -290,7 +297,6 @@ module ProTacts
       # see was the user's deletion, an unseen model row survives as
       # nothing a client ever saw, and what nobody recognizes is
       # reported rather than lost in silence.
-      card = VCard.new(vcard)
       existing = birthday_of(id)
       # The card as it stands before this write, read once for the two
       # halves that need it: the rewrite arm below, which carries
@@ -349,10 +355,12 @@ module ProTacts
     # through the store". The card input is the stored one by
     # definition, and `birthday:` is the model's whole new state, an
     # upsert or a delete, never a splice
-    # (docs/plans/2026-09-07-web-birthday-editor.md).
-    #: (String id, String vcard, birthday: Birthday?) -> Contact
-    def rewrite(id, vcard, birthday:)
-      card = VCard.new(vcard)
+    # (docs/plans/2026-09-07-web-birthday-editor.md). A card rather
+    # than its bytes, for #put's reason: the editor spliced one to
+    # make this save, and re-reading its bytes here would walk them
+    # again to reach what the caller already had.
+    #: (String id, VCard card, birthday: Birthday?) -> Contact
+    def rewrite(id, card, birthday:)
       contact = Contact.new(id:, stored: card, birthday:, inherited: inherited_of(id))
       @database.transaction do
         cards
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 10ed86d..4e57c61 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -668,7 +668,7 @@ module ProTacts
       report_unreadable_lines(card)
       report_broken_assumptions(card)
 
-      stored = store.put(id, vcard)
+      stored = store.put(id, card)
       response.status = existing ? 204 : 201
       # A strong ETag belongs on the answer only when what the resource
       # now serves is the submitted bytes, octet for octet — the one
@@ -681,7 +681,7 @@ module ProTacts
       # tag, while a PUT that carried a birthday somewhere other than
       # where compose puts it back serves bytes that are not the ones
       # it sent, and the client refetches.
-      response["ETag"] = stored.etag if stored.vcard.to_s == vcard
+      response["ETag"] = stored.etag if stored.vcard.to_s == card.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
@@ -773,7 +773,7 @@ module ProTacts
         return edit_screen(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
       end
 
-      store.rewrite(id, edited_card(contact, first, last, r.params).to_s, birthday:)
+      store.rewrite(id, edited_card(contact, first, last, r.params), birthday:)
       r.redirect "/contacts/#{id}", 303
     end
 
@@ -791,13 +791,15 @@ module ProTacts
     # record later. The names escape, because a name is text — a
     # comma or semicolon in one must not read as structure (RFC 2426
     # section 2.4.2).
-    #: (String id, String first, String last) -> String
+    #: (String id, String first, String last) -> VCard
     def new_contact_card(id, first, last)
-      "BEGIN:VCARD\r\nVERSION:3.0\r\n" \
-        "N:#{VCard.escape(last)};#{VCard.escape(first)};;;\r\n" \
-        "FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n" \
-        "UID:#{id}\r\n" \
-        "END:VCARD\r\n"
+      VCard.new(
+        "BEGIN:VCARD\r\nVERSION:3.0\r\n" \
+          "N:#{VCard.escape(last)};#{VCard.escape(first)};;;\r\n" \
+          "FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n" \
+          "UID:#{id}\r\n" \
+          "END:VCARD\r\n",
+      )
     end
 
     # The surgical save — the hazard it exists to remove, the
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index a894dab..541316a 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -3,6 +3,7 @@ require "fileutils"
 require "pathname"
 
 require "pro_tacts/store"
+require "pro_tacts/vcard"
 
 # Builds the data directory the tests and `rake fixtures` run against: a
 # database seeded from the cards in test/fixtures/cards, rebuilt from
@@ -110,7 +111,7 @@ module FixtureData
     store = ProTacts::Store.at(directory / "contacts.db")
     seed = cards
     seed.each do |id, card|
-      store.put(id, card)
+      store.put(id, ProTacts::VCard.new(card))
     end
     backdate(store, seed.keys)
     seed_groups(store)
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index af4a964..a4d5c2a 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -7,6 +7,7 @@ require "rack/test"
 require "tmpdir"
 
 require "pro_tacts/store"
+require "pro_tacts/vcard"
 require "pro_tacts/web"
 
 # The dashboard root (docs/DESIGN.md), exercised the same way WebTest
@@ -37,7 +38,7 @@ class AdminContactsPagesTest < Minitest::Test
       ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
         ProTacts::Web.store = store
         cards.each do |id, card|
-          store.put(id, card)
+          store.put(id, ProTacts::VCard.new(card))
         end
         yield store
       ensure
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index bd172f4..9924594 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -7,6 +7,7 @@ require "tmpdir"
 require "sequel"
 
 require "pro_tacts/store"
+require "pro_tacts/vcard"
 
 class StoreTest < Minitest::Test
   include Sentry::TestHelper
@@ -26,13 +27,18 @@ class StoreTest < Minitest::Test
     Dir.mktmpdir do |dir|
       ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
         cards.each do |id, card|
-          store.put(id, card)
+          store.put(id, vcard(card))
         end
         yield store
       end
     end
   end
 
+  # A write takes the card, not its bytes: the store is handed the
+  # reading its caller already made (Store#put), and the constants
+  # here are the bytes a client would submit.
+  def vcard(bytes) = ProTacts::VCard.new(bytes)
+
   # The store's reports land in the transport this pins for the test;
   # teardown clears it so nothing carries into the next one.
   def setup
@@ -78,7 +84,7 @@ class StoreTest < Minitest::Test
   # octets.
   def test_a_photo_card_round_trips_byte_for_byte
     with_store({}) do |store|
-      store.put("ada", PhotoCard.photo("ada"))
+      store.put("ada", vcard(PhotoCard.photo("ada")))
 
       assert_equal PhotoCard.photo("ada"), store.contact("ada").vcard.to_s
     end
@@ -86,7 +92,7 @@ class StoreTest < Minitest::Test
 
   def test_a_memoji_card_round_trips_byte_for_byte
     with_store({}) do |store|
-      store.put("ada", PhotoCard.memoji("ada"))
+      store.put("ada", vcard(PhotoCard.memoji("ada")))
 
       assert_equal PhotoCard.memoji("ada"), store.contact("ada").vcard.to_s
     end
@@ -100,7 +106,7 @@ class StoreTest < Minitest::Test
     expected = PhotoCard.photo("ada").sub("END:VCARD\r\n", "#{APPLE_NO_YEAR}\r\nEND:VCARD\r\n")
 
     with_store({}) do |store|
-      store.put("ada", submitted)
+      store.put("ada", vcard(submitted))
 
       assert_equal expected, store.contact("ada").vcard.to_s
     end
@@ -134,7 +140,7 @@ class StoreTest < Minitest::Test
   def test_putting_the_same_id_replaces_the_card
     with_store({"aiden" => AIDEN}) do |store|
       updated = AIDEN.sub("Aiden", "Aiden Smith")
-      store.put("aiden", updated)
+      store.put("aiden", vcard(updated))
 
       assert_equal [updated], store.contacts.map { it.vcard.to_s }
     end
@@ -162,7 +168,7 @@ class StoreTest < Minitest::Test
 
   def test_an_id_that_could_not_be_served_is_refused
     with_store do |store|
-      assert_raises(ArgumentError) { store.put("John Smith", AIDEN) }
+      assert_raises(ArgumentError) { store.put("John Smith", vcard(AIDEN)) }
       assert_empty store.contacts
     end
   end
@@ -171,7 +177,7 @@ class StoreTest < Minitest::Test
     Dir.mktmpdir do |dir|
       path = Pathname.new(dir) / "contacts.db"
       ProTacts::Store.connect(path) do
-        it.put("aiden", AIDEN)
+        it.put("aiden", vcard(AIDEN))
       end
 
       ProTacts::Store.connect(path) do |store|
@@ -190,7 +196,7 @@ class StoreTest < Minitest::Test
 
     with_store({"aiden" => AIDEN}) do |store|
       assert_raises(Encoding::UndefinedConversionError) do
-        store.put("aiden".b, accented.b)
+        store.put("aiden".b, vcard(accented.b))
       end
 
       # Pure ASCII carries no such byte, so a binary-flagged id still
@@ -210,7 +216,7 @@ class StoreTest < Minitest::Test
   def test_the_ctag_moves_with_a_cards_content
     with_store({"aiden" => AIDEN}) do |store|
       before = store.ctag
-      store.put("aiden", AIDEN.sub("Aiden", "Aiden Smith"))
+      store.put("aiden", vcard(AIDEN.sub("Aiden", "Aiden Smith")))
 
       refute_equal before, store.ctag
     end
@@ -225,7 +231,7 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN}) do |store|
       alone = store.ctag
 
-      store.put("znorth", ZED)
+      store.put("znorth", vcard(ZED))
       refute_equal alone, store.ctag
 
       store.delete("znorth")
@@ -248,7 +254,7 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN}) do |store|
       first = card_row(store, "aiden")
       sleep 0.002 # the stamp has millisecond resolution
-      store.put("aiden", AIDEN.sub("Aiden", "Aiden Smith"))
+      store.put("aiden", vcard(AIDEN.sub("Aiden", "Aiden Smith")))
       second = card_row(store, "aiden")
 
       assert_equal first.fetch(:created_at), second.fetch(:created_at)
@@ -263,7 +269,7 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN}) do |store|
       before = card_row(store, "aiden").fetch(:updated_at)
       sleep 0.002
-      store.put("aiden", AIDEN)
+      store.put("aiden", vcard(AIDEN))
 
       assert_operator card_row(store, "aiden").fetch(:updated_at), :>, before
     end
@@ -272,7 +278,7 @@ class StoreTest < Minitest::Test
   def test_contacts_by_recency_orders_newest_first
     with_store({"aiden" => AIDEN}) do |store|
       sleep 0.002
-      store.put("znorth", ZED)
+      store.put("znorth", vcard(ZED))
 
       assert_equal %w[znorth aiden], store.contacts_by_recency.map { it.contact.id }
     end
@@ -293,25 +299,6 @@ class StoreTest < Minitest::Test
     end
   end
 
-  # Relabelling bytes is not converting them, so a card whose bytes are
-  # 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: 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)
-
-      error = assert_raises(ArgumentError) do
-        store.put("bad", invalid)
-      end
-
-      assert_match "not valid UTF-8", error.message
-      assert_empty store.contacts
-    end
-  end
-
   ## The change log
 
   def test_a_put_records_the_card_and_its_etag
@@ -326,8 +313,8 @@ class StoreTest < Minitest::Test
 
   def test_every_write_is_logged_in_order
     with_store({"aiden" => AIDEN}) do |store|
-      store.put("aiden", AIDEN.sub("Aiden", "Aiden Smith"))
-      store.put("znorth", ZED)
+      store.put("aiden", vcard(AIDEN.sub("Aiden", "Aiden Smith")))
+      store.put("znorth", vcard(ZED))
       store.delete("aiden")
 
       assert_equal [%w[aiden put], %w[aiden put], %w[znorth put], %w[aiden delete]],
@@ -349,7 +336,7 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN_BORN}) do |store|
       edited = AIDEN.sub("FN:Aiden", "FN:Aiden Smith")
 
-      contact = store.rewrite("aiden", edited, birthday: ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
+      contact = store.rewrite("aiden", vcard(edited), birthday: ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
 
       assert_equal edited, card_row(store, "aiden").fetch(:vcard)
       composed = edited.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n")
@@ -369,7 +356,7 @@ class StoreTest < Minitest::Test
   # what a card-only save does — keeps the row.
   def test_a_rewrite_writing_the_current_birthday_keeps_it
     with_store({"aiden" => AIDEN_BORN}) do |store|
-      store.rewrite("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"),
+      store.rewrite("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")),
                     birthday: ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
 
       assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), birthday_row(store, "aiden")
@@ -385,7 +372,7 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN_BORN}) do |store|
       stored = AIDEN_BORN.sub("BDAY:1985-04-12\r\n", "")
 
-      contact = store.rewrite("aiden", stored, birthday: ProTacts::Birthday.new(month: 4, day: 12))
+      contact = store.rewrite("aiden", vcard(stored), birthday: ProTacts::Birthday.new(month: 4, day: 12))
 
       assert_equal stored, card_row(store, "aiden").fetch(:vcard)
       assert_includes contact.vcard.to_s, "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\n"
@@ -400,7 +387,7 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN_BORN}) do |store|
       stored = AIDEN_BORN.sub("BDAY:1985-04-12\r\n", "")
 
-      contact = store.rewrite("aiden", stored, birthday: nil)
+      contact = store.rewrite("aiden", vcard(stored), birthday: nil)
 
       assert_nil birthday_row(store, "aiden")
       refute_includes contact.vcard.to_s, "BDAY"
@@ -430,7 +417,7 @@ class StoreTest < Minitest::Test
   def test_changes_can_be_read_from_a_sequence_on
     with_store({"aiden" => AIDEN}) do |store|
       token = store.changes.last.sequence
-      store.put("znorth", ZED)
+      store.put("znorth", vcard(ZED))
 
       assert_equal %w[znorth], store.changes(after: token).map { it.card_id }
     end
@@ -442,7 +429,7 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN}) do |store|
       highest = store.changes.last.sequence
       store.delete("aiden")
-      store.put("znorth", ZED)
+      store.put("znorth", vcard(ZED))
 
       assert_operator store.changes.last.sequence, :>, highest
     end
@@ -461,7 +448,7 @@ class StoreTest < Minitest::Test
       path = Pathname.new(dir) / "contacts.db"
 
       FailingLog.connect(path) do |store|
-        assert_raises(RuntimeError) { store.put("aiden", AIDEN) }
+        assert_raises(RuntimeError) { store.put("aiden", vcard(AIDEN)) }
       end
 
       # Reopened, so this is the file talking and not a cache.
@@ -479,7 +466,7 @@ class StoreTest < Minitest::Test
     with_store do |store|
       assert_raises(RuntimeError) do
         database(store).transaction do
-          store.put("aiden", AIDEN)
+          store.put("aiden", vcard(AIDEN))
           raise "the fan-out failed"
         end
       end
@@ -506,7 +493,7 @@ class StoreTest < Minitest::Test
 
   def test_replacing_a_card_replaces_its_index_rows
     with_store({"aiden" => AIDEN}) do |store|
-      store.put("aiden", AIDEN.sub("FN:Aiden\r\n", ""))
+      store.put("aiden", vcard(AIDEN.sub("FN:Aiden\r\n", "")))
 
       assert_equal %w[BEGIN VERSION UID END], indexed_names(store, "aiden")
     end
@@ -573,7 +560,7 @@ class StoreTest < Minitest::Test
   def test_every_other_contact_serves_beside_one_unparseable_card
     with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
       good_cards = store.ctag
-      store.put("broken", "this is not a vCard\r\n")
+      store.put("broken", vcard("this is not a vCard\r\n"))
 
       assert_equal %w[aiden broken znorth], store.contacts.map { it.id }
       assert_equal AIDEN, store.contact("aiden").vcard.to_s
@@ -624,7 +611,7 @@ class StoreTest < Minitest::Test
   def test_the_ctag_moves_with_a_birthday_alone
     with_store({"aiden" => AIDEN}) do |store|
       before = store.ctag
-      store.put("aiden", AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"))
+      store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n")))
 
       refute_equal before, store.ctag
     end
@@ -641,7 +628,7 @@ class StoreTest < Minitest::Test
   # read-modify-write client is the user removing it.
   def test_a_card_put_back_without_its_served_birthday_loses_it
     with_store({"aiden" => AIDEN_BORN}) do |store|
-      store.put("aiden", AIDEN)
+      store.put("aiden", vcard(AIDEN))
 
       assert_nil birthday_row(store, "aiden")
       assert_equal AIDEN, store.contact("aiden").vcard.to_s
@@ -655,7 +642,7 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN}) do |store|
       database(store)[:birthdays].insert(card_id: "aiden", year: 1985)
 
-      store.put("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
+      store.put("aiden", vcard(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.to_s
@@ -669,7 +656,7 @@ class StoreTest < Minitest::Test
       unmodeled = AIDEN.sub("END:VCARD\r\n", "#{bday}END:VCARD\r\n")
 
       with_store({"aiden" => AIDEN_BORN}) do |store|
-        store.put("aiden", unmodeled)
+        store.put("aiden", vcard(unmodeled))
 
         assert_equal unmodeled, store.contact("aiden").vcard.to_s, bday
         assert_nil birthday_row(store, "aiden"), bday
@@ -687,7 +674,7 @@ class StoreTest < Minitest::Test
     shared = AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\rNOTE:b\r\nEND:VCARD\r\n")
 
     with_store({"aiden" => AIDEN_BORN}) do |store|
-      store.put("aiden", shared)
+      store.put("aiden", vcard(shared))
       messages = sentry_messages
 
       assert_equal shared, store.contact("aiden").vcard.to_s
@@ -705,7 +692,7 @@ class StoreTest < Minitest::Test
       with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")}) do |store|
         edited = AIDEN.sub("FN:Aiden", "FN:Aiden Smith")
 
-        store.put("aiden", edited)
+        store.put("aiden", vcard(edited))
 
         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
@@ -719,7 +706,7 @@ class StoreTest < Minitest::Test
   # deletable at all.
   def test_a_birthday_a_client_renders_is_deleted_by_a_rewrite_without_it
     with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n")}) do |store|
-      store.put("aiden", AIDEN)
+      store.put("aiden", vcard(AIDEN))
 
       assert_equal AIDEN, store.contact("aiden").vcard.to_s
       assert_nil birthday_row(store, "aiden")
@@ -734,7 +721,7 @@ class StoreTest < Minitest::Test
     edited = AIDEN.sub("FN:Aiden", "FN:Aiden Smith")
 
     with_store({"aiden" => shared}) do |store|
-      store.put("aiden", edited)
+      store.put("aiden", vcard(edited))
       messages = sentry_messages
 
       assert_equal edited, store.contact("aiden").vcard.to_s
@@ -748,7 +735,7 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04\r\nEND:VCARD\r\n")}) do |store|
       born = AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n")
 
-      store.put("aiden", born)
+      store.put("aiden", vcard(born))
 
       assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), birthday_row(store, "aiden")
       assert_equal born, store.contact("aiden").vcard.to_s
@@ -762,7 +749,7 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY:1985-13\r\nEND:VCARD\r\n")}) do |store|
       # The seed's own arrival report is not this test's subject.
       clear_sentry_events
-      store.put("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
+      store.put("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")))
       messages = sentry_messages
 
       assert_equal 1, messages.length
@@ -776,7 +763,7 @@ class StoreTest < Minitest::Test
   def test_a_rewrite_over_known_bdays_stays_quiet
     ["BDAY:--0412", "BDAY:1985-04"].each do |line|
       with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")}) do |store|
-        store.put("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
+        store.put("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")))
 
         assert_empty sentry_messages, line
       end
@@ -792,7 +779,7 @@ class StoreTest < Minitest::Test
     ["BDAY:1985-13", "BDAY:--0432", "BDAY:19850412", "BDAY:1985-4"].each do |line|
       with_store({}) do |store|
         clear_sentry_events
-        store.put("aiden", AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n"))
+        store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")))
 
         assert_equal 1, sentry_messages.length, line
       end
@@ -803,27 +790,14 @@ class StoreTest < Minitest::Test
     ["BDAY:1985-04-12", "BDAY:1985-04-12T23:10:00Z", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12",
       "BDAY:--0412", "BDAY:--04-12", "BDAY:1985-04", "BDAY:1985"].each do |line|
       with_store({}) do |store|
-        store.put("aiden", AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n"))
+        store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")))
 
         assert_empty sentry_messages, line
       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
-
+  ## The change log
   def test_a_stored_card_is_never_indexed_with_a_bday
     with_store({"aiden" => AIDEN_BORN}) do |store|
       assert_equal %w[BEGIN VERSION FN UID END], indexed_names(store, "aiden")
@@ -919,7 +893,7 @@ class StoreTest < Minitest::Test
       path = Pathname.new(dir) / "contacts.db"
 
       FailingLog.connect(path) do |store|
-        assert_raises(RuntimeError) { store.put("aiden", AIDEN_BORN) }
+        assert_raises(RuntimeError) { store.put("aiden", vcard(AIDEN_BORN)) }
       end
 
       ProTacts::Store.connect(path) do |store|
@@ -1041,8 +1015,8 @@ class StoreTest < Minitest::Test
     with_store({"aiden" => AIDEN}) do |store|
       add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
 
-      put = store.put("aiden", AIDEN)
-      edit = store.rewrite("aiden", AIDEN, birthday: nil)
+      put = store.put("aiden", vcard(AIDEN))
+      edit = store.rewrite("aiden", vcard(AIDEN), birthday: nil)
       logged = database(store)[:changes].where(card_id: "aiden").order(:sequence).all
 
       assert_equal [put.etag, edit.etag], logged.last(2).map { it[:etag] }
@@ -1186,7 +1160,7 @@ class StoreTest < Minitest::Test
       add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
       served = store.contact("aiden").vcard.to_s
 
-      contact = store.put("aiden", served)
+      contact = store.put("aiden", vcard(served))
 
       assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
       assert_equal served, contact.vcard.to_s
@@ -1203,7 +1177,7 @@ class StoreTest < Minitest::Test
       add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
-      store.put("aiden", served)
+      store.put("aiden", vcard(served))
 
       assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
       assert_equal served, store.contact("aiden").vcard.to_s
@@ -1221,7 +1195,7 @@ class StoreTest < Minitest::Test
       add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
-      store.put("aiden", served)
+      store.put("aiden", vcard(served))
 
       assert_equal own, card_row(store, "aiden").fetch(:vcard)
       assert_equal served, store.contact("aiden").vcard.to_s
@@ -1237,7 +1211,7 @@ class StoreTest < Minitest::Test
       add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
-      store.put("aiden", served.sub(HOUSEHOLD_ADDRESS, EDITED_ADDRESS))
+      store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, EDITED_ADDRESS)))
 
       assert_includes card_row(store, "aiden").fetch(:vcard), EDITED_ADDRESS
       composed = store.contact("aiden").vcard.to_s
@@ -1256,7 +1230,7 @@ class StoreTest < Minitest::Test
       add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
-      store.put("aiden", served.sub("#{HOUSEHOLD_ADDRESS}\r\n", ""))
+      store.put("aiden", vcard(served.sub("#{HOUSEHOLD_ADDRESS}\r\n", "")))
 
       assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
       assert_equal served, store.contact("aiden").vcard.to_s
@@ -1273,7 +1247,7 @@ class StoreTest < Minitest::Test
       served = store.contact("aiden").vcard.to_s
       submitted = served.sub(HOUSEHOLD_ADDRESS, "#{EDITED_ADDRESS}\r\n#{OWN_ADDRESS}")
 
-      store.put("aiden", submitted)
+      store.put("aiden", vcard(submitted))
 
       assert_equal submitted, card_row(store, "aiden").fetch(:vcard)
       assert_equal 1, sentry_messages.length
@@ -1292,7 +1266,7 @@ class StoreTest < Minitest::Test
       served = store.contact("aiden").vcard.to_s
       assert_equal 2, served.scan(HOUSEHOLD_ADDRESS).length
 
-      store.put("aiden", served)
+      store.put("aiden", vcard(served))
 
       assert_equal own, card_row(store, "aiden").fetch(:vcard)
       assert_equal served, store.contact("aiden").vcard.to_s
@@ -1320,8 +1294,8 @@ class StoreTest < Minitest::Test
 
         # The widened vocabulary admits the editor's action, and the
         # sequence continues past the copied high-water mark.
-        store.put("aiden", AIDEN)
-        store.rewrite("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"), birthday: nil)
+        store.put("aiden", vcard(AIDEN))
+        store.rewrite("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")), birthday: nil)
         change = store.changes.last
         assert_equal %w[aiden edit], [change.card_id, change.action]
         assert_equal 4, change.sequence
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 6b6a20e..20bde3d 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -129,6 +129,23 @@ class VCardTest < Minitest::Test
     end
   end
 
+  ## The card's bytes
+
+  # Relabelling bytes is not converting them, so bytes that are not
+  # UTF-8 at all never become a card: better than a store serving them
+  # back as `text/vcard; charset=utf-8` while they are no such thing.
+  # Relabelled first, as the route would (Web#write_card), and this
+  # pins the line that catches it — the card, before any walk or any
+  # bind below it could meet the bytes, and now before a store is even
+  # handed one.
+  def test_bytes_that_are_not_utf_8_are_not_a_card
+    invalid = "BEGIN:VCARD\r\nFN:\xFF\xFE\r\nEND:VCARD\r\n".dup.force_encoding(Encoding::UTF_8)
+
+    error = assert_raises(ArgumentError) { ProTacts::VCard.new(invalid) }
+
+    assert_match "not valid UTF-8", error.message
+  end
+
   ## The envelope
 
   # The envelope tests proper live in vcard/test_lines.rb, over the
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 0fddd86..0db6f8e 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -10,6 +10,7 @@ require "tmpdir"
 
 require "pro_tacts/config"
 require "pro_tacts/store"
+require "pro_tacts/vcard"
 require "pro_tacts/web"
 
 class WebTest < Minitest::Test
@@ -726,7 +727,7 @@ class WebTest < Minitest::Test
       ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
         ProTacts::Web.store = store
         names.each do |id, name|
-          store.put(id, card(id, name))
+          store.put(id, ProTacts::VCard.new(card(id, name)))
         end
         yield store
       ensure
@@ -858,7 +859,7 @@ class WebTest < Minitest::Test
       assert_includes last_response.body, "<d:sync-token>#{token}</d:sync-token>"
 
       # A change after the token is the whole of the next delta.
-      store.put("aiden", card("aiden", "Aiden Smith"))
+      store.put("aiden", ProTacts::VCard.new(card("aiden", "Aiden Smith")))
       request "/dav/addressbook/", method: "REPORT", input: sync_collection(token)
       assert_includes last_response.body, "/dav/addressbook/aiden.vcf"
       refute_equal token, last_response.body[%r{<d:sync-token>(.+)</d:sync-token>}, 1]
@@ -960,7 +961,7 @@ class WebTest < Minitest::Test
       before = read_tags.call
       assert_equal before, read_tags.call # stable across requests
 
-      store.put("aiden", card("aiden", "Aiden Smith"))
+      store.put("aiden", ProTacts::VCard.new(card("aiden", "Aiden Smith")))
       after = read_tags.call
 
       before.zip(after).each do