card form: a full date in 1604 saves as a month and day
The editor could save a birthday in 1604, which served as a bare 1604
date and read back at the next sync as a month and day: the one shape
the every-birthday plan gave up. The editor now reads the sentinel as
every client's line does, so what it saves is what comes back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
change
commit ddb35fa9402009b6c87a7a3761fc7fc16c216486
author Claude <noreply@anthropic.com>
date
parent f45b8c3a
diff --git a/docs/plans/2026-09-11-every-birthday-in-the-model.md b/docs/plans/2026-09-11-every-birthday-in-the-model.md
index 4cd2eba..542579a 100644
--- a/docs/plans/2026-09-11-every-birthday-in-the-model.md
+++ b/docs/plans/2026-09-11-every-birthday-in-the-model.md
@@ -51,9 +51,9 @@ into a 1604 one after an iOS save. A family address book has nobody
 born in 1604, so a full date in that year now reads as a month and day,
 parameter or not.
 
-The model can still hold a year of 1604 from the admin editor, and it
-goes out as a bare 1604 date that reads back as a month and day. That
-is the one shape this gives up.
+The admin editor reads a full date in 1604 the same way, as a month
+and day (`Admin::CardForm.birthday`), so the model never holds a year
+that would come back without one.
 
 ## One line, three outcomes
 
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 0592c1c..1b475bd 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -111,6 +111,13 @@ module ProTacts
       # class, for the save to catch and re-render — nothing here
       # reaches Sentry, bad input being ordinary and the toast the
       # fallback.
+      #
+      # A full date in 1604 is a month and day, the sentinel read here as
+      # every client's line reads it (Birthday::NO_YEAR): the model would
+      # otherwise hold a year that serves as a bare 1604 date and reads
+      # back without one at the next sync
+      # (docs/plans/2026-09-11-every-birthday-in-the-model.md, "1604 means
+      # no year").
       #: (untyped fields) -> Birthday?
       def self.birthday(fields)
         year = birthday_component(fields["year"])
@@ -118,6 +125,8 @@ module ProTacts
         day = birthday_component(fields["day"])
         return if year.nil? && month.nil? && day.nil?
 
+        year = nil if year == Birthday::NO_YEAR && month && day
+
         Birthday.new(year:, month:, day:)
       end
 
diff --git a/test/pro_tacts/admin/test_card_form.rb b/test/pro_tacts/admin/test_card_form.rb
index 8ea85f7..7440f8a 100644
--- a/test/pro_tacts/admin/test_card_form.rb
+++ b/test/pro_tacts/admin/test_card_form.rb
@@ -3,6 +3,7 @@ require_relative "../../test_helper"
 require "hegel"
 
 require "pro_tacts/admin/card_form"
+require "pro_tacts/birthday_line"
 require "pro_tacts/contact"
 require "pro_tacts/vcard"
 
@@ -92,4 +93,32 @@ class CardFormTest < Minitest::Test
       assert_equal stored, saved.to_s
     end
   end
+
+  ## The birthday row
+
+  # Whatever the birthday row saves, a served birthday reads back from
+  # its line as itself: the next sync cannot change what the editor
+  # wrote. The year range is drawn around 1604, the sentinel a served
+  # full date would lose its year to (Birthday::NO_YEAR).
+  def test_a_saved_birthday_reads_back_from_the_line_it_serves
+    field = ->(range) { optional(integers(min_value: range.min, max_value: range.max).map(&:to_s)) }
+    Hegel.test(test_cases: 1_000) do |tc|
+      fields = {
+        "year" => tc.draw(one_of(field.(1600..1610), field.(0..9999))),
+        "month" => tc.draw(field.(1..12)),
+        "day" => tc.draw(field.(1..31)),
+      }
+      # A shape the grammar refuses is the row's refusal, no save at all.
+      birthday = begin
+        ProTacts::Admin::CardForm.birthday(fields)
+      rescue ArgumentError
+        nil
+      end
+      line = birthday&.to_line
+      tc.assume(!line.nil?)
+
+      read = ProTacts::BirthdayLine.read(ProTacts::VCard.new("#{line}\r\n").lines.fetch(0))
+      assert_equal birthday, read.birthday
+    end
+  end
 end