import: look each contact over beside the card it arrived as
Before: the review screen asked about property names. A file's unknown
properties were listed with counts and sample values, and each got a
radio pair -- leave it behind, or save it under the note. The question
was about `X-ABRELATEDNAMES` in the abstract, and the best answer it
could give back was a note line this app composed.

After: the review is a walk. Choosing the file lists the contacts it
holds, each row marked with how many of its lines are not coming in,
over a summary of which properties the whole file is losing. Opening a
row gives two cards side by side: on the left the contact exactly as
the file wrote it, content lines in monospace with every line that is
not coming in struck in the danger color and labelled "not imported";
on the right the contact editor, pre-filled with what was read. Read
the spouse's name off the left and type it into the note on the right,
in your own words. Confirm, and the cards land as the walk left them.

A list rather than every pair stacked on one page: a book is hundreds
of contacts and only some will have anything worth looking at, so the
list is what says which rows are worth opening, and a file losing
nothing but PRODID can be landed unread.

How: Vcf.read splits one card into what is coming in and what is being
left behind, which is both what Land is handed and what the left card
strikes through. The right card is Admin::ContactsEdit itself, grown
three arguments (action, back, aside) rather than copied -- a second
editor would be a place where the two could disagree. The birthday is
split out of the card and into the model there exactly as Store#put
splits it, and put back as a BDAY line on save. An import is now a
directory under data/imports with two slots, the file as it arrived
and the cards as they will land, because a card that has been edited
still has to show what it arrived as.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
change
commit 39490738f79e907e4677531351205c83c4fc4908
author Claude <noreply@anthropic.com>
date
parent 44095c1f
diff --git a/AGENTS.md b/AGENTS.md
index fd173f3..702303e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,8 +38,9 @@ rake profile:install  # Download carddav.mobileconfig from the app and
 profiles, so leave those to the user.
 
 Importing is not a task at all: a .vcf is uploaded at `/import` in the
-app, which reads it, asks what to do with the properties no screen here
-shows, and lands the cards through the store
+app, and reviewed there contact by contact — the card as the file wrote
+it beside the card that is landing, with the lines no screen here shows
+struck — before the lot is landed through the store
 (docs/plans/2026-09-21-import-a-vcf.md). The `import:*` tasks, the Swift
 reader they drove Contacts.app with, and the plan directories they wrote
 are gone.
diff --git a/Steepfile b/Steepfile
index d84c8ee..fce51b7 100644
--- a/Steepfile
+++ b/Steepfile
@@ -22,8 +22,8 @@ target :lib do
   # the files stay checked.
   ignore(*%w[
     avatar contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
-    group_dialog group_label groups_edit groups_index groups_show icons import_review import_upload
-    layout list_item phlex upcoming_birthdays
+    group_dialog group_label groups_edit groups_index groups_show icons import_original import_review
+    import_upload layout list_item phlex upcoming_birthdays
   ].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
   signature "sig"
 
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
index 56761a3..761f945 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -38,7 +38,7 @@ a two-sided sync, and there is no second side now. Deleting what you
 exported is a thing to do in Contacts.app, once you have looked at what
 landed.
 
-## Two screens, because of the question
+## What comes in
 
 A card is stored as the card it arrived as. That is this server's whole
 posture (`2026-08-24-vcard-storage-and-groups.md`), and every property
@@ -46,12 +46,13 @@ this address book reads travels through byte for byte. So there is no
 field mapping to approve on the way in, and nothing to confirm about N,
 TEL, ADR or PHOTO.
 
-There is one real question. A book exported from Contacts.app carries
-properties no screen here shows — `X-ABRELATEDNAMES`, `X-SOCIALPROFILE`,
-`X-ABADR`, `PRODID`, whatever the source thought worth writing. Those
-do not come in. A card is meant to be the contact as this app can show
-it, and a book padded with lines nothing can display is one whose cards
-nobody can read — the bytes are the least of it.
+What does not come in is everything else. A book exported from
+Contacts.app carries properties no screen here shows —
+`X-ABRELATEDNAMES`, `X-SOCIALPROFILE`, `X-ABADR`, `PRODID`, whatever
+the source thought worth writing — and a card is meant to be the
+contact as this app can show it. A book padded with lines nothing can
+display is one whose cards nobody can read; the bytes are the least of
+it.
 
 RFC 6352 section 6.3.2.2 is not in tension with that. It binds this
 server to keep what a *client* submits and does not understand, and it
@@ -60,56 +61,80 @@ and comes back out of it untouched. An import is the other direction —
 a person choosing what their own book is made of — and there the rule
 is the opposite one.
 
-So the question is narrower than it first looks: of the properties
-leaving, which are worth saving on the way past? Two choices per
-unknown property:
-
-- **leave it behind** — the default, and what anything nobody speaks
-  for gets.
-- **save it under the note** — for a value worth reading even with no
-  field to read it in. A spouse's name is worth more under the note
-  than nowhere.
-
-The question cannot be asked until the file has been read, which is why
-this is two requests rather than one. The first stages the upload and
-surveys it; the second spends the answers. Each unknown property is
-shown with how many lines wear it and a few real values out of that very
-file, so the choice is made looking at the source's own data rather than
-at a property name.
-
-The decision is per property name, not per line: `X-SOCIALPROFILE;
-type=twitter` is not a second decision from `X-SOCIALPROFILE`. A label
-Contacts hung beside a line it names (`item3.X-ABLabel`) goes wherever
-that line goes — a label naming nothing is worse than either choice —
-while a label on a line that is coming in, `item1.ADR`'s say, comes in
-with it. Under the note, the label is what the value is called, so the
-note reads `Spouse: Jane` and not `X-ABRELATEDNAMES: Jane`.
+"Known" is the reader's list, not the writer's: the envelope, the
+fields a screen shows (`Contact`'s own accessors), and BDAY, which the
+store takes into the model. Adding a screen for a property is what
+brings it in.
+
+A label Contacts hung beside a line it names (`item3.X-ABLabel`) goes
+wherever that line goes — a label naming nothing is worse than either
+outcome — while a label on a line that is coming in, `item1.ADR`'s
+say, comes in with it.
 
 One thing is kept that nothing will show: a line that would not parse
 at all. The parser hands it back without a property name, so there is
-nothing to have listed on the review screen and nothing for the
-importer to have decided; throwing it away unnamed is worse than
-letting it ride along in the card's bytes.
-
-"Known" is the reader's list, not the writer's: the envelope, the fields
-a screen shows (`Contact`'s own accessors), and BDAY, which the store
-takes into the model. Adding a screen for a property is what brings it
-in, and until then importing one would only pad the cards with what
-nobody can read.
-
-## The file waits on the server
-
-Between the two requests the bytes sit under `data/imports`, keyed by a
-minted id, and the review screen carries the id in a hidden field. Not
-the file: a book with pictures in it is tens of megabytes, and a form
-that carries it back to the server is the upload done twice. An id off a
-form is checked against the shape `ChangeId.mint` draws before it is
-made into a path — `../../contacts.db` is a filename too. The file goes
-the moment its cards land, and anything older than an hour is swept on
-the next upload.
-
-Rack's 128-part multipart limit is no longer raised in `config.ru`. A
-plan was one part per card; a `.vcf` is one part.
+nothing to show on a screen and nothing anyone could decide about it;
+throwing it away unnamed is worse than letting it ride along in the
+card's bytes.
+
+## The walk
+
+Dropping silently would be the wrong trade — the person cannot know
+what they lost — and a form of radio buttons over property names was
+the wrong remedy: it asks about `X-ABRELATEDNAMES` in the abstract,
+and the answer it can give back is a line this app composed. What the
+importer actually wants is to look at a contact and fix it.
+
+So the review is a walk, four screens rather than a submission:
+
+1. **Choose the file.**
+2. **The contacts it holds**, one row each, marked with how many of
+   that contact's lines are not coming in, over a summary of which
+   properties the whole file is losing. A book is hundreds of contacts
+   and only some of them will have anything worth looking at, so the
+   list is what says which rows are worth opening — and a file losing
+   nothing but `PRODID` can be landed unread.
+3. **One contact, two cards.** On the left, the card exactly as the
+   file wrote it: content lines in monospace, with every line that is
+   not coming in struck in Gloss's danger color and labelled "not
+   imported" beside it, a strike on its own being a color. On the
+   right, the contact editor, pre-filled with what was read and
+   pointed at the import. Read the spouse's name off the left card and
+   type it into the note on the right, in your own words.
+4. **Confirm**, and the cards land as the walk left them.
+
+The right-hand card is `Admin::ContactsEdit` itself, not a copy of it.
+A second editor for imports would be a place where the two could
+disagree — a field an import writes that an edit cannot undo — so the
+editor grew three arguments instead (`action`, `back`, `aside`) and the
+ordinary edit passes none of them. The birthday is split out of the
+card and into the model here exactly as `Store#put` splits it, so the
+same row renders over a staged card as over a stored contact, and put
+back as a BDAY line on save; a BDAY spelling the model does not read
+stays in the card untouched, that method's own rule.
+
+## The import waits on the server
+
+An import is a directory under `data/imports`, keyed by a minted id
+that every screen of the walk carries in its path. Two slots in it,
+because the review screen shows two things at once: `original.vcf`, the
+uploaded file, written once and never again, and `landing.vcf`, the
+cards as they will land — pared when the import opens and rewritten
+whole each time the editor saves one of them. A card that has been
+edited still has to show what it arrived as, which is why both.
+
+On disk rather than in the browser: a book with pictures in it is tens
+of megabytes, and a form carrying it back and forth is the upload done
+once per screen. An id off a link is checked against the shape
+`ChangeId.mint` draws before it is made into a path — `../../contacts.db`
+is a filename too. The directory goes the moment the cards land, and
+anything older than a day is swept on the next upload: long enough to
+work down a book over an evening, short enough that a closed window
+does not leave that book on disk for a week.
+
+A contact is named by its place in the file, there being no minted id
+until it lands. That holds because the editor's save rewrites a card in
+place and never adds or removes one.
 
 ## Landing
 
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 4a0839c..699318b 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -76,10 +76,24 @@ module ProTacts
       # @rbs @first: String?
       # @rbs @middle: String?
       # @rbs @last: String?
+      # @rbs @action: String
+      # @rbs @back: [String, String]
+      # @rbs @aside: Phlex::HTML?
 
-      #: (contact: Contact, ?notice: String?) -> void
-      def initialize(contact:, notice: nil)
+      # `action`, `back` and `aside` are the import's: the same editor,
+      # over a card that is not stored yet, saving into the import
+      # rather than into a contact and rendered beside the card as it
+      # was exported (Admin::ImportOriginal). One editor rather than a
+      # second one for imports, because a field the two disagreed
+      # about is a field an import writes and an edit cannot undo.
+      # Defaulted to the contact's own, so the ordinary edit says
+      # nothing about any of it.
+      #: (contact: Contact, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: Phlex::HTML?) -> void
+      def initialize(contact:, notice: nil, action: nil, back: nil, aside: nil)
         @contact = contact
+        @action = action || "/contacts/#{contact.id}"
+        @back = back || ["/contacts/#{contact.id}", contact.name || contact.id]
+        @aside = aside
         # The rows are the contact's own, never what a group lends it
         # (Contact#own); the details page is where an inherited row is
         # read (Admin::ContactsShow). The etag below is still the
@@ -98,7 +112,11 @@ module ProTacts
       end
 
       def view_template
-        render Layout.new(title: "Edit #{@contact.name || @contact.id}", notice: @notice) do
+        # Wide only when something is beside the editor: the reading
+        # width a single column wants is still what one card gets
+        # (admin.css).
+        render Layout.new(title: "Edit #{@contact.name || @contact.id}",
+                          wide: !@aside.nil?, notice: @notice) do
           # The editor's Alpine scope, wrapping the record and the add
           # dialog both: the dialog names a type and the form grows a
           # row for it, so the two have to share state, and Alpine
@@ -114,15 +132,15 @@ module ProTacts
             # do to the record from it.
             div(class: "record") do
             div(class: "record-nav") do
-              a(href: "/contacts/#{@contact.id}", class: "type-label") {
-                "‹ #{@contact.name || @contact.id}"
-              }
+              href, label = @back
+              a(href:, class: "type-label") { "‹ #{label}" }
               button(type: "button", data_size: "sm",
                      popovertarget: "add-property") { "add property" }
             end
+            paired do
             div(class: "card") do
               div(class: "card-body") do
-                form(action: "/contacts/#{@contact.id}", method: "post", class: "field-stack", id: FORM) do
+                form(action: @action, method: "post", class: "field-stack", id: FORM) do
                   input(type: "hidden", name: "etag", value: @contact.etag)
                   # The caption is an element rather than bare text
                   # because the row is a grid (admin.css): a text node
@@ -190,11 +208,12 @@ module ProTacts
               # contract, which is what an anchor that acts like a button
               # opts into.
               footer do
-                a(href: "/contacts/#{@contact.id}", class: "btn") { "Cancel" }
+                a(href: @back.fetch(0), class: "btn") { "Cancel" }
                 button(type: "submit", form: FORM, data: {variant: "primary"}) { "Save" }
               end
             end
             end
+            end
             add_property_dialog
           end
         end
@@ -202,6 +221,22 @@ module ProTacts
 
       private
 
+      # The editor's card, and what stands beside it when something
+      # does — the two-column grid the dashboard uses, for its reason
+      # (admin.css). With nothing beside it the wrapper is skipped
+      # rather than rendered around one card: a grid of one is a
+      # different element for no difference on screen.
+      #: () { () -> void } -> void
+      def paired
+        aside = @aside
+        return yield if aside.nil?
+
+        div(class: "paired") do
+          render aside
+          yield
+        end
+      end
+
       # The rows added this pass, rendered by Alpine from `added` —
       # one per type named in the dialog, in the order they were
       # asked for. Nothing stands here until something is added and
diff --git a/lib/pro_tacts/admin/import_original.rb b/lib/pro_tacts/admin/import_original.rb
new file mode 100644
index 0000000..470e259
--- /dev/null
+++ b/lib/pro_tacts/admin/import_original.rb
@@ -0,0 +1,71 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/import/vcf"
+
+module ProTacts
+  module Admin
+    # The left-hand card of an import's review: one contact exactly as
+    # the file wrote it (docs/plans/2026-09-21-import-a-vcf.md).
+    #
+    # Content lines rather than a rendered contact, and in the
+    # monospace the bytes are actually in. The card beside this one is
+    # the rendered reading — it is the editor, over what is landing —
+    # so a second rendering here would show the same thing twice and
+    # answer neither question this card is for: what did the source
+    # actually say, and what of it is not coming with it.
+    #
+    # A line that is not coming in is struck in Gloss's danger color,
+    # which is what that color means on this surface — the blanked
+    # editor row wears it for the same reason (admin.css) — and says
+    # so in words beside it, because a strike alone is a color and
+    # colors are not read the same by everyone.
+    #
+    # Nothing here is a control. What to do about a struck line is
+    # done in the editor beside it: read the spouse's name off this
+    # card and type it into the note on that one.
+    class ImportOriginal < Phlex::HTML
+      # @rbs @card: ::ProTacts::VCard
+      # @rbs @dropped: Array[String]
+
+      # `dropped` is the lines that are not coming in (Vcf::Reading),
+      # matched here by digest. Two byte-identical lines digest alike
+      # and share a fate — the reading is by property name — so there
+      # is no pair this cannot tell apart that it would want to.
+      #: (card: ::ProTacts::VCard, dropped: Array[::ProTacts::VCard::Parser::Line]) -> void
+      def initialize(card:, dropped:)
+        @card = card
+        @dropped = dropped.map(&:digest)
+      end
+
+      def view_template
+        div(class: "card") do
+          div(class: "card-body") do
+            div(class: "section-head") do
+              h2(class: "type-label") { "as exported" }
+              span(class: "type-label") { "#{@dropped.length} not imported" } if @dropped.any?
+            end
+            ul(class: "card-lines") do
+              @card.lines.each { line(it) }
+            end
+          end
+        end
+      end
+
+      private
+
+      # A blank line is not a line to show: it carries no property and
+      # says nothing about what the source held.
+      #: (::ProTacts::VCard::Parser::Line line) -> void
+      def line(line)
+        text = ::ProTacts::Import::Vcf.summary(line)
+        return if text.strip.empty?
+
+        dropped = @dropped.include?(line.digest)
+        li(**(dropped ? {data: {dropped: true}} : {})) do
+          span { text }
+          span(class: "type-label") { "not imported" } if dropped
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/import_review.rb b/lib/pro_tacts/admin/import_review.rb
index ae695e2..20dad00 100644
--- a/lib/pro_tacts/admin/import_review.rb
+++ b/lib/pro_tacts/admin/import_review.rb
@@ -1,122 +1,122 @@
 require "pro_tacts/admin/phlex"
 
+require "pro_tacts/admin/format"
 require "pro_tacts/admin/layout"
-require "pro_tacts/import/vcf"
+require "pro_tacts/admin/list_item"
 
 module ProTacts
   module Admin
-    # The second screen of an import: what the uploaded file holds, and
-    # the one decision it asks the importer to make
+    # The contacts an uploaded .vcf holds, before any of them land
     # (docs/plans/2026-09-21-import-a-vcf.md).
     #
-    # A card is stored as the card it arrived as, so nothing here is a
-    # field-by-field mapping to approve. The only question is about the
-    # properties no screen in this address book shows. Those do not
-    # come in — a book padded with what nothing can display is worse
-    # than a book without it — so the question is narrower than it
-    # looks: is this one worth saving under the note on the way past?
-    # Dropping is checked, because it is what happens to everything
-    # nobody speaks for.
+    # A list rather than every card laid open at once: a book is
+    # hundreds of contacts and only some of them will have anything
+    # worth looking at, so each row says how many of its lines are not
+    # coming in and opening one is what shows the pair of cards
+    # (Admin::ImportOriginal beside the editor). A row with nothing
+    # left behind needs no visit.
     #
-    # Each property is shown with how many lines wear it and a few real
-    # values out of this very file, so the choice is made looking at
-    # the source's own data rather than at a property name.
-    #
-    # `upload` is the staged file (Import::Staged), carried in a hidden
-    # field: the bytes stay on the server, and only the ticket to them
-    # makes the round trip.
+    # Above the list, the same fact for the whole file: which
+    # properties are not coming in, and how many lines each of them
+    # wears. It is what says whether the list is worth working down
+    # at all — a file losing nothing but PRODID can be landed unread.
     class ImportReview < Phlex::HTML
       # @rbs @upload: String
-      # @rbs @file: String
-      # @rbs @cards: Integer
+      # @rbs @rows: Array[[::ProTacts::Contact, Integer]]
       # @rbs @unknown: Array[::ProTacts::Import::Vcf::Unknown]
       # @rbs @group: String
+      # @rbs @notice: String?
 
-      # What each choice is called where it is made. The wording says
-      # what happens to the line, not what the server does with it: an
-      # importer is deciding what their own address book is made of.
-      WORDING = {
-        ::ProTacts::Import::Vcf::DROP => "leave it behind",
-        ::ProTacts::Import::Vcf::NOTE => "save it under the note",
-      }.freeze #: Hash[String, String]
-
-      #: (upload: String, file: String, cards: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String) -> void
-      def initialize(upload:, file:, cards:, unknown:, group:)
+      # `rows` is the contact each card will land as, paired with how
+      # many of its original's lines are not coming with it. `upload`
+      # is the staged import every link and the confirm carry.
+      #: (upload: String, rows: Array[[::ProTacts::Contact, Integer]], unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, ?notice: String?) -> void
+      def initialize(upload:, rows:, unknown:, group:, notice: nil)
         @upload = upload
-        @file = file
-        @cards = cards
+        @rows = rows
         @unknown = unknown
         @group = group
+        @notice = notice
       end
 
       def view_template
-        render Layout.new(title: "Import") do
+        render Layout.new(title: "Import", notice: @notice) do
           div(class: "record") do
             div(class: "record-nav") do
               a(href: "/import", class: "type-label") { "‹ another file" }
             end
-            div(class: "card") do
-              div(class: "card-body") do
-                h1(class: "type-h2", style: "margin: 0;") { @file }
-                p(class: "type-body-sm") { "#{count(@cards, "contact")}, ready to land." }
-                form(action: "/import/land", method: "post", class: "field-stack") do
-                  input(type: "hidden", name: "upload", value: @upload)
-                  group_field
-                  unknown_fields
-                  button(type: "submit", data: {variant: "primary"}) { "import #{count(@cards, "contact")}" }
-                end
-              end
-            end
+            summary_card
+            contacts_list
           end
         end
       end
 
       private
 
-      # The group everything lands in, named for the moment by default
-      # so one import can be found — or undone — apart from the next.
-      # Editable, and emptiable: a blank name puts the arrivals in
-      # nobody's group but everyone's book.
       #: () -> void
-      def group_field
-        label(class: "field") do
-          span(class: "type-label") { "group" }
-          input(type: "text", name: "group", value: @group, placeholder: "no group")
+      def summary_card
+        div(class: "card") do
+          div(class: "card-body") do
+            h1(class: "type-h2", style: "margin: 0;") { count(@rows.length, "contact") }
+            losses
+            form(action: "/import/#{@upload}/land", method: "post", class: "field-stack") do
+              # The group everything lands in, named for the moment by
+              # default so one import can be found — or undone — apart
+              # from the next. Editable, and emptiable: a blank name
+              # puts the arrivals in nobody's group but everyone's book.
+              label(class: "field") do
+                span(class: "type-label") { "group" }
+                input(type: "text", name: "group", value: @group, placeholder: "no group")
+              end
+              button(type: "submit", data: {variant: "primary"}) { "import #{count(@rows.length, "contact")}" }
+            end
+          end
         end
       end
 
+      # What the file is losing, by property rather than by line: a
+      # reader deciding whether to open any of the rows below wants to
+      # know it is `X-SOCIALPROFILE` and `PRODID` going, not that
+      # eight hundred lines are.
       #: () -> void
-      def unknown_fields
+      def losses
         if @unknown.empty?
-          p(class: "type-body-sm gl-muted") do
-            "Every property in this file is one pro-tacts reads. Nothing to decide."
-          end
+          p(class: "type-body-sm gl-muted") { "Every property in this file is one pro-tacts reads." }
           return
         end
 
         p(class: "type-body-sm") do
-          "#{count(@unknown.length, "property", "properties")} here " \
-            "#{@unknown.length == 1 ? "is one" : "are ones"} " \
-            "no screen in pro-tacts shows, so #{@unknown.length == 1 ? "it stays" : "they stay"} " \
-            "behind. Send one under the note if the value is worth reading anyway."
+          plain "No screen in pro-tacts shows "
+          plain @unknown.length == 1 ? "this property" : "these properties"
+          plain ", so they stay behind. Open a contact to read what its own card says and "
+          plain "copy across anything worth keeping."
+        end
+        ul(class: "card-lines") do
+          @unknown.each do |property|
+            li do
+              span { property.name }
+              span(class: "type-label") { count(property.count, "line") }
+            end
+          end
         end
-        @unknown.each { unknown_field(it) }
       end
 
-      #: (::ProTacts::Import::Vcf::Unknown property) -> void
-      def unknown_field(property)
-        div(class: "field") do
-          span(class: "type-label") { "#{property.name} (#{count(property.count, "line")})" }
-          ul(class: "type-body-sm gl-muted", style: "margin: 0; padding-left: 1.25em;") do
-            property.examples.each { |example| li { example } }
+      # A row per contact, marked where something is being left
+      # behind — which is the whole of what the list is for, since
+      # those are the rows worth opening.
+      #: () -> void
+      def contacts_list
+        section do
+          div(class: "section-head") do
+            h2(class: "type-label") { "contacts" }
           end
-          div(class: "field-stack", role: "radiogroup", aria_label: property.name) do
-            ::ProTacts::Import::Vcf::CHOICES.each do |choice|
-              # A label wrapping its own radio is Gloss's Radio.
-              label do
-                input(type: "radio", name: "decide[#{property.name}]", value: choice,
-                      checked: choice == ::ProTacts::Import::Vcf::DROP)
-                span { WORDING.fetch(choice) }
+          ul(class: "card") do
+            @rows.each_with_index do |(contact, dropped), index|
+              render ListItem.new(
+                href: "/import/#{@upload}/#{index}",
+                trailing: (count(dropped, "line") + " left behind" if dropped.positive?),
+              ) do
+                div { Format.name_label(contact) }
               end
             end
           end
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
index f0d010f..c4ae1cf 100644
--- a/lib/pro_tacts/admin/import_upload.rb
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -13,11 +13,9 @@ module ProTacts
     # on a Mac and carrying a plan to a host over HTTP; now it is the
     # file that Mac already knows how to export.
     #
-    # The form takes one .vcf and nothing else. What it does not ask
-    # for here is what to do with the properties this address book
-    # does not read — which of them are worth saving under the note
-    # rather than leaving behind. That question needs the file open to
-    # be worth asking, so it is the next screen's (ImportReview).
+    # The form takes one .vcf and nothing else. Looking over what is
+    # in it needs the file open, so that is the next screen's
+    # (ImportReview, and the pair of cards a row there opens).
     #
     # `landed` is the import that just happened, rendered below the
     # form that is still there to run another.
diff --git a/lib/pro_tacts/import/land.rb b/lib/pro_tacts/import/land.rb
index a207857..cf70749 100644
--- a/lib/pro_tacts/import/land.rb
+++ b/lib/pro_tacts/import/land.rb
@@ -1,26 +1,18 @@
 require "pro_tacts/change_id"
-require "pro_tacts/import/vcf"
 require "pro_tacts/vcard"
-require "pro_tacts/vcard/parser"
 
 module ProTacts
   module Import
-    # Lands an uploaded .vcf's cards in this server's store, with the
-    # importer's decisions applied on the way in
+    # Lands the cards an import has settled on in this server's store
     # (docs/plans/2026-09-21-import-a-vcf.md).
     #
-    # A card arrives a card and is stored as one: nothing here rebuilds
-    # it out of fields, so every line this address book reads travels
-    # through byte for byte. What no screen here shows does not come in
-    # — a book padded with the source's bookkeeping is a book nobody
-    # can read the cards of — and what the review screen collects is
-    # the exception to that: the properties worth saving under the note
-    # on the way past. This is where those are spent.
-    #
-    # It writes through Store#put and Store#regroup rather than through
-    # the routes those sit behind, so it is not a second way to write a
-    # card: it is the same two writes a client's PUT and the groups
-    # dialog make.
+    # What arrives here is already what is coming in: the review screen
+    # pared each card to the properties this book reads and let the
+    # importer edit the result (Vcf.read, Admin::ImportOriginal). So this
+    # step adds one thing only, the identity a stored contact has — a
+    # minted id, and the UID that spells it — and then makes the two
+    # writes a client's PUT and the groups dialog make, Store#put and
+    # Store#regroup. It is not a second way to write a card.
     #
     # Not idempotent, and cannot be: a .vcf carries no id this server
     # minted, so a second upload of the same file is a second set of
@@ -28,7 +20,6 @@ module ProTacts
     # many contacts are about to land before any of them do.
     class Land
       # @rbs @store: Store
-      # @rbs @decisions: Hash[String, String]
       # @rbs @group: String?
       # @rbs @group_ids: Hash[String, String]
 
@@ -41,19 +32,15 @@ module ProTacts
         "import-#{now.utc.strftime("%Y%m%dT%H%M%SZ")}"
       end
 
-      # `decisions` is Vcf::CHOICES by property name, upper case, as
-      # Vcf#unknown names them; a property missing from it is dropped,
-      # the choice nothing has to be said to get. `group` is the group
-      # every arrival joins, or none.
-      #: (Store store, Array[VCard] cards, decisions: Hash[String, String], ?group: String?) -> Array[Contact]
-      def self.call(store, cards, decisions:, group: nil)
-        new(store, decisions:, group:).call(cards)
+      # `group` is the group every arrival joins, or none.
+      #: (Store store, Array[VCard] cards, ?group: String?) -> Array[Contact]
+      def self.call(store, cards, group: nil)
+        new(store, group:).call(cards)
       end
 
-      #: (Store store, decisions: Hash[String, String], ?group: String?) -> void
-      def initialize(store, decisions:, group: nil)
+      #: (Store store, ?group: String?) -> void
+      def initialize(store, group: nil)
         @store = store
-        @decisions = decisions
         @group = group
         @group_ids = {} #: Hash[String, String]
       end
@@ -74,7 +61,7 @@ module ProTacts
         # `client: true` for what a client's PUT means here: a card this
         # creates joins `sync:*`, or it would be on the server and in
         # nobody's book (docs/plans/2026-09-15-client-creates-join-everyone.md).
-        @store.put(id, identified(decide(card), id), client: true)
+        @store.put(id, identified(card, id), client: true)
 
         group = @group
         @store.regroup(id, join: [group_id(group)], leave: []) if group
@@ -99,98 +86,6 @@ module ProTacts
         rest.insert(["UID:#{id}\r\n"])
       end
 
-      # The card as this book will hold it: every property it reads,
-      # byte for byte as it arrived; nothing it does not read; and,
-      # under the note, the values the importer asked to save on the
-      # way past.
-      #: (VCard card) -> VCard
-      def decide(card)
-        lines = card.lines
-        orphaned = orphaned_groups(lines)
-        notes = [] #: Array[String]
-        kept = [] #: Array[String]
-
-        lines.each do |line|
-          property = line.property
-          # A blank line, or one that would not read: no property name
-          # to have decided anything about, so the bytes travel rather
-          # than being thrown away unnamed (VCard::Parser's posture).
-          if property.nil?
-            kept << line.verbatim
-            next
-          end
-
-          unless Vcf.known?(property.name)
-            notes << noted(property, lines) if @decisions[property.name.upcase] == Vcf::NOTE
-            next
-          end
-
-          # A label whose line is gone names nothing, so it goes too.
-          next if orphaned.include?(property.group)
-
-          kept << line.verbatim
-        end
-
-        noted_into(VCard.new(kept.join), notes)
-      end
-
-      # The group prefixes (`item1.`) with nothing left in them but a
-      # label. Apple hangs a label off the line it names rather than
-      # inside it, so bringing in `item3.X-ABLabel:_$!<Spouse>!$_`
-      # without its `item3.X-ABRELATEDNAMES` would leave a name for a
-      # line that is not there. A grouped property this book reads —
-      # `item1.ADR`, say — anchors its own group, because it is coming
-      # in.
-      #: (Array[VCard::Parser::Line] lines) -> Array[String]
-      def orphaned_groups(lines)
-        anchored = {} #: Hash[String, bool]
-        lines.each do |line|
-          property = line.property
-          next if property.nil?
-
-          group = property.group
-          next if group.nil? || property.name.casecmp?(Vcf::LABEL)
-
-          anchored[group] = anchored.fetch(group, false) || Vcf.known?(property.name)
-        end
-        anchored.reject { |_group, kept| kept }.keys
-      end
-
-      # One line as a note reads it: what it was called, then what it
-      # said.
-      #: (VCard::Parser::Property property, Array[VCard::Parser::Line] lines) -> String
-      def noted(property, lines)
-        "#{label_for(property, lines)}: #{property.text.strip}"
-      end
-
-      # What to call a value under the note: the row's own label when
-      # Contacts wrote one beside it, unwrapped the way every other
-      # reader here unwraps one (VCard.unwrap), and the property's name
-      # when there is none. "Spouse: Jane" is the point of the choice;
-      # "X-ABRELATEDNAMES: Jane" would be a property name in the one
-      # place nobody wanted to read one.
-      #: (VCard::Parser::Property property, Array[VCard::Parser::Line] lines) -> String
-      def label_for(property, lines)
-        group = property.group
-        label = group && lines.filter_map { it.property }
-          .find { it.group == group && it.name.casecmp?(Vcf::LABEL) }
-        label ? VCard.unwrap(label.text) : property.name
-      end
-
-      # The noted values under the note the card already carries, one
-      # to a line. Read, joined, and re-escaped rather than spliced: a
-      # NOTE's value is escaped text (RFC 2426 section 3.6.2), so the
-      # line break between two of them is a `\n` in the value and not a
-      # line break in the card.
-      #: (VCard card, Array[String] notes) -> VCard
-      def noted_into(card, notes)
-        return card if notes.empty?
-
-        existing = card.properties.find { it.name.casecmp?("NOTE") }
-        text = [existing&.text, *notes].compact.reject(&:empty?).join("\n")
-        card.replace("NOTE", ["NOTE:#{VCard.escape(text)}\r\n"])
-      end
-
       # Looked up before it is made, so an import naming a group this
       # server already has joins that one rather than colliding with
       # its name (db/migrations/008_group_names.rb).
diff --git a/lib/pro_tacts/import/staged.rb b/lib/pro_tacts/import/staged.rb
index f80a754..62e047e 100644
--- a/lib/pro_tacts/import/staged.rb
+++ b/lib/pro_tacts/import/staged.rb
@@ -6,77 +6,103 @@ require "pro_tacts/change_id"
 
 module ProTacts
   module Import
-    # The uploaded file, held between the two halves of an import
-    # (docs/plans/2026-09-21-import-a-vcf.md). The screen cannot ask
-    # what to do with a property until it has read the file, and the
-    # answer comes back in a second request — so the bytes wait here
-    # rather than riding out to the browser and back in a hidden
-    # field, which would carry a book's pictures twice over.
+    # An import in progress, on disk between the requests that make it
+    # up (docs/plans/2026-09-21-import-a-vcf.md). A review is a walk
+    # over the contacts in a file, a screen at a time, and what the
+    # walk is reading and writing has to outlive each request.
     #
-    # 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.
+    # Two slots per import, because the review screen shows two things
+    # at once. ORIGINAL is the uploaded file, written once and never
+    # again: it is what the left-hand card renders, and a card that has
+    # been edited still has to show what it arrived as. LANDING is the
+    # cards as they will land — pared to what this book reads when the
+    # import opens (Vcf.read), and rewritten whole each time the editor
+    # saves one of them.
+    #
+    # On disk rather than in the browser: a book with pictures in it is
+    # tens of megabytes, and a form carrying it back and forth is the
+    # upload done once per screen. 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
-      # Where they wait. Beside the database rather than under it: a
-      # file here is not a card yet and nothing reads it but the
-      # request that lands it.
+      # Where they wait, a directory per import. Beside the database
+      # rather than in it: these are not cards yet, and nothing reads
+      # them but the screens of the import that wrote them.
       DIRECTORY = "imports" #: String
 
-      # How long an upload is worth keeping. Long enough to read a
-      # list of properties and think about it, short enough that a
-      # window closed on the review screen does not leave a book on
-      # disk for a week.
-      LIFETIME = 3600 #: Integer
-
-      # The bytes staged under a fresh id, which is what the review
-      # screen carries so the confirm can find them again. Minted, so
-      # the id a form hands back is checked against the one shape this
-      # writes (#path) rather than trusted as a filename.
-      #: (String bytes) -> String
-      def self.write(bytes)
-        directory = root
-        FileUtils.mkdir_p(directory)
+      ORIGINAL = "original" #: String
+      LANDING = "landing" #: String
+      SLOTS = [ORIGINAL, LANDING].freeze #: Array[String]
+
+      # How long an import is worth keeping. Long enough to work down
+      # a book's worth of contacts over an evening, and short enough
+      # that a window closed on the review screen does not leave that
+      # book on disk for a week.
+      LIFETIME = 86_400 #: Integer
+
+      # A new import, under a fresh id, which is what every screen of
+      # 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, landing: String) -> String
+      def self.open(original:, landing:)
         sweep
         id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
-        File.binwrite(directory / file_name(id), bytes)
+        directory = root / id
+        FileUtils.mkdir_p(directory)
+        File.binwrite(directory / file_name(ORIGINAL), original)
+        File.binwrite(directory / file_name(LANDING), landing)
         id
       end
 
-      # The staged bytes, or none — an upload swept out from under a
-      # review screen left open, or a confirm submitted twice, the
-      # second finding what the first removed. Ordinary enough for the
-      # screen to say so and ask for the file again.
-      #: (String id) -> String?
-      def self.read(id)
-        file = path(id)
+      # One slot's bytes, or none — an import swept out from under a
+      # screen left open overnight, or one already landed, its second
+      # confirm finding what the first removed. Ordinary enough for
+      # the screen to say so and ask for the file again.
+      #: (String id, String slot) -> String?
+      def self.read(id, slot)
+        file = path(id, slot)
         return nil if file.nil? || !file.file?
 
-        # UTF-8 because #write is only ever handed bytes the route has
-        # already judged to be text (Web#survey_upload, Web#write_card's
+        # UTF-8 because nothing is written here that the route has not
+        # already judged to be text (Web#stage_upload, Web#write_card's
         # rule); a card refuses to be made of anything else regardless
         # (VCard#initialize).
-        File.read(file, encoding: Encoding::UTF_8)
+        File.read(file.to_s, encoding: Encoding::UTF_8)
       end
 
-      # The staged file, gone: the last step of a landing, and what
-      # keeps the ordinary case from waiting on the sweep.
+      # The cards as they will land, rewritten: the editor's save, and
+      # the only write 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.
+      #: (String id, String landing) -> void
+      def self.update(id, landing)
+        file = path(id, LANDING) or raise ArgumentError, "#{id} is not an import id"
+        raise ArgumentError, "#{id} is not an import in progress" unless file.file?
+
+        File.binwrite(file.to_s, landing)
+      end
+
+      # The import, gone: the last step of a landing, and what keeps
+      # the ordinary case from waiting on the sweep.
       #: (String id) -> void
-      def self.remove(id)
-        file = path(id)
-        FileUtils.rm_f(file) if file
+      def self.close(id)
+        directory = import_root(id)
+        FileUtils.rm_rf(directory.to_s) if directory
       end
 
-      # Every upload past its LIFETIME, gone. Run on the way in rather
-      # than on a timer, because an import is the only thing that
-      # writes here and a directory nobody is adding to is not growing.
+      # Every import past its LIFETIME, gone. Run on the way in rather
+      # than on a timer, because an upload is the only thing that
+      # opens one and a directory nobody is adding to is not growing.
       #: (?now: Time) -> void
       def self.sweep(now: Time.now)
         directory = root
         return unless directory.directory?
 
-        directory.each_child do |path|
-          FileUtils.rm_f(path) if path.file? && now - path.mtime > LIFETIME
+        directory.each_child do |import|
+          FileUtils.rm_rf(import.to_s) if now - import.mtime > LIFETIME
         end
       end
 
@@ -85,22 +111,33 @@ module ProTacts
         ProTacts.config.data_dir / DIRECTORY
       end
 
-      # The file an id names, or none for an id this never wrote. The
-      # check is the whole of the path safety here: an id comes back
-      # off a form, and `../../contacts.db` is a filename too.
+      # The directory an id names, or none for an id this never wrote.
+      # The check is the whole of the path safety here: an id comes
+      # back off a link or a form, and `../../contacts.db` is a
+      # filename too.
       #: (String id) -> Pathname?
-      def self.path(id)
+      def self.import_root(id)
         return nil unless ChangeId.minted?(id)
 
-        root / file_name(id)
+        root / id
+      end
+
+      # One slot's file. The slot is checked as narrowly as the id,
+      # for the reason the id is.
+      #: (String id, String slot) -> Pathname?
+      def self.path(id, slot)
+        directory = import_root(id)
+        return nil if directory.nil? || !SLOTS.include?(slot)
+
+        directory / file_name(slot)
       end
 
-      #: (String id) -> String
-      def self.file_name(id)
-        "#{id}.vcf"
+      #: (String slot) -> String
+      def self.file_name(slot)
+        "#{slot}.vcf"
       end
 
-      private_class_method :root, :path, :file_name
+      private_class_method :root, :import_root, :path, :file_name
     end
   end
 end
diff --git a/lib/pro_tacts/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
index f3a9331..9f00cd1 100644
--- a/lib/pro_tacts/import/vcf.rb
+++ b/lib/pro_tacts/import/vcf.rb
@@ -13,10 +13,14 @@ module ProTacts
     # what nothing here will ever show. RFC 6352 section 6.3.2.2 binds
     # this server to keep what a client submits and does not understand,
     # and it does (vcard.rb); an import is the other direction, a person
-    # choosing what their own book is made of. So the reading this
-    # module owes is the split into cards and the question the importer
-    # is asked: of the properties no screen here shows, which are worth
-    # saving under the note on the way past?
+    # choosing what their own book is made of.
+    #
+    # So this module owes two readings. The split into cards, and then
+    # per card the split #read makes: what is coming in, and what is
+    # being left behind. The review screen shows both at once — the
+    # card as it was exported with the doomed lines struck, beside the
+    # card that is landing, open in the editor — so nothing is lost
+    # without having been shown first (Admin::ImportOriginal).
     module Vcf
       # A file that is not a set of vCards.
       class Invalid < StandardError; end
@@ -36,9 +40,9 @@ module ProTacts
       # read.
       # Apple's own name for a row, carried on a line of its own beside
       # the one it names (`item1.X-ABLabel:_$!<Spouse>!$_`). Known, and
-      # named apart from the rest because a decision about the line it
-      # annotates is a decision about it too: a label whose line is
-      # gone names nothing (Land#decide).
+      # named apart from the rest because what happens to the line it
+      # annotates happens to it too: a label whose line is gone names
+      # nothing (#read).
       LABEL = "X-ABLABEL" #: String
 
       KNOWN = [
@@ -46,22 +50,20 @@ module ProTacts
         LABEL,
       ].freeze #: Array[String]
 
-      # What the importer can ask for an unknown property. Dropping is
-      # the default, and what happens to anything not spoken for: a
-      # line no screen here shows is a line this book has no use for,
-      # and carrying it in would leave every card padded with the
-      # source's bookkeeping. The note is the way out for a value worth
-      # reading even with no field to read it in — a spouse's name is
-      # worth more under the note than nowhere.
-      DROP = "drop" #: String
-      NOTE = "note" #: String
-      CHOICES = [DROP, NOTE].freeze #: Array[String]
+      # What an import makes of one card: the card as it will come in,
+      # and the lines it leaves behind. Both, because the review
+      # screen shows them side by side — the original with its doomed
+      # lines struck, and the card that is actually landing, open in
+      # the editor (Admin::ImportOriginal). Signed in
+      # sig/pro_tacts/import.rbs, being a Data class.
+      # @rbs skip
+      Reading = Data.define(:card, :dropped)
 
       # One property the file carries that KNOWN does not name, across
       # every card in it: how many lines wear it, and a few of their
-      # real values, so a choice is made looking at the source's own
-      # data rather than at a property name. Signed in
-      # sig/pro_tacts/import.rbs, being a Data class.
+      # real values. The review screen's summary of what the whole
+      # file is losing, read before working down the contacts one by
+      # one. Signed in sig/pro_tacts/import.rbs, being a Data class.
       # @rbs skip
       Unknown = Data.define(:name, :count, :examples)
 
@@ -84,7 +86,7 @@ module ProTacts
           if open.nil?
             next if line.verbatim.strip.empty?
 
-            raise Invalid, "a line outside a card: #{example(line)}" unless name&.casecmp?("BEGIN")
+            raise Invalid, "a line outside a card: #{summary(line)}" unless name&.casecmp?("BEGIN")
 
             open = [line]
           else
@@ -126,7 +128,7 @@ module ProTacts
             name = property.name.upcase
             next if known?(name)
 
-            (seen[name] ||= []) << example(line)
+            (seen[name] ||= []) << summary(line)
           end
         end
         seen.sort.map { |name, examples|
@@ -135,24 +137,71 @@ module ProTacts
       end
 
       # Whether a property is one this address book reads, asked of a
-      # name as a line spells it — the one question both the survey
-      # and the landing ask (Land#decide).
+      # name as a line spells it — the one question the survey, the
+      # review screen and the landing all ask.
       #: (String name) -> bool
       def self.known?(name)
         KNOWN.include?(name.upcase)
       end
 
+      # One card as this book will hold it, beside what that costs.
+      # The kept lines are the bytes that arrived, in the order they
+      # arrived, so a card comes in as the card it was; the dropped
+      # ones are what no screen here would ever show, which is what
+      # the review screen strikes through.
+      #
+      # A line that would not read is kept. The parser hands one back
+      # without a property name, so there is nothing to have shown on
+      # a screen and nothing anyone could have decided about it, and
+      # throwing it away unnamed is worse than letting it ride in the
+      # card's bytes (vcard/parser.rb's own posture).
+      #: (VCard card) -> Reading
+      def self.read(card)
+        lines = card.lines
+        orphaned = orphaned_groups(lines)
+        kept, dropped = lines.partition { |line|
+          property = line.property
+          property.nil? || (known?(property.name) && !orphaned.include?(property.group))
+        }
+        Reading.new(card: VCard.new(kept.map(&:verbatim).join), dropped:)
+      end
+
+      # The group prefixes (`item1.`) with nothing left in them but a
+      # label. Apple hangs a label off the line it names rather than
+      # inside it, so bringing in `item3.X-ABLabel:_$!<Spouse>!$_`
+      # without its `item3.X-ABRELATEDNAMES` would leave a name for a
+      # line that is not there. A grouped property this book reads —
+      # `item1.ADR`, say — anchors its own group, because it is coming
+      # in.
+      #: (Array[VCard::Parser::Line] lines) -> Array[String]
+      def self.orphaned_groups(lines)
+        anchored = {} #: Hash[String, bool]
+        lines.each do |line|
+          property = line.property
+          next if property.nil?
+
+          group = property.group
+          next if group.nil? || property.name.casecmp?(LABEL)
+
+          anchored[group] = anchored.fetch(group, false) || known?(property.name)
+        end
+        anchored.reject { |_group, kept| kept }.keys
+      end
+
       # A line unfolded, without its terminator, cut short of a
-      # payload nobody wants to read.
+      # payload nobody wants to read — the one rendering of a line
+      # this import shows, in the survey's examples and in the review
+      # screen's reading of the original card
+      # (Admin::ImportOriginal).
       #: (VCard::Parser::Line line) -> String
-      def self.example(line)
+      def self.summary(line)
         text = VCard::Parser.unfold(line.verbatim).chomp
         return text if text.length <= EXAMPLE_LENGTH
 
         "#{text[0, EXAMPLE_LENGTH]}… (#{text.length} characters)"
       end
 
-      private_class_method :example
+      private_class_method :orphaned_groups
     end
   end
 end
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 166209e..46f24be 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -1,25 +1,27 @@
+require "pro_tacts/admin/card_form"
+require "pro_tacts/admin/contacts_edit"
+require "pro_tacts/admin/import_original"
 require "pro_tacts/admin/import_review"
 require "pro_tacts/admin/import_upload"
+require "pro_tacts/birthday"
 require "pro_tacts/import/land"
 require "pro_tacts/import/staged"
 require "pro_tacts/import/vcf"
 
 module ProTacts
   class Web < Roda
-    # The import screen: a .vcf lands its cards in this server's store
-    # (docs/plans/2026-09-21-import-a-vcf.md). It replaces the rake
-    # tasks that read Contacts.app on a Mac, built a plan directory,
-    # and carried it to a host over HTTP — a .vcf is what every
-    # address book on earth already exports, and the machine holding
-    # it is whichever one the browser is on. What comes in is what
-    # this book can show; the rest is dropped, or saved under the note
-    # where the review screen says so.
+    # The import screens: a .vcf, read, looked over contact by
+    # contact, and landed (docs/plans/2026-09-21-import-a-vcf.md).
+    # They replace the rake tasks that read Contacts.app on a Mac,
+    # built a plan directory, and carried it to a host over HTTP — a
+    # .vcf is what every address book on earth already exports, and
+    # the machine holding it is whichever one the browser is on.
     #
-    # Two requests, because the question this screen exists to ask
-    # cannot be asked until the file has been read: the first stages
-    # the upload and surveys what is in it, the second spends the
-    # answers. The file waits on the server between them
-    # (Import::Staged) rather than riding back through the browser.
+    # Four screens, because an import is a walk rather than a
+    # submission: choose the file, look down the contacts it holds,
+    # open any of them beside the card it arrived as, and confirm.
+    # What the walk reads and writes waits on the server between them
+    # (Import::Staged); nothing is in the store until the confirm.
     #
     # Under the same identity gate as every other route (web.rb),
     # which is also where the arrivals' books come from: a card this
@@ -32,27 +34,52 @@ module ProTacts
         end
 
         r.post do
-          survey_upload(r)
+          stage_upload(r)
         end
       end
 
-      # The second half, on its own path rather than the same one: two
-      # posts to /import would be told apart by which fields they
-      # carried, and a form's fields are the last thing that should
-      # decide what a request means.
-      r.post "land" do
-        land_upload(r)
+      r.on String do |upload|
+        # Before the index below, and distinguishable from one: a
+        # path segment is what says which request this is, not which
+        # fields a form happened to carry.
+        r.post "land" do
+          land_upload(r, upload)
+        end
+
+        r.is do
+          r.get do
+            review_screen(upload)
+          end
+        end
+
+        # The contact's place in the file is its name here. There is
+        # no minted id until it lands, and the file's own order is
+        # the one thing about a card that cannot change under the
+        # walk: the editor's save rewrites a card in place and never
+        # adds or removes one.
+        r.on Integer do |index|
+          r.is do
+            r.get do
+              card_screen(upload, index)
+            end
+
+            r.post do
+              save_card(r, upload, index)
+            end
+          end
+        end
       end
     end
 
     private
 
     # The upload's own POST: the file read and judged whole before
-    # anything is staged, `execute`'s old rule that a source which
-    # will not read lands nothing. Its answer is the review screen,
-    # which is where the decisions are made.
-    #: (untyped r) -> String
-    def survey_upload(r)
+    # anything is staged, the old `execute`'s rule that a source which
+    # will not read lands 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 review screen's walk.
+    #: (untyped r) -> untyped
+    def stage_upload(r)
       upload = file_in(r.params["vcf"])
       return import_screen(notice: "Choose a .vcf file to import.") if upload.nil?
 
@@ -70,58 +97,185 @@ module ProTacts
         return import_screen(notice: error.message)
       end
 
-      review_screen(
-        upload: Import::Staged.write(bytes),
-        file: name,
-        cards: cards.length,
-        unknown: Import::Vcf.unknown(cards),
+      id = Import::Staged.open(
+        original: bytes,
+        landing: joined(cards.map { Import::Vcf.read(it).card }),
       )
+      # A 303, the other writes' answer, because what follows is a
+      # walk: every screen of it is a GET a back button can revisit.
+      r.redirect "/import/#{id}", 303
     end
 
-    # The confirm: the staged file read back, the decisions the review
-    # screen collected applied, and the cards landed.
-    #
-    # The re-read parses a file this already parsed once, on the way to
-    # the review screen — not a second judgment of it, but the only way
-    # back to the cards, the bytes being what was staged. A file that
-    # read then and will not read now is a broken assumption and raises
-    # rather than being handled.
-    #: (untyped r) -> String
-    def land_upload(r)
-      id = r.params["upload"].to_s
-      bytes = Import::Staged.read(id)
-      # Swept out from under a review screen left open overnight, or a
-      # confirm submitted twice, the second finding what the first
-      # removed. The file is gone either way and only the person has
-      # another copy.
-      return import_screen(notice: "That upload is no longer here. Choose the file again.") if bytes.nil?
-
-      cards = Import::Vcf.cards(bytes)
-      group = r.params["group"].to_s.strip
-      landed = Import::Land.call(
-        store, cards,
-        decisions: decisions_in(r.params["decide"], Import::Vcf.unknown(cards)),
-        group: group.empty? ? nil : group,
+    # The contacts the file holds, each with what it is losing.
+    #: (String upload, ?notice: String?) -> String
+    def review_screen(upload, notice: nil)
+      staged = staged_cards(upload)
+      return expired_screen if staged.nil?
+
+      originals, landing = staged
+      rows = landing.each_with_index.map { |card, index|
+        # A two-element literal is an Array until something says
+        # otherwise, and an inline annotation needs its own line.
+        [import_contact(card, index), Import::Vcf.read(originals.fetch(index)).dropped.length] #: [Contact, Integer]
+      }
+
+      response["Content-Type"] = "text/html; charset=utf-8"
+      Admin::ImportReview.call(
+        upload:,
+        rows:,
+        unknown: Import::Vcf.unknown(originals),
+        group: Import::Land.default_group,
+        notice:,
+      )
+    end
+
+    # One contact, the card it arrived as beside the card that is
+    # landing. The right-hand half is the contact editor itself, not a
+    # copy of it: a field the two disagreed about would be a field an
+    # import writes and an edit cannot undo.
+    #: (String upload, Integer index, ?notice: String?) -> String?
+    def card_screen(upload, index, notice: nil)
+      staged = staged_cards(upload)
+      return expired_screen if staged.nil?
+
+      originals, landing = staged
+      original = originals[index]
+      card = landing[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.
+      return nil if original.nil? || card.nil?
+
+      response["Content-Type"] = "text/html; charset=utf-8"
+      Admin::ContactsEdit.call(
+        contact: import_contact(card, index),
+        notice:,
+        action: "/import/#{upload}/#{index}",
+        back: ["/import/#{upload}", "the import"],
+        aside: Admin::ImportOriginal.new(card: original, dropped: Import::Vcf.read(original).dropped),
       )
-      Import::Staged.remove(id)
+    end
+
+    # The editor's save, written back into the import rather than into
+    # the store: #apply_edit's shape over a card that is not a contact
+    # yet, down to the refusals, because it is the same form.
+    #: (untyped r, String upload, Integer index) -> untyped
+    def save_card(r, upload, index)
+      staged = staged_cards(upload)
+      return expired_screen if staged.nil?
+
+      _originals, landing = staged
+      card = landing[index]
+      return nil if card.nil?
+
+      contact = import_contact(card, index)
+
+      # N and FN are mandatory (RFC 2426 section 4), so a save blank
+      # throughout is refused — the toast is the backstop, the form's
+      # name pair (Admin::NamePair) being the browser's own refusal of
+      # the same.
+      first = r.params["first"].to_s.strip
+      middle = r.params["middle"].to_s.strip
+      last = r.params["last"].to_s.strip
+      return card_screen(upload, index, notice: "A contact needs a name.") if first.empty? && last.empty?
+
+      fields = r.params["birthday"]
+      birthday =
+        if fields.is_a?(Hash)
+          begin
+            Admin::CardForm.birthday(fields)
+          rescue ArgumentError
+            return card_screen(upload, index, notice: "That birthday is not a shape a date can take.")
+          end
+        else
+          contact.birthday
+        end
+
+      # The snapshot guard the editor always carries, over the staged
+      # card rather than a stored one: two tabs open on the same
+      # import are the case, and applying this save over the other
+      # one's would revert it.
+      if r.params["etag"].to_s != contact.etag
+        return card_screen(upload, index, notice: "This card changed since the page loaded; nothing was saved.")
+      end
+
+      edited = Admin::CardForm.contact_card(contact, first, middle, last, r.params)
+      # The birthday goes back into the card, an import having no
+      # model to hold one until it lands. Skipped for a card carrying
+      # a BDAY spelling the model does not read: that line stayed in
+      # the card (#import_contact, Store#put's own rule), no row
+      # rendered for it, and a replace here would delete it.
+      edited = edited.replace("BDAY", birthday ? [birthday.to_line] : []) unless carries_own_bday?(contact)
+
+      landing[index] = edited
+      Import::Staged.update(upload, joined(landing))
+      r.redirect "/import/#{upload}", 303
+    end
+
+    # The confirm: the cards as the walk left them, landed.
+    #: (untyped r, String upload) -> String
+    def land_upload(r, upload)
+      staged = staged_cards(upload)
+      return expired_screen if staged.nil?
+
+      _originals, landing = staged
+      group = r.params["group"].to_s.strip
+      landed = Import::Land.call(store, landing, group: group.empty? ? nil : group)
+      Import::Staged.close(upload)
 
       import_screen(landed:)
     end
 
-    # What the form said to do with each unknown property, read against
-    # the survey rather than trusted: the names are the ones this run
-    # found, and anything else a post carries is not a property of this
-    # file. An answer missing or unrecognized is a drop, which is what
-    # a property nobody spoke for gets either way (Import::Land#decide).
-    #: (untyped param, Array[Import::Vcf::Unknown] unknown) -> Hash[String, String]
-    def decisions_in(param, unknown)
-      unknown.to_h { |property|
-        answer = param.is_a?(Hash) ? param[property.name].to_s : ""
-        choice = Import::Vcf::CHOICES.include?(answer) ? answer : Import::Vcf::DROP
-        # A two-element literal is an Array until something says
-        # otherwise, and an inline annotation needs its own line.
-        [property.name, choice] #: [String, String]
-      }
+    # The import's two readings, or none when it is gone — swept out
+    # from under a screen left open overnight, or landed already, a
+    # second confirm finding what the first removed. Ordinary enough
+    # for the screen to say so and ask for the file again.
+    #
+    # 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)
+      landing = Import::Staged.read(upload, Import::Staged::LANDING)
+      return nil if original.nil? || landing.nil?
+
+      [Import::Vcf.cards(original), Import::Vcf.cards(landing)]
+    end
+
+    # A staged card read as the contact the editor edits. The id is
+    # its place in the file, there being no minted one until it lands,
+    # and no group lends an unlanded card a line.
+    #
+    # The birthday comes out of the card and into the model, which is
+    # the split Store#put makes on the way in
+    # (docs/plans/2026-09-11-every-birthday-in-the-model.md) — made
+    # here too, so the editor renders the same row over an import as
+    # over a contact. A BDAY the model does not read stays in the
+    # card, that method's own rule.
+    #: (VCard card, Integer index) -> Contact
+    def import_contact(card, index)
+      bdays, rest = card.extract("BDAY")
+      birthday = bdays.filter_map { it.property }.filter_map { Birthday.from_property(it) }.first
+      Contact.new(id: index.to_s, stored: birthday ? rest : card, birthday:, inherited: [])
+    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
@@ -134,20 +288,19 @@ module ProTacts
     end
 
     # The screen a file is chosen on, and the page a landing answers
-    # with. It answers with the result rather than the 303 the other
-    # writes answer with, and has to: the staged file is gone, so the
-    # re-submission a back button offers has nothing to land, and there
-    # is no other page holding what just arrived.
+    # with. The landing answers with the result rather than a 303, and
+    # has to: the staged import is gone, so the re-submission a back
+    # button offers has nothing to land, and there is no other page
+    # holding what just arrived.
     #: (?landed: Array[Contact]?, ?notice: String?) -> String
     def import_screen(landed: nil, notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
       Admin::ImportUpload.call(landed:, notice:)
     end
 
-    #: (upload: String, file: String, cards: Integer, unknown: Array[Import::Vcf::Unknown]) -> String
-    def review_screen(upload:, file:, cards:, unknown:)
-      response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::ImportReview.call(upload:, file:, cards:, unknown:, group: Import::Land.default_group)
+    #: () -> String
+    def expired_screen
+      import_screen(notice: "That import is no longer here. Choose the file again.")
     end
   end
 end
diff --git a/public/admin.css b/public/admin.css
index 6173272..21255ea 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -491,6 +491,61 @@ body {
   align-items: start;
 }
 
+/* The import review's two cards: the contact as the file wrote it
+   beside the contact that is landing, open in the editor
+   (Admin::ImportOriginal, Admin::ContactsEdit). The dashboard's own
+   columns and for its reason -- two where the viewport holds two,
+   one stacked column where it does not -- with `align-items: start`
+   so the shorter card does not stretch to match its neighbor. 22rem
+   rather than the dashboard's 20rem: the left card holds content
+   lines, which are longer than a contact's name. */
+.paired {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
+  gap: var(--gl-space-m);
+  align-items: start;
+}
+
+/* A card's content lines as the file wrote them, and the review
+   list's property names one rung up (Admin::ImportOriginal,
+   Admin::ImportReview): monospace, because these are the bytes and
+   not a rendering of them, and wrapping rather than scrolling
+   because a long value is the thing being judged. */
+.card-lines {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  gap: var(--gl-space-2xs);
+  font-family: var(--gl-font-mono);
+  font-size: var(--gl-step--1);
+  overflow-wrap: anywhere;
+}
+.card-lines > li {
+  display: flex;
+  gap: var(--gl-space-s);
+  align-items: baseline;
+  justify-content: space-between;
+}
+.card-lines > li > span:first-child {
+  min-width: 0;
+}
+
+/* A line that is not coming in, struck in the danger color a blanked
+   editor row wears (see .field[data-blank-removes] above): losing
+   something is what that color means on this surface. The words
+   beside it carry the same fact, a strike on its own being a color,
+   and they do not wrap -- the line wraps instead. */
+.card-lines > li[data-dropped] > span:first-child {
+  color: var(--gl-color-danger);
+  text-decoration: line-through;
+}
+.card-lines > li[data-dropped] > .type-label {
+  color: var(--gl-color-danger);
+  white-space: nowrap;
+}
+
 /* field.css's `form { max-width: 28rem; }` is sized for the
    settings-form pattern; the header's search is a single input, and
    flex: 1 + a narrower cap puts a deliberate 18rem box at the
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 0cbe91c..9724a28 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -10,8 +10,11 @@ module ProTacts
       def self.call: (contact: Contact, groups: Array[Store::Group], all_groups: Array[Store::Group], changes: Array[Store::Change], ?notice: String?) -> String
     end
 
+    # `aside` is a Phlex component, which has no type here: phlex
+    # ships no signatures and the views are ignored (see the
+    # Steepfile).
     class ContactsEdit
-      def self.call: (contact: Contact, ?notice: String?) -> String
+      def self.call: (contact: Contact, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: untyped) -> String
     end
 
     class Dashboard
@@ -43,7 +46,13 @@ module ProTacts
     end
 
     class ImportReview
-      def self.call: (upload: String, file: String, cards: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String) -> String
+      def self.call: (upload: String, rows: Array[[Contact, Integer]], unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, ?notice: String?) -> String
+    end
+
+    # Rendered into ContactsEdit rather than called, so it is the one
+    # view here whose `new` is what checked code reaches for.
+    class ImportOriginal
+      def self.new: (card: ::ProTacts::VCard, dropped: Array[::ProTacts::VCard::Parser::Line]) -> instance
     end
 
     # Rendered by Dashboard, not called from checked code; only
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index 729d645..8177611 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -1,6 +1,6 @@
-# Import::Vcf::Unknown is a Data class, whose members the inline parser
-# cannot read (see sig/pro_tacts/contact.rbs). It is marked `@rbs skip`
-# where it is defined and signed here.
+# 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.
 module ProTacts
   module Import
     module Vcf
@@ -11,6 +11,13 @@ module ProTacts
 
         def self.new: (name: String, count: Integer, examples: Array[String]) -> instance
       end
+
+      class Reading
+        attr_reader card: ::ProTacts::VCard
+        attr_reader dropped: Array[::ProTacts::VCard::Parser::Line]
+
+        def self.new: (card: ::ProTacts::VCard, dropped: Array[::ProTacts::VCard::Parser::Line]) -> instance
+      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 209db18..7703be3 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -18,6 +18,7 @@ class AdminImportPagesTest < Minitest::Test
     VERSION:3.0
     N:Booles;Jane;;;
     FN:Jane Booles
+    TEL;type=CELL:+1 555 0100
     item1.X-ABRELATEDNAMES:Sam Booles
     item1.X-ABLabel:_$!<Spouse>!$_
     X-SOCIALPROFILE;type=twitter:https://twitter.com/jb
@@ -43,17 +44,20 @@ class AdminImportPagesTest < Minitest::Test
   end
 
   # The file as a browser sends one, written out because Rack::Test
-  # uploads a path.
+  # uploads a path. Answers with the import's id, which every screen
+  # of the walk carries.
   def upload(bytes, name: "contacts.vcf")
     Dir.mktmpdir do |tmp|
       path = Pathname.new(tmp) / name
       path.binwrite(bytes)
       post "/import", "vcf" => Rack::Test::UploadedFile.new(path.to_s, "text/vcard", true)
     end
+    last_response.headers["location"].to_s[%r{/import/([a-z]+)}, 1]
   end
 
-  # The ticket to the staged file, as the review screen carries it.
-  def staged = last_response.body[/name="upload" value="([a-z]+)"/, 1]
+  # The snapshot guard the editor's form carries, so a test's save
+  # reads like the browser's.
+  def etag = last_response.body[/name="etag" value="([^"]+)"/, 1]
 
   def test_the_screen_asks_for_a_vcf
     get "/import"
@@ -63,104 +67,169 @@ class AdminImportPagesTest < Minitest::Test
     assert_includes last_response.body, %(<input type="file" name="vcf" accept=".vcf,text/vcard">)
   end
 
-  def test_an_upload_is_reviewed_before_anything_lands
+  def test_an_upload_is_looked_over_before_anything_lands
     with_contacts({}) do |store|
-      upload(JANE + PLAIN)
+      id = upload(JANE + PLAIN)
+
+      assert_equal 303, last_response.status
+      follow_redirect!
 
       assert_equal 200, last_response.status
-      assert_includes last_response.body, "contacts.vcf"
-      assert_includes last_response.body, "2 contacts, ready to land."
+      assert_includes last_response.body, "2 contacts"
+      assert_includes last_response.body, "Jane Booles"
+      assert_includes last_response.body, "Sam Booles"
+      assert_includes last_response.body, %(href="/import/#{id}/0")
       assert_empty store.changes
     end
   end
 
-  # The one question the screen exists to ask, with the file's own
-  # values under it so it can be answered by looking.
-  def test_the_review_asks_what_to_do_with_each_unknown_property
-    with_contacts({}) { upload(JANE) }
+  # The row says what opening it would be for: a contact losing
+  # nothing does not need a visit.
+  def test_the_list_marks_the_contacts_losing_something
+    with_contacts({}) do |_store|
+      upload(JANE + PLAIN)
+      follow_redirect!
+
+      assert_includes last_response.body, "3 lines left behind"
+      assert_equal 1, last_response.body.scan("left behind").length
+    end
+  end
+
+  # The same fact for the file as a whole, read before deciding
+  # whether the list is worth working down at all.
+  def test_the_list_names_the_properties_the_file_is_losing
+    with_contacts({}) do |_store|
+      upload(JANE)
+      follow_redirect!
+
+      assert_includes last_response.body, "X-ABRELATEDNAMES"
+      assert_includes last_response.body, "X-SOCIALPROFILE"
+      assert_includes last_response.body, "1 line"
+    end
+  end
+
+  def test_a_file_this_book_reads_whole_says_so
+    with_contacts({}) do |_store|
+      upload(PLAIN)
+      follow_redirect!
 
-    assert_includes last_response.body, "X-ABRELATEDNAMES (1 line)"
-    assert_includes last_response.body, "X-SOCIALPROFILE (1 line)"
-    assert_includes last_response.body, "item1.X-ABRELATEDNAMES:Sam Booles"
-    %w[drop note].each do |choice|
+      assert_includes last_response.body, "Every property in this file is one pro-tacts reads."
+      refute_includes last_response.body, "left behind"
+    end
+  end
+
+  # The two cards: the contact as the file wrote it, and the contact
+  # that is landing, open in the editor.
+  def test_a_contact_opens_beside_the_card_it_arrived_as
+    with_contacts({}) do |_store|
+      id = upload(JANE)
+
+      get "/import/#{id}/0"
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, %(<div class="paired">)
+      assert_includes last_response.body, "as exported"
+      # Every line of the original, struck where it is not coming in.
+      assert_includes last_response.body, "TEL;type=CELL:+1 555 0100"
       assert_includes last_response.body,
-                      %(<input type="radio" name="decide[X-SOCIALPROFILE]" value="#{choice}")
+                      %(<li data-dropped><span>X-SOCIALPROFILE;type=twitter:https://twitter.com/jb</span>)
+      assert_includes last_response.body, "not imported"
+      # And the editor, pre-filled and pointed at the import.
+      assert_includes last_response.body, %(<form action="/import/#{id}/0" method="post")
+      assert_includes last_response.body, %(value="+1 555 0100")
+      assert_includes last_response.body, %(href="/import/#{id}")
     end
-    # Nothing here will show it, so leaving it behind is what an
-    # unread form sends, and there is no third choice that keeps it.
-    assert_includes last_response.body,
-                    %(name="decide[X-SOCIALPROFILE]" value="drop" checked>)
-    refute_includes last_response.body, %(value="keep")
   end
 
-  def test_a_file_this_book_reads_whole_has_nothing_to_decide
-    with_contacts({}) { upload(PLAIN) }
+  def test_an_index_past_the_end_of_the_file_is_not_found
+    with_contacts({}) do |_store|
+      id = upload(JANE)
 
-    assert_includes last_response.body, "Nothing to decide."
-    refute_includes last_response.body, "stays behind"
-    refute_includes last_response.body, "decide["
+      get "/import/#{id}/7"
+
+      assert_equal 404, last_response.status
+    end
   end
 
-  def test_confirming_lands_the_cards_with_the_decisions_applied
+  # The point of the pair: read what is being left behind on the
+  # left, and put what matters into the card on the right.
+  def test_an_edit_is_held_against_the_contact_until_the_import_lands
     with_contacts({}) do |store|
-      upload(JANE)
+      id = upload(JANE)
+      get "/import/#{id}/0"
 
-      post "/import/land",
-           "upload" => staged,
-           "group" => "import-20260921T031655Z",
-           "decide" => {"X-ABRELATEDNAMES" => "note"}
+      post "/import/#{id}/0",
+           "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
+           "nickname" => "", "note" => "Spouse: Sam Booles"
 
-      assert_equal 200, last_response.status
-      assert_includes last_response.body, "landed (1)"
+      assert_equal 303, last_response.status
+      assert_empty store.changes
 
-      contact = store.contacts.fetch(0)
+      post "/import/#{id}/land", "group" => ""
 
-      assert_equal "Jane Booles", contact.name
-      assert_includes last_response.body, %(href="/contacts/#{contact.id}")
-      refute_includes contact.vcard.to_s, "X-SOCIALPROFILE"
-      assert_includes contact.vcard.to_s, "NOTE:Spouse: Sam Booles"
-      assert_includes store.all_groups.map(&:name), "import-20260921T031655Z"
+      assert_includes store.contacts.fetch(0).vcard.to_s, "NOTE:Spouse: Sam Booles"
+    end
+  end
+
+  # The editor's own guard, over the staged card: two tabs on one
+  # import, and the second save would revert the first.
+  def test_a_save_against_a_stale_card_is_refused
+    with_contacts({}) do |_store|
+      id = upload(JANE)
+      get "/import/#{id}/0"
+
+      post "/import/#{id}/0",
+           "etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
+           "nickname" => "", "note" => "lost"
+
+      assert_includes last_response.body, "This card changed since the page loaded"
+      refute_includes last_response.body, "lost"
     end
   end
 
-  # Leaving it behind is what nobody has to ask for, so a form
-  # submitted without touching a radio brings in only what shows.
-  def test_confirming_with_no_answers_leaves_the_unknown_behind
+  def test_confirming_lands_the_contacts_as_they_stand
     with_contacts({}) do |store|
-      upload(JANE)
+      id = upload(JANE + PLAIN)
 
-      post "/import/land", "upload" => staged, "group" => ""
+      post "/import/#{id}/land", "group" => "import-20260921T031655Z"
 
-      card = store.contacts.fetch(0).vcard.to_s
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, "landed (2)"
+      assert_equal 2, store.contacts.length
+      assert_includes store.all_groups.map(&:name), "import-20260921T031655Z"
 
-      refute_includes card, "X-SOCIALPROFILE"
-      refute_includes card, "X-ABRELATEDNAMES"
-      assert_includes card, "FN:Jane Booles"
-      assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
+      jane = store.contacts.find { it.name == "Jane Booles" }
+
+      assert_includes jane.vcard.to_s, "TEL;type=CELL:+1 555 0100"
+      refute_includes jane.vcard.to_s, "X-SOCIALPROFILE"
+      refute_includes jane.vcard.to_s, "X-ABRELATEDNAMES"
+      assert_includes last_response.body, %(href="/contacts/#{jane.id}")
     end
   end
 
-  # Swept out from under a review screen left open, or a confirm
-  # submitted twice: the file is gone and only the person has another.
-  def test_a_confirm_with_no_staged_file_lands_nothing
+  # Swept out from under a screen left open, or a confirm submitted
+  # twice: the import is gone and only the person has another copy.
+  def test_a_confirm_with_no_staged_import_lands_nothing
     with_contacts({}) do |store|
-      upload(JANE)
-      id = staged
+      id = upload(JANE)
 
-      post "/import/land", "upload" => id, "group" => ""
-      post "/import/land", "upload" => id, "group" => ""
+      post "/import/#{id}/land", "group" => ""
+      post "/import/#{id}/land", "group" => ""
 
-      assert_includes last_response.body, "That upload is no longer here."
+      assert_includes last_response.body, "That import is no longer here."
       assert_equal 1, store.contacts.length
     end
   end
 
-  # An id off a form is a filename if nothing checks it.
-  def test_an_upload_id_this_server_never_minted_reads_nothing
+  # An id off a link is a path if nothing checks it, so only the
+  # shape this server mints reaches the disk at all.
+  def test_an_import_id_this_server_never_minted_reads_nothing
     with_contacts({}) do |store|
-      post "/import/land", "upload" => "../../contacts.db", "group" => ""
+      refute ProTacts::ChangeId.minted?("notminted")
 
-      assert_includes last_response.body, "That upload is no longer here."
+      post "/import/notminted/land", "group" => ""
+
+      assert_includes last_response.body, "That import is no longer here."
       assert_empty store.changes
     end
   end
@@ -174,10 +243,11 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
-  def test_a_file_that_is_not_a_set_of_vcards_lands_nothing
+  def test_a_file_that_is_not_a_set_of_vcards_stages_nothing
     with_contacts({}) do |store|
       upload("hello\r\n")
 
+      assert_equal 200, last_response.status
       assert_includes last_response.body, "a line outside a card"
       assert_empty store.changes
     end
@@ -186,7 +256,7 @@ class AdminImportPagesTest < Minitest::Test
   # Web#write_card's rule over the one other body this app reads:
   # bytes that are not the text they claim to be are refused where
   # they are read, rather than at the insert that would 500 on them.
-  def test_a_file_that_is_not_utf_8_lands_nothing
+  def test_a_file_that_is_not_utf_8_stages_nothing
     with_contacts({}) do |store|
       upload(JANE.b.sub("Jane".b, "Jan\xFF".b))
 
diff --git a/test/pro_tacts/import/test_land.rb b/test/pro_tacts/import/test_land.rb
index 09e5adf..7fd4138 100644
--- a/test/pro_tacts/import/test_land.rb
+++ b/test/pro_tacts/import/test_land.rb
@@ -3,8 +3,8 @@ require_relative "../../test_helper"
 require "pro_tacts/import/land"
 require "pro_tacts/import/vcf"
 
-# Landing an uploaded .vcf's cards, with the importer's decisions
-# spent on the way in (docs/plans/2026-09-21-import-a-vcf.md).
+# Landing the cards an import has settled on
+# (docs/plans/2026-09-21-import-a-vcf.md).
 class ImportLandTest < Minitest::Test
   include ThrowawayContacts
 
@@ -17,97 +17,32 @@ class ImportLandTest < Minitest::Test
     N:Booles;Jane;;;
     FN:Jane Booles
     NOTE:Met at work
-    item1.ADR;type=HOME:;;1 Main St;Springfield;;;
-    item1.X-ABLabel:_$!<Home>!$_
-    item2.X-ABRELATEDNAMES:Sam Booles
-    item2.X-ABLabel:_$!<Spouse>!$_
-    X-SOCIALPROFILE;type=twitter:https://twitter.com/jb
     UID:ABC-123
     END:VCARD
   CARD
 
-  def land(decisions, cards: Vcf.cards(JANE), group: nil)
+  def land(cards: Vcf.cards(JANE), group: nil)
     with_contacts({}) do |store|
-      landed = Land.call(store, cards, decisions:, group:)
-      yield landed, store
+      yield Land.call(store, cards, group:), store
     end
   end
 
-  # Nothing rebuilds a card out of fields: every line this book reads
-  # is the bytes that arrived, minus the id this server gives it.
-  def test_what_this_book_reads_travels_with_the_card
-    land({}) do |landed, _store|
+  # What arrives here is already what is coming in, so nothing is
+  # pared, rewritten or rebuilt but the card's identity.
+  def test_the_card_lands_as_it_was_handed_over
+    land do |landed, _store|
       card = landed.fetch(0).vcard.to_s
 
       assert_includes card, "N:Booles;Jane;;;\r\n"
       assert_includes card, "FN:Jane Booles\r\n"
       assert_includes card, "NOTE:Met at work\r\n"
-      assert_includes card, "item1.ADR;type=HOME:;;1 Main St;Springfield;;;\r\n"
-    end
-  end
-
-  # A grouped property this book reads keeps the label that names it:
-  # the row is coming in, so the name for it is too.
-  def test_a_label_on_a_line_that_comes_in_comes_with_it
-    land({}) do |landed, _store|
-      assert_includes landed.fetch(0).vcard.to_s, "item1.X-ABLabel:_$!<Home>!$_\r\n"
-    end
-  end
-
-  # Nobody has to say so: a property no screen here shows is not
-  # carried in, and the label that named it goes with it.
-  def test_a_property_no_screen_shows_does_not_come_in
-    land({}) do |landed, _store|
-      card = landed.fetch(0).vcard.to_s
-
-      refute_includes card, "X-SOCIALPROFILE"
-      refute_includes card, "X-ABRELATEDNAMES"
-      refute_includes card, "_$!<Spouse>!$_"
-    end
-  end
-
-  # Saying so explicitly is the same thing.
-  def test_dropping_is_what_a_drop_asks_for
-    land({"X-SOCIALPROFILE" => Vcf::DROP}) do |landed, _store|
-      refute_includes landed.fetch(0).vcard.to_s, "X-SOCIALPROFILE"
-    end
-  end
-
-  # The note is the way out for a value worth reading even with no
-  # field to read it in. It is called what the row was called, not
-  # what the property was named.
-  def test_a_noted_property_is_written_under_the_note_by_its_label
-    land({"X-ABRELATEDNAMES" => Vcf::NOTE}) do |landed, _store|
-      card = landed.fetch(0).vcard.to_s
-
-      refute_includes card, "X-ABRELATEDNAMES"
-      refute_includes card, "_$!<Spouse>!$_"
-      # One NOTE, the card's own and the addition, joined by the
-      # escape a line break inside a text value is written as.
-      assert_includes card, "NOTE:Met at work\\nSpouse: Sam Booles\r\n"
-      assert_equal 1, card.scan("NOTE:").length
-    end
-  end
-
-  def test_a_noted_property_with_no_label_is_written_under_its_own_name
-    land({"X-SOCIALPROFILE" => Vcf::NOTE}) do |landed, _store|
-      assert_includes landed.fetch(0).vcard.to_s,
-                      "NOTE:Met at work\\nX-SOCIALPROFILE: https://twitter.com/jb\r\n"
-    end
-  end
-
-  def test_a_card_with_no_note_gets_one
-    cards = Vcf.cards(JANE.sub("NOTE:Met at work\r\n", ""))
-
-    land({"X-ABRELATEDNAMES" => Vcf::NOTE}, cards:) do |landed, _store|
-      assert_includes landed.fetch(0).vcard.to_s, "NOTE:Spouse: Sam Booles\r\n"
     end
   end
 
   # A minted id, and the UID spelling it: the source's own UID named a
   # record in a book this is not (rake contacts:reid's whole subject).
   def test_the_card_lands_under_a_minted_id_its_uid_spells
-    land({}) do |landed, store|
+    land do |landed, store|
       id = landed.fetch(0).id
 
       assert ProTacts::ChangeId.minted?(id)
@@ -120,7 +55,7 @@ class ImportLandTest < Minitest::Test
   # A card this creates joins everyone's book the way a client's
   # create does, and the import's own group besides.
   def test_arrivals_join_the_import_group_and_everyones_book
-    land({}, cards: Vcf.cards(JANE + JANE), group: "import-20260921T031655Z") do |landed, store|
+    land(cards: Vcf.cards(JANE + JANE), group: "import-20260921T031655Z") do |landed, store|
       ids = landed.map(&:id)
 
       assert_equal 2, ids.uniq.length
@@ -139,7 +74,7 @@ class ImportLandTest < Minitest::Test
     with_contacts({}) do |store|
       existing = store.create_group(name: "import-20260921T031655Z")
 
-      landed = Land.call(store, Vcf.cards(JANE + JANE), decisions: {}, group: "import-20260921T031655Z")
+      landed = Land.call(store, Vcf.cards(JANE + JANE), group: "import-20260921T031655Z")
 
       assert_equal landed.map(&:id).sort, store.group(existing).members.sort
       assert_equal 1, store.all_groups.count { it.name == "import-20260921T031655Z" }
@@ -147,7 +82,7 @@ class ImportLandTest < Minitest::Test
   end
 
   def test_no_group_name_lands_the_cards_in_everyones_book_alone
-    land({}) do |landed, store|
+    land do |landed, store|
       assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
       assert_equal landed.map(&:id), store.all_groups.fetch(0).members
     end
diff --git a/test/pro_tacts/import/test_vcf.rb b/test/pro_tacts/import/test_vcf.rb
index f067dbc..86237a6 100644
--- a/test/pro_tacts/import/test_vcf.rb
+++ b/test/pro_tacts/import/test_vcf.rb
@@ -14,8 +14,10 @@ class ImportVcfTest < Minitest::Test
     N:Booles;Jane;;;
     FN:Jane Booles
     TEL;type=CELL;type=VOICE;type=pref:+1 555 0100
-    item1.X-ABRELATEDNAMES:Sam Booles
-    item1.X-ABLabel:_$!<Spouse>!$_
+    item1.ADR;type=HOME:;;1 Main St;Springfield;;;
+    item1.X-ABLabel:_$!<Home>!$_
+    item2.X-ABRELATEDNAMES:Sam Booles
+    item2.X-ABLabel:_$!<Spouse>!$_
     X-SOCIALPROFILE;type=twitter:https://twitter.com/jb
     UID:ABC-123
     END:VCARD
@@ -129,4 +131,48 @@ class ImportVcfTest < Minitest::Test
 
     assert_equal "X-SOCIALPROFILE;type=twitter:https://twitter.com/jb", example
   end
+  # One card as this book will hold it: the lines it reads, in the
+  # bytes and the order they arrived.
+  def test_reading_a_card_keeps_what_a_screen_here_shows
+    landing = Vcf.read(Vcf.cards(JANE).fetch(0)).card.to_s
+
+    assert_includes landing, "N:Booles;Jane;;;\r\n"
+    assert_includes landing, "TEL;type=CELL;type=VOICE;type=pref:+1 555 0100\r\n"
+    assert_includes landing, "item1.ADR;type=HOME:;;1 Main St;Springfield;;;\r\n"
+    assert_includes landing, "UID:ABC-123\r\n"
+    # Still a card: the envelope is read like anything else.
+    assert Vcf.cards(landing).fetch(0).card?
+  end
+
+  def test_reading_a_card_leaves_behind_what_no_screen_shows
+    reading = Vcf.read(Vcf.cards(JANE).fetch(0))
+
+    refute_includes reading.card.to_s, "X-SOCIALPROFILE"
+    refute_includes reading.card.to_s, "X-ABRELATEDNAMES"
+    assert_includes reading.dropped.map { Vcf.summary(it) },
+                    "X-SOCIALPROFILE;type=twitter:https://twitter.com/jb"
+  end
+
+  # Apple hangs a row's label off the line it names rather than
+  # inside it, so a label whose line is leaving has nothing left to
+  # name — while the label on a line that is coming in comes with it.
+  def test_a_label_goes_wherever_the_line_it_names_goes
+    reading = Vcf.read(Vcf.cards(JANE).fetch(0))
+
+    assert_includes reading.card.to_s, "item1.X-ABLabel:_$!<Home>!$_\r\n"
+    refute_includes reading.card.to_s, "_$!<Spouse>!$_"
+    assert_includes reading.dropped.map { Vcf.summary(it) }, "item2.X-ABLabel:_$!<Spouse>!$_"
+  end
+
+  # There is no property name to have shown on a screen and nothing
+  # anyone could have decided about it, so it rides along rather than
+  # being thrown away unnamed.
+  def test_a_line_that_will_not_read_is_kept
+    broken = JANE.sub("FN:Jane Booles\r\n", "FN:Jane Booles\r\nnot a content line\r\n")
+
+    reading = Vcf.read(Vcf.cards(broken).fetch(0))
+
+    assert_includes reading.card.to_s, "not a content line\r\n"
+    assert_empty reading.dropped.select { it.property.nil? }
+  end
 end