names: vcard is a VCard, and the bytes under one are bytes
The body a PUT reads is octets, and it answered to `vcard` while the
card read from it answered to `card` — one name for the thing it was
not, one line apart. Now `vcard` is the card wherever a card needs no
qualifier to say which one, `bytes` is what a card is read from, and
the two helpers that took a card's octets under the card's own name
take `bytes` instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvMYjgmPgf7wa8nzH6DJwW
change
commit 649adc45400f27f6de3ff8d0a5636b635ed7b10a
author Claude <noreply@anthropic.com>
date
parent 856bfb6c
diff --git a/AGENTS.md b/AGENTS.md
index 9438b8e..e77d55e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -190,6 +190,15 @@ not a fixture; edit a `.vcf` to change what the replay serves.
 - `escape` and `fold` in `vcard.rb` have no caller yet. They are the
   writer's half of the module, kept deliberately for PUT and the web
   editor, and tested directly rather than through anything that serves.
+- `vcard` names a `VCard` and nothing else, and a card with no
+  qualifier to give it is named `vcard`. A `vcard` holding a card's
+  bytes is the confusion the rule exists to stop: the two sit one
+  `VCard.new` apart, and at a PUT that gap is the whole question —
+  the body arrives as octets and everything below the route wants
+  the card — so the octets are `bytes` (`Web#write_card`). A card
+  that needs saying which one keeps its own word (`stored`, `own`,
+  `rest`). The `cards` column and the `text/vcard` media type spell
+  it the same way and are neither of them variables.
 - `docs/plans/` entries are dated records of what was decided then. Write
   a new one rather than editing an old one to match current behavior.
 - Comments carry the reasoning; the code carries the rest. One that
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 945e0a5..ecc62b6 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -163,9 +163,9 @@ module ProTacts
     def vcard
       return @vcard if defined?(@vcard)
 
-      card = @stored.insert(@inherited.map { it.line })
+      with_inherited = @stored.insert(@inherited.map { it.line })
       line = @birthday && @birthday.to_line
-      @vcard = line ? card.insert([line]) : card
+      @vcard = line ? with_inherited.insert([line]) : with_inherited
     end
 
     # The same contact with nothing its groups lend it: the editor's
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 88556a6..9c54552 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -279,8 +279,8 @@ module ProTacts
     # 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)
+    #: (String id, VCard vcard) -> Contact
+    def put(id, 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;
@@ -305,12 +305,12 @@ module ProTacts
       # attributing any to a group.
       own = stored_card(id)
       birthday, stored =
-        case card.extract("BDAY")
+        case vcard.extract("BDAY")
         in [[line], rest]
           report_unrecognized_bday_lines([line])
           property = bday_of(line)
           birthday = property && Birthday.from_property(property)
-          [birthday, birthday ? rest : card]
+          [birthday, birthday ? rest : vcard]
         in [[], _]
           # The rewrite arm: carry the unrendered lines out of the
           # stored card, report the unrecognized ones' loss, and keep
@@ -318,12 +318,12 @@ module ProTacts
           carried, lost = carried_and_lost_bday_lines(own)
           kept = existing && !existing.served? ? existing : nil
           report_lost_bday_lines(lost)
-          [kept, card.insert(carried)]
+          [kept, vcard.insert(carried)]
         in [lines, _]
           # More than one BDAY: cardinality-broken data, kept verbatim
           # and reported like any other unrecognized line.
           report_unrecognized_bday_lines(lines)
-          [nil, card]
+          [nil, vcard]
         end
 
       # The other half of the split, the same shape as the birthday's:
@@ -359,16 +359,16 @@ module ProTacts
     # 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))
+    #: (String id, VCard vcard, birthday: Birthday?) -> Contact
+    def rewrite(id, vcard, birthday:)
+      contact = Contact.new(id:, stored: vcard, birthday:, inherited: inherited_of(id))
       @database.transaction do
         cards
           .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
-          .insert(id: contact.id, vcard: card.to_s)
+          .insert(id: contact.id, vcard: vcard.to_s)
         write_birthday(contact.id, birthday)
         record(contact.id, "edit", contact.etag)
-        reindex(contact.id, card)
+        reindex(contact.id, vcard)
       end
       contact
     end
@@ -493,13 +493,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, VCard card) -> void
-    def reindex(id, card)
+    #: (String id, VCard vcard) -> void
+    def reindex(id, vcard)
       # 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
 
-      card.properties.each.with_index do |property, position|
+      vcard.properties.each.with_index do |property, position|
         card_properties.insert(
           card_id: id,
           position:,
@@ -549,11 +549,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.
-    #: (VCard? card) -> [Array[String], Array[VCard::Parser::Line]]
-    def carried_and_lost_bday_lines(card)
-      return [[], []] if card.nil?
+    #: (VCard? vcard) -> [Array[String], Array[VCard::Parser::Line]]
+    def carried_and_lost_bday_lines(vcard)
+      return [[], []] if vcard.nil?
 
-      bdays, = card.extract("BDAY")
+      bdays, = vcard.extract("BDAY")
       carried, rest = bdays.partition { |line|
         property = bday_of(line)
         property && Birthday.unrendered_value?(property.value)
@@ -614,11 +614,11 @@ module ProTacts
     # this leaves keep their positions, so a submission that was the
     # served card round-trips to the bytes it was composed from and the
     # PUT can answer with a strong etag (RFC 6352 section 6.3.2.3).
-    #: (VCard card, Array[Contact::Inherited] inherited, VCard? own) -> VCard
-    def subtract_inherited(card, inherited, own)
-      return card if inherited.empty?
+    #: (VCard vcard, Array[Contact::Inherited] inherited, VCard? own) -> VCard
+    def subtract_inherited(vcard, inherited, own)
+      return vcard if inherited.empty?
 
-      unaccounted = unaccounted_lines(card, own)
+      unaccounted = unaccounted_lines(vcard, own)
       untouched = [] #: Array[VCard::Parser::Line]
       ambiguous = 0
 
@@ -640,7 +640,7 @@ module ProTacts
       end
 
       report_ambiguous_inherited_lines(ambiguous)
-      untouched.reduce(card) { |rest, line| rest.substitute(line.digest, []) }
+      untouched.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }
     end
 
     # The submission's lines that the member's own stored card does not
@@ -650,10 +650,10 @@ module ProTacts
     # client wrote beside it, which is the pool a lent line is
     # attributed from. Everything for a card being created, which has
     # no stored lines to explain anything.
-    #: (VCard card, VCard? own) -> Array[VCard::Parser::Line]
-    def unaccounted_lines(card, own)
+    #: (VCard vcard, VCard? own) -> Array[VCard::Parser::Line]
+    def unaccounted_lines(vcard, own)
       stored = own ? own.lines.map { it.verbatim.chomp } : [] #: Array[String]
-      card.lines.reject { |line|
+      vcard.lines.reject { |line|
         index = stored.index(line.verbatim.chomp)
         stored.delete_at(index) if index
         index
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 4e57c61..b175555 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -631,8 +631,8 @@ module ProTacts
       # past the point where the client could be told what was wrong.
       # Paths need no counterpart: Puma hands PATH_INFO over still
       # percent-encoded, so an id off the wire is ASCII.
-      vcard = request.body.read.force_encoding(Encoding::UTF_8)
-      return precondition("valid-address-data") unless vcard.valid_encoding?
+      bytes = request.body.read.force_encoding(Encoding::UTF_8)
+      return precondition("valid-address-data") unless bytes.valid_encoding?
 
       # CARDDAV:valid-address-data again, on the card this time: the
       # envelope RFC 2426 section 4 requires. That is the whole test. A
@@ -640,8 +640,8 @@ module ProTacts
       # refusing the card: RFC 6352 section 6.3.2.2 has the server keep
       # what it does not understand, and it is kept — the stored bytes
       # are what goes back out.
-      card = VCard.new(vcard)
-      return precondition("valid-address-data") unless card.card?
+      vcard = VCard.new(bytes)
+      return precondition("valid-address-data") unless vcard.card?
 
       # CARDDAV:no-uid-conflict: the submitted UID must not belong to a
       # different resource, and a mapped URI must not be overwritten by
@@ -650,7 +650,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 = card.uid
+      uid = vcard.uid
       owner = uid && store.card_id_with_uid(uid)
       return precondition("no-uid-conflict", owner) if uid != id || owner && owner != id
 
@@ -665,10 +665,10 @@ module ProTacts
       if_none_match = request.env["HTTP_IF_NONE_MATCH"]
       return plain_412 if if_none_match && if_none_match_failed?(if_none_match, existing)
 
-      report_unreadable_lines(card)
-      report_broken_assumptions(card)
+      report_unreadable_lines(vcard)
+      report_broken_assumptions(vcard)
 
-      stored = store.put(id, card)
+      stored = store.put(id, vcard)
       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 == card.to_s
+      response["ETag"] = stored.etag if stored.vcard.to_s == vcard.to_s
 
       # A returned "" would land in the body and pin text/html and
       # content-length onto the 204, which a bodyless status must not
@@ -817,14 +817,14 @@ module ProTacts
       # (Contact#own).
       own = contact.own
 
-      card = contact.stored
-      card = card.replace("N", [n_line(card, first, last)])
-      card = edited_fn(contact, card, first, last)
-      card = card.replace("NICKNAME", text_lines("NICKNAME", nickname))
-      card = card.replace("NOTE", text_lines("NOTE", note))
-      card = edited_phones(own, card, params)
-      card = edited_emails(own, card, params)
-      edited_addresses(own, card, params)
+      vcard = contact.stored
+      vcard = vcard.replace("N", [n_line(vcard, first, last)])
+      vcard = edited_fn(contact, vcard, first, last)
+      vcard = vcard.replace("NICKNAME", text_lines("NICKNAME", nickname))
+      vcard = vcard.replace("NOTE", text_lines("NOTE", note))
+      vcard = edited_phones(own, vcard, params)
+      vcard = edited_emails(own, vcard, params)
+      edited_addresses(own, vcard, params)
     end
 
     # The phones' half of the surgical save: each row names its line
@@ -836,8 +836,8 @@ module ProTacts
     # parameters no field models ride. The addresses come from the
     # contact, never the request, so a doctored digest names nothing
     # and a missing one touches nothing.
-    #: (Contact contact, VCard card, Hash[String, untyped] params) -> VCard
-    def edited_phones(contact, card, params)
+    #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
+    def edited_phones(contact, vcard, params)
       rows = params["phone"]
       if rows.is_a?(Hash)
         contact.phones.each do |phone|
@@ -851,7 +851,7 @@ module ProTacts
           next if submitted.nil? || submitted.to_s.strip == phone.value
 
           value = submitted.to_s.strip
-          card = card.substitute(
+          vcard = vcard.substitute(
             phone.line.digest,
             value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"]
           )
@@ -870,12 +870,12 @@ module ProTacts
         value = it.to_s.strip
         "TEL:#{VCard.escape(value)}\r\n" unless value.empty?
       } #: Array[String]
-      card.insert(added)
+      vcard.insert(added)
     end
 
     # #edited_phones' walk, over EMAIL.
-    #: (Contact contact, VCard card, Hash[String, untyped] params) -> VCard
-    def edited_emails(contact, card, params)
+    #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
+    def edited_emails(contact, vcard, params)
       rows = params["email"]
       if rows.is_a?(Hash)
         contact.emails.each do |email|
@@ -886,7 +886,7 @@ module ProTacts
           next if submitted.nil? || submitted.to_s.strip == email.value
 
           value = submitted.to_s.strip
-          card = card.substitute(
+          vcard = vcard.substitute(
             email.line.digest,
             value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"],
           )
@@ -898,7 +898,7 @@ module ProTacts
         value = it.to_s.strip
         "EMAIL:#{VCard.escape(value)}\r\n" unless value.empty?
       } #: Array[String]
-      card.insert(added)
+      vcard.insert(added)
     end
 
     # #edited_phones' walk, over a row that is six fields rather than
@@ -906,8 +906,8 @@ module ProTacts
     # (Contact#address_of): a row blank throughout, po box included,
     # removes the line, where a partially blanked one keeps it —
     # partial blanks are legal empty components.
-    #: (Contact contact, VCard card, Hash[String, untyped] params) -> VCard
-    def edited_addresses(contact, card, params)
+    #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
+    def edited_addresses(contact, vcard, params)
       rows = params["address"]
       if rows.is_a?(Hash)
         contact.addresses.each do |address|
@@ -918,7 +918,7 @@ module ProTacts
           next if !submitted.is_a?(Hash) || address_unchanged?(address, submitted)
 
           line = address_line(address, submitted)
-          card = card.substitute(address.line.digest, line ? [line] : [])
+          vcard = vcard.substitute(address.line.digest, line ? [line] : [])
         end
       end
 
@@ -930,7 +930,7 @@ module ProTacts
       # included.
       submitted = params["new_address"]
       added = submitted.is_a?(Hash) ? submitted.values : Array(submitted) #: Array[untyped]
-      card.insert(added.filter_map { address_line(nil, it) })
+      vcard.insert(added.filter_map { address_line(nil, it) })
     end
 
     #: (Contact::Address address, untyped submitted) -> bool
@@ -1025,13 +1025,13 @@ module ProTacts
     # name is stale. A card carrying no FN is the other case: the
     # property is mandatory (section 4), so a save fills it in rather
     # than leaving the absence it found.
-    #: (Contact contact, VCard card, String first, String last) -> VCard
-    def edited_fn(contact, card, first, last)
+    #: (Contact contact, VCard vcard, String first, String last) -> VCard
+    def edited_fn(contact, vcard, first, last)
       family, given = contact.name_components || []
-      named = card.properties.any? { it.name.casecmp?("FN") }
-      return card if named && first == given.to_s && last == family.to_s
+      named = vcard.properties.any? { it.name.casecmp?("FN") }
+      return vcard if named && first == given.to_s && last == family.to_s
 
-      card.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n"])
+      vcard.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n"])
     end
 
     # N's replacement line. The first two components are the form's;
@@ -1042,9 +1042,9 @@ module ProTacts
     # form can read splices into a bare five-component value; one
     # short of five is padded, the same empties a whole-N writer
     # would leave.
-    #: (VCard card, String first, String last) -> String
-    def n_line(card, first, last)
-      property = card.properties.find { it.name.casecmp?("N") }
+    #: (VCard vcard, String first, String last) -> String
+    def n_line(vcard, first, last)
+      property = vcard.properties.find { it.name.casecmp?("N") }
       components = [] #: Array[String]
       components.replace(VCard.split_raw_components(property.value)) if property
       components << "" while components.length < 5
@@ -1068,9 +1068,9 @@ module ProTacts
     # message, and a PUT is the only arrival there is. The message
     # carries no card content (ProTacts::SentryScrubber's line); the
     # admin view shows the card raw.
-    #: (VCard card) -> void
-    def report_broken_assumptions(card)
-      broken = card.lines.count { it.broke_assumption? }
+    #: (VCard vcard) -> void
+    def report_broken_assumptions(vcard)
+      broken = vcard.lines.count { it.broke_assumption? }
       return if broken.zero?
 
       Sentry.capture_message(
@@ -1084,9 +1084,9 @@ module ProTacts
     # the no-card-content line the store's BDAY reports already use.
     # BrokenAssumption lines are excluded, so a line reports once; why
     # this lives at the PUT is the comment above, and holds for both.
-    #: (VCard card) -> void
-    def report_unreadable_lines(card)
-      unreadable = card.lines.count { it.unreadable? }
+    #: (VCard vcard) -> void
+    def report_unreadable_lines(vcard)
+      unreadable = vcard.lines.count { it.unreadable? }
       return if unreadable.zero?
 
       Sentry.capture_message(
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index 039b9e1..d71f257 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -12,8 +12,8 @@ class FormatTest < Minitest::Test
   # beside a Birthday, a fallback case passes a card carrying an
   # unmodeled BDAY beside nil, and inheritance defaults to none (see
   # Contact).
-  def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [])
-    ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(vcard), birthday:, inherited:)
+  def contact(bytes = CARD, id: "aiden", birthday: nil, inherited: [])
+    ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(bytes), birthday:, inherited:)
   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 983379a..ea7c10c 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -29,9 +29,9 @@ class ContactTest < Minitest::Test
   # lender passes its own.
   GROUP = "Booles" #: String
 
-  def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [], group: GROUP)
+  def contact(bytes = CARD, id: "aiden", birthday: nil, inherited: [], group: GROUP)
     lent = inherited.map { ProTacts::Contact::Inherited.new(group:, line: it) }
-    ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(vcard), birthday:, inherited: lent)
+    ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(bytes), birthday:, inherited: lent)
   end
 
   # The decode's refusal reports (see #photo); the transport this pins