Read a submission as an EditedContact of the contact before it
Store#put already read the contact it replaces, in three pieces: the
stored card, the birthday, and the lent rows. The one thing a Contact
lacked was the position of each inherited line's group_properties row,
which a member's edit writes back to. Contact::Inherited now carries
it, so Lent and Contact::Inherited stop being the same fact twice.
Lent, lent_of and lent_from go, along with stored_card, which had no
other caller.

EditedContact.new(submitted, before:) holds the submission and the
contact before it (nil for a create), and answers stored, birthday and
group_edits. The birthday split and the lent-line subtraction are its
private methods, moved as they were. It composes the edited contact by
holding one rather than by being one: its groups would be the ones
from before the edit, and put still builds the contact it returns
after the fan-out and the sync:* join.

BirthdayLine goes back to what main has. test_birthday_line.rb and
test_lent.rb become test_edited_contact.rb.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi55bFkB6wo4jguNtsase4
change
commit ef78ab21dd3cbba5e82b0aad83460ecc22d7a881
author Claude <noreply@anthropic.com>
date
parent 21fca757
diff --git a/AGENTS.md b/AGENTS.md
index 28c8c4a..4eefbd0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,11 +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
+├── edited_contact.rb # A submitted card read against the contact it
+│                   # replaces: the card, birthday, and group edits
+│                   # Store#put writes
 ├── birthday.rb     # A partial date, the one fact no card can hold
-├── 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
+├── birthday_line.rb # What a submitted BDAY line is, for EditedContact
 ├── 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 9aa95c9..35c6218 100644
--- a/lib/pro_tacts/birthday_line.rb
+++ b/lib/pro_tacts/birthday_line.rb
@@ -1,14 +1,11 @@
-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, 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.
+  # What one BDAY line in a submitted card is, for EditedContact to act on
+  # (docs/plans/2026-09-11-every-birthday-in-the-model.md). Match with
+  # case/when, not case/in: Steep does not check the bodies of case/in
+  # branches, and these branches are the ones worth checking.
   #
   # The variants are signed in sig/pro_tacts/birthday_line.rbs, for
   # Birthday's reason.
@@ -40,77 +37,5 @@ 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 bc7f271..273faa1 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -77,9 +77,11 @@ module ProTacts
     # One line a group lends this contact, and the group that lends it,
     # whole (Store#inheritance). Not its name, because two groups may
     # share one, and a screen marking the line opens the one group that
-    # lent it (docs/DESIGN.md, "Relationships are navigable").
+    # lent it (docs/DESIGN.md, "Relationships are navigable"). The
+    # position is the group_properties row the line came from, which a
+    # member's edit to it writes back to (EditedContact::GroupEdit).
     # @rbs skip
-    Inherited = Data.define(:group, :line)
+    Inherited = Data.define(:group, :position, :line)
     # @rbs skip
     Address = Data.define(
       :po_box,
@@ -140,6 +142,11 @@ module ProTacts
     # #properties' job.
     attr_reader :birthday
 
+    # The lines this contact's groups lend it (Inherited), which a
+    # submitted card is measured against to tell a member's edit to a
+    # shared line from a line of its own (EditedContact).
+    attr_reader :inherited
+
     # The card to serve: the stored one with the inherited lines and
     # the birthday composed back in, in that order, each immediately
     # before END:VCARD — stored + inherited + birthday, the card a
@@ -342,7 +349,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 — Lent.subtract takes the
+    # No write makes that state — EditedContact 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/lent.rb b/lib/pro_tacts/edited_contact.rb
similarity index 60%
rename from lib/pro_tacts/lent.rb
rename to lib/pro_tacts/edited_contact.rb
index c0c37dc..342b0bb 100644
--- a/lib/pro_tacts/lent.rb
+++ b/lib/pro_tacts/edited_contact.rb
@@ -1,29 +1,43 @@
 require "sentry-ruby"
 
+require "pro_tacts/birthday"
+require "pro_tacts/birthday_line"
+require "pro_tacts/contact"
 require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
 
 module ProTacts
-  # 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.
+  # A card a client submitted over a contact, read back into what the
+  # store keeps: the card to store, the birthday to hold beside it, and
+  # what the member's edits to its groups' lines ask of those groups.
+  # 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).
   #
-  # 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
+  # An edit of a contact rather than a kind of one: the contact after
+  # the write is Store#put's to compose, because the group edits move
+  # the very groups `before` holds, and a card a client creates can
+  # join `sync:*` on the way in. Nothing here reads or writes the
+  # database; the reports go to Sentry from here because this is the
+  # arrival (AGENTS.md, "The one exception is the arrival reports").
+  #
+  # GroupEdit's signature is in sig/pro_tacts/edited_contact.rbs, it
+  # being a Data class.
+  class EditedContact
+    # @rbs @before: Contact?
+    # @rbs @submitted: VCard
+    # @rbs @stored: VCard
+    # @rbs @birthday: Birthday?
+    # @rbs @group_edits: Array[GroupEdit]
+
     # 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
-    Edit = Data.define(:group_id, :position, :line)
+    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:
@@ -41,15 +55,120 @@ module ProTacts
     # group cannot hold").
     SHAREABLE_NAMES = %w[ADR NOTE].freeze #: Array[String]
 
-    # The submitted card with the lines its groups lend it taken back
-    # 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
-    # that plan's "Classifying what came back" and in
+    # `before` is the contact this write replaces, nil for one being
+    # created: its stored card, its birthday, and the lines its groups
+    # lend it are what the submission is read against.
+    #: (VCard submitted, before: Contact?) -> void
+    def initialize(submitted, before:)
+      @before = before
+      @submitted = submitted
+      @birthday, rest = split_birthday
+      @stored, @group_edits = subtract_inherited(rest)
+    end
+
+    attr_reader :before
+
+    attr_reader :submitted
+
+    # 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 :group_edits
+
+    private
+
+    # The card as it stood before this write, nil for one being created.
+    #: () -> VCard?
+    def stored_before
+      @before&.stored
+    end
+
+    # The birthday half (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.
+    #: () -> [Birthday?, VCard]
+    def split_birthday
+      bdays, rest = @submitted.extract("BDAY")
+      if bdays.empty?
+        report_lost_bday_lines
+        existing = @before&.birthday
+        return [existing && !existing.served? ? existing : nil, @submitted]
+      end
+
+      if bdays.length > 1
+        report_many_bday_lines(bdays.length)
+        return [nil, @submitted]
+      end
+
+      line = BirthdayLine.read(bdays.fetch(0))
+      case line
+      when BirthdayLine::Modeled
+        [line.birthday, rest]
+      when BirthdayLine::Unrecognized
+        report_unrecognized_bday_line
+        [nil, @submitted]
+      when BirthdayLine::Unreadable
+        [nil, @submitted]
+      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.
+    #: () -> void
+    def report_lost_bday_lines
+      stored = stored_before
+      return if stored.nil?
+
+      lines, = stored.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 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
@@ -66,11 +185,12 @@ 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[Edit]]
-    def self.subtract(vcard, lent, stored_before)
+    #: (VCard vcard) -> [VCard, Array[GroupEdit]]
+    def subtract_inherited(vcard)
+      lent = @before&.inherited || [] #: Array[Contact::Inherited]
       return [vcard, []] if lent.empty?
 
-      unaccounted = unaccounted_lines(vcard, stored_before)
+      unaccounted = unaccounted_lines(vcard)
       taken = [] #: Array[VCard::Parser::Line]
 
       moved = lent.reject { |row|
@@ -90,19 +210,19 @@ module ProTacts
         match
       }
 
-      edits = [] #: Array[Edit]
+      edits = [] #: Array[GroupEdit]
       ambiguous = 0
       unshareable = 0
       moved.each do |row|
         case classify(row, unaccounted, taken)
-        in Edit => edit then edits << edit
+        in GroupEdit => edit then edits << edit
         in :ambiguous then ambiguous += 1
         in :unshareable then unshareable += 1
         end
       end
 
-      report_ambiguous(ambiguous)
-      report_unshareable(unshareable)
+      report_ambiguous_inherited_lines(ambiguous)
+      report_unshareable_lines(unshareable)
       [taken.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }, edits]
     end
 
@@ -110,31 +230,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) -> (Edit | :ambiguous | :unshareable)
-    def self.classify(row, unaccounted, taken)
+    #: (Contact::Inherited 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 Edit.new(group_id: row.group_id, position: row.position, line: nil) if candidates.empty?
+      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)
-      Edit.new(group_id: row.group_id, position: row.position, line: edited.content)
+      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 self.strike(unaccounted, line)
+    def strike(unaccounted, line)
       unaccounted.delete_at(
         unaccounted.index(line) #: Integer
       )
@@ -146,7 +266,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 self.shareable?(line)
+    def shareable?(line)
       property = line.property
       return false if property.nil?
 
@@ -156,7 +276,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 self.parsed_line(line)
+    def parsed_line(line)
       VCard.new(line).lines.fetch(0)
     end
 
@@ -176,7 +296,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 self.unedited?(line, lent, pool)
+    def unedited?(line, lent, pool)
       value = value_of(lent)
       return line.verbatim.chomp == lent.verbatim.chomp if value.nil?
 
@@ -195,7 +315,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 self.kept_types?(line, lent, pool)
+    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
@@ -203,10 +323,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 self.label_of(line, pool)
+    def label_of(line, pool)
       group = line.property&.group
       return if group.nil?
 
@@ -221,7 +341,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 self.value_of(line)
+    def value_of(line)
       property = line.property
       return nil if property.nil?
 
@@ -234,7 +354,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 self.types_of(line)
+    def types_of(line)
       property = line.property
       return [] if property.nil?
 
@@ -248,9 +368,9 @@ module ProTacts
     # 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 self.unaccounted_lines(vcard, stored_before)
-      stored = stored_before ? stored_before.lines.map { it.verbatim.chomp } : [] #: Array[String]
+    #: (VCard vcard) -> Array[VCard::Parser::Line]
+    def unaccounted_lines(vcard)
+      stored = 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
@@ -264,7 +384,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 self.property_name(line)
+    def property_name(line)
       line[/\A[^;:]*/].to_s
     end
 
@@ -273,9 +393,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 BirthdayLine.report_unrecognized makes.
+    # bargain report_unrecognized_bday_line makes.
     #: (Integer count) -> void
-    def self.report_ambiguous(count)
+    def report_ambiguous_inherited_lines(count)
       return if count.zero?
 
       Sentry.capture_message(
@@ -289,11 +409,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 self.report_unshareable(count)
+    def report_unshareable_lines(count)
       return if count.zero?
 
       Sentry.capture_message(
@@ -301,9 +421,5 @@ 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 ca2916a..3b6eea3 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -4,11 +4,10 @@ require "pathname"
 require "sequel"
 
 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/edited_contact"
 require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
 
@@ -34,8 +33,7 @@ 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
-  # (BirthdayLine.split, Lent.subtract), and Contact composes them back
-  # in on read — so the vcard and the etag a caller sees, and the etag
+  # (EditedContact), 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.
   #
@@ -440,11 +438,11 @@ 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
-    # (BirthdayLine.split, Lent.subtract).
+    # groups lend it, both composed back in on read; EditedContact
+    # reads the submission apart and this writes what it answers.
     #
     # A card rather than its bytes, so the reading a caller already
-    # made is the one BirthdayLine.split decides from: a PUT has asked
+    # made is the one EditedContact 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).
     #
@@ -459,29 +457,19 @@ module ProTacts
     # put over a card that exists joins nothing.
     #: (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.
-      stored_before = stored_card(id)
-      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)
+      # The contact this write replaces, read once for the two that want
+      # it: the edit, which reads the submission against it, and the
+      # change log's diff.
+      before = contact(id)
+      edited = EditedContact.new(vcard, before:)
+      stored, birthday, edits = edited.stored, edited.birthday, edited.group_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. Membership cannot move during a put, so the
+      # inheritance either side of it is the one read above.
+      inherited = before&.inherited || [] #: Array[Contact::Inherited]
       # 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
@@ -496,14 +484,14 @@ module ProTacts
         # read of every other member either side of them.
         unless edits.empty?
           fan_out(member_ids(edits.map(&:group_id).uniq), except: id) { apply_group_edits(edits) }
-          contact = Contact.new(id:, stored:, birthday:, inherited: inheritance(lent_of(id)))
+          contact = Contact.new(id:, stored:, birthday:, inherited: inherited_of(id))
         end
         upsert_card(contact.id, stored)
         # After the card, which the membership names by foreign key, and
         # before the log entry, which records the card composed with
         # whatever the group lends. The put's entry is the card's arrival
         # in the book, so the join writes none of its own.
-        if client && stored_before.nil?
+        if client && before.nil?
           group_members.insert(group_id: everyone_group_id, card_id: contact.id)
           contact = Contact.new(id:, stored:, birthday:, inherited: inherited_of(contact.id))
         end
@@ -512,7 +500,7 @@ module ProTacts
           contact.id,
           action: Action::PUT,
           etag: contact.etag,
-          diff: CardDiff.between(replaced&.vcard, contact.vcard),
+          diff: CardDiff.between(before&.vcard, contact.vcard),
         )
         reindex(contact.id, stored)
         contact
@@ -831,7 +819,7 @@ module ProTacts
     # #delete's shape.
     #: (String id) -> bool
     def delete_group(id)
-      # A plain `first` read, #stored_card's reason: id is the primary
+      # A plain `first` read, #birthday_of's reason: id is the primary
       # key, so there is no ambiguity for `sole` to catch. Read here
       # rather than rescued around the whole method, which would have
       # swallowed a NoMatchingRow raised from inside the transaction
@@ -1036,7 +1024,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[Lent::Edit] edits) -> void
+    #: (Array[EditedContact::GroupEdit] edits) -> void
     def apply_group_edits(edits)
       edits.each do |edit|
         row = group_properties.where(group_id: edit.group_id, position: edit.position)
@@ -1153,15 +1141,6 @@ module ProTacts
       Sequel::Migrator.run(@database, MIGRATIONS.to_s)
     end
 
-    # The card currently stored for an id, or nil for a card being
-    # created. A plain `first` read: the primary key leaves `sole`
-    # nothing to catch, and no row is the ordinary answer.
-    #: (String id) -> VCard?
-    def stored_card(id)
-      row = cards.where(id:).first
-      row && VCard.new(row.fetch(:vcard).to_s)
-    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
@@ -1232,15 +1211,7 @@ module ProTacts
     # composed card is the same bytes every read.
     #: (String id) -> Array[Contact::Inherited]
     def inherited_of(id)
-      inheritance(lent_of(id))
-    end
-
-    # 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]
-    def lent_of(id)
-      inherited_rows.where(card_id: id).map { lent_from(it) }
+      inheritance(inherited_rows.where(card_id: id).all)
     end
 
     # Every contact's inheritance in one pass, keyed by card, for the
@@ -1254,15 +1225,20 @@ module ProTacts
       lenders = groups_by_id(rows.map { it.fetch(:group_id).to_s })
       rows
         .group_by { it.fetch(:card_id).to_s }
-        .transform_values { |mine| inheritance(mine.map { lent_from(it) }, lenders) }
-    end
-
-    # 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]
-    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) }
+        .transform_values { inheritance(it, lenders) }
+    end
+
+    # Inherited rows as the model reads them: the group lending each,
+    # whole, and the row's position and line.
+    #: (Array[Hash[Symbol, untyped]] rows, ?Hash[String, Group] lenders) -> Array[Contact::Inherited]
+    def inheritance(rows, lenders = groups_by_id(rows.map { it.fetch(:group_id).to_s }))
+      rows.map {
+        Contact::Inherited.new(
+          group: lenders.fetch(it.fetch(:group_id).to_s),
+          position: it.fetch(:position).to_i,
+          line: it.fetch(:line).to_s,
+        )
+      }
     end
 
     # The groups these ids name, whole and keyed by id; no reads at all
@@ -1308,15 +1284,6 @@ module ProTacts
         )
     end
 
-    #: (Hash[Symbol, untyped] row) -> Lent
-    def lent_from(row)
-      Lent.new(
-        group_id: row.fetch(:group_id).to_s,
-        position: row.fetch(:position).to_i,
-        line: row.fetch(:line).to_s,
-      )
-    end
-
     # A birthday row read as the model. The shape was validated on the
     # way in, so a row that no longer parses is corruption to raise on
     # rather than quietly drop.
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 702e9ef..db24f36 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -32,9 +32,10 @@ module ProTacts
 
     class Inherited
       attr_reader group: Store::Group
+      attr_reader position: Integer
       attr_reader line: String
 
-      def self.new: (group: Store::Group, line: String) -> instance
+      def self.new: (group: Store::Group, position: Integer, line: String) -> instance
     end
 
     class Photo
diff --git a/sig/pro_tacts/edited_contact.rbs b/sig/pro_tacts/edited_contact.rbs
new file mode 100644
index 0000000..217cc90
--- /dev/null
+++ b/sig/pro_tacts/edited_contact.rbs
@@ -0,0 +1,15 @@
+# EditedContact::GroupEdit is a Data class, whose members the inline
+# parser cannot read (see sig/pro_tacts/contact.rbs). EditedContact
+# itself is an ordinary class and its types are inline in
+# edited_contact.rb.
+module ProTacts
+  class EditedContact
+    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
deleted file mode 100644
index 59ae447..0000000
--- a/sig/pro_tacts/lent.rbs
+++ /dev/null
@@ -1,21 +0,0 @@
-# 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_birthday_line.rb b/test/pro_tacts/test_birthday_line.rb
deleted file mode 100644
index b668951..0000000
--- a/test/pro_tacts/test_birthday_line.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-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_contact.rb b/test/pro_tacts/test_contact.rb
index daa675c..87b653c 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -30,7 +30,7 @@ class ContactTest < Minitest::Test
   GROUP = ProTacts::Store::Group.new(id: "nous", name: "Booles", label: "Booles", lines: [], members: ["aiden"]) #: ProTacts::Store::Group
 
   def contact(bytes = CARD, id: "aiden", birthday: nil, inherited: [], group: GROUP)
-    lent = inherited.map { ProTacts::Contact::Inherited.new(group:, line: it) }
+    lent = inherited.each_with_index.map { |line, position| ProTacts::Contact::Inherited.new(group:, position:, line:) }
     ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(bytes), birthday:, inherited: lent)
   end
 
diff --git a/test/pro_tacts/test_edited_contact.rb b/test/pro_tacts/test_edited_contact.rb
new file mode 100644
index 0000000..b9b1182
--- /dev/null
+++ b/test/pro_tacts/test_edited_contact.rb
@@ -0,0 +1,164 @@
+require_relative "../test_helper"
+
+require "pro_tacts/birthday"
+require "pro_tacts/contact"
+require "pro_tacts/edited_contact"
+require "pro_tacts/store"
+require "pro_tacts/vcard"
+
+# The edit on its own, with no store behind it: what Store#put writes
+# is decided here from the submission and the contact before it, so
+# each branch can be read off its inputs. test_store.rb holds the same
+# rules through a write, where the rows and the served card are what
+# is checked.
+class EditedContactTest < 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)
+  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::Store::Group.new(
+    id: "kkkk", name: "Household", label: "Household", lines: [HOUSEHOLD_ADDRESS], members: ["aiden"],
+  )
+  LENT = [ProTacts::Contact::Inherited.new(group: HOUSEHOLD, position: 3, line: HOUSEHOLD_ADDRESS)].freeze
+
+  def setup
+    setup_sentry
+  end
+
+  def teardown
+    teardown_sentry_test
+  end
+
+  def with_lines(*lines) = AIDEN.sub("END:VCARD\r\n", lines.map { "#{it}\r\n" }.join + "END:VCARD\r\n")
+
+  def before(stored = AIDEN, birthday: nil, inherited: [])
+    ProTacts::Contact.new(id: "aiden", stored: ProTacts::VCard.new(stored), birthday:, inherited:)
+  end
+
+  def edit(bytes, before: nil)
+    ProTacts::EditedContact.new(ProTacts::VCard.new(bytes), before:)
+  end
+
+  def group_edit(line) = ProTacts::EditedContact::GroupEdit.new(group_id: "kkkk", position: 3, line:)
+
+  ## The birthday
+
+  def test_a_modeled_birthday_leaves_the_card_for_the_model
+    edited = edit(with_lines("BDAY:1985-04-12"))
+
+    assert_equal APRIL_12, edited.birthday
+    assert_equal AIDEN, edited.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, edit(AIDEN, before: before(birthday: month_only)).birthday
+  end
+
+  def test_a_card_without_a_bday_drops_a_birthday_the_client_was_sent
+    assert_nil edit(AIDEN, before: before(birthday: APRIL_12)).birthday
+  end
+
+  def test_an_unrecognized_bday_stays_in_the_card_and_is_reported
+    submitted = with_lines("BDAY:1985-13")
+    edited = edit(submitted)
+
+    assert_nil edited.birthday
+    assert_equal submitted, edited.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")
+    edited = edit(submitted)
+
+    assert_nil edited.birthday
+    assert_equal submitted, edited.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
+    edit(AIDEN, before: 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_contact_in_no_group_is_stored_as_it_arrived
+    submitted = with_lines(OWN_ADDRESS)
+    edited = edit(submitted, before: before)
+
+    assert_equal submitted, edited.stored.to_s
+    assert_empty edited.group_edits
+  end
+
+  def test_an_untouched_lent_line_is_subtracted_and_asks_nothing
+    edited = edit(with_lines(HOUSEHOLD_ADDRESS), before: before(inherited: LENT))
+
+    assert_equal AIDEN, edited.stored.to_s
+    assert_empty edited.group_edits
+    assert_empty sentry_messages
+  end
+
+  def test_a_reserialized_lent_line_is_still_untouched
+    edited = edit(with_lines(RESERIALIZED_ADDRESS), before: before(inherited: LENT))
+
+    assert_equal AIDEN, edited.stored.to_s
+    assert_empty edited.group_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, edit(submitted, before: before(own, inherited: LENT)).stored.to_s
+  end
+
+  def test_an_edited_lent_line_is_an_edit_of_its_row
+    edited = edit(with_lines(EDITED_ADDRESS), before: before(inherited: LENT))
+
+    assert_equal AIDEN, edited.stored.to_s
+    assert_equal [group_edit(EDITED_ADDRESS)], edited.group_edits
+  end
+
+  def test_a_deleted_lent_line_is_a_removal_of_its_row
+    edited = edit(AIDEN, before: before(inherited: LENT))
+
+    assert_equal AIDEN, edited.stored.to_s
+    assert_equal [group_edit(nil)], edited.group_edits
+  end
+
+  def test_two_candidates_for_one_lent_line_stay_in_the_card_and_are_reported
+    submitted = with_lines(EDITED_ADDRESS, OWN_ADDRESS)
+    edited = edit(submitted, before: before(inherited: LENT))
+
+    assert_equal submitted, edited.stored.to_s
+    assert_empty edited.group_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")
+    edited = edit(submitted, before: before(inherited: LENT))
+
+    assert_equal submitted, edited.stored.to_s
+    assert_empty edited.group_edits
+    assert_equal 1, sentry_messages.length
+  end
+
+  def test_the_birthday_and_the_lent_lines_leave_together
+    edited = edit(with_lines(HOUSEHOLD_ADDRESS, "BDAY:1985-04-12"), before: before(inherited: LENT))
+
+    assert_equal AIDEN, edited.stored.to_s
+    assert_equal APRIL_12, edited.birthday
+    assert_empty edited.group_edits
+  end
+end
diff --git a/test/pro_tacts/test_lent.rb b/test/pro_tacts/test_lent.rb
deleted file mode 100644
index 475a061..0000000
--- a/test/pro_tacts/test_lent.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-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 d3b5324..3fc2585 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 BirthdayLine.report_unrecognized.
+  # than stores quietly is EditedContact#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 b1a743e..c9937fb 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
-  # (Lent.subtract).
+  # (EditedContact).
   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"