Model birthdays as partial dates
A partial date has no vCard 3.0 spelling (RFC 2425 section 5.8.4 requires
all three date components), so BDAY moves into its own table — the third
the database cannot rebuild — composed into served cards on read and
subtracted on write, the way a group's shared attributes are planned to
be. A birthday-carrying PUT no longer stores octet for octet, so those
responses drop the strong ETag and the client refetches (RFC 6352 section
6.3.2.3); the plan and the wire mapping are docs/plans/2026-08-31-partial-birthdays.md.

Assisted-by: GLM-5.3 via pi
change ksmzouullxtsqytxunrmlltqwttqnspm
commit 12989cbcef99219eaba9747c15745ebf3b5b21a7
author Alpha Chen <alpha@kejadlen.dev>
date
parent xuztuwuz
diff --git a/AGENTS.md b/AGENTS.md
index b79dc95..23b973a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -135,12 +135,14 @@ not a fixture; edit a `.vcf` to change what the replay serves.
   configuration as it loads, since the unhandled-request middleware is
   given its directory at class-definition time. Set it afterwards and
   the 404 captures land in `log/` instead of `tmp/`.
-- Only two things in the database cannot be rebuilt: the cards and the
-  change log. Everything else is a projection of the cards that
-  `rake index:rebuild` will make again, so no repair is ever needed for
-  it. A write that touches a card must land its change-log entry in the
-  same transaction, because a client's sync token silently skips
-  whatever the log missed.
+- Only three things in the database cannot be rebuilt: the cards, the
+  change log, and the birthdays — a partial date has no vCard 3.0
+  spelling, so it lives beside its card rather than in it (see
+  docs/plans/2026-08-31-partial-birthdays.md). Everything else is a
+  projection of the cards that `rake index:rebuild` will make again,
+  so no repair is ever needed for it. A write that touches a card must
+  land its change-log entry in the same transaction, because a client's
+  sync token silently skips whatever the log missed.
 - A read whose filter is meant to identify one row uses `sole`, not
   `first` — `first` answers with one of several rather than saying the
   filter was too loose. `sole` raises either way it is wrong:
diff --git a/README.md b/README.md
index 4b24f40..5f82b7d 100644
--- a/README.md
+++ b/README.md
@@ -38,10 +38,11 @@ END:VCARD
 Storing the card rather than a parse of it is what lets the server keep
 properties it does not model, which RFC 6352 section 6.3.2.2 requires of
 anything accepting writes; `docs/plans/2026-08-24-vcard-storage-and-groups.md`
-makes the case at length. The same database holds the change log, which
-is the one thing in it that cannot be rebuilt, and an index of parsed
-properties, which can — `rake index:rebuild` derives that again from the
-cards alone.
+makes the case at length. The same database holds the change log and
+the birthdays — the two things in it that cannot be rebuilt, the
+birthdays because a partial date has no vCard 3.0 spelling — and an
+index of parsed properties, which can: `rake index:rebuild` derives
+that again from the stored cards alone.
 
 macOS Contacts displays them over Tailscale serve as of 2026-08-14, so
 later work has a known-good baseline to change. See
@@ -93,11 +94,14 @@ carry:
   cards through multiget or `GET` on its own.
 - `GET` a card: the vCard body plus an `ETag` header.
 - `PUT` a card: 201 for a create at an unmapped href, 204 for a replace,
-  both carrying the strong `ETag` — permitted because what is stored is
-  what was submitted (RFC 6352 section 6.3.2.3). A refused write is a
-  412 naming the precondition in a `DAV:error` body. This row follows
-  the RFC rather than client evidence: no live write had been answered
-  when it was written, so the first synced device to edit is its test.
+  each carrying the strong `ETag` only when what is stored is what was
+  submitted, octet for octet (RFC 6352 section 6.3.2.3) — a card whose
+  birthday is subtracted out before storage and composed back in on
+  read goes without the tag and the client refetches. A refused write
+  is a 412 naming the precondition in a `DAV:error` body. The statuses
+  follow the RFC rather than client evidence: no live write had been
+  answered when they were written, so the first synced device to edit
+  is its test.
 
 Three properties are load-bearing in non-obvious ways, documented in
 `docs/macos-contacts.md`: the collection's `resourcetype` (without
diff --git a/db/migrations/002_birthdays.rb b/db/migrations/002_birthdays.rb
new file mode 100644
index 0000000..44fd9a2
--- /dev/null
+++ b/db/migrations/002_birthdays.rb
@@ -0,0 +1,51 @@
+# Birthdays leave the cards and become state of their own: a partial
+# date has no vCard 3.0 spelling (RFC 2425 section 5.8.4 requires all
+# three components), so the card stops carrying BDAY and the database
+# holds it — docs/plans/2026-08-31-partial-birthdays.md.
+#
+# `text: true` on the string column because these are STRICT tables,
+# which refuse Sequel's plain String. No timestamp: a birthday changes
+# with its card's write and the card's stamp is the write's stamp.
+require "pro_tacts/birthday"
+
+Sequel.migration do
+  change do
+    # One birthday per card, keyed by it and dying with it. The columns
+    # are nullable integers whose nulls carry the shape, validated by
+    # Birthday rather than by the table: the six legal shapes are a
+    # grammar fact, not a constraint SQLite can check.
+    #
+    # This is the third table in the database that cannot be rebuilt.
+    # The cards and the change log were the first two; the index tables
+    # remain projections that `rake index:rebuild` will make again. A
+    # birthday cannot be re-derived from its card because it no longer
+    # lives there.
+    create_table(:birthdays, strict: true) do
+      foreign_key :card_id, :cards, type: String, text: true, null: false, primary_key: true, on_delete: :cascade
+      Integer :year
+      Integer :month
+      Integer :day
+    end
+
+    # The same move a write makes, run over the cards already stored: a
+    # BDAY in a modeled spelling moves into the new table and out of the
+    # card, and any other BDAY stays in the card verbatim. Composed
+    # again on read, a modeled birthday changes served bytes only by
+    # position — it lands before END:VCARD — so clients refetch each
+    # birthday-carrying card once, by design rather than by accident.
+    # The change log is not written: as far as any client can tell, no
+    # contact changed, and the stamps are left alone to say the same.
+    self[:cards].order(:id).each do |row|
+      split = ProTacts::Birthday.subtract(row.fetch(:vcard))
+      next if split.birthday.nil?
+
+      self[:cards].where(id: row.fetch(:id)).update(vcard: split.card)
+      self[:birthdays].insert(
+        card_id: row.fetch(:id),
+        year: split.birthday.year,
+        month: split.birthday.month,
+        day: split.birthday.day,
+      )
+    end
+  end
+end
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
new file mode 100644
index 0000000..86046e6
--- /dev/null
+++ b/lib/pro_tacts/birthday.rb
@@ -0,0 +1,220 @@
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
+module ProTacts
+  # A birthday as the app models it: a partial date, where any of year,
+  # month, and day may be missing.
+  #
+  # The shape is RFC 6350 section 4.3.1's date grammar, which admits six
+  # of the eight presence patterns: all three, year-month, year alone,
+  # month-day, month alone, and day alone. Year-with-day and nothing-at-
+  # all are not forms a date can take there, so the constructor refuses
+  # them. Five of the six appear as examples in the vendored text; month
+  # 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: RFC 2425 section 5.8.4 defines
+  # the date value as `date-fullyear ["-"] date-month ["-"] date-mday`
+  # 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 — the same split a group's shared attributes
+  # take (docs/plans/2026-08-24-vcard-storage-and-groups.md), and the
+  # full reasoning 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.
+    FULL_DATE = /\A(\d{4})-(\d{2})-(\d{2})(?: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/macos-contacts.md,
+    # "Birthdays without a year"). 1604 because it is the first year of
+    # the Gregorian calendar; the parameter names the year to omit.
+    OMIT_YEAR_DATE = /\A1604-(\d{2})-(\d{2})\z/ #: Regexp
+    OMIT_YEAR = "1604" #: String
+
+    # A content line naming BDAY, optionally group-prefixed the way
+    # macOS labels properties (`item1.BDAY`). A group's first physical
+    # line is all that is matched; continuations travel with it.
+    BDAY_LINE = /\A(?:[A-Za-z0-9-]+\.)?BDAY[;:]/i #: Regexp
+
+    # The anchor compose inserts before, so the property lands inside
+    # the envelope however bare the card is.
+    END_LINE = /\AEND:VCARD/i #: Regexp
+
+    # A physical line that continues the one above it (RFC 2426 section
+    # 2.6 folding).
+    CONTINUATION = /\A[ \t]/ #: Regexp
+
+    # What subtract found in a card: the card to store, the birthday it
+    # gave up, and whether a BDAY line is still in the card because no
+    # modeled spelling would recompose it — the probe forms ride on
+    # exactly that, served as stored.
+    #
+    # The signature lives in sig/pro_tacts/birthday.rbs with the class's.
+    Split = Data.define(:card, :birthday, :bday_kept)
+
+    # The only constructor, so no birthday exists in a shape the grammar
+    # rejects. 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.
+    def self.new(year: nil, month: nil, day: nil)
+      if year.nil? && month.nil? && day.nil?
+        raise ArgumentError, "a birthday needs at least one of year, month, and day"
+      end
+      if year && month.nil? && day
+        raise ArgumentError, "year with day but no month is not a shape RFC 6350 section 4.3.1 admits"
+      end
+      if month && !month.between?(1, 12)
+        raise ArgumentError, "month out of range: #{month}"
+      end
+      if day && !day.between?(1, 31)
+        raise ArgumentError, "day out of range: #{day}"
+      end
+      if year && !year.between?(0, 9999)
+        raise ArgumentError, "year out of range: #{year}"
+      end
+
+      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 compose leaves the card alone.
+    def to_line
+      if year && month && day
+        format("BDAY:%04d-%02d-%02d", year, month, day)
+      elsif month && day
+        format("BDAY;X-APPLE-OMIT-YEAR=1604:1604-%02d-%02d", month, day)
+      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.
+    def served?
+      !to_line.nil?
+    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 compose emits are accepted, and only bare: any extra
+    # parameter travels with the line, so a line carrying one is left
+    # whole. A grouped `item1.BDAY` likewise stays, because its label
+    # pairing belongs to the card.
+    def self.from_property(property)
+      return nil if property.group
+
+      if property.parameters.empty? && (m = property.value.match(FULL_DATE))
+        new(year: m[1].to_i, month: m[2].to_i, day: m[3].to_i)
+      elsif apple_no_year?(property) && (m = property.value.match(OMIT_YEAR_DATE))
+        new(month: m[1].to_i, day: m[2].to_i)
+      end
+    rescue ArgumentError
+      # A component out of range is unmodeled, not exceptional: the line
+      # stays in the card as it came.
+      nil
+    end
+
+    # The parameter half of the Apple form, exactly: the one parameter,
+    # named for the year it stands in. A different sentinel or a value
+    # disagreeing with the year in the date is not the verified form and
+    # is not claimed.
+    def self.apple_no_year?(property)
+      return false unless property.parameters.length == 1
+
+      name, value = property.parameters.fetch(0)
+      name.casecmp?("X-APPLE-OMIT-YEAR") && value == OMIT_YEAR
+    end
+
+    # Splits a submitted card into what gets stored and the birthday it
+    # carried. One BDAY line in a modeled spelling moves into the model
+    # and out of the card; any other BDAY — the vCard 4.0 forms, a
+    # foreign sentinel, more than one — is data this model cannot
+    # recompose and stays in the card byte for byte, with the
+    # model emptied so compose never adds a second BDAY beside it.
+    def self.subtract(card)
+      # Bytes that are not the UTF-8 the store's contract promises are
+      # never parsed — they pass through untouched to the bind that
+      # refuses them, which is where the contract is enforced.
+      unless card.valid_encoding?
+        return Split.new(card:, birthday: nil, bday_kept: false)
+      end
+
+      bday_groups, other_groups = line_groups(card).partition { it.first.match?(BDAY_LINE) }
+
+      birthday = nil
+      if bday_groups.length == 1
+        property = parse_line(bday_groups.fetch(0).join)
+        birthday = property && from_property(property)
+      end
+
+      extracted = !birthday.nil?
+      Split.new(
+        card: extracted ? other_groups.join : card,
+        birthday:,
+        bday_kept: !extracted && !bday_groups.empty?,
+      )
+    end
+
+    # Puts a birthday into a stored card, immediately before END:VCARD,
+    # as the inverse of subtract. A birthday with no wire form, or none
+    # at all, leaves the card exactly as it is. The inserted line takes
+    # the END line's own terminator so a card stays single-convention;
+    # with no END to anchor to there is no envelope worth respecting, and
+    # the line is appended with CRLF.
+    def self.compose(card, birthday)
+      line = birthday && birthday.to_line
+      return card if line.nil?
+
+      lines = physical_lines(card)
+      index = lines.rindex { it.match?(END_LINE) }
+      if index
+        lines.insert(index, line + terminator_of(lines.fetch(index)))
+        lines.join
+      else
+        card + line + "\r\n"
+      end
+    end
+
+    # One content line read as a property, or nil when it does not read
+    # as one. The parser unfolds what it is handed, so a folded BDAY
+    # arrives here as the single logical line it is.
+    def self.parse_line(text)
+      VCard::Parser.parse(text).fetch(0, nil)
+    rescue VCard::ParseError
+      nil
+    end
+
+    # Physical lines with their terminators attached, so surgery on them
+    # cannot lose or normalize a line break.
+    def self.physical_lines(card)
+      card.split(/(?<=\n)/, -1)
+    end
+
+    # Logical lines: a physical line and the continuations folded under
+    # it, kept together because a BDAY and its fold are one property.
+    # Slicing before each non-continuation, rather than chunking, is
+    # what attaches a continuation to the line above it.
+    def self.line_groups(card)
+      physical_lines(card).slice_when { |_line, next_line| !next_line.match?(CONTINUATION) }.to_a
+    end
+
+    # The line break a line ends with, for the line inserted beside it to
+    # match. CRLF when it has none, being the grammar's own.
+    def self.terminator_of(line)
+      line[/\r?\n\z/] || "\r\n"
+    end
+  end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 8b7366c..61acb5d 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -3,6 +3,7 @@ require "digest"
 require "pathname"
 require "sequel"
 
+require "pro_tacts/birthday"
 require "pro_tacts/contact"
 require "pro_tacts/vcard/parser"
 
@@ -12,17 +13,26 @@ module ProTacts
   # The transactional store: one SQLite database holding each contact's
   # card exactly as it was submitted.
   #
-  # Three kinds of state live here and they are not equally precious.
+  # Four kinds of state live here and they are not equally precious.
   # The cards are the truth about contact data, and the reason they are
   # stored as the card rather than a parse of it is
   # docs/plans/2026-08-24-vcard-storage-and-groups.md. The change log is
   # the truth about history, and is why this is one database rather than
   # a directory of files: "what changed since token X" cannot be
   # recovered from current state, so a card, its etag, and its
-  # change-log entry have to land or fail together. Everything else is
+  # change-log entry have to land or fail together. The birthdays are
+  # the third thing that cannot be rebuilt — a partial date has no
+  # vCard 3.0 spelling, so it lives beside its card rather than in it
+  # (docs/plans/2026-08-31-partial-birthdays.md). Everything else is
   # an index derived from the cards, and #rebuild_index will make it
   # again from nothing.
   #
+  # Every Contact this store hands out is composed, never the stored
+  # card alone: a birthday is subtracted out of a card on the way in
+  # and composed back in on the way out, so the vcard and the etag a
+  # caller sees — and the etag the change log records — describe the
+  # card a client downloads, not the bytes on disk.
+  #
   # Sequel's transactions join one already open rather than failing on
   # SQLite's lack of nesting, which is what lets the group fan-out this
   # design needs — one member's card rewriting its group and every other
@@ -100,7 +110,8 @@ module ProTacts
     # what SQLite feels like returning.
     #: () -> Array[Contact]
     def contacts
-      cards.order(:id).map { contact_from(it) }
+      birthdays = birthdays_by_id
+      cards.order(:id).map { contact_from(it, birthdays[it.fetch(:id).to_s]) }
     end
 
     # The collection's content tag: one value that changes when any card
@@ -129,7 +140,7 @@ module ProTacts
     # nil.
     #: (String id) -> Contact?
     def contact(id)
-      contact_from(cards.where(id:).sole)
+      contact_from(cards.where(id:).sole, birthday_of(id))
     rescue Sequel::NoMatchingRow
       nil
     end
@@ -153,6 +164,19 @@ module ProTacts
     # this card hashed to now, and the index rows read off the card. One
     # transaction, because the log entry cannot be rebuilt from anything.
     #
+    # 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.
+    #
     # 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
@@ -161,13 +185,36 @@ module ProTacts
     # where it is read, in write_card.
     #: (String id, String vcard) -> Contact
     def put(id, vcard)
-      contact = Contact.for(id:, vcard:)
+      split = Birthday.subtract(vcard)
+      existing = birthday_of(id)
+      birthday =
+        if split.bday_kept
+          nil
+        elsif split.birthday
+          split.birthday
+        elsif existing && !existing.served?
+          existing
+        else
+          nil
+        end
+
+      contact = Contact.for(id:, vcard: Birthday.compose(split.card, birthday))
       @database.transaction do
         cards
           .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
-          .insert(id: contact.id, vcard: contact.vcard)
+          .insert(id: contact.id, vcard: split.card)
+        if birthday
+          birthdays
+            .insert_conflict(
+              target: :card_id,
+              update: {year: Sequel[:excluded][:year], month: Sequel[:excluded][:month], day: Sequel[:excluded][:day]},
+            )
+            .insert(card_id: contact.id, year: birthday.year, month: birthday.month, day: birthday.day)
+        else
+          birthdays.where(card_id: contact.id).delete
+        end
         record(contact.id, "put", contact.etag)
-        reindex(contact)
+        reindex(contact.id, split.card)
       end
       contact
     end
@@ -196,13 +243,16 @@ module ProTacts
     # Always safe to run: nothing is authoritative here, so if the
     # projection ever disagrees with the cards, this is the repair.
     # Returns the ids of cards that did not parse, which are served in
-    # full but contribute nothing to the index.
+    # full but contribute nothing to the index. Raw rows rather than
+    # #contacts, which compose birthdays in: the index reflects what is
+    # stored, and no stored card carries a BDAY.
     #: () -> Array[String]
     def rebuild_index
       @database.transaction do
         card_properties.delete
-        contacts.each.with_object([]) do |contact, unindexed|
-          unindexed << contact.id unless reindex(contact)
+        cards.order(:id).all.each.with_object([]) do |row, unindexed|
+          id = row.fetch(:id).to_s
+          unindexed << id unless reindex(id, row.fetch(:vcard).to_s)
         end
       end
     end
@@ -229,6 +279,11 @@ module ProTacts
       @database[:card_parameters]
     end
 
+    #: () -> Sequel::Dataset
+    def birthdays
+      @database[:birthdays]
+    end
+
     #: (String card_id, String action, String? etag) -> void
     def record(card_id, action, etag)
       change_log.insert(card_id:, action:, etag:)
@@ -238,24 +293,26 @@ module ProTacts
     # diffed because it is derived data and replacing it is the cheaper
     # correct thing. Returns whether the card parsed: one that does not
     # is still served, so refusing to index it must not fail the write.
-    #: (Contact contact) -> bool
-    def reindex(contact)
+    # Takes the stored card — with the birthday already subtracted — so
+    # the index never sees a BDAY no stored card carries.
+    #: (String id, String vcard) -> bool
+    def reindex(id, vcard)
       # Before the parse, so that a card which has stopped parsing does
       # not keep the rows from when it did.
-      card_properties.where(card_id: contact.id).delete
-      properties = properties_of(contact.vcard)
+      card_properties.where(card_id: id).delete
+      properties = properties_of(vcard)
       return false if properties.nil?
 
       properties.each.with_index do |property, position|
         card_properties.insert(
-          card_id: contact.id,
+          card_id: id,
           position:,
           property_group: property.group,
           name: property.name,
           value: property.value,
         )
         property.parameters.each do |name, value|
-          card_parameters.insert(card_id: contact.id, position:, name:, value:)
+          card_parameters.insert(card_id: id, position:, name:, value:)
         end
       end
       true
@@ -280,9 +337,40 @@ module ProTacts
 
     # 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.
-    #: (Hash[Symbol, untyped] row) -> Contact
-    def contact_from(row)
-      Contact.for(id: row.fetch(:id).to_s, vcard: row.fetch(:vcard).to_s)
+    # The card is composed with its birthday first, so both halves
+    # describe what a client downloads.
+    #: (Hash[Symbol, untyped] row, Birthday? birthday) -> Contact
+    def contact_from(row, birthday)
+      Contact.for(
+        id: row.fetch(:id).to_s,
+        vcard: Birthday.compose(row.fetch(:vcard).to_s, birthday),
+      )
+    end
+
+    # Every birthday, keyed by card, for the listing reads that compose
+    # a whole collection in one pass.
+    #: () -> Hash[String, Birthday]
+    def birthdays_by_id
+      birthdays.all.to_h {
+        [it.fetch(:card_id).to_s, birthday_from(it)] #: [String, Birthday]
+      }
+    end
+
+    # One card's birthday, or nil for none. A plain `first` read rather
+    # than `sole`: card_id is the primary key, so there is no ambiguity
+    # for `sole` to catch and no row is the ordinary answer.
+    #: (String id) -> Birthday?
+    def birthday_of(id)
+      row = birthdays.where(card_id: id).first
+      row && birthday_from(row)
+    end
+
+    # A birthday row read as the model. The shape was validated on the
+    # way in, so a row that no longer parses is corruption to raise on
+    # rather than quietly drop.
+    #: (Hash[Symbol, untyped] row) -> Birthday
+    def birthday_from(row)
+      Birthday.new(year: row[:year], month: row[:month], day: row[:day])
     end
 
     #: (Hash[Symbol, untyped] row) -> Change
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index fb182e1..2da37bf 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -431,13 +431,15 @@ module ProTacts
 
       stored = store.put(id, vcard)
       response.status = existing ? 204 : 201
-      # A strong ETag belongs on the answer because what was stored is
+      # A strong ETag belongs on the answer only when what was stored is
       # the submitted bytes, octet for octet — the one case RFC 6352
-      # section 6.3.2.3 says a client may rely on the tag it gets back.
-      # Nothing subtracts from a card before storage yet; when a group
-      # contributes properties at write time, that PUT must stop
-      # sending one.
-      response["ETag"] = stored.etag
+      # section 6.3.2.3 lets a client rely on the tag it gets back, and
+      # anywhere else it forbids one outright. A birthday is subtracted
+      # from the card before storage and composed back in on read, so a
+      # PUT that carried one stores a different card than it was handed
+      # and the client refetches; a card with nothing to subtract still
+      # stores octet for octet and still gets the tag.
+      response["ETag"] = stored.etag if stored.vcard == vcard
 
       # A returned "" would land in the body and pin text/html and
       # content-length onto the 204, which a bodyless status must not
diff --git a/sig/pro_tacts/birthday.rbs b/sig/pro_tacts/birthday.rbs
new file mode 100644
index 0000000..f6b7e99
--- /dev/null
+++ b/sig/pro_tacts/birthday.rbs
@@ -0,0 +1,47 @@
+# Birthday and Birthday::Split are Data classes, whose members the
+# inline parser cannot read (see sig/pro_tacts/contact.rbs). Both are
+# marked `@rbs skip` in birthday.rb and signed here instead.
+module ProTacts
+  class Birthday
+    FULL_DATE: Regexp
+    OMIT_YEAR_DATE: Regexp
+    OMIT_YEAR: String
+    BDAY_LINE: Regexp
+    END_LINE: Regexp
+    CONTINUATION: Regexp
+
+    attr_reader year: Integer?
+    attr_reader month: Integer?
+    attr_reader day: Integer?
+
+    def self.new: (?year: Integer?, ?month: Integer?, ?day: Integer?) -> instance
+
+    def to_line: () -> String?
+
+    def served?: () -> bool
+
+    def self.from_property: (VCard::Property property) -> Birthday?
+
+    def self.apple_no_year?: (VCard::Property property) -> bool
+
+    def self.subtract: (String card) -> Split
+
+    def self.compose: (String card, Birthday? birthday) -> String
+
+    def self.parse_line: (String text) -> VCard::Property?
+
+    def self.physical_lines: (String card) -> Array[String]
+
+    def self.line_groups: (String card) -> Array[Array[String]]
+
+    def self.terminator_of: (String line) -> String
+
+    class Split
+      attr_reader card: String
+      attr_reader birthday: Birthday?
+      attr_reader bday_kept: bool
+
+      def self.new: (card: String, birthday: Birthday?, bday_kept: bool) -> instance
+    end
+  end
+end
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index 924225b..42ef58d 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -16,8 +16,8 @@ Content-Type: text/xml
           <d:report><d:sync-collection/></d:report>
         </d:supported-report>
       </d:supported-report-set>
-      <cs:getctag>ef8eddac0927936788c8997a6faedb70b86480fc67475851dcb15013ddc230c9</cs:getctag>
-      <d:sync-token>http://pro-tacts/sync/ef8eddac0927936788c8997a6faedb70b86480fc67475851dcb15013ddc230c9</d:sync-token>
+      <cs:getctag>8d49f12d5a21ce322b92458f141a911457556c0113fa89bbb6d647264744a0d9</cs:getctag>
+      <d:sync-token>http://pro-tacts/sync/8d49f12d5a21ce322b92458f141a911457556c0113fa89bbb6d647264744a0d9</d:sync-token>
       <d:current-user-privilege-set>
         <d:privilege><d:read/></d:privilege>
         <d:privilege><d:write/></d:privilege>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index 017bece..26e8c89 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -35,7 +35,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-complete.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"a4e6b4c955cfcebf3431ab4ab547c64a6bf1983ceba7953a77074ec64c1d6fbd"</d:getetag>
+      <d:getetag>"45d8178d8bb621243701ec282a386af13a226c9a6aa3f98bcd4d53a1deffefdc"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -44,7 +44,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-datetime.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"348c560bf1811fc48380fac606cf7198333ae0746c4f5ba8cec7269474b13162"</d:getetag>
+      <d:getetag>"16aae3a4d1cb495a0fa706932b411f87862c0d2111bab8b07efd3b5354b44847"</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-omit-year.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"55f079c8ad3ab5fce768ce51eecc2447ab3be4d24dbf84967a2360b780b58f9d"</d:getetag>
+      <d:getetag>"a448d198e3c96acaf128f490b5ebdc1a961823ffb15f1dca480fde63f9e63902"</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 4544c7f..6da3896 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -34,7 +34,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-complete.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"a4e6b4c955cfcebf3431ab4ab547c64a6bf1983ceba7953a77074ec64c1d6fbd"</d:getetag>
+      <d:getetag>"45d8178d8bb621243701ec282a386af13a226c9a6aa3f98bcd4d53a1deffefdc"</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-datetime.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"348c560bf1811fc48380fac606cf7198333ae0746c4f5ba8cec7269474b13162"</d:getetag>
+      <d:getetag>"16aae3a4d1cb495a0fa706932b411f87862c0d2111bab8b07efd3b5354b44847"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -70,7 +70,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-omit-year.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"55f079c8ad3ab5fce768ce51eecc2447ab3be4d24dbf84967a2360b780b58f9d"</d:getetag>
+      <d:getetag>"a448d198e3c96acaf128f490b5ebdc1a961823ffb15f1dca480fde63f9e63902"</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 3d63a5f..4bae886 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -16,8 +16,8 @@ Content-Type: text/xml
           <d:report><d:sync-collection/></d:report>
         </d:supported-report>
       </d:supported-report-set>
-      <cs:getctag>ef8eddac0927936788c8997a6faedb70b86480fc67475851dcb15013ddc230c9</cs:getctag>
-      <d:sync-token>http://pro-tacts/sync/ef8eddac0927936788c8997a6faedb70b86480fc67475851dcb15013ddc230c9</d:sync-token>
+      <cs:getctag>8d49f12d5a21ce322b92458f141a911457556c0113fa89bbb6d647264744a0d9</cs:getctag>
+      <d:sync-token>http://pro-tacts/sync/8d49f12d5a21ce322b92458f141a911457556c0113fa89bbb6d647264744a0d9</d:sync-token>
       <d:current-user-privilege-set>
         <d:privilege><d:read/></d:privilege>
         <d:privilege><d:write/></d:privilege>
@@ -60,7 +60,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-complete.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"a4e6b4c955cfcebf3431ab4ab547c64a6bf1983ceba7953a77074ec64c1d6fbd"</d:getetag>
+      <d:getetag>"45d8178d8bb621243701ec282a386af13a226c9a6aa3f98bcd4d53a1deffefdc"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -69,7 +69,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/bday-datetime.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"348c560bf1811fc48380fac606cf7198333ae0746c4f5ba8cec7269474b13162"</d:getetag>
+      <d:getetag>"16aae3a4d1cb495a0fa706932b411f87862c0d2111bab8b07efd3b5354b44847"</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-omit-year.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"55f079c8ad3ab5fce768ce51eecc2447ab3be4d24dbf84967a2360b780b58f9d"</d:getetag>
+      <d:getetag>"a448d198e3c96acaf128f490b5ebdc1a961823ffb15f1dca480fde63f9e63902"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/fixtures/macos-exchange/10-put-contact-edit/response b/test/fixtures/macos-exchange/10-put-contact-edit/response
index 0ca9ef3..e291fbe 100644
--- a/test/fixtures/macos-exchange/10-put-contact-edit/response
+++ b/test/fixtures/macos-exchange/10-put-contact-edit/response
@@ -1,3 +1,2 @@
 204
-ETag: "07436a908594751e83c07cf99e4ed9b6a0fca38a14c492b90d977aba1780f63a"
 
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
new file mode 100644
index 0000000..964cd49
--- /dev/null
+++ b/test/pro_tacts/test_birthday.rb
@@ -0,0 +1,194 @@
+require_relative "../test_helper"
+
+require "pro_tacts/birthday"
+
+class BirthdayTest < Minitest::Test
+  # The six shapes RFC 6350 section 4.3.1 admits, one assertion per
+  # shape so a failure names the one that broke.
+  def test_every_admitted_shape_constructs
+    ProTacts::Birthday.new(year: 1985, month: 4, day: 12)
+    ProTacts::Birthday.new(year: 1985, month: 4)
+    ProTacts::Birthday.new(year: 1985)
+    ProTacts::Birthday.new(month: 4, day: 12)
+    ProTacts::Birthday.new(month: 4)
+    ProTacts::Birthday.new(day: 12)
+  end
+
+  # The two the grammar refuses: year with day but no month has no form
+  # a date can take, and a birthday that is nothing at all is not one.
+  def test_the_shapes_the_grammar_refuses
+    error = assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 1985, day: 12) }
+    assert_match "not a shape", error.message
+
+    assert_raises(ArgumentError) { ProTacts::Birthday.new }
+  end
+
+  def test_components_out_of_range_are_refused
+    assert_raises(ArgumentError) { ProTacts::Birthday.new(month: 13, day: 12) }
+    assert_raises(ArgumentError) { ProTacts::Birthday.new(month: 4, day: 32) }
+    assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 10_000, month: 4, day: 12) }
+  end
+
+  def test_the_wire_forms
+    assert_equal "BDAY:1985-04-12", ProTacts::Birthday.new(year: 1985, month: 4, day: 12).to_line
+    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
+    [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
+      refute birthday.served?
+    end
+  end
+
+  def test_the_served_shapes
+    [ProTacts::Birthday.new(year: 1985, month: 4, day: 12), ProTacts::Birthday.new(month: 4, day: 12)].each do |birthday|
+      assert birthday.served?
+    end
+  end
+
+  ## subtract
+
+  CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada\r\nUID:ada\r\nEND:VCARD\r\n"
+
+  def test_a_full_date_moves_into_the_model_and_out_of_the_card
+    split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\nBDAY:1985-04-12\r\n"))
+
+    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), split.birthday
+    assert_equal CARD, split.card
+    refute split.bday_kept
+  end
+
+  # docs/macos-contacts.md, "Birthdays without a year": the verified
+  # Apple form, with 1604 standing in for the year in both halves.
+  def test_the_apple_no_year_form_moves_into_the_model
+    split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\nBDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\n"))
+
+    assert_equal ProTacts::Birthday.new(month: 4, day: 12), split.birthday
+    assert_equal CARD, split.card
+  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.
+  def test_a_date_time_loses_its_time_on_the_way_in
+    ["BDAY:1985-04-12T23:10:00Z", "BDAY:1985-04-12T08:30:00-06:00"].each do |line|
+      split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\n#{line}\r\n"))
+
+      assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), split.birthday, line
+      assert_equal CARD, split.card, line
+    end
+  end
+
+  def test_names_and_parameters_match_without_case
+    split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\nbday;x-apple-omit-year=1604:1604-04-12\r\n"))
+
+    assert_equal ProTacts::Birthday.new(month: 4, day: 12), split.birthday
+  end
+
+  def test_a_folded_bday_is_one_property
+    split = ProTacts::Birthday.subtract(CARD.sub("UID:ada\r\n", "BDAY:1985-04-\r\n 12\r\nUID:ada\r\n"))
+
+    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), split.birthday
+    assert_equal CARD, split.card
+  end
+
+  # Everything the model cannot recompose stays in the card byte for
+  # byte (RFC 6352 section 6.3.2.2), and the model is emptied so compose
+  # never adds a second BDAY beside it. The probe cards ride on this.
+  def test_the_unmodeled_spellings_stay_verbatim
+    unmodeled = [
+      "BDAY:--0412",       # vCard 4.0 basic, month-day
+      "BDAY:--04-12",      # vCard 4.0 extended, month-day
+      "BDAY:1985",         # year alone
+      "BDAY:1985-04",      # year and month
+      "BDAY:---12",        # day alone
+      "BDAY:19850412",     # undashed full date, legal 3.0
+      "BDAY:1985-4-12",    # unpadded, not the grammar's shape
+      "BDAY;VALUE=DATE:1985-04-12",
+      "BDAY;X-APPLE-OMIT-YEAR=1605:1605-04-12", # a foreign sentinel
+      "BDAY;X-APPLE-OMIT-YEAR=1604;X-OTHER=1:1604-04-12", # parameters compose would drop
+      "item1.BDAY:1985-04-12", # a grouped property's label pairing belongs to the card
+      "BDAY:",             # nothing to read
+      "BDAY:1985-13-40",   # components out of range
+    ]
+
+    unmodeled.each do |line|
+      card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\n#{line}\r\n")
+      split = ProTacts::Birthday.subtract(card)
+
+      assert_nil split.birthday, line
+      assert_equal card, split.card, line
+      assert split.bday_kept, line
+    end
+  end
+
+  def test_more_than_one_bday_stays_verbatim
+    card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nBDAY:1985-04-12\r\nBDAY:1986-04-12\r\n")
+    split = ProTacts::Birthday.subtract(card)
+
+    assert_nil split.birthday
+    assert_equal card, split.card
+    assert split.bday_kept
+  end
+
+  def test_a_card_without_a_bday_is_untouched
+    split = ProTacts::Birthday.subtract(CARD)
+
+    assert_nil split.birthday
+    assert_equal CARD, split.card
+    refute split.bday_kept
+  end
+
+  # Bytes that are not the UTF-8 the store's contract promises are not
+  # this model's business: they pass through so the bind that enforces
+  # the contract is what refuses them.
+  def test_bytes_that_are_not_utf_8_pass_through_unparsed
+    invalid = "BDAY:\xFF\xFE\r\n".dup.force_encoding(Encoding::UTF_8)
+    split = ProTacts::Birthday.subtract(invalid)
+
+    assert_equal invalid, split.card
+    assert_nil split.birthday
+    refute split.bday_kept
+  end
+
+  ## compose
+
+  def test_compose_puts_the_birthday_before_the_end
+    composed = ProTacts::Birthday.compose(CARD, ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
+
+    assert_equal CARD.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), composed
+  end
+
+  def test_compose_takes_the_end_lines_own_terminator
+    card = "BEGIN:VCARD\nUID:ada\nEND:VCARD\n"
+    composed = ProTacts::Birthday.compose(card, ProTacts::Birthday.new(month: 4, day: 12))
+
+    assert_equal "BEGIN:VCARD\nUID:ada\nBDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\nEND:VCARD\n", composed
+  end
+
+  def test_compose_leaves_a_card_alone_when_there_is_nothing_to_say
+    assert_equal CARD, ProTacts::Birthday.compose(CARD, nil)
+    assert_equal CARD, ProTacts::Birthday.compose(CARD, ProTacts::Birthday.new(year: 1985))
+  end
+
+  def test_compose_appends_when_there_is_no_end_to_anchor_to
+    assert_equal "not a card\r\nBDAY:1985-04-12\r\n",
+      ProTacts::Birthday.compose("not a card\r\n", ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
+  end
+
+  ## the pair of them
+
+  # subtract and compose are inverses on the model and on every line
+  # but the BDAY's own place in the card, which compose chooses.
+  def test_compose_undoes_subtract
+    birthday = ProTacts::Birthday.new(month: 4, day: 12)
+    split = ProTacts::Birthday.subtract(CARD.sub("END:VCARD\r\n", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\nEND:VCARD\r\n"))
+
+    assert_equal birthday, split.birthday
+    assert_equal birthday, ProTacts::Birthday.subtract(ProTacts::Birthday.compose(split.card, birthday)).birthday
+  end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 7b57536..5322a17 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -416,6 +416,147 @@ class StoreTest < Minitest::Test
     end
   end
 
+  ## Birthdays
+
+  AIDEN_BORN = AIDEN.sub("FN:Aiden\r\n", "FN:Aiden\r\nBDAY:1985-04-12\r\n")
+
+  # The stored card carries no BDAY — a partial date has no vCard 3.0
+  # spelling — and every read composes the birthday back in, so the
+  # served card is the submitted one with the line back where compose
+  # puts it.
+  def test_a_birthday_is_stored_beside_the_card_and_served_within_it
+    with_store({"aiden" => AIDEN_BORN}) do |store|
+      assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+      assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), store.contact("aiden").vcard
+    end
+  end
+
+  # The composed card is what an etag describes, on a read and in the
+  # change log alike, so a client's If-Match and its sync token both
+  # talk about the card it downloads.
+  def test_the_etag_and_the_log_describe_the_composed_card
+    with_store({"aiden" => AIDEN_BORN}) do |store|
+      composed = AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n")
+
+      assert_equal ProTacts::Contact.etag_for(composed), store.contact("aiden").etag
+      assert_equal ProTacts::Contact.etag_for(composed), store.changes.last.etag
+    end
+  end
+
+  # The ctag follows the composed card too: a birthday moved is a
+  # change a client must see, even when the stored bytes did not move.
+  def test_the_ctag_moves_with_a_birthday_alone
+    with_store({"aiden" => AIDEN}) do |store|
+      before = store.ctag
+      store.put("aiden", AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"))
+
+      refute_equal before, store.ctag
+    end
+  end
+
+  def test_an_apple_no_year_birthday_round_trips
+    with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\nEND:VCARD\r\n")}) do |store|
+      assert_equal ProTacts::Birthday.new(month: 4, day: 12), birthday_row(store, "aiden")
+    end
+  end
+
+  # A submitted card with no BDAY deletes the birthday — the client
+  # was served one and sent its card back without it, which on a
+  # read-modify-write client is the user removing it.
+  def test_a_card_put_back_without_its_served_birthday_loses_it
+    with_store({"aiden" => AIDEN_BORN}) do |store|
+      store.put("aiden", AIDEN)
+
+      assert_nil birthday_row(store, "aiden")
+      assert_equal AIDEN, store.contact("aiden").vcard
+    end
+  end
+
+  # A birthday no client can see — year alone, month alone, day alone,
+  # year and month — survives a round trip the client never saw a
+  # birthday in. Planted directly, because no writer produces one yet.
+  def test_a_birthday_no_client_can_see_survives_a_card_without_one
+    with_store({"aiden" => AIDEN}) do |store|
+      database(store)[:birthdays].insert(card_id: "aiden", year: 1985)
+
+      store.put("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
+
+      assert_equal ProTacts::Birthday.new(year: 1985), birthday_row(store, "aiden")
+      assert_equal AIDEN.sub("FN:Aiden", "FN:Aiden Smith"), store.contact("aiden").vcard
+    end
+  end
+
+  # A BDAY the model cannot recompose stays in the card verbatim and
+  # empties the model: the card's own line speaks for itself, and
+  # compose must never add a second one beside it.
+  def test_an_unmodeled_bday_stays_in_the_card_and_empties_the_model
+    unmodeled = AIDEN.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n")
+
+    with_store({"aiden" => AIDEN_BORN}) do |store|
+      store.put("aiden", unmodeled)
+
+      assert_equal unmodeled, store.contact("aiden").vcard
+      assert_nil birthday_row(store, "aiden")
+    end
+  end
+
+  def test_a_stored_card_is_never_indexed_with_a_bday
+    with_store({"aiden" => AIDEN_BORN}) do |store|
+      assert_equal %w[BEGIN VERSION FN UID END], indexed_names(store, "aiden")
+    end
+  end
+
+  def test_deleting_a_card_takes_its_birthday_with_it
+    with_store({"aiden" => AIDEN_BORN}) do |store|
+      store.delete("aiden")
+
+      assert_empty database(store)[:birthdays].all
+    end
+  end
+
+  # The transaction the whole design turns on, now with a third piece:
+  # a birthday must not survive a write whose log entry failed.
+  def test_a_failed_birthday_write_leaves_no_birthday
+    Dir.mktmpdir do |dir|
+      path = Pathname.new(dir) / "contacts.db"
+
+      FailingLog.connect(path) do |store|
+        assert_raises(RuntimeError) { store.put("aiden", AIDEN_BORN) }
+      end
+
+      ProTacts::Store.connect(path) do |store|
+        assert_empty store.contacts
+        assert_empty database(store)[:birthdays].all
+      end
+    end
+  end
+
+  ## The migration
+
+  # A database at the old shape — BDAY in the card, no birthdays table
+  # — is carried over by the same subtraction a write makes: modeled
+  # forms move, unmodeled forms stay put.
+  def test_the_migration_moves_a_modeled_bday_out_of_the_cards
+    Dir.mktmpdir do |dir|
+      path = Pathname.new(dir) / "contacts.db"
+      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"))
+      end
+
+      ProTacts::Store.connect(path) do |store|
+        assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+        assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), birthday_row(store, "aiden")
+        assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), store.contact("aiden").vcard
+
+        # 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
+        assert_nil birthday_row(store, "znorth")
+      end
+    end
+  end
+
   private
 
   # A store whose change-log write fails, after Store#put has already put
@@ -461,6 +602,13 @@ class StoreTest < Minitest::Test
     ]
   end
 
+  # The birthday row read through the store's own model, so a test sees
+  # the shape it holds and not the columns it came from.
+  def birthday_row(store, id)
+    birthday = database(store)[:birthdays].where(card_id: id).first
+    birthday && ProTacts::Birthday.new(year: birthday[:year], month: birthday[:month], day: birthday[:day])
+  end
+
   def wreck_the_index(store)
     database(store)[:card_parameters].delete
     database(store)[:card_properties].update(value: "wrong")
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 00f9345..85e933c 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -289,6 +289,45 @@ class WebTest < Minitest::Test
     end
   end
 
+  # RFC 6352 section 6.3.2.3: a strong ETag belongs on a PUT answer only
+  # when what was stored is the submission octet for octet. A birthday is
+  # subtracted before storage and composed back in on read, so a PUT that
+  # carried one elsewhere in the card than where compose puts it must not
+  # claim the tag — the client refetches instead. macOS writes BDAY
+  # mid-card, which is the shape here.
+  def test_a_put_whose_birthday_moves_goes_without_the_strong_etag
+    born = card("new", "New").sub("FN:New\r\n", "FN:New\r\nBDAY:1985-04-12\r\n")
+
+    with_contacts({}) do
+      put_request "new", born, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+      assert_equal 201, last_response.status
+      assert_nil last_response["ETag"]
+
+      # What the client refetches carries the birthday, composed before
+      # the END, and that refetched tag is the one the next write matches.
+      get "/dav/addressbook/new.vcf"
+      assert_includes last_response.body, "BDAY:1985-04-12\r\nEND:VCARD"
+      etag = last_response["ETag"]
+
+      put_request "new", card("new", "New"), "CONTENT_TYPE" => VCARD, "HTTP_IF_MATCH" => etag
+      assert_equal 204, last_response.status
+    end
+  end
+
+  # The boundary of the same rule: a birthday already sitting where
+  # compose puts it stores octet for octet, and the tag is honest.
+  def test_a_put_whose_birthday_composes_in_place_keeps_the_strong_etag
+    born = card("new", "New").sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n")
+
+    with_contacts({}) do
+      put_request "new", born, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+      assert_equal 201, last_response.status
+      assert_equal %("#{Digest::SHA256.hexdigest(born)}"), last_response["ETag"]
+    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