birthday: read a BDAY that spells out VALUE=date
Every date from the iPhone spelled VALUE=date out loud;
the default says nothing new, so the bare-line read is allowed.

Assisted-by: Claude Opus 5 via Claude Code
change ytnqqkzquvwypvvkkxluvzmqkklnttoo
commit ca951c4679bd1dcdb0cfc44cb086546cba845480
author Alpha Chen <alpha@kejadlen.dev>
date
parent wxxnprll
diff --git a/docs/apple-contacts.md b/docs/apple-contacts.md
index 6c71c19..f46f797 100644
--- a/docs/apple-contacts.md
+++ b/docs/apple-contacts.md
@@ -470,7 +470,7 @@ of the year 2 and goes back that way. A day-only birthday iOS never
 displayed comes back as a year-2 date it made up.
 
 Every birthday it writes spells out `value=date`, the default value type
-of RFC 2426 section 3.1.5, which `ProTacts::Birthday` does not read. The
+of RFC 2426 section 3.1.5, so the parameter adds nothing to the line. The
 carry across a rewrite (`plans/2026-09-01-birthdays-across-a-rewrite.md`)
 kept the three shapes iOS dropped; the made-up dates replaced what was
 stored. Verified 2026-09-11 against iOS 26.6.2 (dataaccessd/1.0).
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 10e8c71..3b73442 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -28,16 +28,16 @@ module ProTacts
       # form serves it (the editor can save a shape nothing renders,
       # and the details page still shows what the record holds) — then
       # any well-shaped stored spelling parsed by Birthday.from_value,
-      # bare properties only, like the model's own reads, and the raw
-      # value for everything else, shown as stored. The prose itself is
-      # Birthday#to_s, every shape's one rendering.
+      # bare properties only (VALUE=date aside), like the model's own
+      # reads, and the raw value for everything else, shown as stored.
+      # The prose itself is Birthday#to_s, every shape's one rendering.
       #: (Contact contact) -> String?
       def self.birthday(contact)
         property = contact.properties.find { it.name.casecmp?("BDAY") }
         return contact.birthday&.to_s if property.nil?
 
         birthday = contact.birthday
-        if birthday.nil? && property.group.nil? && property.parameters.empty?
+        if birthday.nil? && property.group.nil? && Birthday.significant_parameters(property).empty?
           birthday = Birthday.from_value(property.value)
         end
         birthday&.to_s || property.value
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index 959e545..c16225c 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -181,28 +181,39 @@ module ProTacts
     # Reads one parsed BDAY property into the model, or nil for every
     # spelling the model does not recompose — which keeps it in the card
     # verbatim rather than losing it (RFC 6352 section 6.3.2.2). Only the
-    # two forms #to_line emits are accepted, and only bare: any extra
-    # parameter travels with the line, so a line carrying one is left
-    # whole. A grouped `item1.BDAY` likewise stays, because its label
-    # pairing belongs to the card.
+    # two forms #to_line emits are accepted, and only bare but for
+    # VALUE=date (#significant_parameters): any other parameter travels
+    # with the line, so a line carrying one is left whole. A grouped
+    # `item1.BDAY` likewise stays, because its label pairing belongs to
+    # the card.
     def self.from_property(property)
       return nil if property.group
 
-      if property.parameters.empty? && (m = property.value.match(FULL_DATE))
+      parameters = significant_parameters(property)
+      if parameters.empty? && (m = property.value.match(FULL_DATE))
         new(year: m[1].to_i, month: m[2].to_i, day: m[3].to_i)
-      elsif apple_no_year?(property) && (m = property.value.match(OMIT_YEAR_DATE))
+      elsif apple_no_year?(parameters) && (m = property.value.match(OMIT_YEAR_DATE))
         new(month: m[1].to_i, day: m[2].to_i)
       end
     end
 
+    # A BDAY's parameters without VALUE=date, which names the default
+    # value type (RFC 2426 section 3.1.5) and so says nothing the bare
+    # line does not. iOS spells it out on every birthday it writes
+    # (docs/apple-contacts.md, "iOS writes a birthday it cannot hold as
+    # a date it made up").
+    def self.significant_parameters(property)
+      property.parameters.reject { |name, value| name.casecmp?("VALUE") && value.casecmp?("date") }
+    end
+
     # The parameter half of the Apple form, exactly: the one parameter,
     # named for the year it stands in. A different sentinel or a value
     # disagreeing with the year in the date is not the verified form and
     # is not claimed.
-    def self.apple_no_year?(property)
-      return false unless property.parameters.length == 1
+    def self.apple_no_year?(parameters)
+      return false unless parameters.length == 1
 
-      name, value = property.parameters.fetch(0)
+      name, value = parameters.fetch(0)
       name.casecmp?("X-APPLE-OMIT-YEAR") && value == OMIT_YEAR
     end
 
@@ -224,7 +235,7 @@ module ProTacts
     # the loss — Store#put, to Sentry.
     def self.rendered?(property)
       !from_property(property).nil? ||
-        (property.group.nil? && property.parameters.empty? && property.value.match?(REDUCED_DATE))
+        (property.group.nil? && significant_parameters(property).empty? && property.value.match?(REDUCED_DATE))
     end
   end
 end
diff --git a/sig/pro_tacts/birthday.rbs b/sig/pro_tacts/birthday.rbs
index ce352cb..d217ba0 100644
--- a/sig/pro_tacts/birthday.rbs
+++ b/sig/pro_tacts/birthday.rbs
@@ -29,7 +29,9 @@ module ProTacts
 
     def self.from_property: (VCard::Parser::Property property) -> Birthday?
 
-    def self.apple_no_year?: (VCard::Parser::Property property) -> bool
+    def self.significant_parameters: (VCard::Parser::Property property) -> Array[[String, String]]
+
+    def self.apple_no_year?: (Array[[String, String]] parameters) -> bool
 
     def self.unrendered_value?: (String value) -> bool
 
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index a42375e..89c3943 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -102,6 +102,14 @@ class FormatTest < Minitest::Test
     assert_equal "February 30", ProTacts::Admin::Format.birthday(contact(nonsense))
   end
 
+  # A line stored before the model read VALUE=date still carries it,
+  # and displays as the date it names.
+  def test_a_stored_value_date_birthday_displays_its_date
+    stored = CARD.sub("END:VCARD\r\n", "BDAY;value=date:1985-12-10\r\nEND:VCARD\r\n")
+
+    assert_equal "December 10, 1985", ProTacts::Admin::Format.birthday(contact(stored))
+  end
+
   def test_a_birthday_that_will_not_parse_is_shown_as_stored
     unparseable = CARD.sub("END:VCARD\r\n", "BDAY:not-a-date\r\nEND:VCARD\r\n")
 
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index 88014d8..0072c80 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -117,6 +117,26 @@ class BirthdayTest < Minitest::Test
     assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_property(sentinel)
   end
 
+  # VALUE=date names BDAY's default type (RFC 2426 section 3.1.5), and
+  # iOS spells it out on every birthday it writes.
+  def test_value_date_reads_as_the_bare_line_would
+    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12),
+      ProTacts::Birthday.from_property(property("1985-04-12", parameters: [["value", "date"]]))
+    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12),
+      ProTacts::Birthday.from_property(property("1985-04-12", parameters: [["VALUE", "DATE"]]))
+
+    sentinel = property("1604-04-12", parameters: [["VALUE", "date"], ["X-APPLE-OMIT-YEAR", "1604"]])
+    assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_property(sentinel)
+  end
+
+  # What iOS writes back for a no-year birthday: the sentinel's date
+  # without its parameter, a real 1604 as the bare line is
+  # (docs/plans/2026-08-31-partial-birthdays.md).
+  def test_a_value_date_1604_is_a_real_year
+    assert_equal ProTacts::Birthday.new(year: 1604, month: 3, day: 8),
+      ProTacts::Birthday.from_property(property("1604-03-08", parameters: [["value", "date"]]))
+  end
+
   # A component out of range is unmodeled, not exceptional: the
   # patterns encode the ranges, so the value never matches and the
   # line stays wherever it was — Store reports it as unrecognized
@@ -132,6 +152,7 @@ class BirthdayTest < Minitest::Test
   def test_what_the_model_cannot_recompose_reads_as_nil
     [property("--0412"), property("1985-04"), property("19850412"),
       property("1985-04-12", parameters: [["X-OTHER", "1"]]),
+      property("1985-04-12", parameters: [["VALUE", "text"]]),
       property("1604-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"], ["X-OTHER", "1"]]),
       property("1985-04-12", group: "item1")].each do |unmodeled|
       assert_nil ProTacts::Birthday.from_property(unmodeled), unmodeled.value
@@ -195,7 +216,9 @@ class BirthdayTest < Minitest::Test
   def test_rendered_reads_the_spellings_clients_show
     [property("1985-04-12"), property("1985-04-12T23:10:00Z"),
       property("1604-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"]]),
-      property("--0412"), property("--04-12")].each do |rendered|
+      property("--0412"), property("--04-12"),
+      property("1985-04-12", parameters: [["value", "date"]]),
+      property("--0412", parameters: [["value", "date"]])].each do |rendered|
       assert ProTacts::Birthday.rendered?(rendered), rendered.value
     end
   end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index cfee6bd..4667c87 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -704,6 +704,19 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # iOS writes every birthday with VALUE=date spelled out
+  # (docs/apple-contacts.md), and the line moves into the model like a
+  # bare one, served back in the model's spelling.
+  def test_a_birthday_spelling_out_value_date_moves_into_the_model
+    with_store({"aiden" => AIDEN}) do |store|
+      store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "BDAY;value=date:1985-04-12\r\nEND:VCARD\r\n")))
+
+      assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), birthday_row(store, "aiden")
+      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.to_s
+    end
+  end
+
   def test_an_apple_no_year_birthday_round_trips
     with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\nEND:VCARD\r\n")}) do |store|
       assert_equal ProTacts::Birthday.new(month: 4, day: 12), birthday_row(store, "aiden")