Make the split two functions instead of Arrival
Arrival was a class with one call, three return values and no state
worth keeping, so it goes. Each half moves to where its vocabulary
already lives:

- BirthdayLine.split, beside BirthdayLine.read, with its three reports
  as private class methods.
- Lent.subtract, on Lent promoted to a top-level Data class in lent.rb
  and reopened the way CardDiff is. GroupEdit comes with it as
  Lent::Edit, and the classify and match helpers become private class
  methods.

Store#put is back to main's shape with those two calls in place of the
private methods. test_arrival.rb splits into test_birthday_line.rb and
test_lent.rb.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi55bFkB6wo4jguNtsase4
change
commit 21fca757eeb8a3d15f8c7f075ce3db39aaa9c52c
author Claude <noreply@anthropic.com>
date
parent 9a0625f4
diff --git a/AGENTS.md b/AGENTS.md
index 9b59c3e..28c8c4a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,10 +80,11 @@ 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 Arrival
+├── birthday_line.rb # What a submitted BDAY line is, and the split
+│                   # of a card's birthday from it, for Store#put
+├── lent.rb         # A line a group lends a card, and the subtraction
+│                   # that takes it back out of a submitted one
 ├── 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/birthday_line.rb b/lib/pro_tacts/birthday_line.rb
index 0e1b053..9aa95c9 100644
--- a/lib/pro_tacts/birthday_line.rb
+++ b/lib/pro_tacts/birthday_line.rb
@@ -1,11 +1,14 @@
+require "sentry-ruby"
+
 require "pro_tacts/birthday"
+require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
 
 module ProTacts
-  # 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.
+  # What one BDAY line in a submitted card is, and the split Store#put
+  # makes on it (docs/plans/2026-09-11-every-birthday-in-the-model.md).
+  # Match with case/when, not case/in: Steep does not check the bodies
+  # of case/in branches, and these branches are the ones worth checking.
   #
   # The variants are signed in sig/pro_tacts/birthday_line.rbs, for
   # Birthday's reason.
@@ -37,5 +40,77 @@ module ProTacts
       birthday = Birthday.from_property(property)
       birthday ? Modeled.new(birthday:) : Unrecognized.new
     end
+
+    # The birthday half of the split a write makes: 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 for the module's reason, which is what the last arm is
+    # for.
+    #: (VCard vcard, Birthday? existing, VCard? stored_before) -> [Birthday?, VCard]
+    def self.split(vcard, existing, stored_before)
+      bdays, rest = vcard.extract("BDAY")
+      if bdays.empty?
+        report_lost(stored_before)
+        return [existing && !existing.served? ? existing : nil, vcard]
+      end
+
+      if bdays.length > 1
+        report_many(bdays.length)
+        return [nil, vcard]
+      end
+
+      line = read(bdays.fetch(0))
+      case line
+      when Modeled
+        [line.birthday, rest]
+      when Unrecognized
+        report_unrecognized
+        [nil, vcard]
+      when 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 self.report_unrecognized
+      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 self.report_many(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 self.report_lost(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
+
+    private_class_method :report_unrecognized, :report_many, :report_lost
   end
 end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 9f8396c..bc7f271 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 — Arrival#subtract_inherited takes the
+    # No write makes that state — Lent.subtract 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/arrival.rb b/lib/pro_tacts/lent.rb
similarity index 63%
rename from lib/pro_tacts/arrival.rb
rename to lib/pro_tacts/lent.rb
index 1ad7887..c0c37dc 100644
--- a/lib/pro_tacts/arrival.rb
+++ b/lib/pro_tacts/lent.rb
@@ -1,47 +1,29 @@
 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).
+  # 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.
   #
-  # 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)
-
+  # The signature lives in sig/pro_tacts/lent.rbs, this being a Data
+  # class.
+  # @rbs skip
+  Lent = Data.define(:group_id, :position, :line)
+
+  # Reopened rather than defined in the block above, for the reason
+  # VCard::Parser::Property is.
+  class Lent
     # 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)
+    Edit = 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:
@@ -59,107 +41,14 @@ module ProTacts
     # 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
+    # out, and the edits the lines that came back changed ask of those
+    # groups: 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
+    # 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.
     #
@@ -177,8 +66,8 @@ module ProTacts
     # 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)
+    #: (VCard vcard, Array[Lent] lent, VCard? stored_before) -> [VCard, Array[Edit]]
+    def self.subtract(vcard, lent, stored_before)
       return [vcard, []] if lent.empty?
 
       unaccounted = unaccounted_lines(vcard, stored_before)
@@ -201,19 +90,19 @@ module ProTacts
         match
       }
 
-      edits = [] #: Array[GroupEdit]
+      edits = [] #: Array[Edit]
       ambiguous = 0
       unshareable = 0
       moved.each do |row|
         case classify(row, unaccounted, taken)
-        in GroupEdit => edit then edits << edit
+        in Edit => 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)
+      report_ambiguous(ambiguous)
+      report_unshareable(unshareable)
       [taken.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }, edits]
     end
 
@@ -221,31 +110,31 @@ module ProTacts
     # 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
+    # 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)
+    #: (Lent row, Array[VCard::Parser::Line] unaccounted, Array[VCard::Parser::Line] taken) -> (Edit | :ambiguous | :unshareable)
+    def self.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 Edit.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)
+      Edit.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)
+    def self.strike(unaccounted, line)
       unaccounted.delete_at(
         unaccounted.index(line) #: Integer
       )
@@ -257,7 +146,7 @@ module ProTacts
     # 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)
+    def self.shareable?(line)
       property = line.property
       return false if property.nil?
 
@@ -267,7 +156,7 @@ module ProTacts
     # 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)
+    def self.parsed_line(line)
       VCard.new(line).lines.fetch(0)
     end
 
@@ -287,7 +176,7 @@ module ProTacts
     # 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)
+    def self.unedited?(line, lent, pool)
       value = value_of(lent)
       return line.verbatim.chomp == lent.verbatim.chomp if value.nil?
 
@@ -306,7 +195,7 @@ module ProTacts
     # 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)
+    def self.kept_types?(line, lent, pool)
       label = label_of(line, pool)&.property
       types = types_of(line)
       types = (types + [label.text.downcase]).uniq.sort if label
@@ -314,10 +203,10 @@ module ProTacts
     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
+    # 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)
+    def self.label_of(line, pool)
       group = line.property&.group
       return if group.nil?
 
@@ -332,7 +221,7 @@ module ProTacts
     # 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)
+    def self.value_of(line)
       property = line.property
       return nil if property.nil?
 
@@ -345,7 +234,7 @@ module ProTacts
     # 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)
+    def self.types_of(line)
       property = line.property
       return [] if property.nil?
 
@@ -360,7 +249,7 @@ module ProTacts
     # 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)
+    def self.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)
@@ -375,7 +264,7 @@ module ProTacts
     # 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)
+    def self.property_name(line)
       line[/\A[^;:]*/].to_s
     end
 
@@ -384,9 +273,9 @@ module ProTacts
     # attribute — two lines of that name arrived that the member's card
     # does not explain, and calling either the edit would be a guess.
     # The card is stored as it arrived and the line is news, the same
-    # bargain report_unrecognized_bday_line makes.
+    # bargain BirthdayLine.report_unrecognized makes.
     #: (Integer count) -> void
-    def report_ambiguous_inherited_lines(count)
+    def self.report_ambiguous(count)
       return if count.zero?
 
       Sentry.capture_message(
@@ -400,11 +289,11 @@ module ProTacts
     # 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
+    # `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)
+    def self.report_unshareable(count)
       return if count.zero?
 
       Sentry.capture_message(
@@ -412,5 +301,9 @@ module ProTacts
         level: :warning,
       )
     end
+
+    private_class_method :classify, :strike, :shareable?, :parsed_line, :unedited?, :kept_types?, :label_of,
+                         :value_of, :types_of, :unaccounted_lines, :property_name, :report_ambiguous,
+                         :report_unshareable
   end
 end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 5d955c6..ca2916a 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -3,11 +3,12 @@ require "json"
 require "pathname"
 require "sequel"
 
-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"
+require "pro_tacts/lent"
 require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
 
@@ -32,10 +33,11 @@ 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 (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.
+  # groups are both subtracted out of a card on the way in
+  # (BirthdayLine.split, Lent.subtract), 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.
@@ -438,13 +440,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; Arrival makes that
-    # split and this applies it.
+    # groups lend it, both composed back in on read
+    # (BirthdayLine.split, Lent.subtract).
     #
     # A card rather than its bytes, so the reading a caller already
-    # 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).
+    # made is the one BirthdayLine.split 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).
     #
     # 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
@@ -458,23 +460,28 @@ module ProTacts
     #: (String id, VCard vcard, ?client: bool) -> Contact
     def put(id, vcard, client: false)
       existing = birthday_of(id)
-      # Read once for the two that want it: the arrival's split, and
-      # the card this write replaces.
+      # Read once for the three that want it: the birthday split, the
+      # subtraction after it, and the card this write replaces.
       stored_before = stored_card(id)
-      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
+      birthday, stored = BirthdayLine.split(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
       # 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 = Lent.subtract(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
@@ -1029,7 +1036,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[Arrival::GroupEdit] edits) -> void
+    #: (Array[Lent::Edit] edits) -> void
     def apply_group_edits(edits)
       edits.each do |edit|
         row = group_properties.where(group_id: edit.group_id, position: edit.position)
@@ -1230,8 +1237,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 Arrival::Lent).
-    #: (String id) -> Array[Arrival::Lent]
+    # model wants (see Lent).
+    #: (String id) -> Array[Lent]
     def lent_of(id)
       inherited_rows.where(card_id: id).map { lent_from(it) }
     end
@@ -1253,7 +1260,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[Arrival::Lent] lent, ?Hash[String, Group] lenders) -> Array[Contact::Inherited]
+    #: (Array[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
@@ -1301,9 +1308,9 @@ module ProTacts
         )
     end
 
-    #: (Hash[Symbol, untyped] row) -> Arrival::Lent
+    #: (Hash[Symbol, untyped] row) -> Lent
     def lent_from(row)
-      Arrival::Lent.new(
+      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
deleted file mode 100644
index de9d155..0000000
--- a/sig/pro_tacts/arrival.rbs
+++ /dev/null
@@ -1,22 +0,0 @@
-# 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/lent.rbs b/sig/pro_tacts/lent.rbs
new file mode 100644
index 0000000..59ae447
--- /dev/null
+++ b/sig/pro_tacts/lent.rbs
@@ -0,0 +1,21 @@
+# Lent and Lent::Edit are Data classes, whose members the inline parser
+# cannot read (see sig/pro_tacts/contact.rbs). Both are marked
+# `@rbs skip` in lent.rb and signed here instead; the methods reopened
+# onto Lent carry their own inline types.
+module ProTacts
+  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
+
+    class Edit
+      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
deleted file mode 100644
index 962a683..0000000
--- a/test/pro_tacts/test_arrival.rb
+++ /dev/null
@@ -1,159 +0,0 @@
-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_birthday_line.rb b/test/pro_tacts/test_birthday_line.rb
new file mode 100644
index 0000000..b668951
--- /dev/null
+++ b/test/pro_tacts/test_birthday_line.rb
@@ -0,0 +1,74 @@
+require_relative "../test_helper"
+
+require "pro_tacts/birthday"
+require "pro_tacts/birthday_line"
+require "pro_tacts/vcard"
+
+# The birthday half of a write on its own, with no store behind it.
+# test_store.rb holds the same rules through Store#put, where the
+# birthdays row and the served card are what is checked.
+class BirthdayLineTest < 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"
+  APRIL_12 = ProTacts::Birthday.new(year: 1985, month: 4, day: 12)
+
+  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 split(bytes, existing: nil, stored_before: nil)
+    ProTacts::BirthdayLine.split(vcard(bytes), existing, stored_before && vcard(stored_before))
+  end
+
+  def test_a_modeled_birthday_leaves_the_card_for_the_model
+    birthday, rest = split(with_lines("BDAY:1985-04-12"))
+
+    assert_equal APRIL_12, birthday
+    assert_equal AIDEN, rest.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, split(AIDEN, existing: month_only).first
+  end
+
+  def test_a_card_without_a_bday_drops_a_birthday_the_client_was_sent
+    assert_nil split(AIDEN, existing: APRIL_12).first
+  end
+
+  def test_an_unrecognized_bday_stays_in_the_card_and_is_reported
+    submitted = with_lines("BDAY:1985-13")
+    birthday, rest = split(submitted)
+
+    assert_nil birthday
+    assert_equal submitted, rest.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")
+    birthday, rest = split(submitted)
+
+    assert_nil birthday
+    assert_equal submitted, rest.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
+    split(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
+end
diff --git a/test/pro_tacts/test_lent.rb b/test/pro_tacts/test_lent.rb
new file mode 100644
index 0000000..475a061
--- /dev/null
+++ b/test/pro_tacts/test_lent.rb
@@ -0,0 +1,86 @@
+require_relative "../test_helper"
+
+require "pro_tacts/lent"
+require "pro_tacts/vcard"
+
+# The subtraction on its own, with no store behind it: what the lines
+# a member's groups lend come back as, read off the card and what
+# stood before it. test_store.rb holds the same rules through
+# Store#put, where the group rows and the served card are what is
+# checked.
+class LentTest < 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
+  HOUSEHOLD = ProTacts::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 subtract(bytes, stored_before: AIDEN, lent: [HOUSEHOLD])
+    stored, edits = ProTacts::Lent.subtract(vcard(bytes), lent, stored_before && vcard(stored_before))
+    [stored.to_s, edits]
+  end
+
+  def test_a_card_with_nothing_lent_is_stored_as_it_arrived
+    submitted = with_lines(OWN_ADDRESS)
+
+    assert_equal [submitted, []], subtract(submitted, stored_before: nil, lent: [])
+  end
+
+  def test_an_untouched_lent_line_is_subtracted_and_asks_nothing
+    assert_equal [AIDEN, []], subtract(with_lines(HOUSEHOLD_ADDRESS))
+    assert_empty sentry_messages
+  end
+
+  def test_a_reserialized_lent_line_is_still_untouched
+    assert_equal [AIDEN, []], subtract(with_lines(RESERIALIZED_ADDRESS))
+  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, []], subtract(submitted, stored_before: own)
+  end
+
+  def test_an_edited_lent_line_is_an_edit_of_its_row
+    edit = ProTacts::Lent::Edit.new(group_id: "kkkk", position: 0, line: EDITED_ADDRESS)
+
+    assert_equal [AIDEN, [edit]], subtract(with_lines(EDITED_ADDRESS))
+  end
+
+  def test_a_deleted_lent_line_is_a_removal_of_its_row
+    removal = ProTacts::Lent::Edit.new(group_id: "kkkk", position: 0, line: nil)
+
+    assert_equal [AIDEN, [removal]], subtract(AIDEN)
+  end
+
+  def test_two_candidates_for_one_lent_line_stay_in_the_card_and_are_reported
+    submitted = with_lines(EDITED_ADDRESS, OWN_ADDRESS)
+
+    assert_equal [submitted, []], subtract(submitted)
+    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")
+
+    assert_equal [submitted, []], subtract(submitted)
+    assert_equal 1, sentry_messages.length
+  end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 1f2810d..d3b5324 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 Arrival#report_unrecognized_bday_line.
+  # than stores quietly is BirthdayLine.report_unrecognized.
   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 535a90b..b1a743e 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
-  # (Arrival#subtract_inherited).
+  # (Lent.subtract).
   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"