Split a submitted card in Arrival, not in Store
Store#put carried the whole of the inverse of Contact's composition:
the birthday split, the two-pass subtraction of what a member's
groups lend it, the classification of each lent line that came back,
and the five Sentry reports those make. None of it reads or writes the
database, and it was a fifth of store.rb.

ProTacts::Arrival now takes the submitted card with what stood before
it (the card, the birthday, the lent rows) and answers the card to
store, the birthday to hold, and the group edits to apply. Store#put
reads those inputs, hands them over, and applies the answer in its
transaction as before. Lent and GroupEdit move with it, since they are
its input and its output.

The methods and their comments move unchanged. test_arrival.rb covers
each branch without a store; test_store.rb keeps the same rules
through a write.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi55bFkB6wo4jguNtsase4
change
commit 9a0625f430c5b4f85b18e8f0ece4b3cec091385a
author Claude <noreply@anthropic.com>
date
parent zvwylsqu
diff --git a/AGENTS.md b/AGENTS.md
index 805a29a..9b59c3e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,8 +80,10 @@ lib/pro_tacts/
 │                   # group change log, and the derived index
 ├── contact.rb      # the one model of a contact: id, vCard, etag,
 │                   # and the structured accessors over the card
+├── arrival.rb      # A submitted card split back into what the store
+│                   # keeps: the card, the birthday, the group edits
 ├── birthday.rb     # A partial date, the one fact no card can hold
-├── birthday_line.rb # What a submitted BDAY line is, for Store#put
+├── birthday_line.rb # What a submitted BDAY line is, for Arrival
 ├── card_diff.rb    # The lines a write added and removed
 ├── sync_token.rb   # The token a syncing client carries: minted
 │                   # at a ctag for a book, and read back
diff --git a/lib/pro_tacts/arrival.rb b/lib/pro_tacts/arrival.rb
new file mode 100644
index 0000000..1ad7887
--- /dev/null
+++ b/lib/pro_tacts/arrival.rb
@@ -0,0 +1,416 @@
+require "sentry-ruby"
+
+require "pro_tacts/birthday"
+require "pro_tacts/birthday_line"
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
+module ProTacts
+  # A card as it arrives at Store#put, split back into what the store
+  # keeps: the card to store, the birthday to hold beside it, and what
+  # the member's edits ask of the groups lending it lines. The inverse
+  # of what Contact composes — served is stored plus inherited plus
+  # birthday, so stored is submitted minus both
+  # (docs/plans/2026-08-31-partial-birthdays.md,
+  # docs/plans/2026-08-24-vcard-storage-and-groups.md).
+  #
+  # Nothing here reads or writes the database. The store hands over
+  # what stood before the write — the card, the birthday, the lines
+  # lent — and applies what comes back inside its own transaction. The
+  # reports go to Sentry from here because this is the arrival
+  # (AGENTS.md, "The one exception is the arrival reports").
+  #
+  # The signature of the two Data classes is in
+  # sig/pro_tacts/arrival.rbs, for the reason Store's are in its own.
+  class Arrival
+    # @rbs @stored: VCard
+    # @rbs @birthday: Birthday?
+    # @rbs @edits: Array[GroupEdit]
+
+    # One line a group lends one card, with the row it is lent from —
+    # the write path's reading of what Store#inherited_of answers as
+    # the model's. A member's edit has to reach the group_properties
+    # row the line came from, which takes the position as well as the
+    # group.
+    # @rbs skip
+    Lent = Data.define(:group_id, :position, :line)
+
+    # What one classified line asks of the group it came from: the row
+    # to write, and the line that replaces it — or nil to remove the
+    # row, which is what a member's deletion of a shared line means
+    # (docs/plans/2026-09-09-group-edits-propagate.md, "The open
+    # question, decided").
+    # @rbs skip
+    GroupEdit = Data.define(:group_id, :position, :line)
+
+    # The property names whose value is structured rather than free
+    # text (RFC 2426 section 3.2.1), among the two a group may lend:
+    # ADR is components, NOTE is text (db/migrations/004_groups.rb).
+    # Which reading applies is the caller's to know — the parser holds
+    # no value types — and this is the one caller that compares values.
+    STRUCTURED_VALUES = %w[ADR].freeze #: Array[String]
+
+    # The property names a group may lend, a second copy of the CHECK
+    # in db/migrations/004_groups.rb and saying so here. The constraint
+    # stays the authority; this is the pre-check a propagated edit
+    # passes first, so that a line the group cannot hold is refused
+    # rather than raising on an ordinary sync
+    # (docs/plans/2026-09-09-group-edits-propagate.md, "The line a
+    # group cannot hold").
+    SHAREABLE_NAMES = %w[ADR NOTE].freeze #: Array[String]
+
+    # `stored_before` is the card this write replaces, nil for one
+    # being created: the birthday split asks it about a BDAY the write
+    # is dropping, and the subtraction strikes its lines from the pool.
+    #: (VCard vcard, birthday_before: Birthday?, stored_before: VCard?, lent: Array[Lent]) -> void
+    def initialize(vcard, birthday_before:, stored_before:, lent:)
+      @birthday, rest = split_birthday(vcard, birthday_before, stored_before)
+      @stored, @edits = subtract_inherited(rest, lent, stored_before)
+    end
+
+    # The card to store: the submission minus its birthday and minus
+    # what its groups lend it.
+    attr_reader :stored
+
+    # The model's new birthday, or nil to empty the model.
+    attr_reader :birthday
+
+    # The group rows the member's edits rewrite or remove, empty where
+    # every lent line came back untouched.
+    attr_reader :edits
+
+    private
+
+    # The birthday half of the split
+    # (docs/plans/2026-09-11-every-birthday-in-the-model.md): the
+    # model's new birthday, and the card to store beside it. 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).
+    #
+    # No BDAY is a client that was sent the birthday removing it, or
+    # one that was never sent it and so cannot have. A BDAY line left
+    # in the stored card goes with the write, which is worth saying.
+    #
+    # case/when rather than case/in, BirthdayLine's own rule: Steep
+    # does not check the bodies of case/in branches, and these are the
+    # ones worth checking — which is what the last arm is for.
+    #: (VCard vcard, Birthday? existing, VCard? stored_before) -> [Birthday?, VCard]
+    def split_birthday(vcard, existing, stored_before)
+      bdays, rest = vcard.extract("BDAY")
+      if bdays.empty?
+        report_lost_bday_lines(stored_before)
+        return [existing && !existing.served? ? existing : nil, vcard]
+      end
+
+      if bdays.length > 1
+        report_many_bday_lines(bdays.length)
+        return [nil, vcard]
+      end
+
+      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 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
+    # (the line config.ru draws for Sentry); 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
+
+    # 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 loss report, the write's half of the arrival one: a BDAY line
+    # still in the stored card is one the model did not take, and a
+    # write without it is about to drop it with nobody told.
+    #: (VCard? stored_before) -> void
+    def report_lost_bday_lines(stored_before)
+      return if stored_before.nil?
+
+      lines, = stored_before.extract("BDAY")
+      return if lines.empty?
+
+      Sentry.capture_message("a write dropped #{lines.length} BDAY line(s) the model did not take", level: :warning)
+    end
+
+    # The submitted card with the lines its groups lend it taken back
+    # out: served is stored plus inherited, so stored is submitted
+    # minus inherited and a client that PUTs back what it downloaded
+    # stores what it started with
+    # (docs/plans/2026-08-24-vcard-storage-and-groups.md, "Groups
+    # compose into cards"). Which of four shapes a lent line came back
+    # as, and what each asks of its group, is #classify — argued in
+    # that plan's "Classifying what came back" and in
+    # docs/plans/2026-09-09-group-edits-propagate.md.
+    #
+    # Two passes, because one lent line must not be attributed a line
+    # another lent line would have matched exactly. A member of two
+    # groups that each lend an address, editing one of them, submits
+    # one edited address and one untouched: walked in a single pass,
+    # whichever group came first would read the other's untouched line
+    # as its own edit and the other would read a deletion. Every
+    # untouched line is struck first, and only what is left is
+    # attributed.
+    #
+    # #substitute rather than #extract and #insert, which would move a
+    # member's own lines of the same name to the card's end: the lines
+    # this leaves keep their positions, so a submission that was the
+    # served card round-trips to the bytes it was composed from and the
+    # PUT can answer with a strong etag (RFC 6352 section 6.3.2.3).
+    #: (VCard vcard, Array[Lent] lent, VCard? stored_before) -> [VCard, Array[GroupEdit]]
+    def subtract_inherited(vcard, lent, stored_before)
+      return [vcard, []] if lent.empty?
+
+      unaccounted = unaccounted_lines(vcard, stored_before)
+      taken = [] #: Array[VCard::Parser::Line]
+
+      moved = lent.reject { |row|
+        candidates = unaccounted.select { it.names?(property_name(row.line)) }
+        # Blind to lines saying the same thing the way the editor's
+        # digests are blind to identical bytes
+        # (VCard::Parser::Line#digest): where a member's own card
+        # carries what its group lends, which copy this takes is
+        # undecidable and their saying the same thing makes it not
+        # matter.
+        match = candidates.find { unedited?(it, parsed_line(row.line), unaccounted) }
+        if match
+          label = label_of(match, unaccounted)
+          taken << strike(unaccounted, match)
+          taken << strike(unaccounted, label) if label
+        end
+        match
+      }
+
+      edits = [] #: Array[GroupEdit]
+      ambiguous = 0
+      unshareable = 0
+      moved.each do |row|
+        case classify(row, unaccounted, taken)
+        in GroupEdit => edit then edits << edit
+        in :ambiguous then ambiguous += 1
+        in :unshareable then unshareable += 1
+        end
+      end
+
+      report_ambiguous_inherited_lines(ambiguous)
+      report_unshareable_lines(unshareable)
+      [taken.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }, edits]
+    end
+
+    # What a lent line that came back changed asks of its group: the
+    # edit the one candidate of its name is, or the deletion no
+    # candidate at all is. The two refusals ask nothing — more than one
+    # candidate cannot be attributed, and a candidate the group could
+    # not hold must not be tried (#shareable?) — and both leave the
+    # line in the member's own card, where an edit at least is not
+    # lost.
+    #
+    # An edit's line is struck from the pool and carried by the group
+    # from here on, so `taken` grows the way it does for an untouched
+    # one.
+    #: (Lent row, Array[VCard::Parser::Line] unaccounted, Array[VCard::Parser::Line] taken) -> (GroupEdit | :ambiguous | :unshareable)
+    def classify(row, unaccounted, taken)
+      candidates = unaccounted.select { it.names?(property_name(row.line)) }
+      return GroupEdit.new(group_id: row.group_id, position: row.position, line: nil) if candidates.empty?
+      return :ambiguous unless candidates.length == 1
+
+      edited = candidates.fetch(0)
+      return :unshareable unless shareable?(edited)
+
+      taken << strike(unaccounted, edited)
+      GroupEdit.new(group_id: row.group_id, position: row.position, line: edited.content)
+    end
+
+    # The line out of the pool, so no second lent line is attributed
+    # it. `index` rather than `delete`, which would take every copy of
+    # a line a card carries twice.
+    #: (Array[VCard::Parser::Line] unaccounted, VCard::Parser::Line line) -> VCard::Parser::Line
+    def strike(unaccounted, line)
+      unaccounted.delete_at(
+        unaccounted.index(line) #: Integer
+      )
+      line
+    end
+
+    # Whether a group could hold this line: one of the names
+    # SHAREABLE_NAMES admits, and no property group in front of it —
+    # the CHECK's two clauses, read off the parse rather than matched
+    # as bytes. A line that would not read is not one to share.
+    #: (VCard::Parser::Line line) -> bool
+    def shareable?(line)
+      property = line.property
+      return false if property.nil?
+
+      property.group.nil? && SHAREABLE_NAMES.include?(property.name.upcase)
+    end
+
+    # A lent line as the parser reads it: one logical line, the group
+    # schema admitting no other shape (db/migrations/004_groups.rb).
+    #: (String line) -> VCard::Parser::Line
+    def parsed_line(line)
+      VCard.new(line).lines.fetch(0)
+    end
+
+    # Whether a submitted line still says what the group lends, which
+    # is a question about what it says and not about its bytes: macOS
+    # re-serializes every card it touches
+    # (docs/apple-contacts.md, "The client rewrites every card it
+    # touches"), and comparing bytes reads every such line as an edit.
+    #
+    # What a line says is its value and its types, because a relabel is
+    # an edit the group takes like any other
+    # (docs/plans/2026-08-24-vcard-storage-and-groups.md, "Edits
+    # propagate to the group"). Every other parameter goes uncompared,
+    # being the half the client rewrites unasked — it drops the ones it
+    # does not model and fills in defaults on the ones it does.
+    #
+    # A line that will not read has no value to compare and falls back
+    # to its bytes, which still recognize the line nobody touched.
+    #: (VCard::Parser::Line line, VCard::Parser::Line lent, Array[VCard::Parser::Line] pool) -> bool
+    def unedited?(line, lent, pool)
+      value = value_of(lent)
+      return line.verbatim.chomp == lent.verbatim.chomp if value.nil?
+
+      value_of(line) == value && kept_types?(line, lent, pool)
+    end
+
+    # Whether a submitted line carries the types the group lent it,
+    # across the three rewrites they survive. Two are cosmetic and
+    # Property#types already absorbs them: the values come back
+    # uppercased, and `pref` comes back filled in.
+    #
+    # The third moves the type off the line: a type Contacts has no
+    # field for comes back as an `X-ABLabel` in a property group
+    # (docs/apple-contacts.md, "An address type the client cannot
+    # model becomes a custom label"), so that label counts as one of
+    # the line's types and `ADR;TYPE=dom` coming back as `item1.ADR`
+    # with `item1.X-ABLabel:dom` is unedited.
+    #: (VCard::Parser::Line line, VCard::Parser::Line lent, Array[VCard::Parser::Line] pool) -> bool
+    def kept_types?(line, lent, pool)
+      label = label_of(line, pool)&.property
+      types = types_of(line)
+      types = (types + [label.text.downcase]).uniq.sort if label
+      types == types_of(lent)
+    end
+
+    # The `X-ABLabel` sharing a property group with `line`, the other
+    # half of the pair #kept_types? reads as one. Nil for an ungrouped
+    # line, or a group the pool holds no label for.
+    #: (VCard::Parser::Line line, Array[VCard::Parser::Line] pool) -> VCard::Parser::Line?
+    def label_of(line, pool)
+      group = line.property&.group
+      return if group.nil?
+
+      pool.find { |other|
+        property = other.property
+        !property.nil? && property.group&.casecmp?(group) == true && property.name.casecmp?("X-ABLABEL")
+      }
+    end
+
+    # What a line says, as the reading its property's value type calls
+    # for: an ADR compares component by component (RFC 2426 section
+    # 3.2.1) and a NOTE as its unescaped text (section 2.4.2). Nil for
+    # a line that would not read, which has no value at all.
+    #: (VCard::Parser::Line line) -> Array[String]?
+    def value_of(line)
+      property = line.property
+      return nil if property.nil?
+
+      STRUCTURED_VALUES.include?(property.name.upcase) ? property.components : [property.text]
+    end
+
+    # A line's TYPE values as the set the round trip preserves, sorted
+    # because `TYPE=home;TYPE=pref` and `TYPE=pref,home` are one thing
+    # (RFC 2426 section 3.2.1, which the parser reads into pairs either
+    # way). Empty for a line that would not read, which has no
+    # parameters to compare.
+    #: (VCard::Parser::Line line) -> Array[String]
+    def types_of(line)
+      property = line.property
+      return [] if property.nil?
+
+      property.types.sort
+    end
+
+    # The submission's lines the card as it stood before this write
+    # does not already explain — one struck per stored line of the same bytes,
+    # so a card that stores one of something and submits two leaves one
+    # over. What is left is what the groups lent plus whatever the
+    # client wrote beside it, which is the pool a lent line is
+    # attributed from. Everything for a card being created, which has
+    # no stored lines to explain anything.
+    #: (VCard vcard, VCard? stored_before) -> Array[VCard::Parser::Line]
+    def unaccounted_lines(vcard, stored_before)
+      stored = stored_before ? stored_before.lines.map { it.verbatim.chomp } : [] #: Array[String]
+      vcard.lines.reject { |line|
+        index = stored.index(line.verbatim.chomp)
+        stored.delete_at(index) if index
+        index
+      }
+    end
+
+    # A content line's property name: what stands before its first
+    # parameter or its value (RFC 2426 section 2.1.1). Read off the
+    # bytes rather than parsed, because the only lines asked are a
+    # group's, which carry no `item1.` prefix to strip — the schema
+    # refuses one (db/migrations/004_groups.rb).
+    #: (String line) -> String
+    def property_name(line)
+      line[/\A[^;:]*/].to_s
+    end
+
+    # The ambiguity report: a lent line that came back as neither its
+    # own bytes nor a single candidate is one this server cannot
+    # 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_line makes.
+    #: (Integer count) -> void
+    def report_ambiguous_inherited_lines(count)
+      return if count.zero?
+
+      Sentry.capture_message(
+        "a submitted card left #{count} inherited line(s) with more than one line of that name to attribute them to",
+        level: :warning,
+      )
+    end
+
+    # The refusal report: a member edited a shared line into a shape no
+    # group may hold, so the edit stays on the member and the group
+    # keeps what it lent. The known cause is an edit that lands in a
+    # type Contacts cannot model — a relabel to one, or a new value
+    # under one — which comes back as a property group with an
+    # `X-ABLabel` beside it (#kept_types?), and the news is worth
+    # having because that member now serves the line twice — its own
+    # copy and the group's — until someone reconciles them.
+    #: (Integer count) -> void
+    def report_unshareable_lines(count)
+      return if count.zero?
+
+      Sentry.capture_message(
+        "a submitted card edited #{count} inherited line(s) into a shape no group may hold",
+        level: :warning,
+      )
+    end
+  end
+end
diff --git a/lib/pro_tacts/birthday_line.rb b/lib/pro_tacts/birthday_line.rb
index f84f3ea..0e1b053 100644
--- a/lib/pro_tacts/birthday_line.rb
+++ b/lib/pro_tacts/birthday_line.rb
@@ -2,7 +2,7 @@ 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
+  # What one BDAY line in a submitted card is, for Arrival 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.
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index e57db9e..9f8396c 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -342,7 +342,7 @@ module ProTacts
     # Matched by the line's bytes, which is the same blindness the
     # editor's digests have (VCard::Parser::Line#digest): a stored
     # line whose bytes are a group's line reads as the group's here.
-    # No write makes that state — Store#subtract_inherited takes the
+    # No write makes that state — Arrival#subtract_inherited takes the
     # group's copy back out of a submission — but a member whose own
     # card spells the line exactly as its group does still has one, and
     # naming the group over both copies says something true about the
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index c160b59..5d955c6 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -2,10 +2,9 @@ require "date"
 require "json"
 require "pathname"
 require "sequel"
-require "sentry-ruby"
 
+require "pro_tacts/arrival"
 require "pro_tacts/birthday"
-require "pro_tacts/birthday_line"
 require "pro_tacts/card_diff"
 require "pro_tacts/change_id"
 require "pro_tacts/contact"
@@ -33,10 +32,10 @@ module ProTacts
   #
   # Every Contact this store hands out is composed, never the stored
   # card alone: a birthday and the lines a contact inherits from its
-  # groups are both subtracted out of a card on the way in, and Contact
-  # composes them back in on read — 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.
+  # groups are both subtracted out of a card on the way in (Arrival),
+  # and Contact composes them back in on read — 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.
   #
   # The signature lives in sig/pro_tacts/store.rbs, for the Change Data
   # class the inline syntax cannot read.
@@ -94,24 +93,6 @@ module ProTacts
     # @rbs skip
     UpcomingBirthday = Data.define(:contact, :occurs_on)
 
-    # One line a group lends one card, with the row it is lent from —
-    # the write path's reading of what #inherited_of answers as the
-    # model's. A member's edit has to reach the group_properties row
-    # the line came from, which takes the position as well as the
-    # group. The signature is in sig/pro_tacts/store.rbs with Store's
-    # own.
-    # @rbs skip
-    Lent = Data.define(:group_id, :position, :line)
-
-    # What one classified line asks of the group it came from: the row
-    # to write, and the line that replaces it — or nil to remove the
-    # row, which is what a member's deletion of a shared line means
-    # (docs/plans/2026-09-09-group-edits-propagate.md, "The open
-    # question, decided").
-    # @rbs skip
-    GroupEdit = Data.define(:group_id, :position, :line)
-
-    # Everything the store cannot rebuild, read at one moment: the
     # stored cards by id, the birthdays by card id, the groups, and the
     # books — the name each named one goes by, keyed by login. What
     # `rake db:dump` writes (tasks/db.rake).
@@ -202,23 +183,6 @@ module ProTacts
     # than a run of bad draws.
     CONTACT_ID_ATTEMPTS = 8 #: Integer
 
-    # The property names whose value is structured rather than free
-    # text (RFC 2426 section 3.2.1), among the two a group may lend:
-    # ADR is components, NOTE is text (db/migrations/004_groups.rb).
-    # Which reading applies is the caller's to know — the parser holds
-    # no value types — and this is the one caller that compares values.
-    STRUCTURED_VALUES = %w[ADR].freeze #: Array[String]
-
-    # The property names a group may lend, a second copy of the CHECK
-    # in db/migrations/004_groups.rb and saying so here. The constraint
-    # stays the authority; this is the pre-check a propagated edit
-    # passes first, so that a line the group cannot hold is refused
-    # rather than raising on an ordinary sync
-    # (docs/plans/2026-09-09-group-edits-propagate.md, "The line a
-    # group cannot hold").
-    SHAREABLE_NAMES = %w[ADR NOTE].freeze #: Array[String]
-
-    # A groups row's own columns, qualified for #group_label's reason:
     # the label is written against the table by name.
     GROUP_COLUMNS = [Sequel[:groups][:id], Sequel[:groups][:name]].freeze #: Array[untyped]
 
@@ -474,14 +438,13 @@ module ProTacts
     # transaction, because the log entry cannot be rebuilt from anything.
     #
     # What is stored is the card minus its birthday and minus what its
-    # groups lend it, both composed back in on read
-    # (docs/plans/2026-08-31-partial-birthdays.md,
-    # docs/plans/2026-08-24-vcard-storage-and-groups.md).
+    # groups lend it, both composed back in on read; Arrival makes that
+    # split and this applies it.
     #
     # A card rather than its bytes, so the reading a caller already
-    # made is the one #split_birthday decides from: a PUT has asked
-    # whether the bytes are a card at all and whose UID they carry
-    # before it gets here (Web#write_card).
+    # made is the one Arrival splits: a PUT has asked whether the bytes
+    # are a card at all and whose UID they carry before it gets here
+    # (Web#write_card).
     #
     # The strings are UTF-8 by contract and the bind is the third line
     # holding them to it, under Web#write_card's relabel-and-judge and
@@ -495,28 +458,23 @@ module ProTacts
     #: (String id, VCard vcard, ?client: bool) -> Contact
     def put(id, vcard, client: false)
       existing = birthday_of(id)
-      # Read once for the three that want it: the birthday split, the
-      # subtraction after it, and the card this write replaces.
+      # Read once for the two that want it: the arrival's split, and
+      # the card this write replaces.
       stored_before = stored_card(id)
-      birthday, stored = split_birthday(vcard, existing, stored_before)
-
-      # The other half of the split, the same shape as the birthday's:
-      # what the groups lend comes back out of the submission before it
-      # is stored, so a write cannot materialize a group's lines into
-      # the member's own card.
-      #
-      # The Contact this returns is the composed one — those lines put
+      lent = lent_of(id)
+      arrival = Arrival.new(vcard, birthday_before: existing, stored_before:, lent:)
+      stored, birthday, edits = arrival.stored, arrival.birthday, arrival.edits
+
+      # The Contact this returns is the composed one — the lent lines put
       # back, read off membership as it stands at this write — and the
       # logged etag is its hash, so a client's token describes the card
       # it downloads.
-      lent = lent_of(id)
       inherited = inheritance(lent)
       # Composed from the parts already read here rather than through
       # #contact, which would read all three again. Membership cannot
       # move during a put, so the inheritance either side of it is the
       # one read above.
       replaced = stored_before && Contact.new(id:, stored: stored_before, birthday: existing, inherited:)
-      stored, edits = subtract_inherited(stored, lent, stored_before)
       # Built before the transaction as well as inside it, because its
       # constructor is where an id that could not be served is refused
       # and a refusal from inside a transaction reaches the caller
@@ -1071,7 +1029,7 @@ module ProTacts
     # what it lent. A removal leaves a gap in the positions, which
     # costs nothing — position orders a group's lines and is not
     # counted.
-    #: (Array[GroupEdit] edits) -> void
+    #: (Array[Arrival::GroupEdit] edits) -> void
     def apply_group_edits(edits)
       edits.each do |edit|
         row = group_properties.where(group_id: edit.group_id, position: edit.position)
@@ -1188,48 +1146,6 @@ module ProTacts
       Sequel::Migrator.run(@database, MIGRATIONS.to_s)
     end
 
-    # The birthday half of the split a write makes
-    # (docs/plans/2026-09-11-every-birthday-in-the-model.md): the
-    # model's new birthday, and the card to store beside it. 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).
-    #
-    # No BDAY is a client that was sent the birthday removing it, or
-    # one that was never sent it and so cannot have. A BDAY line left
-    # in the stored card goes with the write, which is worth saying.
-    #
-    # case/when rather than case/in, BirthdayLine's own rule: Steep
-    # does not check the bodies of case/in branches, and these are the
-    # ones worth checking — which is what the last arm is for.
-    #: (VCard vcard, Birthday? existing, VCard? stored_before) -> [Birthday?, VCard]
-    def split_birthday(vcard, existing, stored_before)
-      bdays, rest = vcard.extract("BDAY")
-      if bdays.empty?
-        report_lost_bday_lines(stored_before)
-        return [existing && !existing.served? ? existing : nil, vcard]
-      end
-
-      if bdays.length > 1
-        report_many_bday_lines(bdays.length)
-        return [nil, vcard]
-      end
-
-      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 card currently stored for an id, or nil for a card being
     # created. A plain `first` read: the primary key leaves `sole`
     # nothing to catch, and no row is the ordinary answer.
@@ -1239,296 +1155,6 @@ module ProTacts
       row && VCard.new(row.fetch(:vcard).to_s)
     end
 
-    # 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
-    # (the line config.ru draws for Sentry); 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
-
-    # 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
-    # out: served is stored plus inherited, so stored is submitted
-    # minus inherited and a client that PUTs back what it downloaded
-    # stores what it started with
-    # (docs/plans/2026-08-24-vcard-storage-and-groups.md, "Groups
-    # compose into cards"). Which of four shapes a lent line came back
-    # as, and what each asks of its group, is #classify — argued in
-    # that plan's "Classifying what came back" and in
-    # docs/plans/2026-09-09-group-edits-propagate.md.
-    #
-    # Two passes, because one lent line must not be attributed a line
-    # another lent line would have matched exactly. A member of two
-    # groups that each lend an address, editing one of them, submits
-    # one edited address and one untouched: walked in a single pass,
-    # whichever group came first would read the other's untouched line
-    # as its own edit and the other would read a deletion. Every
-    # untouched line is struck first, and only what is left is
-    # attributed.
-    #
-    # #substitute rather than #extract and #insert, which would move a
-    # member's own lines of the same name to the card's end: the lines
-    # this leaves keep their positions, so a submission that was the
-    # served card round-trips to the bytes it was composed from and the
-    # PUT can answer with a strong etag (RFC 6352 section 6.3.2.3).
-    #: (VCard vcard, Array[Lent] lent, VCard? stored_before) -> [VCard, Array[GroupEdit]]
-    def subtract_inherited(vcard, lent, stored_before)
-      return [vcard, []] if lent.empty?
-
-      unaccounted = unaccounted_lines(vcard, stored_before)
-      taken = [] #: Array[VCard::Parser::Line]
-
-      moved = lent.reject { |row|
-        candidates = unaccounted.select { it.names?(property_name(row.line)) }
-        # Blind to lines saying the same thing the way the editor's
-        # digests are blind to identical bytes
-        # (VCard::Parser::Line#digest): where a member's own card
-        # carries what its group lends, which copy this takes is
-        # undecidable and their saying the same thing makes it not
-        # matter.
-        match = candidates.find { unedited?(it, parsed_line(row.line), unaccounted) }
-        if match
-          label = label_of(match, unaccounted)
-          taken << strike(unaccounted, match)
-          taken << strike(unaccounted, label) if label
-        end
-        match
-      }
-
-      edits = [] #: Array[GroupEdit]
-      ambiguous = 0
-      unshareable = 0
-      moved.each do |row|
-        case classify(row, unaccounted, taken)
-        in GroupEdit => edit then edits << edit
-        in :ambiguous then ambiguous += 1
-        in :unshareable then unshareable += 1
-        end
-      end
-
-      report_ambiguous_inherited_lines(ambiguous)
-      report_unshareable_lines(unshareable)
-      [taken.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }, edits]
-    end
-
-    # What a lent line that came back changed asks of its group: the
-    # edit the one candidate of its name is, or the deletion no
-    # candidate at all is. The two refusals ask nothing — more than one
-    # candidate cannot be attributed, and a candidate the group could
-    # not hold must not be tried (#shareable?) — and both leave the
-    # line in the member's own card, where an edit at least is not
-    # lost.
-    #
-    # An edit's line is struck from the pool and carried by the group
-    # from here on, so `taken` grows the way it does for an untouched
-    # one.
-    #: (Lent row, Array[VCard::Parser::Line] unaccounted, Array[VCard::Parser::Line] taken) -> (GroupEdit | :ambiguous | :unshareable)
-    def classify(row, unaccounted, taken)
-      candidates = unaccounted.select { it.names?(property_name(row.line)) }
-      return GroupEdit.new(group_id: row.group_id, position: row.position, line: nil) if candidates.empty?
-      return :ambiguous unless candidates.length == 1
-
-      edited = candidates.fetch(0)
-      return :unshareable unless shareable?(edited)
-
-      taken << strike(unaccounted, edited)
-      GroupEdit.new(group_id: row.group_id, position: row.position, line: edited.content)
-    end
-
-    # The line out of the pool, so no second lent line is attributed
-    # it. `index` rather than `delete`, which would take every copy of
-    # a line a card carries twice.
-    #: (Array[VCard::Parser::Line] unaccounted, VCard::Parser::Line line) -> VCard::Parser::Line
-    def strike(unaccounted, line)
-      unaccounted.delete_at(
-        unaccounted.index(line) #: Integer
-      )
-      line
-    end
-
-    # Whether a group could hold this line: one of the names
-    # SHAREABLE_NAMES admits, and no property group in front of it —
-    # the CHECK's two clauses, read off the parse rather than matched
-    # as bytes. A line that would not read is not one to share.
-    #: (VCard::Parser::Line line) -> bool
-    def shareable?(line)
-      property = line.property
-      return false if property.nil?
-
-      property.group.nil? && SHAREABLE_NAMES.include?(property.name.upcase)
-    end
-
-    # A lent line as the parser reads it: one logical line, the group
-    # schema admitting no other shape (db/migrations/004_groups.rb).
-    #: (String line) -> VCard::Parser::Line
-    def parsed_line(line)
-      VCard.new(line).lines.fetch(0)
-    end
-
-    # Whether a submitted line still says what the group lends, which
-    # is a question about what it says and not about its bytes: macOS
-    # re-serializes every card it touches
-    # (docs/apple-contacts.md, "The client rewrites every card it
-    # touches"), and comparing bytes reads every such line as an edit.
-    #
-    # What a line says is its value and its types, because a relabel is
-    # an edit the group takes like any other
-    # (docs/plans/2026-08-24-vcard-storage-and-groups.md, "Edits
-    # propagate to the group"). Every other parameter goes uncompared,
-    # being the half the client rewrites unasked — it drops the ones it
-    # does not model and fills in defaults on the ones it does.
-    #
-    # A line that will not read has no value to compare and falls back
-    # to its bytes, which still recognize the line nobody touched.
-    #: (VCard::Parser::Line line, VCard::Parser::Line lent, Array[VCard::Parser::Line] pool) -> bool
-    def unedited?(line, lent, pool)
-      value = value_of(lent)
-      return line.verbatim.chomp == lent.verbatim.chomp if value.nil?
-
-      value_of(line) == value && kept_types?(line, lent, pool)
-    end
-
-    # Whether a submitted line carries the types the group lent it,
-    # across the three rewrites they survive. Two are cosmetic and
-    # Property#types already absorbs them: the values come back
-    # uppercased, and `pref` comes back filled in.
-    #
-    # The third moves the type off the line: a type Contacts has no
-    # field for comes back as an `X-ABLabel` in a property group
-    # (docs/apple-contacts.md, "An address type the client cannot
-    # model becomes a custom label"), so that label counts as one of
-    # the line's types and `ADR;TYPE=dom` coming back as `item1.ADR`
-    # with `item1.X-ABLabel:dom` is unedited.
-    #: (VCard::Parser::Line line, VCard::Parser::Line lent, Array[VCard::Parser::Line] pool) -> bool
-    def kept_types?(line, lent, pool)
-      label = label_of(line, pool)&.property
-      types = types_of(line)
-      types = (types + [label.text.downcase]).uniq.sort if label
-      types == types_of(lent)
-    end
-
-    # The `X-ABLabel` sharing a property group with `line`, the other
-    # half of the pair #kept_types? reads as one. Nil for an ungrouped
-    # line, or a group the pool holds no label for.
-    #: (VCard::Parser::Line line, Array[VCard::Parser::Line] pool) -> VCard::Parser::Line?
-    def label_of(line, pool)
-      group = line.property&.group
-      return if group.nil?
-
-      pool.find { |other|
-        property = other.property
-        !property.nil? && property.group&.casecmp?(group) == true && property.name.casecmp?("X-ABLABEL")
-      }
-    end
-
-    # What a line says, as the reading its property's value type calls
-    # for: an ADR compares component by component (RFC 2426 section
-    # 3.2.1) and a NOTE as its unescaped text (section 2.4.2). Nil for
-    # a line that would not read, which has no value at all.
-    #: (VCard::Parser::Line line) -> Array[String]?
-    def value_of(line)
-      property = line.property
-      return nil if property.nil?
-
-      STRUCTURED_VALUES.include?(property.name.upcase) ? property.components : [property.text]
-    end
-
-    # A line's TYPE values as the set the round trip preserves, sorted
-    # because `TYPE=home;TYPE=pref` and `TYPE=pref,home` are one thing
-    # (RFC 2426 section 3.2.1, which the parser reads into pairs either
-    # way). Empty for a line that would not read, which has no
-    # parameters to compare.
-    #: (VCard::Parser::Line line) -> Array[String]
-    def types_of(line)
-      property = line.property
-      return [] if property.nil?
-
-      property.types.sort
-    end
-
-    # The submission's lines the card as it stood before this write
-    # does not already explain — one struck per stored line of the same bytes,
-    # so a card that stores one of something and submits two leaves one
-    # over. What is left is what the groups lent plus whatever the
-    # client wrote beside it, which is the pool a lent line is
-    # attributed from. Everything for a card being created, which has
-    # no stored lines to explain anything.
-    #: (VCard vcard, VCard? stored_before) -> Array[VCard::Parser::Line]
-    def unaccounted_lines(vcard, stored_before)
-      stored = stored_before ? stored_before.lines.map { it.verbatim.chomp } : [] #: Array[String]
-      vcard.lines.reject { |line|
-        index = stored.index(line.verbatim.chomp)
-        stored.delete_at(index) if index
-        index
-      }
-    end
-
-    # A content line's property name: what stands before its first
-    # parameter or its value (RFC 2426 section 2.1.1). Read off the
-    # bytes rather than parsed, because the only lines asked are a
-    # group's, which carry no `item1.` prefix to strip — the schema
-    # refuses one (db/migrations/004_groups.rb).
-    #: (String line) -> String
-    def property_name(line)
-      line[/\A[^;:]*/].to_s
-    end
-
-    # The ambiguity report: a lent line that came back as neither its
-    # own bytes nor a single candidate is one this server cannot
-    # 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_line makes.
-    #: (Integer count) -> void
-    def report_ambiguous_inherited_lines(count)
-      return if count.zero?
-
-      Sentry.capture_message(
-        "a submitted card left #{count} inherited line(s) with more than one line of that name to attribute them to",
-        level: :warning,
-      )
-    end
-
-    # The refusal report: a member edited a shared line into a shape no
-    # group may hold, so the edit stays on the member and the group
-    # keeps what it lent. The known cause is an edit that lands in a
-    # type Contacts cannot model — a relabel to one, or a new value
-    # under one — which comes back as a property group with an
-    # `X-ABLabel` beside it (#kept_types?), and the news is worth
-    # having because that member now serves the line twice — its own
-    # copy and the group's — until someone reconciles them.
-    #: (Integer count) -> void
-    def report_unshareable_lines(count)
-      return if count.zero?
-
-      Sentry.capture_message(
-        "a submitted card edited #{count} inherited line(s) into a shape no group may hold",
-        level: :warning,
-      )
-    end
-
-    # The loss report, the write's half of the arrival one: a BDAY line
-    # still in the stored card is one the model did not take, and a
-    # write without it is about to drop it with nobody told.
-    #: (VCard? stored_before) -> void
-    def report_lost_bday_lines(stored_before)
-      return if stored_before.nil?
-
-      lines, = stored_before.extract("BDAY")
-      return if lines.empty?
-
-      Sentry.capture_message("a write 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
     # Contact composes the served one — its vcard, its etag, everything
     # a caller reads — with the inherited lines beside it, so all
@@ -1604,8 +1230,8 @@ module ProTacts
 
     # The same read as it stands for a write: the rows a member's edit
     # has to reach back to, before #inheritance drops them to what the
-    # model wants (see Lent).
-    #: (String id) -> Array[Lent]
+    # model wants (see Arrival::Lent).
+    #: (String id) -> Array[Arrival::Lent]
     def lent_of(id)
       inherited_rows.where(card_id: id).map { lent_from(it) }
     end
@@ -1627,7 +1253,7 @@ module ProTacts
     # What a model asks of a group's rows: the group lending each,
     # whole, and the line itself. Which group_properties row lent it is
     # the write path's business and stops here (Contact#group_of).
-    #: (Array[Lent] lent, ?Hash[String, Group] lenders) -> Array[Contact::Inherited]
+    #: (Array[Arrival::Lent] lent, ?Hash[String, Group] lenders) -> Array[Contact::Inherited]
     def inheritance(lent, lenders = groups_by_id(lent.map(&:group_id)))
       lent.map { Contact::Inherited.new(group: lenders.fetch(it.group_id), line: it.line) }
     end
@@ -1675,9 +1301,9 @@ module ProTacts
         )
     end
 
-    #: (Hash[Symbol, untyped] row) -> Lent
+    #: (Hash[Symbol, untyped] row) -> Arrival::Lent
     def lent_from(row)
-      Lent.new(
+      Arrival::Lent.new(
         group_id: row.fetch(:group_id).to_s,
         position: row.fetch(:position).to_i,
         line: row.fetch(:line).to_s,
diff --git a/sig/pro_tacts/arrival.rbs b/sig/pro_tacts/arrival.rbs
new file mode 100644
index 0000000..de9d155
--- /dev/null
+++ b/sig/pro_tacts/arrival.rbs
@@ -0,0 +1,22 @@
+# Lent and GroupEdit are Data classes, whose members the inline parser
+# cannot read (see sig/pro_tacts/contact.rbs). Arrival itself is an
+# ordinary class and its types are inline in arrival.rb.
+module ProTacts
+  class Arrival
+    class Lent
+      attr_reader group_id: String
+      attr_reader position: Integer
+      attr_reader line: String
+
+      def self.new: (group_id: String, position: Integer, line: String) -> instance
+    end
+
+    class GroupEdit
+      attr_reader group_id: String
+      attr_reader position: Integer
+      attr_reader line: String?
+
+      def self.new: (group_id: String, position: Integer, line: String?) -> instance
+    end
+  end
+end
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index a538db0..f070db6 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -38,14 +38,6 @@ module ProTacts
       def self.new: (contact: Contact, occurs_on: Date) -> instance
     end
 
-    class Lent
-      attr_reader group_id: String
-      attr_reader position: Integer
-      attr_reader line: String
-
-      def self.new: (group_id: String, position: Integer, line: String) -> instance
-    end
-
     class Group
       attr_reader id: String
       attr_reader name: String?
@@ -73,13 +65,5 @@ module ProTacts
 
       def self.new: (cards: Hash[String, String], birthdays: Hash[String, Birthday], groups: Array[Group], books: Hash[String, String]) -> instance
     end
-
-    class GroupEdit
-      attr_reader group_id: String
-      attr_reader position: Integer
-      attr_reader line: String?
-
-      def self.new: (group_id: String, position: Integer, line: String?) -> instance
-    end
   end
 end
diff --git a/test/pro_tacts/test_arrival.rb b/test/pro_tacts/test_arrival.rb
new file mode 100644
index 0000000..962a683
--- /dev/null
+++ b/test/pro_tacts/test_arrival.rb
@@ -0,0 +1,159 @@
+require_relative "../test_helper"
+
+require "pro_tacts/arrival"
+require "pro_tacts/birthday"
+require "pro_tacts/vcard"
+
+# The split on its own, with no database behind it: what Store#put
+# applies is decided here from the card and what stood before it, so
+# each branch can be read off its inputs. test_store.rb holds the same
+# rules through a write, where the group rows and the served card are
+# what is checked.
+class ArrivalTest < Minitest::Test
+  include Sentry::TestHelper
+  include SentryMessages
+
+  AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+  HOUSEHOLD_ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
+  EDITED_ADDRESS = HOUSEHOLD_ADDRESS.sub("7 Calculus", "8 Calculus") #: String
+  RESERIALIZED_ADDRESS = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "ADR;type=HOME;type=pref:") #: String
+  OWN_ADDRESS = "ADR:;;1 Long Road;;;;" #: String
+  LENT = ProTacts::Arrival::Lent.new(group_id: "kkkk", position: 0, line: HOUSEHOLD_ADDRESS)
+
+  def setup
+    setup_sentry
+  end
+
+  def teardown
+    teardown_sentry_test
+  end
+
+  def vcard(bytes) = ProTacts::VCard.new(bytes)
+
+  def with_lines(*lines) = AIDEN.sub("END:VCARD\r\n", lines.map { "#{it}\r\n" }.join + "END:VCARD\r\n")
+
+  def arrive(bytes, birthday_before: nil, stored_before: nil, lent: [])
+    ProTacts::Arrival.new(
+      vcard(bytes),
+      birthday_before:,
+      stored_before: stored_before && vcard(stored_before),
+      lent:,
+    )
+  end
+
+  ## The birthday
+
+  def test_a_modeled_birthday_leaves_the_card_for_the_model
+    arrival = arrive(with_lines("BDAY:1985-04-12"))
+
+    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), arrival.birthday
+    assert_equal AIDEN, arrival.stored.to_s
+    assert_empty sentry_messages
+  end
+
+  def test_a_card_without_a_bday_keeps_a_birthday_no_client_was_sent
+    month_only = ProTacts::Birthday.new(month: 4)
+
+    assert_equal month_only, arrive(AIDEN, birthday_before: month_only).birthday
+  end
+
+  def test_a_card_without_a_bday_drops_a_birthday_the_client_was_sent
+    assert_nil arrive(AIDEN, birthday_before: ProTacts::Birthday.new(year: 1985, month: 4, day: 12)).birthday
+  end
+
+  def test_an_unrecognized_bday_stays_in_the_card_and_is_reported
+    submitted = with_lines("BDAY:1985-13")
+    arrival = arrive(submitted)
+
+    assert_nil arrival.birthday
+    assert_equal submitted, arrival.stored.to_s
+    assert_equal ["a submitted card carried a BDAY line the model does not take"], sentry_messages
+  end
+
+  def test_several_bday_lines_stay_in_the_card_and_are_reported
+    submitted = with_lines("BDAY:1985-04-12", "BDAY:1986-05-13")
+    arrival = arrive(submitted)
+
+    assert_nil arrival.birthday
+    assert_equal submitted, arrival.stored.to_s
+    assert_equal ["a submitted card carried 2 BDAY lines"], sentry_messages
+  end
+
+  def test_a_write_dropping_a_bday_the_model_did_not_take_is_reported
+    arrive(AIDEN, stored_before: with_lines("BDAY:1985-13"))
+
+    assert_equal ["a write dropped 1 BDAY line(s) the model did not take"], sentry_messages
+  end
+
+  ## The lent lines
+
+  def test_a_card_with_nothing_lent_is_stored_as_it_arrived
+    submitted = with_lines(OWN_ADDRESS)
+    arrival = arrive(submitted)
+
+    assert_equal submitted, arrival.stored.to_s
+    assert_empty arrival.edits
+  end
+
+  def test_an_untouched_lent_line_is_subtracted_and_asks_nothing
+    arrival = arrive(with_lines(HOUSEHOLD_ADDRESS), stored_before: AIDEN, lent: [LENT])
+
+    assert_equal AIDEN, arrival.stored.to_s
+    assert_empty arrival.edits
+    assert_empty sentry_messages
+  end
+
+  def test_a_reserialized_lent_line_is_still_untouched
+    arrival = arrive(with_lines(RESERIALIZED_ADDRESS), stored_before: AIDEN, lent: [LENT])
+
+    assert_equal AIDEN, arrival.stored.to_s
+    assert_empty arrival.edits
+  end
+
+  def test_a_members_own_line_keeps_its_place_beside_a_lent_one
+    own = AIDEN.sub("FN:Aiden\r\n", "#{OWN_ADDRESS}\r\nFN:Aiden\r\n")
+    submitted = own.sub("END:VCARD\r\n", "#{HOUSEHOLD_ADDRESS}\r\nEND:VCARD\r\n")
+
+    assert_equal own, arrive(submitted, stored_before: own, lent: [LENT]).stored.to_s
+  end
+
+  def test_an_edited_lent_line_is_an_edit_of_its_row
+    arrival = arrive(with_lines(EDITED_ADDRESS), stored_before: AIDEN, lent: [LENT])
+
+    assert_equal AIDEN, arrival.stored.to_s
+    assert_equal [ProTacts::Arrival::GroupEdit.new(group_id: "kkkk", position: 0, line: EDITED_ADDRESS)], arrival.edits
+  end
+
+  def test_a_deleted_lent_line_is_a_removal_of_its_row
+    arrival = arrive(AIDEN, stored_before: AIDEN, lent: [LENT])
+
+    assert_equal AIDEN, arrival.stored.to_s
+    assert_equal [ProTacts::Arrival::GroupEdit.new(group_id: "kkkk", position: 0, line: nil)], arrival.edits
+  end
+
+  def test_two_candidates_for_one_lent_line_stay_in_the_card_and_are_reported
+    submitted = with_lines(EDITED_ADDRESS, OWN_ADDRESS)
+    arrival = arrive(submitted, stored_before: AIDEN, lent: [LENT])
+
+    assert_equal submitted, arrival.stored.to_s
+    assert_empty arrival.edits
+    assert_equal 1, sentry_messages.length
+  end
+
+  def test_an_edit_no_group_may_hold_stays_in_the_card_and_is_reported
+    submitted = with_lines(HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "item1.ADR:"), "item1.X-ABLabel:dom")
+    arrival = arrive(submitted, stored_before: AIDEN, lent: [LENT])
+
+    assert_equal submitted, arrival.stored.to_s
+    assert_empty arrival.edits
+    assert_equal 1, sentry_messages.length
+  end
+
+  def test_the_birthday_and_the_lent_lines_leave_together
+    arrival = arrive(with_lines(HOUSEHOLD_ADDRESS, "BDAY:1985-04-12"), stored_before: AIDEN, lent: [LENT])
+
+    assert_equal AIDEN, arrival.stored.to_s
+    assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), arrival.birthday
+    assert_empty arrival.edits
+  end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index ad5913a..1f2810d 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -948,7 +948,7 @@ class StoreTest < Minitest::Test
   # component out of range (month 13, day 32), a spelling its grammar
   # does not take (undashed, unpadded — see Birthday's patterns), and a
   # BDAY the card hangs off a property group. Why it reports rather
-  # than stores quietly is Store#report_unrecognized_bday_line.
+  # than stores quietly is Arrival#report_unrecognized_bday_line.
   def test_an_unrecognized_bday_arriving_is_reported
     ["BDAY:1985-13", "BDAY:--0432", "BDAY:19850412", "BDAY:1985-4", "item1.BDAY:1985-04-12"].each do |line|
       with_store({}) do |store|
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 9d96483..535a90b 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -392,7 +392,7 @@ class WebTest < Minitest::Test
   # back is subtracted before storage and composed again on read, so
   # the resource serves the submitted bytes exactly and the tag is
   # honest — even though what was stored is fewer bytes than arrived
-  # (Store#subtract_inherited).
+  # (Arrival#subtract_inherited).
   def test_a_members_put_of_its_served_card_keeps_the_strong_etag
     address = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom"