birthdays: carry unrenderable ones across a rewrite
macOS Contacts drops BDAY lines it cannot render from every card it
writes, so put reading a no-BDAY rewrite as a deletion was losing
birthdays the client never showed. The carry is gated on shape, not
spelling — the reduced no-year forms macOS renders stay deletable.

Assisted-by: GLM-5.3 via pi
change tzkqyvquyzrxknyswstyuxzuuopousko
commit d9f9d607fca8cc355513b0f651781fd4205e1bbd
author Alpha Chen <alpha@kejadlen.dev>
date
parent wslrwwyx
diff --git a/docs/plans/2026-09-01-birthdays-across-a-rewrite.md b/docs/plans/2026-09-01-birthdays-across-a-rewrite.md
new file mode 100644
index 0000000..5ae276b
--- /dev/null
+++ b/docs/plans/2026-09-01-birthdays-across-a-rewrite.md
@@ -0,0 +1,94 @@
+# Unrenderable birthdays ride across a rewrite
+
+2026-09-01. macOS Contacts renders two of RFC 6350 section 4.3.1's six
+birthday shapes: a full date, and a month with a day. For the rest —
+year and month, year alone, month alone, day alone — it shows nothing,
+and the next card it writes carries no BDAY at all. Editing an
+unrelated field is enough; all three observed losses came from adding
+a note.
+
+## The old deletion rule guarded the wrong half
+
+`Store#put` treated a submitted card with no BDAY as a deletion, with
+one exception: the model row was kept when it held a shape no client
+was served (`existing && !existing.served?`). That guards the model's
+half. But a birthday in an unmodeled *spelling* never becomes a model
+row — `Birthday.subtract` leaves the line in the card verbatim, per
+RFC 6352 section 6.3.2.2 — and there is nothing in the birthdays table
+to fall back on. The client rewrites the card, and the only copy goes
+with it. The rule protected the shapes the model holds and lost the
+ones the card holds, which is backwards: the card-held ones are
+exactly those no client can round-trip.
+
+They also cannot simply move into the birthdays table. The model has
+no spelling that `compose` would put back on the wire for these
+shapes, so a promoted birthday would vanish from every served card
+instead of surviving in one — a read-side regression dressed as a fix.
+
+## The divider is the shape, not the spelling
+
+A rewrite with no BDAY means two different things, and "did the
+client render this birthday" decides between them:
+
+- macOS renders the unmodeled no-year spellings too (`--0412`,
+  `--04-12`), so a rewrite that omits one is a user removing a
+  birthday they could see. That deletion is honored.
+- The four unserved shapes render nothing, so no client can have
+  removed them. `put` carries those lines across the rewrite — the
+  new stored card is the submission plus the stored BDAY lines,
+  verbatim with their folds.
+
+The carried spellings are a whitelist (`Birthday::UNRENDERED_VALUES`):
+the four partial shapes, dashed as RFC 6350 section 4.3.1's examples
+spell them, components in range. A value off the list — served in
+any spelling, unpadded, out of range, unrecognizable — dies with the
+rewrite that drops it, so garbage never becomes immortal. The day
+`to_line` learns a verified spelling for one of the four — the
+month-alone probe is still unrun — is the day its spelling leaves
+the list; the tests pin the correspondence.
+
+## What nobody recognizes is reported, not swallowed
+
+A BDAY line that parses to a value neither modelable, rendered, nor
+carried is unexpected input, and it meets two quiet fates: stored
+verbatim with no one told (RFC 6352 section 6.3.2.2 keeps it, but
+keeping is not reporting), or dropped by a rewrite that could not
+have shown it. Both moments report to Sentry — the arrival in
+`Store#put`'s split, the loss in its carry branch. The messages carry
+no card content, per SentryScrubber's line: the values stay on the
+machine, where the admin view shows them raw.
+
+The classifiers themselves cannot raise — the whitelist matches or
+does not, and `from_property`'s out-of-range rescue is what read
+paths depend on — so there is no error to swallow on the way to a
+classification. What is left is the classification, and the
+unrecognized one is never silent.
+
+## Where the layers sit
+
+The surgery is not the model's. `Birthday` is a partial date and the
+property-level reading of one (`from_property`, `to_line`, the
+whitelist, the rendered set); `VCard` is the card — parsed beside its
+bytes, Enumerable over its lines, one verb for taking a property's
+lines and one for putting lines back; and `Store` owns the policy —
+the deletion rule, the carry, and both reports — over the two.
+Migration 002 spells its own split out against `VCard` and `Birthday`
+directly, so what it did to databases that already ran it cannot
+drift with the app's internals.
+
+## The cost, taken deliberately
+
+A birthday no client renders is now client-undeletable, the same
+terms the model-row half of the rule already had: a PUT carrying any
+BDAY replaces it, and deleting the contact removes it, but a rewrite
+alone never will. The alternative is the silent loss the probe
+recorded, which is worse for a family address book — the birthdays
+these shapes hold are the partial ones ("1985, maybe April") that
+nowhere else in the card is recorded.
+
+## The wire consequences
+
+The carried card is not the submitted bytes, so the PUT answer omits
+the strong ETag (RFC 6352 section 6.3.2.3) and the client refetches —
+which is what shows it the birthday survived its edit. The change log
+and ctag move with the composed card as they already did.
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index 4f60289..67a8510 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -18,9 +18,10 @@ module ProTacts
   # with all three components required, and RFC 2426 section 3.1.5 builds
   # BDAY on it. So a birthday is database state that no stored card
   # carries, composed into the served card on read and subtracted out of
-  # a submitted one on write — Store owns that surgery, over the line
-  # rules VCard provides and the property-level reading below; the full
-  # reasoning is docs/plans/2026-08-31-partial-birthdays.md.
+  # a submitted one on write — Store owns that surgery, over the card
+  # VCard reads and the property-level reading below; the full reasoning
+  # is docs/plans/2026-08-31-partial-birthdays.md and
+  # docs/plans/2026-09-01-birthdays-across-a-rewrite.md.
   #
   # The signature lives in sig/pro_tacts/birthday.rbs: a Data class has
   # no constant super class for the inline syntax to read.
@@ -42,6 +43,30 @@ module ProTacts
     OMIT_YEAR_DATE = /\A1604-(\d{2})-(\d{2})\z/ #: Regexp
     OMIT_YEAR = "1604" #: String
 
+    # The BDAY values no client renders, spelled whole: the four
+    # partial shapes #to_line has no form for, dashed as RFC 6350
+    # section 4.3.1's examples spell them, components in range, each
+    # pattern anchored alone so no edit to one can loosen another's.
+    # A value off the list is not carried across a rewrite, by the
+    # same probe: the served shapes in any spelling were visible, so
+    # their absence is a deletion, and an unrecognized or out-of-range
+    # value is cleaner dying with the rewrite than living forever
+    # (docs/macos-contacts.md, "A birthday the client cannot render is
+    # dropped from the card").
+    UNRENDERED_VALUES = [
+      /\A\d{4}-(?:0[1-9]|1[0-2])\z/,       # year and month
+      /\A\d{4}\z/,                         # year alone
+      /\A--(?:0[1-9]|1[0-2])\z/,           # month alone
+      /\A---(?:0[1-9]|[12]\d|3[01])\z/,    # day alone
+    ] #: Array[Regexp]
+
+    # The reduced no-year values macOS reads on a card it did not write
+    # and converts to the sentinel on its own
+    # (docs/macos-contacts.md, "Birthdays without a year"). Together
+    # with #from_property's two spellings this is the rendered set: a
+    # BDAY a client could see, and so could have deleted.
+    REDUCED_DATE = /\A--(?:0[1-9]|1[0-2])-?(?:0[1-9]|[12]\d|3[01])\z/ #: Regexp
+
     # The only constructor, so no birthday exists in a shape the grammar
     # rejects: one `in` clause per shape RFC 6350 section 4.3.1 admits,
     # each component's range standing where the component stands, so the
@@ -73,6 +98,8 @@ module ProTacts
     # full date, and Apple's 1604 sentinel for a month and day. The other
     # four shapes have no verified spelling and reach no client — this
     # returns nil for them and no card grows a BDAY on their behalf.
+    # UNRENDERED_VALUES is exactly those four shapes' spellings, so
+    # widening this means narrowing that.
     def to_line
       if year && month && day
         format("BDAY:%04d-%02d-%02d", year, month, day)
@@ -119,5 +146,26 @@ module ProTacts
       name, value = property.parameters.fetch(0)
       name.casecmp?("X-APPLE-OMIT-YEAR") && value == OMIT_YEAR
     end
+
+    # Whether a BDAY value is one no client renders — the spellings a
+    # rewrite carries across, so the birthday survives a client that
+    # drops what it cannot display. A predicate rather than a reader:
+    # nothing downstream wants the shape as a model, and a whitelist
+    # cannot raise on what it does not recognize.
+    def self.unrendered_value?(value)
+      UNRENDERED_VALUES.any? { value.match?(it) }
+    end
+
+    # Whether a client renders this BDAY property as a birthday: one of
+    # the two spellings #to_line emits (#from_property reads exactly
+    # those), or a reduced no-year value macOS additionally reads. A
+    # client that renders a BDAY can have deleted it, so its absence
+    # from a rewrite is a removal; a property that is neither rendered
+    # nor carried is neither, and the rewrite that drops it reports
+    # 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))
+    end
   end
 end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 7bac3f6..152950f 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -1,10 +1,11 @@
-
 require "digest"
 require "pathname"
 require "sequel"
+require "sentry-ruby"
 
 require "pro_tacts/birthday"
 require "pro_tacts/contact"
+require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
 
 Sequel.extension :migration
@@ -188,23 +189,18 @@ module ProTacts
     #
     # What is stored is the card minus its birthday, which moves into
     # the birthdays table — vCard 3.0 cannot carry a partial date, so
-    # the card never carries BDAY and every read composes it back in
-    # (docs/plans/2026-08-31-partial-birthdays.md). 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. A submitted
-    # BDAY in a spelling the model does not recompose stays in the
-    # card byte for byte and empties the model, keeping RFC 6352
-    # section 6.3.2.2's promise; a submitted card with no BDAY at all
-    # deletes the birthday only when the model held a shape a client
-    # could have seen, because one it never served is not something
-    # the client could have removed.
+    # no stored card carries the model's BDAY and every read composes
+    # it back in (docs/plans/2026-08-31-partial-birthdays.md). 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.
     #
     # 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
-    # where it is read, in write_card.
+    # where it is read, in write_card, and refused at the card itself
+    # before that: VCard raises on bytes that are not text.
     #: (String id, String vcard) -> Contact
     def put(id, vcard)
       # The birthday half of the split a write makes, one arm per shape
@@ -214,21 +210,34 @@ module ProTacts
       # line that will not parse, more than one — is data the model
       # cannot recompose and stays in the card byte for byte, with the
       # model emptied so nothing composes a second BDAY beside it
-      # (RFC 6352 section 6.3.2.2). No BDAY at all is nothing to split:
-      # the birthday to carry is whatever an unseen model row already
-      # holds, and a served one was the user's deletion. Bytes that are
-      # not UTF-8 raise at the card before any of this runs — a PUT
-      # has already answered them with a 412 by then.
+      # (RFC 6352 section 6.3.2.2). No BDAY at all is a client's
+      # rewrite, and macOS Contacts drops the BDAY lines it cannot
+      # render from every card it writes (docs/macos-contacts.md):
+      # what the stored card held in a spelling no client renders
+      # rides across the rewrite, what it held that a client could
+      # 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.
       existing = birthday_of(id)
       birthday, stored =
         case VCard.new(vcard).partition { it.names?("BDAY") }
         in [[line], others]
+          report_unrecognized_bday_lines([line])
           property = line.property
           birthday = property && Birthday.from_property(property)
           [birthday, birthday ? others.map(&:verbatim).join : vcard]
         in [[], _]
-          [existing && !existing.served? ? existing : nil, vcard]
-        in [_, _]
+          # The rewrite arm: carry the unrendered lines out of the
+          # stored card, report the unrecognized ones' loss, and keep
+          # whatever an unseen model row holds.
+          carried, lost = carried_and_lost_bday_lines(stored_vcard(id))
+          kept = existing && !existing.served? ? existing : nil
+          report_lost_bday_lines(lost)
+          [kept, VCard.new(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, vcard]
         end
 
@@ -361,6 +370,64 @@ module ProTacts
       Sequel::Migrator.run(@database, MIGRATIONS.to_s)
     end
 
+    # The card currently stored for an id, or nil for a card being
+    # created. A plain `first` read: the primary key leaves `sole`
+    # nothing to catch, and no row is the ordinary answer.
+    #: (String id) -> String?
+    def stored_vcard(id)
+      row = cards.where(id:).first
+      row && row.fetch(:vcard).to_s
+    end
+
+    # A stored card's BDAY lines in two piles: the verbatim lines a
+    # rewrite carries across — values no client renders — and the ones
+    # 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.
+    #: (String? vcard) -> [Array[String], Array[VCard::Line]]
+    def carried_and_lost_bday_lines(vcard)
+      return [[], []] if vcard.nil?
+
+      bdays, = VCard.new(vcard).partition { it.names?("BDAY") }
+      carried, rest = bdays.partition { |line|
+        line.property && Birthday.unrendered_value?(line.property.value)
+      }
+      lost = rest.reject { |line| line.property && Birthday.rendered?(line.property) }
+      [carried.map(&:verbatim), lost]
+    end
+
+    # The arrival report: a BDAY line this server can neither model,
+    # recognize as rendered, nor recognize as carried is unexpected
+    # input, and storing it verbatim would be the last anyone heard of
+    # it. The message carries no card content
+    # (ProTacts::SentryScrubber's line); the values stay on the
+    # machine, where the admin view shows them raw.
+    #: (Array[VCard::Line] lines) -> void
+    def report_unrecognized_bday_lines(lines)
+      unrecognized = lines.count { |line|
+        line.property.nil? || (!Birthday.rendered?(line.property) && !Birthday.unrendered_value?(line.property.value))
+      }
+      return if unrecognized.zero?
+
+      Sentry.capture_message(
+        "a submitted card carried #{unrecognized} BDAY line(s) no client renders and no whitelist recognizes",
+        level: :warning,
+      )
+    end
+
+    # The loss report, the rewrite's half of the arrival one: a stored
+    # BDAY no client renders and no whitelist recognizes is about to be
+    # dropped, and nobody would know.
+    #: (Array[VCard::Line] lines) -> void
+    def report_lost_bday_lines(lines)
+      return if lines.empty?
+
+      Sentry.capture_message(
+        "a rewrite dropped #{lines.length} BDAY line(s) no client renders and no whitelist carries",
+        level: :warning,
+      )
+    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
diff --git a/sig/pro_tacts/birthday.rbs b/sig/pro_tacts/birthday.rbs
index a73636a..aec6546 100644
--- a/sig/pro_tacts/birthday.rbs
+++ b/sig/pro_tacts/birthday.rbs
@@ -6,6 +6,8 @@ module ProTacts
     FULL_DATE: Regexp
     OMIT_YEAR_DATE: Regexp
     OMIT_YEAR: String
+    UNRENDERED_VALUES: Array[Regexp]
+    REDUCED_DATE: Regexp
 
     attr_reader year: Integer?
     attr_reader month: Integer?
@@ -20,5 +22,9 @@ module ProTacts
     def self.from_property: (VCard::Property property) -> Birthday?
 
     def self.apple_no_year?: (VCard::Property property) -> bool
+
+    def self.unrendered_value?: (String value) -> bool
+
+    def self.rendered?: (VCard::Property property) -> bool
   end
 end
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index b256673..00433dd 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -21,7 +21,7 @@ class BirthdayTest < Minitest::Test
     error = assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 1985, day: 12) }
     assert_match "not a shape", error.message
 
-    assert_raises(ArgumentError) { ProTacts::Birthday.new() }
+    assert_raises(ArgumentError) { ProTacts::Birthday.new }
   end
 
   def test_components_out_of_range_are_refused
@@ -98,7 +98,8 @@ class BirthdayTest < Minitest::Test
   end
 
   # A component out of range is unmodeled, not exceptional: from_property
-  # reads nil and the line stays wherever it was.
+  # reads nil and the line stays wherever it was. Store reports the value
+  # as unrecognized, so the swallow is not silent.
   def test_an_out_of_range_date_reads_as_nil
     assert_nil ProTacts::Birthday.from_property(property("1985-13-40"))
   end
@@ -115,4 +116,47 @@ class BirthdayTest < Minitest::Test
       assert_nil ProTacts::Birthday.from_property(unmodeled), unmodeled.value
     end
   end
+
+  ## the values no client renders
+
+  def test_unrendered_value_accepts_the_four_shapes_spellings
+    ["1985-04", "1985", "--04", "---12"].each do |value|
+      assert ProTacts::Birthday.unrendered_value?(value), value
+    end
+  end
+
+  # The rendered set matches none of the four, and a value the grammar
+  # will not read matches nothing. The padded pair pins the anchors —
+  # each pattern is anchored alone, so no spelling matches inside a
+  # longer or padded value.
+  def test_unrendered_value_refuses_everything_else
+    ["1985-04-12", "--0412", "--04-12", "19850412", "1985-4", "1985-13", "--00", "", "x1985", "1985-04\n"].each do |value|
+      refute ProTacts::Birthday.unrendered_value?(value), value
+    end
+  end
+
+  ## the values a client renders
+
+  # from_property's two spellings, the reduced no-year values macOS
+  # additionally reads, and their case: a client that renders a BDAY
+  # can have deleted it, so its absence from a rewrite is a removal.
+  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|
+      assert ProTacts::Birthday.rendered?(rendered), rendered.value
+    end
+  end
+
+  # What no client renders — the carried shapes, the out-of-range, the
+  # unrecognizable — is not rendered, and a rewrite dropping it is
+  # something Store reports rather than a deletion it honors.
+  def test_rendered_refuses_what_no_client_shows
+    [property("1985-04"), property("1985"), property("--04"), property("---12"),
+      property("1985-13"), property("--0432"), property("19850412"),
+      property("banana"),
+      property("--0412", group: "item1")].each do |unrendered|
+      refute ProTacts::Birthday.rendered?(unrendered), unrendered.value
+    end
+  end
 end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index f7c874e..443855c 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -15,6 +15,23 @@ class StoreTest < Minitest::Test
 
   # A store on disk rather than in memory: WAL, the busy timeout, and
   # reopening a database are all part of what is under test.
+  # minitest 6 ships no mock, so the Sentry capture is stubbed by hand:
+  # the bound original is swapped back in an ensure, so one assertion
+  # cannot leak into the next. Sentry is never initialized under test,
+  # so the original is inert anyway — this only makes it observable.
+  # Returns what was captured.
+  def capturing_sentry
+    messages = []
+    original = Sentry.method(:capture_message)
+    Sentry.define_singleton_method(:capture_message) { |message, **| messages << message }
+    begin
+      yield messages
+    ensure
+      Sentry.define_singleton_method(:capture_message, original)
+    end
+    messages
+  end
+
   def with_store(cards = {})
     Dir.mktmpdir do |dir|
       ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
@@ -526,6 +543,110 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # The card's half of the same rule. A BDAY in a shape no client
+  # renders lives in the card — the model has no spelling for it — and
+  # macOS Contacts drops those lines from every card it writes, so a
+  # rewrite that omits the BDAY carries them across or loses them
+  # (docs/macos-contacts.md, "A birthday the client cannot render is
+  # dropped from the card").
+  def test_a_birthday_no_client_renders_survives_a_rewrite_that_drops_it
+    ["BDAY:1985-04", "BDAY:1985", "BDAY:--04", "BDAY:---12"].each do |line|
+      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)
+
+        assert_equal edited.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n"), store.contact("aiden").vcard, line
+        assert_nil birthday_row(store, "aiden"), line
+      end
+    end
+  end
+
+  # The divider is the shape, not the spelling: macOS renders --0412,
+  # so a rewrite without it has removed a birthday the client could
+  # see, and the deletion is honored. Honoring it is what keeps these
+  # 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)
+
+      assert_equal AIDEN, store.contact("aiden").vcard
+      assert_nil birthday_row(store, "aiden")
+    end
+  end
+
+  # A submission carrying any BDAY replaces what was there, carried
+  # line included: PUT is a whole-card replace, not a merge.
+  def test_a_submitted_birthday_replaces_the_carried_line
+    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)
+
+      assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), birthday_row(store, "aiden")
+      assert_equal born, store.contact("aiden").vcard
+    end
+  end
+
+  # The loss report, the rewrite's half of the arrival one: a stored
+  # BDAY no client renders and no whitelist recognizes is about to be
+  # dropped, and nobody would know.
+  def test_a_rewrite_dropping_an_unrecognized_bday_is_reported
+    with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY:1985-13\r\nEND:VCARD\r\n")}) do |store|
+      messages = capturing_sentry {
+        store.put("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
+      }
+
+      assert_equal 1, messages.length
+      assert_match /BDAY/, messages.fetch(0)
+    end
+  end
+
+  # A rewrite's quiet cases: a carried line survives (asserted above),
+  # a rendered one was deleted by a user who could see it, and a card
+  # with no BDAY at all has nothing to say.
+  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|
+        messages = capturing_sentry {
+          store.put("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
+        }
+
+        assert_empty messages, line
+      end
+    end
+  end
+
+  # The arrival report: a submitted BDAY this server can neither model,
+  # recognize as rendered, nor recognize as carried is unexpected input,
+  # and storing it verbatim would be the last anyone heard of it.
+  # Known forms — the modeled spellings, the reduced values macOS
+  # reads, the carried shapes — stay quiet.
+  def test_an_unrecognized_bday_arriving_is_reported
+    ["BDAY:1985-13", "BDAY:--0432", "BDAY:19850412", "BDAY:1985-4"].each do |line|
+      with_store({}) do |store|
+        messages = capturing_sentry {
+          store.put("aiden", AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n"))
+        }
+
+        assert_equal 1, messages.length, line
+      end
+    end
+  end
+
+  def test_a_bday_the_server_knows_arrives_quietly
+    ["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|
+        messages = capturing_sentry {
+          store.put("aiden", AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n"))
+        }
+
+        assert_empty 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
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 85e933c..b942ddd 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -328,6 +328,27 @@ class WebTest < Minitest::Test
     end
   end
 
+  # The same omission for the rewrite that carries a birthday across:
+  # the stored card is the submission plus the BDAY line macOS dropped,
+  # so the answer cannot claim the tag — the client refetches, and the
+  # refetch is what shows the birthday survived its edit.
+  def test_a_put_that_carries_a_birthday_back_goes_without_the_strong_etag
+    unrendered = card("new", "New").sub("END:VCARD\r\n", "BDAY:1985-04\r\nEND:VCARD\r\n")
+
+    with_contacts({}) do
+      put_request "new", unrendered, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+      edited = card("new", "New Smith")
+      put_request "new", edited, "CONTENT_TYPE" => VCARD
+
+      assert_equal 204, last_response.status
+      assert_nil last_response["ETag"]
+
+      get "/dav/addressbook/new.vcf"
+      assert_includes last_response.body, "BDAY:1985-04\r\nEND:VCARD"
+    end
+  end
+
   # The preconditions of RFC 6352 section 6.3.2.1, marshalled as 412s
   # in the DAV:error form RFC 4918 section 16 defines.
   def test_put_of_a_non_vcard_media_type_names_its_precondition