import: take a .vcf, and ask what to do with what it carries
Before: importing meant rake tasks on a Mac -- a Swift helper reading
Contacts.app, a plan directory on disk with a status per contact, an
upload of that plan's cards, and a second task to delete the originals.
The reader decided what a contact could carry, and the plan was a ledger
whose only reader was the machinery that wrote it.

After: /import takes one .vcf, the file Contacts.app already exports.
The screen reads it, says how many contacts are in it, and lists every
property no screen here shows -- with how many lines wear it and a few
real values out of that very file. Each one is kept (the default, which
loses nothing), dropped, or written under the note. Confirming lands the
cards in a group named for the moment.

A card is stored as the card it arrived as, so there is no field mapping
to approve: the one real question is what to do with the properties this
address book will not display, and that is what the review screen asks.
A label Contacts hung beside a line it names goes wherever that line
goes, and under the note the label is what the value is called -- the
note reads "Spouse: Jane", not "X-ABRELATEDNAMES: Jane".

How: Import::Vcf splits the upload into cards and surveys the properties
KNOWN does not name. The file waits under data/imports between the two
requests, keyed by a minted id the review screen carries in a hidden
field, checked against ChangeId's shape before it is made into a path.
Import::Land mints an id per card, rewrites the UID to spell it, spends
the decisions, and writes through Store#put and Store#regroup. The rake
tasks, the Swift reader, the plan machinery and finalize are gone, and
config.ru no longer raises Rack's multipart part limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
change
commit bb9ac55895d044210a516f85448b1ab1838d523e
author Claude <noreply@anthropic.com>
date
parent 3579ef17
diff --git a/AGENTS.md b/AGENTS.md
index 73d6c1e..fd173f3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,16 +30,6 @@ rake dev              # Dev server, seeded from test/fixtures/cards into a
 rake console          # irb with the store open on the configured database
                       # (console.rb loads it), after db:dump commits a
                       # safety net for what the session might do there
-rake import:macos:plan # Plan importing this Mac's iCloud contacts
-                      # (LIMIT=n; reads Contacts, so leave it to the user)
-rake import:macos:finalize # Finalize the contacts the oldest
-                      # unfinished plan landed, taking them off this
-                      # Mac and filing the plan away done (host in
-                      # data/import/config.yml; PLAN=dir for another;
-                      # writes Contacts, so leave it to the user)
-rake import:status    # Summarize the plans in data/import — in
-                      # flight and filed away done — and what would
-                      # carry the next one further
 rake profile:install  # Download carddav.mobileconfig from the app and
                       # stage it for approval
 ```
@@ -47,10 +37,12 @@ rake profile:install  # Download carddav.mobileconfig from the app and
 `rake profile:*` needs `PRO_TACTS_HOSTNAME` and touches installed system
 profiles, so leave those to the user.
 
-An import's middle step is not a task: a plan's cards are uploaded at
-`/import` in the app, which lands them through the store
-(docs/plans/2026-09-20-import-by-upload.md). `rake import:execute` was
-that step and is gone.
+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
+(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.
 
 ## Task management
 
@@ -70,9 +62,9 @@ lib/pro_tacts/
 ├── web/contacts.rb # The card browser and its editor
 ├── web/groups.rb   # The group screens and their editor
 ├── web/setup.rb    # The device setup screen and its profile
-├── web/import.rb   # The import screen: a plan's cards, uploaded
+├── web/import.rb   # The import screen: a .vcf, uploaded and decided
 ├── web/api.rb      # JSON for scripts: the group list
-├── import/         # Import plans, and landing one in the store
+├── import/         # Reading an uploaded .vcf, and landing its cards
 ├── admin/          # The Phlex views those screens render, plus
 │                   # card_form.rb, which reads a form back into a card
 ├── store.rb        # Sequel over SQLite: cards, change log, derived index
diff --git a/Steepfile b/Steepfile
index 2c434f5..d84c8ee 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 layout list_item phlex
-    plan_upload upcoming_birthdays
+    group_dialog group_label groups_edit groups_index groups_show icons import_review import_upload
+    layout list_item phlex upcoming_birthdays
   ].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
   signature "sig"
 
diff --git a/config.ru b/config.ru
index 965c93d..5d7615c 100644
--- a/config.ru
+++ b/config.ru
@@ -3,7 +3,6 @@
 # require time, so requiring the app has no side effects.
 require "pathname"
 require "fileutils"
-require "rack"
 require "sentry-ruby"
 
 $LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
@@ -51,17 +50,6 @@ Sentry.init do |sentry|
   sentry.traces_sample_rate = 1.0
 end
 
-# Rack refuses a multipart body of more than 128 parts by default, and
-# the import screen is handed one part per card in a plan -- 443 for
-# this Mac's iCloud book, and no plan is split to suit a parser. The
-# limit guards a public endpoint against a cheap body; this app is
-# reachable only over the tailnet, by a few family members, all trusted
-# (README, the simplifying assumptions), so raising it spends nothing
-# that was being protected. Here rather than beside the route, because
-# Rack reads it when it parses and requiring the app has no side
-# effects.
-Rack::Utils.multipart_part_limit = 2048
-
 ProTacts::Web.plugin :common_logger, $stderr
 
 run ProTacts::Web.freeze.app
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
new file mode 100644
index 0000000..7e5b89c
--- /dev/null
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -0,0 +1,120 @@
+# Importing a .vcf
+
+2026-09-21. Importing is one upload of one file. `/import` takes a
+`.vcf`, says what is in it, asks what to do with the properties no
+screen here shows, and lands the cards through the store. Everything
+that came before it — `import:macos:plan`, `import:macos:finalize`,
+`import:status`, the Swift reader they drove Contacts.app with, and the
+plan directories under `data/import` — is gone.
+
+## Why the plan machinery went
+
+Yesterday's change (`2026-09-20-import-by-upload.md`) moved the middle
+step of an import into the app and left the two ends on a Mac: a rake
+task read Contacts.app through a Swift helper and wrote a plan
+directory, the plan's cards were uploaded, and a second task went back
+to delete the originals. That kept three things alive for one import:
+
+- **A reader for one application.** `script/macos-contacts.swift` spoke
+  to the Contacts framework, and everything downstream was shaped by
+  what it chose to hand over. A contact could only carry what the
+  reader knew to ask for.
+- **A plan on disk.** Directories under `data/import`, a status per
+  contact, a format version, and the rules for moving a plan from
+  active to done — a ledger whose only reader was the machinery that
+  wrote it.
+- **A checkout on the Mac.** The repository, the gems and a Ruby, on a
+  machine whose part in the import was holding an address book.
+
+A `.vcf` replaces all three. Contacts.app exports one (File, Export,
+Export vCard), and so does every other address book worth importing
+from. The export is the reading, done by the application that owns the
+data, and the file is the whole of what has to travel.
+
+What goes with it is the delete: nothing takes the originals off the
+Mac any more. Finalize's check — ask the host, contact by contact,
+whether it has the card, then delete what it has — was the safe half of
+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
+
+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`): RFC 6352 section
+6.3.2.2 requires a server to keep what it does not understand, and this
+one keeps it 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. Kept,
+they travel with the card and come back out of it untouched; they are
+simply invisible in this app. That is the right default and it is not
+always what is wanted: some of it is the source's bookkeeping, and some
+of it is worth reading even with no field to read it in.
+
+So the importer gets three choices per unknown property:
+
+- **keep it in the card** — the default, and the one that loses nothing.
+- **drop it** — for what the source keeps and this book has no use for.
+- **write it under the note** — for a value worth reading anyway. 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 book'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.
+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 takes it
+off the list of things to decide about.
+
+## 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.
+
+## Landing
+
+`Import::Land` writes through `Store#put` and `Store#regroup` rather
+than through the routes those two sit behind. 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:
+
+1. Mint an id, and give the card the UID that spells it. The source's
+   own UID names a record in a book this is not, and a contact with two
+   spellings of its identity is what `rake contacts:reid` exists to
+   undo.
+2. `Store#put` with `client: true`, so a card this creates joins
+   everyone's book the way a client's create does
+   (`2026-09-15-client-creates-join-everyone.md`).
+3. `Store#regroup` into the import's group, made if it is missing. The
+   group is named `import-<timestamp>` by default and can be renamed or
+   emptied on the review screen: what landed together can be found
+   together, and undone together.
+
+Importing is 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
+contacts. The review screen is what stands in for the `If-None-Match:
+*` the old PUT sent: it says how many contacts are about to land, and
+under what group name, before any of them do.
diff --git a/lib/pro_tacts/admin/import_review.rb b/lib/pro_tacts/admin/import_review.rb
new file mode 100644
index 0000000..67a6d21
--- /dev/null
+++ b/lib/pro_tacts/admin/import_review.rb
@@ -0,0 +1,134 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/layout"
+require "pro_tacts/import/vcf"
+
+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
+    # (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: keep them (they
+    # are served back untouched, just invisible here), drop them, or
+    # write them under the note where they can at least be read. Keep
+    # is checked, because it is the choice that loses nothing.
+    #
+    # 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 book'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.
+    class ImportReview < Phlex::HTML
+      # @rbs @upload: String
+      # @rbs @file: String
+      # @rbs @cards: Integer
+      # @rbs @unknown: Array[::ProTacts::Import::Vcf::Unknown]
+      # @rbs @group: 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 about their own address book, not about
+      # RFC 6352's rule for a property a server does not understand.
+      WORDING = {
+        ::ProTacts::Import::Vcf::KEEP => "keep it in the card",
+        ::ProTacts::Import::Vcf::DROP => "drop it",
+        ::ProTacts::Import::Vcf::NOTE => "write 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:)
+        @upload = upload
+        @file = file
+        @cards = cards
+        @unknown = unknown
+        @group = group
+      end
+
+      def view_template
+        render Layout.new(title: "Import") 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
+          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")
+        end
+      end
+
+      #: () -> void
+      def unknown_fields
+        if @unknown.empty?
+          p(class: "type-body-sm gl-muted") do
+            "Every property in this file is one this address book reads. Nothing to decide."
+          end
+          return
+        end
+
+        p(class: "type-body-sm") do
+          "#{count(@unknown.length, "property", "properties")} here " \
+            "#{@unknown.length == 1 ? "is one" : "are ones"} " \
+            "no screen in this address book shows. Kept, they travel with the card and come back out " \
+            "of it untouched; they are just not on display anywhere."
+        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 } }
+          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::KEEP)
+                span { WORDING.fetch(choice) }
+              end
+            end
+          end
+        end
+      end
+
+      # A count with its noun, the plural spelled out where an "s" on
+      # the end will not do it.
+      #: (Integer number, String noun, ?String? plural) -> String
+      def count(number, noun, plural = nil)
+        number == 1 ? "#{number} #{noun}" : "#{number} #{plural || "#{noun}s"}"
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
new file mode 100644
index 0000000..2c4a9c0
--- /dev/null
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -0,0 +1,92 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/layout"
+require "pro_tacts/admin/list_item"
+
+module ProTacts
+  module Admin
+    # GET /import — the screen an address book arrives on, and the page
+    # a landing answers with (docs/plans/2026-09-21-import-a-vcf.md).
+    # Until this existed an import meant rake tasks reading Contacts.app
+    # 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 — that question needs the file open to be worth asking,
+    # so it is the next screen's (ImportReview).
+    #
+    # `landed` is the import that just happened, rendered below the
+    # form that is still there to run another.
+    class ImportUpload < Phlex::HTML
+      # @rbs @landed: Array[Contact]?
+      # @rbs @notice: String?
+
+      #: (?landed: Array[Contact]?, ?notice: String?) -> void
+      def initialize(landed: nil, notice: nil)
+        @landed = landed
+        @notice = notice
+      end
+
+      def view_template
+        render Layout.new(title: "Import", notice: @notice) do
+          div(class: "record") do
+            div(class: "record-nav") do
+              a(href: "/", class: "type-label") { "‹ contacts" }
+            end
+            upload_card
+            landed_list if @landed
+          end
+        end
+      end
+
+      private
+
+      #: () -> void
+      def upload_card
+        div(class: "card") do
+          div(class: "card-body") do
+            h1(class: "type-h2", style: "margin: 0;") { "Import contacts" }
+            p(class: "type-body-sm") do
+              "Choose a vCard file — Contacts exports one with File, Export, Export vCard. " \
+                "The next screen says what is in it before anything lands."
+            end
+            form(action: "/import", method: "post", enctype: "multipart/form-data", class: "field-stack") do
+              label(class: "field") do
+                span(class: "type-label") { "vcard file" }
+                input(type: "file", name: "vcf", accept: ".vcf,text/vcard")
+              end
+              button(type: "submit", data: {variant: "primary"}) { "read the file" }
+            end
+          end
+        end
+      end
+
+      # What landed, contact by contact, each linked to the record it
+      # became — so the next thing to do, reading one or fixing one, is
+      # a click away.
+      #: () -> void
+      def landed_list
+        landed = @landed or return
+
+        section do
+          div(class: "section-head") do
+            h2(class: "type-label") { "landed (#{landed.length})" }
+          end
+          ul(class: "card") do
+            landed.each do |contact|
+              render ListItem.new(
+                href: "/contacts/#{contact.id}",
+                avatar: Avatar.new(contact:, size: "lg"),
+              ) do
+                div { Format.name_label(contact) }
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 0e26368..de9376c 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -121,9 +121,9 @@ module ProTacts
             # search over contacts and /setup is not a contact — a
             # header link would put it beside the search on every
             # screen, at the weight the app's own name has. Import is
-            # here for the same reason and is rarer still: a plan
-            # arrives a handful of times in a book's life
-            # (docs/plans/2026-09-20-import-by-upload.md).
+            # here for the same reason and is rarer still: an address
+            # book arrives a handful of times in a book's life
+            # (docs/plans/2026-09-21-import-a-vcf.md).
             footer(class: "admin-footer") do
               version = ProTacts.config.version
               span(class: "type-label") { version } if version
diff --git a/lib/pro_tacts/admin/plan_upload.rb b/lib/pro_tacts/admin/plan_upload.rb
deleted file mode 100644
index 5c609fd..0000000
--- a/lib/pro_tacts/admin/plan_upload.rb
+++ /dev/null
@@ -1,102 +0,0 @@
-require "pro_tacts/admin/phlex"
-
-require "pro_tacts/admin/avatar"
-require "pro_tacts/admin/format"
-require "pro_tacts/admin/layout"
-require "pro_tacts/admin/list_item"
-
-module ProTacts
-  module Admin
-    # GET /import — the screen a plan lands from, and the page its
-    # upload answers with (docs/plans/2026-09-20-import-by-upload.md).
-    # Until this existed a plan was carried by `rake import:execute`,
-    # which had to run somewhere holding the plan on disk, with a host
-    # configured to aim at; here the machine holding the plan is
-    # whichever one the browser is on, and the host is the one being
-    # looked at.
-    #
-    # PlanUpload rather than Import, which is the module doing the
-    # landing: this is the one screen of it, named for what a person
-    # does there, the way DeviceSetup is.
-    #
-    # The form asks for the card files rather than the whole plan
-    # directory: a card names the id it lands under in its filename and
-    # the groups it joins in its own text, so `cards/` is the whole of
-    # what this server needs. `plan.yml` stays the Mac's ledger, for
-    # `import:macos:finalize` to read.
-    #
-    # `arrivals` is the run that just happened, rendered below the form
-    # that is still there to run another.
-    class PlanUpload < Phlex::HTML
-      # @rbs @arrivals: Array[::ProTacts::Import::Land::Arrival]?
-      # @rbs @notice: String?
-
-      #: (?arrivals: Array[::ProTacts::Import::Land::Arrival]?, ?notice: String?) -> void
-      def initialize(arrivals: nil, notice: nil)
-        @arrivals = arrivals
-        @notice = notice
-      end
-
-      def view_template
-        render Layout.new(title: "Import", notice: @notice) do
-          div(class: "record") do
-            div(class: "record-nav") do
-              a(href: "/", class: "type-label") { "‹ contacts" }
-            end
-            upload_card
-            arrivals_list if @arrivals
-          end
-        end
-      end
-
-      private
-
-      #: () -> void
-      def upload_card
-        div(class: "card") do
-          div(class: "card-body") do
-            h1(class: "type-h2", style: "margin: 0;") { "Import a plan" }
-            p(class: "type-body-sm") do
-              "Choose every card in a plan's cards directory. Each one lands as the " \
-                "contact its file describes, in the groups that file names."
-            end
-            form(action: "/import", method: "post", enctype: "multipart/form-data", class: "field-stack") do
-              label(class: "field") do
-                span(class: "type-label") { "cards" }
-                input(type: "file", name: "cards[]", multiple: true, accept: ".yml")
-              end
-              button(type: "submit", data_variant: "primary") { "import" }
-            end
-          end
-        end
-      end
-
-      # What the upload did, contact by contact: a row per card, linked
-      # to the record it landed as, so the next thing to do — reading
-      # one, fixing one — is a click away. A card already here says so
-      # rather than being left out: "nothing happened to this one" is
-      # the answer a second upload of the same plan is owed.
-      #: () -> void
-      def arrivals_list
-        arrivals = @arrivals or return
-
-        section do
-          div(class: "section-head") do
-            h2(class: "type-label") { "landed (#{arrivals.count(&:stored)} of #{arrivals.length})" }
-          end
-          ul(class: "card") do
-            arrivals.each do |arrival|
-              render ListItem.new(
-                href: "/contacts/#{arrival.contact.id}",
-                avatar: Avatar.new(contact: arrival.contact, size: "lg"),
-                trailing: ("already here" unless arrival.stored),
-              ) do
-                div { Format.name_label(arrival.contact) }
-              end
-            end
-          end
-        end
-      end
-    end
-  end
-end
diff --git a/lib/pro_tacts/import/card.rb b/lib/pro_tacts/import/card.rb
deleted file mode 100644
index b92ccfd..0000000
--- a/lib/pro_tacts/import/card.rb
+++ /dev/null
@@ -1,206 +0,0 @@
-require "base64"
-require "yaml"
-
-require "pro_tacts/admin/card_form"
-require "pro_tacts/birthday"
-require "pro_tacts/contact"
-require "pro_tacts/vcard"
-
-module ProTacts
-  module Import
-    # One contact in a plan, as the file a person edits before execute:
-    # the fields the web editor has, and the groups it belongs to
-    # (docs/plans/2026-09-16-importing-from-macos.md, "A card is a form
-    # filled in"). Signed in sig/pro_tacts/import.rbs, being a Data class.
-    # @rbs skip
-    Card = Data.define(:first, :last, :nickname, :birthday, :phones, :emails, :addresses, :note, :photo, :groups, :source)
-
-    # Reopened for the reason VCard::Parser::Property is.
-    class Card
-      # A card file that will not read as one.
-      class Invalid < StandardError; end
-
-      KEYS = %w[first last nickname birthday phones emails addresses note photo groups source].freeze #: Array[String]
-
-      # What the source gave, kept in the card rather than off to the
-      # side: the fields above are read out of it, and `finalize`
-      # compares the Mac's contact against it before deleting anything,
-      # so the two are read together and edited in one place.
-      SOURCE_KEYS = %w[identifier vcard note contact].freeze #: Array[String]
-
-      # A phone's parts: a number, and the label the source card's
-      # X-ABLabel gave the row, left out rather than written empty
-      # (docs/plans/2026-09-18-phone-labels.md, "The plan card carries
-      # one").
-      PHONE_KEYS = %w[number label].freeze #: Array[String]
-
-      BIRTHDAY = /\A(\d{4})-(\d{2})-(\d{2})\z/ #: Regexp
-
-      # A picture's format as `PHOTO`'s TYPE names it, read off the
-      # decoded bytes the way Contact#photo reads one back: the magic
-      # bytes cannot mislabel what they are.
-      PHOTO_TYPES = {"image/jpeg" => "JPEG", "image/png" => "PNG", "image/gif" => "GIF"}.freeze #: Hash[String, String]
-
-      # A card off disk, where a plan keeps one. The BOM-tolerant mode is
-      # YAML.safe_load_file's own, kept because the read moved out of it.
-      #: (Pathname path) -> Card
-      def self.read(path)
-        parse(File.read(path, mode: "r:bom|utf-8"), path.to_s)
-      end
-
-      # Every problem is refused rather than skipped: a key this does not
-      # know, or a value YAML read as something other than text (an
-      # unquoted `no` is false, and an unquoted 1815-12-10 a Date), is an
-      # edit that would otherwise vanish.
-      #
-      # `name` is what a refusal names, and is a path only where the card
-      # came from one: an upload has no path this server can point at, so
-      # the import screen names the file the browser sent instead
-      # (docs/plans/2026-09-20-import-by-upload.md).
-      #: (String document, String name) -> Card
-      def self.parse(document, name)
-        document = YAML.safe_load(document)
-        invalid = ->(why) { raise Invalid, "#{name}: #{why}" }
-        invalid.("is not a mapping") unless document.is_a?(Hash)
-
-        missing = KEYS - document.keys
-        invalid.("has no #{missing.join(", ")}") unless missing.empty?
-        unknown = document.keys - KEYS
-        invalid.("has #{unknown.join(", ")}, which no field takes") unless unknown.empty?
-
-        first, last, nickname, birthday, phones, emails, addresses, note, photo, groups, source = document.values_at(*KEYS)
-        invalid.("first and last must be text; quote them") unless first.is_a?(String) && last.is_a?(String)
-        invalid.("needs a first or last name") if first.strip.empty? && last.strip.empty?
-        invalid.("nickname must be text, or blank") unless nickname.nil? || nickname.is_a?(String)
-        invalid.("birthday must be a quoted YYYY-MM-DD, or blank") unless birthday.nil? || birthday.is_a?(String) && birthday.match?(BIRTHDAY)
-        invalid.("phones must be a list, each with a quoted number and an optional label") unless phones.is_a?(Array) && phones.all? { phone?(it) }
-        invalid.("emails must be a list of addresses") unless texts?(emails)
-        addresses.is_a?(Array) && addresses.all? { address?(it) } or
-          invalid.("addresses must be a list, each with any of #{Contact::ADDRESS_COMPONENTS.join(", ")}")
-        invalid.("groups must be a list of names") unless texts?(groups) && groups.none? { it.strip.empty? }
-        invalid.("note must be text, or blank") unless note.nil? || note.is_a?(String)
-        invalid.("photo must be true or false") unless [true, false].include?(photo)
-        invalid.("source must hold #{SOURCE_KEYS.join(", ")}") unless source?(source)
-        card = new(first:, last:, nickname:, birthday:, phones:, emails:, addresses:, note:, photo:, groups:, source:)
-        invalid.("photo is true and the source holds no picture this server knows") if photo && card.picture.nil?
-
-        card
-      rescue Psych::DisallowedClass => error
-        raise Invalid, "#{name}: #{error.message.sub(/\ATried to load unspecified class: /, "reads as a ")}; quote it"
-      end
-
-      #: (untyped value) -> bool
-      def self.texts?(value)
-        value.is_a?(Array) && value.all?(String)
-      end
-
-      #: (untyped value) -> bool
-      def self.address?(value)
-        value.is_a?(Hash) && (value.keys - Contact::ADDRESS_COMPONENTS).empty? &&
-          value.values.all?(String) && value.values.any? { !it.strip.empty? }
-      end
-
-      # The shape Macos.entry writes and no other: a number is the one
-      # fact a phone cannot lack, and a label written at all cannot be
-      # blank — the builder leaves it out rather than writing it empty.
-      #: (untyped value) -> bool
-      def self.phone?(value)
-        value.is_a?(Hash) && (value.keys - PHONE_KEYS).empty? && value.values.all?(String) &&
-          !value.fetch("number", "").strip.empty? &&
-          (value["label"].nil? || !value["label"].strip.empty?)
-      end
-
-      #: (untyped value) -> bool
-      def self.source?(value)
-        value.is_a?(Hash) && value.keys.sort == SOURCE_KEYS.sort &&
-          value.fetch("identifier").is_a?(String) && value.fetch("vcard").is_a?(String) &&
-          [NilClass, String].include?(value.fetch("note").class) && value.fetch("contact").is_a?(Hash)
-      end
-
-      private_class_method :texts?, :address?, :phone?, :source?
-
-      # The contact the web create and then the editor's add rows would
-      # make of these fields. The birthday rides beside the card as the
-      # store keeps it, and Contact composes the line back in
-      # (docs/plans/2026-09-11-every-birthday-in-the-model.md).
-      # No middle name: a plan's card holds first and last, and a source
-      # N carrying anything past them is refused rather than imported
-      # (Macos.name), so there is none here to write.
-      #: (String id) -> Contact
-      def contact(id)
-        created = Contact.new(id:, stored: Admin::CardForm.new_card(id, first, "", last), birthday: nil, inherited: [])
-        stored = Admin::CardForm.contact_card(created, first, "", last, {
-          "nickname" => nickname.to_s, "note" => note.to_s,
-          "new_phone" => phones.filter_map { it["label"] ? nil : it.fetch("number") },
-          "new_email" => emails, "new_address" => addresses
-        })
-        # The picture and a labeled phone are written here for the same
-        # reason: no form has a field for either. A label is two lines
-        # under one property group, the shape macOS keeps
-        # (docs/plans/2026-09-18-phone-labels.md, "The plan card carries
-        # one"), the numbers counting up from one on a card built from
-        # nothing — finding a free number in a card that already has
-        # groups is the editor's problem, and waits for it.
-        stored = stored.insert([picture, *labeled_phone_lines].compact)
-        Contact.new(id:, stored:, birthday: born_on, inherited: [])
-      end
-
-      # The labeled phones as their grouped lines: the number and its
-      # X-ABLabel under one `item` prefix each, escaped as text is.
-      #: () -> Array[String]
-      def labeled_phone_lines
-        phones.select { it["label"] }.each.with_index(1).flat_map { |phone, index|
-          group = "item#{index}"
-          ["#{group}.TEL:#{VCard.escape(phone.fetch("number"))}\r\n",
-           "#{group}.X-ABLabel:#{VCard.escape(phone.fetch("label"))}\r\n"]
-        }
-      end
-
-      # The source's picture as a PHOTO line, or nil for a card that
-      # carries none and for data that decodes to no format the server
-      # knows — which Card.read refuses rather than dropping. The
-      # thumbnail stands in when the framework gave no full image, which
-      # is the only picture some contacts have
-      # (docs/plans/2026-09-16-importing-from-macos.md, "Reading the
-      # Mac").
-      #: () -> String?
-      def picture
-        return unless photo
-
-        contact = source.fetch("contact") #: untyped
-        photo_line(contact["imageData"]) || photo_line(contact["thumbnailImageData"])
-      end
-
-      # One picture as a PHOTO line, or nil for data that is not text,
-      # not base64, or no format the server knows.
-      #: (untyped data) -> String?
-      def photo_line(data)
-        return unless data.is_a?(String)
-
-        bytes = begin
-          Base64.strict_decode64(data)
-        rescue ArgumentError
-          return
-        end
-        signature = Contact::PHOTO_SIGNATURES.find { bytes.start_with?(it.first) }
-        return if signature.nil?
-
-        "PHOTO;ENCODING=b;TYPE=#{PHOTO_TYPES.fetch(signature.last)}:#{data}\r\n"
-      end
-
-      #: () -> Birthday?
-      def born_on
-        captures = birthday&.match(BIRTHDAY)&.captures
-        return if captures.nil?
-
-        year, month, day = captures
-        Birthday.new(year: Integer(year), month: Integer(month), day: Integer(day))
-      end
-
-      #: () -> Hash[String, untyped]
-      def document
-        KEYS.zip([first, last, nickname, birthday, phones, emails, addresses, note, photo, groups, source]).to_h
-      end
-    end
-  end
-end
diff --git a/lib/pro_tacts/import/config.rb b/lib/pro_tacts/import/config.rb
deleted file mode 100644
index 13a9128..0000000
--- a/lib/pro_tacts/import/config.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-require "pathname"
-require "uri"
-require "yaml"
-
-module ProTacts
-  module Import
-    # The import tasks' world under data/import: where everything
-    # lives — the paths below — and the standing data kept between
-    # plans, read from config.yml as the members of the Data. Not
-    # committed — /data is ignored — because it holds this
-    # deployment's own host and choices. Each key of the file is a
-    # member, grown one at a time as the tasks take standing data off
-    # the command line.
-    # @rbs skip
-    class Config < Data.define(:host)
-      # The root of the import collateral: the standing data in FILE,
-      # the plans in flight under ACTIVE, filed away under DONE once
-      # every contact is off this Mac — the two states a plan's
-      # directory names, beside the one file that outlives them.
-      ROOT = Pathname.new("data/import")
-      FILE = ROOT / "config.yml"
-      ACTIVE = ROOT / "active"
-      DONE = ROOT / "done"
-
-      # Every problem is refused rather than skipped, naming the file —
-      # Card#read's rules for a hand-edited file. The host is required
-      # rather than nil-able, and parsed: a bare hostname is the base
-      # URL of a server that serves HTTPS, which every deployment
-      # does, so the scheme is spelled out — the host a plan records
-      # is the one a second run is compared against — and anything
-      # that is not an http or https URL is refused.
-      def self.read(path = FILE)
-        invalid = ->(why) { raise ArgumentError, "#{path}: #{why}" }
-        invalid.("does not exist") unless path.file?
-
-        document = YAML.safe_load_file(path)
-        invalid.("has no host") if document.nil?
-        invalid.("is not a mapping") unless document.is_a?(Hash)
-        unknown = document.keys - members.map(&:to_s)
-        invalid.("has #{unknown.join(", ")}, which no member takes") unless unknown.empty?
-
-        host = document.fetch("host", nil)
-        invalid.("has no host") if host.nil?
-        invalid.("host must be text") unless host.is_a?(String)
-        invalid.("host is blank") if host.strip.empty?
-
-        begin
-          uri = URI.parse(host)
-          uri = URI.parse("https://#{uri}") if uri.scheme.nil?
-        rescue URI::InvalidURIError
-          invalid.("host does not read as a URL")
-        end
-        unless uri.is_a?(URI::HTTP)
-          raise ArgumentError, "#{path}: host must be an http or https URL, such as https://contacts"
-        end
-
-        new(host: uri)
-      end
-    end
-  end
-end
diff --git a/lib/pro_tacts/import/finalize.rb b/lib/pro_tacts/import/finalize.rb
deleted file mode 100644
index 62f0194..0000000
--- a/lib/pro_tacts/import/finalize.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-require "pro_tacts/import/plan"
-
-module ProTacts
-  module Import
-    # Finalizes a plan's contacts off this Mac, once they have landed
-    # on a host (docs/plans/2026-09-16-importing-from-macos.md,
-    # "Removing the originals"). Every check is a reason to keep a
-    # contact rather than to delete one, because a wrong keep costs a
-    # second look and a wrong delete costs the contact.
-    #
-    # The host is the one in data/import/config.yml, read by the task,
-    # rather than one the plan recorded: landing is the import screen's
-    # now and writes nothing back to the plan, so what the plan can
-    # still say is which contacts were meant to go, and the host is
-    # asked about each of them (docs/plans/2026-09-20-import-by-upload.md).
-    class Finalize
-      # @rbs @plan: Plan
-      # @rbs @host: String
-      # @rbs @client: _Client
-      # @rbs @mac: _Mac
-
-      class Failed < StandardError; end
-
-      # What a run did: how many contacts it took off the Mac, and the
-      # ones it left there, each as its plan id, the name the plan recorded
-      # and why it stayed. Both, since the person reading has a contact to
-      # find in Contacts and a line to find in plan.yml. Signed in
-      # sig/pro_tacts/import.rbs, being a Data class.
-      # @rbs skip
-      Result = Data.define(:done, :kept)
-
-      #: (Plan plan, host: String, client: _Client, mac: _Mac) -> Result
-      def self.call(plan, host:, client:, mac:)
-        new(plan, host:, client:, mac:).call
-      end
-
-      #: (Plan plan, host: String, client: _Client, mac: _Mac) -> void
-      def initialize(plan, host:, client:, mac:)
-        @plan = plan
-        @host = host
-        @client = client
-        @mac = mac
-      end
-
-      #: () -> Result
-      def call
-        # Every contact the plan has not finished, whether or not the
-        # import screen has landed it: #keep asks the host about each
-        # one, and a contact whose card is not there is kept rather
-        # than deleted — the check that stood behind the recorded
-        # status, and now stands in its place.
-        outstanding = @plan.outstanding
-        records = @mac.show(outstanding.map(&:source_id))
-        gone, still_there = outstanding.partition { !records.key?(it.source_id) }
-        # A contact this Mac no longer has is a contact done, which is
-        # what a rerun of an interrupted run sees.
-        gone.each { @plan.record(it.id, Plan::DONE) }
-
-        kept = [] #: Array[[String, String, String]]
-        going = still_there.select { |contact|
-          why = keep(contact, records.fetch(contact.source_id))
-          kept << [contact.id, contact.name, why] if why
-          why.nil?
-        }
-        # One call for the batch, since each one starts the script again,
-        # and one save request per contact inside it, so a contact the
-        # store refuses is the only one that stays. A run that dies before
-        # the plan records the rest finds them gone next time.
-        refused = @mac.delete(going.map(&:source_id))
-        deleted, stuck = going.partition { !refused.key?(it.source_id) }
-        deleted.each { @plan.record(it.id, Plan::DONE) }
-        stuck.each {
-          kept << [it.id, it.name, "this Mac would not delete #{it.source_id}: #{refused.fetch(it.source_id)}"]
-        }
-
-        Result.new(done: gone.size + deleted.size, kept:)
-      end
-
-      private
-
-      # Why this contact stays on the Mac, or nil for one to delete. The
-      # comparison is exact because CNContact carries no modification
-      # date: a contact Contacts.app rewrote on its own is kept, which is
-      # the safe direction to be wrong in.
-      #: (Plan::Contact contact, Hash[String, untyped] record) -> String?
-      def keep(contact, record)
-        source = @plan.card(contact.id).source
-        if !on_host?(contact.id)
-          "no card of its id is on #{@host}"
-        elsif record.fetch("vcard") != source.fetch("vcard")
-          "it has changed on this Mac since the plan"
-        elsif record.fetch("note") != source.fetch("note")
-          "its note has changed on this Mac since the plan"
-        end
-      end
-
-      # Asked of the card browser rather than of `/dav/addressbook`,
-      # which serves the asking user's book alone: a card in no `sync:`
-      # group is on the host and outside every book, and the question
-      # here is whether the host still has it at all
-      # (docs/plans/2026-09-12-per-user-books.md).
-      #: (String id) -> bool
-      def on_host?(id)
-        response = @client.call("GET", "/contacts/#{id}")
-        case response.status
-        when 200 then true
-        when 404 then false
-        else raise Failed, "GET of /contacts/#{id} answered #{response.status}: #{response.body}"
-        end
-      end
-    end
-  end
-end
diff --git a/lib/pro_tacts/import/http_client.rb b/lib/pro_tacts/import/http_client.rb
deleted file mode 100644
index 78db5e9..0000000
--- a/lib/pro_tacts/import/http_client.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-require "net/http"
-
-module ProTacts
-  module Import
-    # A host over one started connection, which is how `finalize` asks
-    # whether a card it is about to delete from the Mac is still on the
-    # server it landed on. The identity header is the host's proxy to
-    # write, so nothing here sets one.
-    class HttpClient
-      # @rbs @http: Net::HTTP
-
-      # What a host answered. It lives here rather than beside the
-      # caller because the caller is now whoever holds a client: it
-      # was Execute's, and Execute is the import screen
-      # (docs/plans/2026-09-20-import-by-upload.md). Signed in
-      # sig/pro_tacts/import.rbs, being a Data class.
-      # @rbs skip
-      Response = Data.define(:status, :headers, :body)
-
-      #: (Net::HTTP http) -> void
-      def initialize(http)
-        @http = http
-      end
-
-      #: (String method, String path, ?headers: Hash[String, String], ?body: String?) -> Response
-      def call(method, path, headers: {}, body: nil)
-        response = @http.send_request(method, path, body, headers)
-        Response.new(status: response.code.to_i, headers: response.to_hash.transform_values { it.join(", ") }, body: response.body.to_s)
-      end
-    end
-  end
-end
diff --git a/lib/pro_tacts/import/land.rb b/lib/pro_tacts/import/land.rb
index 054d373..80b2665 100644
--- a/lib/pro_tacts/import/land.rb
+++ b/lib/pro_tacts/import/land.rb
@@ -1,92 +1,211 @@
-require "pro_tacts/import/card"
-require "pro_tacts/import/plan"
+require "pro_tacts/change_id"
+require "pro_tacts/import/vcf"
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
 
 module ProTacts
   module Import
-    # Lands a plan's cards in this server's own store, the step the
-    # import screen runs on what a browser uploaded
-    # (docs/plans/2026-09-20-import-by-upload.md). It writes what
-    # `import:execute` used to write over HTTP, through the store
-    # rather than through the routes, and lands the same thing: the
-    # card the plan's file describes, in the groups that file names.
+    # Lands an uploaded .vcf's cards in this server's store, with the
+    # importer's decisions applied on the way in
+    # (docs/plans/2026-09-21-import-a-vcf.md).
     #
-    # Idempotent, so a plan uploaded twice writes nothing the second
-    # time — which is what the upload has in place of the statuses
-    # `execute` recorded, there being no plan on this side to record
-    # one in.
+    # A card arrives a card and is stored as one: nothing here rebuilds
+    # it out of fields, so a property this address book has never heard
+    # of is kept by default and served back byte for byte (RFC 6352
+    # section 6.3.2.2, and VCard's whole posture). What the review
+    # screen collects is the exception to that — the handful of
+    # properties the importer would rather drop, or read under the
+    # note — and 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.
+    #
+    # 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
+    # cards. The review screen is what stands in for that — it says how
+    # 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]
 
-      # One card's arrival: the contact as it now stands, and whether
-      # this upload is what put it there. `stored` false is a card
-      # already here — a re-upload, or a plan landed once already —
-      # left as it is rather than written over, which is what the PUT's
-      # `If-None-Match: *` bought before.
-      # Signed in sig/pro_tacts/import.rbs, being a Data class.
-      # @rbs skip
-      Arrival = Data.define(:contact, :stored)
-
-      # The cards by the id each will be stored under, in the order
-      # they should land.
-      #: (Store store, Hash[String, Card] cards) -> Array[Arrival]
-      def self.call(store, cards)
-        new(store).call(cards)
+      # The group an import offers to put its arrivals in, named for
+      # when it happened: what landed together can be found together,
+      # and undone together, without anything having to be recorded
+      # about the file it came from.
+      #: (?Time now) -> String
+      def self.default_group(now = Time.now)
+        "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 kept.
+      # `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)
       end
 
-      #: (Store store) -> void
-      def initialize(store)
+      #: (Store store, decisions: Hash[String, String], ?group: String?) -> void
+      def initialize(store, decisions:, group: nil)
         @store = store
+        @decisions = decisions
+        @group = group
         @group_ids = {} #: Hash[String, String]
       end
 
-      #: (Hash[String, Card] cards) -> Array[Arrival]
+      #: (Array[VCard] cards) -> Array[Contact]
       def call(cards)
-        cards.map { |id, card| land(id, card) }
+        cards.map { land(it) }
       end
 
       private
 
-      # A card, then its groups, the order `execute` wrote them in: the
-      # PUT creates, and the membership that follows is what puts the
-      # card in its books.
-      #: (String id, Card card) -> Arrival
-      def land(id, card)
-        stored = @store.contact(id).nil?
+      # A card, then its group, the order a create writes them in: the
+      # put makes the contact, and the membership that follows puts it
+      # somewhere to be found.
+      #: (VCard card) -> Contact
+      def land(card)
+        id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
         # `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).
-        # The arrival reports at the PUT (Web#report_broken_assumptions)
-        # have nothing to say about a card this built: its lines come
-        # from Admin::CardForm, the editor's own writer, not off a wire.
-        @store.put(id, card.contact(id).vcard, client: true) if stored
-
-        named = card.groups.uniq.map { group_id(it) }
-        # Out of everyone's book unless the file names it, the same
-        # reading of a card's groups the browser's own save makes
-        # (Web#apply_groups, whose `was` the import filled with this).
-        everyone = group_id(Plan::EVERYONE)
-        @store.regroup(id, join: named, leave: named.include?(everyone) ? [] : [everyone])
-
-        # Read back rather than kept from the put, because the groups
+        @store.put(id, identified(decide(card), id), client: true)
+
+        group = @group
+        @store.regroup(id, join: [group_id(group)], leave: []) if group
+
+        # Read back rather than kept from the put, because the group
         # above moved what the card serves. Nothing can have taken it
         # away in between, so a miss here is a broken assumption rather
         # than a case to handle.
-        contact = @store.contact(id) || raise("#{id} was landed and is not stored")
-        Arrival.new(contact:, stored:)
+        @store.contact(id) || raise("#{id} was landed and is not stored")
+      end
+
+      # The card under the id it is stored as. The source's UID goes
+      # and the minted id takes its place, the shape every other create
+      # here writes (Admin::CardForm.new_card) and the one rake
+      # contacts:reid exists to restore: a UID from the book this file
+      # was exported from names a record on some other server, and
+      # keeping it would leave a contact with two spellings of who it
+      # is.
+      #: (VCard card, String id) -> VCard
+      def identified(card, id)
+        _, rest = card.extract("UID")
+        rest.insert(["UID:#{id}\r\n"])
+      end
+
+      # The card with the importer's decisions spent on it: the lines
+      # they dropped gone, the lines they sent to the note gone from
+      # where they were and written under it, and everything else —
+      # every known property, and every unknown one they kept — exactly
+      # as it arrived, bytes and all.
+      #: (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: there is no field
+          # to have made a decision about, and the bytes travel
+          # (VCard::Parser's own posture).
+          if property.nil?
+            kept << line.verbatim
+            next
+          end
+
+          choice = choice_for(property)
+          notes << noted(property, lines) if choice == Vcf::NOTE
+          next unless choice == Vcf::KEEP
+          # 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
+
+      # What the importer asked for this property. Anything they were
+      # not asked about — every known property — is kept, which is also
+      # what an unknown one gets when the form sent no answer for it
+      # (Web#decisions_in).
+      #: (VCard::Parser::Property property) -> String
+      def choice_for(property)
+        @decisions.fetch(property.name.upcase, Vcf::KEEP)
+      end
+
+      # The group prefixes (`item1.`) whose every line but the label is
+      # leaving. Apple hangs a label off the line it names rather than
+      # inside it, so dropping `item3.X-ABRELATEDNAMES` without this
+      # would leave `item3.X-ABLabel:_$!<Spouse>!$_` behind, naming a
+      # line that is no longer there.
+      #: (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) || choice_for(property) == Vcf::KEEP
+        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 a plan naming a group this
+      # 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) — Execute's own
-      # shape, and for its reason.
+      # its name (db/migrations/008_group_names.rb).
       #
       # The lookup is per name rather than a list read once up front,
       # because the put above makes a group of its own: the first card
       # into an empty store creates `sync:*` (Store#everyone_group_id),
       # and a list read before that would send this to create it again.
-      # Memoized, so a plan of four hundred cards under three group
-      # names reads the list three times.
+      # Memoized, so four hundred cards under one group name read the
+      # list once.
       #: (String name) -> String
       def group_id(name)
         @group_ids[name] ||= @store.all_groups.find { it.name == name }&.id || @store.create_group(name:)
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
deleted file mode 100644
index 23c5906..0000000
--- a/lib/pro_tacts/import/macos.rb
+++ /dev/null
@@ -1,420 +0,0 @@
-require "json"
-require "open3"
-require "pathname"
-
-require "pro_tacts/change_id"
-require "pro_tacts/contact"
-require "pro_tacts/import/card"
-require "pro_tacts/import/plan"
-require "pro_tacts/vcard/parser"
-
-module ProTacts
-  module Import
-    # A plan from this Mac's iCloud contacts, read by
-    # script/macos-contacts.swift. The builder knows only the fields it has
-    # been taught, and refuses the rest
-    # (docs/plans/2026-09-16-importing-from-macos.md, "The builder knows
-    # only what it was taught").
-    module Macos
-      SCRIPT = Pathname.new(__FILE__).dirname.join("../../../script/macos-contacts.swift").expand_path #: Pathname
-
-      # Every field no branch handles, across every contact read, so one
-      # build names all of them.
-      class Unknown < StandardError
-        # @rbs @seen: Hash[String, Array[[String, String]]]
-
-        EXAMPLES = 3 #: Integer
-
-        # Long enough for a line's parameters, short of a photo's payload.
-        EXAMPLE_LENGTH = 100 #: Integer
-
-        #: () -> void
-        def initialize
-          @seen = Hash.new { |seen, field| seen[field] = [] }
-          super
-        end
-
-        #: (String field, String source_id, String example) -> void
-        def add(field, source_id, example)
-          @seen[field] << [source_id, example]
-        end
-
-        #: () -> bool
-        def any? = !@seen.empty?
-
-        #: () -> String
-        def message
-          lines = @seen.sort.flat_map do |field, seen|
-            examples = seen.uniq(&:last).first(EXAMPLES).map do |source_id, example|
-              "    #{source_id}: #{truncate(example).inspect}"
-            end
-            ["  #{field}: #{seen.size}", *examples]
-          end
-          "no branch handles these fields, so no plan was written:\n#{lines.join("\n")}"
-        end
-
-        private
-
-        #: (String example) -> String
-        def truncate(example)
-          return example if example.length <= EXAMPLE_LENGTH
-
-          "#{example[0, EXAMPLE_LENGTH]}… (#{example.length} characters)"
-        end
-      end
-
-      # The reader's output, one parsed object per contact.
-      #: (limit: Integer?) -> Array[Hash[String, untyped]]
-      def self.read(limit:)
-        run("read", *[limit&.to_s].compact)
-      end
-
-      # This Mac as `Finalize` speaks to it: the contacts it still
-      # has, by source id, and the delete that takes them off it.
-      module Mac
-        #: (Array[String] source_ids) -> Hash[String, untyped]
-        def self.show(source_ids)
-          return {} if source_ids.empty?
-
-          Macos.run("show", *source_ids).to_h {
-            [it.fetch("identifier"), it] #: [String, untyped]
-          }
-        end
-
-        # The contacts this Mac would not delete, each to what it said;
-        # empty when every one of them left.
-        #: (Array[String] source_ids) -> Hash[String, String]
-        def self.delete(source_ids)
-          return {} if source_ids.empty?
-
-          Macos.run("delete", *source_ids).reject { it.fetch("deleted") }.to_h {
-            [it.fetch("identifier"), it.fetch("error")] #: [String, String]
-          }
-        end
-      end
-
-      #: (*String arguments) -> Array[Hash[String, untyped]]
-      def self.run(*arguments)
-        output, status = Open3.capture2("swift", SCRIPT.to_s, *arguments)
-        raise "#{SCRIPT.basename} #{arguments.first} exited #{status.exitstatus}" unless status.success?
-
-        output.each_line.map { JSON.parse(it) }
-      end
-
-      #: (Pathname dir, Array[Hash[String, untyped]] records, created_at: Time) -> Plan
-      def self.plan(dir, records, created_at:)
-        unknown = Unknown.new
-        entries = records.map { entry(it, unknown) }
-        raise unknown if unknown.any?
-
-        Plan.write(dir, source: "macos", created_at:, entries:)
-      end
-
-      #: (Hash[String, untyped] record, Unknown unknown) -> Plan::Entry
-      def self.entry(record, unknown)
-        id = ChangeId.mint(12)
-        source_id = record.fetch("identifier") #: String
-        vcard = record.fetch("vcard") #: String
-        note = record.fetch("note") #: String?
-        contact = record.fetch("contact") #: Hash[String, untyped]
-
-        lines = VCard::Parser.lines(vcard)
-        grouped = grouped(lines)
-        carried = lines.select { carried?(it, grouped, source_id, unknown) }
-        first, last = name(carried, source_id, unknown)
-        nickname = only(carried, "NICKNAME", source_id, unknown)
-        birthday = only(carried, "BDAY", source_id, unknown)
-        maiden_name = only(carried, "X-MAIDENNAME", source_id, unknown)
-        labels = labels_by_group(lines)
-        phones = properties(carried, "TEL").filter_map { |property|
-          number = property.text
-          # A number the card left empty is a row Contacts kept the
-          # label of and nothing else, and an empty add row inserts
-          # nothing (Admin::CardForm).
-          next if number.empty?
-
-          label = labels[property.group]
-          label ? {"number" => number, "label" => label} : {"number" => number} #: Hash[String, String]
-        }
-        emails = values(carried, "EMAIL")
-        addresses = properties(carried, "ADR").map { address(it) }
-        source = {"identifier" => source_id, "vcard" => vcard, "note" => note, "contact" => contact}
-        # Beyond the vCard, and carried from the source the card holds:
-        # the note AppleScript gave up, and the picture the serializer
-        # leaves out. A contact whose picture came back only as a
-        # thumbnail still has one.
-        photo = !contact.fetch("imageData").nil? || !contact.fetch("thumbnailImageData").nil?
-        card = Card.new(
-          first:, last:, nickname:, birthday:, phones:, emails:, addresses:,
-          note: noted(note, maiden_name, related_names(lines, carried)), photo:, groups: [], source:
-        )
-        Plan::Entry.new(id:, source_id:, card:)
-      end
-
-      # The note, with the maiden name and the people this contact is
-      # related to written under it: this address book has no field for
-      # either, and a name worth keeping is worth more in the note than
-      # nowhere (the related names' reasoning,
-      # docs/plans/2026-09-16-importing-from-macos.md, "The builder
-      # knows only what it was taught").
-      #: (String? note, String? maiden, Array[String] related) -> String?
-      def self.noted(note, maiden, related)
-        under = [maiden&.then { "Maiden name: #{it}" }, *related].compact
-        return note if under.empty?
-
-        [note, under.join("\n")].compact.reject(&:empty?).join("\n\n")
-      end
-
-      # Each related name as "<label>: <name>", the label being the
-      # X-ABLabel sharing its group — Apple's own labels arrive wrapped
-      # (`_$!<Spouse>!$_`) and a person's own arrives as they typed it.
-      # The labels are read off every line rather than the carried ones,
-      # a label being dropped with the group it annotates.
-      #: (Array[VCard::Parser::Line] lines, Array[VCard::Parser::Line] carried) -> Array[String]
-      def self.related_names(lines, carried)
-        labels = labels_by_group(lines)
-        properties(carried, "X-ABRELATEDNAMES").map {
-          label = labels[it.group]
-          label ? "#{label}: #{it.text}" : it.text
-        }
-      end
-
-      # Each `item` group to the label its X-ABLabel gives it, unescaped
-      # and unwrapped of Apple's own vocabulary. The map both label
-      # readers walk — a carried TEL's label and a related name's —
-      # read off every line, an X-ABLabel never being carried itself.
-      #: (Array[VCard::Parser::Line] lines) -> Hash[String?, String]
-      def self.labels_by_group(lines)
-        properties(lines, "X-ABLabel").to_h {
-          [it.group, VCard.unwrap(VCard.unescape(it.value))] #: [String?, String]
-        }
-      end
-
-      # The properties of the carried lines naming one property, in the
-      # order the card wrote them.
-      #: (Array[VCard::Parser::Line] lines, String name) -> Array[VCard::Parser::Property]
-      def self.properties(lines, name)
-        lines.filter_map { it.property }.select { it.name.casecmp?(name) }
-      end
-
-      #: (Array[VCard::Parser::Line] lines, String name) -> Array[String]
-      def self.values(lines, name) = properties(lines, name).map(&:text)
-
-      # An address as the editor's add row holds one, the blank
-      # components left out rather than written as empty fields.
-      #: (VCard::Parser::Property property) -> Hash[String, String]
-      def self.address(property)
-        # The post office box leads the value and has no field; a card
-        # carrying one does not get this far (see ADR's value check).
-        _po_box, *parts = property.components
-        Contact::ADDRESS_COMPONENTS.zip(parts).to_h { |key, value|
-          [key, value.to_s] #: [String, String]
-        }.reject { |_key, value| value.empty? }
-      end
-
-      # The one line naming a property a card holds once, or nil for a
-      # card with none; more than one is refused, the field having room
-      # for a single value.
-      #: (Array[VCard::Parser::Line] lines, String name, String source_id, Unknown unknown) -> String?
-      def self.only(lines, name, source_id, unknown)
-        found = lines.select { it.names?(name) }
-        return found.first&.property&.text if found.size <= 1
-
-        unknown.add("#{name} lines: #{found.size}", source_id, found.map { example(it) }.join(" / "))
-        nil
-      end
-
-      # The `item` groups this card explains: a group with a line in it
-      # that says what the group is for, rather than annotating it.
-      #: (Array[VCard::Parser::Line] lines) -> Array[String]
-      def self.grouped(lines)
-        lines.filter_map { it.property }
-          .reject { ANNOTATIONS.include?(it.name.upcase) }
-          .filter_map { it.group }
-      end
-
-      # Whether a line of the source card is carried into the card's
-      # fields; one that is not is recorded unknown or, for the envelope
-      # and the dropped properties, left behind.
-      #: (VCard::Parser::Line line, Array[String] grouped, String source_id, Unknown unknown) -> bool
-      def self.carried?(line, grouped, source_id, unknown)
-        property = line.property
-        example = example(line)
-        if property.nil?
-          unknown.add("unreadable line", source_id, example) unless example.strip.empty?
-          return false
-        end
-
-        name = property.name.upcase
-        case name
-        when "BEGIN", "END"
-          false
-        when "VERSION"
-          unknown.add("VERSION:#{property.value}", source_id, example) unless property.value == "3.0"
-          false
-        when *DROPPED
-          # Fields this address book does not have. The source keeps
-          # them, the card carrying what it was read from.
-          false
-        when *ANNOTATIONS
-          # A line that annotates the line sharing its group — a label,
-          # or an address's country code — goes wherever that line goes,
-          # which is why dropping `item1.URL` drops `item1.X-ABADR` with
-          # it. One annotating nothing is a line with no explanation, so
-          # it is unknown like any other.
-          group = property.group
-          unknown.add(name, source_id, example) unless group && grouped.include?(group)
-          false
-        when "PRODID"
-          # Contacts writes its own on every save; the source's stays in the backup.
-          form = form(property, example)
-          unknown.add(form, source_id, example) unless form == "PRODID"
-          unknown.add("PRODID value", source_id, example) unless property.value.start_with?("-//Apple Inc.//")
-          false
-        when *FORMS.keys
-          refused = [] #: Array[String]
-          form = form(property, example)
-          refused << form unless FORMS.fetch(name).include?(form)
-          refused << "#{name} value" unless value?(name, property)
-          refused.each { unknown.add(it, source_id, example) }
-          refused.empty? && !dropped_value?(name, property)
-        else
-          unknown.add(name, source_id, example)
-          false
-        end
-      end
-
-      # The first and last name, from the one N, when the one FN is those
-      # two joined as the web editor joins them: a card holding more of a
-      # name than that would lose it.
-      #: (Array[VCard::Parser::Line] lines, String source_id, Unknown unknown) -> [String, String]
-      def self.name(lines, source_id, unknown)
-        ns = lines.select { it.names?("N") }
-        fns = lines.select { it.names?("FN") }
-        unknown.add("N lines: #{ns.size}", source_id, ns.map { example(it) }.join(" / ")) unless ns.size == 1
-        unknown.add("FN lines: #{fns.size}", source_id, fns.map { example(it) }.join(" / ")) unless fns.size == 1
-
-        n = ns.first
-        family, given, *rest = n&.property&.components || []
-        first = given.to_s
-        last = family.to_s
-        unknown.add("N beyond first and last", source_id, example(n)) if n && rest.any? { !it.empty? }
-        unknown.add("no name", source_id, n ? example(n) : "") if n && first.empty? && last.empty?
-
-        fn = fns.first
-        if fn && fn.property&.text != [first, last].reject(&:empty?).join(" ")
-          unknown.add("FN other than first and last", source_id, example(fn))
-        end
-
-        [first, last]
-      end
-
-      # The spellings each carried property is taken in, group and
-      # parameters included, as the source wrote them: `item1.` and
-      # `item2.` are one spelling, since the number counts a card's
-      # groups rather than saying anything about the line, and a
-      # trailing `type=pref` is left off, ranking a line rather than
-      # naming a kind (VCard::Parser::Property#types drops it for the
-      # same reason).
-      FORMS = {
-        "FN" => ["FN"],
-        "N" => ["N"],
-        "NICKNAME" => ["NICKNAME"],
-        "BDAY" => ["BDAY"],
-        "TEL" => [
-          "TEL",
-          "TEL;type=CELL;type=VOICE",
-          "TEL;type=HOME;type=VOICE",
-          "TEL;type=IPHONE;type=CELL;type=VOICE",
-          "TEL;type=MAIN",
-          "TEL;type=OTHER;type=VOICE",
-          "TEL;type=WORK;type=VOICE",
-          "item#.TEL"
-        ],
-        "EMAIL" => [
-          "EMAIL;type=INTERNET",
-          "EMAIL;type=INTERNET;type=HOME",
-          "EMAIL;type=INTERNET;type=WORK",
-          "item#.EMAIL;type=INTERNET"
-        ],
-        "ADR" => ["ADR;type=HOME", "item#.ADR;type=HOME", "ADR;type=WORK", "item#.ADR;type=WORK"],
-        "X-ABRELATEDNAMES" => ["item#.X-ABRELATEDNAMES"],
-        "X-MAIDENNAME" => ["X-MAIDENNAME"]
-      }.freeze #: Hash[String, Array[String]]
-
-      # A number as Contacts hands one over, which is as a person typed
-      # it: "+12532189075" and "(206) 651-4359" are both numbers, and an
-      # empty value is a row with no number in it at all.
-      PHONE = /\A[0-9 +().-]*\z/ #: Regexp
-
-      # A number with the extension a person wrote after it, "+1 (425)
-      # 707-1712 X71712" as one line. Digits have to come first: a value
-      # that is an extension and nothing else ("ext. 4") labels a row
-      # rather than filling it, and stays refused.
-      EXTENSION = /\A[0-9 +().-]*[0-9][0-9 +().-]*(?:x|ext)\.?\s*[0-9]+\z/i #: Regexp
-
-      # Properties read and thrown away, whatever form they take: this
-      # address book has no field for any of them, and a card's `source`
-      # still holds the line.
-      DROPPED = %w[
-        IMPP ORG TITLE URL X-SOCIALPROFILE
-        X-APPLE-SUBADMINISTRATIVEAREA X-APPLE-SUBLOCALITY X-AIM
-      ].freeze #: Array[String]
-
-      # Properties that say something about the line sharing their group
-      # rather than about the contact: a label, and the country code
-      # Contacts writes beside an address.
-      ANNOTATIONS = %w[X-ABLABEL X-ABADR].freeze #: Array[String]
-
-      # Whether a value is one its field can hold: a phone is a `+` and
-      # digits, with or without an extension, a birthday a whole date,
-      # an address the seven components RFC 2426 section 3.2.1 gives it
-      # with no post office box, since no field carries one. A name is
-      # any text, and an email is asked nothing here — see
-      # #dropped_value?.
-      #: (String name, VCard::Parser::Property property) -> bool
-      def self.value?(name, property)
-        case name
-        when "TEL" then property.value.match?(PHONE) || property.value.match?(EXTENSION)
-        when "BDAY" then property.value.match?(Card::BIRTHDAY)
-        when "ADR" then property.components.size == 7 && property.components.fetch(0).empty?
-        else true
-        end
-      end
-
-      # A value its field has no use for, dropped rather than refused.
-      # An EMAIL without an `@` is not an address at all — Exchange
-      # writes a directory name into one
-      # (`/O=microsoft/OU=.../cn=algersha`) — so it is not a spelling to
-      # learn but a row this address book has no field for, and it goes
-      # the way the DROPPED properties go, the source keeping the line.
-      # A work address goes the same way for the opposite reason: the
-      # editor's address rows carry no type, so a work address would
-      # land as an unmarked second one beside the home, and the one
-      # address the fields can hold without lying is the home — the
-      # work stays in the source. The form is still checked around
-      # this, so a value like it in a spelling nobody has seen is
-      # reported like any other.
-      #: (String name, VCard::Parser::Property property) -> bool
-      def self.dropped_value?(name, property)
-        return true if name == "EMAIL" && !property.value.include?("@")
-
-        name == "ADR" && property.parameters.any? { |key, value| key.casecmp?("TYPE") && value.casecmp?("WORK") }
-      end
-
-      # A line unfolded, without its terminator.
-      #: (VCard::Parser::Line line) -> String
-      def self.example(line)
-        VCard::Parser.unfold(line.verbatim).chomp
-      end
-
-      # A line as the source spelled it, less its value.
-      #: (VCard::Parser::Property property, String example) -> String
-      def self.form(property, example)
-        example.delete_suffix(":#{property.value}").sub(/\Aitem\d+\./, "item#.").delete_suffix(";type=pref")
-      end
-
-      private_class_method :entry, :grouped, :carried?, :name, :noted, :related_names, :labels_by_group, :only, :properties, :values, :address, :value?, :dropped_value?, :example, :form
-    end
-  end
-end
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
deleted file mode 100644
index 885731f..0000000
--- a/lib/pro_tacts/import/plan.rb
+++ /dev/null
@@ -1,159 +0,0 @@
-require "fileutils"
-require "pathname"
-require "time"
-require "yaml"
-
-require "pro_tacts/import/card"
-
-module ProTacts
-  module Import
-    # One import, as a directory: what a source builder decided, and how
-    # far finalize has carried it
-    # (docs/plans/2026-09-16-importing-from-macos.md, and
-    # docs/plans/2026-09-20-import-by-upload.md for the step that left).
-    class Plan
-      # @rbs @dir: Pathname
-      # @rbs @manifest: Hash[String, untyped]
-
-      # What a builder hands over for one contact: its card, which the
-      # plan's own groups are added to. The original rides inside the
-      # card (Card::SOURCE_KEYS). Signed in sig/pro_tacts/import.rbs,
-      # being a Data class.
-      # @rbs skip
-      Entry = Data.define(:id, :source_id, :card)
-
-      # A contact as the plan lists it, with the last step it reached:
-      # nil until it is done. The steps between — `landed` and
-      # `imported` — were `execute`'s to record, and went with it when
-      # landing moved into the app
-      # (docs/plans/2026-09-20-import-by-upload.md); a plan carried by
-      # that task still reads, and a contact wearing either status is
-      # outstanding like any other. The name is the card's when the plan
-      # was built, written down so a person reading plan.yml can tell
-      # which line is whose without opening a card; nothing reads it
-      # back to decide anything.
-      # @rbs skip
-      Contact = Data.define(:id, :source_id, :name, :status)
-
-      FORMAT = 2 #: Integer
-
-      # Store::EVERYONE, spelled out so a plan loads no database.
-      EVERYONE = "sync:*" #: String
-
-      # A contact's final state: on the host, in its groups, and off
-      # this Mac. A plan whose every contact has reached it is done and
-      # files away under done/ (tasks/import.rake).
-      DONE = "done" #: String
-
-      # The spelling of DONE plans recorded before the state was
-      # named for what it is; read as it so a plan built then files
-      # away now.
-      REMOVED = "removed" #: String
-
-      # Whether the plan has carried every contact through: nothing
-      # left for finalize to do.
-      #: () -> bool
-      def done? = outstanding.empty?
-
-      #: (Pathname dir, source: String, created_at: Time, entries: Array[Entry]) -> Plan
-      def self.write(dir, source:, created_at:, entries:)
-        FileUtils.mkdir_p(dir.dirname)
-        # Raises when the directory is there, so no plan is written over
-        # another.
-        Dir.mkdir(dir)
-
-        group = "import-#{created_at.utc.strftime("%Y%m%dT%H%M%SZ")}"
-        (dir / "cards").mkpath
-        entries.each do |entry|
-          card = entry.card
-          card = card.with(groups: [EVERYONE, group, *card.groups])
-          (dir / "cards/#{entry.id}.yml").write(YAML.dump(card.document))
-        end
-
-        plan = new(dir, {
-          "format" => FORMAT,
-          "source" => source,
-          "created_at" => created_at.utc.iso8601,
-          "group" => group,
-          "contacts" => entries.map {
-            # A card may hold only one of the two names.
-            person = [it.card.first, it.card.last].reject(&:empty?).join(" ")
-            {"id" => it.id, "source_id" => it.source_id, "name" => person, "status" => nil}
-          }
-        })
-        plan.save
-        plan
-      end
-
-      # Every plan under dir, oldest first: a plan's directory is named
-      # for the minute it was built, so the names sort as the plans do.
-      #: (Pathname dir) -> Array[Plan]
-      def self.all(dir)
-        Pathname.glob("#{dir}/*/plan.yml").sort_by { it.dirname.basename.to_s }.map { read(it.dirname) }
-      end
-
-      #: (Pathname dir) -> Plan
-      def self.read(dir)
-        new(dir, YAML.safe_load_file(dir / "plan.yml"))
-      end
-
-      attr_reader :dir #: Pathname
-
-      #: (Pathname dir, Hash[String, untyped] manifest) -> void
-      def initialize(dir, manifest)
-        format = manifest.fetch("format")
-        raise ArgumentError, "#{dir} is plan format #{format}, and this reads #{FORMAT}" unless format == FORMAT
-
-        @dir = dir
-        @manifest = manifest
-      end
-
-      #: () -> String
-      def source = @manifest.fetch("source")
-
-      #: () -> String
-      def created_at = @manifest.fetch("created_at")
-
-      #: () -> String
-      def group = @manifest.fetch("group")
-
-      #: () -> Array[Contact]
-      def contacts
-        @manifest.fetch("contacts").map {
-          Contact.new(id: it.fetch("id"), source_id: it.fetch("source_id"), name: it.fetch("name"), status: it.fetch("status"))
-        }
-      end
-
-      #: (String id, String status) -> void
-      def record(id, status)
-        @manifest.fetch("contacts").find { it.fetch("id") == id }.store("status", status)
-        save
-      end
-
-      # The contacts still to carry, for a task picking a plan to take
-      # further: none for a plan every step has finished. Anything that
-      # is not the final state counts, so a plan `execute` left part way
-      # through is outstanding where it left off.
-      #: () -> Array[Contact]
-      def outstanding = contacts.reject { [DONE, REMOVED].include?(it.status) }
-
-      # The card as its file now reads, edits included.
-      #: (String id) -> Card
-      def card(id)
-        Card.read(dir / "cards/#{id}.yml")
-      end
-
-      # Every step is saved as it is taken, and replaced whole by a rename
-      # so a run that dies mid-write leaves the plan as it was before.
-      #: () -> void
-      def save
-        written = dir / "plan.yml.tmp"
-        written.open("w") do |file|
-          file.write(YAML.dump(@manifest))
-          file.fsync
-        end
-        written.rename(dir / "plan.yml")
-      end
-    end
-  end
-end
diff --git a/lib/pro_tacts/import/staged.rb b/lib/pro_tacts/import/staged.rb
new file mode 100644
index 0000000..f80a754
--- /dev/null
+++ b/lib/pro_tacts/import/staged.rb
@@ -0,0 +1,106 @@
+require "fileutils"
+require "pathname"
+
+require "pro_tacts"
+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.
+    #
+    # 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.
+      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)
+        sweep
+        id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
+        File.binwrite(directory / file_name(id), bytes)
+        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)
+        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
+        # rule); a card refuses to be made of anything else regardless
+        # (VCard#initialize).
+        File.read(file, 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.
+      #: (String id) -> void
+      def self.remove(id)
+        file = path(id)
+        FileUtils.rm_f(file) if file
+      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.
+      #: (?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
+        end
+      end
+
+      #: () -> Pathname
+      def self.root
+        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.
+      #: (String id) -> Pathname?
+      def self.path(id)
+        return nil unless ChangeId.minted?(id)
+
+        root / file_name(id)
+      end
+
+      #: (String id) -> String
+      def self.file_name(id)
+        "#{id}.vcf"
+      end
+
+      private_class_method :root, :path, :file_name
+    end
+  end
+end
diff --git a/lib/pro_tacts/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
new file mode 100644
index 0000000..75e49cc
--- /dev/null
+++ b/lib/pro_tacts/import/vcf.rb
@@ -0,0 +1,144 @@
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
+module ProTacts
+  module Import
+    # An uploaded .vcf, read as the cards it holds and as what this
+    # server does not recognise in them
+    # (docs/plans/2026-09-21-import-a-vcf.md).
+    #
+    # Nothing here rebuilds a card out of fields. A contact arrives as a
+    # vCard and is stored as one — RFC 6352 section 6.3.2.2 requires a
+    # server to keep what it does not understand, and this one does
+    # (vcard.rb) — 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 keeping?
+    module Vcf
+      # A file that is not a set of vCards.
+      class Invalid < StandardError; end
+
+      # The properties this address book reads. The envelope (BEGIN,
+      # END, VERSION, UID), the fields a screen shows (Contact's own
+      # readers: N, FN, NICKNAME, TEL, EMAIL, ADR, NOTE, PHOTO, and the
+      # X-ABLabel that names a row), and BDAY, which the store takes
+      # into the model on the way in
+      # (docs/plans/2026-09-11-every-birthday-in-the-model.md).
+      #
+      # Everything else is unknown, which here means "nothing here will
+      # show it", not "nothing here can hold it": a kept line is served
+      # back byte for byte like any other. That is why the list is the
+      # reader's and not the writer's.
+      # 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).
+      LABEL = "X-ABLABEL" #: String
+
+      KNOWN = [
+        *%w[BEGIN END VERSION UID N FN NICKNAME BDAY TEL EMAIL ADR NOTE PHOTO],
+        LABEL,
+      ].freeze #: Array[String]
+
+      # What the importer can ask for an unknown property. Keeping is
+      # the default because it loses nothing: the line is stored as it
+      # arrived and served back untouched, invisible to these screens
+      # rather than gone. Dropping is for what the source keeps and
+      # this book has no use for. The note is 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.
+      KEEP = "keep" #: String
+      DROP = "drop" #: String
+      NOTE = "note" #: String
+      CHOICES = [KEEP, DROP, NOTE].freeze #: Array[String]
+
+      # 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 this book's own
+      # data rather than at a property name. Signed in
+      # sig/pro_tacts/import.rbs, being a Data class.
+      # @rbs skip
+      Unknown = Data.define(:name, :count, :examples)
+
+      EXAMPLES = 3 #: Integer
+
+      # Long enough for a line's parameters, short of a photo's payload.
+      EXAMPLE_LENGTH = 120 #: Integer
+
+      # The cards a file holds, in the order it wrote them, each as the
+      # bytes it arrived in. A card is what lies from BEGIN to END, and
+      # the lines between cards are blank or the file is not a set of
+      # vCards — refused whole rather than partly imported, `execute`'s
+      # own rule that a source which will not read lands nothing.
+      #: (String bytes) -> Array[VCard]
+      def self.cards(bytes)
+        cards = [] #: Array[VCard]
+        open = nil #: Array[VCard::Parser::Line]?
+        VCard::Parser.lines(bytes).each do |line|
+          name = line.property&.name
+          if open.nil?
+            next if line.verbatim.strip.empty?
+
+            raise Invalid, "a line outside a card: #{example(line)}" unless name&.casecmp?("BEGIN")
+
+            open = [line]
+          else
+            open << line
+            next unless name&.casecmp?("END")
+
+            cards << VCard.new(open.map(&:verbatim).join)
+            open = nil
+          end
+        end
+        raise Invalid, "the last card has no END:VCARD" unless open.nil?
+        raise Invalid, "this file holds no vCards" if cards.empty?
+
+        refused = cards.reject(&:card?)
+        raise Invalid, "#{refused.size} of these are not vCards: BEGIN, VERSION and END are what makes one" if refused.any?
+
+        cards
+      end
+
+      # Every property in these cards that KNOWN does not name, by
+      # name rather than by the spelling the line wore: the three
+      # choices read the same whatever parameters a line carries, so
+      # `X-SOCIALPROFILE;type=twitter` is not a second decision from
+      # `X-SOCIALPROFILE`. Ordered by name, a list to work down.
+      #
+      # An unreadable line is not among them and asks nothing: the
+      # parser hands one back without a property, the card is served
+      # from its bytes regardless, and there is no field it could be
+      # dropped out of (vcard/parser.rb's own posture).
+      #: (Array[VCard] cards) -> Array[Unknown]
+      def self.unknown(cards)
+        seen = {} #: Hash[String, Array[String]]
+        cards.each do |card|
+          card.lines.each do |line|
+            property = line.property
+            next if property.nil?
+
+            name = property.name.upcase
+            next if KNOWN.include?(name)
+
+            (seen[name] ||= []) << example(line)
+          end
+        end
+        seen.sort.map { |name, examples|
+          Unknown.new(name:, count: examples.size, examples: examples.uniq.first(EXAMPLES))
+        }
+      end
+
+      # A line unfolded, without its terminator, cut short of a
+      # payload nobody wants to read.
+      #: (VCard::Parser::Line line) -> String
+      def self.example(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
+    end
+  end
+end
diff --git a/lib/pro_tacts/web/api.rb b/lib/pro_tacts/web/api.rb
index 7c2dc35..3210422 100644
--- a/lib/pro_tacts/web/api.rb
+++ b/lib/pro_tacts/web/api.rb
@@ -2,10 +2,12 @@ require "json"
 
 module ProTacts
   class Web < Roda
-    # JSON for scripts, beside the HTML screens a person uses. An import
-    # looks its group up here by name before making it, so a rerun
-    # joins the group an earlier run made
-    # (docs/plans/2026-09-16-importing-from-macos.md).
+    # JSON for scripts, beside the HTML screens a person uses. It was
+    # written for `rake import:execute`, which looked a group up by
+    # name over HTTP before making it; that import is gone
+    # (docs/plans/2026-09-21-import-a-vcf.md) and the route stays as
+    # what it always was — the group list, for anything scripted
+    # pointed at this server.
     hash_branch("api") do |r|
       r.get "groups" do
         response["Content-Type"] = "application/json"
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index d639af6..d8f6869 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -1,91 +1,151 @@
-require "pro_tacts/admin/plan_upload"
-require "pro_tacts/import/card"
+require "pro_tacts/admin/import_review"
+require "pro_tacts/admin/import_upload"
 require "pro_tacts/import/land"
+require "pro_tacts/import/staged"
+require "pro_tacts/import/vcf"
 
 module ProTacts
   class Web < Roda
-    # The import screen: a plan's cards arrive as an upload and land in
-    # this server's store (docs/plans/2026-09-20-import-by-upload.md).
-    # It replaces `rake import:execute`, which carried the same cards
-    # over HTTP from the machine the plan sat on — the routes it drove
-    # are still there, and nothing here is a second way to write a
-    # card: Import::Land goes through Store#put and Store#regroup, the
-    # two writes that PUT and the groups dialog make.
+    # 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.
     #
-    # Under the same identity gate as every other route (web.rb), which
-    # is also where the cards' books come from: a card this creates
-    # joins everyone's book the way a client's create does, so the
-    # person importing does not have to be the person syncing.
+    # 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.
+    #
+    # Under the same identity gate as every other route (web.rb),
+    # which is also where the arrivals' books come from: a card this
+    # creates joins everyone's book the way a client's create does, so
+    # the person importing does not have to be the person syncing.
     hash_branch("import") do |r|
       r.is do
         r.get do
-          plan_upload_screen
+          import_screen
         end
 
         r.post do
-          apply_import(r)
+          survey_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)
+      end
     end
 
     private
 
-    # The upload's whole POST, #apply_edit's shape: the request's own
-    # validity first — every file named and read before anything is
-    # written, `execute`'s rule that a card which will not read stops
-    # the run with nothing landed — and the write after it.
-    #
-    # It answers with the result rather than the 303 the other writes
-    # answer with, and can afford to: landing is idempotent, so the
-    # re-submission a back button offers writes nothing, and there is
-    # no other page that holds what just arrived.
+    # 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 apply_import(r)
-      uploads = files_in(r.params["cards"])
-      return plan_upload_screen(notice: "Choose the card files in a plan's cards directory.") if uploads.empty?
+    def survey_upload(r)
+      upload = file_in(r.params["vcf"])
+      return import_screen(notice: "Choose a .vcf file to import.") if upload.nil?
 
-      cards = {} #: Hash[String, Import::Card]
-      uploads.each do |upload|
-        # The browser sends a basename; File.basename is against a
-        # client that sends more than one.
-        name = File.basename(upload[:filename].to_s)
-        id = name.delete_suffix(".yml")
-        unless name.end_with?(".yml") && id.match?(Contact::ID_FORMAT)
-          return plan_upload_screen(notice: "#{name} is not a plan card: a card file is named for the id it lands under.")
-        end
+      name = File.basename(upload[:filename].to_s)
+      # Relabelled and judged in the same breath, Web#write_card's rule
+      # for the one other body this app reads: a multipart part arrives
+      # as binary, and a force_encoding nobody validates is a lie every
+      # later reader inherits.
+      bytes = upload.fetch(:tempfile).read.force_encoding(Encoding::UTF_8)
+      return import_screen(notice: "#{name} is not UTF-8 text.") unless bytes.valid_encoding?
 
-        # Relabelled and judged in the same breath, Web#write_card's
-        # rule for the one other body this app reads: a multipart part
-        # arrives as binary, and a force_encoding nobody validates is a
-        # lie every later reader inherits — here it would surface as a
-        # 500 out of SQLite on the insert, past the point where the
-        # person could be told which file was wrong.
-        bytes = upload.fetch(:tempfile).read.force_encoding(Encoding::UTF_8)
-        return plan_upload_screen(notice: "#{name} is not UTF-8 text.") unless bytes.valid_encoding?
-
-        begin
-          cards[id] = Import::Card.parse(bytes, name)
-        rescue Import::Card::Invalid => error
-          return plan_upload_screen(notice: error.message)
-        end
+      begin
+        cards = Import::Vcf.cards(bytes)
+      rescue Import::Vcf::Invalid => error
+        return import_screen(notice: error.message)
       end
 
-      plan_upload_screen(arrivals: Import::Land.call(store, cards))
+      review_screen(
+        upload: Import::Staged.write(bytes),
+        file: name,
+        cards: cards.length,
+        unknown: Import::Vcf.unknown(cards),
+      )
+    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,
+      )
+      Import::Staged.remove(id)
+
+      import_screen(landed:)
     end
 
-    # The file parts the form sent, and none for a POST carrying no
+    # 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 keep, the choice
+    # that loses nothing.
+    #: (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::KEEP
+        # A two-element literal is an Array until something says
+        # otherwise, and an inline annotation needs its own line.
+        [property.name, choice] #: [String, String]
+      }
+    end
+
+    # The file part the form sent, and none for a POST carrying no
     # file: #ids_in's shape over an upload, and its reason for being a
     # method — the empty case needs a type, and the signature is where
     # one fits.
-    #: (untyped param) -> Array[untyped]
-    def files_in(param)
-      param.is_a?(Array) ? param.select { it.is_a?(Hash) && it[:tempfile] } : []
+    #: (untyped param) -> untyped
+    def file_in(param)
+      param if param.is_a?(Hash) && param[:tempfile]
+    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.
+    #: (?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
 
-    #: (?arrivals: Array[Import::Land::Arrival]?, ?notice: String?) -> String
-    def plan_upload_screen(arrivals: nil, notice: nil)
+    #: (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::PlanUpload.call(arrivals:, notice:)
+      Admin::ImportReview.call(upload:, file:, cards:, unknown:, group: Import::Land.default_group)
     end
   end
 end
diff --git a/script/macos-contacts.swift b/script/macos-contacts.swift
deleted file mode 100644
index f6ab711..0000000
--- a/script/macos-contacts.swift
+++ /dev/null
@@ -1,203 +0,0 @@
-// Reads and deletes this Mac's Contacts for an import plan
-// (docs/plans/2026-09-16-importing-from-macos.md, "Reading the Mac" and
-// "Removing the originals").
-//
-//   swift script/macos-contacts.swift read [limit]
-//   swift script/macos-contacts.swift show <identifier>...
-//   swift script/macos-contacts.swift delete <identifier>...
-//
-// read prints one JSON object per person in the iCloud account, companies
-// skipped, in Contacts.app's own order: the identifier, the framework's
-// vCard, every fetched key, and the note, which only AppleScript will give
-// up. Cards are read unmerged, so a contact linked to a card in another
-// account (Monica, or pro-tacts itself) is this account's card alone.
-//
-// show prints those same objects for the identifiers it is given, leaving
-// out the ones this Mac no longer has; delete deletes them one at a time
-// and prints, per identifier it still has, whether the delete took and the
-// error if it did not. An identifier this Mac no longer has is already
-// deleted, and prints nothing.
-
-import Contacts
-import Foundation
-
-func fail(_ message: String) -> Never {
-  FileHandle.standardError.write(Data("macos-contacts: \(message)\n".utf8))
-  exit(1)
-}
-
-// Every key but the note, which the framework strips from an unentitled
-// fetch without an error.
-let keys: [String] = [
-  CNContactIdentifierKey, CNContactTypeKey,
-  CNContactNamePrefixKey, CNContactGivenNameKey, CNContactMiddleNameKey, CNContactFamilyNameKey,
-  CNContactPreviousFamilyNameKey, CNContactNameSuffixKey, CNContactNicknameKey,
-  CNContactPhoneticGivenNameKey, CNContactPhoneticMiddleNameKey, CNContactPhoneticFamilyNameKey,
-  CNContactOrganizationNameKey, CNContactDepartmentNameKey, CNContactJobTitleKey,
-  CNContactPhoneticOrganizationNameKey,
-  CNContactBirthdayKey, CNContactNonGregorianBirthdayKey, CNContactDatesKey,
-  CNContactImageDataKey, CNContactThumbnailImageDataKey, CNContactImageDataAvailableKey,
-  CNContactPhoneNumbersKey, CNContactEmailAddressesKey, CNContactPostalAddressesKey,
-  CNContactUrlAddressesKey, CNContactRelationsKey, CNContactSocialProfilesKey,
-  CNContactInstantMessageAddressesKey,
-]
-
-func json(_ value: Any?) -> Any {
-  switch value {
-  case nil: return NSNull()
-  case let string as String: return string
-  case let data as Data: return data.base64EncodedString()
-  case let number as NSNumber: return number
-  case let components as DateComponents:
-    var fields: [String: Any] = [:]
-    if let calendar = components.calendar { fields["calendar"] = calendar.identifier.debugDescription }
-    if let era = components.era { fields["era"] = era }
-    if let year = components.year { fields["year"] = year }
-    if let month = components.month { fields["month"] = month }
-    if let day = components.day { fields["day"] = day }
-    if let leap = components.isLeapMonth { fields["leap_month"] = leap }
-    return fields
-  // A CNLabeledValue's type parameter is an Objective-C lightweight
-  // generic, so its parts are read by key rather than through a cast.
-  case let labeled as [NSObject] where labeled.allSatisfy({ $0 is CNLabeledValue<NSString> }):
-    return labeled.map {
-      ["identifier": json($0.value(forKey: "identifier")), "label": json($0.value(forKey: "label")), "value": json($0.value(forKey: "value"))]
-    }
-  case let phone as CNPhoneNumber: return phone.stringValue
-  case let address as CNPostalAddress:
-    return [
-      "street": address.street, "sub_locality": address.subLocality, "city": address.city,
-      "sub_administrative_area": address.subAdministrativeArea, "state": address.state,
-      "postal_code": address.postalCode, "country": address.country, "iso_country_code": address.isoCountryCode,
-    ]
-  case let relation as CNContactRelation: return relation.name
-  case let profile as CNSocialProfile:
-    return [
-      "url": profile.urlString, "username": profile.username,
-      "user_identifier": profile.userIdentifier, "service": profile.service,
-    ]
-  case let im as CNInstantMessageAddress: return ["username": im.username, "service": im.service]
-  default: fail("no JSON spelling for a \(type(of: value!))")
-  }
-}
-
-// Every person's note by identifier, in one Apple event, and every
-// identifier AppleScript knows, so a contact it spells differently fails
-// rather than reading as one with no note.
-func notes() -> (ids: Set<String>, notes: [String: String]) {
-  let source = "tell application \"Contacts\" to get {id, note} of every person"
-  var error: NSDictionary?
-  guard let result = NSAppleScript(source: source)?.executeAndReturnError(&error) else {
-    fail("reading notes through AppleScript: \(error ?? [:])")
-  }
-  guard let ids = result.atIndex(1), let notes = result.atIndex(2), ids.numberOfItems == notes.numberOfItems else {
-    fail("AppleScript answered notes in an unexpected shape")
-  }
-  var known: Set<String> = []
-  var byId: [String: String] = [:]
-  if ids.numberOfItems > 0 {
-    for index in 1...ids.numberOfItems {
-      guard let id = ids.atIndex(index)?.stringValue else { fail("a person with no id") }
-      known.insert(id)
-      if let note = notes.atIndex(index)?.stringValue { byId[id] = note }
-    }
-  }
-  return (known, byId)
-}
-
-let arguments = Array(CommandLine.arguments.dropFirst())
-let usage = "usage: macos-contacts.swift read [limit] | show <identifier>... | delete <identifier>..."
-guard let command = arguments.first, ["read", "show", "delete"].contains(command) else { fail(usage) }
-let rest = Array(arguments.dropFirst())
-
-var limit: Int? = nil
-if command == "read" {
-  if let first = rest.first {
-    guard let n = Int(first), n > 0 else { fail("a limit is a positive number") }
-    limit = n
-  }
-} else {
-  guard !rest.isEmpty else { fail(usage) }
-}
-
-let store = CNContactStore()
-let icloud: [CNContainer]
-do {
-  icloud = try store.containers(matching: nil).filter { $0.name == "iCloud" }
-} catch {
-  fail("listing accounts: \(error)")
-}
-guard icloud.count == 1 else { fail("found \(icloud.count) accounts named iCloud, and reads exactly one") }
-
-let request = CNContactFetchRequest(
-  keysToFetch: keys.map { $0 as NSString } + [CNContactVCardSerialization.descriptorForRequiredKeys()])
-request.predicate =
-  command == "read"
-  ? CNContact.predicateForContactsInContainer(withIdentifier: icloud[0].identifier)
-  // An identifier names one card, and the named ones were read out of
-  // iCloud by an earlier run of this script.
-  : CNContact.predicateForContacts(withIdentifiers: rest)
-// Merged contacts carry identifiers no card has, which AppleScript cannot
-// find and a delete could not name.
-request.unifyResults = false
-request.sortOrder = .userDefault
-
-var people: [CNContact] = []
-do {
-  try store.enumerateContacts(with: request) { contact, stop in
-    guard contact.contactType == .person else { return }
-    people.append(contact)
-    if let limit, people.count >= limit { stop.pointee = true }
-  }
-} catch {
-  fail("reading contacts: \(error)")
-}
-
-if command == "delete" {
-  // One save request per contact, because CNSaveRequest is all or nothing
-  // and the store refuses some contacts it will hand over quite happily:
-  // batched, one of those takes every other delete down with it. An
-  // identifier that matched nothing prints no line, a delete of a contact
-  // this Mac no longer has being a delete that already happened.
-  for contact in people {
-    guard let mutable = contact.mutableCopy() as? CNMutableContact else { fail("\(contact.identifier) will not copy") }
-    let request = CNSaveRequest()
-    request.delete(mutable)
-    var result: [String: Any] = ["identifier": contact.identifier]
-    do {
-      try store.execute(request)
-      result["deleted"] = true
-    } catch {
-      result["deleted"] = false
-      result["error"] = "\(error)"
-    }
-    let line = try! JSONSerialization.data(withJSONObject: result, options: [.sortedKeys])
-    FileHandle.standardOutput.write(line + Data("\n".utf8))
-  }
-  exit(0)
-}
-
-let (noted, noteById) = notes()
-for contact in people {
-  guard noted.contains(contact.identifier) else {
-    fail("AppleScript has no person \(contact.identifier), so its note cannot be read")
-  }
-  for key in keys where !contact.isKeyAvailable(key) {
-    fail("\(key) came back unavailable for \(contact.identifier)")
-  }
-  var fields: [String: Any] = [:]
-  for key in keys { fields[key] = json(contact.value(forKey: key)) }
-
-  let vcard: String
-  do {
-    vcard = String(decoding: try CNContactVCardSerialization.data(with: [contact]), as: UTF8.self)
-  } catch {
-    fail("serializing \(contact.identifier): \(error)")
-  }
-
-  let object: [String: Any] = [
-    "identifier": contact.identifier, "vcard": vcard, "contact": fields, "note": json(noteById[contact.identifier]),
-  ]
-  let line = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
-  FileHandle.standardOutput.write(line + Data("\n".utf8))
-}
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 36b309e..0cbe91c 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -38,8 +38,12 @@ module ProTacts
       def self.call: (hostname: String, name: String) -> String
     end
 
-    class PlanUpload
-      def self.call: (?arrivals: Array[::ProTacts::Import::Land::Arrival]?, ?notice: String?) -> String
+    class ImportUpload
+      def self.call: (?landed: Array[Contact]?, ?notice: String?) -> String
+    end
+
+    class ImportReview
+      def self.call: (upload: String, file: String, cards: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String) -> String
     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 fc08004..729d645 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -1,100 +1,16 @@
-# Import::Card, Import::Plan::Entry, ::Contact, Import::Land::Arrival, and
-# Import::HttpClient::Response 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. _Client is an
-# interface, which inline syntax has no way to declare.
+# 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.
 module ProTacts
   module Import
-    class Card
-      attr_reader first: String
-      attr_reader last: String
-      attr_reader nickname: String?
-      attr_reader birthday: String?
-      attr_reader phones: Array[Hash[String, String]]
-      attr_reader emails: Array[String]
-      attr_reader addresses: Array[Hash[String, String]]
-      attr_reader note: String?
-      attr_reader photo: bool
-      attr_reader groups: Array[String]
-      attr_reader source: Hash[String, untyped]
-
-      def self.new: (first: String, last: String, nickname: String?, birthday: String?, phones: Array[Hash[String, String]], emails: Array[String], addresses: Array[Hash[String, String]], note: String?, photo: bool, groups: Array[String], source: Hash[String, untyped]) -> instance
-
-      # Data#with, for the one member a plan fills in itself.
-      def with: (groups: Array[String]) -> Card
-    end
-
-    class Plan
-      class Entry
-        attr_reader id: String
-        attr_reader source_id: String
-        attr_reader card: Card
-
-        def self.new: (id: String, source_id: String, card: Card) -> instance
-      end
-
-      class Contact
-        attr_reader id: String
-        attr_reader source_id: String
+    module Vcf
+      class Unknown
         attr_reader name: String
-        attr_reader status: String?
-
-        def self.new: (id: String, source_id: String, name: String, status: String?) -> instance
-      end
-    end
-
-    class Config
-      ROOT: Pathname
-      FILE: Pathname
-      ACTIVE: Pathname
-      DONE: Pathname
-
-      attr_reader host: URI::HTTP
-
-      def self.members: () -> Array[Symbol]
-      def self.new: (host: URI::HTTP) -> instance
-      def self.read: (?Pathname path) -> instance
-    end
+        attr_reader count: Integer
+        attr_reader examples: Array[String]
 
-    class Finalize
-      class Result
-        attr_reader done: Integer
-        attr_reader kept: Array[[String, String, String]]
-
-        def self.new: (done: Integer, kept: Array[[String, String, String]]) -> instance
-      end
-    end
-
-    class HttpClient
-      class Response
-        attr_reader status: Integer
-        attr_reader headers: Hash[String, String]
-        attr_reader body: String
-
-        def self.new: (status: Integer, headers: Hash[String, String], body: String) -> instance
-      end
-    end
-
-    class Land
-      class Arrival
-        attr_reader contact: ProTacts::Contact
-        attr_reader stored: bool
-
-        def self.new: (contact: ProTacts::Contact, stored: bool) -> instance
+        def self.new: (name: String, count: Integer, examples: Array[String]) -> instance
       end
     end
-
-    # A host, spoken to by path: Import::HttpClient, or the app itself
-    # under test.
-    interface _Client
-      def call: (String method, String path, ?headers: Hash[String, String], ?body: String?) -> HttpClient::Response
-    end
-
-    # The Mac a plan was read from: Import::Macos::Mac, or a test's stand-in.
-    interface _Mac
-      def show: (Array[String] source_ids) -> Hash[String, untyped]
-
-      def delete: (Array[String] source_ids) -> Hash[String, String]
-    end
   end
 end
diff --git a/tasks/import.rake b/tasks/import.rake
deleted file mode 100644
index ee3e375..0000000
--- a/tasks/import.rake
+++ /dev/null
@@ -1,151 +0,0 @@
-# Importing an address book from another source
-# (docs/plans/2026-09-16-importing-from-macos.md,
-# docs/plans/2026-09-19-import-under-data-import.md for the layout
-# and the vocabulary, and docs/plans/2026-09-20-import-by-upload.md
-# for the middle step, which is a screen in the app rather than a
-# task here).
-
-require "fileutils"
-require "pathname"
-
-require "pro_tacts/import/config"
-
-# The plan a task carries further. A module rather than task-file methods,
-# which rake redefines noisily when a file is loaded twice.
-module ImportTasks
-  Config = ProTacts::Import::Config
-
-  # What the one carrying task left here waits on — a contact still to
-  # finish — named so a status read can say what it would pick next
-  # without restating its choice. Landing is the import screen's, and
-  # writes nothing back to a plan, so a contact is outstanding until
-  # finalize takes it off this Mac.
-  TO_FINALIZE = ->(plan) { plan.outstanding.any? }
-
-  # The plan PLAN names, or the oldest one still waiting for this step:
-  # plans are carried in the order they were built, so the next one to
-  # take further is the oldest that has not been. A PLAN that is not a
-  # directory is read as a plan's name under Config::ACTIVE, the way
-  # the directories there are named.
-  def self.plan(waiting, step)
-    named = ENV.fetch("PLAN", nil)&.then { Pathname.new(it) }
-    named = Config::ACTIVE / named if named && !named.directory?
-    if named
-      abort "#{named} holds no plan.yml" unless (named / "plan.yml").file?
-      return ProTacts::Import::Plan.read(named)
-    end
-
-    ProTacts::Import::Plan.all(Config::ACTIVE).find(&waiting) ||
-      abort("no plan in #{Config::ACTIVE} is waiting #{step}: run rake import:macos:plan, or name one in PLAN")
-  end
-
-  # A finished plan's final state: moved under Config::DONE, nothing
-  # left to carry. The finalize task files one away as its last step;
-  # the status read sweeps any a dead run left behind.
-  def self.file_away(plan)
-    FileUtils.mkdir_p(Config::DONE)
-    FileUtils.mv(plan.dir, Config::DONE / plan.dir.basename)
-  end
-end
-
-namespace :import do
-  namespace :macos do
-    desc "Plan importing this Mac's iCloud contacts into data/import/active (LIMIT=n for the first n)"
-    task :plan do
-      require "pro_tacts/import/macos"
-
-      created_at = Time.now.utc
-      dir = ImportTasks::Config::ACTIVE / "macos-#{created_at.strftime("%Y%m%dT%H%M%SZ")}"
-      limit = ENV.fetch("LIMIT", nil)&.then { Integer(it) }
-
-      records = ProTacts::Import::Macos.read(limit:)
-      plan = ProTacts::Import::Macos.plan(dir, records, created_at:)
-      phone = ->(row) { [row.fetch("number"), row["label"]].compact.join(" ") }
-      plan.contacts.each do |contact|
-        card = plan.card(contact.id)
-        puts "#{contact.id}  #{contact.name}#{card.phones.map { "  #{phone.call(it)}" }.join}"
-      end
-      puts "#{plan.contacts.size} contacts planned in #{dir}"
-    rescue ProTacts::Import::Macos::Unknown => error
-      abort error.message
-    end
-
-    desc "Finalize the contacts the oldest unfinished plan landed, taking them off this Mac and filing the plan away done (PLAN=dir for another)"
-    task :finalize do
-      require "net/http"
-      require "pro_tacts/import/http_client"
-      require "pro_tacts/import/macos"
-      require "pro_tacts/import/finalize"
-
-      plan = ImportTasks.plan(ImportTasks::TO_FINALIZE, "to finalize")
-      # The host in the config rather than one the plan recorded: the
-      # import screen lands a plan and writes nothing back to it, so the
-      # config is the only place a host is named
-      # (docs/plans/2026-09-20-import-by-upload.md). Finalize asks it
-      # about every contact before deleting any, so a plan whose cards
-      # were never uploaded keeps all of them.
-      uri = ImportTasks::Config.read.host
-      puts "finalizing the contacts #{plan.dir} landed on #{uri}"
-
-      result = nil #: ProTacts::Import::Finalize::Result?
-      Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
-        result = ProTacts::Import::Finalize.call(
-          plan, host: uri.to_s, client: ProTacts::Import::HttpClient.new(http), mac: ProTacts::Import::Macos::Mac
-        )
-      end
-      result.kept.each { |id, name, why| puts "kept #{id} #{name}: #{why}" }
-      puts "#{result.done} contacts finalized"
-      if plan.done?
-        ImportTasks.file_away(plan)
-        puts "filed #{plan.dir.basename} away in #{ImportTasks::Config::DONE}"
-      end
-    end
-  end
-
-  desc "Summarize the plans in data/import — in flight and filed away done — and what would carry the next one further"
-  task :status do
-    require "pro_tacts/import/plan"
-
-    # A finished plan files away; this sweeps any a dead run left in
-    # active/, so the in-flight list below is what still has work —
-    # which is also why nothing in it is ever finished: the sweep took
-    # it.
-    ProTacts::Import::Plan.all(ImportTasks::Config::ACTIVE).each do |plan|
-      ImportTasks.file_away(plan) if plan.done?
-    end
-
-    plans = ProTacts::Import::Plan.all(ImportTasks::Config::ACTIVE)
-    filed = ProTacts::Import::Plan.all(ImportTasks::Config::DONE)
-    if plans.empty? && filed.empty?
-      puts "no plans in #{ImportTasks::Config::ACTIVE}; run rake import:macos:plan"
-      next
-    end
-
-    # One line per plan in flight, as far along as it is. The fraction
-    # counts the contacts finalized, which is the only progress a plan
-    # still records: landing happens in the app and leaves no mark here
-    # (docs/plans/2026-09-20-import-by-upload.md), so a glance answers
-    # “how much of this is off the Mac”. The plan names are the
-    # directories', copy-pasteable into PLAN=.
-    in_flight = plans.map {
-      "#{it.dir.basename}  #{it.contacts.size - it.outstanding.size}/#{it.contacts.size} finalized"
-    }
-
-    finalizing = plans.find(&ImportTasks::TO_FINALIZE)
-    next_up = finalizing ? [
-      "next: /import would land the cards of #{finalizing.dir.basename}",
-      "next: import:macos:finalize would finish #{finalizing.dir.basename}",
-    ] : []
-
-    # What was carried through, whole counts because every contact
-    # reached the final state — the fraction above can never appear
-    # here.
-    done = filed.map { "#{it.dir.basename}  #{it.contacts.size} contacts" }
-
-    # Sections separated by a blank line only where one follows
-    # another, so a status with no plans in flight says nothing of
-    # next steps and still reports its history.
-    sections = [in_flight, next_up, done.empty? ? [] : ["done:", *done]].reject(&:empty?)
-    puts sections.map { it.join("\n") }.join("\n\n")
-  end
-end
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
new file mode 100644
index 0000000..bcb339c
--- /dev/null
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -0,0 +1,190 @@
+require_relative "../../test_helper"
+
+require "pathname"
+require "rack/test"
+require "tmpdir"
+
+require "pro_tacts/web"
+
+# The import screens, exercised the way the other admin screens are:
+# real requests through the Roda app against a throwaway store
+# (docs/plans/2026-09-21-import-a-vcf.md).
+class AdminImportPagesTest < Minitest::Test
+  include Rack::Test::Methods
+  include ThrowawayContacts
+
+  JANE = <<~CARD.gsub("\n", "\r\n")
+    BEGIN:VCARD
+    VERSION:3.0
+    N:Booles;Jane;;;
+    FN:Jane Booles
+    item1.X-ABRELATEDNAMES:Sam Booles
+    item1.X-ABLabel:_$!<Spouse>!$_
+    X-SOCIALPROFILE;type=twitter:https://twitter.com/jb
+    UID:ABC-123
+    END:VCARD
+  CARD
+
+  PLAIN = <<~CARD.gsub("\n", "\r\n")
+    BEGIN:VCARD
+    VERSION:3.0
+    N:Booles;Sam;;;
+    FN:Sam Booles
+    UID:DEF-456
+    END:VCARD
+  CARD
+
+  def app
+    ProTacts::Web
+  end
+
+  def setup
+    header "Remote-User", "test@example.com"
+  end
+
+  # The file as a browser sends one, written out because Rack::Test
+  # uploads a path.
+  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
+  end
+
+  # The ticket to the staged file, as the review screen carries it.
+  def staged = last_response.body[/name="upload" value="([a-z]+)"/, 1]
+
+  def test_the_screen_asks_for_a_vcf
+    get "/import"
+
+    assert_equal 200, last_response.status
+    assert_includes last_response.body, %(<form action="/import" method="post" enctype="multipart/form-data")
+    assert_includes last_response.body, %(<input type="file" name="vcf" accept=".vcf,text/vcard">)
+  end
+
+  def test_an_upload_is_reviewed_before_anything_lands
+    with_contacts({}) do |store|
+      upload(JANE + PLAIN)
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, "contacts.vcf"
+      assert_includes last_response.body, "2 contacts, ready to land."
+      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) }
+
+    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[keep drop note].each do |choice|
+      assert_includes last_response.body,
+                      %(<input type="radio" name="decide[X-SOCIALPROFILE]" value="#{choice}")
+    end
+    # Keeping loses nothing, so it is what an unread form sends.
+    assert_includes last_response.body,
+                    %(name="decide[X-SOCIALPROFILE]" value="keep" checked>)
+  end
+
+  def test_a_file_this_book_reads_whole_has_nothing_to_decide
+    with_contacts({}) { upload(PLAIN) }
+
+    assert_includes last_response.body, "Nothing to decide."
+    refute_includes last_response.body, "decide["
+  end
+
+  def test_confirming_lands_the_cards_with_the_decisions_applied
+    with_contacts({}) do |store|
+      upload(JANE)
+
+      post "/import/land",
+           "upload" => staged,
+           "group" => "import-20260921T031655Z",
+           "decide" => {"X-SOCIALPROFILE" => "drop", "X-ABRELATEDNAMES" => "note"}
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, "landed (1)"
+
+      contact = store.contacts.fetch(0)
+
+      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"
+    end
+  end
+
+  # The default is the choice that loses nothing, so a form submitted
+  # without touching a radio keeps everything.
+  def test_confirming_with_no_answers_keeps_everything
+    with_contacts({}) do |store|
+      upload(JANE)
+
+      post "/import/land", "upload" => staged, "group" => ""
+
+      assert_includes store.contacts.fetch(0).vcard.to_s, "X-SOCIALPROFILE"
+      assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
+    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
+    with_contacts({}) do |store|
+      upload(JANE)
+      id = staged
+
+      post "/import/land", "upload" => id, "group" => ""
+      post "/import/land", "upload" => id, "group" => ""
+
+      assert_includes last_response.body, "That upload 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
+    with_contacts({}) do |store|
+      post "/import/land", "upload" => "../../contacts.db", "group" => ""
+
+      assert_includes last_response.body, "That upload is no longer here."
+      assert_empty store.changes
+    end
+  end
+
+  def test_an_upload_with_no_file_says_what_to_choose
+    with_contacts({}) do |store|
+      post "/import"
+
+      assert_includes last_response.body, "Choose a .vcf file to import."
+      assert_empty store.changes
+    end
+  end
+
+  def test_a_file_that_is_not_a_set_of_vcards_lands_nothing
+    with_contacts({}) do |store|
+      upload("hello\r\n")
+
+      assert_includes last_response.body, "a line outside a card"
+      assert_empty store.changes
+    end
+  end
+
+  # 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
+    with_contacts({}) do |store|
+      upload(JANE.b.sub("Jane".b, "Jan\xFF".b))
+
+      assert_includes last_response.body, "contacts.vcf is not UTF-8 text."
+      assert_empty store.changes
+    end
+  end
+end
diff --git a/test/pro_tacts/admin/test_plan_upload.rb b/test/pro_tacts/admin/test_plan_upload.rb
deleted file mode 100644
index b911ced..0000000
--- a/test/pro_tacts/admin/test_plan_upload.rb
+++ /dev/null
@@ -1,133 +0,0 @@
-require_relative "../../test_helper"
-
-require "pathname"
-require "rack/test"
-require "tmpdir"
-
-require "pro_tacts/import/plan"
-require "pro_tacts/web"
-
-# The import screen, exercised the way the other admin screens are:
-# real requests through the Roda app against a throwaway store
-# (docs/plans/2026-09-20-import-by-upload.md).
-class AdminPlanUploadTest < Minitest::Test
-  include Rack::Test::Methods
-  include ThrowawayContacts
-
-  Plan = ProTacts::Import::Plan
-
-  IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
-
-  def app
-    ProTacts::Web
-  end
-
-  def setup
-    header "Remote-User", "test@example.com"
-  end
-
-  # A plan on disk, as `rake import:macos:plan` leaves one, and a store
-  # with nothing in it for its cards to land in.
-  def with_plan
-    Dir.mktmpdir do |tmp|
-      dir = Pathname.new(tmp) / "plan"
-      entries = IDS.map { |id|
-        source = {"identifier" => "#{id}:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
-        card = ProTacts::Import::Card.new(first: "Contact", last: id, nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source:)
-        Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card:)
-      }
-      Plan.write(dir, source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries:)
-      with_contacts({}) { |store| yield dir, store }
-    end
-  end
-
-  def upload(path) = Rack::Test::UploadedFile.new(path.to_s, "application/yaml", true)
-
-  def upload_cards(dir, paths = IDS.map { dir / "cards/#{it}.yml" })
-    post "/import", "cards" => paths.map { upload(it) }
-  end
-
-  def test_the_screen_asks_for_a_plans_cards
-    with_plan do |_dir, _store|
-      get "/import"
-
-      assert_equal 200, last_response.status
-      assert_includes last_response.body, %(<form action="/import" method="post" enctype="multipart/form-data")
-      assert_includes last_response.body, %(<input type="file" name="cards[]" multiple accept=".yml">)
-    end
-  end
-
-  def test_uploaded_cards_land_as_contacts_in_the_plans_group
-    with_plan do |dir, store|
-      upload_cards(dir)
-
-      assert_equal 200, last_response.status
-      IDS.each { assert_equal "Contact #{it}", store.contact(it).name }
-      assert_equal IDS.sort, store.all_groups.find { it.name == "import-20260916T180412Z" }.members.sort
-      assert_includes last_response.body, "landed (2 of 2)"
-      IDS.each { assert_includes last_response.body, %(href="/contacts/#{it}") }
-    end
-  end
-
-  def test_a_card_already_here_is_said_so_rather_than_landed_again
-    with_plan do |dir, store|
-      upload_cards(dir)
-      changes = store.changes.size
-
-      upload_cards(dir)
-
-      assert_includes last_response.body, "landed (0 of 2)"
-      assert_includes last_response.body, "already here"
-      assert_equal changes, store.changes.size
-    end
-  end
-
-  def test_an_upload_with_no_files_says_what_to_choose
-    with_plan do |_dir, store|
-      post "/import"
-
-      assert_includes last_response.body, "Choose the card files in a plan&#39;s cards directory."
-      assert_empty store.changes
-    end
-  end
-
-  def test_a_card_that_will_not_read_lands_nothing
-    with_plan do |dir, store|
-      path = dir / "cards/#{IDS.last}.yml"
-      path.write(path.read.sub("last: #{IDS.last}", "last: no"))
-
-      upload_cards(dir)
-
-      assert_includes last_response.body, "#{IDS.last}.yml: first and last must be text; quote them"
-      assert_empty store.changes
-    end
-  end
-
-  # The filename is the id the card lands under, so a file named
-  # anything else is a file from somewhere other than a plan.
-  def test_a_file_that_is_not_a_plan_card_lands_nothing
-    with_plan do |dir, store|
-      (dir / "cards/notes.txt").write("hello")
-
-      upload_cards(dir, [dir / "cards/notes.txt"])
-
-      assert_includes last_response.body, "notes.txt is not a plan card"
-      assert_empty store.changes
-    end
-  end
-
-  # 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_card_that_is_not_utf_8_lands_nothing
-    with_plan do |dir, store|
-      path = dir / "cards/#{IDS.first}.yml"
-      path.binwrite(path.read.sub("Contact", "Contac\xFF"))
-
-      upload_cards(dir)
-
-      assert_includes last_response.body, "#{IDS.first}.yml is not UTF-8 text."
-      assert_empty store.changes
-    end
-  end
-end
diff --git a/test/pro_tacts/import/test_card.rb b/test/pro_tacts/import/test_card.rb
deleted file mode 100644
index 70adc43..0000000
--- a/test/pro_tacts/import/test_card.rb
+++ /dev/null
@@ -1,193 +0,0 @@
-require_relative "../../test_helper"
-
-require "pathname"
-require "tmpdir"
-
-require "pro_tacts/import/card"
-
-class ImportCardTest < Minitest::Test
-  Card = ProTacts::Import::Card
-
-  ADA = <<~YAML
-    first: Ada
-    last: Lovelace
-    nickname: The Countess
-    birthday: '1815-12-10'
-    phones:
-    - number: "+12532189075"
-    - number: "+17863533882"
-      label: Google Voice
-    emails:
-    - ada@example.com
-    addresses:
-    - street: 12 Marylebone
-      locality: London
-    note: An analyst.
-    photo: false
-    groups:
-    - sync:*
-    - import-20260916T180412Z
-    source:
-      identifier: A:ABPerson
-      vcard: "BEGIN:VCARD\\r\\nEND:VCARD\\r\\n"
-      note:
-      contact: {}
-  YAML
-
-  SOURCE = {"identifier" => "A:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}.freeze
-
-  def read(yaml)
-    Dir.mktmpdir do |tmp|
-      path = Pathname.new(tmp) / "kmnuqmzxylru.yml"
-      path.write(yaml)
-      Card.read(path)
-    end
-  end
-
-  def assert_invalid(yaml, message)
-    error = assert_raises(Card::Invalid) { read(yaml) }
-    assert_includes error.message, message
-  end
-
-  def test_a_card_reads_its_fields
-    assert_equal Card.new(first: "Ada", last: "Lovelace", nickname: "The Countess", birthday: "1815-12-10", phones: [{"number" => "+12532189075"}, {"number" => "+17863533882", "label" => "Google Voice"}], emails: ["ada@example.com"], addresses: [{"street" => "12 Marylebone", "locality" => "London"}], note: "An analyst.", photo: false, groups: ["sync:*", "import-20260916T180412Z"], source: SOURCE), read(ADA)
-  end
-
-  def test_a_card_is_the_contact_the_web_create_and_add_rows_make
-    contact = read(ADA).contact("kmnuqmzxylru")
-
-    assert_equal "kmnuqmzxylru", contact.id
-    assert_equal <<~CARD.gsub("\n", "\r\n"), contact.vcard.to_s
-      BEGIN:VCARD
-      VERSION:3.0
-      N:Lovelace;Ada;;;
-      FN:Ada Lovelace
-      UID:kmnuqmzxylru
-      NICKNAME:The Countess
-      NOTE:An analyst.
-      TEL:+12532189075
-      EMAIL:ada@example.com
-      ADR:;;12 Marylebone;London;;;
-      item1.TEL:+17863533882
-      item1.X-ABLabel:Google Voice
-      BDAY:1815-12-10
-      END:VCARD
-    CARD
-  end
-
-  def test_a_name_is_escaped_into_the_card
-    card = Card.new(first: "Ada; Countess", last: "", nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
-
-    assert_equal ["Ada; Countess"], [card.contact("kmnuqmzxylru").name]
-  end
-
-  def test_the_document_reads_back_as_the_card
-    card = read(ADA)
-
-    assert_equal card, read(YAML.dump(card.document))
-  end
-
-  def test_a_missing_field_is_refused
-    assert_invalid ADA.sub("phones:\n- number: \"+12532189075\"\n- number: \"+17863533882\"\n  label: Google Voice\n", ""), "has no phones"
-  end
-
-  def test_a_field_no_card_takes_is_refused
-    assert_invalid "#{ADA}url: https://example.com\n", "has url, which no field takes"
-  end
-
-  def test_an_unquoted_number_is_refused
-    assert_invalid ADA.sub('number: "+12532189075"', "number: +12532189075"), "phones must be a list, each with a quoted number and an optional label"
-  end
-
-  # The shape a plan built before labels carried, refused rather than
-  # read leniently: no plan is re-read, but a stale one a person lands
-  # by hand is news, not a silent loss of every number
-  # (docs/plans/2026-09-18-phone-labels.md, "The plan card carries
-  # one").
-  def test_a_bare_string_phone_is_refused
-    assert_invalid ADA.sub("phones:\n- number: \"+12532189075\"\n- number: \"+17863533882\"\n  label: Google Voice\n", "phones:\n- \"+12532189075\"\n"), "phones must be a list, each with a quoted number and an optional label"
-  end
-
-  def test_a_phone_with_no_number_is_refused
-    assert_invalid ADA.sub("- number: \"+17863533882\"\n  label: Google Voice", "- number: ''\n  label: Google Voice"), "phones must be a list"
-  end
-
-  # The label the builder leaves out rather than writes empty, an edit
-  # putting back as one.
-  def test_a_blank_label_is_refused
-    assert_invalid ADA.sub("label: Google Voice", "label: ''"), "phones must be a list"
-  end
-
-  # A label is text, escaped as text is: a comma in one must not read
-  # as structure when the card is read back.
-  def test_a_label_is_escaped_into_its_line
-    labeled = ADA.sub("label: Google Voice", "label: Work, mobile")
-
-    assert_includes read(labeled).contact("kmnuqmzxylru").vcard.to_s, "item1.X-ABLabel:Work\\, mobile\r\n"
-  end
-
-  def test_a_name_yaml_reads_as_something_else_is_refused
-    assert_invalid ADA.sub("last: Lovelace", "last: no"), "first and last must be text"
-  end
-
-  def test_a_card_with_no_name_is_refused
-    assert_invalid ADA.sub("first: Ada", "first: ''").sub("last: Lovelace", "last: ' '"), "needs a first or last name"
-  end
-
-  def test_an_unquoted_date_is_refused
-    assert_invalid ADA.sub("birthday: '1815-12-10'", "birthday: 1815-12-10"), "reads as a Date; quote it"
-  end
-
-  def test_a_birthday_that_is_not_a_whole_date_is_refused
-    assert_invalid ADA.sub("birthday: '1815-12-10'", "birthday: '--12-10'"), "birthday must be a quoted YYYY-MM-DD"
-  end
-
-  def test_a_card_with_no_birthday_reads_and_writes_the_blank
-    card = read(ADA.sub("birthday: '1815-12-10'", "birthday:"))
-
-    assert_nil card.birthday
-    refute_includes card.contact("kmnuqmzxylru").vcard.to_s, "BDAY"
-  end
-
-  def test_an_address_part_no_field_takes_is_refused
-    assert_invalid ADA.sub("  locality: London", "  po_box: '4'"), "addresses must be a list, each with any of"
-  end
-
-  def test_a_picture_the_source_holds_is_a_photo_line
-    jpeg = ["\xFF\xD8\xFFhello".b].pack("m0")
-    source = SOURCE.merge("contact" => {"imageData" => jpeg})
-    card = read(ADA.sub("photo: false", "photo: true").sub("  contact: {}", "  contact:\n    imageData: #{jpeg}"))
-
-    assert_equal source.fetch("contact"), card.source.fetch("contact")
-    assert_includes card.contact("kmnuqmzxylru").vcard.to_s, "PHOTO;ENCODING=b;TYPE=JPEG:#{jpeg}\r\n"
-    assert_equal "image/jpeg", card.contact("kmnuqmzxylru").photo.mime_type
-  end
-
-  def test_a_thumbnail_is_the_picture_when_the_source_holds_no_image
-    jpeg = ["\xFF\xD8\xFFthumbnail".b].pack("m0")
-    card = read(ADA.sub("photo: false", "photo: true").sub("  contact: {}", "  contact:\n    imageData:\n    thumbnailImageData: #{jpeg}"))
-
-    assert_includes card.contact("kmnuqmzxylru").vcard.to_s, "PHOTO;ENCODING=b;TYPE=JPEG:#{jpeg}\r\n"
-  end
-
-  def test_the_full_image_wins_over_the_thumbnail
-    image = ["\xFF\xD8\xFFimage".b].pack("m0")
-    thumbnail = ["\xFF\xD8\xFFthumbnail".b].pack("m0")
-    card = read(ADA.sub("photo: false", "photo: true")
-      .sub("  contact: {}", "  contact:\n    imageData: #{image}\n    thumbnailImageData: #{thumbnail}"))
-
-    assert_includes card.contact("kmnuqmzxylru").vcard.to_s, "PHOTO;ENCODING=b;TYPE=JPEG:#{image}\r\n"
-  end
-
-  def test_a_photo_the_source_cannot_supply_is_refused
-    assert_invalid ADA.sub("photo: false", "photo: true"), "photo is true and the source holds no picture"
-  end
-
-  def test_a_blank_group_is_refused
-    assert_invalid ADA.sub("- sync:*", "- ''"), "groups must be a list of names"
-  end
-
-  def test_a_file_that_is_not_a_mapping_is_refused
-    assert_invalid "- Ada\n", "is not a mapping"
-  end
-end
diff --git a/test/pro_tacts/import/test_config.rb b/test/pro_tacts/import/test_config.rb
deleted file mode 100644
index 60e35ca..0000000
--- a/test/pro_tacts/import/test_config.rb
+++ /dev/null
@@ -1,111 +0,0 @@
-require_relative "../../test_helper"
-
-require "pathname"
-require "tmpdir"
-require "uri"
-
-require "pro_tacts/import/config"
-
-class ImportConfigTest < Minitest::Test
-  Config = ProTacts::Import::Config
-
-  def test_the_mapping_is_read
-    with_config_file("host: https://contacts\n") do |path|
-      assert_equal Config.new(host: URI.parse("https://contacts")), Config.read(path)
-    end
-  end
-
-  def test_a_bare_hostname_is_read_as_https
-    with_config_file("host: contacts\n") do |path|
-      assert_equal URI.parse("https://contacts"), Config.read(path).host
-    end
-  end
-
-  def test_read_names_the_config_under_the_data_directory_by_default
-    Dir.mktmpdir do |dir|
-      Dir.chdir(dir) do
-        error = assert_raises(ArgumentError) { Config.read }
-
-        assert_equal "data/import/config.yml: does not exist", error.message
-      end
-    end
-  end
-
-  def test_a_missing_file_is_refused
-    Dir.mktmpdir do |dir|
-      path = Pathname.new(dir) / "config.yml"
-
-      error = assert_raises(ArgumentError) { Config.read(path) }
-
-      assert_equal "#{path}: does not exist", error.message
-    end
-  end
-
-  def test_an_empty_file_has_no_host
-    with_config_file("") do |path|
-      error = assert_raises(ArgumentError) { Config.read(path) }
-
-      assert_equal "#{path}: has no host", error.message
-    end
-  end
-
-  def test_a_file_whose_top_level_is_not_a_mapping_is_refused
-    with_config_file("- one\n- two\n") do |path|
-      error = assert_raises(ArgumentError) { Config.read(path) }
-
-      assert_equal "#{path}: is not a mapping", error.message
-    end
-  end
-
-  def test_a_key_no_member_takes_is_refused
-    with_config_file("hots: contacts\n") do |path|
-      error = assert_raises(ArgumentError) { Config.read(path) }
-
-      assert_equal "#{path}: has hots, which no member takes", error.message
-    end
-  end
-
-  def test_a_host_that_is_not_text_is_refused
-    with_config_file("host: 9292\n") do |path|
-      error = assert_raises(ArgumentError) { Config.read(path) }
-
-      assert_equal "#{path}: host must be text", error.message
-    end
-  end
-
-  def test_a_blank_host_is_refused
-    with_config_file("host: \"\"\n") do |path|
-      error = assert_raises(ArgumentError) { Config.read(path) }
-
-      assert_equal "#{path}: host is blank", error.message
-    end
-  end
-
-  def test_a_host_that_does_not_read_as_a_url_is_refused
-    with_config_file("host: exa mple\n") do |path|
-      error = assert_raises(ArgumentError) { Config.read(path) }
-
-      assert_equal "#{path}: host does not read as a URL", error.message
-    end
-  end
-
-  def test_a_host_that_is_not_http_or_https_is_refused
-    with_config_file("host: ftp://example.com\n") do |path|
-      error = assert_raises(ArgumentError) { Config.read(path) }
-
-      assert_equal "#{path}: host must be an http or https URL, such as https://contacts", error.message
-    end
-  end
-
-  private
-
-  #: (String content) { (Pathname) -> void } -> void
-  def with_config_file(content)
-    Dir.mktmpdir do |dir|
-      path = Pathname.new(dir) / "config.yml"
-      path.write(content)
-
-      yield path
-    end
-  end
-end
diff --git a/test/pro_tacts/import/test_finalize.rb b/test/pro_tacts/import/test_finalize.rb
deleted file mode 100644
index 9aec686..0000000
--- a/test/pro_tacts/import/test_finalize.rb
+++ /dev/null
@@ -1,195 +0,0 @@
-require_relative "../../test_helper"
-
-require "pathname"
-require "rack/mock"
-require "tmpdir"
-
-require "pro_tacts/import/finalize"
-require "pro_tacts/import/http_client"
-require "pro_tacts/import/land"
-require "pro_tacts/import/plan"
-require "pro_tacts/web"
-
-class ImportFinalizeTest < Minitest::Test
-  include ThrowawayContacts
-
-  Card = ProTacts::Import::Card
-  Land = ProTacts::Import::Land
-  Plan = ProTacts::Import::Plan
-  Finalize = ProTacts::Import::Finalize
-
-  HOST = "https://contacts"
-  IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
-
-  # The app in place of the host finalize asks about each card, the
-  # proxy's identity header included. Landing no longer goes over the
-  # wire (ProTacts::Import::Land), so this is all a client is for.
-  class RackClient
-    def call(method, path, headers: {}, body: nil)
-      env = Rack::MockRequest.env_for(path, method:, input: body, "HTTP_REMOTE_USER" => "test@example.com")
-      headers.each { |name, value| env[name.casecmp?("Content-Type") ? "CONTENT_TYPE" : "HTTP_#{name.upcase.tr("-", "_")}"] = value }
-      status, response_headers, response_body = ProTacts::Web.call(env)
-      bytes = +""
-      response_body.each { bytes << it }
-      response_body.close if response_body.respond_to?(:close)
-      ProTacts::Import::HttpClient::Response.new(status:, headers: response_headers, body: bytes)
-    end
-  end
-
-  # This Mac, as the contacts it still has: the reader's objects by source
-  # id, and a delete that takes them out of this hash. A source id in
-  # `refuses` is one the store will not delete, which is a real thing a Mac
-  # does (docs/plans/2026-09-16-importing-from-macos.md, "Removing the
-  # originals").
-  class Mac
-    attr_reader :deleted
-
-    def initialize(records, refuses: {})
-      @records = records
-      @refuses = refuses
-      @deleted = []
-    end
-
-    def show(source_ids) = @records.slice(*source_ids)
-
-    def delete(source_ids)
-      going = source_ids - @refuses.keys
-      @deleted.concat(going)
-      @records = @records.except(*going)
-      @refuses.slice(*source_ids)
-    end
-  end
-
-  def vcard(id) = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Contact #{id}\r\nEND:VCARD\r\n"
-
-  def source_id(id) = "#{id}:ABPerson"
-
-  def record(id, note: nil)
-    {"identifier" => source_id(id), "vcard" => vcard(id), "note" => note}
-  end
-
-  # A plan already landed on the host, since that is what finalize works on.
-  def with_landed_plan(notes: {}, edit: nil)
-    Dir.mktmpdir do |tmp|
-      dir = Pathname.new(tmp) / "plan"
-      entries = IDS.map { |id|
-        source = {"identifier" => source_id(id), "vcard" => vcard(id), "note" => notes[id], "contact" => {}}
-        card = Card.new(first: "Contact", last: id, nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source:)
-        Plan::Entry.new(id:, source_id: source_id(id), card:)
-      }
-      Plan.write(dir, source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries:)
-      IDS.each { (dir / "cards/#{it}.yml").write(edit.call((dir / "cards/#{it}.yml").read)) } if edit
-      with_contacts({}) do |store|
-        plan = Plan.read(dir)
-        Land.call(store, IDS.to_h { [it, plan.card(it)] })
-        yield plan, store, RackClient.new
-      end
-    end
-  end
-
-  def test_a_contact_that_matches_its_source_leaves_the_mac
-    with_landed_plan do |plan, _store, client|
-      mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
-
-      result = Finalize.call(plan, host: HOST, client:, mac:)
-
-      assert_equal 2, result.done
-      assert_empty result.kept
-      assert_equal IDS.map { source_id(it) }, mac.deleted
-      assert_equal %w[done done], Plan.read(plan.dir).contacts.map(&:status)
-    end
-  end
-
-  def test_a_contact_changed_on_the_mac_is_kept
-    with_landed_plan do |plan, _store, client|
-      records = IDS.to_h { [source_id(it), record(it)] }
-      records[source_id(IDS.first)]["vcard"] = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Renamed\r\nEND:VCARD\r\n"
-      mac = Mac.new(records)
-
-      result = Finalize.call(plan, host: HOST, client:, mac:)
-
-      assert_equal [[IDS.first, "Contact #{IDS.first}", "it has changed on this Mac since the plan"]], result.kept
-      assert_equal [source_id(IDS.last)], mac.deleted
-      assert_equal [nil, "done"], Plan.read(plan.dir).contacts.map(&:status)
-    end
-  end
-
-  def test_a_note_written_since_the_plan_keeps_its_contact
-    with_landed_plan(notes: {IDS.first => "Analyst."}) do |plan, _store, client|
-      records = IDS.to_h { [source_id(it), record(it)] }
-      records[source_id(IDS.first)]["note"] = "Analyst. Also a countess."
-      mac = Mac.new(records)
-
-      result = Finalize.call(plan, host: HOST, client:, mac:)
-
-      assert_equal [[IDS.first, "Contact #{IDS.first}", "its note has changed on this Mac since the plan"]], result.kept
-      assert_equal [source_id(IDS.last)], mac.deleted
-    end
-  end
-
-  def test_a_contact_whose_card_left_the_host_is_kept
-    with_landed_plan do |plan, store, client|
-      store.delete(IDS.first)
-      mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
-
-      result = Finalize.call(plan, host: HOST, client:, mac:)
-
-      assert_equal [[IDS.first, "Contact #{IDS.first}", "no card of its id is on #{HOST}"]], result.kept
-      assert_equal [source_id(IDS.last)], mac.deleted
-    end
-  end
-
-  # The card browser shows every card; /dav/addressbook serves the asking
-  # user's book, which a card in no `sync:` group is outside of.
-  def test_a_contact_whose_card_is_in_no_book_still_leaves_the_mac
-    with_landed_plan(edit: ->(yaml) { yaml.sub("- sync:*\n", "") }) do |plan, store, client|
-      refute_includes store.book_cards("test@example.com"), IDS.first
-      mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
-
-      result = Finalize.call(plan, host: HOST, client:, mac:)
-
-      assert_empty result.kept
-      assert_equal IDS.map { source_id(it) }, mac.deleted
-    end
-  end
-
-  def test_a_contact_already_off_the_mac_counts_as_done
-    with_landed_plan do |plan, _store, client|
-      mac = Mac.new({source_id(IDS.last) => record(IDS.last)})
-
-      result = Finalize.call(plan, host: HOST, client:, mac:)
-
-      assert_equal 2, result.done
-      assert_equal [source_id(IDS.last)], mac.deleted
-      assert_equal %w[done done], Plan.read(plan.dir).contacts.map(&:status)
-    end
-  end
-
-  # The store refuses some contacts it will hand over quite happily, so a
-  # refusal keeps its own contact and no other: the rest still leave, and
-  # the plan records them.
-  def test_a_contact_the_mac_will_not_delete_is_kept_alone
-    with_landed_plan do |plan, _store, client|
-      mac = Mac.new(IDS.to_h { [source_id(it), record(it)] }, refuses: {source_id(IDS.first) => "faulting, 134092"})
-
-      result = Finalize.call(plan, host: HOST, client:, mac:)
-
-      assert_equal 1, result.done
-      assert_equal [[IDS.first, "Contact #{IDS.first}", "this Mac would not delete #{source_id(IDS.first)}: faulting, 134092"]], result.kept
-      assert_equal [source_id(IDS.last)], mac.deleted
-      assert_equal [nil, "done"], Plan.read(plan.dir).contacts.map(&:status)
-    end
-  end
-
-  def test_a_rerun_finalizes_nothing_again
-    with_landed_plan do |plan, _store, client|
-      mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
-      Finalize.call(plan, host: HOST, client:, mac:)
-
-      result = Finalize.call(Plan.read(plan.dir), host: HOST, client:, mac:)
-
-      assert_equal 0, result.done
-      assert_equal IDS.map { source_id(it) }, mac.deleted
-    end
-  end
-end
diff --git a/test/pro_tacts/import/test_land.rb b/test/pro_tacts/import/test_land.rb
index 2b224fd..f50ab47 100644
--- a/test/pro_tacts/import/test_land.rb
+++ b/test/pro_tacts/import/test_land.rb
@@ -1,122 +1,158 @@
 require_relative "../../test_helper"
 
-require "pathname"
-require "tmpdir"
-
 require "pro_tacts/import/land"
-require "pro_tacts/import/plan"
-require "pro_tacts/web"
+require "pro_tacts/import/vcf"
 
-# Landing a plan's cards in the store, which is what the import screen
-# does with an upload (docs/plans/2026-09-20-import-by-upload.md). It
-# writes through Store, so these read the store back rather than a
-# response.
+# 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).
 class ImportLandTest < Minitest::Test
   include ThrowawayContacts
 
   Land = ProTacts::Import::Land
-  Plan = ProTacts::Import::Plan
-
-  IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
-
-  def with_plan(groups: [])
-    Dir.mktmpdir do |tmp|
-      dir = Pathname.new(tmp) / "plan"
-      entries = IDS.map { |id|
-        source = {"identifier" => "#{id}:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
-        card = ProTacts::Import::Card.new(first: "Contact", last: id, nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups:, source:)
-        Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card:)
-      }
-      Plan.write(dir, source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries:)
-      with_contacts({}) { |store| yield Plan.read(dir), store }
+  Vcf = ProTacts::Import::Vcf
+
+  JANE = <<~CARD.gsub("\n", "\r\n")
+    BEGIN:VCARD
+    VERSION:3.0
+    N:Booles;Jane;;;
+    FN:Jane Booles
+    NOTE:Met at work
+    item1.X-ABRELATEDNAMES:Sam Booles
+    item1.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)
+    with_contacts({}) do |store|
+      landed = Land.call(store, cards, decisions:, group:)
+      yield landed, store
     end
   end
 
-  def cards(plan) = IDS.to_h { [it, plan.card(it)] }
-
-  def imported_group(store) = group_named(store, "import-20260916T180412Z")
+  # Nothing rebuilds a card out of fields: the bytes that arrived are
+  # the bytes that are stored, minus the id this server gives it.
+  def test_a_kept_property_travels_with_the_card
+    land({"X-SOCIALPROFILE" => Vcf::KEEP}) do |landed, _store|
+      card = landed.fetch(0).vcard.to_s
 
-  def everyone(store) = group_named(store, "sync:*")
-
-  def group_named(store, name)
-    store.all_groups.find { it.name == name }
+      assert_includes card, "X-SOCIALPROFILE;type=twitter:https://twitter.com/jb\r\n"
+      assert_includes card, "item1.X-ABRELATEDNAMES:Sam Booles\r\n"
+    end
   end
 
-  def edit_cards(plan)
-    IDS.each do |id|
-      path = plan.dir / "cards/#{id}.yml"
-      path.write(yield(path.read))
+  # A property nobody was asked about is kept, which is every known
+  # one and any unknown one the form said nothing about.
+  def test_a_property_nobody_decided_is_kept
+    land({}) do |landed, _store|
+      assert_includes landed.fetch(0).vcard.to_s, "X-SOCIALPROFILE"
     end
   end
 
-  def test_every_card_lands_in_the_plans_group
-    with_plan do |plan, store|
-      arrivals = Land.call(store, cards(plan))
+  def test_a_dropped_property_is_gone
+    land({"X-SOCIALPROFILE" => Vcf::DROP}) do |landed, _store|
+      card = landed.fetch(0).vcard.to_s
 
-      IDS.each { assert_equal "Contact #{it}", store.contact(it).name }
-      assert_equal IDS.sort, imported_group(store).members.sort
-      assert_equal IDS.sort, everyone(store).members.sort
-      assert_equal [true, true], arrivals.map(&:stored)
-      assert_equal IDS, arrivals.map { it.contact.id }
+      refute_includes card, "X-SOCIALPROFILE"
+      assert_includes card, "FN:Jane Booles\r\n"
     end
   end
 
-  def test_a_second_upload_writes_nothing_again
-    with_plan do |plan, store|
-      Land.call(store, cards(plan))
-      changes = store.changes.size
+  # Apple hangs a row's label off the line it names rather than inside
+  # it, so a label whose line is dropped would be left naming nothing.
+  def test_a_dropped_line_takes_its_label_with_it
+    land({"X-ABRELATEDNAMES" => Vcf::DROP}) do |landed, _store|
+      card = landed.fetch(0).vcard.to_s
 
-      arrivals = Land.call(store, cards(plan))
+      refute_includes card, "X-ABRELATEDNAMES"
+      refute_includes card, "X-ABLabel"
+    end
+  end
 
-      assert_equal [false, false], arrivals.map(&:stored)
-      assert_equal changes, store.changes.size
-      assert_equal 1, store.all_groups.count { it.name == "import-20260916T180412Z" }
+  # The note is where a value goes when it is worth reading and there
+  # is 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, "X-ABLabel"
+      # 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
 
-  # The card on the server wins, the way the PUT's `If-None-Match: *`
-  # made it win: an upload is not an editor, and the person editing the
-  # card here would not expect a re-upload to revert them.
-  def test_a_card_already_here_is_not_written_over
-    with_plan do |plan, store|
-      Land.call(store, cards(plan))
-      edit_cards(plan) { it.sub("first: Contact", "first: Renamed") }
+  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
 
-      arrivals = Land.call(store, cards(plan))
+  def test_a_card_with_no_note_gets_one
+    cards = Vcf.cards(JANE.sub("NOTE:Met at work\r\n", ""))
 
-      assert_equal [false, false], arrivals.map(&:stored)
-      IDS.each { assert_equal "Contact #{it}", store.contact(it).name }
+    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
 
-  def test_a_group_already_here_is_joined_rather_than_made_again
-    with_plan do |plan, store|
-      id = store.create_group(name: "import-20260916T180412Z")
+  # 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|
+      id = landed.fetch(0).id
 
-      Land.call(store, cards(plan))
-
-      assert_equal id, imported_group(store).id
-      assert_equal IDS.sort, store.group(id).members.sort
+      assert ProTacts::ChangeId.minted?(id)
+      refute_includes landed.fetch(0).vcard.to_s, "ABC-123"
+      assert_includes landed.fetch(0).vcard.to_s, "UID:#{id}\r\n"
+      assert_equal id, store.card_id_with_uid(id)
     end
   end
 
-  def test_a_card_joins_the_groups_its_file_names_making_those_missing
-    with_plan(groups: ["family"]) do |plan, store|
-      Land.call(store, cards(plan))
+  # 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|
+      ids = landed.map(&:id)
+
+      assert_equal 2, ids.uniq.length
+      groups = store.all_groups.select { it.members.sort == ids.sort }
 
-      assert_equal IDS.sort, group_named(store, "family").members.sort
-      assert_equal 1, store.all_groups.count { it.name == "family" }
+      assert_equal [ProTacts::Store::EVERYONE, "import-20260921T031655Z"],
+                   groups.map(&:name).sort
     end
   end
 
-  def test_a_card_whose_file_leaves_out_everyone_is_taken_out_of_it
-    with_plan do |plan, store|
-      edit_cards(plan) { it.sub("- sync:*\n", "") }
+  # The group is looked up before it is made, and the first card's put
+  # is what creates `sync:*` — so a list read once up front would send
+  # the next lookup to create a group that now exists
+  # (db/migrations/008_group_names.rb).
+  def test_landing_into_a_group_that_already_exists_joins_it
+    with_contacts({}) do |store|
+      existing = store.create_group(name: "import-20260921T031655Z")
 
-      Land.call(store, cards(plan))
+      landed = Land.call(store, Vcf.cards(JANE + JANE), decisions: {}, group: "import-20260921T031655Z")
 
-      assert_empty everyone(store).members
-      assert_equal IDS.sort, imported_group(store).members.sort
+      assert_equal landed.map(&:id).sort, store.group(existing).members.sort
+      assert_equal 1, store.all_groups.count { it.name == "import-20260921T031655Z" }
     end
   end
+
+  def test_no_group_name_lands_the_cards_in_everyones_book_alone
+    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
+  end
+
+  # Named for when it happened, so what landed together can be found
+  # together.
+  def test_the_default_group_is_named_for_the_moment
+    assert_equal "import-20260921T031655Z", Land.default_group(Time.utc(2026, 9, 21, 3, 16, 55))
+  end
 end
diff --git a/test/pro_tacts/import/test_macos.rb b/test/pro_tacts/import/test_macos.rb
deleted file mode 100644
index 1b41440..0000000
--- a/test/pro_tacts/import/test_macos.rb
+++ /dev/null
@@ -1,559 +0,0 @@
-require_relative "../../test_helper"
-
-require "json"
-require "pathname"
-require "tmpdir"
-
-require "pro_tacts/import/macos"
-require "pro_tacts/import/plan"
-
-class ImportMacosTest < Minitest::Test
-  Macos = ProTacts::Import::Macos
-
-  Card = ProTacts::Import::Card
-
-  CREATED_AT = Time.utc(2026, 9, 16, 18, 4, 12)
-  NAME = ["N:Lovelace;Ada;;;", "FN:Ada Lovelace"].freeze
-  GROUPS = ["sync:*", "import-20260916T180412Z"].freeze
-
-  def record(identifier, name: NAME, lines: [], note: nil, image: nil, thumbnail: nil)
-    {
-      "identifier" => identifier,
-      "vcard" => ["BEGIN:VCARD", "VERSION:3.0", *name, *lines, "END:VCARD", ""].join("\r\n"),
-      "contact" => {"identifier" => identifier, "imageData" => image, "thumbnailImageData" => thumbnail},
-      "note" => note
-    }
-  end
-
-  def in_tmpdir
-    Dir.mktmpdir { yield Pathname.new(it) / "plan" }
-  end
-
-  def test_a_named_card_is_planned_under_a_minted_id
-    in_tmpdir do |dir|
-      plan = Macos.plan(dir, [record("A:ABPerson")], created_at: CREATED_AT)
-
-      assert_equal 1, plan.contacts.size
-      contact = plan.contacts.first
-      assert_match(/\A[k-z]{12}\z/, contact.id)
-      assert_equal "A:ABPerson", contact.source_id
-      card = plan.card(contact.id)
-      assert_equal ["Ada", "Lovelace", nil, nil, [], [], [], GROUPS],
-        [card.first, card.last, card.nickname, card.birthday, card.phones, card.emails, card.addresses, card.groups]
-    end
-  end
-
-  def test_the_card_holds_the_source_it_was_read_from
-    in_tmpdir do |dir|
-      read = record("A:ABPerson")
-      read["contact"] = read.fetch("contact").merge("givenName" => "Ada")
-      plan = Macos.plan(dir, [read], created_at: CREATED_AT)
-
-      source = plan.card(plan.contacts.first.id).source
-      assert_equal read.fetch("vcard"), source.fetch("vcard")
-      assert_equal "A:ABPerson", source.fetch("identifier")
-      assert_nil source.fetch("note")
-      assert_equal read.fetch("contact"), source.fetch("contact")
-    end
-  end
-
-  def test_every_unknown_field_is_named_and_no_plan_is_written
-    in_tmpdir do |dir|
-      records = [
-        record("A:ABPerson", lines: ["ROLE:Analyst", "EMAIL:ada@example.com"], note: "Analyst."),
-        record("B:ABPerson", lines: ["EMAIL:mary@example.com"], image: "/9j/")
-      ]
-
-      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
-      assert_equal <<~MESSAGE.chomp, error.message
-        no branch handles these fields, so no plan was written:
-          EMAIL: 2
-            A:ABPerson: "EMAIL:ada@example.com"
-            B:ABPerson: "EMAIL:mary@example.com"
-          ROLE: 1
-            A:ABPerson: "ROLE:Analyst"
-      MESSAGE
-      refute dir.exist?
-    end
-  end
-
-  def test_examples_are_distinct_unfolded_and_cut_short
-    in_tmpdir do |dir|
-      records = %w[A B C D].map { record("#{it}:ABPerson", lines: ["PHOTO;ENCODING=b:#{"A" * 60}\r\n #{"A" * 60}"]) }
-      records << record("E:ABPerson", lines: ["NOTE:one\\ntwo"], note: "one\ntwo")
-      records << record("F:ABPerson", lines: ["NOTE:one\\ntwo"])
-
-      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
-      assert_equal <<~MESSAGE.chomp, error.message
-        no branch handles these fields, so no plan was written:
-          NOTE: 2
-            E:ABPerson: "NOTE:one\\\\ntwo"
-          PHOTO: 4
-            A:ABPerson: "PHOTO;ENCODING=b:#{"A" * 83}… (137 characters)"
-      MESSAGE
-    end
-  end
-
-  def test_a_cell_number_is_a_phone
-    in_tmpdir do |dir|
-      lines = ["TEL;type=CELL;type=VOICE;type=pref:+12532189075", "TEL;type=CELL;type=VOICE;type=pref:+12532189076"]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_equal [{"number" => "+12532189075"}, {"number" => "+12532189076"}], plan.card(plan.contacts.first.id).phones
-    end
-  end
-
-  def test_a_name_is_read_unescaped
-    in_tmpdir do |dir|
-      plan = Macos.plan(dir, [record("A:ABPerson", name: ["N:O\\,Brien;Ada;;;", "FN:Ada O\\,Brien"])], created_at: CREATED_AT)
-
-      assert_equal "O,Brien", plan.card(plan.contacts.first.id).last
-    end
-  end
-
-  def test_an_address_a_birthday_and_the_emails_are_fields
-    in_tmpdir do |dir|
-      lines = [
-        "item1.ADR;type=HOME;type=pref:;;12 Marylebone Rd;London;;NW1 5LS;England",
-        "BDAY:1815-12-10",
-        "EMAIL;type=INTERNET;type=HOME:ada@example.com",
-        "EMAIL;type=INTERNET;type=WORK;type=pref:ada@analytical.example",
-        "EMAIL;type=INTERNET:ada@lovelace.example"
-      ]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      card = plan.card(plan.contacts.first.id)
-      assert_equal "1815-12-10", card.birthday
-      assert_equal ["ada@example.com", "ada@analytical.example", "ada@lovelace.example"], card.emails
-      assert_equal [{"street" => "12 Marylebone Rd", "locality" => "London", "postal_code" => "NW1 5LS", "country" => "England"}], card.addresses
-    end
-  end
-
-  # An address is carried under its group already; an email labelled in
-  # Contacts is the same shape, and its label goes where an address's
-  # does. Nothing is lost that an unlabelled row would have kept: an
-  # imported EMAIL line carries no type either (Admin::CardForm).
-  def test_a_labelled_email_is_carried_and_its_label_dropped
-    in_tmpdir do |dir|
-      lines = [
-        "item1.EMAIL;type=INTERNET;type=pref:ada@example.com",
-        "item1.X-ABLabel:_$!<Home>!$_"
-      ]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_equal ["ada@example.com"], plan.card(plan.contacts.first.id).emails
-    end
-  end
-
-  # Contacts writes an address ungrouped when nothing labels it, and
-  # grouped when something does; both are the one field.
-  def test_an_ungrouped_address_is_an_address
-    in_tmpdir do |dir|
-      lines = ["ADR;type=HOME:;;12 Marylebone Rd;London;;NW1 5LS;England"]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_equal [{"street" => "12 Marylebone Rd", "locality" => "London", "postal_code" => "NW1 5LS", "country" => "England"}],
-        plan.card(plan.contacts.first.id).addresses
-    end
-  end
-
-  # The editor's address rows carry no type, so the work address would
-  # land as an unmarked second one beside the home; the home is the one
-  # the fields can hold, and the work stays in the source.
-  def test_a_work_address_is_dropped_and_the_home_carried
-    in_tmpdir do |dir|
-      lines = [
-        "ADR;type=HOME:;;12 Marylebone Rd;London;;NW1 5LS;",
-        "ADR;type=WORK:;;17 Clerkenwell Rd;London;;EC1R 5BQ;",
-        "item3.ADR;type=WORK;type=pref:;;4 Russell Sq;London;;WC1B 4JP;"
-      ]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_equal [{"street" => "12 Marylebone Rd", "locality" => "London", "postal_code" => "NW1 5LS"}],
-        plan.card(plan.contacts.first.id).addresses
-    end
-  end
-
-  def test_a_work_address_alone_is_no_address_at_all
-    in_tmpdir do |dir|
-      lines = ["item1.ADR;type=WORK:;;17 Clerkenwell Rd;London;;EC1R 5BQ;"]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_empty plan.card(plan.contacts.first.id).addresses
-    end
-  end
-
-  # A work number is a number, and one Contacts labelled is too — the
-  # label riding the annotation rail to the row it names, where a
-  # labelled email's and address's are still dropped
-  # (docs/plans/2026-09-18-phone-labels.md).
-  def test_a_work_number_and_a_labelled_one_are_phones
-    in_tmpdir do |dir|
-      lines = [
-        "TEL;type=WORK;type=VOICE:+1 203-536-3941",
-        "item1.TEL;type=pref:(206) 651-4359",
-        "item1.X-ABLabel:school"
-      ]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_equal [{"number" => "+1 203-536-3941"}, {"number" => "(206) 651-4359", "label" => "school"}],
-        plan.card(plan.contacts.first.id).phones
-    end
-  end
-
-  # Apple's own label vocabulary arrives wrapped, and a phone's label
-  # unwraps the way a related name's does.
-  def test_a_phone_with_an_apple_label_unwraps_it
-    in_tmpdir do |dir|
-      lines = [
-        "item1.TEL;type=pref:+12532189075",
-        "item1.X-ABLabel:_$!<Other>!$_"
-      ]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_equal [{"number" => "+12532189075", "label" => "Other"}], plan.card(plan.contacts.first.id).phones
-    end
-  end
-
-  # The main number, with the rank Contacts gives it: `type=pref` names
-  # no kind and comes off the form, so the allowlist holds the spelling
-  # without it and both arrive under the one entry.
-  def test_a_main_number_is_a_phone_ranked_or_not
-    in_tmpdir do |dir|
-      records = [
-        record("A:ABPerson", lines: ["TEL;type=MAIN;type=pref:+12532189075"]),
-        record("B:ABPerson", lines: ["TEL;type=MAIN:(206) 651-4359"])
-      ]
-      plan = Macos.plan(dir, records, created_at: CREATED_AT)
-
-      assert_equal [[{"number" => "+12532189075"}], [{"number" => "(206) 651-4359"}]],
-        plan.contacts.map { plan.card(it.id).phones }
-    end
-  end
-
-  # Where someone works has no field in this address book; the source
-  # keeps the lines the card drops.
-  def test_a_job_title_and_an_organization_are_dropped
-    in_tmpdir do |dir|
-      lines = ["TITLE:Countess of Lovelace", "ORG:Analytical Engine Co.;"]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      card = plan.card(plan.contacts.first.id)
-      assert_equal [[], [], []], [card.phones, card.emails, card.addresses]
-      assert_includes card.source.fetch("vcard"), "TITLE:Countess of Lovelace"
-      assert_includes card.source.fetch("vcard"), "ORG:Analytical Engine Co.;"
-    end
-  end
-
-  def test_a_second_address_under_another_item_number_is_one_form
-    in_tmpdir do |dir|
-      lines = [
-        "item1.ADR;type=HOME;type=pref:;;12 Marylebone Rd;London;;;",
-        "item2.ADR;type=HOME:;;Ockham Park;Surrey;;;"
-      ]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_equal ["12 Marylebone Rd", "Ockham Park"], plan.card(plan.contacts.first.id).addresses.map { it.fetch("street") }
-    end
-  end
-
-  def test_every_kind_of_number_is_a_phone_and_an_empty_row_is_not
-    in_tmpdir do |dir|
-      lines = [
-        "TEL;type=pref:+1 203-536-3941",
-        "TEL;type=HOME;type=VOICE;type=pref:(206) 651-4359",
-        "TEL;type=IPHONE;type=CELL;type=VOICE:",
-        "TEL;type=CELL;type=VOICE:+12532189075",
-        "TEL;type=OTHER;type=VOICE:+1 (425) 707-1712"
-      ]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_equal [{"number" => "+1 203-536-3941"}, {"number" => "(206) 651-4359"}, {"number" => "+12532189075"},
-        {"number" => "+1 (425) 707-1712"}], plan.card(plan.contacts.first.id).phones
-    end
-  end
-
-  def test_a_nickname_is_a_field_and_an_instant_message_address_is_dropped
-    in_tmpdir do |dir|
-      lines = ["NICKNAME:The Countess", "IMPP;X-SERVICE-TYPE=Skype;type=HOME;type=pref:skype:ada"]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_equal "The Countess", plan.card(plan.contacts.first.id).nickname
-    end
-  end
-
-  def test_the_note_and_the_picture_are_fields
-    in_tmpdir do |dir|
-      jpeg = ["\xFF\xD8\xFFhello".b].pack("m0")
-      plan = Macos.plan(dir, [record("A:ABPerson", note: "Analyst.", image: jpeg)], created_at: CREATED_AT)
-
-      card = plan.card(plan.contacts.first.id)
-      assert_equal "Analyst.", card.note
-      assert card.photo
-      assert_equal "image/jpeg", card.contact(plan.contacts.first.id).photo.mime_type
-    end
-  end
-
-  def test_a_contact_whose_picture_is_only_a_thumbnail_carries_it
-    in_tmpdir do |dir|
-      jpeg = ["\xFF\xD8\xFFthumbnail".b].pack("m0")
-      plan = Macos.plan(dir, [record("A:ABPerson", thumbnail: jpeg)], created_at: CREATED_AT)
-
-      card = plan.card(plan.contacts.first.id)
-      assert card.photo
-      assert_equal "image/jpeg", card.contact(plan.contacts.first.id).photo.mime_type
-    end
-  end
-
-  def test_the_fields_this_address_book_does_not_have_are_dropped
-    in_tmpdir do |dir|
-      lines = [
-        "IMPP;X-SERVICE-TYPE=Skype;type=HOME;type=pref:skype:ada",
-        "X-SOCIALPROFILE;type=twitter:https://twitter.com/ada",
-        "item1.X-APPLE-SUBADMINISTRATIVEAREA:Middlesex",
-        "item1.X-APPLE-SUBLOCALITY:Marylebone",
-        "X-AIM;type=HOME;type=pref:ada",
-        "item2.URL;type=pref:https://example.com",
-        "item2.X-ABLabel:_$!<HomePage>!$_",
-        "item2.X-ABADR:us"
-      ]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_equal "Ada Lovelace", plan.card(plan.contacts.first.id).contact("kmnuqmzxylru").name
-    end
-  end
-
-  def test_a_related_name_is_written_under_the_note
-    in_tmpdir do |dir|
-      lines = [
-        "item2.X-ABRELATEDNAMES;type=pref:Sylvia Lovelace",
-        "item2.X-ABLabel:_$!<Spouse>!$_",
-        "item3.X-ABRELATEDNAMES:Byron",
-        "item3.X-ABLabel:the poet",
-        "item4.URL;type=pref:https://example.com",
-        "item4.X-ABLabel:_$!<HomePage>!$_"
-      ]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:, note: "Analyst.")], created_at: CREATED_AT)
-
-      assert_equal "Analyst.\n\nSpouse: Sylvia Lovelace\nthe poet: Byron", plan.card(plan.contacts.first.id).note
-    end
-  end
-
-  def test_a_related_name_is_the_whole_note_of_a_contact_with_none
-    in_tmpdir do |dir|
-      lines = ["item2.X-ABRELATEDNAMES;type=pref:Sylvia", "item2.X-ABLabel:_$!<Spouse>!$_"]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_equal "Spouse: Sylvia", plan.card(plan.contacts.first.id).note
-    end
-  end
-
-  def test_a_maiden_name_is_written_under_the_note
-    in_tmpdir do |dir|
-      lines = [
-        "X-MAIDENNAME:Byron",
-        "item2.X-ABRELATEDNAMES;type=pref:Sylvia Lovelace",
-        "item2.X-ABLabel:_$!<Spouse>!$_"
-      ]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:, note: "Analyst.")], created_at: CREATED_AT)
-
-      assert_equal "Analyst.\n\nMaiden name: Byron\nSpouse: Sylvia Lovelace", plan.card(plan.contacts.first.id).note
-    end
-  end
-
-  def test_a_maiden_name_is_the_whole_note_of_a_contact_with_none
-    in_tmpdir do |dir|
-      plan = Macos.plan(dir, [record("A:ABPerson", lines: ["X-MAIDENNAME:Byron"])], created_at: CREATED_AT)
-
-      assert_equal "Maiden name: Byron", plan.card(plan.contacts.first.id).note
-    end
-  end
-
-  def test_a_second_maiden_name_is_refused
-    in_tmpdir do |dir|
-      records = [record("A:ABPerson", lines: ["X-MAIDENNAME:Byron", "X-MAIDENNAME:King"])]
-
-      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
-      assert_includes error.message, "X-MAIDENNAME lines: 2: 1"
-    end
-  end
-
-  def test_a_maiden_name_in_any_other_form_is_refused_under_that_form
-    in_tmpdir do |dir|
-      records = [record("A:ABPerson", lines: ["item1.X-MAIDENNAME:Byron"])]
-
-      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
-      assert_includes error.message, "item#.X-MAIDENNAME: 1"
-    end
-  end
-
-  def test_an_annotation_with_no_line_to_annotate_is_refused
-    in_tmpdir do |dir|
-      records = [record("A:ABPerson", lines: ["item5.X-ABADR:us", "X-ABLabel:_$!<Spouse>!$_"])]
-
-      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
-      assert_equal <<~MESSAGE.chomp, error.message
-        no branch handles these fields, so no plan was written:
-          X-ABADR: 1
-            A:ABPerson: "item5.X-ABADR:us"
-          X-ABLABEL: 1
-            A:ABPerson: "X-ABLabel:_$!<Spouse>!$_"
-      MESSAGE
-    end
-  end
-
-  def test_a_second_nickname_is_refused
-    in_tmpdir do |dir|
-      records = [record("A:ABPerson", lines: ["NICKNAME:The Countess", "NICKNAME:Ada"])]
-
-      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
-      assert_includes error.message, "NICKNAME lines: 2: 1"
-    end
-  end
-
-  def test_a_value_no_field_can_hold_is_refused
-    in_tmpdir do |dir|
-      records = [
-        record("A:ABPerson", lines: ["BDAY:--12-10"]),
-        record("C:ABPerson", lines: ["item1.ADR;type=HOME:P.O. Box 4;;;London;;;"]),
-        record("D:ABPerson", lines: ["item1.ADR;type=HOME:;;Ockham Park;Surrey"]),
-        record("E:ABPerson", lines: ["BDAY:1815-12-10", "BDAY:1815-12-11"])
-      ]
-
-      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
-      assert_equal <<~MESSAGE.chomp, error.message
-        no branch handles these fields, so no plan was written:
-          ADR value: 2
-            C:ABPerson: "item1.ADR;type=HOME:P.O. Box 4;;;London;;;"
-            D:ABPerson: "item1.ADR;type=HOME:;;Ockham Park;Surrey"
-          BDAY lines: 2: 1
-            E:ABPerson: "BDAY:1815-12-10 / BDAY:1815-12-11"
-          BDAY value: 1
-            A:ABPerson: "BDAY:--12-10"
-      MESSAGE
-    end
-  end
-
-  # An address book has no field for a directory name, and Exchange
-  # writes one into an EMAIL. It leaves with the IMPPs rather than
-  # stopping the plan, and the source keeps the line.
-  def test_an_email_that_is_not_an_address_is_dropped
-    in_tmpdir do |dir|
-      lines = [
-        "item2.EMAIL;type=INTERNET:/O=microsoft/OU=northamerica/cn=Recipients/cn=algersha",
-        "EMAIL;type=INTERNET:ada@example.com"
-      ]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      card = plan.card(plan.contacts.first.id)
-      assert_equal ["ada@example.com"], card.emails
-      assert_includes card.source.fetch("vcard"), "cn=algersha"
-    end
-  end
-
-  # An extension is part of the number as a person typed it.
-  def test_a_number_carries_the_extension_written_after_it
-    in_tmpdir do |dir|
-      lines = ["TEL;type=WORK;type=VOICE:+1 (425) 707-1712 X71712"]
-      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
-      assert_equal [{"number" => "+1 (425) 707-1712 X71712"}], plan.card(plan.contacts.first.id).phones
-    end
-  end
-
-  def test_a_name_beyond_first_and_last_is_refused
-    in_tmpdir do |dir|
-      records = [
-        record("A:ABPerson", name: ["N:Lovelace;Ada;Augusta;;", "FN:Ada Lovelace"]),
-        record("B:ABPerson", name: ["N:Lovelace;Ada;;;", "FN:Countess Ada Lovelace"]),
-        record("C:ABPerson", name: ["FN:Ada Lovelace"]),
-        record("D:ABPerson", name: ["N:;;;;", "FN:"])
-      ]
-
-      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
-      assert_equal <<~MESSAGE.chomp, error.message
-        no branch handles these fields, so no plan was written:
-          FN other than first and last: 2
-            B:ABPerson: "FN:Countess Ada Lovelace"
-            C:ABPerson: "FN:Ada Lovelace"
-          N beyond first and last: 1
-            A:ABPerson: "N:Lovelace;Ada;Augusta;;"
-          N lines: 0: 1
-            C:ABPerson: ""
-          no name: 1
-            D:ABPerson: "N:;;;;"
-      MESSAGE
-    end
-  end
-
-  def test_an_apple_prodid_is_dropped
-    in_tmpdir do |dir|
-      plan = Macos.plan(dir, [record("A:ABPerson", lines: ["PRODID:-//Apple Inc.//macOS 26.0//EN"])], created_at: CREATED_AT)
-
-      card = plan.card(plan.contacts.first.id)
-      assert_equal [[], [], []], [card.phones, card.emails, card.addresses]
-    end
-  end
-
-  def test_any_other_prodid_is_refused
-    in_tmpdir do |dir|
-      records = [
-        record("A:ABPerson", lines: ["PRODID:-//Monica//EN"]),
-        record("B:ABPerson", lines: ["PRODID;VALUE=text:-//Apple Inc.//macOS 26.0//EN"])
-      ]
-
-      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
-      assert_equal <<~MESSAGE.chomp, error.message
-        no branch handles these fields, so no plan was written:
-          PRODID value: 1
-            A:ABPerson: "PRODID:-//Monica//EN"
-          PRODID;VALUE=text: 1
-            B:ABPerson: "PRODID;VALUE=text:-//Apple Inc.//macOS 26.0//EN"
-      MESSAGE
-    end
-  end
-
-  def test_a_line_in_any_other_form_is_refused_under_that_form
-    in_tmpdir do |dir|
-      records = [
-        record("C:ABPerson", lines: ["item1.TEL;type=CELL;type=VOICE;type=pref:+12532189075"]),
-        record("D:ABPerson", lines: ["TEL;type=CELL;type=VOICE;type=pref:ext. 4"]),
-        record("E:ABPerson", name: ["N:Lovelace;Ada;;;", "FN;CHARSET=utf-8:Ada Lovelace"])
-      ]
-
-      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
-      assert_equal <<~MESSAGE.chomp, error.message
-        no branch handles these fields, so no plan was written:
-          FN lines: 0: 1
-            E:ABPerson: ""
-          FN;CHARSET=utf-8: 1
-            E:ABPerson: "FN;CHARSET=utf-8:Ada Lovelace"
-          TEL value: 1
-            D:ABPerson: "TEL;type=CELL;type=VOICE;type=pref:ext. 4"
-          item#.TEL;type=CELL;type=VOICE: 1
-            C:ABPerson: "item1.TEL;type=CELL;type=VOICE;type=pref:+12532189075"
-      MESSAGE
-    end
-  end
-
-  def test_a_card_that_is_not_version_3_is_refused
-    in_tmpdir do |dir|
-      source = record("A:ABPerson")
-      source["vcard"] = source.fetch("vcard").sub("VERSION:3.0", "VERSION:4.0")
-
-      error = assert_raises(Macos::Unknown) { Macos.plan(dir, [source], created_at: CREATED_AT) }
-
-      assert_includes error.message, "VERSION:4.0: 1"
-    end
-  end
-end
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
deleted file mode 100644
index f97f2e7..0000000
--- a/test/pro_tacts/import/test_plan.rb
+++ /dev/null
@@ -1,152 +0,0 @@
-require_relative "../../test_helper"
-
-require "pathname"
-require "tmpdir"
-
-require "pro_tacts/import/plan"
-
-class ImportPlanTest < Minitest::Test
-  include ThrowawayContacts
-
-  Plan = ProTacts::Import::Plan
-
-  CREATED_AT = Time.utc(2026, 9, 16, 18, 4, 12)
-
-  SOURCE = {"identifier" => "A:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
-  ADA = ProTacts::Import::Card.new(first: "Ada", last: "Lovelace", nickname: nil, birthday: nil, phones: [{"number" => "+12532189075"}], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
-
-  def entry(id, source_id: "#{id}:ABPerson", card: ADA)
-    Plan::Entry.new(id:, source_id:, card:)
-  end
-
-  def in_tmpdir
-    Dir.mktmpdir { yield Pathname.new(it) / "plan" }
-  end
-
-  def test_a_written_plan_reads_back
-    in_tmpdir do |dir|
-      Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
-
-      plan = Plan.read(dir)
-
-      assert_equal "macos", plan.source
-      assert_equal "2026-09-16T18:04:12Z", plan.created_at
-      assert_equal [Plan::Contact.new(id: "kmnuqmzxylru", source_id: "kmnuqmzxylru:ABPerson", name: "Ada Lovelace", status: nil), Plan::Contact.new(id: "vmnlryyvktux", source_id: "vmnlryyvktux:ABPerson", name: "Ada Lovelace", status: nil)], plan.contacts
-    end
-  end
-
-  def test_a_contact_with_one_name_is_listed_under_it
-    in_tmpdir do |dir|
-      card = ProTacts::Import::Card.new(first: "Mitch", last: "", nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
-
-      Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru", card:)])
-
-      assert_equal ["Mitch"], Plan.read(dir).contacts.map(&:name)
-    end
-  end
-
-  def test_the_group_is_named_for_when_the_plan_was_built
-    in_tmpdir do |dir|
-      Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [])
-
-      assert_equal "import-20260916T180412Z", Plan.read(dir).group
-    end
-  end
-
-  def test_a_card_joins_everyone_and_the_plans_group_after_its_own
-    in_tmpdir do |dir|
-      card = ADA.with(groups: ["family"])
-      Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru", card:)])
-
-      assert_equal <<~YAML, (dir / "cards/kmnuqmzxylru.yml").read
-        ---
-        first: Ada
-        last: Lovelace
-        nickname:
-        birthday:
-        phones:
-        - number: "+12532189075"
-        emails: []
-        addresses: []
-        note:
-        photo: false
-        groups:
-        - sync:*
-        - import-20260916T180412Z
-        - family
-        source:
-          identifier: A:ABPerson
-          vcard: "BEGIN:VCARD\\r\\nEND:VCARD\\r\\n"
-          note:
-          contact: {}
-      YAML
-    end
-  end
-
-  def test_a_card_reads_back_with_its_edits
-    in_tmpdir do |dir|
-      plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
-      path = dir / "cards/kmnuqmzxylru.yml"
-      path.write(path.read.sub("first: Ada", "first: Augusta Ada"))
-
-      assert_equal "Augusta Ada", plan.card("kmnuqmzxylru").first
-    end
-  end
-
-  def test_a_plan_is_never_written_over_another
-    in_tmpdir do |dir|
-      Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
-
-      assert_raises(Errno::EEXIST) { Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: []) }
-      assert_equal ["kmnuqmzxylru"], Plan.read(dir).contacts.map(&:id)
-    end
-  end
-
-  def test_plans_read_back_oldest_first_with_what_each_is_waiting_for
-    Dir.mktmpdir do |tmp|
-      imports = Pathname.new(tmp)
-      %w[20260916T180412Z 20260915T090000Z].each do |stamp|
-        Plan.write(imports / "macos-#{stamp}", source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
-      end
-      Plan.read(imports / "macos-20260915T090000Z").record("kmnuqmzxylru", Plan::DONE)
-
-      plans = Plan.all(imports)
-
-      assert_equal ["macos-20260915T090000Z", "macos-20260916T180412Z"], plans.map { it.dir.basename.to_s }
-      assert_equal [[], ["kmnuqmzxylru"]], plans.map { it.outstanding.map(&:id) }
-    end
-  end
-
-  def test_progress_is_saved_into_the_plan_as_it_is_recorded
-    in_tmpdir do |dir|
-      plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
-
-      plan.record("kmnuqmzxylru", Plan::DONE)
-
-      read = Plan.read(dir)
-      assert_equal ["done", nil], read.contacts.map(&:status)
-      assert_equal ["plan.yml"], dir.children.map { it.basename.to_s }.grep(/plan/)
-    end
-  end
-
-  def test_a_plan_is_done_once_every_contact_reaches_done
-    in_tmpdir do |dir|
-      plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
-
-      refute plan.done?
-      plan.record("kmnuqmzxylru", Plan::DONE)
-      refute plan.done?
-      plan.record("vmnlryyvktux", Plan::DONE)
-      assert plan.done?
-    end
-  end
-
-  def test_a_plan_recorded_under_the_old_vocabulary_finishes_too
-    in_tmpdir do |dir|
-      plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
-      plan.record("kmnuqmzxylru", "removed")
-
-      assert plan.done?
-    end
-  end
-end
diff --git a/test/pro_tacts/import/test_vcf.rb b/test/pro_tacts/import/test_vcf.rb
new file mode 100644
index 0000000..f067dbc
--- /dev/null
+++ b/test/pro_tacts/import/test_vcf.rb
@@ -0,0 +1,132 @@
+require_relative "../../test_helper"
+
+require "pro_tacts/import/vcf"
+
+# Reading an uploaded .vcf: the split into cards, and the survey of
+# what this address book does not show
+# (docs/plans/2026-09-21-import-a-vcf.md).
+class ImportVcfTest < Minitest::Test
+  Vcf = ProTacts::Import::Vcf
+
+  JANE = <<~CARD.gsub("\n", "\r\n")
+    BEGIN:VCARD
+    VERSION:3.0
+    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>!$_
+    X-SOCIALPROFILE;type=twitter:https://twitter.com/jb
+    UID:ABC-123
+    END:VCARD
+  CARD
+
+  SAM = <<~CARD.gsub("\n", "\r\n")
+    BEGIN:VCARD
+    VERSION:3.0
+    N:Booles;Sam;;;
+    FN:Sam Booles
+    X-SOCIALPROFILE;type=facebook:https://facebook.com/sb
+    PRODID:-//Apple Inc.//macOS 15.0//EN
+    UID:DEF-456
+    END:VCARD
+  CARD
+
+  def test_a_file_reads_as_the_cards_it_holds_byte_for_byte
+    cards = Vcf.cards(JANE + SAM)
+
+    assert_equal [JANE, SAM], cards.map(&:to_s)
+  end
+
+  # Contacts writes one card after another with nothing between them,
+  # but an export edited by hand or joined with `cat` has blank lines,
+  # and they are not a card's business.
+  def test_blank_lines_between_cards_are_not_cards
+    cards = Vcf.cards("#{JANE}\r\n\r\n#{SAM}")
+
+    assert_equal [JANE, SAM], cards.map(&:to_s)
+  end
+
+  def test_a_line_outside_a_card_refuses_the_whole_file
+    error = assert_raises(Vcf::Invalid) { Vcf.cards("FN:Stray\r\n#{JANE}") }
+
+    assert_includes error.message, "FN:Stray"
+  end
+
+  def test_a_card_with_no_end_refuses_the_whole_file
+    error = assert_raises(Vcf::Invalid) { Vcf.cards(JANE.sub("END:VCARD\r\n", "")) }
+
+    assert_includes error.message, "no END:VCARD"
+  end
+
+  def test_a_file_with_no_cards_in_it_refuses
+    error = assert_raises(Vcf::Invalid) { Vcf.cards("\r\n") }
+
+    assert_includes error.message, "holds no vCards"
+  end
+
+  # BEGIN, VERSION and END are what makes a vCard (RFC 2426 section 4),
+  # and VCard#card? is what says so — the same judgment a PUT makes.
+  def test_something_shaped_like_a_card_without_a_version_refuses
+    error = assert_raises(Vcf::Invalid) { Vcf.cards(JANE.sub("VERSION:3.0\r\n", "")) }
+
+    assert_includes error.message, "are not vCards"
+  end
+
+  def test_the_survey_names_what_no_screen_here_shows
+    unknown = Vcf.unknown(Vcf.cards(JANE + SAM))
+
+    assert_equal %w[PRODID X-ABRELATEDNAMES X-SOCIALPROFILE], unknown.map(&:name)
+  end
+
+  # The envelope, the fields a screen shows, and the label that names a
+  # row are all read here, so none of them is a decision.
+  def test_the_survey_asks_nothing_about_a_property_this_book_reads
+    names = Vcf.unknown(Vcf.cards(JANE + SAM)).map(&:name)
+
+    refute_includes names, "TEL"
+    refute_includes names, "X-ABLABEL"
+    refute_includes names, "UID"
+  end
+
+  # The three choices read the same whatever parameters a line wears,
+  # so a property is one decision however many spellings it arrives in.
+  def test_parameters_do_not_make_a_second_decision
+    social = Vcf.unknown(Vcf.cards(JANE + SAM)).find { it.name == "X-SOCIALPROFILE" }
+
+    assert_equal 2, social.count
+    assert_equal ["X-SOCIALPROFILE;type=twitter:https://twitter.com/jb",
+                  "X-SOCIALPROFILE;type=facebook:https://facebook.com/sb"],
+                 social.examples
+  end
+
+  # A choice is made looking at the book's own data, so the examples
+  # are real lines — but no more of them than a list can be read at,
+  # and never a picture's payload.
+  def test_the_examples_are_a_few_real_lines_and_stop_short_of_a_payload
+    cards = Vcf.cards((1..5).map { |n| JANE.sub("jb", "jb#{n}") }.join)
+
+    examples = Vcf.unknown(cards).find { it.name == "X-SOCIALPROFILE" }.examples
+
+    assert_equal Vcf::EXAMPLES, examples.length
+
+    long = JANE.sub("https://twitter.com/jb", "x" * 400)
+
+    example = Vcf.unknown(Vcf.cards(long)).find { it.name == "X-SOCIALPROFILE" }.examples.fetch(0)
+
+    assert_includes example, "characters)"
+    assert_operator example.length, :<, 200
+  end
+
+  # A line's value arrives folded across physical lines (RFC 2426
+  # section 2.6); what it says is one line, and that is what the
+  # screen shows.
+  def test_an_example_is_unfolded
+    folded = JANE.sub("X-SOCIALPROFILE;type=twitter:https://twitter.com/jb\r\n",
+                      "X-SOCIALPROFILE;type=twitter:https://twitter.com\r\n /jb\r\n")
+
+    example = Vcf.unknown(Vcf.cards(folded)).find { it.name == "X-SOCIALPROFILE" }.examples.fetch(0)
+
+    assert_equal "X-SOCIALPROFILE;type=twitter:https://twitter.com/jb", example
+  end
+end
diff --git a/test/tasks/test_import.rb b/test/tasks/test_import.rb
deleted file mode 100644
index d390cfd..0000000
--- a/test/tasks/test_import.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-require_relative "../test_helper"
-
-require "pathname"
-require "rake"
-require "tmpdir"
-
-require "pro_tacts/import/plan"
-
-class ImportTasksTest < Minitest::Test
-  Plan = ProTacts::Import::Plan
-
-  SOURCE = {"identifier" => "A:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
-  ADA = ProTacts::Import::Card.new(first: "Ada", last: "Lovelace", nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
-
-  def entry(id) = Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card: ADA)
-
-  def test_status_counts_each_plans_contacts_and_files_finished_plans_away
-    Dir.mktmpdir do |root|
-      root = Pathname.new(root)
-      plans = root / "data" / "import" / "active"
-      # A plan carried all the way off the Mac — done, so swept into
-      # done/ rather than listed — one mid-flight with one contact
-      # finalized and one still to go, and one freshly built with
-      # everything to do.
-      through = Plan.write(plans / "macos-20260831T000000Z", source: "macos", created_at: Time.utc(2026, 8, 31), entries: [entry("qxqmqmqmqmqm"), entry("zzzzzzzzzzzz")])
-      through.contacts.each { it.status or through.record(it.id, Plan::DONE) }
-      underway = Plan.write(plans / "macos-20260901T000000Z", source: "macos", created_at: Time.utc(2026, 9, 1), entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
-      underway.record("vmnlryyvktux", Plan::DONE)
-      Plan.write(plans / "macos-20260916T180412Z", source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries: [entry("aaaaaaaaaaaa")])
-
-      out, = run_status(root)
-
-      assert_includes out, "macos-20260901T000000Z  1/2 finalized\n"
-      assert_includes out, "macos-20260916T180412Z  0/1 finalized\n"
-      assert_includes out, "next: /import would land the cards of macos-20260901T000000Z\n"
-      assert_includes out, "next: import:macos:finalize would finish macos-20260901T000000Z\n"
-      assert_includes out, "done:\nmacos-20260831T000000Z  2 contacts\n"
-      assert (root / "data/import/done/macos-20260831T000000Z/plan.yml").file?
-      refute (plans / "macos-20260831T000000Z").directory?
-      refute_includes out, "macos-20260831T000000Z  2/2 finalized"
-    end
-  end
-
-  def test_status_with_only_done_plans_reports_them_alone
-    Dir.mktmpdir do |root|
-      root = Pathname.new(root)
-      plan = Plan.write(root / "data/import/done/macos-20260831T000000Z", source: "macos", created_at: Time.utc(2026, 8, 31), entries: [entry("qxqmqmqmqmqm")])
-      plan.contacts.each { it.status or plan.record(it.id, Plan::DONE) }
-
-      out, = run_status(root)
-
-      assert_equal "done:\nmacos-20260831T000000Z  1 contacts\n", out
-    end
-  end
-
-  def test_status_with_no_plans_says_how_to_build_one
-    Dir.mktmpdir do |root|
-      out, = run_status(Pathname.new(root))
-
-      assert_includes out, "no plans in data/import/active; run rake import:macos:plan"
-    end
-  end
-
-  private
-
-  def run_status(root)
-    with_import_tasks(root) { capture_io { Rake.application["import:status"].invoke } }
-  end
-
-  # Loads tasks/import.rake in a Rake application of its own, from root
-  # as the working directory: the tasks read data/import relative to
-  # where rake runs, not through ProTacts.config.
-  def with_import_tasks(root)
-    application = Rake.application
-    Rake.application = Rake::Application.new
-    load (Pathname.new(__dir__).parent.parent / "tasks" / "import.rake").to_s
-    Dir.chdir(root) { yield }
-  ensure
-    Rake.application = application
-  end
-end