contact: take its birthday instead of parsing it back
The parse recovered a fact that never left the room — Store had just
composed it in — and deriving structure from bytes stops being able to
answer once groups compose inherited properties. Contact now holds the
stored card beside the birthday and composes the served one on read;
the wire bytes and etags do not move.

Assisted-by: GLM-5.3 via pi
change yoylrxvrzllwrotuypynrvyqntrozyqu
commit 58afc7cc24256f8c265eaa694e3803c26332007e
author Alpha Chen <alpha@kejadlen.dev>
date
parent unwpxvuz
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index cf26b28..1c288e2 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -17,6 +17,15 @@ module ProTacts
   # 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 card this holds is the stored one, and the birthday sits beside
+  # it rather than being parsed back out of the card it was composed
+  # into (docs/plans/2026-09-05-contact-takes-its-structure.md): Store's
+  # write either moves a BDAY line into the model or leaves it in the
+  # card, never both, so the two facts are independent and #vcard
+  # composes the served card from them. An unmodeled BDAY spelling is
+  # the card's own — #properties shows it, and #birthday reads nil over
+  # 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
@@ -26,8 +35,8 @@ 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, and the card defers the
-  # walk that reads them until something asks. The CardDAV paths never
+  # contents: everything else 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.
@@ -37,6 +46,8 @@ module ProTacts
   # one. #properties is the read for those.
   class Contact
     # @rbs @id: String
+    # @rbs @stored: VCard
+    # @rbs @birthday: Birthday?
     # @rbs @vcard: VCard
     # @rbs @etag: String
 
@@ -69,14 +80,20 @@ module ProTacts
       :type,
     )
 
-    # 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: VCard) -> Contact
-    def self.for(id:, vcard:)
+    # A contact from its id, its stored card, and its birthday. The
+    # only way to make one: an etag that came from anywhere but the
+    # card in hand is an etag that can be wrong, and a birthday from
+    # anywhere but the store is wrong the same way — which is why
+    # `birthday:` is required and carries no default, since an
+    # optional nil would let a caller quietly get no birthday off a
+    # card that has one. Named `stored:` rather than `vcard:` because
+    # what #vcard returns is composed, and an argument that is not
+    # what the reader hands back is a trap.
+    #: (id: String, stored: VCard, birthday: Birthday?) -> Contact
+    def self.for(id:, stored:, birthday:)
       raise ArgumentError, "invalid contact id: #{id}" unless id.match?(ID_FORMAT)
 
-      new(id, vcard, etag_for(vcard))
+      new(id, stored, birthday)
     end
 
     #: (VCard vcard) -> String
@@ -84,18 +101,45 @@ module ProTacts
       %("#{Digest::SHA256.hexdigest(vcard.to_s)}")
     end
 
-    #: (String id, VCard vcard, String etag) -> void
-    def initialize(id, vcard, etag)
+    #: (String id, VCard stored, Birthday? birthday) -> void
+    def initialize(id, stored, birthday)
       @id = id
-      @vcard = vcard
-      @etag = etag
+      @stored = stored
+      @birthday = birthday
     end
 
     attr_reader :id
 
-    attr_reader :vcard
+    # The model held beside the card (see the class comment) — the
+    # store's fact, not a parse of the served one. Nil over a card
+    # carrying an unmodeled BDAY spelling; showing that is
+    # #properties' job.
+    attr_reader :birthday
+
+    # The card to serve: the stored one with the birthday composed back
+    # in, immediately before END:VCARD — the same insert over the same
+    # two inputs Store composed at each read path before, so the bytes
+    # a client downloads do not move. A birthday with no wire form, or
+    # none at all, serves the stored card exactly as it is. Composed on
+    # first ask and memoized, and the etag's derivation deferred with
+    # it: a caller that reads neither — and the listing paths read
+    # neither — pays for no composition of its own.
+    #: () -> VCard
+    def vcard
+      return @vcard if defined?(@vcard)
+
+      line = @birthday && @birthday.to_line
+      @vcard = line ? @stored.insert([line]) : @stored
+    end
+
+    # The etag over #vcard's bytes, derived here on first ask and
+    # memoized with the card it hashes.
+    #: () -> String
+    def etag
+      return @etag if defined?(@etag)
 
-    attr_reader :etag
+      @etag = self.class.etag_for(vcard)
+    end
 
     # The image formats a decoded PHOTO is recognized by, prefix to
     # mime type. A picture's type is read off the decoded bytes rather
@@ -141,7 +185,7 @@ module ProTacts
     # 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 = @vcard.properties
+    def properties = vcard.properties
 
     # FN's value (RFC 2426 section 3.1.1), in text form.
     #: () -> String?
@@ -188,17 +232,6 @@ module ProTacts
       of_name("ADR").filter_map { address_of(it) }
     end
 
-    # The birthday the served card carries — the model the store
-    # composed into it (see Birthday), parsed back out because the two
-    # forms Birthday serves are exactly the two from_property accepts.
-    # Nil when the card carries no BDAY the model recomposes; an
-    # unmodeled spelling stays in the card and is #properties' to show.
-    #: () -> Birthday?
-    def birthday
-      property = properties.find { it.name.casecmp?("BDAY") }
-      property && Birthday.from_property(property)
-    end
-
     # The card's NOTE (RFC 2426 section 3.6.2), in text form.
     #: () -> String?
     def notes
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 7e24733..88edc41 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -29,9 +29,9 @@ module ProTacts
   # again from nothing.
   #
   # Every Contact this store hands out is composed, never the stored
-  # card alone: a birthday is subtracted out of a card on the way in
-  # and composed back in on the way out, so the vcard and the etag a
-  # caller sees — and the etag the change log records — describe the
+  # card alone: a birthday is subtracted out of a card on the way in,
+  # and Contact composes it back in on read, so the vcard and the etag
+  # a caller sees — and the etag the change log records — describe the
   # card a client downloads, not the bytes on disk.
   #
   # Sequel's transactions join one already open rather than failing on
@@ -275,7 +275,7 @@ module ProTacts
       # 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))
+      contact = Contact.for(id:, stored:, birthday:)
       @database.transaction do
         cards
           .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
@@ -471,29 +471,19 @@ module ProTacts
       )
     end
 
-    # The etag comes back out of the card rather than out of a column, so
-    # a row can never carry one that disagrees with the bytes beside it.
-    # The card is composed with its birthday first, so both halves
-    # describe what a client downloads.
+    # The birthday travels beside the card rather than inside it, and
+    # Contact composes the served one — its vcard, its etag, everything
+    # a caller reads — so both describe what a client downloads rather
+    # than the bytes on disk.
     #: (Hash[Symbol, untyped] row, Birthday? birthday) -> Contact
     def contact_from(row, birthday)
       Contact.for(
         id: row.fetch(:id).to_s,
-        vcard: with_birthday(VCard.new(row.fetch(:vcard).to_s), birthday),
+        stored: 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.
-    #: (VCard card, Birthday? birthday) -> VCard
-    def with_birthday(card, birthday)
-      line = birthday && birthday.to_line
-      return card if line.nil?
-
-      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
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index 7115cf5..b1ca2ca 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -6,10 +6,13 @@ 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"
+    "UID:ada\r\nEND:VCARD\r\n"
 
-  def contact(vcard = CARD, id: "aiden")
-    ProTacts::Contact.for(id:, vcard: ProTacts::VCard.new(vcard))
+  # The production shapes: a modeled birthday passes a BDAY-free card
+  # beside a Birthday, and a fallback case passes a card carrying an
+  # unmodeled BDAY beside nil (see Contact).
+  def contact(vcard = CARD, id: "aiden", birthday: nil)
+    ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:)
   end
 
   # Initials come from the structured N property (given + family),
@@ -50,23 +53,27 @@ class FormatTest < Minitest::Test
   end
 
   def test_formats_the_birthday
-    assert_equal "December 10, 1985", ProTacts::Admin::Format.birthday(contact)
+    birthday = ProTacts::Birthday.new(year: 1985, month: 12, day: 10)
+
+    assert_equal "December 10, 1985", ProTacts::Admin::Format.birthday(contact(CARD, birthday:))
   end
 
   # RFC 2426 section 3.1.5 lets BDAY carry a date-time, and a client is
-  # free to send one; the display only reads the date part of it.
+  # free to send one; the display reads only the date part of a bare
+  # value it meets — the same drop the write path makes on the way in
+  # (Birthday.from_property).
   def test_a_birthday_with_a_time_component_still_formats
-    with_time = CARD.sub("BDAY:1985-12-10", "BDAY:1985-12-10T00:00:00Z")
+    with_time = CARD.sub("END:VCARD\r\n", "BDAY:1985-12-10T00:00:00Z\r\nEND:VCARD\r\n")
 
     assert_equal "December 10, 1985", ProTacts::Admin::Format.birthday(contact(with_time))
   end
 
   # Apple's 1604-sentinel spelling for "no year on file" (see
-  # ProTacts::Birthday), the shape a served card actually carries.
+  # ProTacts::Birthday), the model shape the served card carries.
   def test_a_birthday_with_no_year_omits_one
-    no_year = CARD.sub("BDAY:1985-12-10", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-12-10")
+    birthday = ProTacts::Birthday.new(month: 12, day: 10)
 
-    assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(no_year))
+    assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(CARD, birthday:))
   end
 
   # The reduced no-year spelling (Birthday::REDUCED_DATE), which macOS
@@ -74,14 +81,14 @@ class FormatTest < Minitest::Test
   # so the line stays in the card verbatim — display renders it as a
   # day all the same, not as the stored string.
   def test_a_reduced_no_year_birthday_displays_its_day
-    reduced = CARD.sub("BDAY:1985-12-10", "BDAY:--12-10")
+    reduced = CARD.sub("END:VCARD\r\n", "BDAY:--12-10\r\nEND:VCARD\r\n")
 
     assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(reduced))
   end
 
   # The undashed variant of the reduced spelling.
   def test_an_undashed_reduced_no_year_birthday_displays_its_day
-    basic = CARD.sub("BDAY:1985-12-10", "BDAY:--1210")
+    basic = CARD.sub("END:VCARD\r\n", "BDAY:--1210\r\nEND:VCARD\r\n")
 
     assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(basic))
   end
@@ -89,13 +96,13 @@ class FormatTest < Minitest::Test
   # Calendar-nonsense in the reduced spelling, rendered as the day it
   # names like the modeled one below.
   def test_a_calendar_nonsense_reduced_no_year_birthday_renders_its_components
-    nonsense = CARD.sub("BDAY:1985-12-10", "BDAY:--02-30")
+    nonsense = CARD.sub("END:VCARD\r\n", "BDAY:--02-30\r\nEND:VCARD\r\n")
 
     assert_equal "February 30", ProTacts::Admin::Format.birthday(contact(nonsense))
   end
 
   def test_a_birthday_that_will_not_parse_is_shown_as_stored
-    unparseable = CARD.sub("BDAY:1985-12-10", "BDAY:not-a-date")
+    unparseable = CARD.sub("END:VCARD\r\n", "BDAY:not-a-date\r\nEND:VCARD\r\n")
 
     assert_equal "not-a-date", ProTacts::Admin::Format.birthday(contact(unparseable))
   end
@@ -104,18 +111,18 @@ class FormatTest < Minitest::Test
   # ranges, not the calendar, and rendering is calendar-free — the
   # prose names the day it says rather than refusing to display.
   def test_a_calendar_nonsense_birthday_renders_its_components
-    nonsense = CARD.sub("BDAY:1985-12-10", "BDAY:1985-02-30")
+    nonsense = ProTacts::Birthday.new(year: 1985, month: 2, day: 30)
 
-    assert_equal "February 30, 1985", ProTacts::Admin::Format.birthday(contact(nonsense))
+    assert_equal "February 30, 1985", ProTacts::Admin::Format.birthday(contact(CARD, birthday: nonsense))
   end
 
   # The partial shapes no client renders still display: every form
   # the grammar admits has Birthday#to_s's one prose.
   def test_the_partial_shapes_display_their_components
-    year_month = CARD.sub("BDAY:1985-12-10", "BDAY:1985-10")
-    year = CARD.sub("BDAY:1985-12-10", "BDAY:1985")
-    month = CARD.sub("BDAY:1985-12-10", "BDAY:--07")
-    day = CARD.sub("BDAY:1985-12-10", "BDAY:---04")
+    year_month = CARD.sub("END:VCARD\r\n", "BDAY:1985-10\r\nEND:VCARD\r\n")
+    year = CARD.sub("END:VCARD\r\n", "BDAY:1985\r\nEND:VCARD\r\n")
+    month = CARD.sub("END:VCARD\r\n", "BDAY:--07\r\nEND:VCARD\r\n")
+    day = CARD.sub("END:VCARD\r\n", "BDAY:---04\r\nEND:VCARD\r\n")
 
     assert_equal "October 1985", ProTacts::Admin::Format.birthday(contact(year_month))
     assert_equal "1985", ProTacts::Admin::Format.birthday(contact(year))
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index a1c8fde..2e730c7 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -14,16 +14,29 @@ class ContactTest < Minitest::Test
     "TEL;TYPE=mobile:+1-555-0100\r\nTEL;TYPE=work:+1-555-0199\r\n" \
     "EMAIL;TYPE=home:ada@example.com\r\n" \
     "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 = CARD, id: "aiden")
-    ProTacts::Contact.for(id:, vcard: ProTacts::VCard.new(vcard))
+    "NOTE:Countess\\, mathematician.\r\nUID:ada\r\nEND:VCARD\r\n"
+
+  # The production shapes (see the class comment): a modeled birthday
+  # passes a BDAY-free card beside a Birthday, and a fallback case
+  # passes a card carrying an unmodeled BDAY beside nil — no stored
+  # card ever carries a BDAY the model recomposes.
+  def contact(vcard = CARD, id: "aiden", birthday: nil)
+    ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:)
   end
 
   def test_the_etag_hashes_the_card
     assert_equal %("#{Digest::SHA256.hexdigest(CARD)}"), contact.etag
   end
 
+  # The etag hashes the composed card, not the stored one, so it moves
+  # when a birthday moves even though the bytes on disk do not.
+  def test_the_etag_hashes_the_composed_card
+    birthday = ProTacts::Birthday.new(year: 1985, month: 12, day: 10)
+    composed = CARD.sub("END:VCARD\r\n", "BDAY:1985-12-10\r\nEND:VCARD\r\n")
+
+    assert_equal %("#{Digest::SHA256.hexdigest(composed)}"), contact(CARD, birthday:).etag
+  end
+
   def test_the_etag_moves_with_the_card
     etag = contact.etag
 
@@ -137,15 +150,36 @@ class ContactTest < Minitest::Test
     assert_nil contact.notes
   end
 
-  def test_the_birthday_is_the_model_the_store_composed
-    assert_equal ProTacts::Birthday.new(year: 1985, month: 12, day: 10), contact(STRUCTURED).birthday
+  # The birthday is the model held beside the card, not a parse of
+  # the card it composes into — the reader returns the store's fact.
+  def test_the_birthday_is_the_one_held_beside_the_card
+    birthday = ProTacts::Birthday.new(year: 1985, month: 12, day: 10)
+
+    assert_equal birthday, contact(STRUCTURED, birthday:).birthday
+  end
+
+  # The served card is the stored one with the birthday's line composed
+  # in immediately before END:VCARD — the bytes a client downloads.
+  def test_the_served_card_composes_the_birthday_in
+    birthday = ProTacts::Birthday.new(year: 1985, month: 12, day: 10)
+    composed = STRUCTURED.sub("END:VCARD\r\n", "BDAY:1985-12-10\r\nEND:VCARD\r\n")
+
+    assert_equal composed, contact(STRUCTURED, birthday:).vcard.to_s
+  end
+
+  # A birthday with no wire form — the shapes no client renders —
+  # composes nothing, and the stored card serves exactly as it is.
+  def test_a_birthday_with_no_wire_form_leaves_the_card_as_stored
+    year_alone = ProTacts::Birthday.new(year: 1985)
+
+    assert_equal STRUCTURED, contact(STRUCTURED, birthday: year_alone).vcard.to_s
   end
 
   # A BDAY in a spelling the model does not recompose stays in the card
-  # verbatim; the accessor reads nil, and showing it as stored is the
-  # substrate's job (Format.birthday).
+  # verbatim; the reader answers nil over it, and showing it as stored
+  # is the substrate's job (Format.birthday).
   def test_an_unmodeled_birthday_spelling_reads_nil
-    unmodeled = STRUCTURED.sub("BDAY:1985-12-10", "BDAY:not-a-date")
+    unmodeled = STRUCTURED.sub("END:VCARD\r\n", "BDAY:not-a-date\r\nEND:VCARD\r\n")
 
     assert_nil contact(unmodeled).birthday
   end