import: stage a file per card and the list's rows at upload
A walk screen read and parsed every staged card to draw the list down
its side and to match the one card it showed. The upload now splits
the file into a card per file and saves each row's label, sort key and
losses beside them, rewritten only for the card a Save changes; the
match reads the book's listings and reads only the best match whole.
docs/plans/2026-10-14-lists-read-the-index.md records why.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GNVLyiMrZJ7sZctEYq6WCM
change
commit 4d7f93ec5e62ac259de78f46560551052a174eee
author Claude <noreply@anthropic.com>
date
parent 6d457e7c
diff --git a/docs/plans/2026-10-14-lists-read-the-index.md b/docs/plans/2026-10-14-lists-read-the-index.md
new file mode 100644
index 0000000..a10d730
--- /dev/null
+++ b/docs/plans/2026-10-14-lists-read-the-index.md
@@ -0,0 +1,46 @@
+# Lists read the index
+
+2026-10-14. A screen that lists contacts reads them from the
+`card_properties` and `card_parameters` index rather than parsing every
+stored card, and the import's walk saves its list's rows once, at the
+upload, rather than reading every staged card on every screen. Both
+came out of measuring allocations per request on a 600-card book with
+120 photos of about 80 KB each (PR #28).
+
+## Why
+
+A list shows a name, a phone or two, and an avatar, but the card it
+read them from carries the whole base64 picture. On that book
+`/contacts`, `/search` and a group's members each allocated over 200 MB
+a request, almost all of it the PHOTO lines of cards whose pictures the
+list draws as a small avatar, or not at all.
+
+## The listing type
+
+`ListedContact` is what a row, a sort, or a search reads: the
+`Contact` readings (name, nickname, organization, birthday, phones,
+emails, addresses, notes) over a card written back out of the index's
+rows with the PHOTO left out (`Store#listed_contacts`). Its readings
+are Contact's own, inherited lines and the composed birthday included,
+so a row and the contact's page cannot disagree.
+
+That card is not the stored one, so a `ListedContact` answers no bytes
+and no etag: nothing that writes, guards a write, or serves a client
+takes one. A screen that does any of those reads the contact whole
+(`Store#contact`).
+
+Whether there is a picture is read from the first bytes the index holds
+of the card's first PHOTO (`Contact::PHOTO_HEAD`, `Contact.photo_head?`),
+enough to know its type, which is all the avatar chooses between a
+picture and initials on.
+
+## The import
+
+An upload is split into a file per card under `imports/<id>/original/`
+and `revised/`, named by its place in the file, so a screen reads the
+one card it shows. The list down the walk's side is a `rows.json` of a
+label, a sort key and a count of dropped lines per card, read once at
+the upload and again for the one card a Save rewrites
+(`Web#import_row`). Matching a card against the book reads the
+listings, then reads the best match whole, since that is the one a
+merge writes into.
diff --git a/lib/pro_tacts/admin/import_sidebar.rb b/lib/pro_tacts/admin/import_sidebar.rb
index c97f29c..238d54e 100644
--- a/lib/pro_tacts/admin/import_sidebar.rb
+++ b/lib/pro_tacts/admin/import_sidebar.rb
@@ -15,7 +15,7 @@ module ProTacts
     # (Admin::ContactsShow#diff_lines, .diff-removed in admin.css).
     class ImportSidebar < Phlex::HTML
       # @rbs @upload: String
-      # @rbs @rows: Array[[::ProTacts::Contact, Integer, Integer]]
+      # @rbs @rows: Array[[String, Integer, Integer]]
       # @rbs @saved: Hash[String, String]
       # @rbs @current: Integer?
 
@@ -25,7 +25,7 @@ module ProTacts
       # (Web#walk_order); `saved` the contact each row that has come in
       # was written as, by the row's place in the file; `current` the
       # row whose screen is open.
-      #: (upload: String, rows: Array[[::ProTacts::Contact, Integer, Integer]], saved: Hash[String, String], current: Integer) -> void
+      #: (upload: String, rows: Array[[String, Integer, Integer]], saved: Hash[String, String], current: Integer) -> void
       def initialize(upload:, rows:, saved:, current:)
         @upload = upload
         @rows = rows
@@ -38,8 +38,8 @@ module ProTacts
       def view_template
         nav(class: "record walk-list") do
           ul(class: "card") do
-            @rows.each do |(contact, dropped, index)|
-              row(contact, dropped, index)
+            @rows.each do |(label, dropped, index)|
+              row(label, dropped, index)
             end
           end
         end
@@ -47,8 +47,8 @@ module ProTacts
 
       private
 
-      #: (::ProTacts::Contact contact, Integer dropped, Integer index) -> void
-      def row(contact, dropped, index)
+      #: (String label, Integer dropped, Integer index) -> void
+      def row(label, dropped, index)
         stored = @saved[index.to_s]
         li do
           # Every row opens in the walk, a saved one included, as the
@@ -61,7 +61,7 @@ module ProTacts
             aria_current: ("page" if index == @current),
             data: {state: stored ? "saved" : "unsaved"}) do
             div(style: "flex: 1; min-width: 0;") do
-              div(style: "font-weight: 550;") { Format.name_label(contact) }
+              div(style: "font-weight: 550;") { label }
               unless stored
                 span(class: "type-label diff-removed") { "-#{dropped}" } if dropped.positive?
               end
diff --git a/lib/pro_tacts/import/match.rb b/lib/pro_tacts/import/match.rb
index b8d1aca..0c9b89d 100644
--- a/lib/pro_tacts/import/match.rb
+++ b/lib/pro_tacts/import/match.rb
@@ -25,13 +25,13 @@ module ProTacts
       PHONE_DIGITS = 10 #: Integer
 
       # The contacts `arriving` might be, most alike first.
-      #: (Contact arriving, Array[Contact] contacts) -> Array[Contact]
+      #: (Contact arriving, Array[ListedContact] contacts) -> Array[ListedContact]
       def self.candidates(arriving, contacts)
         scored = contacts.filter_map { |contact|
           fields = likeness(arriving, contact)
           next if (fields.max || 0.0) < THRESHOLD
 
-          pair = [contact, fields.sum(0.0)] #: [Contact, Float]
+          pair = [contact, fields.sum(0.0)] #: [ListedContact, Float]
           pair
         }
         scored.sort_by { -it[1] }.first(LIMIT).map { it[0] }
@@ -39,7 +39,7 @@ module ProTacts
 
       # How alike the two are in each of the three, from 0 for nothing
       # alike to 1 for a value they share.
-      #: (Contact arriving, Contact stored) -> Array[Float]
+      #: (Contact arriving, ListedContact stored) -> Array[Float]
       def self.likeness(arriving, stored)
         [
           closest(names(arriving), names(stored)),
@@ -90,13 +90,13 @@ module ProTacts
       # The name as its words, sorted: "Booles, Jane" and "Jane
       # Booles" are one name written two ways, and an edit distance
       # over the two spellings would call them strangers.
-      #: (Contact contact) -> Array[String]
+      #: (Contact | ListedContact contact) -> Array[String]
       def self.names(contact)
         [contact.name.to_s.downcase.scan(/[[:alnum:]]+/).sort.join(" ")].reject(&:empty?)
       end
 
       # An address's case is no part of it.
-      #: (Contact contact) -> Array[String]
+      #: (Contact | ListedContact contact) -> Array[String]
       def self.emails(contact)
         contact.emails.map { it.value.strip.downcase }.reject(&:empty?)
       end
@@ -113,7 +113,7 @@ module ProTacts
         }.max || 0.0
       end
 
-      #: (Contact contact) -> Array[String]
+      #: (Contact | ListedContact contact) -> Array[String]
       def self.phones(contact)
         contact.phones.map { phone(it.value) }.reject(&:empty?)
       end
diff --git a/lib/pro_tacts/import/staged.rb b/lib/pro_tacts/import/staged.rb
index a7b1f36..e935551 100644
--- a/lib/pro_tacts/import/staged.rb
+++ b/lib/pro_tacts/import/staged.rb
@@ -8,10 +8,12 @@ require "pro_tacts/change_id"
 module ProTacts
   module Import
     # An import in progress, on disk between the requests that make it
-    # up: its three slots, and why on disk, are
-    # docs/plans/2026-09-21-import-a-vcf.md, "The import waits on the
-    # server". Under the data directory, which is the one place this
-    # app writes (Config#data_dir), and not the system temp dir: a
+    # up: why on disk is docs/plans/2026-09-21-import-a-vcf.md, "The
+    # import waits on the server", and why a file per card with the
+    # list's rows beside them is
+    # docs/plans/2026-10-14-lists-read-the-index.md, "The import".
+    # Under the data directory, which is the one place this app writes
+    # (Config#data_dir), and not the system temp dir: a
     # half-finished import is this server's own state, short-lived
     # without being anyone else's.
     module Staged
@@ -23,7 +25,23 @@ module ProTacts
       ORIGINAL = "original" #: String
       REVISED = "revised" #: String
       SAVED = "saved" #: String
-      SLOTS = [ORIGINAL, REVISED, SAVED].freeze #: Array[String]
+      ROWS = "rows" #: String
+      # The walk's notes, a JSON file each.
+      SLOTS = [SAVED, ROWS].freeze #: Array[String]
+
+      # The cards' two slots, each a directory holding a file per card
+      # named by its place in the file, so a screen reads the one card
+      # it shows rather than every card the upload held.
+      CARDS = [ORIGINAL, REVISED].freeze #: Array[String]
+
+      # One row of the walk's list as it was last read: what the row is
+      # called and sorted by, off the card as it will be written, and
+      # how many of its original's lines are not coming with it
+      # (Web#walk_sidebar). Read once at the upload and again for the
+      # one card a Save rewrites, rather than off every card on every
+      # screen.
+      # @rbs skip
+      Row = Data.define(:label, :sort_key, :losses)
 
       # The two halves of the saved slot: the name of the group this
       # import files its arrivals under, and the contact each saved
@@ -39,14 +57,16 @@ module ProTacts
       # it carries. Minted, so the id a link or a form hands back is
       # checked against the one shape this writes (#path) rather than
       # trusted as a filename.
-      #: (original: String, revised: String, group: String) -> String
-      def self.open(original:, revised:, group:)
+      #: (originals: Array[String], revised: Array[String], rows: Array[Row], group: String) -> String
+      def self.open(originals:, revised:, rows:, group:)
         sweep
         id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
         directory = root / id
-        FileUtils.mkdir_p(directory)
-        File.binwrite(directory / file_name(ORIGINAL), original)
-        File.binwrite(directory / file_name(REVISED), revised)
+        {ORIGINAL => originals, REVISED => revised}.each do |slot, cards|
+          FileUtils.mkdir_p(directory / slot)
+          cards.each_with_index { |bytes, index| File.binwrite(directory / slot / "#{index}.vcf", bytes) }
+        end
+        File.binwrite(directory / file_name(ROWS), JSON.generate(rows.map(&:to_h)))
         # The group is settled here, and never renamed (the plan's
         # "Nothing waits for the end"). Nothing saved yet, and
         # empty rather than absent, so every slot of an import that
@@ -73,19 +93,45 @@ module ProTacts
         File.read(file.to_s, encoding: Encoding::UTF_8)
       end
 
-      # The cards as they will be written, rewritten: the editor's
-      # save, and one of the two writes this takes after the import
-      # opens. Whole rather than a card at a time, because the file is
-      # the unit a re-read splits (Vcf.cards) and a card's index in it
-      # is how a screen names one.
-      #
-      # Still rewritten for a card that was just saved into the store,
-      # rather than dropped from the file: the walk's list renders
-      # every row from these bytes, and a row that has come in is one
-      # it still has to be able to name (Admin::ImportSidebar).
-      #: (String id, String revised) -> void
-      def self.update(id, revised)
-        write(id, REVISED, revised)
+      # One card's bytes from either cards slot, by its place in the
+      # file, or none for an import that is gone or a place past its
+      # last card.
+      #: (String id, String slot, Integer index) -> String?
+      def self.card(id, slot, index)
+        file = card_path(id, slot, index)
+        return nil if file.nil? || !file.file?
+
+        # #read's encoding, for #read's reason.
+        File.read(file.to_s, encoding: Encoding::UTF_8)
+      end
+
+      # The walk's list, a Row per card in file order, or none for an
+      # import that is gone. A slot that will not read back as rows is
+      # #saved's broken assumption, and JSON or Row says so.
+      #: (String id) -> Array[Row]?
+      def self.rows(id)
+        raw = read(id, ROWS)
+        return nil if raw.nil?
+
+        parsed = JSON.parse(raw, symbolize_names: true) #: Array[Hash[Symbol, untyped]]
+        parsed.map { Row.new(label: it.fetch(:label), sort_key: it.fetch(:sort_key), losses: it.fetch(:losses)) }
+      end
+
+      # One card as it will be written, rewritten, with its row: the
+      # editor's save, and one of the writes this takes after the
+      # import opens. Still rewritten for a card that was just saved
+      # into the store, rather than dropped: the walk's list names
+      # every row, and a row that has come in is one it still has to
+      # be able to name (Admin::ImportSidebar).
+      #: (String id, Integer index, String bytes, Row row) -> void
+      def self.update(id, index, bytes, row)
+        file = card_path(id, REVISED, index) or raise ArgumentError, "#{id} is not an import id"
+        raise ArgumentError, "#{id} has no card #{index}" unless file.file?
+
+        File.binwrite(file.to_s, bytes)
+        rows = rows(id) or raise ArgumentError, "#{id} is not an import in progress"
+        rows[index] = row
+        write(id, ROWS, JSON.generate(rows.map(&:to_h)))
       end
 
       # What this import has done: the group it files under, and the
@@ -165,6 +211,16 @@ module ProTacts
         directory / file_name(slot)
       end
 
+      # One card's file in a cards slot, checked as #path checks: the
+      # place is an Integer the route matched, and never below zero.
+      #: (String id, String slot, Integer index) -> Pathname?
+      def self.card_path(id, slot, index)
+        directory = import_root(id)
+        return nil if directory.nil? || !CARDS.include?(slot) || index.negative?
+
+        directory / slot / "#{index}.vcf"
+      end
+
       # One slot, rewritten. Both refusals name which of the two
       # things went wrong: an id this never minted, or an import that
       # is no longer here.
@@ -187,13 +243,12 @@ module ProTacts
         ids
       end
 
-      # The cards are a .vcf, and the walk's notes beside them are not.
       #: (String slot) -> String
       def self.file_name(slot)
-        slot == ORIGINAL || slot == REVISED ? "#{slot}.vcf" : "#{slot}.json"
+        "#{slot}.json"
       end
 
-      private_class_method :root, :import_root, :path, :file_name, :write, :ids
+      private_class_method :root, :import_root, :path, :card_path, :file_name, :write, :ids
     end
   end
 end
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 0203347..d417988 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -23,9 +23,10 @@ module ProTacts
     # (docs/plans/2026-09-21-import-a-vcf.md, "The walk" through
     # "Nothing waits for the end").
     #
-    # Every screen re-reads the whole staged file, so each step that
-    # grows with it is a span (Web#span) counting cards, never their
-    # content.
+    # Every screen reads the one card it shows and the walk's list,
+    # staged as rows at the upload (#import_row); each step that grows
+    # with the file or the book is a span (Web#span) counting cards,
+    # never their content.
     #
     # Under the same identity gate as every other route (web.rb). A
     # card this creates is ticked into everyone's book rather than the
@@ -80,9 +81,10 @@ module ProTacts
     # The upload's own POST: the file — a .vcf, or a Monica export
     # read as one — read and judged whole before anything is staged,
     # so a source that will not read brings in nothing. What it
-    # stages is both readings —
-    # the file as it arrived, and the cards pared to what this book
-    # shows — and then it is the walk's first card.
+    # stages is both readings — the file as it arrived, and the cards
+    # pared to what this book shows — a card at a time, with the
+    # walk's list read off them once, and then it is the walk's first
+    # card.
     #: (untyped r) -> untyped
     def stage_upload(r)
       upload = file_in(r.params["vcf"])
@@ -104,11 +106,17 @@ module ProTacts
         return import_screen(notice: error.message)
       end
 
-      revised = span("import.pare") { cards.map { Import::Vcf.read(it).card } }
+      readings = span("import.pare") { cards.map { Import::Vcf.read(it) } }
+      rows = span("import.rows") {
+        readings.each_with_index.map { |reading, index|
+          import_row(reading.card, index, Import::Vcf.losses(reading.dropped).length)
+        }
+      }
       id = span("import.stage") {
         Import::Staged.open(
-          original: bytes,
-          revised: joined(revised),
+          originals: cards.map(&:to_s),
+          revised: readings.map { it.card.to_s },
+          rows:,
           # Named now and never renamed (the plan's "Nothing waits
           # for the end"); its membership is ticked a contact at a time
           # (#import_picker).
@@ -119,23 +127,32 @@ module ProTacts
       # list's first row (#walk_order), not the file's first card. A
       # 303, the other writes' answer, because every screen of the
       # walk is a GET a back button can revisit.
-      r.redirect "/import/#{id}/#{walk_order(import_contacts(revised)).fetch(0)}", 303
+      r.redirect "/import/#{id}/#{walk_order(rows).fetch(0)}", 303
     end
 
     # The walk's list of contacts, as every screen of it shows them:
-    # the contact each card will be written as, how many of its
+    # the name each card will be written under, how many of its
     # original's lines are not coming with it, and which rows are in
     # the book already. `current` is the row whose screen is open.
-    #: (String upload, Array[VCard] originals, Array[VCard] revised, Hash[String, String] saved, current: Integer) -> Admin::ImportSidebar
-    def walk_sidebar(upload, originals, revised, saved, current:)
-      rows = span("import.sidebar") {
-        contacts = import_contacts(revised)
-        walk_order(contacts).map { |index|
-          dropped = Import::Vcf.read(originals.fetch(index)).dropped
-          [contacts.fetch(index), Import::Vcf.losses(dropped).length, index] #: [Contact, Integer, Integer]
-        }
+    # Drawn from the staged rows (#import_row), not the cards.
+    #: (String upload, Array[Import::Staged::Row] rows, Hash[String, String] saved, current: Integer) -> Admin::ImportSidebar
+    def walk_sidebar(upload, rows, saved, current:)
+      listed = walk_order(rows).map { |index|
+        row = rows.fetch(index)
+        [row.label, row.losses, index] #: [String, Integer, Integer]
       }
-      Admin::ImportSidebar.new(upload:, rows:, saved:, current:)
+      Admin::ImportSidebar.new(upload:, rows: listed, saved:, current:)
+    end
+
+    # One card's row in the walk's list, read off the contact it will
+    # be written as: once per card at the upload, and again for the
+    # card a Save rewrites, so no screen reads every card to draw the
+    # list (docs/plans/2026-10-14-lists-read-the-index.md, "The
+    # import"). The losses are the original's, which no Save moves.
+    #: (VCard card, Integer index, Integer losses) -> Import::Staged::Row
+    def import_row(card, index, losses)
+      contact = import_contact(card, index)
+      Import::Staged::Row.new(label: Admin::Format.name_label(contact), sort_key: Admin::Format.sort_key(contact), losses:)
     end
 
     # The rows' places in the file, in the order the list shows them:
@@ -144,16 +161,13 @@ module ProTacts
     # where the book's id would, as a number: a row's id is that place
     # spelled as a string, and "10" sorts before "2". A row is still
     # named by its place (the Integer route), which a save cannot
-    # move; its position in the list is read off the revised cards
-    # afresh on every screen, so a family name changed on the way in
-    # moves its row the way it would move the contact's. The contacts
-    # rather than the cards, so the sidebar sorts the ones it shows
-    # instead of reading every card a second time.
-    #: (Array[Contact] contacts) -> Array[Integer]
-    def walk_order(contacts)
-      span("import.walk_order") {
-        contacts.each_index.sort_by { |index| [Admin::Format.sort_key(contacts.fetch(index)), index] }
-      }
+    # move; its position in the list is its row's sort key, which the
+    # Save that rewrites a card writes again (#import_row), so a family
+    # name changed on the way in moves its row the way it would move
+    # the contact's.
+    #: (Array[Import::Staged::Row] rows) -> Array[Integer]
+    def walk_order(rows)
+      rows.each_index.sort_by { |index| [rows.fetch(index).sort_key, index] }
     end
 
     # A row opened: the card it arrived as beside the contact editor
@@ -180,12 +194,11 @@ module ProTacts
     # plan's "Writing them in").
     #: (String upload, Integer index, ?into: String?, ?notice: String?, ?joined: Array[String]?, ?named: Array[String]?) -> String?
     def card_screen(upload, index, into: "", notice: nil, joined: nil, named: nil)
-      staged = staged_cards(upload)
-      return expired_screen if staged.nil?
+      rows = Import::Staged.rows(upload)
+      return expired_screen if rows.nil?
 
-      originals, revised = staged
-      original = originals[index]
-      card = revised[index]
+      original = staged_card(upload, Import::Staged::ORIGINAL, index)
+      card = staged_card(upload, Import::Staged::REVISED, index)
       # An index past the end of the file is the empty-body 404 the
       # not_found handler fills in, the same as a contact id nobody
       # has.
@@ -195,7 +208,7 @@ module ProTacts
       stored = saved[index.to_s]
       contact = stored ? store.contact(stored) : nil
       dropped = Import::Vcf.read(original).dropped
-      sidebar = walk_sidebar(upload, originals, revised, saved, current: index)
+      sidebar = walk_sidebar(upload, rows, saved, current: index)
 
       response["Content-Type"] = "text/html; charset=utf-8"
       return span("import.render") {
@@ -213,13 +226,15 @@ module ProTacts
       } if contact
 
       arriving = import_contact(card, index)
-      book = span("import.contacts") { |step| store.contacts.tap { step.set_attribute("rows", it.length) } }
+      book = span("import.contacts") { |step| store.listed_contacts.tap { step.set_attribute("rows", it.length) } }
       matches = span("import.match") { Import::Match.candidates(arriving, book) }
       target = into.nil? || into.empty? ? nil : store.contact(into)
       # The row opened with no choice made is the first choice
       # offered, the best match
-      # (docs/plans/2026-09-23-update-is-the-default.md).
-      target ||= matches.first if into.nil?
+      # (docs/plans/2026-09-23-update-is-the-default.md), read whole
+      # now that it is the one being folded into.
+      best = matches.first
+      target ||= store.contact(best.id) if into.nil? && best
       merge = target && span("import.merge") { Import::Merge.new(target, card) }
       span("import.render") {
         Admin::ContactsEdit.call(
@@ -248,12 +263,13 @@ module ProTacts
     # them — a toggle has to show the side it is on, and a contact
     # edited since it was offered can have stopped looking like the
     # card.
-    #: (String upload, Integer index, Array[Contact] matches, Contact? target) -> Admin::ImportTarget?
+    #: (String upload, Integer index, Array[ListedContact] matches, Contact? target) -> Admin::ImportTarget?
     def import_target(upload, index, matches, target)
-      matches = [target, *matches] if target && matches.none? { it.id == target.id }
-      return nil if matches.empty?
+      offered = matches #: Array[Contact | ListedContact]
+      offered = [target, *matches] if target && matches.none? { it.id == target.id }
+      return nil if offered.empty?
 
-      Admin::ImportTarget.new(row: "/import/#{upload}/#{index}", matches:, into: target)
+      Admin::ImportTarget.new(row: "/import/#{upload}/#{index}", matches: offered, into: target)
     end
 
     # The editor over a stored row's contact, reached from that row's
@@ -266,11 +282,10 @@ module ProTacts
     # goes back to the row screen.
     #: (untyped r, String upload, Integer index, ?contact: Contact?, ?notice: String?) -> untyped
     def edit_card_screen(r, upload, index, contact: nil, notice: nil)
-      staged = staged_cards(upload)
-      return expired_screen if staged.nil?
+      rows = Import::Staged.rows(upload)
+      return expired_screen if rows.nil?
 
-      originals, revised = staged
-      original = originals[index]
+      original = staged_card(upload, Import::Staged::ORIGINAL, index)
       return nil if original.nil?
 
       _group, saved = Import::Staged.saved(upload)
@@ -286,7 +301,7 @@ module ProTacts
         action: "/import/#{upload}/#{index}",
         save: "Save",
         aside: Admin::ImportOriginal.new(card: original, dropped: Import::Vcf.read(original).dropped),
-        sidebar: walk_sidebar(upload, originals, revised, saved, current: index),
+        sidebar: walk_sidebar(upload, rows, saved, current: index),
       )
     end
 
@@ -325,11 +340,10 @@ module ProTacts
     # card and its groups going in together.
     #: (untyped r, String upload, Integer index) -> untyped
     def save_card(r, upload, index)
-      staged = staged_cards(upload)
-      return expired_screen if staged.nil?
+      rows = Import::Staged.rows(upload)
+      return expired_screen if rows.nil?
 
-      _originals, revised = staged
-      card = revised[index]
+      card = staged_card(upload, Import::Staged::REVISED, index)
       return nil if card.nil?
 
       _group, saved = Import::Staged.saved(upload)
@@ -427,9 +441,8 @@ module ProTacts
         }
         # Staged after the write, unlike a new contact's below
         # (docs/plans/2026-09-23-merging-on-import.md, "The Save").
-        revised[index] = edited
-        span("import.restage") { Import::Staged.update(upload, joined(revised)) }
-        return walk_on(r, upload, index, written, saved, revised)
+        span("import.restage") { restage(upload, index, edited, rows) }
+        return walk_on(r, upload, index, written, saved, rows)
       end
 
       # The birthday goes back into the card, an import having no
@@ -454,24 +467,33 @@ module ProTacts
       # Staged before it is stored, and kept afterwards: the list
       # renders every row from these bytes, so a row that has come in
       # is still a row the list has to be able to name.
-      revised[index] = edited
-      span("import.restage") { Import::Staged.update(upload, joined(revised)) }
+      span("import.restage") { restage(upload, index, edited, rows) }
       written = span("import.write") { Import::Write.call(store, edited, joins: ticked, named: wanted, everyone: syncing) }
-      walk_on(r, upload, index, written, saved, revised)
+      walk_on(r, upload, index, written, saved, rows)
+    end
+
+    # A Save's card staged in place of the one it rewrote, and its row
+    # read again off it (#import_row): `rows` comes back holding the new
+    # row, for the walk on from it.
+    #: (String upload, Integer index, VCard edited, Array[Import::Staged::Row] rows) -> void
+    def restage(upload, index, edited, rows)
+      row = import_row(edited, index, rows.fetch(index).losses)
+      Import::Staged.update(upload, index, edited.to_s, row)
+      rows[index] = row
     end
 
     # A row's Save landed, as a new contact or an updated one: the row
     # noted as saved, and the walk on from it. `saved` is the rows
-    # saved before this one, and `revised` the file's cards with this
+    # saved before this one, and `rows` the walk's list with this
     # row's save staged among them.
-    #: (untyped r, String upload, Integer index, Contact written, Hash[String, String] saved, Array[VCard] revised) -> untyped
-    def walk_on(r, upload, index, written, saved, revised)
+    #: (untyped r, String upload, Integer index, Contact written, Hash[String, String] saved, Array[Import::Staged::Row] rows) -> untyped
+    def walk_on(r, upload, index, written, saved, rows)
       Import::Staged.record(upload, index.to_s, written.id)
 
       # The last one: there is nothing left to come back to, so the
       # walk is over rather than a list of rows that all say the same
       # thing.
-      return close_walk(r, upload) if saved.length + 1 == revised.length
+      return close_walk(r, upload) if saved.length + 1 == rows.length
 
       # Onto the next row still to save below this one, in the list's
       # order (#walk_order). Below rather than first, so a row
@@ -482,7 +504,7 @@ module ProTacts
       # `first` because empty is the case the line above took, and
       # reaching it is a broken assumption rather than a screen to
       # render.
-      order = walk_order(import_contacts(revised))
+      order = walk_order(rows)
       remaining = order.reject { |i| saved.key?(i.to_s) || i == index }
       below = order.drop(order.index(index).to_i + 1)
       r.redirect "/import/#{upload}/#{below.find { remaining.include?(it) } || remaining.fetch(0)}", 303
@@ -505,25 +527,14 @@ module ProTacts
       r.redirect(filed ? "/groups/#{filed.id}" : "/contacts", 303)
     end
 
-    # The import's two readings, or none when it is gone
-    # (Import::Staged.read).
-    #
-    # The re-read parses files this already parsed, which is not a
-    # second judgment of them: the bytes are what was staged, and this
-    # is the only way back to the cards. A file that read on the way
-    # in and will not read now is a broken assumption and raises.
-    #: (String upload) -> [Array[VCard], Array[VCard]]?
-    def staged_cards(upload)
-      original = Import::Staged.read(upload, Import::Staged::ORIGINAL)
-      revised = Import::Staged.read(upload, Import::Staged::REVISED)
-      return nil if original.nil? || revised.nil?
-
-      span("import.staged") { |step|
-        step.set_attribute("bytes", original.bytesize + revised.bytesize)
-        cards = [Import::Vcf.cards(original), Import::Vcf.cards(revised)] #: [Array[VCard], Array[VCard]]
-        step.set_attribute("cards", cards[0].length)
-        cards
-      }
+    # One staged card, as it arrived or as it will be written, or none
+    # for a place past the file's last card (Import::Staged.card). Its
+    # bytes are what the upload split and judged (#stage_upload), so
+    # this is not a second judgment of them.
+    #: (String upload, String slot, Integer index) -> VCard?
+    def staged_card(upload, slot, index)
+      bytes = Import::Staged.card(upload, slot, index)
+      bytes && VCard.new(bytes)
     end
 
     # A staged card read as the contact the editor edits. The id is
@@ -541,29 +552,11 @@ module ProTacts
       Contact.new(id: index.to_s, stored: birthday ? rest : card, birthday:, inherited: [])
     end
 
-    # Every staged card as #import_contact reads it, in file order.
-    #: (Array[VCard] revised) -> Array[Contact]
-    def import_contacts(revised)
-      revised.each_with_index.map { |card, index| import_contact(card, index) }
-    end
-
     #: (Contact contact) -> bool
     def carries_own_bday?(contact)
       contact.stored.lines.any? { it.names?("BDAY") }
     end
 
-    # Cards back into a file, each keeping its own bytes. A break
-    # after any that did not carry one: a file whose last card ends
-    # without a newline is still a file, and two cards glued at
-    # END:VCARDBEGIN:VCARD is not.
-    #: (Array[VCard] cards) -> String
-    def joined(cards)
-      cards.map { |card|
-        bytes = card.to_s
-        bytes.end_with?("\n") ? bytes : "#{bytes}\r\n"
-      }.join
-    end
-
     # The file part the form sent, and none for a POST carrying no
     # file: #ids_in's shape over an upload, and its reason for being a
     # method — the empty case needs a type, and the signature is where
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 528a813..22df518 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -70,22 +70,23 @@ module ProTacts
     end
 
     class ImportTarget
-      def self.new: (row: String, matches: Array[Contact], into: Contact?) -> instance
+      def self.new: (row: String, matches: Array[Contact | ListedContact], into: Contact?) -> instance
     end
 
     class ImportSidebar
-      def self.new: (upload: String, rows: Array[[Contact, Integer, Integer]], saved: Hash[String, String], current: Integer) -> instance
+      def self.new: (upload: String, rows: Array[[String, Integer, Integer]], saved: Hash[String, String], current: Integer) -> instance
     end
 
     class ImportSaved
       def self.call: (contact: Contact, login: String, groups: Array[Group], all_groups: Array[Store::GroupChoice], aside: untyped, sidebar: untyped, edit: String, land: String, ?notice: String?) -> String
     end
 
-    # Its sort_key orders the import's list the way the listings of
-    # contacts are ordered (Web#walk_order), and its search_key is the
-    # fold Search matches on.
+    # Its sort_key and name_label are the import's list rows'
+    # (Web#import_row), ordered the way the listings of contacts are, and
+    # its search_key is the fold Search matches on.
     module Format
       def self.sort_key: (Contact | ListedContact contact) -> String
+      def self.name_label: (Contact | ListedContact contact) -> String
       def self.search_key: (String value) -> String
     end
 
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index 8177611..93947d0 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -1,6 +1,7 @@
-# Import::Vcf::Unknown and ::Reading are Data classes, whose members the
-# inline parser cannot read (see sig/pro_tacts/contact.rbs). Each is
-# marked `@rbs skip` where it is defined and signed here.
+# Import::Vcf::Unknown and ::Reading, and Import::Staged::Row, are Data
+# classes, whose members the inline parser cannot read (see
+# sig/pro_tacts/contact.rbs). Each is marked `@rbs skip` where it is
+# defined and signed here.
 module ProTacts
   module Import
     module Vcf
@@ -19,5 +20,16 @@ module ProTacts
         def self.new: (card: ::ProTacts::VCard, dropped: Array[::ProTacts::VCard::Parser::Line]) -> instance
       end
     end
+
+    module Staged
+      class Row
+        attr_reader label: String
+        attr_reader sort_key: String
+        attr_reader losses: Integer
+
+        def self.new: (label: String, sort_key: String, losses: Integer) -> instance
+        def to_h: () -> Hash[Symbol, untyped]
+      end
+    end
   end
 end
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 902c544..187ab0a 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -985,7 +985,7 @@ class AdminImportPagesTest < Minitest::Test
 
       transactions = sentry_events.grep(Sentry::TransactionEvent)
       ops = transactions.flat_map { |transaction| transaction.spans.map { it[:op] } }.grep(/\Aimport\./).uniq.sort
-      assert_equal %w[contacts match merge pare render restage sidebar split stage staged walk_order write].map { "import.#{it}" }, ops
+      assert_equal %w[contacts match merge pare render restage rows split stage write].map { "import.#{it}" }, ops
       transactions.each { refute_match(/Booles|Jane/, JSON.generate(it.to_h)) }
     end
   ensure