import: take the originals off the Mac
A contact comes off this Mac when its card is on the host and its bytes still match the backup,
and any other is kept with the check it failed printed, a wrong delete being the one you cannot take back.
The tasks now carry the oldest plan with work left in it, rather than the newest one built,
and the status `joined` is `imported`, which is what that step actually did.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue: rtqvmzvp
change lprltsmqtkxqlxvxxxnnprrvltlrswto
commit 493ae9fd58e3d88b33730c7e729989da3db1f19b
author Alpha Chen <alpha@kejadlen.dev>
date
parent lruxyzup
diff --git a/AGENTS.md b/AGENTS.md
index 02c9090..3b7d92d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,7 +26,10 @@ rake dev              # Dev server, seeded from test/fixtures/cards into a
                       # (needs fd and entr)
 rake import:macos:plan # Plan importing this Mac's iCloud contacts
                       # (LIMIT=n; reads Contacts, so leave it to the user)
-rake import:execute   # Land the newest plan in data/imports on a host
+rake import:macos:remove # Delete from this Mac the contacts the oldest
+                      # landed plan imported (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=https://contacts; PLAN=dir for another)
 rake profile:install  # Download carddav.mobileconfig from the app and
                       # stage it for approval
diff --git a/docs/plans/2026-09-16-importing-from-macos.md b/docs/plans/2026-09-16-importing-from-macos.md
index b25fe31..8bb5083 100644
--- a/docs/plans/2026-09-16-importing-from-macos.md
+++ b/docs/plans/2026-09-16-importing-from-macos.md
@@ -23,7 +23,7 @@ it stands when `execute` carries it.
 
 `plan.yml` also records how far `execute` and `remove` have carried the
 plan, saved after every step. A contact's status goes from none to
-`landed`, `joined`, and `removed`. Each save writes a new file and renames
+`landed`, `imported`, and `removed`. Each save writes a new file and renames
 it over the old one, so a run that dies mid-save leaves the plan as the
 step before left it. `execute` records the host before its first write
 and refuses a `HOST` other than the one recorded, so one plan cannot
@@ -52,12 +52,14 @@ original.
 
 ## Execute is the same for every source
 
-`rake import:execute HOST=host` lands the newest plan in `data/imports`,
-the one just planned, and `PLAN=dir` names another. It does the
-following, recording
-each step in the plan:
+`rake import:execute HOST=host` lands the oldest plan in `data/imports`
+with a contact still to land or still to be put in its groups, and
+`PLAN=dir` names another. Plans are carried in the order they were
+built, so the one to take further is the oldest not taken there yet —
+the rule `remove` picks by too. It does the following, recording each
+step in the plan:
 
-1. Read every card not yet joined, and stop before any write if one
+1. Read every card not yet imported, and stop before any write if one
    will not read.
 2. PUT each card to `/dav/addressbook/<id>.vcf` with `If-None-Match: *`.
 3. Put each card in the groups its file names through
@@ -192,15 +194,21 @@ editor writes it.
 
 ## Removing the originals
 
-`rake import:macos:remove PLAN=dir` deletes, through `CNSaveRequest`, the
-contacts this plan landed. For each one it first GETs the card from the
-host the plan names, then reads the contact again and compares it to its
-backup, note included, and skips it, printing why, if either check
+`rake import:macos:remove` deletes, through `CNSaveRequest`, the contacts
+the oldest landed plan imported, and `PLAN=dir` names another. The host
+is the one that plan recorded rather than a `HOST`, the contacts to take
+off this Mac being the ones already carried somewhere. For each one it
+first GETs the card from that host, then reads the contact again through
+`macos-contacts.swift show` and compares it to its backup, note
+included, and keeps it, printing why, if either check
 fails. A contact edited on the Mac since the plan is kept, and so
 is one whose card is no longer on the host. A contact already gone from
 the Mac counts as removed, so a rerun is safe for the same reason
-`execute`'s is.
+`execute`'s is. The deletes go in one `CNSaveRequest`, since each one
+starts the script again, and the statuses are recorded after it: a run
+that dies between the two finds those contacts gone next time and
+records them then.
 
 The comparison is exact because CNContact exposes no modification date.
-A contact Contacts.app touched on its own will be skipped, which is the
+A contact Contacts.app touched on its own will be kept, which is the
 safe direction to be wrong in.
diff --git a/lib/pro_tacts/import/execute.rb b/lib/pro_tacts/import/execute.rb
index 74ee029..2a5a54f 100644
--- a/lib/pro_tacts/import/execute.rb
+++ b/lib/pro_tacts/import/execute.rb
@@ -43,7 +43,7 @@ module ProTacts
         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 == "joined" }.to_h {
+        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
@@ -51,7 +51,7 @@ module ProTacts
         @plan.host = @host unless landed_on
 
         @plan.contacts.each { land(it.id, cards.fetch(it.id)) if it.status.nil? }
-        @plan.contacts.each { join(it.id, cards.fetch(it.id)) if it.status == "landed" }
+        @plan.contacts.each { add_to_groups(it.id, cards.fetch(it.id)) if it.status == "landed" }
       end
 
       private
@@ -77,17 +77,17 @@ module ProTacts
       # 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 join(id, card)
+      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, "joining #{id} to #{card.groups.join(", ")} answered #{response.status}" unless response.status == 303
+        raise Failed, "adding #{id} to #{card.groups.join(", ")} answered #{response.status}" unless response.status == 303
 
-        @plan.record(id, "joined")
+        @plan.record(id, "imported")
       end
 
       # Looked up before it is made, so a run that died after making it
-      # joins the one it made.
+      # reuses the one it made.
       #: (String name) -> String
       def group_id(name)
         @group_ids[name] ||= listed_group_id(name) || make_group(name)
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index 565ac60..b211b05 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -65,10 +65,31 @@ module ProTacts
       # The reader's output, one parsed object per contact.
       #: (limit: Integer?) -> Array[Hash[String, untyped]]
       def self.read(limit:)
-        command = ["swift", SCRIPT.to_s, "read"]
-        command << limit.to_s if limit
-        output, status = Open3.capture2(*command)
-        raise "#{SCRIPT.basename} exited #{status.exitstatus}" unless status.success?
+        run("read", *[limit&.to_s].compact)
+      end
+
+      # This Mac as `Remove` 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
+
+        #: (Array[String] source_ids) -> void
+        def self.delete(source_ids)
+          Macos.run("delete", *source_ids) unless source_ids.empty?
+        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
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
index d4b5fb4..a82cf76 100644
--- a/lib/pro_tacts/import/plan.rb
+++ b/lib/pro_tacts/import/plan.rb
@@ -21,7 +21,7 @@ module ProTacts
       Entry = Data.define(:id, :source_id, :card, :backup)
 
       # A contact as the plan lists it, with the last step it reached:
-      # nil, then landed, joined, and removed, in that order.
+      # nil, then landed, imported, and removed, in that order.
       # @rbs skip
       Contact = Data.define(:id, :source_id, :status)
 
@@ -60,6 +60,13 @@ module ProTacts
         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"))
@@ -106,6 +113,15 @@ 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 }
+
+      # Where a source's own files for one contact were written.
+      #: (String id) -> Pathname
+      def backup(id) = dir / "backups" / id
+
       # The card as its file now reads, edits included.
       #: (String id) -> Card
       def card(id)
diff --git a/lib/pro_tacts/import/remove.rb b/lib/pro_tacts/import/remove.rb
new file mode 100644
index 0000000..5d35a5a
--- /dev/null
+++ b/lib/pro_tacts/import/remove.rb
@@ -0,0 +1,89 @@
+require "pro_tacts/import/plan"
+
+module ProTacts
+  module Import
+    # Takes the originals off the Mac, once a plan has carried them to 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.
+    class Remove
+      # @rbs @plan: Plan
+      # @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 with why. Signed in
+      # sig/pro_tacts/import.rbs, being a Data class.
+      # @rbs skip
+      Result = Data.define(:removed, :kept)
+
+      #: (Plan plan, client: _Client, mac: _Mac) -> Result
+      def self.call(plan, client:, mac:)
+        new(plan, client:, mac:).call
+      end
+
+      #: (Plan plan, client: _Client, mac: _Mac) -> void
+      def initialize(plan, client:, mac:)
+        @plan = plan
+        @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) }
+        # A contact this Mac no longer has is a contact removed, which is
+        # what a rerun of an interrupted run sees.
+        gone.each { @plan.record(it.id, "removed") }
+
+        kept = [] #: Array[[String, String]]
+        going = still_there.select { |contact|
+          why = keep(contact, records.fetch(contact.source_id))
+          kept << [contact.id, why] if why
+          why.nil?
+        }
+        # One delete for the batch, since each one starts the script
+        # again; a run that dies before the plan records them finds them
+        # gone next time.
+        @mac.delete(going.map(&:source_id))
+        going.each { @plan.record(it.id, "removed") }
+
+        Result.new(removed: gone.size + going.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)
+        backup = @plan.backup(contact.id)
+        note = (backup / "note.txt").then { it.file? ? it.read : nil }
+        if !on_host?(contact.id)
+          "its card is not on #{@plan.host}"
+        elsif record.fetch("vcard") != (backup / "original.vcf").read
+          "it has changed on this Mac since the plan"
+        elsif record.fetch("note") != note
+          "its note has changed on this Mac since the plan"
+        end
+      end
+
+      #: (String id) -> bool
+      def on_host?(id)
+        response = @client.call("GET", "/dav/addressbook/#{id}.vcf")
+        case response.status
+        when 200 then true
+        when 404 then false
+        else raise Failed, "GET of #{id} answered #{response.status}"
+        end
+      end
+    end
+  end
+end
diff --git a/script/macos-contacts.swift b/script/macos-contacts.swift
index 0c009f9..af2b3d3 100644
--- a/script/macos-contacts.swift
+++ b/script/macos-contacts.swift
@@ -1,13 +1,20 @@
-// Reads this Mac's Contacts for an import plan
-// (docs/plans/2026-09-16-importing-from-macos.md, "Reading the Mac").
+// 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>...
 //
-// prints one JSON object per person in the iCloud account, companies
+// 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, and an
+// identifier it no longer has is already deleted.
 
 import Contacts
 import Foundation
@@ -96,12 +103,19 @@ func notes() -> (ids: Set<String>, notes: [String: String]) {
   return (known, byId)
 }
 
-let arguments = CommandLine.arguments.dropFirst()
-guard arguments.first == "read" else { fail("usage: macos-contacts.swift read [limit]") }
+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 arguments.count > 1 {
-  guard let n = Int(arguments[arguments.startIndex + 1]), n > 0 else { fail("a limit is a positive number") }
-  limit = n
+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()
@@ -115,7 +129,12 @@ guard icloud.count == 1 else { fail("found \(icloud.count) accounts named iCloud
 
 let request = CNContactFetchRequest(
   keysToFetch: keys.map { $0 as NSString } + [CNContactVCardSerialization.descriptorForRequiredKeys()])
-request.predicate = CNContact.predicateForContactsInContainer(withIdentifier: icloud[0].identifier)
+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
@@ -132,6 +151,25 @@ do {
   fail("reading contacts: \(error)")
 }
 
+if command == "delete" {
+  // One save request for the batch: a delete of a contact this Mac no
+  // longer has is a delete that already happened, so the identifiers that
+  // matched nothing are left alone.
+  let request = CNSaveRequest()
+  for contact in people {
+    guard let mutable = contact.mutableCopy() as? CNMutableContact else { fail("\(contact.identifier) will not copy") }
+    request.delete(mutable)
+  }
+  if !people.isEmpty {
+    do {
+      try store.execute(request)
+    } catch {
+      fail("deleting \(people.count) contacts: \(error)")
+    }
+  }
+  exit(0)
+}
+
 let (noted, noteById) = notes()
 for contact in people {
   guard noted.contains(contact.identifier) else {
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index 0df6dce..67f934f 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -33,6 +33,15 @@ module ProTacts
       end
     end
 
+    class Remove
+      class Result
+        attr_reader removed: Integer
+        attr_reader kept: Array[[String, String]]
+
+        def self.new: (removed: Integer, kept: Array[[String, String]]) -> instance
+      end
+    end
+
     class Execute
       class Response
         attr_reader status: Integer
@@ -48,5 +57,12 @@ module ProTacts
     interface _Client
       def call: (String method, String path, ?headers: Hash[String, String], ?body: String?) -> Execute::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) -> void
+    end
   end
 end
diff --git a/tasks/import.rake b/tasks/import.rake
index d4b45ee..c1751a6 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -1,15 +1,39 @@
 # Importing an address book from another source
 # (docs/plans/2026-09-16-importing-from-macos.md).
 
+require "pathname"
+
+# 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
+  DIR = Pathname.new("data/imports")
+
+  # 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 DIR, the way the directories
+  # there are named.
+  def self.plan(waiting, step)
+    named = ENV.fetch("PLAN", nil)&.then { Pathname.new(it) }
+    named = DIR / 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(DIR).find(&waiting) ||
+      abort("no plan in #{DIR} is waiting #{step}: run rake import:macos:plan, or name one in PLAN")
+  end
+end
+
 namespace :import do
   namespace :macos do
     desc "Plan importing this Mac's iCloud contacts into data/imports (LIMIT=n for the first n)"
     task :plan do
-      require "pathname"
       require "pro_tacts/import/macos"
 
       created_at = Time.now.utc
-      dir = Pathname.new("data/imports/macos-#{created_at.strftime("%Y%m%dT%H%M%SZ")}")
+      dir = ImportTasks::DIR / "macos-#{created_at.strftime("%Y%m%dT%H%M%SZ")}"
       limit = ENV.fetch("LIMIT", nil)&.then { Integer(it) }
 
       records = ProTacts::Import::Macos.read(limit:)
@@ -23,36 +47,47 @@ namespace :import do
     rescue ProTacts::Import::Macos::Unknown => error
       abort error.message
     end
+
+    desc "Delete from this Mac the contacts the oldest landed plan imported (PLAN=dir for another)"
+    task :remove do
+      require "net/http"
+      require "uri"
+      require "pro_tacts/import/http_client"
+      require "pro_tacts/import/macos"
+      require "pro_tacts/import/remove"
+
+      plan = ImportTasks.plan(->(it) { it.with_status("imported").any? }, "to leave this Mac")
+      host = plan.host or abort("#{plan.dir} has not landed on a host")
+      uri = URI.parse(host)
+      puts "removing the contacts #{plan.dir} landed on #{host}"
+
+      result = nil #: ProTacts::Import::Remove::Result?
+      Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
+        result = ProTacts::Import::Remove.call(
+          plan, client: ProTacts::Import::HttpClient.new(http), mac: ProTacts::Import::Macos::Mac
+        )
+      end
+      result.kept.each { |id, why| puts "kept #{id}: #{why}" }
+      puts "#{result.removed} contacts removed from this Mac"
+    end
   end
 
-  desc "Land the newest plan in data/imports, or the one in PLAN, on the pro-tacts at HOST, a base URL such as https://contacts"
+  desc "Land the oldest plan in data/imports still to land, or the one in PLAN, on the pro-tacts at HOST, a base URL such as https://contacts"
   task :execute do
     require "net/http"
-    require "pathname"
     require "uri"
     require "pro_tacts/import/execute"
     require "pro_tacts/import/http_client"
     require "pro_tacts/import/plan"
 
-    # The plan task writes under data/imports, and names each plan for the
-    # minute it was built, so the newest is the one just planned. A PLAN
-    # that is not a directory is read as a plan's name under there, the
-    # way the directories there are named.
-    named = ENV.fetch("PLAN", nil)&.then { Pathname.new(it) }
-    named = Pathname.new("data/imports") / named if named && !named.directory?
-    dir = named ||
-      Pathname.glob("data/imports/*/plan.yml").map(&:dirname).max_by { it.basename.to_s } ||
-      abort("no plan in data/imports: run rake import:macos:plan, or name one in PLAN")
-    abort("#{dir} holds no plan.yml") unless (dir / "plan.yml").file?
-
-    plan = ProTacts::Import::Plan.read(dir)
+    plan = ImportTasks.plan(->(it) { it.with_status(nil).any? || it.with_status("landed").any? }, "to land")
     # A bare hostname is the base URL of a server that serves HTTPS, which
     # every deployment does; the scheme is spelled out here so the host
     # the plan records is the one a second run is compared against.
     uri = URI.parse(ENV.fetch("HOST"))
     uri = URI.parse("https://#{uri}") if uri.scheme.nil?
     host = uri.to_s
-    puts "landing #{dir} on #{host}"
+    puts "landing #{plan.dir} on #{host}"
 
     Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
       ProTacts::Import::Execute.call(plan, host:, client: ProTacts::Import::HttpClient.new(http))
diff --git a/test/pro_tacts/import/test_execute.rb b/test/pro_tacts/import/test_execute.rb
index 8ca99d3..73fad6b 100644
--- a/test/pro_tacts/import/test_execute.rb
+++ b/test/pro_tacts/import/test_execute.rb
@@ -80,7 +80,7 @@ class ImportExecuteTest < Minitest::Test
       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[joined joined], Plan.read(plan.dir).contacts.map(&:status)
+      assert_equal %w[imported imported], Plan.read(plan.dir).contacts.map(&:status)
     end
   end
 
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
index 7a68ed4..7fbc2dc 100644
--- a/test/pro_tacts/import/test_plan.rb
+++ b/test/pro_tacts/import/test_plan.rb
@@ -91,6 +91,22 @@ class ImportPlanTest < Minitest::Test
     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", "imported")
+
+      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) }
+    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")])
diff --git a/test/pro_tacts/import/test_remove.rb b/test/pro_tacts/import/test_remove.rb
new file mode 100644
index 0000000..774abb1
--- /dev/null
+++ b/test/pro_tacts/import/test_remove.rb
@@ -0,0 +1,144 @@
+require_relative "../../test_helper"
+
+require "pathname"
+require "rack/mock"
+require "tmpdir"
+
+require "pro_tacts/import/execute"
+require "pro_tacts/import/plan"
+require "pro_tacts/import/remove"
+require "pro_tacts/web"
+
+class ImportRemoveTest < Minitest::Test
+  include ThrowawayContacts
+
+  Card = ProTacts::Import::Card
+  Plan = ProTacts::Import::Plan
+  Execute = ProTacts::Import::Execute
+  Remove = ProTacts::Import::Remove
+
+  HOST = "https://contacts"
+  IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
+
+  # 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.
+  class Mac
+    attr_reader :deleted
+
+    def initialize(records)
+      @records = records
+      @deleted = []
+    end
+
+    def show(source_ids) = @records.slice(*source_ids)
+
+    def delete(source_ids)
+      @deleted.concat(source_ids)
+      @records = @records.except(*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 remove works on.
+  def with_landed_plan(notes: {})
+    Dir.mktmpdir do |tmp|
+      dir = Pathname.new(tmp) / "plan"
+      entries = IDS.map { |id|
+        backup = {"original.vcf" => vcard(id)}
+        backup["note.txt"] = notes.fetch(id) if notes.key?(id)
+        card = Card.new(first: "Contact", last: id, phones: [], groups: [])
+        Plan::Entry.new(id:, source_id: source_id(id), card:, backup:)
+      }
+      Plan.write(dir, source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries:)
+      with_contacts({}) do |store|
+        client = ImportExecuteTest::RackClient.new
+        plan = Plan.read(dir)
+        Execute.call(plan, host: HOST, client:)
+        yield plan, store, client
+      end
+    end
+  end
+
+  def test_a_contact_that_matches_its_backup_leaves_the_mac
+    with_landed_plan do |plan, _store, client|
+      mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
+
+      result = Remove.call(plan, client:, mac:)
+
+      assert_equal 2, result.removed
+      assert_empty result.kept
+      assert_equal IDS.map { source_id(it) }, mac.deleted
+      assert_equal %w[removed removed], 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 = Remove.call(plan, client:, mac:)
+
+      assert_equal [[IDS.first, "it has changed on this Mac since the plan"]], result.kept
+      assert_equal [source_id(IDS.last)], mac.deleted
+      assert_equal ["imported", "removed"], 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 = Remove.call(plan, client:, mac:)
+
+      assert_equal [[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 = Remove.call(plan, client:, mac:)
+
+      assert_equal [[IDS.first, "its card is not on #{HOST}"]], result.kept
+      assert_equal [source_id(IDS.last)], mac.deleted
+    end
+  end
+
+  def test_a_contact_already_off_the_mac_counts_as_removed
+    with_landed_plan do |plan, _store, client|
+      mac = Mac.new({source_id(IDS.last) => record(IDS.last)})
+
+      result = Remove.call(plan, client:, mac:)
+
+      assert_equal 2, result.removed
+      assert_equal [source_id(IDS.last)], mac.deleted
+      assert_equal %w[removed removed], Plan.read(plan.dir).contacts.map(&:status)
+    end
+  end
+
+  def test_a_rerun_removes_nothing_again
+    with_landed_plan do |plan, _store, client|
+      mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
+      Remove.call(plan, client:, mac:)
+
+      result = Remove.call(Plan.read(plan.dir), client:, mac:)
+
+      assert_equal 0, result.removed
+      assert_equal IDS.map { source_id(it) }, mac.deleted
+    end
+  end
+end