birthday: model every well-shaped BDAY, send only two
Every well-shaped birthday lands in the table; only two go out the door,
and the carry that chased dropped lines is not needed anymore.

Assisted-by: Claude Opus 5 via Claude Code
change vnwnwmrtqxozopyzrpvxooppxlsmrzro
commit d3c6de85cf7813bbdbcdf59f35b8fd3d592c8efc
author Alpha Chen <alpha@kejadlen.dev>
date
parent ytnqqkzq
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
new file mode 100644
index 0000000..4cd2eba
--- /dev/null
+++ b/docs/plans/2026-09-11-every-birthday-in-the-model.md
@@ -0,0 +1,92 @@
+# Every birthday goes in the model
+
+2026-09-11. Every well-shaped BDAY a client sends moves into the
+birthdays table, and only the two shapes that survive a round trip
+through both Apple clients are sent back. The other four are kept and
+shown in the admin, but no served card carries them.
+
+This replaces the carry in
+`2026-09-01-birthdays-across-a-rewrite.md`, and reverses the 1604 rule
+in `2026-08-31-partial-birthdays.md`.
+
+## What survives a round trip
+
+From the macOS probe (`docs/apple-contacts.md`, "A birthday the client
+cannot render is dropped from the card") and the iOS one ("iOS writes a
+birthday it cannot hold as a date it made up"):
+
+| Shape | macOS | iOS |
+|---|---|---|
+| Full date | survives | survives, with `value=date` added |
+| Month and day | the sentinel survives | survives only if 1604 means no year |
+| Year and month, year, day | dropped | dropped, or a date it made up |
+| Month alone | not observed | dropped |
+
+A full date and a month-day are the two `Birthday#to_line` sends.
+
+## Why the carry went
+
+The carry existed because a BDAY in a shape no client renders stayed
+in the stored card, got served, got dropped by the client, and had to
+be put back. The 2026-09-01 plan kept it in the card rather than the
+model because moving it would make it vanish from every served card.
+
+That vanishing costs nothing: no client displays those shapes, so
+serving them only ever gave a client something to drop or, on iOS, to
+turn into a made-up year-2 date. With every well-shaped BDAY in the
+model, no stored card holds a birthday a client cannot handle, and the
+only rule left is the one the model already had: a birthday row no
+client was sent survives a card sent back without one.
+
+The same move fixes a spelling iOS misreads. `BDAY:--11-27` served
+verbatim showed on iOS as January 27 of the year 2 and was written
+back that way; in the model it is sent as the sentinel instead.
+
+## 1604 means no year
+
+iOS writes the Apple sentinel back as `BDAY;value=date:1604-MM-DD`,
+without `X-APPLE-OMIT-YEAR`. The 2026-08-31 plan read a bare 1604 date
+as a real birthday in 1604, which would turn every no-year birthday
+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.
+
+## One line, three outcomes
+
+`BirthdayLine.read` sorts a submitted BDAY line into one of three
+variants, and `Store#put` has one arm for each:
+
+- `Modeled`: a birthday the model takes. It leaves the card.
+- `Unrecognized`: a BDAY the model does not take — an unknown value, a
+  parameter other than `VALUE=date` or the sentinel's, or a grouped
+  `item1.BDAY`. It stays in the card verbatim and is reported.
+- `Unreadable`: a line the parser could not read. It stays in the card,
+  already reported by `Web#report_unreadable_lines`.
+
+A card with more than one BDAY keeps them all in the card and is
+reported whatever they hold: a contact has one birthday.
+
+The variants are matched with `case`/`when`. Steep 2.1.0.dev.1 does not
+check the bodies of `case`/`in` branches at all — a missing method in
+one reports nothing — while `case`/`when` narrows the union by class.
+`when` does not raise on an unmatched value, so each match ends in an
+explicit raise.
+
+## No migration
+
+Cards stored under the old rule can hold BDAY lines this change would
+move into the model, and a rewrite now drops such a line with a report.
+No deployed database held any cards when this landed, so nothing moves
+them.
+
+## Migration 002 reads through the new rule
+
+`db/migrations/002_birthdays.rb` calls `Birthday.from_property`, so
+run today against a database that predates it, it would move every
+well-shaped BDAY rather than the two spellings it moved when it was
+written. Every database that has run 002 is unaffected, and a new one
+has no cards when 002 runs.
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 3b73442..ac81509 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -23,24 +23,18 @@ module ProTacts
         (contact.name || contact.id).to_s.strip[0].to_s.upcase
       end
 
-      # A contact's birthday, for display: the model's — composed into
-      # the served card when a client can carry it, held alone when no
-      # 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 (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.
+      # A contact's birthday, for display: the model's, including the
+      # shapes no client is sent, then a BDAY still in the card — read the
+      # way a write reads one, else shown raw. The prose is Birthday#to_s.
       #: (Contact contact) -> String?
       def self.birthday(contact)
+        birthday = contact.birthday
+        return birthday.to_s if birthday
+
         property = contact.properties.find { it.name.casecmp?("BDAY") }
-        return contact.birthday&.to_s if property.nil?
+        return nil if property.nil?
 
-        birthday = contact.birthday
-        if birthday.nil? && property.group.nil? && Birthday.significant_parameters(property).empty?
-          birthday = Birthday.from_value(property.value)
-        end
-        birthday&.to_s || property.value
+        Birthday.from_property(property)&.to_s || property.value
       end
 
       # ADR's components as display lines — street, then everything
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index c16225c..42b92f6 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -15,78 +15,54 @@ module ProTacts
   # alone follows from its allowance of ISO 8601 truncated representation
   # f), which it references but does not spell out.
   #
-  # vCard 3.0 cannot carry any of this, 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. Why it cannot live in the card, and what each shape puts
-  # on the wire, are docs/plans/2026-08-31-partial-birthdays.md and
-  # docs/plans/2026-09-01-birthdays-across-a-rewrite.md.
+  # Every well-shaped BDAY a client sends is read into this model, and
+  # only the two shapes that survive a round trip through both Apple
+  # clients go back out (#to_line); the other four are kept and shown
+  # but never sent. Store moves the line out of the card and composes it
+  # back in. The reasoning is docs/plans/2026-09-11-every-birthday-in-the-model.md;
+  # why a partial date cannot live in a vCard 3.0 card is
+  # docs/plans/2026-08-31-partial-birthdays.md.
   #
   # The signature lives in sig/pro_tacts/birthday.rbs: a Data class has
   # no constant super class for the inline syntax to read.
   # @rbs skip
   class Birthday < Data.define(:year, :month, :day)
-    # A full date, the one partial shape vCard 3.0 carries natively
-    # (RFC 2425 section 5.8.4). The dashed form is what macOS writes;
-    # the undashed one is legal too and deliberately unmodeled, so it
-    # stays in a card verbatim rather than drift to this model's
-    # spelling. The optional time is RFC 2426 section 3.1.5's other
-    # value shape, and it is accepted and dropped: a birthday is a
-    # date, the one deliberate loss the design records. Components in
-    # range, so an out-of-range date does not match and stays in the
-    # card rather than reaching a constructor that would refuse it.
-    FULL_DATE = /\A(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])(?:T\d{2}:?\d{2}(:?\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\z/ #: Regexp
-
-    # The no-year shape macOS writes, with 1604 standing in for the year
-    # in both the parameter and the value (docs/apple-contacts.md,
-    # "Birthdays without a year"). 1604 because it is the first year of
-    # the Gregorian calendar; the parameter names the year to omit.
-    # Components in range, like FULL_DATE.
-    OMIT_YEAR_DATE = /\A1604-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\z/ #: Regexp
+    # The year Apple writes in place of a missing one. It means "no year"
+    # wherever it appears, parameter or not, because iOS writes the
+    # sentinel back without its parameter (docs/apple-contacts.md, "iOS
+    # writes a birthday it cannot hold as a date it made up").
+    NO_YEAR = 1604 #: Integer
     OMIT_YEAR = "1604" #: String
 
-    # The BDAY values no client renders, one named pattern per shape —
-    # dashed as RFC 6350 section 4.3.1's examples spell them,
-    # components in range, each anchored alone so no edit to one can
-    # loosen another's. Capturing, because #from_value reads the
-    # components back; the predicate reads below only match, which
-    # captures do not disturb. 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/apple-contacts.md, "A birthday
-    # the client cannot render is dropped from the card").
+    # The BDAY values this reads, one pattern per spelling, components
+    # in range so an out-of-range value does not match and stays in the
+    # card rather than reaching a constructor that would refuse it.
+    #
+    # A full date is dashed, as both clients write it; the undashed form
+    # RFC 2425 section 5.8.4 also allows is not read. The optional time
+    # is RFC 2426 section 3.1.5's other value shape, accepted and dropped:
+    # a birthday is a date.
+    FULL_DATE = /\A(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])(?:T\d{2}:?\d{2}(:?\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\z/ #: Regexp
+    # The rest are RFC 6350 section 4.3.1's reduced and truncated forms,
+    # the month-day in both the basic and extended spelling.
+    MONTH_AND_DAY = /\A--(0[1-9]|1[0-2])-?(0[1-9]|[12]\d|3[01])\z/ #: Regexp
     YEAR_AND_MONTH = /\A(\d{4})-(0[1-9]|1[0-2])\z/ #: Regexp
     YEAR_ALONE = /\A(\d{4})\z/ #: Regexp
     MONTH_ALONE = /\A--(0[1-9]|1[0-2])\z/ #: Regexp
     DAY_ALONE = /\A---(0[1-9]|[12]\d|3[01])\z/ #: Regexp
 
-    UNRENDERED_VALUES = [
-      YEAR_AND_MONTH,
-      YEAR_ALONE,
-      MONTH_ALONE,
-      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/apple-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. Capturing
-    # for #from_value, like the unrendered patterns above.
-    REDUCED_DATE = /\A--(0[1-9]|1[0-2])-?(0[1-9]|[12]\d|3[01])\z/ #: Regexp
+    # The value X-APPLE-OMIT-YEAR is allowed on: the sentinel date alone,
+    # no time, as macOS writes it (docs/apple-contacts.md, "Birthdays
+    # without a year").
+    OMIT_YEAR_DATE = /\A1604-(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
-    # pattern is the grammar rather than a prose description of it. A
-    # range admits any Comparable in its bounds, so the check is
-    # Integer-grade only in practice: the two producers — SQLite's
-    # integer columns and #to_i — cannot yield anything else, and a
-    # String or nil-shaped miss falls to the else. Components are not
-    # calendar-validated beyond their ranges: a partial date has no
-    # whole date to be wrong about, and a nonsense but well-shaped one
-    # round-trips rather than 500s a write.
+    # each component's range standing where the component stands. A range
+    # admits any Comparable in its bounds, but the two producers — SQLite's
+    # integer columns and #to_i — only yield Integers, and a String misses
+    # to the else. Components are not calendar-validated: a nonsense but
+    # well-shaped date round-trips rather than 500s a write.
     def self.new(year: nil, month: nil, day: nil)
       case [year, month, day]
       in [0..9999, 1..12, 1..31]
@@ -103,12 +79,9 @@ module ProTacts
       super
     end
 
-    # The two wire forms a client has been verified to accept: a plain
-    # 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.
+    # The line sent to clients: a plain full date, or Apple's 1604
+    # sentinel for a month and day. Nil for the other four shapes, which
+    # no client displays, so no served card carries them.
     def to_line
       case [year, month, day]
       in [Integer => y, Integer => m, Integer => d]
@@ -121,13 +94,9 @@ module ProTacts
     end
 
     # The birthday in prose, one clause per shape the constructor
-    # admits — the same six, in the same order — so every display of a
-    # birthday agrees. Carries no calendar: the components were
-    # range-checked when built, and Date.new would only add a way to
-    # fail, on a value like February 30 that is impossible but
-    # well-shaped and renders as the day it names. No else: the six are
-    # every shape a Birthday holds, so a seventh is better raising here
-    # than rendering as whichever clause it fell through to.
+    # admits, so every display of a birthday agrees. No calendar: Date.new
+    # would only add a way to fail on a well-shaped February 30. No else:
+    # the six are every shape a Birthday holds.
     def to_s
       case [year, month, day]
       in [Integer => y, Integer => m, Integer => d]
@@ -145,71 +114,35 @@ module ProTacts
       end
     end
 
-    # Whether any served card carries this birthday. The deletion rule
-    # turns on it: a client that never saw the birthday cannot have
-    # deleted it.
+    # Whether served cards carry this birthday. A client that was never
+    # sent a birthday cannot have deleted it (Store#put).
     def served?
       !to_line.nil?
     end
 
-    # Any well-shaped BDAY value as a model, regardless of spelling:
-    # a full date (the optional time accepted and dropped, as
-    # #from_property drops it), the reduced no-year, and the four
-    # UNRENDERED_VALUES shapes — every form the grammar admits. A
-    # display reader, the raw-value counterpart of #from_property: the
-    # write path keeps reading properties, and accepts only what
-    # #to_line can serve back, so what a client PUTs still recomposes
-    # into exactly the served forms. A bare 1604 date is a full date
-    # here, as it is there — the sentinel is the parameter's to claim.
-    # Nil for anything else, and the caller keeps the value as stored.
-    def self.from_value(value)
-      if (m = value.match(FULL_DATE))
-        new(year: m[1].to_i, month: m[2].to_i, day: m[3].to_i)
-      elsif (m = value.match(REDUCED_DATE))
-        new(month: m[1].to_i, day: m[2].to_i)
-      elsif (m = value.match(YEAR_AND_MONTH))
-        new(year: m[1].to_i, month: m[2].to_i)
-      elsif (m = value.match(YEAR_ALONE))
-        new(year: m[1].to_i)
-      elsif (m = value.match(MONTH_ALONE))
-        new(month: m[1].to_i)
-      elsif (m = value.match(DAY_ALONE))
-        new(day: m[1].to_i)
-      end
-    end
-
-    # 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 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.
+    # One parsed BDAY property as a birthday, or nil for a line the model
+    # does not take — which then stays in the card verbatim (RFC 6352
+    # section 6.3.2.2). A grouped `item1.BDAY` is not taken, because its
+    # label pairing belongs to the card, and neither is any parameter but
+    # VALUE=date and the Apple sentinel's.
     def self.from_property(property)
       return nil if property.group
 
       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?(parameters) && (m = property.value.match(OMIT_YEAR_DATE))
-        new(month: m[1].to_i, day: m[2].to_i)
+      if parameters.empty? || (apple_no_year?(parameters) && property.value.match?(OMIT_YEAR_DATE))
+        from_value(property.value)
       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").
+    # line does not. iOS spells it out on every birthday it writes.
     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.
+    # The Apple sentinel's parameter, exactly: the one parameter, naming
+    # 1604 as the year to omit.
     def self.apple_no_year?(parameters)
       return false unless parameters.length == 1
 
@@ -217,25 +150,25 @@ module ProTacts
       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? && significant_parameters(property).empty? && property.value.match?(REDUCED_DATE))
+    # A BDAY value in any spelling this reads, or nil.
+    def self.from_value(value)
+      if (m = value.match(FULL_DATE))
+        year = m[1].to_i
+        month = m[2].to_i
+        day = m[3].to_i
+        year == NO_YEAR ? new(month:, day:) : new(year:, month:, day:)
+      elsif (m = value.match(MONTH_AND_DAY))
+        new(month: m[1].to_i, day: m[2].to_i)
+      elsif (m = value.match(YEAR_AND_MONTH))
+        new(year: m[1].to_i, month: m[2].to_i)
+      elsif (m = value.match(YEAR_ALONE))
+        new(year: m[1].to_i)
+      elsif (m = value.match(MONTH_ALONE))
+        new(month: m[1].to_i)
+      elsif (m = value.match(DAY_ALONE))
+        new(day: m[1].to_i)
+      end
     end
+    private_class_method :from_value, :apple_no_year?
   end
 end
diff --git a/lib/pro_tacts/birthday_line.rb b/lib/pro_tacts/birthday_line.rb
new file mode 100644
index 0000000..f84f3ea
--- /dev/null
+++ b/lib/pro_tacts/birthday_line.rb
@@ -0,0 +1,41 @@
+require "pro_tacts/birthday"
+require "pro_tacts/vcard/parser"
+
+module ProTacts
+  # What one BDAY line in a submitted card is, for Store#put to act on
+  # (docs/plans/2026-09-11-every-birthday-in-the-model.md). Match with
+  # case/when, not case/in: Steep does not check the bodies of case/in
+  # branches, and these branches are the ones worth checking.
+  #
+  # The variants are signed in sig/pro_tacts/birthday_line.rbs, for
+  # Birthday's reason.
+  module BirthdayLine
+    # A birthday the model takes: the line leaves the card.
+    # @rbs skip
+    class Modeled < Data.define(:birthday)
+    end
+
+    # A line that reads as a BDAY the model does not take. It stays in
+    # the card verbatim and is reported.
+    # @rbs skip
+    class Unrecognized < Data.define
+    end
+
+    # A line the parser could not read as a BDAY at all. It stays in the
+    # card, and Web#report_unreadable_lines has already reported it.
+    # @rbs skip
+    class Unreadable < Data.define
+    end
+
+    # The line's property is checked by name as well as read, because a
+    # line matched as BDAY by its bytes can still fail to parse into one.
+    #: (VCard::Parser::Line line) -> (Modeled | Unrecognized | Unreadable)
+    def self.read(line)
+      property = line.property
+      return Unreadable.new unless property&.name&.casecmp?("BDAY")
+
+      birthday = Birthday.from_property(property)
+      birthday ? Modeled.new(birthday:) : Unrecognized.new
+    end
+  end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index eaa4b01..28528a0 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -5,6 +5,7 @@ require "sequel"
 require "sentry-ruby"
 
 require "pro_tacts/birthday"
+require "pro_tacts/birthday_line"
 require "pro_tacts/card_diff"
 require "pro_tacts/contact"
 require "pro_tacts/vcard"
@@ -367,49 +368,42 @@ module ProTacts
     # the bind is the third line.
     #: (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;
-      # any other BDAY — the vCard 4.0 forms, a foreign sentinel, a
-      # line that will not parse, a BDAY sharing its line's bytes with
-      # another, 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 a client's
-      # rewrite, and macOS Contacts drops the BDAY lines it cannot
-      # render from every card it writes (docs/apple-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.
+      # The birthday half of the split a write makes
+      # (docs/plans/2026-09-11-every-birthday-in-the-model.md). A card
+      # holds at most one BDAY, and one that reads as a birthday leaves
+      # the card for the model. Any other stays in the card byte for
+      # byte, reported, with the model emptied so nothing composes a
+      # second BDAY beside it (RFC 6352 section 6.3.2.2).
       existing = birthday_of(id)
       # The card as it stands before this write, read once for the two
-      # halves that need it: the rewrite arm below, which carries
-      # unrendered BDAY lines across, and the subtraction after the
-      # case, which accounts for the member's own lines before
-      # attributing any to a group.
+      # halves that need it: the no-BDAY arm below, which reports what
+      # it drops, and the subtraction after it, which accounts for the
+      # member's own lines before attributing any to a group.
       own = stored_card(id)
+      bdays, rest = vcard.extract("BDAY")
       birthday, stored =
-        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 : 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(own)
-          kept = existing && !existing.served? ? existing : nil
-          report_lost_bday_lines(lost)
-          [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)
+        if bdays.empty?
+          # No BDAY: a client that was sent the birthday removed it, and
+          # one that was never sent it cannot have. A BDAY line left in
+          # the stored card goes with the rewrite, which is worth saying.
+          report_lost_bday_lines(own)
+          [existing && !existing.served? ? existing : nil, vcard]
+        elsif bdays.length > 1
+          report_many_bday_lines(bdays.length)
           [nil, vcard]
+        else
+          line = BirthdayLine.read(bdays.fetch(0))
+          case line
+          when BirthdayLine::Modeled
+            [line.birthday, rest]
+          when BirthdayLine::Unrecognized
+            report_unrecognized_bday_line
+            [nil, vcard]
+          when BirthdayLine::Unreadable
+            [nil, vcard]
+          else
+            raise "no arm for #{line.class}"
+          end
         end
 
       # The other half of the split, the same shape as the birthday's:
@@ -832,63 +826,21 @@ module ProTacts
       row && VCard.new(row.fetch(:vcard).to_s)
     end
 
-    # The BDAY a decision may be made of: the line's property, when it
-    # has one and it names BDAY. Found by name rather than position,
-    # and safe to act on because the line is the unit a rewrite or a
-    # move to the model carries and a line holds at most one property
-    # (VCard::Parser::Line) — so moving its bytes moves this and
-    # nothing else. Nil for any other shape, the empty line a failed
-    # read leaves included.
-    #: (VCard::Parser::Line line) -> VCard::Parser::Property?
-    def bday_of(line)
-      property = line.property
-      property if property&.name&.casecmp?("BDAY")
-    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.
-    #: (VCard? vcard) -> [Array[String], Array[VCard::Parser::Line]]
-    def carried_and_lost_bday_lines(vcard)
-      return [[], []] if vcard.nil?
-
-      bdays, = vcard.extract("BDAY")
-      carried, rest = bdays.partition { |line|
-        property = bday_of(line)
-        property && Birthday.unrendered_value?(property.value)
-      }
-      lost = rest.reject { |line|
-        property = bday_of(line)
-        property && Birthday.rendered?(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
+    # The arrival report: a BDAY the model does not take 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::Parser::Line] lines) -> void
-    def report_unrecognized_bday_lines(lines)
-      unrecognized = lines.count { |line|
-        # A line with no property is one this server could not read at
-        # all, and a value it never read is not a value it failed to
-        # recognize. The line's bytes are stored either way.
-        next false if line.property.nil?
-
-        property = bday_of(line)
-        property.nil? || (!Birthday.rendered?(property) && !Birthday.unrendered_value?(property.value))
-      }
-      return if unrecognized.zero?
+    # (ProTacts::SentryScrubber's line); the value stays on the
+    # machine, where the admin view shows it raw.
+    #: () -> void
+    def report_unrecognized_bday_line
+      Sentry.capture_message("a submitted card carried a BDAY line the model does not take", level: :warning)
+    end
 
-      Sentry.capture_message(
-        "a submitted card carried #{unrecognized} BDAY line(s) no client renders and no whitelist recognizes",
-        level: :warning,
-      )
+    # A contact has one birthday, so a card with several cannot say
+    # which it means, whatever each line holds.
+    #: (Integer count) -> void
+    def report_many_bday_lines(count)
+      Sentry.capture_message("a submitted card carried #{count} BDAY lines", level: :warning)
     end
 
     # The submitted card with the lines its groups lend it taken back
@@ -1158,7 +1110,7 @@ module ProTacts
     # attribute — two lines of that name arrived that the member's card
     # does not explain, and calling either the edit would be a guess.
     # The card is stored as it arrived and the line is news, the same
-    # bargain report_unrecognized_bday_lines makes, and carries no card
+    # bargain report_unrecognized_bday_line makes, and carries no card
     # content for the same reason (ProTacts::SentryScrubber).
     #: (Integer count) -> void
     def report_ambiguous_inherited_lines(count)
@@ -1189,17 +1141,17 @@ module ProTacts
       )
     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::Parser::Line] lines) -> void
-    def report_lost_bday_lines(lines)
+    # The loss report, the rewrite's half of the arrival one: a BDAY
+    # line still in the stored card is one the model did not take, and a
+    # rewrite without it is about to drop it with nobody told.
+    #: (VCard? own) -> void
+    def report_lost_bday_lines(own)
+      return if own.nil?
+
+      lines, = own.extract("BDAY")
       return if lines.empty?
 
-      Sentry.capture_message(
-        "a rewrite dropped #{lines.length} BDAY line(s) no client renders and no whitelist carries",
-        level: :warning,
-      )
+      Sentry.capture_message("a rewrite dropped #{lines.length} BDAY line(s) the model did not take", level: :warning)
     end
 
     # The birthday travels beside the card rather than inside it, and
diff --git a/sig/pro_tacts/birthday.rbs b/sig/pro_tacts/birthday.rbs
index d217ba0..100f552 100644
--- a/sig/pro_tacts/birthday.rbs
+++ b/sig/pro_tacts/birthday.rbs
@@ -3,15 +3,15 @@
 # birthday.rb and signed here instead.
 module ProTacts
   class Birthday
-    FULL_DATE: Regexp
-    OMIT_YEAR_DATE: Regexp
+    NO_YEAR: Integer
     OMIT_YEAR: String
+    FULL_DATE: Regexp
+    MONTH_AND_DAY: Regexp
     YEAR_AND_MONTH: Regexp
     YEAR_ALONE: Regexp
     MONTH_ALONE: Regexp
     DAY_ALONE: Regexp
-    UNRENDERED_VALUES: Array[Regexp]
-    REDUCED_DATE: Regexp
+    OMIT_YEAR_DATE: Regexp
 
     attr_reader year: Integer?
     attr_reader month: Integer?
@@ -25,16 +25,12 @@ module ProTacts
 
     def served?: () -> bool
 
-    def self.from_value: (String value) -> Birthday?
-
     def self.from_property: (VCard::Parser::Property property) -> Birthday?
 
     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
-
-    def self.rendered?: (VCard::Parser::Property property) -> bool
+    def self.from_value: (String value) -> Birthday?
   end
 end
diff --git a/sig/pro_tacts/birthday_line.rbs b/sig/pro_tacts/birthday_line.rbs
new file mode 100644
index 0000000..05fc917
--- /dev/null
+++ b/sig/pro_tacts/birthday_line.rbs
@@ -0,0 +1,21 @@
+# BirthdayLine's variants are Data classes, whose members the inline
+# parser cannot read (see sig/pro_tacts/birthday.rbs for the same
+# limit). Each is marked `@rbs skip` in birthday_line.rb and signed
+# here; the module's own reader carries its signature inline.
+module ProTacts
+  module BirthdayLine
+    class Modeled
+      attr_reader birthday: Birthday
+
+      def self.new: (birthday: Birthday) -> instance
+    end
+
+    class Unrecognized
+      def self.new: () -> instance
+    end
+
+    class Unreadable
+      def self.new: () -> instance
+    end
+  end
+end
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index aee624a..567431e 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -51,7 +51,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+      <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -78,7 +78,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-day-only.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"25ef184d775a0c7551be4cf5397292a36982d89565aa33e314d65bc0aae0299c"</d:getetag>
+      <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -87,7 +87,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"303991514fc3eb60721a39f014cf533a407039f558b4dfc5556bfc31c35173e9"</d:getetag>
+      <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -96,7 +96,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-month-only.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"6aafe94f0d0857d91ee3a8bba7a49f9ceb758270cb5a2b4d35e7f48026494881"</d:getetag>
+      <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -114,7 +114,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-year-month.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"aa31020a69a1123e0c3bfefa0cf34a8ecf8c58e2f4af6f293c62920a06a26357"</d:getetag>
+      <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -123,7 +123,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-year-only.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"05e62746a7c24908a89e3ea9f4333a4033ed18d7398b1ff1c1ad192850924313"</d:getetag>
+      <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index b17fc5b..9e9de80 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -26,7 +26,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+      <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -53,7 +53,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-day-only.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"25ef184d775a0c7551be4cf5397292a36982d89565aa33e314d65bc0aae0299c"</d:getetag>
+      <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -62,7 +62,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"303991514fc3eb60721a39f014cf533a407039f558b4dfc5556bfc31c35173e9"</d:getetag>
+      <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -71,7 +71,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-month-only.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"6aafe94f0d0857d91ee3a8bba7a49f9ceb758270cb5a2b4d35e7f48026494881"</d:getetag>
+      <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -89,7 +89,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-year-month.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"aa31020a69a1123e0c3bfefa0cf34a8ecf8c58e2f4af6f293c62920a06a26357"</d:getetag>
+      <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -98,7 +98,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-year-only.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"05e62746a7c24908a89e3ea9f4333a4033ed18d7398b1ff1c1ad192850924313"</d:getetag>
+      <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
index 56d1a44..f6c4ddb 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -16,7 +16,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+      <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -43,7 +43,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-day-only.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"25ef184d775a0c7551be4cf5397292a36982d89565aa33e314d65bc0aae0299c"</d:getetag>
+      <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -52,7 +52,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"303991514fc3eb60721a39f014cf533a407039f558b4dfc5556bfc31c35173e9"</d:getetag>
+      <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -61,7 +61,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-month-only.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"6aafe94f0d0857d91ee3a8bba7a49f9ceb758270cb5a2b4d35e7f48026494881"</d:getetag>
+      <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -79,7 +79,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-year-month.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"aa31020a69a1123e0c3bfefa0cf34a8ecf8c58e2f4af6f293c62920a06a26357"</d:getetag>
+      <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -88,7 +88,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-year-only.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"05e62746a7c24908a89e3ea9f4333a4033ed18d7398b1ff1c1ad192850924313"</d:getetag>
+      <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index aee624a..567431e 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -51,7 +51,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+      <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -78,7 +78,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-day-only.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"25ef184d775a0c7551be4cf5397292a36982d89565aa33e314d65bc0aae0299c"</d:getetag>
+      <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -87,7 +87,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"303991514fc3eb60721a39f014cf533a407039f558b4dfc5556bfc31c35173e9"</d:getetag>
+      <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -96,7 +96,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-month-only.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"6aafe94f0d0857d91ee3a8bba7a49f9ceb758270cb5a2b4d35e7f48026494881"</d:getetag>
+      <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -114,7 +114,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-year-month.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"aa31020a69a1123e0c3bfefa0cf34a8ecf8c58e2f4af6f293c62920a06a26357"</d:getetag>
+      <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -123,7 +123,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-year-only.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"05e62746a7c24908a89e3ea9f4333a4033ed18d7398b1ff1c1ad192850924313"</d:getetag>
+      <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 36f3eb0..5206828 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1535,7 +1535,7 @@ class AdminContactsPagesTest < Minitest::Test
   # submitted birthday would compose a second BDAY beside it. Refused
   # whole, name edit included: one submit, one write.
   def test_a_birthday_against_a_cards_own_spelling_is_refused
-    resident = ADA.sub("BDAY:1985-12-10\r\n", "BDAY:1985-04\r\n")
+    resident = ADA.sub("BDAY:1985-12-10\r\n", "BDAY:1985-13\r\n")
 
     with_contacts({"ada" => resident}) do |store|
       assert_nil store.contact("ada").birthday
@@ -1549,7 +1549,7 @@ class AdminContactsPagesTest < Minitest::Test
       contact = store.contact("ada")
       assert_nil contact.birthday
       assert_equal 1, contact.vcard.to_s.scan("BDAY").length
-      assert_includes contact.vcard.to_s, "BDAY:1985-04\r\n"
+      assert_includes contact.vcard.to_s, "BDAY:1985-13\r\n"
       assert_includes contact.vcard.to_s, "FN:Ada Lovelace\r\n"
     end
   end
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index 89c3943..937656c 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -77,10 +77,9 @@ class FormatTest < Minitest::Test
     assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(CARD, birthday:))
   end
 
-  # The reduced no-year spelling (Birthday::REDUCED_DATE), which macOS
-  # reads on a card it did not write: the model does not recompose it,
-  # so the line stays in the card verbatim — display renders it as a
-  # day all the same, not as the stored string.
+  # A month-day spelling still in a card rather than the model: a write
+  # moves it out, and display reads it the way a write would
+  # (Birthday::MONTH_AND_DAY), not as the stored string.
   def test_a_reduced_no_year_birthday_displays_its_day
     reduced = CARD.sub("END:VCARD\r\n", "BDAY:--12-10\r\nEND:VCARD\r\n")
 
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index 0072c80..28f2584 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -45,9 +45,8 @@ class BirthdayTest < Minitest::Test
     assert_equal "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12", ProTacts::Birthday.new(month: 4, day: 12).to_line
   end
 
-  # The four shapes no client has been verified to accept reach no wire;
-  # this is the set the deletion rule treats as invisible.
-  def test_the_shapes_without_a_verified_spelling_serve_nothing
+  # The four shapes no client displays are never sent.
+  def test_the_shapes_no_client_displays_serve_nothing
     [ProTacts::Birthday.new(year: 1985, month: 4), ProTacts::Birthday.new(year: 1985),
       ProTacts::Birthday.new(month: 4), ProTacts::Birthday.new(day: 12)].each do |birthday|
       assert_nil birthday.to_line
@@ -88,9 +87,18 @@ class BirthdayTest < Minitest::Test
     ProTacts::VCard::Parser::Property.new(group:, name: "BDAY", parameters:, value:)
   end
 
-  def test_a_full_date_reads_into_the_model
-    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12),
-      ProTacts::Birthday.from_property(property("1985-04-12"))
+  def test_every_well_shaped_spelling_reads
+    {
+      "1985-04-12" => ProTacts::Birthday.new(year: 1985, month: 4, day: 12),
+      "--0412" => ProTacts::Birthday.new(month: 4, day: 12),
+      "--04-12" => ProTacts::Birthday.new(month: 4, day: 12),
+      "1985-04" => ProTacts::Birthday.new(year: 1985, month: 4),
+      "1985" => ProTacts::Birthday.new(year: 1985),
+      "--04" => ProTacts::Birthday.new(month: 4),
+      "---12" => ProTacts::Birthday.new(day: 12),
+    }.each do |value, birthday|
+      assert_equal birthday, ProTacts::Birthday.from_property(property(value)), value
+    end
   end
 
   # docs/apple-contacts.md, "Birthdays without a year": the verified
@@ -101,6 +109,14 @@ class BirthdayTest < Minitest::Test
     assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_property(sentinel)
   end
 
+  # iOS writes the sentinel back without its parameter, so 1604 means no
+  # year on its own (docs/plans/2026-09-11-every-birthday-in-the-model.md).
+  def test_a_1604_date_has_no_year
+    [property("1604-03-08"), property("1604-03-08", parameters: [["value", "date"]])].each do |ios|
+      assert_equal ProTacts::Birthday.new(month: 3, day: 8), ProTacts::Birthday.from_property(ios)
+    end
+  end
+
   # RFC 2426 section 3.1.5 lets BDAY carry a date and time; the time is
   # the one thing ingest deliberately loses, because a birthday is a
   # date — the design record's decision, not an oversight.
@@ -129,109 +145,19 @@ class BirthdayTest < Minitest::Test
     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
-  # when a rewrite drops it.
-  def test_an_out_of_range_date_reads_as_nil
-    assert_nil ProTacts::Birthday.from_property(property("1985-13-40"))
-  end
-
-  # Everything the model cannot recompose reads as nil, keeping it in
-  # the card verbatim (RFC 6352 section 6.3.2.2): extra parameters
-  # travel with their line, and a grouped property's label pairing
-  # belongs to the card.
-  def test_what_the_model_cannot_recompose_reads_as_nil
-    [property("--0412"), property("1985-04"), property("19850412"),
+  # Everything else reads as nil and stays in the card verbatim (RFC
+  # 6352 section 6.3.2.2): a value out of range or in no spelling this
+  # reads, a parameter that travels with its line, the sentinel's
+  # parameter on a date it does not stand in for, and a grouped
+  # property, whose label pairing belongs to the card.
+  def test_what_the_model_does_not_take_reads_as_nil
+    [property("1985-13-40"), property("--0432"), property("19850412"), property("banana"), property(""),
       property("1985-04-12", parameters: [["X-OTHER", "1"]]),
       property("1985-04-12", parameters: [["VALUE", "text"]]),
+      property("1985-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"]]),
       property("1604-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"], ["X-OTHER", "1"]]),
-      property("1985-04-12", group: "item1")].each do |unmodeled|
+      property("1985-04-12", group: "item1"), property("--0412", group: "item1")].each do |unmodeled|
       assert_nil ProTacts::Birthday.from_property(unmodeled), unmodeled.value
     end
   end
-
-  ## from_value
-
-  # The display reader: every well-shaped spelling models, regardless
-  # of what the write path serves — the two from_property reads (a
-  # date-time's time dropped on the way in, as there), the reduced
-  # no-year in both spellings, and the four no client renders.
-  def test_from_value_reads_every_well_shaped_spelling
-    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), ProTacts::Birthday.from_value("1985-04-12")
-    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), ProTacts::Birthday.from_value("1985-04-12T23:10:00Z")
-    assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_value("--04-12")
-    assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_value("--0412")
-    assert_equal ProTacts::Birthday.new(year: 1985, month: 4), ProTacts::Birthday.from_value("1985-04")
-    assert_equal ProTacts::Birthday.new(year: 1985), ProTacts::Birthday.from_value("1985")
-    assert_equal ProTacts::Birthday.new(month: 4), ProTacts::Birthday.from_value("--04")
-    assert_equal ProTacts::Birthday.new(day: 12), ProTacts::Birthday.from_value("---12")
-  end
-
-  # The sentinel is the parameter's to claim, exactly as from_property
-  # reads it.
-  def test_from_value_reads_a_bare_1604_date_as_a_full_date
-    assert_equal ProTacts::Birthday.new(year: 1604, month: 4, day: 12), ProTacts::Birthday.from_value("1604-04-12")
-  end
-
-  # Anything else is nothing display can name, and the caller keeps
-  # the value as stored.
-  def test_from_value_refuses_what_it_cannot_read
-    ["banana", "1985-13-40", "19850412", ""].each do |value|
-      assert_nil ProTacts::Birthday.from_value(value), 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"),
-      property("1985-04-12", parameters: [["value", "date"]]),
-      property("--0412", parameters: [["value", "date"]])].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 4667c87..481b4c7 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -752,7 +752,7 @@ class StoreTest < Minitest::Test
   # The card's own line speaks for itself, and nothing composes a
   # second one beside it. A fold travels with its line, byte for byte.
   def test_an_unmodeled_bday_stays_in_the_card_and_empties_the_model
-    ["BDAY:--0412", "BDAY:1985-\r\n 04\r\n", "BDAY:1985-04-12\r\nBDAY:1986-04-12\r\n"].each do |bday|
+    ["BDAY:1985-13", "BDAY:1985-\r\n 13\r\n", "BDAY:1985-04-12\r\nBDAY:1986-04-12\r\n"].each do |bday|
       unmodeled = AIDEN.sub("END:VCARD\r\n", "#{bday}END:VCARD\r\n")
 
       with_store({"aiden" => AIDEN_BORN}) do |store|
@@ -783,27 +783,42 @@ class StoreTest < Minitest::Test
     end
   end
 
-  # macOS Contacts drops the lines it cannot render from every card it
-  # writes, so a rewrite that omits the BDAY carries them across rather
-  # than reading the absence as a deletion (docs/apple-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|
+  # The four shapes no client displays go into the model and are never
+  # sent, so a card sent back without them has not deleted them.
+  def test_a_birthday_no_client_is_sent_survives_a_rewrite
+    {
+      "BDAY:1985-04" => ProTacts::Birthday.new(year: 1985, month: 4),
+      "BDAY:1985" => ProTacts::Birthday.new(year: 1985),
+      "BDAY:--04" => ProTacts::Birthday.new(month: 4),
+      "BDAY:---12" => ProTacts::Birthday.new(day: 12),
+    }.each do |line, birthday|
       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")
+        assert_equal AIDEN, store.contact("aiden").vcard.to_s, line
 
+        edited = AIDEN.sub("FN:Aiden", "FN:Aiden Smith")
         store.put("aiden", vcard(edited))
 
-        assert_equal edited.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n"), store.contact("aiden").vcard.to_s, line
-        assert_nil birthday_row(store, "aiden"), line
+        assert_equal edited, store.contact("aiden").vcard.to_s, line
+        assert_equal birthday, birthday_row(store, "aiden"), line
+      end
+    end
+  end
+
+  # A month and day goes out as the sentinel whatever spelling it came
+  # in, including the extended one iOS misreads as a year-2 date and the
+  # parameterless 1604 iOS writes back.
+  def test_a_month_and_day_in_any_spelling_goes_out_as_the_sentinel
+    ["BDAY:--11-27", "BDAY:--1127", "BDAY;value=date:1604-11-27"].each do |line|
+      with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")}) do |store|
+        assert_equal ProTacts::Birthday.new(month: 11, day: 27), birthday_row(store, "aiden"), line
+        assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-11-27\r\nEND:VCARD\r\n"),
+          store.contact("aiden").vcard.to_s, 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.
+  # --0412 goes out as the sentinel, so a rewrite without it removed a
+  # birthday the client was sent, and the deletion is honored.
   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", vcard(AIDEN))
@@ -813,9 +828,8 @@ class StoreTest < Minitest::Test
     end
   end
 
-  # The rewrite's half of the same rule: a shared line is not carried
-  # (carrying it would carry its fellow bytes too), so dropping it is
-  # reported rather than silent.
+  # A shared line never reaches the model, so it stays in the stored
+  # card, and a rewrite dropping it is reported rather than silent.
   def test_a_rewrite_dropping_a_shared_bday_line_is_reported
     shared = AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04\rNOTE:b\r\nEND:VCARD\r\n")
     edited = AIDEN.sub("FN:Aiden", "FN:Aiden Smith")
@@ -829,9 +843,9 @@ class StoreTest < Minitest::Test
     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
+  # A submission carrying any BDAY replaces what was there, a birthday
+  # no client was sent included: PUT is a whole-card replace.
+  def test_a_submitted_birthday_replaces_one_no_client_was_sent
     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")
 
@@ -843,8 +857,8 @@ class StoreTest < Minitest::Test
   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.
+  # BDAY the model did not take 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|
       # The seed's own arrival report is not this test's subject.
@@ -857,9 +871,7 @@ class StoreTest < Minitest::Test
     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.
+  # A rewrite over birthdays the model holds has no line to drop.
   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|
@@ -870,13 +882,11 @@ class StoreTest < Minitest::Test
     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.
+  # The arrival report: a submitted BDAY the model does not take is
+  # unexpected input, and storing it verbatim would be the last anyone
+  # heard of it.
   def test_an_unrecognized_bday_arriving_is_reported
-    ["BDAY:1985-13", "BDAY:--0432", "BDAY:19850412", "BDAY:1985-4"].each do |line|
+    ["BDAY:1985-13", "BDAY:--0432", "BDAY:19850412", "BDAY:1985-4", "item1.BDAY:1985-04-12"].each do |line|
       with_store({}) do |store|
         clear_sentry_events
         store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")))
@@ -888,7 +898,8 @@ class StoreTest < Minitest::Test
 
   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|
+      "BDAY:--0412", "BDAY:--04-12", "BDAY:1985-04", "BDAY:1985", "BDAY:--04", "BDAY:---12",
+      "BDAY;value=date:1985-04-12"].each do |line|
       with_store({}) do |store|
         store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")))
 
@@ -897,6 +908,18 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # A contact has one birthday, so several BDAY lines are reported even
+  # when each would read on its own.
+  def test_several_bday_lines_are_reported_whatever_they_hold
+    with_store({}) do |store|
+      store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nBDAY:1986-04-12\r\nEND:VCARD\r\n")))
+      messages = sentry_messages
+
+      assert_equal 1, messages.length
+      assert_match "2 BDAY lines", messages.fetch(0)
+    end
+  end
+
   ## The change log
   def test_a_stored_card_is_never_indexed_with_a_bday
     with_store({"aiden" => AIDEN_BORN}) do |store|
@@ -1774,7 +1797,7 @@ class StoreTest < Minitest::Test
       Sequel.connect("sqlite://#{path}") do |db|
         Sequel::Migrator.run(db, ProTacts::Store::MIGRATIONS.to_s, target: 1)
         db[:cards].insert(id: "aiden", vcard: AIDEN_BORN)
-        db[:cards].insert(id: "znorth", vcard: ZED.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n"))
+        db[:cards].insert(id: "znorth", vcard: ZED.sub("END:VCARD\r\n", "BDAY:19850412\r\nEND:VCARD\r\n"))
         db[:cards].insert(id: "xavi", vcard: shared)
       end
 
@@ -1784,7 +1807,7 @@ class StoreTest < Minitest::Test
         assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), store.contact("aiden").vcard.to_s
 
         # Unmodeled: byte-identical, no birthday row beside it.
-        assert_equal ZED.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n"), store.contact("znorth").vcard.to_s
+        assert_equal ZED.sub("END:VCARD\r\n", "BDAY:19850412\r\nEND:VCARD\r\n"), store.contact("znorth").vcard.to_s
         assert_nil birthday_row(store, "znorth")
 
         # A BDAY sharing its line's bytes stays put: the line moves as
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 53729e1..e33aeee 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -384,24 +384,26 @@ 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")
+  # A birthday no client is sent is not in the card a client downloads
+  # (docs/plans/2026-09-11-every-birthday-in-the-model.md), so sending
+  # that card back is the served card octet for octet and keeps the
+  # tag — and keeps the birthday.
+  def test_a_birthday_no_client_is_sent_stays_off_the_wire
+    unserved = 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
+    with_contacts({}) do |store|
+      put_request "new", unserved, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
       assert_nil last_response["ETag"]
 
       get "/dav/addressbook/new.vcf"
-      assert_includes last_response.body, "BDAY:1985-04\r\nEND:VCARD"
+      served = last_response.body
+      refute_includes served, "BDAY"
+
+      put_request "new", served, "CONTENT_TYPE" => VCARD
+
+      assert_equal 204, last_response.status
+      assert_equal %("#{Digest::SHA256.hexdigest(served)}"), last_response["ETag"]
+      assert_equal ProTacts::Birthday.new(year: 1985, month: 4), store.contact("new")&.birthday
     end
   end