import: write plans and execute them against a host
A plan is a directory of cards and backups, and a plan.json that keeps score:
execute lands each card, makes the group, joins them, and saves after every chore.
A rerun skips what the plan has recorded, and takes a bare 412 as a card that landed,
so a run that died between a write and its save picks up where it ended.

Assisted-by: Claude Opus 5 via Claude Code
Issue: rtqvmzvp
change vmnlryyvktuxlnxszssnlulkyqtqntsv
commit 00176ba94d360e467c030bd880fc3d9fafbd573a
author Alpha Chen <alpha@kejadlen.dev>
date
parent kmnuqmzx
diff --git a/AGENTS.md b/AGENTS.md
index a65c3a5..eb4a59a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,6 +24,8 @@ rake db:dump          # Write the cards, birthdays, and groups to data/dump
 rake dev              # Dev server, seeded from test/fixtures/cards into a
                       # throwaway tmpdir on every start, reloading on change
                       # (needs fd and entr)
+rake import:execute   # Land an import plan on a host
+                      # (PLAN=dir HOST=https://contacts)
 rake profile:install  # Download carddav.mobileconfig from the app and
                       # stage it for approval
 ```
@@ -50,6 +52,7 @@ lib/pro_tacts/
 ├── web/groups.rb   # The group screens and their editor
 ├── web/setup.rb    # The device setup screen and its profile
 ├── web/api.rb      # JSON for scripts: the group list
+├── import/         # Import plans, and landing them on a host
 ├── 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 d1652cd..82cb478 100644
--- a/Steepfile
+++ b/Steepfile
@@ -26,5 +26,5 @@ target :lib do
   ].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
   signature "sig"
 
-  library "date", "digest", "fileutils", "json", "logger", "pathname", "securerandom", "strscan", "yaml"
+  library "date", "digest", "fileutils", "json", "logger", "net-http", "pathname", "securerandom", "strscan", "time", "uri", "yaml"
 end
diff --git a/docs/plans/2026-09-16-importing-from-macos.md b/docs/plans/2026-09-16-importing-from-macos.md
index 535bbf6..2e8cae3 100644
--- a/docs/plans/2026-09-16-importing-from-macos.md
+++ b/docs/plans/2026-09-16-importing-from-macos.md
@@ -6,26 +6,28 @@ host, and remove the originals from the Mac. Monica gets an importer of
 its own later, and the two share everything but the first and last
 steps.
 
-## A plan is what will happen
+## A plan is what will happen, and how far it got
 
 A plan is a directory under `data/imports/`, which is ignored, since it
 holds every contact in full:
 
 | Path | Holds |
 |---|---|
-| `plan.json` | The source, when it was built, the group, and each contact's minted id beside its source id |
+| `plan.json` | The source, when it was built, the group, and each contact's minted id beside its source id; then the host, the group's id, and each contact's status |
 | `cards/<id>.vcf` | The bytes `execute` PUTs, byte for byte |
 | `backups/<id>/` | The original, in whatever files the source reads it as |
-| `log.jsonl` | What `execute` and `remove` did, one event per line |
 
-The builder writes the first three once and nothing rewrites them. A plan
-has no open questions in it: every card is final when the plan is
+The builder writes the cards and backups once and nothing rewrites them.
+A plan has no open questions in it: every card is final when the plan is
 written, so `execute` runs it rather than finishing it.
 
-The log is appended rather than rewritten, so a run that dies partway
-leaves an accurate record of what landed. Every event names the host,
-and `execute` refuses a `HOST` other than the one the log already names,
-so one plan cannot land on two servers.
+`plan.json` 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
+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
+land on two servers.
 
 Companies are not imported. A contact whose `contactType` is
 `organization` is left out of the plan, so `remove` never touches it
@@ -36,8 +38,9 @@ contacts at a time: `rake import:macos:plan LIMIT=n` plans the first n
 people, companies skipped. First means Contacts.app's own list order,
 `CNContactSortOrderUserDefault`; the fetch request's default is
 `CNContactSortOrderNone`, which promises no order at all
-(`CNContactFetchRequest.h`). Plans keep no record of one another. The next plan's first n are new because `remove` took
-the last batch off the Mac, so a contact that landed and was not removed
+(`CNContactFetchRequest.h`). Plans keep no record of one another. The
+next plan's first n are new because `remove` took the last batch off the
+Mac, so a contact that landed and was not removed
 lands again, as a second card, from the next plan that includes it. Each
 plan is its own group.
 
@@ -49,26 +52,31 @@ original.
 
 ## Execute is the same for every source
 
-`rake import:execute PLAN=dir HOST=host` does the following, logging
-each step:
+`rake import:execute PLAN=dir HOST=host` does the following, recording
+each step in the plan:
 
 1. PUT each card to `/dav/addressbook/<id>.vcf` with `If-None-Match: *`.
 2. Create the plan's group, `import-<timestamp>`, through `POST /groups`.
 3. Join each card to it through `POST /contacts/<id>/groups`.
 
-Execute is idempotent against the host, not against its own log: a run
-can die after a write lands and before the log records it, so a rerun
-repeats every step and each step accepts its own earlier success.
+A rerun skips every step the plan records. The plan alone is not
+enough, because a run can die after a write lands and before the plan
+records it, so each step also accepts its own earlier success from the
+host. Skipping
+first keeps a rerun from collecting refusals, each of which warns Sentry
+(`RefusalAlerts`).
 
-- A PUT answered 412 means the id is already taken, and at 48 bits a
+- A bare 412 to the PUT is `If-None-Match` failing, and at 48 bits a
   minted id is taken only by this plan's own earlier PUT, so the card
-  counts as landed.
-- A group name the host refuses as taken is this plan's, since the name
-  carries the plan's timestamp. Its id comes from `GET /api/groups`,
-  every group's id and name as JSON, since the refusal does not name
-  the group that holds the name.
+  counts as landed. A 412 with a body is the card refused.
+- The group is looked up by name in `GET /api/groups`, every group's id
+  and name as JSON, before it is created. A name that is already there
+  is this plan's, since it carries the plan's timestamp.
 - Joining a group twice is joining once (`Store#add_member`).
 
+`HOST` is a base URL, such as `https://contacts` or the dev server's
+`http://localhost:9292`.
+
 These are the routes a client and the admin UI already use, so the
 server gains nothing for this. The host's proxy supplies the identity,
 and a PUT that creates a card also puts it in the writer's `sync:` group
@@ -121,9 +129,9 @@ through, and Apple wrote them.
 
 `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 log names, then reads the contact again and compares it to its
-backup, note included, and skips it with a logged reason if either
-check fails. A contact edited on the Mac since the plan is kept, and so
+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
+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.
diff --git a/lib/pro_tacts/import/execute.rb b/lib/pro_tacts/import/execute.rb
new file mode 100644
index 0000000..c35e5db
--- /dev/null
+++ b/lib/pro_tacts/import/execute.rb
@@ -0,0 +1,110 @@
+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
+
+      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
+      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
+        # 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) if it.status.nil? }
+        group_id = @plan.group_id || make_group
+        @plan.contacts.each { join(it.id, group_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) -> void
+      def land(id)
+        response = @client.call(
+          "PUT", "/dav/addressbook/#{id}.vcf",
+          headers: {"Content-Type" => "text/vcard; charset=utf-8", "If-None-Match" => "*"},
+          body: @plan.card(id)
+        )
+        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
+
+      # Looked up before it is made, so a run that died after making it
+      # joins the one it made.
+      #: () -> String
+      def make_group
+        id = listed_group_id
+        unless id
+          response = post("/groups", [["name", @plan.group]])
+          raise Failed, "creating group #{@plan.group} answered #{response.status}" unless response.status == 303
+
+          id = listed_group_id or raise Failed, "group #{@plan.group} was created and is not listed"
+        end
+
+        @plan.group_id = id
+        id
+      end
+
+      #: () -> String?
+      def listed_group_id
+        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") == @plan.group }&.fetch("id")
+      end
+
+      #: (String id, String group_id) -> void
+      def join(id, group_id)
+        response = post("/contacts/#{id}/groups", [["groups[]", group_id]])
+        raise Failed, "joining #{id} to #{@plan.group} answered #{response.status}" unless response.status == 303
+
+        @plan.record(id, "joined")
+      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/http_client.rb b/lib/pro_tacts/import/http_client.rb
new file mode 100644
index 0000000..1fa89d1
--- /dev/null
+++ b/lib/pro_tacts/import/http_client.rb
@@ -0,0 +1,24 @@
+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.
+    class HttpClient
+      # @rbs @http: Net::HTTP
+
+      #: (Net::HTTP http) -> void
+      def initialize(http)
+        @http = http
+      end
+
+      #: (String method, String path, ?headers: Hash[String, String], ?body: String?) -> Execute::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)
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
new file mode 100644
index 0000000..2477c26
--- /dev/null
+++ b/lib/pro_tacts/import/plan.rb
@@ -0,0 +1,129 @@
+require "fileutils"
+require "json"
+require "pathname"
+require "time"
+
+module ProTacts
+  module Import
+    # One import, as a directory: what a source builder decided, and how
+    # far execute and remove have carried it
+    # (docs/plans/2026-09-16-importing-from-macos.md).
+    class Plan
+      # @rbs @dir: Pathname
+      # @rbs @manifest: Hash[String, untyped]
+
+      # What a builder hands over for one contact: the card execute PUTs
+      # and the original's files, by name. Signed in
+      # sig/pro_tacts/import.rbs, being a Data class.
+      # @rbs skip
+      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.
+      # @rbs skip
+      Contact = Data.define(:id, :source_id, :status)
+
+      FORMAT = 1 #: Integer
+
+      #: (Pathname dir, source: String, created_at: Time, entries: Array[Entry]) -> Plan
+      def self.write(dir, source:, created_at:, entries:)
+        FileUtils.mkdir_p(dir.dirname)
+        # Raises when the directory is there, so no plan is written over
+        # another.
+        Dir.mkdir(dir)
+
+        entries.each do |entry|
+          (dir / "cards").mkpath
+          (dir / "cards/#{entry.id}.vcf").binwrite(entry.card)
+          backup = dir / "backups" / entry.id
+          backup.mkpath
+          entry.backup.each { |name, bytes| (backup / name).binwrite(bytes) }
+        end
+
+        plan = new(dir, {
+          "format" => FORMAT,
+          "source" => source,
+          "created_at" => created_at.utc.iso8601,
+          "group" => "import-#{created_at.utc.strftime("%Y%m%dT%H%M%SZ")}",
+          "host" => nil,
+          "group_id" => nil,
+          "contacts" => entries.map { {"id" => it.id, "source_id" => it.source_id, "status" => nil} }
+        })
+        plan.save
+        plan
+      end
+
+      #: (Pathname dir) -> Plan
+      def self.read(dir)
+        new(dir, JSON.parse((dir / "plan.json").read))
+      end
+
+      attr_reader :dir #: Pathname
+
+      #: (Pathname dir, Hash[String, untyped] manifest) -> void
+      def initialize(dir, manifest)
+        format = manifest.fetch("format")
+        raise ArgumentError, "#{dir} is plan format #{format}, and this reads #{FORMAT}" unless format == FORMAT
+
+        @dir = dir
+        @manifest = manifest
+      end
+
+      #: () -> String
+      def source = @manifest.fetch("source")
+
+      #: () -> String
+      def created_at = @manifest.fetch("created_at")
+
+      #: () -> String
+      def group = @manifest.fetch("group")
+
+      # 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
+
+      #: () -> String?
+      def group_id = @manifest.fetch("group_id")
+
+      #: (String id) -> void
+      def group_id=(id)
+        @manifest["group_id"] = id
+        save
+      end
+
+      #: () -> Array[Contact]
+      def contacts
+        @manifest.fetch("contacts").map { Contact.new(id: it.fetch("id"), source_id: it.fetch("source_id"), status: it.fetch("status")) }
+      end
+
+      #: (String id, String status) -> void
+      def record(id, status)
+        @manifest.fetch("contacts").find { it.fetch("id") == id }.store("status", status)
+        save
+      end
+
+      #: (String id) -> String
+      def card(id)
+        (dir / "cards/#{id}.vcf").binread
+      end
+
+      # Every step is saved as it is taken, and replaced whole by a rename
+      # so a run that dies mid-write leaves the plan as it was before.
+      #: () -> void
+      def save
+        written = dir / "plan.json.tmp"
+        written.open("w") do |file|
+          file.write(JSON.pretty_generate(@manifest))
+          file.fsync
+        end
+        written.rename(dir / "plan.json")
+      end
+    end
+  end
+end
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
new file mode 100644
index 0000000..fcf589d
--- /dev/null
+++ b/sig/pro_tacts/import.rbs
@@ -0,0 +1,43 @@
+# 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.
+module ProTacts
+  module Import
+    class Plan
+      class Entry
+        attr_reader id: String
+        attr_reader source_id: String
+        attr_reader card: String
+        attr_reader backup: Hash[String, String]
+
+        def self.new: (id: String, source_id: String, card: String, backup: Hash[String, String]) -> instance
+      end
+
+      class Contact
+        attr_reader id: String
+        attr_reader source_id: String
+        attr_reader status: String?
+
+        def self.new: (id: String, source_id: String, status: String?) -> instance
+      end
+    end
+
+    class Execute
+      class Response
+        attr_reader status: Integer
+        attr_reader headers: Hash[String, String]
+        attr_reader body: String
+
+        def self.new: (status: Integer, headers: Hash[String, String], body: String) -> instance
+      end
+    end
+
+    # 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
+    end
+  end
+end
diff --git a/tasks/import.rake b/tasks/import.rake
new file mode 100644
index 0000000..d8dd04b
--- /dev/null
+++ b/tasks/import.rake
@@ -0,0 +1,23 @@
+# Importing an address book from another source
+# (docs/plans/2026-09-16-importing-from-macos.md).
+
+namespace :import do
+  desc "Land the plan 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"
+
+    plan = ProTacts::Import::Plan.read(Pathname.new(ENV.fetch("PLAN")))
+    host = ENV.fetch("HOST")
+    uri = URI.parse(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))
+    end
+    puts "#{plan.contacts.size} contacts in #{plan.group} on #{host}"
+  end
+end
diff --git a/test/pro_tacts/import/test_execute.rb b/test/pro_tacts/import/test_execute.rb
new file mode 100644
index 0000000..3fe135a
--- /dev/null
+++ b/test/pro_tacts/import/test_execute.rb
@@ -0,0 +1,117 @@
+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
+
+  def with_plan(cards = IDS.to_h { [it, card(it, "Contact #{it}")] })
+    Dir.mktmpdir do |tmp|
+      dir = Pathname.new(tmp) / "plan"
+      entries = cards.map { |id, card| Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card:, backup: {}) }
+      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)
+    store.all_groups.find { it.name == "import-20260916T180412Z" }
+  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 store.contact(it), it }
+      assert_equal IDS.sort, imported_group(store).members.sort
+      assert_equal %w[joined joined], 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))
+
+      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({"kmnuqmzxylru" => card("someone-else", "Mismatched UID")}) do |plan, store, client|
+      error = assert_raises(Execute::Failed) { Execute.call(plan, host: HOST, client:) }
+
+      assert_includes error.message, "kmnuqmzxylru"
+      assert_includes error.message, "412"
+      assert_equal [nil], Plan.read(plan.dir).contacts.map(&:status)
+    end
+  end
+end
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
new file mode 100644
index 0000000..fe493f3
--- /dev/null
+++ b/test/pro_tacts/import/test_plan.rb
@@ -0,0 +1,81 @@
+require_relative "../../test_helper"
+
+require "json"
+require "pathname"
+require "tmpdir"
+
+require "pro_tacts/import/plan"
+
+class ImportPlanTest < Minitest::Test
+  include ThrowawayContacts
+
+  Plan = ProTacts::Import::Plan
+
+  CREATED_AT = Time.utc(2026, 9, 16, 18, 4, 12)
+
+  def entry(id, source_id: "#{id}:ABPerson", backup: {"original.vcf" => "BEGIN:VCARD\r\n"})
+    Plan::Entry.new(id:, source_id:, card: card(id, "Ada Lovelace"), backup:)
+  end
+
+  def in_tmpdir
+    Dir.mktmpdir { yield Pathname.new(it) / "plan" }
+  end
+
+  def test_a_written_plan_reads_back
+    in_tmpdir do |dir|
+      Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
+
+      plan = Plan.read(dir)
+
+      assert_equal "macos", plan.source
+      assert_equal "2026-09-16T18:04:12Z", plan.created_at
+      assert_equal [Plan::Contact.new(id: "kmnuqmzxylru", source_id: "kmnuqmzxylru:ABPerson", status: nil), Plan::Contact.new(id: "vmnlryyvktux", source_id: "vmnlryyvktux:ABPerson", status: nil)], plan.contacts
+      assert_equal card("kmnuqmzxylru", "Ada Lovelace"), plan.card("kmnuqmzxylru")
+      assert_nil plan.host
+      assert_nil plan.group_id
+    end
+  end
+
+  def test_the_group_is_named_for_when_the_plan_was_built
+    in_tmpdir do |dir|
+      Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [])
+
+      assert_equal "import-20260916T180412Z", Plan.read(dir).group
+    end
+  end
+
+  def test_the_backup_is_written_as_given
+    in_tmpdir do |dir|
+      photo = "\xFF\xD8\xFF".b
+      Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru", backup: {"original.vcf" => "BEGIN:VCARD\r\n", "photo.jpg" => photo})])
+
+      assert_equal "BEGIN:VCARD\r\n", (dir / "backups/kmnuqmzxylru/original.vcf").read
+      assert_equal photo, (dir / "backups/kmnuqmzxylru/photo.jpg").binread
+    end
+  end
+
+  def test_a_plan_is_never_written_over_another
+    in_tmpdir do |dir|
+      Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
+
+      assert_raises(Errno::EEXIST) { Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: []) }
+      assert_equal ["kmnuqmzxylru"], Plan.read(dir).contacts.map(&:id)
+    end
+  end
+
+  def test_progress_is_saved_into_the_plan_as_it_is_recorded
+    in_tmpdir do |dir|
+      plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
+
+      plan.host = "https://contacts"
+      plan.record("kmnuqmzxylru", "landed")
+      plan.group_id = "kxsv"
+
+      read = Plan.read(dir)
+      assert_equal "https://contacts", read.host
+      assert_equal "kxsv", read.group_id
+      assert_equal ["landed", nil], read.contacts.map(&:status)
+      assert_equal ["plan.json"], dir.children.map { it.basename.to_s }.grep(/plan/)
+    end
+  end
+end