import: land a plan by uploading its cards
The middle step of an import was `rake import:execute`, which ran on
the machine holding the plan and wrote to a host over HTTP. It is now
a screen: the plan's card files are uploaded at /import and land
through the store, which drops the host in the config being named
twice, the checkout on the Mac, and the identity coming from a proxy
rather than from the person doing it.

Import::Land writes what Execute wrote — Store#put for a card the
store does not have, then Store#regroup into the groups the file
names — so it is the same two writes the PUT and the groups dialog
make rather than a third way to write a card. A card already here is
left as it is and reported as such, which is what If-None-Match
bought, so a second upload writes nothing.

A plan can no longer record `landed` or `imported`, there being no
plan on the server's side, so a contact is outstanding until it is
done and finalize asks the host about each one — the check that stood
behind the status all along. The plan format is unchanged and nothing
on disk is rewritten.

docs/plans/2026-09-20-import-by-upload.md has the whole of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
change
commit 04f03cd66c01a513797824a95f01cd7821fb9626
author Claude <noreply@anthropic.com>
date
parent ypumouov
diff --git a/AGENTS.md b/AGENTS.md
index 09eff06..73d6c1e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,16 +33,13 @@ rake console          # irb with the store open on the configured database
 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
-                      # landed plan imported, taking them off this
-                      # Mac and filing the plan away done
-                      # (PLAN=dir for another; writes Contacts, so
-                      # leave it to the user)
-rake import:execute   # Land the oldest plan still to land on a host
-                      # (host in data/import/config.yml; PLAN=dir for
-                      # another)
+                      # 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 execute
-                      # and finalize would carry next
+                      # 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
 ```
@@ -50,6 +47,11 @@ 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.
+
 ## Task management
 
 The backlog lives in `ranger`, not GitHub Issues. `RANGER_DEFAULT_BACKLOG`
@@ -68,8 +70,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/api.rb      # JSON for scripts: the group list
-├── import/         # Import plans, and landing them on a host
+├── import/         # Import plans, and landing one in the store
 ├── 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 e6911e1..2c434f5 100644
--- a/Steepfile
+++ b/Steepfile
@@ -22,7 +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 upcoming_birthdays
+    group_dialog group_label groups_edit groups_index groups_show icons layout list_item phlex
+    plan_upload upcoming_birthdays
   ].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
   signature "sig"
 
diff --git a/config.ru b/config.ru
index 5d7615c..965c93d 100644
--- a/config.ru
+++ b/config.ru
@@ -3,6 +3,7 @@
 # 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")
@@ -50,6 +51,17 @@ 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-20-import-by-upload.md b/docs/plans/2026-09-20-import-by-upload.md
new file mode 100644
index 0000000..4790cc3
--- /dev/null
+++ b/docs/plans/2026-09-20-import-by-upload.md
@@ -0,0 +1,112 @@
+# Importing by upload
+
+2026-09-20. The middle step of an import — landing a plan's cards on a
+host — stopped being a rake task and became a screen in the app. A plan
+is still built on the Mac by `rake import:macos:plan` and still
+finalized there by `rake import:macos:finalize`; between them, the cards
+are uploaded at `/import` and land through the store.
+
+## What was wrong with a task
+
+`rake import:execute` ran on the machine holding the plan and wrote to a
+host over HTTP: a PUT per card to `/dav/addressbook/<id>.vcf`, then a
+`POST /contacts/<id>/groups`, with `GET /api/groups` in front of each
+group it had to make. That meant three things a person had to keep
+right, none of which the import is about:
+
+- **A host, named twice.** `data/import/config.yml` had to name the
+  server, and the plan recorded which one it landed on so a second run
+  could not aim elsewhere. Both facts existed only because the writer
+  was somewhere else.
+- **A checkout, on the Mac.** Landing needed the repository, the gems,
+  and a Ruby, on a machine whose only real job in the import is holding
+  Contacts.app.
+- **An identity from a proxy.** The cards landed as whoever the host's
+  proxy said the request was, which is not visibly the person running
+  the task.
+
+Uploading answers all three. The person is already looking at the host
+in a browser, already past the identity gate as themselves, and the
+machine holding the plan is whichever one they are sitting at.
+
+## What is uploaded
+
+The plan's card files — `data/import/active/<plan>/cards/*.yml` — chosen
+in one multi-file picker. Not `plan.yml`, and not the directory: a card
+carries everything the server needs, because `Plan.write` names each
+file for the id it lands under and writes `sync:*` and the plan's
+`import-<timestamp>` group into the card itself. `plan.yml` stays on the
+Mac as its own ledger, which is where finalize reads it.
+
+Rack refuses a multipart body of more than 128 parts by default, and a
+whole-book plan is 443 cards. `config.ru` raises the limit. The default
+guards a public endpoint against a cheap body; this app is on a tailnet
+and everyone who can reach it is an admin (docs/DESIGN.md), so there was
+nothing there to protect.
+
+## Landing
+
+`Import::Land` writes what `Execute` wrote, 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 the PUT
+and the groups dialog make:
+
+1. Read every uploaded file first (`Import::Card.parse`), so a card that
+   will not read stops the upload with nothing landed. That was
+   `execute`'s rule and it survives unchanged.
+2. `Store#put` with `client: true` for each card the store does not
+   already have, 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 groups the file names, making any that are
+   missing, and out of `sync:*` unless the file names it — the same
+   reading of a card's groups the browser's own save makes.
+
+A card already in the store is left as it is and reported as such rather
+than written over, which is what the PUT's `If-None-Match: *` bought
+before. So a plan uploaded twice writes nothing the second time, and a
+person who has since edited a contact here does not lose the edit to a
+re-upload.
+
+The upload answers with the page listing what arrived rather than the
+303 the other writes answer with. Landing is idempotent, so the
+re-submission a back button offers writes nothing, and there is no other
+page holding what just came in.
+
+## What the plan stopped recording
+
+A contact's status went from none through `landed` and `imported` to
+`done`. The two middle states were `execute`'s to write, and the app
+cannot write them: it has no plan, only cards. So a contact is now
+outstanding until it is `done`, and `Plan#with_status` is
+`Plan#outstanding`.
+
+Nothing is lost by it. Finalize already asked the host, card by card,
+whether it still had the contact before deleting the original
+(`GET /contacts/<id>`) — a stronger check than the status, since the
+status only said a write had happened once. That check now stands alone:
+a contact whose card is not on the host is kept, which is exactly what a
+plan whose cards were never uploaded gets.
+
+`plan.yml` also stopped recording a host, which was `execute`'s guard
+against landing one plan on two servers. Uploading is aimed by which
+server the browser is pointed at, so there is nothing to guard.
+Finalize reads the host from `data/import/config.yml` instead, which is
+still the one place a host is named
+(`2026-09-19-import-under-data-import.md`).
+
+The plan format did not change, and nothing on disk is rewritten: a plan
+`execute` left part way through still reads, and its `landed` and
+`imported` contacts read as outstanding, which is what they are.
+
+## The screen
+
+`/import`, linked from the footer beside device setup, for that link's
+reason: the dashboard is a search over contacts and a plan is not a
+contact. It is rarer than device setup — a plan arrives a handful of
+times in a book's life.
+
+The form takes the cards and nothing else. Its answer is a row per card,
+linked to the record it landed as, marking the ones that were already
+here. A refusal is a toast naming the file: which card would not read,
+or which file was not a plan card at all.
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index c14f791..0e26368 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -120,11 +120,15 @@ module ProTacts
             # Device setup belongs here because the dashboard is a
             # 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.
+            # 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).
             footer(class: "admin-footer") do
               version = ProTacts.config.version
               span(class: "type-label") { version } if version
               span(class: "type-label") { "+debug" } if ProTacts.config.debug?
+              a(href: "/import", class: "type-label") { "import" }
               a(href: "/setup", class: "type-label") { "device setup" }
             end
             # A server-rendered toast, Gloss's [role=status] contract:
diff --git a/lib/pro_tacts/admin/plan_upload.rb b/lib/pro_tacts/admin/plan_upload.rb
new file mode 100644
index 0000000..5c609fd
--- /dev/null
+++ b/lib/pro_tacts/admin/plan_upload.rb
@@ -0,0 +1,102 @@
+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
index b4fac74..b92ccfd 100644
--- a/lib/pro_tacts/import/card.rb
+++ b/lib/pro_tacts/import/card.rb
@@ -41,14 +41,26 @@ module ProTacts
       # 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.
-      #: (Pathname path) -> Card
-      def self.read(path)
-        document = YAML.safe_load_file(path)
-        invalid = ->(why) { raise Invalid, "#{path}: #{why}" }
+      #
+      # `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
@@ -74,7 +86,7 @@ module ProTacts
 
         card
       rescue Psych::DisallowedClass => error
-        raise Invalid, "#{path}: #{error.message.sub(/\ATried to load unspecified class: /, "reads as a ")}; quote it"
+        raise Invalid, "#{name}: #{error.message.sub(/\ATried to load unspecified class: /, "reads as a ")}; quote it"
       end
 
       #: (untyped value) -> bool
diff --git a/lib/pro_tacts/import/execute.rb b/lib/pro_tacts/import/execute.rb
deleted file mode 100644
index 2a5a54f..0000000
--- a/lib/pro_tacts/import/execute.rb
+++ /dev/null
@@ -1,119 +0,0 @@
-require "json"
-require "uri"
-
-require "pro_tacts/import/plan"
-
-module ProTacts
-  module Import
-    # Lands a plan on a host through the routes a client and the admin
-    # UI use, the same for every source. Idempotent against the host as
-    # well as the plan (docs/plans/2026-09-16-importing-from-macos.md,
-    # "Execute is the same for every source").
-    class Execute
-      # @rbs @plan: Plan
-      # @rbs @host: String
-      # @rbs @client: _Client
-      # @rbs @group_ids: Hash[String, String]
-
-      class Failed < StandardError; end
-
-      # What a host answered. Signed in sig/pro_tacts/import.rbs, being a
-      # Data class.
-      # @rbs skip
-      Response = Data.define(:status, :headers, :body)
-
-      #: (Plan plan, host: String, client: _Client) -> void
-      def self.call(plan, host:, client:)
-        new(plan, host:, client:).call
-      end
-
-      #: (Plan plan, host: String, client: _Client) -> void
-      def initialize(plan, host:, client:)
-        @plan = plan
-        @host = host
-        @client = client
-        @group_ids = {}
-      end
-
-      #: () -> void
-      def call
-        landed_on = @plan.host
-        if landed_on && landed_on != @host
-          raise Failed, "#{@plan.dir} is landing on #{landed_on}; refusing to land it on #{@host} too"
-        end
-        # Every card still to carry is read before the first write, so an
-        # edit that will not read stops the run with nothing landed.
-        cards = @plan.contacts.reject { it.status == "imported" }.to_h {
-          [it.id, @plan.card(it.id)] #: [String, Card]
-        }
-        # Before the first write, so a run that dies after one still ties
-        # the plan to this host.
-        @plan.host = @host unless landed_on
-
-        @plan.contacts.each { land(it.id, cards.fetch(it.id)) if it.status.nil? }
-        @plan.contacts.each { add_to_groups(it.id, cards.fetch(it.id)) if it.status == "landed" }
-      end
-
-      private
-
-      # A bare 412 is If-None-Match failing on an id this plan minted, so
-      # the card is on the host already: an earlier run's PUT landed and
-      # died before the plan recorded it. A 412 with a body is the card
-      # refused.
-      #: (String id, Card card) -> void
-      def land(id, card)
-        response = @client.call(
-          "PUT", "/dav/addressbook/#{id}.vcf",
-          headers: {"Content-Type" => "text/vcard; charset=utf-8", "If-None-Match" => "*"},
-          body: card.contact(id).vcard.to_s
-        )
-        unless [201, 204].include?(response.status) || response.status == 412 && response.body.empty?
-          raise Failed, "PUT of #{id} answered #{response.status}: #{response.body}"
-        end
-
-        @plan.record(id, "landed")
-      end
-
-      # The card's groups, and out of `sync:*` unless it is one of them:
-      # the PUT that created the card put it there.
-      #: (String id, Card card) -> void
-      def add_to_groups(id, card)
-        form = card.groups.uniq.map { ["groups[]", group_id(it)] } #: Array[[String, String]]
-        form << ["was[]", group_id(Plan::EVERYONE)]
-        response = post("/contacts/#{id}/groups", form)
-        raise Failed, "adding #{id} to #{card.groups.join(", ")} answered #{response.status}" unless response.status == 303
-
-        @plan.record(id, "imported")
-      end
-
-      # Looked up before it is made, so a run that died after making it
-      # reuses the one it made.
-      #: (String name) -> String
-      def group_id(name)
-        @group_ids[name] ||= listed_group_id(name) || make_group(name)
-      end
-
-      #: (String name) -> String
-      def make_group(name)
-        response = post("/groups", [["name", name]])
-        raise Failed, "creating group #{name} answered #{response.status}" unless response.status == 303
-
-        listed_group_id(name) or raise Failed, "group #{name} was created and is not listed"
-      end
-
-      #: (String name) -> String?
-      def listed_group_id(name)
-        response = @client.call("GET", "/api/groups")
-        raise Failed, "listing groups answered #{response.status}" unless response.status == 200
-
-        groups = JSON.parse(response.body) #: Array[Hash[String, String?]]
-        groups.find { it.fetch("name") == name }&.fetch("id")
-      end
-
-      #: (String path, Array[[String, String]] form) -> Response
-      def post(path, form)
-        @client.call("POST", path, headers: {"Content-Type" => "application/x-www-form-urlencoded"}, body: URI.encode_www_form(form))
-      end
-    end
-  end
-end
diff --git a/lib/pro_tacts/import/finalize.rb b/lib/pro_tacts/import/finalize.rb
index 0e89f8c..62f0194 100644
--- a/lib/pro_tacts/import/finalize.rb
+++ b/lib/pro_tacts/import/finalize.rb
@@ -7,8 +7,15 @@ module ProTacts
     # "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
 
@@ -22,23 +29,29 @@ module ProTacts
       # @rbs skip
       Result = Data.define(:done, :kept)
 
-      #: (Plan plan, client: _Client, mac: _Mac) -> Result
-      def self.call(plan, client:, mac:)
-        new(plan, client:, mac:).call
+      #: (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, client: _Client, mac: _Mac) -> void
-      def initialize(plan, client:, mac:)
+      #: (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
-        imported = @plan.with_status("imported")
-        records = @mac.show(imported.map(&:source_id))
-        gone, still_there = imported.partition { !records.key?(it.source_id) }
+        # 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) }
@@ -73,7 +86,7 @@ module ProTacts
       def keep(contact, record)
         source = @plan.card(contact.id).source
         if !on_host?(contact.id)
-          "its card is no longer on #{@plan.host}"
+          "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")
diff --git a/lib/pro_tacts/import/http_client.rb b/lib/pro_tacts/import/http_client.rb
index 1fa89d1..78db5e9 100644
--- a/lib/pro_tacts/import/http_client.rb
+++ b/lib/pro_tacts/import/http_client.rb
@@ -1,23 +1,31 @@
 require "net/http"
 
-require "pro_tacts/import/execute"
-
 module ProTacts
   module Import
-    # A host over one started connection. The identity header is the
-    # host's proxy to write, so nothing here sets one.
+    # 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?) -> Execute::Response
+      #: (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)
-        Execute::Response.new(status: response.code.to_i, headers: response.to_hash.transform_values { it.join(", ") }, body: response.body.to_s)
+        Response.new(status: response.code.to_i, headers: response.to_hash.transform_values { it.join(", ") }, body: response.body.to_s)
       end
     end
   end
diff --git a/lib/pro_tacts/import/land.rb b/lib/pro_tacts/import/land.rb
new file mode 100644
index 0000000..3afa536
--- /dev/null
+++ b/lib/pro_tacts/import/land.rb
@@ -0,0 +1,96 @@
+require "pro_tacts/import/card"
+require "pro_tacts/import/plan"
+
+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.
+    #
+    # 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.
+    class Land
+      # @rbs @store: Store
+      # @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)
+      end
+
+      #: (Store store) -> void
+      def initialize(store)
+        @store = store
+        # Read once, and kept up to date as groups are made, so a plan
+        # naming one group across four hundred cards reads the list
+        # once rather than four hundred times. A nameless group is left
+        # out: a card's groups are names, and no name finds one.
+        @group_ids = {} #: Hash[String, String]
+        store.all_groups.each do |group|
+          name = group.name
+          @group_ids[name] = group.id if name
+        end
+      end
+
+      #: (Hash[String, Card] cards) -> Array[Arrival]
+      def call(cards)
+        cards.map { |id, card| land(id, card) }
+      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?
+        # `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
+        # 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:)
+      end
+
+      # Looked up before it is made, so a plan naming a group this
+      # server already has joins that one rather than colliding with
+      # its name (db/migrations/008_group_names.rb).
+      #: (String name) -> String
+      def group_id(name)
+        @group_ids[name] ||= @store.create_group(name:)
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
index 94547c8..885731f 100644
--- a/lib/pro_tacts/import/plan.rb
+++ b/lib/pro_tacts/import/plan.rb
@@ -8,8 +8,9 @@ require "pro_tacts/import/card"
 module ProTacts
   module Import
     # One import, as a directory: what a source builder decided, and how
-    # far execute and finalize have carried it
-    # (docs/plans/2026-09-16-importing-from-macos.md).
+    # 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]
@@ -22,10 +23,15 @@ module ProTacts
       Entry = Data.define(:id, :source_id, :card)
 
       # A contact as the plan lists it, with the last step it reached:
-      # nil, then landed, imported, and done, in that order. 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.
+      # 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)
 
@@ -45,11 +51,9 @@ module ProTacts
       REMOVED = "removed" #: String
 
       # Whether the plan has carried every contact through: nothing
-      # left for execute or finalize to do.
+      # left for finalize to do.
       #: () -> bool
-      def done?
-        contacts.all? { [DONE, REMOVED].include?(it.status) }
-      end
+      def done? = outstanding.empty?
 
       #: (Pathname dir, source: String, created_at: Time, entries: Array[Entry]) -> Plan
       def self.write(dir, source:, created_at:, entries:)
@@ -71,7 +75,6 @@ module ProTacts
           "source" => source,
           "created_at" => created_at.utc.iso8601,
           "group" => group,
-          "host" => nil,
           "contacts" => entries.map {
             # A card may hold only one of the two names.
             person = [it.card.first, it.card.last].reject(&:empty?).join(" ")
@@ -114,16 +117,6 @@ module ProTacts
       #: () -> String
       def group = @manifest.fetch("group")
 
-      # The host the plan is landing on, once execute has started.
-      #: () -> String?
-      def host = @manifest.fetch("host")
-
-      #: (String host) -> void
-      def host=(host)
-        @manifest["host"] = host
-        save
-      end
-
       #: () -> Array[Contact]
       def contacts
         @manifest.fetch("contacts").map {
@@ -137,10 +130,12 @@ module ProTacts
         save
       end
 
-      # The contacts this far along, for a task picking a plan to carry
-      # further: none for a plan every step has finished.
-      #: (String? status) -> Array[Contact]
-      def with_status(status) = contacts.select { it.status == status }
+      # 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
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index f5743b6..e9ec6c7 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -137,4 +137,5 @@ require "pro_tacts/web/api"
 require "pro_tacts/web/contacts"
 require "pro_tacts/web/dav"
 require "pro_tacts/web/groups"
+require "pro_tacts/web/import"
 require "pro_tacts/web/setup"
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
new file mode 100644
index 0000000..b8dbf32
--- /dev/null
+++ b/lib/pro_tacts/web/import.rb
@@ -0,0 +1,83 @@
+require "pro_tacts/admin/plan_upload"
+require "pro_tacts/import/card"
+require "pro_tacts/import/land"
+
+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.
+    #
+    # 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.
+    hash_branch("import") do |r|
+      r.is do
+        r.get do
+          plan_upload_screen
+        end
+
+        r.post do
+          apply_import(r)
+        end
+      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.
+    #: (untyped r) -> String
+    def apply_import(r)
+      uploads = r.params["cards"]
+      uploads = uploads.is_a?(Array) ? uploads.select { it.is_a?(Hash) && it[:tempfile] } : []
+      return plan_upload_screen(notice: "Choose the card files in a plan's cards directory.") if uploads.empty?
+
+      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
+
+        # 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
+      end
+
+      plan_upload_screen(arrivals: Import::Land.call(store, cards))
+    end
+
+    #: (?arrivals: Array[Import::Land::Arrival]?, ?notice: String?) -> String
+    def plan_upload_screen(arrivals: nil, notice: nil)
+      response["Content-Type"] = "text/html; charset=utf-8"
+      Admin::PlanUpload.call(arrivals:, notice:)
+    end
+  end
+end
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index df50045..36b309e 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -38,6 +38,10 @@ 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
+    end
+
     # Rendered by Dashboard, not called from checked code; only
     # its LIMIT is read in web.rb.
     class UpcomingBirthdays
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index 36fe287..fc08004 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -1,8 +1,8 @@
-# Import::Card, Import::Plan::Entry, ::Contact, and Import::Execute::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::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.
 module ProTacts
   module Import
     class Card
@@ -65,7 +65,7 @@ module ProTacts
       end
     end
 
-    class Execute
+    class HttpClient
       class Response
         attr_reader status: Integer
         attr_reader headers: Hash[String, String]
@@ -75,10 +75,19 @@ module ProTacts
       end
     end
 
+    class Land
+      class Arrival
+        attr_reader contact: ProTacts::Contact
+        attr_reader stored: bool
+
+        def self.new: (contact: ProTacts::Contact, stored: bool) -> 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?) -> Execute::Response
+      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.
diff --git a/tasks/import.rake b/tasks/import.rake
index e63d347..ee3e375 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -1,11 +1,12 @@
 # Importing an address book from another source
-# (docs/plans/2026-09-16-importing-from-macos.md, and
+# (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 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 "uri"
 
 require "pro_tacts/import/config"
 
@@ -14,11 +15,12 @@ require "pro_tacts/import/config"
 module ImportTasks
   Config = ProTacts::Import::Config
 
-  # What each carrying task waits on — a contact still to carry through
-  # that step — named so a status read can say what those tasks would
-  # pick next without restating their choice.
-  TO_LAND = ->(plan) { plan.with_status(nil).any? || plan.with_status("landed").any? }
-  TO_FINALIZE = ->(plan) { plan.with_status("imported").any? }
+  # 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
@@ -68,7 +70,7 @@ namespace :import do
       abort error.message
     end
 
-    desc "Finalize the contacts the oldest landed plan imported, taking them off this Mac and filing the plan away done (PLAN=dir for another)"
+    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"
@@ -76,14 +78,19 @@ namespace :import do
       require "pro_tacts/import/finalize"
 
       plan = ImportTasks.plan(ImportTasks::TO_FINALIZE, "to finalize")
-      host = plan.host or abort("#{plan.dir} has not landed on a host")
-      uri = URI.parse(host)
-      puts "finalizing the contacts #{plan.dir} landed on #{host}"
+      # 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, client: ProTacts::Import::HttpClient.new(http), mac: ProTacts::Import::Macos::Mac
+          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}" }
@@ -95,7 +102,7 @@ namespace :import do
     end
   end
 
-  desc "Summarize the plans in data/import — in flight and filed away done — and what execute and finalize would carry next"
+  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"
 
@@ -114,21 +121,21 @@ namespace :import do
       next
     end
 
-    # One line per plan in flight, as far along as it is: the fraction
-    # counts everything past “planned”, because from “landed” on the
-    # card is on the host — which is the state a glance wants. The
-    # plan names are the directories', copy-pasteable into PLAN=.
+    # 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 {
-      carried = it.contacts.size - it.with_status(nil).size
-      "#{it.dir.basename}  #{carried}/#{it.contacts.size} contacts"
+      "#{it.dir.basename}  #{it.contacts.size - it.outstanding.size}/#{it.contacts.size} finalized"
     }
 
-    landing = plans.find(&ImportTasks::TO_LAND)
     finalizing = plans.find(&ImportTasks::TO_FINALIZE)
-    next_up = [
-      ("next: import:execute would land #{landing.dir.basename}" if landing),
-      ("next: import:macos:finalize would finish #{finalizing.dir.basename}" if finalizing),
-    ].compact
+    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
@@ -141,22 +148,4 @@ namespace :import do
     sections = [in_flight, next_up, done.empty? ? [] : ["done:", *done]].reject(&:empty?)
     puts sections.map { it.join("\n") }.join("\n\n")
   end
-
-  desc "Land the oldest plan in data/import/active still to land, or the one in PLAN, on the pro-tacts at the host in data/import/config.yml, a base URL such as https://contacts"
-  task :execute do
-    require "net/http"
-    require "pro_tacts/import/execute"
-    require "pro_tacts/import/http_client"
-    require "pro_tacts/import/plan"
-
-    plan = ImportTasks.plan(ImportTasks::TO_LAND, "to land")
-    uri = ImportTasks::Config.read.host
-    puts "landing #{plan.dir} on #{uri}"
-
-    Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
-      ProTacts::Import::Execute.call(plan, host: uri.to_s, client: ProTacts::Import::HttpClient.new(http))
-    end
-    groups = plan.contacts.flat_map { plan.card(it.id).groups }.uniq
-    puts "#{plan.contacts.size} contacts in #{groups.join(", ")} on #{uri}"
-  end
 end
diff --git a/test/pro_tacts/admin/test_footer.rb b/test/pro_tacts/admin/test_footer.rb
index a9c9caa..64691ef 100644
--- a/test/pro_tacts/admin/test_footer.rb
+++ b/test/pro_tacts/admin/test_footer.rb
@@ -36,6 +36,12 @@ class AdminFooterTest < Minitest::Test
     assert_includes last_response.body, %(<a href="/setup" class="type-label">device setup</a>)
   end
 
+  def test_the_footer_links_to_the_import_screen
+    with_env({})
+
+    assert_includes last_response.body, %(<a href="/import" class="type-label">import</a>)
+  end
+
   def test_the_footer_names_the_version_the_image_carries
     with_env("VERSION" => "20260904-1822-a1b2c3d")
 
@@ -49,11 +55,11 @@ class AdminFooterTest < Minitest::Test
   def test_an_unversioned_run_names_no_version
     with_env("VERSION" => "")
 
-    assert_includes last_response.body, %(<footer class="admin-footer"><a href="/setup")
+    assert_includes last_response.body, %(<footer class="admin-footer"><a href="/import")
 
     with_env({})
 
-    assert_includes last_response.body, %(<footer class="admin-footer"><a href="/setup")
+    assert_includes last_response.body, %(<footer class="admin-footer"><a href="/import")
   end
 
   # The label is a standing notice, not a status: the log is recording
diff --git a/test/pro_tacts/admin/test_plan_upload.rb b/test/pro_tacts/admin/test_plan_upload.rb
new file mode 100644
index 0000000..b911ced
--- /dev/null
+++ b/test/pro_tacts/admin/test_plan_upload.rb
@@ -0,0 +1,133 @@
+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_execute.rb b/test/pro_tacts/import/test_execute.rb
deleted file mode 100644
index 3d6baf3..0000000
--- a/test/pro_tacts/import/test_execute.rb
+++ /dev/null
@@ -1,175 +0,0 @@
-require_relative "../../test_helper"
-
-require "pathname"
-require "rack/mock"
-require "tmpdir"
-
-require "pro_tacts/import/execute"
-require "pro_tacts/import/plan"
-require "pro_tacts/web"
-
-class ImportExecuteTest < Minitest::Test
-  include ThrowawayContacts
-
-  Plan = ProTacts::Import::Plan
-  Execute = ProTacts::Import::Execute
-
-  HOST = "https://contacts"
-  IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
-
-  # The app in place of a host, the proxy's identity header included.
-  class RackClient
-    attr_reader :requests
-
-    def initialize
-      @requests = []
-    end
-
-    def call(method, path, headers: {}, body: nil)
-      @requests << [method, path]
-      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)
-      Execute::Response.new(status:, headers: response_headers, body: bytes)
-    end
-  end
-
-  # A client whose host refuses every PUT with a precondition body.
-  class RefusingClient < RackClient
-    def call(method, path, headers: {}, body: nil)
-      return Execute::Response.new(status: 412, headers: {}, body: "<error/>") if method == "PUT"
-
-      super
-    end
-  end
-
-  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, RackClient.new }
-    end
-  end
-
-  def imported_group(store) = group_named(store, "import-20260916T180412Z")
-
-  def everyone(store) = group_named(store, "sync:*")
-
-  def group_named(store, name)
-    store.all_groups.find { it.name == name }
-  end
-
-  def edit_cards(plan)
-    IDS.each do |id|
-      path = plan.dir / "cards/#{id}.yml"
-      path.write(yield(path.read))
-    end
-  end
-
-  def test_every_card_lands_in_the_plans_group
-    with_plan do |plan, store, client|
-      Execute.call(plan, host: HOST, client:)
-
-      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 %w[imported imported], Plan.read(plan.dir).contacts.map(&:status)
-    end
-  end
-
-  def test_a_rerun_writes_nothing_again
-    with_plan do |plan, store, client|
-      Execute.call(plan, host: HOST, client:)
-      changes = store.changes.size
-      client.requests.clear
-
-      Execute.call(Plan.read(plan.dir), host: HOST, client:)
-
-      assert_empty client.requests
-      assert_equal changes, store.changes.size
-      assert_equal 1, store.all_groups.count { it.name == "import-20260916T180412Z" }
-    end
-  end
-
-  # A run that died between a write and the plan recording it.
-  def test_a_card_already_on_the_host_counts_as_landed
-    with_plan do |plan, store, client|
-      client.call("PUT", "/dav/addressbook/#{IDS.first}.vcf", headers: {"Content-Type" => "text/vcard"}, body: plan.card(IDS.first).contact(IDS.first).vcard.to_s)
-
-      Execute.call(plan, host: HOST, client:)
-
-      assert_equal IDS.sort, imported_group(store).members.sort
-    end
-  end
-
-  def test_a_group_already_on_the_host_is_joined_rather_than_made_again
-    with_plan do |plan, store, client|
-      id = store.create_group(name: "import-20260916T180412Z")
-
-      Execute.call(plan, host: HOST, client:)
-
-      assert_equal id, imported_group(store).id
-      assert_equal IDS.sort, store.group(id).members.sort
-    end
-  end
-
-  def test_a_plan_that_landed_on_one_host_refuses_another
-    with_plan do |plan, store, client|
-      Execute.call(plan, host: HOST, client:)
-
-      error = assert_raises(Execute::Failed) { Execute.call(Plan.read(plan.dir), host: "http://localhost:9292", client:) }
-      assert_includes error.message, HOST
-    end
-  end
-
-  def test_a_refused_card_stops_the_run_and_is_not_recorded
-    with_plan do |plan, _store, _client|
-      error = assert_raises(Execute::Failed) { Execute.call(plan, host: HOST, client: RefusingClient.new) }
-
-      assert_includes error.message, IDS.first
-      assert_includes error.message, "412"
-      assert_equal [nil, nil], Plan.read(plan.dir).contacts.map(&:status)
-    end
-  end
-
-  def test_a_card_joins_the_groups_its_file_names_making_those_missing
-    with_plan(groups: ["family"]) do |plan, store, client|
-      Execute.call(plan, host: HOST, client:)
-
-      assert_equal IDS.sort, group_named(store, "family").members.sort
-      assert_equal 1, store.all_groups.count { it.name == "family" }
-    end
-  end
-
-  def test_a_card_whose_file_leaves_out_everyone_is_taken_out_of_it
-    with_plan do |plan, store, client|
-      edit_cards(plan) { it.sub("- sync:*\n", "") }
-
-      Execute.call(plan, host: HOST, client:)
-
-      assert_empty everyone(store).members
-      assert_equal IDS.sort, imported_group(store).members.sort
-    end
-  end
-
-  def test_a_card_that_will_not_read_stops_the_run_before_anything_lands
-    with_plan do |plan, store, client|
-      path = plan.dir / "cards/#{IDS.last}.yml"
-      path.write(path.read.sub("last: #{IDS.last}", "last: no"))
-
-      assert_raises(ProTacts::Import::Card::Invalid) { Execute.call(plan, host: HOST, client:) }
-
-      assert_empty client.requests
-      assert_nil Plan.read(plan.dir).host
-      assert_empty store.changes
-    end
-  end
-end
diff --git a/test/pro_tacts/import/test_finalize.rb b/test/pro_tacts/import/test_finalize.rb
index c369054..9aec686 100644
--- a/test/pro_tacts/import/test_finalize.rb
+++ b/test/pro_tacts/import/test_finalize.rb
@@ -4,22 +4,38 @@ require "pathname"
 require "rack/mock"
 require "tmpdir"
 
-require "pro_tacts/import/execute"
-require "pro_tacts/import/plan"
 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
-  Execute = ProTacts::Import::Execute
   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
@@ -64,10 +80,9 @@ class ImportFinalizeTest < Minitest::Test
       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|
-        client = ImportExecuteTest::RackClient.new
         plan = Plan.read(dir)
-        Execute.call(plan, host: HOST, client:)
-        yield plan, store, client
+        Land.call(store, IDS.to_h { [it, plan.card(it)] })
+        yield plan, store, RackClient.new
       end
     end
   end
@@ -76,7 +91,7 @@ class ImportFinalizeTest < Minitest::Test
     with_landed_plan do |plan, _store, client|
       mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
 
-      result = Finalize.call(plan, client:, mac:)
+      result = Finalize.call(plan, host: HOST, client:, mac:)
 
       assert_equal 2, result.done
       assert_empty result.kept
@@ -91,11 +106,11 @@ class ImportFinalizeTest < Minitest::Test
       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, client:, mac:)
+      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 ["imported", "done"], Plan.read(plan.dir).contacts.map(&:status)
+      assert_equal [nil, "done"], Plan.read(plan.dir).contacts.map(&:status)
     end
   end
 
@@ -105,7 +120,7 @@ class ImportFinalizeTest < Minitest::Test
       records[source_id(IDS.first)]["note"] = "Analyst. Also a countess."
       mac = Mac.new(records)
 
-      result = Finalize.call(plan, client:, mac:)
+      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
@@ -117,9 +132,9 @@ class ImportFinalizeTest < Minitest::Test
       store.delete(IDS.first)
       mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
 
-      result = Finalize.call(plan, client:, mac:)
+      result = Finalize.call(plan, host: HOST, client:, mac:)
 
-      assert_equal [[IDS.first, "Contact #{IDS.first}", "its card is no longer on #{HOST}"]], result.kept
+      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
@@ -131,7 +146,7 @@ class ImportFinalizeTest < Minitest::Test
       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, client:, mac:)
+      result = Finalize.call(plan, host: HOST, client:, mac:)
 
       assert_empty result.kept
       assert_equal IDS.map { source_id(it) }, mac.deleted
@@ -142,7 +157,7 @@ class ImportFinalizeTest < Minitest::Test
     with_landed_plan do |plan, _store, client|
       mac = Mac.new({source_id(IDS.last) => record(IDS.last)})
 
-      result = Finalize.call(plan, client:, mac:)
+      result = Finalize.call(plan, host: HOST, client:, mac:)
 
       assert_equal 2, result.done
       assert_equal [source_id(IDS.last)], mac.deleted
@@ -157,21 +172,21 @@ class ImportFinalizeTest < Minitest::Test
     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, client:, mac:)
+      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 ["imported", "done"], Plan.read(plan.dir).contacts.map(&:status)
+      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, client:, mac:)
+      Finalize.call(plan, host: HOST, client:, mac:)
 
-      result = Finalize.call(Plan.read(plan.dir), 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
diff --git a/test/pro_tacts/import/test_land.rb b/test/pro_tacts/import/test_land.rb
new file mode 100644
index 0000000..2b224fd
--- /dev/null
+++ b/test/pro_tacts/import/test_land.rb
@@ -0,0 +1,122 @@
+require_relative "../../test_helper"
+
+require "pathname"
+require "tmpdir"
+
+require "pro_tacts/import/land"
+require "pro_tacts/import/plan"
+require "pro_tacts/web"
+
+# 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.
+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 }
+    end
+  end
+
+  def cards(plan) = IDS.to_h { [it, plan.card(it)] }
+
+  def imported_group(store) = group_named(store, "import-20260916T180412Z")
+
+  def everyone(store) = group_named(store, "sync:*")
+
+  def group_named(store, name)
+    store.all_groups.find { it.name == name }
+  end
+
+  def edit_cards(plan)
+    IDS.each do |id|
+      path = plan.dir / "cards/#{id}.yml"
+      path.write(yield(path.read))
+    end
+  end
+
+  def test_every_card_lands_in_the_plans_group
+    with_plan do |plan, store|
+      arrivals = Land.call(store, cards(plan))
+
+      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 }
+    end
+  end
+
+  def test_a_second_upload_writes_nothing_again
+    with_plan do |plan, store|
+      Land.call(store, cards(plan))
+      changes = store.changes.size
+
+      arrivals = Land.call(store, cards(plan))
+
+      assert_equal [false, false], arrivals.map(&:stored)
+      assert_equal changes, store.changes.size
+      assert_equal 1, store.all_groups.count { it.name == "import-20260916T180412Z" }
+    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") }
+
+      arrivals = Land.call(store, cards(plan))
+
+      assert_equal [false, false], arrivals.map(&:stored)
+      IDS.each { assert_equal "Contact #{it}", store.contact(it).name }
+    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")
+
+      Land.call(store, cards(plan))
+
+      assert_equal id, imported_group(store).id
+      assert_equal IDS.sort, store.group(id).members.sort
+    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))
+
+      assert_equal IDS.sort, group_named(store, "family").members.sort
+      assert_equal 1, store.all_groups.count { it.name == "family" }
+    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", "") }
+
+      Land.call(store, cards(plan))
+
+      assert_empty everyone(store).members
+      assert_equal IDS.sort, imported_group(store).members.sort
+    end
+  end
+end
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
index 68ae2b3..f97f2e7 100644
--- a/test/pro_tacts/import/test_plan.rb
+++ b/test/pro_tacts/import/test_plan.rb
@@ -32,7 +32,6 @@ class ImportPlanTest < Minitest::Test
       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
-      assert_nil plan.host
     end
   end
 
@@ -109,13 +108,12 @@ class ImportPlanTest < Minitest::Test
       %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", "imported")
+      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.with_status(nil).map(&:id) }
-      assert_equal [["kmnuqmzxylru"], []], plans.map { it.with_status("imported").map(&:id) }
+      assert_equal [[], ["kmnuqmzxylru"]], plans.map { it.outstanding.map(&:id) }
     end
   end
 
@@ -123,12 +121,10 @@ class ImportPlanTest < Minitest::Test
     in_tmpdir do |dir|
       plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
 
-      plan.host = "https://contacts"
-      plan.record("kmnuqmzxylru", "landed")
+      plan.record("kmnuqmzxylru", Plan::DONE)
 
       read = Plan.read(dir)
-      assert_equal "https://contacts", read.host
-      assert_equal ["landed", nil], read.contacts.map(&:status)
+      assert_equal ["done", nil], read.contacts.map(&:status)
       assert_equal ["plan.yml"], dir.children.map { it.basename.to_s }.grep(/plan/)
     end
   end
diff --git a/test/tasks/test_import.rb b/test/tasks/test_import.rb
index e8c780a..d390cfd 100644
--- a/test/tasks/test_import.rb
+++ b/test/tasks/test_import.rb
@@ -19,26 +19,25 @@ class ImportTasksTest < Minitest::Test
       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 a contact through
-      # to the finalizing step and one past it, and one freshly built
-      # with everything to do.
+      # 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("kmnuqmzxylru", "imported")
       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  2/2 contacts\n"
-      assert_includes out, "macos-20260916T180412Z  0/1 contacts\n"
-      assert_includes out, "next: import:execute would land macos-20260916T180412Z\n"
+      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 contacts"
+      refute_includes out, "macos-20260831T000000Z  2/2 finalized"
     end
   end